//! Multi-account session store. //! //! Multi-account from the first struct: one JSON file holds every logged-in //! account (keyed by DID), each with its own DPoP key + token pair, plus the //! install-wide OAuth client keypair. File mode 0600. //! //! Layout: {home}/accounts.json where home = $LINJI_HOME or //! ~/.local/share/linji. const std = @import("std"); pub const Account = struct { did: []const u8, handle: []const u8, pds: []const u8, client_id: []const u8, dpop_secret_hex: []const u8, access_token: []const u8, refresh_token: []const u8, scope: []const u8, dpop_nonce: ?[]const u8 = null, /// Set when the account's home PDS is external (e.g. bsky.social): /// `pds`/tokens then belong to the space host (our zds) after the /// bootstrap swap, and this remembers where identity actually lives. home_pds: ?[]const u8 = null, }; pub const Store = struct { allocator: std.mem.Allocator, dir: []const u8, path: []const u8, accounts: std.ArrayList(Account) = .empty, default_did: ?[]const u8 = null, client_secret_hex: ?[]const u8 = null, pub fn homeDir(allocator: std.mem.Allocator) ![]const u8 { if (envVal("LINJI_HOME")) |dir| return allocator.dupe(u8, dir); const home = envVal("HOME") orelse return error.NoHome; return std.fs.path.join(allocator, &.{ home, ".local", "share", "linji" }); } fn envVal(name: [*:0]const u8) ?[]const u8 { return if (std.c.getenv(name)) |value| std.mem.span(value) else null; } pub fn load(allocator: std.mem.Allocator, io: std.Io) !Store { const dir = try homeDir(allocator); const path = try std.fs.path.join(allocator, &.{ dir, "accounts.json" }); var store = Store{ .allocator = allocator, .dir = dir, .path = path }; const data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(4 * 1024 * 1024)) catch |err| switch (err) { error.FileNotFound => return store, else => return err, }; const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); defer parsed.deinit(); const zj = @import("zat").json; if (zj.getString(parsed.value, "default")) |d| store.default_did = try allocator.dupe(u8, d); if (zj.getString(parsed.value, "clientSecretHex")) |s| store.client_secret_hex = try allocator.dupe(u8, s); if (zj.getArray(parsed.value, "accounts")) |items| { for (items) |item| { const account = Account{ .did = try allocator.dupe(u8, zj.getString(item, "did") orelse continue), .handle = try allocator.dupe(u8, zj.getString(item, "handle") orelse ""), .pds = try allocator.dupe(u8, zj.getString(item, "pds") orelse ""), .client_id = try allocator.dupe(u8, zj.getString(item, "clientId") orelse ""), .dpop_secret_hex = try allocator.dupe(u8, zj.getString(item, "dpopSecretHex") orelse ""), .access_token = try allocator.dupe(u8, zj.getString(item, "accessToken") orelse ""), .refresh_token = try allocator.dupe(u8, zj.getString(item, "refreshToken") orelse ""), .scope = try allocator.dupe(u8, zj.getString(item, "scope") orelse ""), .dpop_nonce = if (zj.getString(item, "dpopNonce")) |n| try allocator.dupe(u8, n) else null, .home_pds = if (zj.getString(item, "homePds")) |n| try allocator.dupe(u8, n) else null, }; try store.accounts.append(allocator, account); } } return store; } pub fn save(self: *Store, io: std.Io) !void { _ = io; try mkdirPath(self.allocator, self.dir); var out: std.Io.Writer.Allocating = .init(self.allocator); defer out.deinit(); const w = &out.writer; try w.writeAll("{\n"); if (self.default_did) |d| try w.print(" \"default\":{f},\n", .{std.json.fmt(d, .{})}); if (self.client_secret_hex) |s| try w.print(" \"clientSecretHex\":{f},\n", .{std.json.fmt(s, .{})}); try w.writeAll(" \"accounts\":["); for (self.accounts.items, 0..) |a, i| { if (i > 0) try w.writeAll(","); try w.writeAll("\n {"); try w.print("\"did\":{f},\"handle\":{f},\"pds\":{f},\"clientId\":{f},\"dpopSecretHex\":{f},\"accessToken\":{f},\"refreshToken\":{f},\"scope\":{f}", .{ std.json.fmt(a.did, .{}), std.json.fmt(a.handle, .{}), std.json.fmt(a.pds, .{}), std.json.fmt(a.client_id, .{}), std.json.fmt(a.dpop_secret_hex, .{}), std.json.fmt(a.access_token, .{}), std.json.fmt(a.refresh_token, .{}), std.json.fmt(a.scope, .{}), }); if (a.dpop_nonce) |n| try w.print(",\"dpopNonce\":{f}", .{std.json.fmt(n, .{})}); if (a.home_pds) |n| try w.print(",\"homePds\":{f}", .{std.json.fmt(n, .{})}); try w.writeAll("}"); } try w.writeAll("\n ]\n}\n"); try writeFile600(self.allocator, self.path, out.written()); } /// Find by DID or handle. pub fn find(self: *Store, name: []const u8) ?*Account { for (self.accounts.items) |*a| { if (std.mem.eql(u8, a.did, name) or std.mem.eql(u8, a.handle, name)) return a; } return null; } pub fn defaultAccount(self: *Store) ?*Account { const d = self.default_did orelse return null; return self.find(d); } /// Insert or replace (matched by DID). Sets default when first account. /// All strings are re-duped into the store's own allocator - callers' /// buffers (e.g. a request arena) are never retained. pub fn upsert(self: *Store, account: Account) !void { const owned: Account = .{ .did = try self.allocator.dupe(u8, account.did), .handle = try self.allocator.dupe(u8, account.handle), .pds = try self.allocator.dupe(u8, account.pds), .client_id = try self.allocator.dupe(u8, account.client_id), .dpop_secret_hex = try self.allocator.dupe(u8, account.dpop_secret_hex), .access_token = try self.allocator.dupe(u8, account.access_token), .refresh_token = try self.allocator.dupe(u8, account.refresh_token), .scope = try self.allocator.dupe(u8, account.scope), .dpop_nonce = if (account.dpop_nonce) |n| try self.allocator.dupe(u8, n) else null, .home_pds = if (account.home_pds) |n| try self.allocator.dupe(u8, n) else null, }; if (self.find(account.did)) |existing| { existing.* = owned; } else { try self.accounts.append(self.allocator, owned); } if (self.default_did == null) self.default_did = account.did; } }; pub fn hexEncode(bytes: *const [32]u8) [64]u8 { return std.fmt.bytesToHex(bytes.*, .lower); } pub fn hexDecode(hex: []const u8) ![32]u8 { if (hex.len != 64) return error.InvalidHexLength; var out: [32]u8 = undefined; _ = try std.fmt.hexToBytes(&out, hex); return out; } fn mkdirOne(path: []const u8, buf: []u8) !void { if (path.len == 0 or path.len >= buf.len) return error.PathTooLong; @memcpy(buf[0..path.len], path); buf[path.len] = 0; const path_z: [:0]const u8 = buf[0..path.len :0]; if (std.c.mkdir(path_z.ptr, 0o700) == 0) return; const err: std.posix.E = @enumFromInt(std.posix.system._errno().*); if (err == .EXIST) return; return error.CreateDirFailed; } fn mkdirPath(allocator: std.mem.Allocator, path: []const u8) !void { var buf: [4096]u8 = undefined; var index: usize = 0; while (index < path.len) : (index += 1) { if (path[index] != std.fs.path.sep) continue; if (index == 0) continue; try mkdirOne(path[0..index], &buf); } _ = allocator; try mkdirOne(path, &buf); } pub fn writeFile600(allocator: std.mem.Allocator, path: []const u8, data: []const u8) !void { if (std.fs.path.dirname(path)) |dir| try mkdirPath(allocator, dir); const path_z = try allocator.dupeZ(u8, path); defer allocator.free(path_z); const file = std.c.fopen(path_z.ptr, "wb") orelse return error.OpenFailed; defer _ = std.c.fclose(file); if (data.len > 0) { if (std.c.fwrite(data.ptr, 1, data.len, file) != data.len) return error.WriteFailed; } // enforce 0600 even if the file pre-existed with a wider mode _ = std.c.chmod(path_z.ptr, 0o600); } test "store round-trip" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir = try tmp.dir.realPath(std.testing.io, &path_buf); const io = std.testing.io; var store = Store{ .allocator = allocator, .dir = try allocator.dupe(u8, dir), .path = try std.fs.path.join(allocator, &.{dir, "accounts.json"}), }; store.client_secret_hex = "ab"; try store.upsert(.{ .did = "did:plc:alice", .handle = "alice.test", .pds = "http://127.0.0.1:2583", .client_id = "http://127.0.0.1:18751/client-metadata.json", .dpop_secret_hex = "cd", .access_token = "at", .refresh_token = "rt", .scope = "atproto repo:*", .dpop_nonce = "nonce", }); try store.save(io); var loaded = try Store.load(allocator, io); defer allocator.free(loaded.dir); defer allocator.free(loaded.path); loaded.dir = try allocator.dupe(u8, dir); // load() computes its own path; not needed for asserts below try std.testing.expectEqual(@as(usize, 1), loaded.accounts.items.len); const a = loaded.find("alice.test").?; try std.testing.expectEqualStrings("did:plc:alice", a.did); try std.testing.expectEqualStrings("nonce", a.dpop_nonce.?); try std.testing.expectEqualStrings("did:plc:alice", loaded.default_did.?); } test "hex round-trip" { const bytes = [_]u8{0x21} ** 32; const hex = hexEncode(&bytes); const decoded = try hexDecode(&hex); try std.testing.expectEqualSlices(u8, &bytes, &decoded); }