const std = @import("std"); const clock = @import("../core/clock.zig"); const atid = @import("../core/atid.zig"); const log = @import("../core/log.zig"); const auth = @import("../auth/tokens.zig"); const blobstore = @import("blobstore.zig"); const cbor_json = @import("../internal/cbor_json.zig"); const eventlog = @import("eventlog.zig"); const permissioned = @import("../internal/permissioned_data.zig"); const request_context = @import("../internal/request_context.zig"); const sharded_locks = @import("../internal/sharded_locks.zig"); const space_uri = @import("../internal/space_uri.zig"); const zat = @import("zat"); const zqlite = @import("zqlite"); const Io = std.Io; pub const Error = error{ InvalidCollection, InvalidCursor, InvalidRecordKey, InvalidRecordType, InvalidSwap, ValidationRequired, InvalidReservedSigningKey, MissingRecord, MissingReservedSigningKey, MissingRecordBlock, RepoNotFound, InvalidRepoPath, InvalidDagCbor, InvalidRefreshSession, InvalidAccountStatus, AccountNotFound, HandleNotAvailable, RateLimitExceeded, StoreNotInitialized, }; pub const AccountStatus = enum { active, takendown, suspended, deactivated, deleted, pub fn asString(self: AccountStatus) []const u8 { return switch (self) { .active => "active", .takendown => "takendown", .suspended => "suspended", .deactivated => "deactivated", .deleted => "deleted", }; } pub fn parse(raw: []const u8) AccountStatus { if (std.mem.eql(u8, raw, "takendown")) return .takendown; if (std.mem.eql(u8, raw, "suspended")) return .suspended; if (std.mem.eql(u8, raw, "deactivated")) return .deactivated; if (std.mem.eql(u8, raw, "deleted")) return .deleted; return .active; } pub fn isActive(self: AccountStatus) bool { return self == .active; } }; pub const Record = struct { did: []const u8, collection: []const u8, rkey: []const u8, cid: []const u8, value_json: []const u8, validation_status: []const u8, rev: []const u8, seq: u64, pub fn uri(self: Record, allocator: std.mem.Allocator) ![]const u8 { return std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ self.did, self.collection, self.rkey }); } }; pub const BlobRecord = struct { mime_type: []const u8, data: []const u8, }; pub const SeqEvent = struct { seq: u64, frame: []const u8, }; pub const EmailInfo = struct { email: []const u8, email_confirmed: bool, auth_code: ?[]const u8, auth_code_expires_at: ?i64, pending_email: ?[]const u8, }; pub const CodeStatus = enum { valid, invalid, expired, }; pub const OAuthRequest = struct { request_id: []const u8, client_id: []const u8, redirect_uri: []const u8, scope: []const u8, state: []const u8, code_challenge: []const u8, code_challenge_method: []const u8, response_mode: []const u8, login_hint: ?[]const u8, dpop_jkt: ?[]const u8, expires_at: i64, sub: ?[]const u8, code: ?[]const u8, auth_method: ?[]const u8, prompt: ?[]const u8 = null, }; pub const OAuthToken = struct { pub const Kind = enum { access, refresh, previous_refresh, used_refresh }; family_id: []const u8, did: []const u8, client_id: []const u8, scope: []const u8, access_token: []const u8, refresh_token: []const u8, access_expires_at: i64, refresh_expires_at: i64, previous_refresh_token: ?[]const u8, previous_refresh_expires_at: ?i64, revoked: bool, dpop_jkt: ?[]const u8, auth_method: ?[]const u8, kind: Kind, }; pub const SessionInfo = struct { id: []const u8, did: []const u8, handle: []const u8, auth_method: []const u8, app_password_name: ?[]const u8, controller_did: ?[]const u8, created_at: i64, updated_at: i64, last_used_at: ?i64, access_expires_at: i64, refresh_expires_at: i64, revoked_at: ?i64, active: bool, }; pub const OAuthGrantInfo = struct { did: []const u8, handle: []const u8, client_id: []const u8, scope: []const u8, created_at: i64, expires_at: i64, revoked_at: ?i64, active: bool, auth_method: ?[]const u8, }; pub const SessionTokenRow = struct { id: []const u8, did: []const u8, access_jti: []const u8, refresh_jti: []const u8, auth_method: []const u8, controller_did: ?[]const u8, created_at: i64, updated_at: i64, revoked_at: ?i64, }; pub const AppPassword = struct { name: []const u8, password_hash: []const u8, created_at: i64, privileged: bool, scopes: ?[]const u8, created_by_controller_did: ?[]const u8, }; pub const AuditEvent = struct { id: []const u8, subject_did: []const u8, actor_did: []const u8, controller_did: ?[]const u8, action: []const u8, details_json: []const u8, created_at: i64, }; pub const Passkey = struct { id: []const u8, did: []const u8, credential_id: []const u8, public_key: []const u8, sign_count: u32, friendly_name: ?[]const u8, created_at: i64, last_used: ?i64, }; pub const WebAuthnChallenge = struct { id: []const u8, did: []const u8, challenge: []const u8, kind: []const u8, state_json: []const u8, expires_at: i64, }; pub const DiscoverableWebAuthnChallenge = struct { request_id: []const u8, challenge: []const u8, expires_at: i64, }; pub const InviteCode = struct { code: []const u8, available: i64, disabled: bool, for_account: []const u8, created_by: []const u8, created_at: i64, uses: []InviteCodeUse, }; pub const ReservedSigningKey = struct { did: ?[]const u8, signing_key: []const u8, secret_key: [32]u8, expires_at: i64, }; pub const InviteCodeUse = struct { used_by: []const u8, used_at: i64, }; pub const AppPreference = struct { name: []const u8, value_json: []const u8, }; pub const Resident = struct { handle: []const u8, did: []const u8, active: bool, rev: []const u8, record_count: u64, }; pub const CollectionSummary = struct { collection: []const u8, count: u64, }; pub const ActivityBucket = struct { start_us: u64, end_us: u64, count: u64, }; pub const RecentRecord = struct { handle: []const u8, did: []const u8, collection: []const u8, rkey: []const u8, cid: []const u8, seq: u64, }; pub const ImportedRecord = struct { collection: []const u8, rkey: []const u8, cid: []const u8, blob_cids: []const []const u8, }; pub const ImportedBlock = struct { cid: []const u8, data: []const u8, }; pub const CommitInfo = struct { cid: []const u8, rev: []const u8, }; pub const WriteResult = struct { commit: CommitInfo, records: []Record, }; pub const SpaceConfig = struct { uri: []const u8, authority_did: []const u8, space_type: []const u8, skey: []const u8, read_managing_app: ?[]const u8, read_policy: []const u8, write_managing_app: ?[]const u8, write_policy: []const u8, app_access_json: []const u8, is_authority: bool, deleted_at: ?i64, }; pub const SimpleSpacePolicy = struct { policy: []const u8, managing_app: ?[]const u8, }; pub const OplogCursor = struct { rev: []const u8, idx: i64, }; pub const SimpleSpaceMember = struct { did: []const u8, created_at: i64, read: bool, write: bool, }; pub const SpaceRecord = struct { space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, cid: []const u8, value_json: []const u8, validation_status: []const u8, repo_rev: []const u8, updated_at: i64, pub fn uri(self: SpaceRecord, allocator: std.mem.Allocator) ![]const u8 { return space_uri.SpaceUri.formatRecord(allocator, self.space, self.repo_did, self.collection, self.rkey); } }; pub const SpaceRecordRef = struct { collection: []const u8, rkey: []const u8, cid: []const u8, value_json: ?[]const u8 = null, }; pub const SpaceRecordOplogEntry = struct { rev: []const u8, idx: i64, action: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, cid: ?[]const u8, prev: ?[]const u8, value_json: ?[]const u8, }; pub const SpaceState = struct { set_hash: ?[]const u8, rev: ?[]const u8, }; pub const SpaceWriterState = struct { space_rev: []const u8, repo_did: []const u8, hash: []const u8, rev: []const u8, }; pub const CredentialRecipient = struct { service_id: []const u8, service_endpoint: []const u8, expires_at: i64, }; pub const PreparedRecord = struct { cid: []const u8, value_json: []const u8, validation_status: []const u8, blob_cids: []const []const u8, }; pub const SpaceWriteOp = union(enum) { create: struct { collection: []const u8, rkey: []const u8, prepared: PreparedRecord }, put: struct { collection: []const u8, rkey: []const u8, prepared: PreparedRecord }, update: struct { collection: []const u8, rkey: []const u8, prepared: PreparedRecord }, delete: struct { collection: []const u8, rkey: []const u8 }, }; pub const SpaceWriteResult = union(enum) { create: SpaceRecord, update: SpaceRecord, delete: void, }; pub const WriteProfile = struct { validation_ns: u64 = 0, lock_wait_ns: u64 = 0, load_repo_ns: u64 = 0, stage_records_ns: u64 = 0, build_commit_ns: u64 = 0, sql_ns: u64 = 0, event_publish_ns: u64 = 0, total_ns: u64 = 0, }; pub const WriteOp = union(enum) { create: struct { collection: []const u8, rkey: ?[]const u8, value: std.json.Value, }, update: struct { collection: []const u8, rkey: []const u8, value: std.json.Value, swap: RecordSwap = .none, }, delete: struct { collection: []const u8, rkey: []const u8, swap: RecordSwap = .none, }, }; pub const RecordSwap = union(enum) { none, missing, cid: []const u8, }; pub const ValidationMode = enum { known, require, skip, }; pub const WriteOptions = struct { swap_commit: ?[]const u8 = null, validate: ValidationMode = .known, /// Permit a commit with no record operations. Only the account-creation /// genesis commit uses this; XRPC applyWrites keeps rejecting empty writes. allow_empty_commit: bool = false, }; const BlobRef = struct { cid: []const u8, uri: []const u8, }; const RepoBlockReader = struct { allocator: std.mem.Allocator, did: []const u8, connection: ?zqlite.Conn = null, locked: bool = false, fn reader(self: *RepoBlockReader) zat.mst.BlockReader { return .{ .ctx = self, .getFn = getBlock, }; } fn getBlock(ctx: *anyopaque, cid_raw: []const u8) anyerror!?[]const u8 { const self: *RepoBlockReader = @ptrCast(@alignCast(ctx)); const cid = try cidText(self.allocator, cid_raw); if (self.connection) |read_conn| { return repoBlockDataFrom(read_conn, self.allocator, self.did, cid); } if (self.locked) { return repoBlockDataLocked(self.allocator, self.did, cid); } db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return repoBlockDataLocked(self.allocator, self.did, cid); } }; fn repoBlockDataLocked(allocator: std.mem.Allocator, did: []const u8, cid: []const u8) !?[]const u8 { return repoBlockDataFrom(conn, allocator, did, cid); } fn repoBlockDataFrom( read_conn: zqlite.Conn, allocator: std.mem.Allocator, did: []const u8, cid: []const u8, ) !?[]const u8 { const row = try read_conn.row( \\SELECT data \\FROM repo_blocks \\WHERE did = ? AND cid = ? \\LIMIT 1 , .{ did, cid }); if (row == null) return null; defer row.?.deinit(); const data = row.?.nullableBlob(0) orelse return null; return try allocator.dupe(u8, data); } var conn: zqlite.Conn = undefined; var database_path: ?[:0]u8 = null; var initialized = false; var store_io: Io = undefined; const DatabaseMutex = struct { inner: Io.Mutex = .init, owner_ptr: std.atomic.Value(usize) = .init(0), owner_len: std.atomic.Value(usize) = .init(0), acquired_at_ms: std.atomic.Value(u32) = .init(0), fn lockUncancelable(self: *DatabaseMutex, io: Io) void { const owner_ptr = self.owner_ptr.load(.acquire); if (owner_ptr != 0) { const held_ms = nowMsWrapping() -% self.acquired_at_ms.load(.acquire); if (held_ms >= 100) { const owner_len = self.owner_len.load(.acquire); const owner: [*]const u8 = @ptrFromInt(owner_ptr); log.err( "database lock contention owner={s} held_ms={d} waiter={s}\n", .{ owner[0..owner_len], held_ms, request_context.currentRoute() }, ); } } self.inner.lockUncancelable(io); const owner = request_context.currentRoute(); self.owner_len.store(owner.len, .monotonic); self.acquired_at_ms.store(nowMsWrapping(), .monotonic); self.owner_ptr.store(@intFromPtr(owner.ptr), .release); } fn unlock(self: *DatabaseMutex, io: Io) void { self.owner_ptr.store(0, .release); self.inner.unlock(io); } }; var db_mutex: DatabaseMutex = .{}; var write_lanes: sharded_locks.ShardedLocks(32) = .{}; var blob_lanes: sharded_locks.ShardedLocks(64) = .{}; var next_seq: u64 = 1; var blob_gc_worker_started: std.atomic.Value(bool) = .init(false); const blob_gc_grace_seconds: i64 = 24 * 60 * 60; const blob_gc_batch: usize = 256; const blob_gc_interval_seconds: i64 = 60; pub const BlobGcResult = struct { deleted: usize = 0, bytes: u64 = 0, failed: usize = 0, }; pub fn init(io: Io, path: []const u8) !void { if (initialized) return; store_io = io; if (!std.mem.eql(u8, path, ":memory:") and !std.fs.path.isAbsolute(path)) { if (std.fs.path.dirname(path)) |dir| try Io.Dir.createDirPath(.cwd(), io, dir); } const path_z = try std.heap.page_allocator.dupeSentinel(u8, path, 0); var path_owned = true; defer if (path_owned) std.heap.page_allocator.free(path_z); conn = try zqlite.open(path_z.ptr, zqlite.OpenFlags.Create | zqlite.OpenFlags.ReadWrite); database_path = path_z; path_owned = false; initialized = true; errdefer close(); try conn.busyTimeout(5000); try conn.execNoArgs("PRAGMA journal_mode=WAL"); try conn.execNoArgs("PRAGMA foreign_keys=ON"); const migrations_started = clock.monotonicNs(); try migrate(); const migrations_ready = clock.monotonicNs(); next_seq = try loadNextSeqLocked(); log.info("startup migrations_ms={d}\n", .{(migrations_ready - migrations_started) / std.time.ns_per_ms}); log.info("startup sequence_ms={d}\n", .{(clock.monotonicNs() - migrations_ready) / std.time.ns_per_ms}); } pub fn close() void { if (!initialized) return; conn.close(); if (database_path) |path| std.heap.page_allocator.free(path); database_path = null; initialized = false; } pub fn nowMs() i64 { return clock.nowMs(); } /// low 32 bits of nowMs(); differences via -% stay exact for spans under ~49 days fn nowMsWrapping() u32 { return @truncate(@as(u64, @bitCast(nowMs()))); } pub fn randomBytes(buffer: []u8) void { store_io.random(buffer); } pub fn randomToken(allocator: std.mem.Allocator, prefix: []const u8, comptime byte_len: usize) ![]const u8 { var bytes: [byte_len]u8 = undefined; randomBytes(&bytes); const hex = std.fmt.bytesToHex(bytes, .lower); return std.fmt.allocPrint(allocator, "{s}{s}", .{ prefix, &hex }); } pub fn currentIo() Io { return store_io; } pub fn latestSequence() u64 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); return next_seq -| 1; } pub fn resolveRepo(repo: []const u8) ?auth.Account { return findAccount(std.heap.page_allocator, repo) catch null; } pub fn findAccount(allocator: std.mem.Allocator, identifier: []const u8) !?auth.Account { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return try findAccountLocked(allocator, identifier); } pub fn findActiveAccount(allocator: std.mem.Allocator, identifier: []const u8) !?auth.Account { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT handle, did, email, password_hash \\FROM accounts \\WHERE account_status = 'active' \\ AND (lower(handle) = lower(?) OR did = ? OR lower(email) = lower(?)) \\LIMIT 1 , .{ identifier, identifier, identifier }); if (row == null) return null; defer row.?.deinit(); return try accountFromRow(allocator, row.?); } pub fn consumeRateLimit(subject: []const u8, action: []const u8, now_ms: i64, window_ms: i64, limit: usize) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.execNoArgs("BEGIN IMMEDIATE"); errdefer conn.execNoArgs("ROLLBACK") catch {}; try conn.exec( \\DELETE FROM rate_limit_events \\WHERE action = ? AND occurred_at < ? , .{ action, now_ms - window_ms }); const row = try conn.row( \\SELECT COUNT(*) \\FROM rate_limit_events \\WHERE subject = ? AND action = ? AND occurred_at >= ? , .{ subject, action, now_ms - window_ms }); defer if (row) |value| value.deinit(); if (row != null and row.?.int(0) >= @as(i64, @intCast(limit))) { try conn.execNoArgs("ROLLBACK"); return Error.RateLimitExceeded; } try conn.exec( \\INSERT INTO rate_limit_events (subject, action, occurred_at) \\VALUES (?, ?, ?) , .{ subject, action, now_ms }); try conn.execNoArgs("COMMIT"); } pub fn searchAccounts(allocator: std.mem.Allocator, query: []const u8, limit: usize) ![]auth.Account { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const actual_limit = if (limit == 0) 25 else limit; const pattern = try std.fmt.allocPrint(allocator, "%{s}%", .{query}); var rows = if (query.len == 0) try conn.rows( \\SELECT handle, did, email, password_hash \\FROM accounts \\ORDER BY handle \\LIMIT ? , .{@as(i64, @intCast(actual_limit))}) else try conn.rows( \\SELECT handle, did, email, password_hash \\FROM accounts \\WHERE lower(handle) LIKE lower(?) OR did LIKE ? \\ORDER BY handle \\LIMIT ? , .{ pattern, pattern, @as(i64, @intCast(actual_limit)) }); defer rows.deinit(); var accounts: std.ArrayList(auth.Account) = .empty; while (rows.next()) |row| { try accounts.append(allocator, try accountFromRow(allocator, row)); } if (rows.err) |err| return err; return accounts.toOwnedSlice(allocator); } pub fn listResidents(allocator: std.mem.Allocator, limit: usize) ![]Resident { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const actual_limit = if (limit == 0) 24 else @min(limit, 100); var rows = try conn.rows( \\SELECT a.handle, a.did, a.account_status, COALESCE(c.rev, ''), COUNT(r.uri) \\FROM accounts a \\LEFT JOIN ( \\ SELECT c1.did, c1.rev \\ FROM commits c1 \\ JOIN ( \\ SELECT did, MAX(seq) AS seq \\ FROM commits \\ GROUP BY did \\ ) latest ON latest.did = c1.did AND latest.seq = c1.seq \\) c ON c.did = a.did \\LEFT JOIN records r ON r.did = a.did \\WHERE a.handle NOT LIKE '%.test' \\GROUP BY a.did \\ORDER BY a.activated_at IS NULL, a.handle COLLATE NOCASE \\LIMIT ? , .{@as(i64, @intCast(actual_limit))}); defer rows.deinit(); var residents: std.ArrayList(Resident) = .empty; while (rows.next()) |row| { try residents.append(allocator, .{ .handle = try allocator.dupe(u8, row.text(0)), .did = try allocator.dupe(u8, row.text(1)), .active = AccountStatus.parse(row.text(2)).isActive(), .rev = try allocator.dupe(u8, row.text(3)), .record_count = @intCast(row.int(4)), }); } if (rows.err) |err| return err; return residents.toOwnedSlice(allocator); } pub fn profileAvatarCid(allocator: std.mem.Allocator, did: []const u8) !?[]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT rb.data \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.did = ? AND r.collection = 'app.bsky.actor.profile' AND r.rkey = 'self' \\LIMIT 1 , .{did}); if (row == null) return null; defer row.?.deinit(); const profile_json = recordJsonFromBlock(allocator, row.?.nullableBlob(0) orelse "") catch return null; var parsed = std.json.parseFromSlice(std.json.Value, allocator, profile_json, .{}) catch return null; defer parsed.deinit(); if (parsed.value != .object) return null; const avatar = parsed.value.object.get("avatar") orelse return null; if (avatar != .object) return null; const ref = avatar.object.get("ref") orelse return null; if (ref != .object) return null; const link = ref.object.get("$link") orelse return null; if (link != .string) return null; const blob_row = try conn.row( \\SELECT 1 \\FROM blobs \\WHERE did = ? AND cid = ? \\LIMIT 1 , .{ did, link.string }); if (blob_row == null) return null; defer blob_row.?.deinit(); return try allocator.dupe(u8, link.string); } pub fn listCollectionSummaries(allocator: std.mem.Allocator, limit: usize) ![]CollectionSummary { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const actual_limit = if (limit == 0) 12 else @min(limit, 50); var rows = try conn.rows( \\SELECT r.collection, COUNT(*) AS count \\FROM records r \\JOIN accounts a ON a.did = r.did \\WHERE a.handle NOT LIKE '%.test' \\GROUP BY r.collection \\ORDER BY count DESC, r.collection COLLATE NOCASE \\LIMIT ? , .{@as(i64, @intCast(actual_limit))}); defer rows.deinit(); var summaries: std.ArrayList(CollectionSummary) = .empty; while (rows.next()) |row| { try summaries.append(allocator, .{ .collection = try allocator.dupe(u8, row.text(0)), .count = @intCast(row.int(1)), }); } if (rows.err) |err| return err; return summaries.toOwnedSlice(allocator); } pub fn listLandingActivity(allocator: std.mem.Allocator, bucket_count: usize) ![]ActivityBucket { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const actual_count = if (bucket_count == 0) 18 else @min(bucket_count, 48); var rows = try conn.rows( \\SELECT c.rev \\FROM commits c \\JOIN accounts a ON a.did = c.did \\WHERE a.handle NOT LIKE '%.test' \\ORDER BY c.seq ASC , .{}); defer rows.deinit(); var timestamps: std.ArrayList(u64) = .empty; defer timestamps.deinit(allocator); while (rows.next()) |row| { const timestamp = atid.timestampMicros(row.text(0)) catch continue; try timestamps.append(allocator, timestamp); } if (rows.err) |err| return err; if (timestamps.items.len == 0) return &.{}; const first = timestamps.items[0]; const last = timestamps.items[timestamps.items.len - 1]; const span = if (last > first) last - first else 1; const width = @max(@as(u64, 1), (span + actual_count - 1) / actual_count); const buckets = try allocator.alloc(ActivityBucket, actual_count); for (buckets, 0..) |*bucket, i| { const start = first + width * i; bucket.* = .{ .start_us = start, .end_us = start + width, .count = 0, }; } for (timestamps.items) |timestamp| { const offset = if (timestamp <= first) 0 else timestamp - first; const index = @min(actual_count - 1, @as(usize, @intCast(offset / width))); buckets[index].count += 1; } buckets[actual_count - 1].end_us = last; return buckets; } pub fn listLandingRecentRecords(allocator: std.mem.Allocator, limit: usize) ![]RecentRecord { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const actual_limit = if (limit == 0) 8 else @min(limit, 24); var rows = try conn.rows( \\SELECT a.handle, r.did, r.collection, r.rkey, r.cid, r.seq \\FROM records r \\JOIN accounts a ON a.did = r.did \\WHERE a.handle NOT LIKE '%.test' \\ORDER BY r.seq DESC, r.collection COLLATE NOCASE \\LIMIT ? , .{@as(i64, @intCast(actual_limit))}); defer rows.deinit(); var records: std.ArrayList(RecentRecord) = .empty; while (rows.next()) |row| { try records.append(allocator, .{ .handle = try allocator.dupe(u8, row.text(0)), .did = try allocator.dupe(u8, row.text(1)), .collection = try allocator.dupe(u8, row.text(2)), .rkey = try allocator.dupe(u8, row.text(3)), .cid = try allocator.dupe(u8, row.text(4)), .seq = @intCast(row.int(5)), }); } if (rows.err) |err| return err; return records.toOwnedSlice(allocator); } fn findAccountLocked(allocator: std.mem.Allocator, identifier: []const u8) !?auth.Account { const row = try conn.row( \\SELECT handle, did, email, password_hash \\FROM accounts \\WHERE lower(handle) = lower(?) OR did = ? OR lower(email) = lower(?) \\LIMIT 1 , .{ identifier, identifier, identifier }); if (row == null) return null; defer row.?.deinit(); return try accountFromRow(allocator, row.?); } fn accountFromRow(allocator: std.mem.Allocator, row: zqlite.Row) !auth.Account { return .{ .handle = try allocator.dupe(u8, row.text(0)), .did = try allocator.dupe(u8, row.text(1)), .email = try allocator.dupe(u8, row.text(2)), .password = try allocator.dupe(u8, row.text(3)), }; } pub fn createAccount( allocator: std.mem.Allocator, handle: []const u8, email: []const u8, password: []const u8, did: []const u8, activated: bool, ) !auth.Account { const signing_key = try generateAccountSigningKey(); return createAccountWithSigningKey(allocator, handle, email, password, did, activated, signing_key); } pub fn createAccountWithSigningKey( allocator: std.mem.Allocator, handle: []const u8, email: []const u8, password: []const u8, did: []const u8, activated: bool, signing_key: [32]u8, ) !auth.Account { return createAccountWithSigningKeyAndInvite(allocator, handle, email, password, did, activated, signing_key, null); } pub fn createAccountWithSigningKeyAndInvite( allocator: std.mem.Allocator, handle: []const u8, email: []const u8, password: []const u8, did: []const u8, activated: bool, signing_key: [32]u8, invite_code: ?[]const u8, ) !auth.Account { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); if (invite_code) |code| { try ensureInviteAvailableLocked(code); } var salt: [16]u8 = undefined; store_io.random(&salt); const password_hash = try auth.hashPassword(allocator, password, salt); try conn.execNoArgs("BEGIN IMMEDIATE"); errdefer conn.execNoArgs("ROLLBACK") catch {}; try conn.exec( \\INSERT INTO accounts (did, handle, email, password_hash, activated_at, account_status, signing_key_type, signing_key) \\VALUES (?, ?, ?, ?, CASE WHEN ? THEN unixepoch() ELSE NULL END, CASE WHEN ? THEN 'active' ELSE 'deactivated' END, 'secp256k1', ?) , .{ did, handle, email, password_hash, activated, activated, zqlite.blob(&signing_key) }); if (invite_code) |code| { try recordInviteUseLocked(code, did); } try conn.execNoArgs("COMMIT"); return .{ .handle = try allocator.dupe(u8, handle), .did = try allocator.dupe(u8, did), .email = try allocator.dupe(u8, email), .password = try allocator.dupe(u8, password_hash), }; } pub fn generateAccountSigningKey() ![32]u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return generateSigningKey(); } pub fn reserveSigningKey(allocator: std.mem.Allocator, did: ?[]const u8) !ReservedSigningKey { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const secret_key = try generateSigningKey(); var keypair = try zat.Keypair.fromSecretKey(.secp256k1, secret_key); const signing_key = try keypair.did(allocator); const expires_at = nowMs() + (24 * 60 * 60 * 1000); try conn.exec( \\INSERT INTO reserved_signing_keys (did, signing_key, signing_key_type, secret_key, expires_at) \\VALUES (?, ?, 'secp256k1', ?, ?) , .{ did, signing_key, zqlite.blob(&secret_key), expires_at }); return .{ .did = if (did) |value| try allocator.dupe(u8, value) else null, .signing_key = signing_key, .secret_key = secret_key, .expires_at = expires_at, }; } pub fn consumeReservedSigningKey(signing_key_or_did: []const u8, did: []const u8) ![32]u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return consumeReservedSigningKeyLocked(signing_key_or_did, did); } pub fn createInviteCode(allocator: std.mem.Allocator, public_url: []const u8, use_count: i64, for_account: []const u8, created_by: []const u8) ![]const u8 { if (use_count < 1) return error.InvalidUseCount; db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var tries: usize = 0; while (tries < 16) : (tries += 1) { const code = try generateInviteCode(allocator, public_url); const existing = try conn.row("SELECT 1 FROM invite_codes WHERE code = ? LIMIT 1", .{code}); if (existing) |row| { row.deinit(); continue; } try insertInviteCodeLocked(code, use_count, for_account, created_by); return code; } return error.InviteCodeCollision; } pub fn createInviteCodes(allocator: std.mem.Allocator, public_url: []const u8, code_count: i64, use_count: i64, for_account: []const u8, created_by: []const u8) ![][]const u8 { if (code_count < 1 or use_count < 1) return error.InvalidUseCount; const max_count: i64 = 100; if (code_count > max_count) return error.InvalidUseCount; var codes: std.ArrayList([]const u8) = .empty; errdefer { for (codes.items) |code| allocator.free(code); codes.deinit(allocator); } var i: i64 = 0; while (i < code_count) : (i += 1) { try codes.append(allocator, try createInviteCode(allocator, public_url, use_count, for_account, created_by)); } return codes.toOwnedSlice(allocator); } pub fn ensureBootstrapInviteCode(allocator: std.mem.Allocator, public_url: []const u8) !?[]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const accounts_row = try conn.row("SELECT COUNT(*) FROM accounts", .{}); if (accounts_row == null) return null; defer accounts_row.?.deinit(); const invites_row = try conn.row("SELECT COUNT(*) FROM invite_codes", .{}); if (invites_row == null) return null; defer invites_row.?.deinit(); const accounts = accounts_row.?.int(0); const invites = invites_row.?.int(0); if (accounts != 0 or invites != 0) return null; const code = try generateInviteCode(allocator, public_url); try insertInviteCodeLocked(code, 1, "admin", "admin"); return code; } pub fn getAccountInviteCodes(allocator: std.mem.Allocator, did: []const u8, include_used: bool) ![]InviteCode { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT code, available_uses, disabled, for_account, created_by, created_at \\FROM invite_codes \\WHERE for_account = ? \\ORDER BY created_at DESC, code , .{did}); defer rows.deinit(); var codes: std.ArrayList(InviteCode) = .empty; while (rows.next()) |row| { const code_text = row.text(0); const uses = try inviteCodeUsesLocked(allocator, code_text); const available = row.int(1); const disabled = row.int(2) != 0; if (!include_used and (disabled or @as(i64, @intCast(uses.len)) >= available)) continue; try codes.append(allocator, .{ .code = try allocator.dupe(u8, code_text), .available = available, .disabled = disabled, .for_account = try allocator.dupe(u8, row.text(3)), .created_by = try allocator.dupe(u8, row.text(4)), .created_at = row.int(5), .uses = uses, }); } if (rows.err) |err| return err; return codes.toOwnedSlice(allocator); } pub fn inviteCodeIsAvailable(code: []const u8) !bool { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); ensureInviteAvailableLocked(code) catch return false; return true; } pub fn signingKeypair(did: []const u8) !zat.Keypair { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return signingKeypairLocked(did); } pub fn updateAccountHandle(did: []const u8, handle: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const existing = try conn.row( \\SELECT did \\FROM accounts \\WHERE lower(handle) = lower(?) \\LIMIT 1 , .{handle}); if (existing) |row| { defer row.deinit(); if (!std.mem.eql(u8, row.text(0), did)) return Error.HandleNotAvailable; } const account = try conn.row( \\SELECT 1 \\FROM accounts \\WHERE did = ? , .{did}); if (account == null) return Error.AccountNotFound; account.?.deinit(); try conn.exec( \\UPDATE accounts \\SET handle = ? \\WHERE did = ? , .{ handle, did }); } pub fn setAccountActive(did: []const u8, active: bool) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); if (active) { const status = try accountStatusLocked(did); if (status != .active and status != .deactivated) return Error.InvalidAccountStatus; try conn.exec( \\UPDATE accounts \\SET account_status = 'active', \\ activated_at = COALESCE(activated_at, unixepoch()) \\WHERE did = ? , .{did}); } else { try conn.exec( \\UPDATE accounts \\SET account_status = 'deactivated', \\ activated_at = COALESCE(activated_at, unixepoch()) \\WHERE did = ? , .{did}); } } pub fn setAccountTakendown(did: []const u8, applied: bool, status_ref: ?[]const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); if (applied) { try conn.execNoArgs("BEGIN IMMEDIATE"); errdefer conn.execNoArgs("ROLLBACK") catch {}; try conn.exec( \\UPDATE accounts \\SET account_status = 'takendown', \\ account_status_ref = ? \\WHERE did = ? , .{ status_ref, did }); try revokeAccountTokensLocked(did); try conn.execNoArgs("COMMIT"); } else { try conn.exec( \\UPDATE accounts \\SET account_status = 'active', \\ account_status_ref = NULL, \\ activated_at = COALESCE(activated_at, unixepoch()) \\WHERE did = ? AND account_status = 'takendown' , .{did}); } } pub fn accountStatus(did: []const u8) !AccountStatus { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return accountStatusLocked(did); } pub fn sequenceAccountEvent(allocator: std.mem.Allocator, did: []const u8, status: AccountStatus) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const seq = try nextSeqLocked(); const frame = try accountEventFrame(allocator, seq, did, status); try insertSeqEventLocked(seq, did, "", frame); eventlog.publish(seq); } pub fn sequenceIdentityEvent(allocator: std.mem.Allocator, did: []const u8, handle: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const seq = try nextSeqLocked(); const frame = try identityEventFrame(allocator, seq, did, handle); try insertSeqEventLocked(seq, did, "", frame); eventlog.publish(seq); } /// Firehose announcement for a freshly created account, matching the official /// PDS sequencer order: #identity, #account, genesis #commit, #sync. The /// genesis commit is an empty-ops write so consumers see the repo exist /// before its first record. pub fn sequenceNewAccount(allocator: std.mem.Allocator, account: auth.Account) !void { try sequenceIdentityEvent(allocator, account.did, account.handle); try sequenceAccountEvent(allocator, account.did, .active); _ = try applyWritesWithOptions(allocator, account, &.{}, .{ .allow_empty_commit = true }); try sequenceSyncEvent(allocator, account.did); } /// Startup repair for accounts created before ZDS sequenced creation events: /// any active account with no commits gets its genesis announcement now, so /// relays stop reporting RepoNotFound for it. pub fn sequenceUnannouncedAccounts(allocator: std.mem.Allocator) !void { const unannounced = blk: { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT did, handle FROM accounts \\WHERE activated_at IS NOT NULL AND account_status = 'active' \\ AND did NOT IN (SELECT did FROM commits) , .{}); defer rows.deinit(); var found: std.ArrayList(auth.Account) = .empty; while (rows.next()) |row| { try found.append(allocator, .{ .did = try allocator.dupe(u8, row.text(0)), .handle = try allocator.dupe(u8, row.text(1)), .email = "", .password = "", }); } break :blk try found.toOwnedSlice(allocator); }; for (unannounced) |account| { sequenceNewAccount(allocator, account) catch |err| { log.err("store announce failed did={s} handle={s} err={s}\n", .{ account.did, account.handle, @errorName(err) }); continue; }; log.info("store announced pre-existing account did={s} handle={s}\n", .{ account.did, account.handle }); } } pub fn sequenceSyncEvent(allocator: std.mem.Allocator, did: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const seq = try nextSeqLocked(); const commit = try latestCommitRawLocked(allocator, did) orelse return Error.RepoNotFound; const frame = try syncEventFrame(allocator, seq, did, commit.commit_cid_text, commit.data_cid_raw, commit.rev, commit.commit_data); try insertSeqEventLocked(seq, did, commit.commit_cid_text, frame); eventlog.publish(seq); } pub fn isAccountActive(did: []const u8) bool { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); requireInitialized() catch return false; return accountActiveLocked(did) catch false; } pub fn create( allocator: std.mem.Allocator, account: auth.Account, collection: []const u8, maybe_rkey: ?[]const u8, value: std.json.Value, ) !Record { const rkey = maybe_rkey orelse try nextRkey(allocator); const result = try applyWrites(allocator, account, &.{.{ .create = .{ .collection = collection, .rkey = rkey, .value = value, } }}); if (result.records.len == 0) return Error.MissingRecord; return result.records[0]; } /// Browser credentials are independent of application OAuth grants. Store only /// the token digest; password changes and unavailable accounts invalidate reuse. pub fn putBrowserSession(token_hash: []const u8, account: auth.Account, expires_at: i64) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.execNoArgs("DELETE FROM browser_sessions WHERE expires_at <= unixepoch()"); try conn.exec("INSERT INTO browser_sessions (token_hash, did, password_hash, expires_at) VALUES (?, ?, ?, ?)", .{ token_hash, account.did, account.password, expires_at }); } pub fn getBrowserSession(allocator: std.mem.Allocator, token_hash: []const u8, timestamp: i64) !?auth.Account { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT a.handle, a.did, a.email, a.password_hash \\FROM browser_sessions s JOIN accounts a ON a.did = s.did \\WHERE s.token_hash = ? AND s.expires_at > ? \\AND a.password_hash = s.password_hash AND a.account_status = 'active' \\AND a.activated_at IS NOT NULL , .{ token_hash, timestamp }); if (row == null) return null; defer row.?.deinit(); return .{ .handle = try allocator.dupe(u8, row.?.text(0)), .did = try allocator.dupe(u8, row.?.text(1)), .email = try allocator.dupe(u8, row.?.text(2)), .password = try allocator.dupe(u8, row.?.text(3)), }; } pub fn deleteBrowserSession(token_hash: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec("DELETE FROM browser_sessions WHERE token_hash = ?", .{token_hash}); } pub fn setOAuthPrompt(request_id: []const u8, prompt: ?[]const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec("UPDATE oauth_requests SET prompt = ? WHERE request_id = ?", .{ prompt, request_id }); } pub fn putOAuthRequest( request_id: []const u8, client_id: []const u8, redirect_uri: []const u8, scope: []const u8, state: []const u8, code_challenge: []const u8, code_challenge_method: []const u8, response_mode: []const u8, login_hint: ?[]const u8, dpop_jkt: ?[]const u8, expires_at: i64, ) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\INSERT INTO oauth_requests \\ (request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, response_mode, login_hint, dpop_jkt, expires_at) \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) , .{ request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, response_mode, login_hint, dpop_jkt, expires_at }); } pub fn getOAuthRequest(allocator: std.mem.Allocator, request_id: []const u8) !?OAuthRequest { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, response_mode, login_hint, dpop_jkt, expires_at, sub, code, auth_method, prompt \\FROM oauth_requests \\WHERE request_id = ? , .{request_id}); if (row == null) return null; defer row.?.deinit(); return try oauthRequestFromRow(row.?, allocator); } pub fn authorizeOAuthRequest(request_id: []const u8, did: []const u8, code: []const u8, auth_method: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE oauth_requests \\SET sub = ?, code = ?, auth_method = ? \\WHERE request_id = ? , .{ did, code, auth_method, request_id }); } pub fn getOAuthRequestByCode(allocator: std.mem.Allocator, code: []const u8) !?OAuthRequest { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, response_mode, login_hint, dpop_jkt, expires_at, sub, code, auth_method, prompt \\FROM oauth_requests \\WHERE code = ? , .{code}); if (row == null) return null; defer row.?.deinit(); return try oauthRequestFromRow(row.?, allocator); } pub fn consumeOAuthCode(request_id: []const u8, code: []const u8) !bool { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( "UPDATE oauth_requests SET code = NULL WHERE request_id = ? AND code = ?", .{ request_id, code }, ); const row = try conn.row("SELECT changes()", .{}); if (row == null) return false; defer row.?.deinit(); return row.?.int(0) == 1; } pub fn putOAuthToken( did: []const u8, client_id: []const u8, scope: []const u8, access_token: []const u8, refresh_token: []const u8, access_expires_at: i64, refresh_expires_at: i64, dpop_jkt: ?[]const u8, auth_method: ?[]const u8, ) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\INSERT INTO oauth_tokens (family_id, access_token, refresh_token, did, client_id, scope, expires_at, access_expires_at, refresh_expires_at, dpop_jkt, auth_method) \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) , .{ access_token, access_token, refresh_token, did, client_id, scope, access_expires_at, access_expires_at, refresh_expires_at, dpop_jkt, auth_method }); } pub fn getOAuthToken(allocator: std.mem.Allocator, token: []const u8) !?OAuthToken { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT family_id, did, client_id, scope, access_token, refresh_token, access_expires_at, refresh_expires_at, \\ previous_refresh_token, previous_refresh_expires_at, revoked_at, dpop_jkt, auth_method \\FROM oauth_tokens \\WHERE access_token = ? OR refresh_token = ? OR previous_refresh_token = ? \\ORDER BY created_at DESC \\LIMIT 1 , .{ token, token, token }); if (row == null) return try getOAuthTokenByUsedRefresh(allocator, token); defer row.?.deinit(); const access_token = row.?.text(4); const refresh_token = row.?.text(5); const previous_refresh_token = row.?.nullableText(8); const kind: OAuthToken.Kind = if (std.mem.eql(u8, token, access_token)) .access else if (std.mem.eql(u8, token, refresh_token)) .refresh else if (previous_refresh_token != null and std.mem.eql(u8, token, previous_refresh_token.?)) .previous_refresh else return null; return .{ .family_id = try allocator.dupe(u8, row.?.text(0)), .did = try allocator.dupe(u8, row.?.text(1)), .client_id = try allocator.dupe(u8, row.?.text(2)), .scope = try allocator.dupe(u8, row.?.text(3)), .access_token = try allocator.dupe(u8, access_token), .refresh_token = try allocator.dupe(u8, refresh_token), .access_expires_at = row.?.int(6), .refresh_expires_at = row.?.int(7), .previous_refresh_token = if (previous_refresh_token) |text| try allocator.dupe(u8, text) else null, .previous_refresh_expires_at = row.?.nullableInt(9), .revoked = row.?.nullableInt(10) != null, .dpop_jkt = if (row.?.nullableText(11)) |text| try allocator.dupe(u8, text) else null, .auth_method = if (row.?.nullableText(12)) |text| try allocator.dupe(u8, text) else null, .kind = kind, }; } fn getOAuthTokenByUsedRefresh(allocator: std.mem.Allocator, token: []const u8) !?OAuthToken { const row = try conn.row( \\SELECT t.family_id, t.did, t.client_id, t.scope, t.access_token, t.refresh_token, t.access_expires_at, t.refresh_expires_at, \\ t.previous_refresh_token, t.previous_refresh_expires_at, t.revoked_at, t.dpop_jkt, t.auth_method \\FROM oauth_used_refresh_tokens u \\JOIN oauth_tokens t ON t.family_id = u.family_id \\WHERE u.refresh_token = ? \\LIMIT 1 , .{token}); if (row == null) return null; defer row.?.deinit(); return .{ .family_id = try allocator.dupe(u8, row.?.text(0)), .did = try allocator.dupe(u8, row.?.text(1)), .client_id = try allocator.dupe(u8, row.?.text(2)), .scope = try allocator.dupe(u8, row.?.text(3)), .access_token = try allocator.dupe(u8, row.?.text(4)), .refresh_token = try allocator.dupe(u8, row.?.text(5)), .access_expires_at = row.?.int(6), .refresh_expires_at = row.?.int(7), .previous_refresh_token = if (row.?.nullableText(8)) |text| try allocator.dupe(u8, text) else null, .previous_refresh_expires_at = row.?.nullableInt(9), .revoked = row.?.nullableInt(10) != null, .dpop_jkt = if (row.?.nullableText(11)) |text| try allocator.dupe(u8, text) else null, .auth_method = if (row.?.nullableText(12)) |text| try allocator.dupe(u8, text) else null, .kind = .used_refresh, }; } pub fn rotateOAuthToken( current_refresh_token: []const u8, new_access_token: []const u8, new_refresh_token: []const u8, access_expires_at: i64, refresh_expires_at: i64, previous_refresh_expires_at: i64, ) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\INSERT OR IGNORE INTO oauth_used_refresh_tokens (refresh_token, family_id, expires_at) \\SELECT refresh_token, family_id, refresh_expires_at \\FROM oauth_tokens \\WHERE refresh_token = ? AND revoked_at IS NULL , .{current_refresh_token}); try conn.exec( \\UPDATE oauth_tokens \\SET previous_refresh_token = refresh_token, \\ previous_refresh_expires_at = ?, \\ access_token = ?, \\ refresh_token = ?, \\ expires_at = ?, \\ access_expires_at = ?, \\ refresh_expires_at = ? \\WHERE refresh_token = ? AND revoked_at IS NULL , .{ previous_refresh_expires_at, new_access_token, new_refresh_token, access_expires_at, access_expires_at, refresh_expires_at, current_refresh_token }); } pub fn recordDpopJti(jti: []const u8, expires_at: i64) !bool { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec("DELETE FROM dpop_jtis WHERE expires_at < unixepoch()", .{}); const row = try conn.row("SELECT 1 FROM dpop_jtis WHERE jti = ?", .{jti}); if (row) |found| { found.deinit(); return false; } try conn.exec( \\INSERT INTO dpop_jtis (jti, expires_at) \\VALUES (?, ?) , .{ jti, expires_at }); return true; } pub fn revokeOAuthToken(token: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE oauth_tokens \\SET revoked_at = unixepoch() \\WHERE access_token = ? OR refresh_token = ? OR previous_refresh_token = ? \\ OR family_id IN (SELECT family_id FROM oauth_used_refresh_tokens WHERE refresh_token = ?) , .{ token, token, token, token }); } pub fn revokeOAuthTokenFamily(family_id: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE oauth_tokens \\SET revoked_at = unixepoch() \\WHERE family_id = ? , .{family_id}); } pub const AccessQuery = struct { kind: enum { oauth, password } = .oauth, state: enum { authorized, past, all } = .authorized, group: ?[]const u8 = null, search: []const u8 = "", offset: i64 = 0, limit: i64 = 20, }; pub const AccessGroup = struct { key: []const u8, name: []const u8, count: i64, newest: i64, }; pub const AccessPage = struct { authorized: i64 = 0, total: i64 = 0, groups: []const AccessGroup = &.{}, grants: []const OAuthGrantInfo = &.{}, sessions: []const SessionInfo = &.{}, next: ?i64 = null, }; // Account-scoped summaries and bounded details; never select token values. pub fn listAccountAccess(allocator: std.mem.Allocator, did: []const u8, query: AccessQuery) !AccessPage { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const oauth = query.kind == .oauth; const base = if (oauth) "WITH access AS (SELECT rowid AS ordinal, did, client_id, scope, created_at, refresh_expires_at, revoked_at, auth_method, client_id AS group_key, client_id AS label, (revoked_at IS NULL AND refresh_expires_at > unixepoch()) AS active FROM oauth_tokens WHERE did = ?) " else "WITH access AS (SELECT rowid AS ordinal, id, did, auth_method, app_password_name, controller_did, created_at, updated_at, last_used_at, access_expires_at, refresh_expires_at, revoked_at, auth_method || ':' || COALESCE(app_password_name, '') AS group_key, COALESCE(app_password_name, 'Account password') AS label, (revoked_at IS NULL AND (access_expires_at > unixepoch() OR refresh_expires_at > unixepoch())) AS active FROM session_tokens WHERE did = ?) "; var page: AccessPage = .{}; const totals_sql = try std.fmt.allocPrint(allocator, "{s}SELECT count(*), COALESCE(sum(active),0) FROM access", .{base}); const totals = (try conn.row(totals_sql, .{did})).?; page.total = totals.int(0); page.authorized = totals.int(1); totals.deinit(); const filter = " WHERE (? = 'all' OR active = (? = 'authorized')) AND instr(lower(label),lower(?)) > 0 "; if (query.group) |group| { const columns = if (oauth) "SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.refresh_expires_at, t.revoked_at, t.auth_method, t.active FROM access t JOIN accounts a ON a.did=t.did" else "SELECT t.id, t.did, a.handle, t.auth_method, t.app_password_name, t.controller_did, t.created_at, t.updated_at, t.last_used_at, t.access_expires_at, t.refresh_expires_at, t.revoked_at, t.active FROM access t JOIN accounts a ON a.did=t.did"; const sql = try std.fmt.allocPrint(allocator, "{s}{s}{s} AND group_key = ? ORDER BY t.created_at DESC, ordinal DESC LIMIT ? OFFSET ?", .{ base, columns, filter }); var rows = try conn.rows(sql, .{ did, @tagName(query.state), @tagName(query.state), query.search, group, query.limit + 1, query.offset }); defer rows.deinit(); if (oauth) { const items = try oauthGrantsFromRows(allocator, &rows); if (items.len > query.limit) page.next = query.offset + query.limit; page.grants = items[0..@min(items.len, @as(usize, @intCast(query.limit)))]; } else { const items = try sessionsFromRows(allocator, &rows); if (items.len > query.limit) page.next = query.offset + query.limit; page.sessions = items[0..@min(items.len, @as(usize, @intCast(query.limit)))]; } } else { const sql = try std.fmt.allocPrint(allocator, "{s}SELECT group_key,label,count(*),max(created_at) FROM access{s} GROUP BY group_key ORDER BY group_key LIMIT ? OFFSET ?", .{ base, filter }); var rows = try conn.rows(sql, .{ did, @tagName(query.state), @tagName(query.state), query.search, query.limit + 1, query.offset }); defer rows.deinit(); var groups: std.ArrayList(AccessGroup) = .empty; while (rows.next()) |row| { if (groups.items.len == query.limit) { page.next = query.offset + query.limit; break; } try groups.append(allocator, .{ .key = try allocator.dupe(u8, row.text(0)), .name = try allocator.dupe(u8, row.text(1)), .count = row.int(2), .newest = row.int(3) }); } page.groups = try groups.toOwnedSlice(allocator); } return page; } pub fn listOAuthGrantsForAccount(allocator: std.mem.Allocator, did: []const u8, active_only: bool, limit: i64) ![]OAuthGrantInfo { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const sql = if (active_only) \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.refresh_expires_at, t.revoked_at, t.auth_method, \\ CASE WHEN t.revoked_at IS NULL AND t.refresh_expires_at > unixepoch() THEN 1 ELSE 0 END AS active \\FROM oauth_tokens t \\JOIN accounts a ON a.did = t.did \\WHERE t.did = ? AND t.revoked_at IS NULL AND t.refresh_expires_at > unixepoch() \\ORDER BY t.created_at DESC \\LIMIT ? else \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.refresh_expires_at, t.revoked_at, t.auth_method, \\ CASE WHEN t.revoked_at IS NULL AND t.refresh_expires_at > unixepoch() THEN 1 ELSE 0 END AS active \\FROM oauth_tokens t \\JOIN accounts a ON a.did = t.did \\WHERE t.did = ? \\ORDER BY t.created_at DESC \\LIMIT ? ; var rows = try conn.rows(sql, .{ did, limit }); defer rows.deinit(); return try oauthGrantsFromRows(allocator, &rows); } pub fn listOAuthGrantsForAllAccounts(allocator: std.mem.Allocator, active_only: bool, limit: i64) ![]OAuthGrantInfo { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const sql = if (active_only) \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.refresh_expires_at, t.revoked_at, t.auth_method, \\ CASE WHEN t.revoked_at IS NULL AND t.refresh_expires_at > unixepoch() THEN 1 ELSE 0 END AS active \\FROM oauth_tokens t \\JOIN accounts a ON a.did = t.did \\WHERE t.revoked_at IS NULL AND t.refresh_expires_at > unixepoch() \\ORDER BY t.created_at DESC \\LIMIT ? else \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.refresh_expires_at, t.revoked_at, t.auth_method, \\ CASE WHEN t.revoked_at IS NULL AND t.refresh_expires_at > unixepoch() THEN 1 ELSE 0 END AS active \\FROM oauth_tokens t \\JOIN accounts a ON a.did = t.did \\ORDER BY t.created_at DESC \\LIMIT ? ; var rows = try conn.rows(sql, .{limit}); defer rows.deinit(); return try oauthGrantsFromRows(allocator, &rows); } pub fn createSessionTokenRow( allocator: std.mem.Allocator, did: []const u8, access_jti: []const u8, refresh_jti: []const u8, access_expires_at: i64, refresh_expires_at: i64, auth_method: []const u8, controller_did: ?[]const u8, app_password_name: ?[]const u8, ) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const id = try randomToken(allocator, "ses-", 16); errdefer allocator.free(id); try conn.exec( \\INSERT INTO session_tokens \\ (id, did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, auth_method, controller_did, app_password_name) \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) , .{ id, did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, auth_method, controller_did, app_password_name }); return id; } pub fn listSessionsForAccount(allocator: std.mem.Allocator, did: []const u8, active_only: bool, limit: i64) ![]SessionInfo { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const sql = if (active_only) \\SELECT s.id, s.did, a.handle, s.auth_method, s.app_password_name, s.controller_did, \\ s.created_at, s.updated_at, s.last_used_at, s.access_expires_at, s.refresh_expires_at, s.revoked_at, \\ CASE WHEN s.revoked_at IS NULL AND (s.access_expires_at > unixepoch() OR s.refresh_expires_at > unixepoch()) THEN 1 ELSE 0 END AS active \\FROM session_tokens s \\JOIN accounts a ON a.did = s.did \\WHERE s.did = ? AND s.revoked_at IS NULL AND (s.access_expires_at > unixepoch() OR s.refresh_expires_at > unixepoch()) \\ORDER BY COALESCE(s.last_used_at, s.updated_at, s.created_at) DESC \\LIMIT ? else \\SELECT s.id, s.did, a.handle, s.auth_method, s.app_password_name, s.controller_did, \\ s.created_at, s.updated_at, s.last_used_at, s.access_expires_at, s.refresh_expires_at, s.revoked_at, \\ CASE WHEN s.revoked_at IS NULL AND (s.access_expires_at > unixepoch() OR s.refresh_expires_at > unixepoch()) THEN 1 ELSE 0 END AS active \\FROM session_tokens s \\JOIN accounts a ON a.did = s.did \\WHERE s.did = ? \\ORDER BY COALESCE(s.last_used_at, s.updated_at, s.created_at) DESC \\LIMIT ? ; var rows = try conn.rows(sql, .{ did, limit }); defer rows.deinit(); return try sessionsFromRows(allocator, &rows); } pub fn listSessionsForAllAccounts(allocator: std.mem.Allocator, active_only: bool, limit: i64) ![]SessionInfo { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const sql = if (active_only) \\SELECT s.id, s.did, a.handle, s.auth_method, s.app_password_name, s.controller_did, \\ s.created_at, s.updated_at, s.last_used_at, s.access_expires_at, s.refresh_expires_at, s.revoked_at, \\ CASE WHEN s.revoked_at IS NULL AND (s.access_expires_at > unixepoch() OR s.refresh_expires_at > unixepoch()) THEN 1 ELSE 0 END AS active \\FROM session_tokens s \\JOIN accounts a ON a.did = s.did \\WHERE s.revoked_at IS NULL AND (s.access_expires_at > unixepoch() OR s.refresh_expires_at > unixepoch()) \\ORDER BY COALESCE(s.last_used_at, s.updated_at, s.created_at) DESC \\LIMIT ? else \\SELECT s.id, s.did, a.handle, s.auth_method, s.app_password_name, s.controller_did, \\ s.created_at, s.updated_at, s.last_used_at, s.access_expires_at, s.refresh_expires_at, s.revoked_at, \\ CASE WHEN s.revoked_at IS NULL AND (s.access_expires_at > unixepoch() OR s.refresh_expires_at > unixepoch()) THEN 1 ELSE 0 END AS active \\FROM session_tokens s \\JOIN accounts a ON a.did = s.did \\ORDER BY COALESCE(s.last_used_at, s.updated_at, s.created_at) DESC \\LIMIT ? ; var rows = try conn.rows(sql, .{limit}); defer rows.deinit(); return try sessionsFromRows(allocator, &rows); } pub fn createAppPassword( allocator: std.mem.Allocator, did: []const u8, name: []const u8, password_hash: []const u8, privileged: bool, scopes: ?[]const u8, created_by_controller_did: ?[]const u8, ) !AppPassword { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\INSERT INTO app_passwords \\ (did, name, password_hash, privileged, scopes, created_by_controller_did) \\VALUES (?, ?, ?, ?, ?, ?) , .{ did, name, password_hash, privileged, scopes, created_by_controller_did }); const row = (try conn.row( \\SELECT name, password_hash, created_at, privileged, scopes, created_by_controller_did \\FROM app_passwords \\WHERE did = ? AND name = ? \\LIMIT 1 , .{ did, name })) orelse return error.MissingRecord; defer row.deinit(); return appPasswordFromRow(row, allocator); } pub fn listAppPasswords(allocator: std.mem.Allocator, did: []const u8) ![]AppPassword { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT name, password_hash, created_at, privileged, scopes, created_by_controller_did \\FROM app_passwords \\WHERE did = ? \\ORDER BY created_at DESC, name ASC , .{did}); defer rows.deinit(); var out: std.ArrayList(AppPassword) = .empty; while (rows.next()) |row| { try out.append(allocator, try appPasswordFromRow(row, allocator)); } return out.toOwnedSlice(allocator); } pub fn getAppPasswordByName(allocator: std.mem.Allocator, did: []const u8, name: []const u8) !?AppPassword { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT name, password_hash, created_at, privileged, scopes, created_by_controller_did \\FROM app_passwords \\WHERE did = ? AND name = ? \\LIMIT 1 , .{ did, name }); if (row == null) return null; defer row.?.deinit(); return try appPasswordFromRow(row.?, allocator); } pub fn findMatchingAppPassword(allocator: std.mem.Allocator, did: []const u8, password: []const u8) !?AppPassword { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT name, password_hash, created_at, privileged, scopes, created_by_controller_did \\FROM app_passwords \\WHERE did = ? \\ORDER BY created_at DESC , .{did}); defer rows.deinit(); while (rows.next()) |row| { const candidate = try appPasswordFromRow(row, allocator); if (auth.passwordHashMatches(candidate.password_hash, password)) return candidate; } return null; } pub fn sessionAuthMethod(allocator: std.mem.Allocator, did: []const u8, jti: []const u8) !?[]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT auth_method \\FROM session_tokens \\WHERE did = ? AND (access_jti = ? OR refresh_jti = ?) AND revoked_at IS NULL \\LIMIT 1 , .{ did, jti, jti }); if (row == null) return null; defer row.?.deinit(); return try allocator.dupe(u8, row.?.text(0)); } pub fn revokeAppPassword(did: []const u8, name: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE session_tokens \\SET revoked_at = unixepoch(), updated_at = unixepoch() \\WHERE did = ? AND app_password_name = ? AND revoked_at IS NULL , .{ did, name }); try conn.exec( \\DELETE FROM app_passwords \\WHERE did = ? AND name = ? , .{ did, name }); } pub const SessionTokenStatus = enum { active, expired, revoked, unknown }; /// for the refresh scope `jti` may also name the jti the last rotation retired, /// which stays usable until its grace window closes /// /// `+did` keeps SQLite off the did index so the OR resolves as two jti point /// lookups instead of a scan of every session the account has pub fn sessionTokenStatus(did: []const u8, jti: []const u8, scope: []const u8) !SessionTokenStatus { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const is_refresh = std.mem.eql(u8, scope, "com.atproto.refresh"); const row = if (is_refresh) try conn.row( \\SELECT id, revoked_at IS NOT NULL, \\ (CASE WHEN refresh_jti = ? THEN refresh_expires_at ELSE previous_refresh_expires_at END) > unixepoch() \\FROM session_tokens \\WHERE +did = ? AND (refresh_jti = ? OR previous_refresh_jti = ?) \\LIMIT 1 , .{ jti, did, jti, jti }) else try conn.row( \\SELECT id, revoked_at IS NOT NULL, access_expires_at > unixepoch() \\FROM session_tokens \\WHERE did = ? AND access_jti = ? \\LIMIT 1 , .{ did, jti }); const found = row orelse return .unknown; defer found.deinit(); if (found.int(1) != 0) return .revoked; if (found.int(2) == 0) return .expired; try conn.exec("UPDATE session_tokens SET last_used_at = unixepoch() WHERE id = ?", .{found.text(0)}); return .active; } pub const RefreshSession = struct { id: []const u8, auth_method: []const u8, access_jti: []const u8, refresh_jti: []const u8, access_expires_at: i64, refresh_expires_at: i64, /// the jti presented is the one the last rotation retired; the caller hands /// back the successor already on the row instead of rotating again replayed: bool, }; pub fn refreshSessionByJti(allocator: std.mem.Allocator, did: []const u8, jti: []const u8) !?RefreshSession { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT id, auth_method, access_jti, refresh_jti, access_expires_at, refresh_expires_at, refresh_jti <> ? \\FROM session_tokens \\WHERE +did = ? \\ AND (refresh_jti = ? OR (previous_refresh_jti = ? AND previous_refresh_expires_at > unixepoch())) \\ AND refresh_expires_at > unixepoch() AND revoked_at IS NULL \\LIMIT 1 , .{ jti, did, jti, jti }); const found = row orelse return null; defer found.deinit(); return .{ .id = try allocator.dupe(u8, found.text(0)), .auth_method = try allocator.dupe(u8, found.text(1)), .access_jti = try allocator.dupe(u8, found.text(2)), .refresh_jti = try allocator.dupe(u8, found.text(3)), .access_expires_at = found.int(4), .refresh_expires_at = found.int(5), .replayed = found.int(6) != 0, }; } /// rotates only while `old_refresh_jti` is still the row's current refresh jti; /// a request that lost the race sees `InvalidRefreshSession` and should re-read /// the row, which now reports the winner's successor as a replay pub fn rotateSessionToken( id: []const u8, old_refresh_jti: []const u8, new_access_jti: []const u8, new_refresh_jti: []const u8, access_expires_at: i64, refresh_expires_at: i64, previous_refresh_grace_secs: i64, ) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE session_tokens \\SET access_jti = ?, refresh_jti = ?, access_expires_at = ?, refresh_expires_at = ?, \\ previous_refresh_jti = ?, previous_refresh_expires_at = unixepoch() + ?, \\ updated_at = unixepoch(), last_used_at = unixepoch() \\WHERE id = ? AND refresh_jti = ? AND revoked_at IS NULL , .{ new_access_jti, new_refresh_jti, access_expires_at, refresh_expires_at, old_refresh_jti, previous_refresh_grace_secs, id, old_refresh_jti }); if (conn.changes() == 0) return error.InvalidRefreshSession; } pub fn revokeSessionToken(did: []const u8, jti: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE session_tokens \\SET revoked_at = unixepoch(), updated_at = unixepoch() \\WHERE did = ? AND (access_jti = ? OR refresh_jti = ?) , .{ did, jti, jti }); } pub fn recordAuditEvent( subject_did: []const u8, actor_did: []const u8, controller_did: ?[]const u8, action: []const u8, details_json: []const u8, ) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const id = try randomToken(std.heap.page_allocator, "aud-", 16); defer std.heap.page_allocator.free(id); try conn.exec( \\INSERT INTO account_audit_log \\ (id, subject_did, actor_did, controller_did, action, details_json) \\VALUES (?, ?, ?, ?, ?, ?) , .{ id, subject_did, actor_did, controller_did, action, details_json }); } pub fn putWebAuthnChallenge(did: []const u8, kind: []const u8, challenge: []const u8, state_json: []const u8, expires_at: i64) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const id = try randomToken(std.heap.page_allocator, "wan-", 16); defer std.heap.page_allocator.free(id); try conn.exec("DELETE FROM webauthn_challenges WHERE did = ? AND kind = ?", .{ did, kind }); try conn.exec( \\INSERT INTO webauthn_challenges (id, did, challenge, kind, state_json, expires_at) \\VALUES (?, ?, ?, ?, ?, ?) , .{ id, did, challenge, kind, state_json, expires_at }); } pub fn getWebAuthnChallenge(allocator: std.mem.Allocator, did: []const u8, kind: []const u8) !?WebAuthnChallenge { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT id, did, challenge, kind, state_json, expires_at \\FROM webauthn_challenges \\WHERE did = ? AND kind = ? , .{ did, kind }); if (row == null) return null; defer row.?.deinit(); return .{ .id = try allocator.dupe(u8, row.?.text(0)), .did = try allocator.dupe(u8, row.?.text(1)), .challenge = try allocator.dupe(u8, row.?.text(2)), .kind = try allocator.dupe(u8, row.?.text(3)), .state_json = try allocator.dupe(u8, row.?.text(4)), .expires_at = row.?.int(5), }; } pub fn deleteWebAuthnChallenge(did: []const u8, kind: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec("DELETE FROM webauthn_challenges WHERE did = ? AND kind = ?", .{ did, kind }); } pub fn putDiscoverableWebAuthnChallenge(request_id: []const u8, challenge: []const u8, expires_at: i64) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec("DELETE FROM webauthn_discoverable_challenges WHERE request_id = ?", .{request_id}); try conn.exec( \\INSERT INTO webauthn_discoverable_challenges (request_id, challenge, expires_at) \\VALUES (?, ?, ?) , .{ request_id, challenge, expires_at }); } pub fn getDiscoverableWebAuthnChallenge(allocator: std.mem.Allocator, request_id: []const u8) !?DiscoverableWebAuthnChallenge { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT request_id, challenge, expires_at \\FROM webauthn_discoverable_challenges \\WHERE request_id = ? , .{request_id}); if (row == null) return null; defer row.?.deinit(); return .{ .request_id = try allocator.dupe(u8, row.?.text(0)), .challenge = try allocator.dupe(u8, row.?.text(1)), .expires_at = row.?.int(2), }; } pub fn deleteDiscoverableWebAuthnChallenge(request_id: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec("DELETE FROM webauthn_discoverable_challenges WHERE request_id = ?", .{request_id}); } pub fn savePasskey(allocator: std.mem.Allocator, did: []const u8, credential_id: []const u8, public_key: []const u8, sign_count: u32, friendly_name: ?[]const u8) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const id = try randomToken(allocator, "pk-", 16); try conn.exec( \\INSERT INTO passkeys (id, did, credential_id, public_key, sign_count, friendly_name) \\VALUES (?, ?, ?, ?, ?, ?) , .{ id, did, zqlite.blob(credential_id), zqlite.blob(public_key), @as(i64, @intCast(sign_count)), friendly_name }); return id; } pub fn getPasskeyByCredentialId(allocator: std.mem.Allocator, credential_id: []const u8) !?Passkey { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT id, did, credential_id, public_key, sign_count, friendly_name, created_at, last_used \\FROM passkeys \\WHERE credential_id = ? , .{zqlite.blob(credential_id)}); if (row == null) return null; defer row.?.deinit(); return try passkeyFromRow(allocator, row.?); } pub fn listPasskeys(allocator: std.mem.Allocator, did: []const u8) ![]Passkey { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT id, did, credential_id, public_key, sign_count, friendly_name, created_at, last_used \\FROM passkeys \\WHERE did = ? \\ORDER BY created_at DESC , .{did}); defer rows.deinit(); var out: std.ArrayList(Passkey) = .empty; while (rows.next()) |row| try out.append(allocator, try passkeyFromRow(allocator, row)); if (rows.err) |err| return err; return out.toOwnedSlice(allocator); } pub fn updatePasskeyUse(id: []const u8, sign_count: u32) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE passkeys \\SET sign_count = ?, last_used = unixepoch() \\WHERE id = ? , .{ @as(i64, @intCast(sign_count)), id }); } pub fn deletePasskey(did: []const u8, id: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec("DELETE FROM passkeys WHERE did = ? AND id = ?", .{ did, id }); } pub fn updatePasskeyName(did: []const u8, id: []const u8, friendly_name: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE passkeys \\SET friendly_name = ? \\WHERE did = ? AND id = ? , .{ friendly_name, did, id }); } fn passkeyFromRow(allocator: std.mem.Allocator, row: zqlite.Row) !Passkey { return .{ .id = try allocator.dupe(u8, row.text(0)), .did = try allocator.dupe(u8, row.text(1)), .credential_id = try allocator.dupe(u8, row.blob(2)), .public_key = try allocator.dupe(u8, row.blob(3)), .sign_count = @intCast(row.int(4)), .friendly_name = if (row.nullableText(5)) |text| try allocator.dupe(u8, text) else null, .created_at = row.int(6), .last_used = row.nullableInt(7), }; } pub fn put( allocator: std.mem.Allocator, account: auth.Account, collection: []const u8, rkey: []const u8, value: std.json.Value, ) !Record { const result = try applyWrites(allocator, account, &.{.{ .update = .{ .collection = collection, .rkey = rkey, .value = value, } }}); if (result.records.len == 0) return Error.MissingRecord; return result.records[0]; } pub fn delete(allocator: std.mem.Allocator, account: auth.Account, collection: []const u8, rkey: []const u8) !CommitInfo { const result = try applyWrites(allocator, account, &.{.{ .delete = .{ .collection = collection, .rkey = rkey, } }}); return result.commit; } pub fn applyWrites(allocator: std.mem.Allocator, account: auth.Account, ops: []const WriteOp) !WriteResult { return applyWritesWithOptions(allocator, account, ops, .{}); } pub fn applyWritesWithOptions(allocator: std.mem.Allocator, account: auth.Account, ops: []const WriteOp, options: WriteOptions) !WriteResult { return applyWritesMeasured(allocator, account, ops, options, null); } pub fn applyWritesProfiled(allocator: std.mem.Allocator, account: auth.Account, ops: []const WriteOp, profile: *WriteProfile) !WriteResult { profile.* = .{}; return applyWritesMeasured(allocator, account, ops, .{}, profile); } fn applyWritesMeasured(allocator: std.mem.Allocator, account: auth.Account, ops: []const WriteOp, options: WriteOptions, profile: ?*WriteProfile) !WriteResult { const total_start = monotonicNs(); if (ops.len == 0 and !options.allow_empty_commit) return Error.MissingRecord; const validation_start = monotonicNs(); for (ops) |op| switch (op) { .create => |create_op| { if (zat.Nsid.parse(create_op.collection) == null) return Error.InvalidCollection; if (create_op.rkey) |rkey| if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey; try validateRecordForWrite(create_op.collection, create_op.rkey, create_op.value, options.validate); }, .update => |update_op| { if (zat.Nsid.parse(update_op.collection) == null) return Error.InvalidCollection; if (zat.Rkey.parse(update_op.rkey) == null) return Error.InvalidRecordKey; try validateRecordForWrite(update_op.collection, update_op.rkey, update_op.value, options.validate); }, .delete => |delete_op| { if (zat.Nsid.parse(delete_op.collection) == null) return Error.InvalidCollection; if (zat.Rkey.parse(delete_op.rkey) == null) return Error.InvalidRecordKey; }, }; addProfile(profile, .validation_ns, elapsedNs(validation_start)); const lock_start = monotonicNs(); const lane = write_lanes.lock(store_io, account.did); defer lane.unlock(); addProfile(profile, .lock_wait_ns, elapsedNs(lock_start)); try requireInitialized(); const load_start = monotonicNs(); const loaded = blk: { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); const seq = try nextSeqLocked(); const current = try latestCommitRawLocked(allocator, account.did); const resolved_ops = try resolveWriteOpsLocked(allocator, ops); try requireWriteSwapsLocked(allocator, account.did, current, options, resolved_ops); break :blk .{ .seq = seq, .current = current, .rev = try revForSeq(allocator, seq, if (current) |root| root.rev else null), .keypair = try signingKeypairLocked(account.did), .ops = resolved_ops, .prev_record_cids = try previousRecordCidsLocked(allocator, account.did, resolved_ops), }; }; const seq = loaded.seq; const current = loaded.current; const rev = loaded.rev; const keypair = loaded.keypair; const resolved_ops = loaded.ops; const prev_record_cids = loaded.prev_record_cids; var repo_block_reader = RepoBlockReader{ .allocator = allocator, .did = account.did, }; var tree = if (current) |root| try zat.mst.Mst.loadLazy(allocator, root.data_cid_raw, repo_block_reader.reader()) else zat.mst.Mst.init(allocator); addProfile(profile, .load_repo_ns, elapsedNs(load_start)); var records: std.ArrayList(Record) = .empty; var record_blocks: std.ArrayList(ImportedBlock) = .empty; var mst_blocks: std.ArrayList(zat.car.Block) = .empty; var blob_refs: std.ArrayList(BlobRef) = .empty; // half-open [start, end) into blob_refs for each staged record, parallel to // `records`. stageRecordWrite appends a record's refs contiguously, so the // index pass can find them without rescanning the whole list per record. var record_ref_spans: std.ArrayList(struct { start: usize, end: usize }) = .empty; const stage_start = monotonicNs(); for (resolved_ops) |op| switch (op) { .create => |create_op| { const rkey = create_op.rkey orelse return Error.InvalidRecordKey; const refs_start = blob_refs.items.len; const record = try stageRecordWrite(allocator, &tree, account, create_op.collection, rkey, create_op.value, options.validate, rev, seq, &record_blocks, &blob_refs); try records.append(allocator, record); try record_ref_spans.append(allocator, .{ .start = refs_start, .end = blob_refs.items.len }); }, .update => |update_op| { const refs_start = blob_refs.items.len; const record = try stageRecordWrite(allocator, &tree, account, update_op.collection, update_op.rkey, update_op.value, options.validate, rev, seq, &record_blocks, &blob_refs); try records.append(allocator, record); try record_ref_spans.append(allocator, .{ .start = refs_start, .end = blob_refs.items.len }); }, .delete => |delete_op| { const path = try repoPath(allocator, delete_op.collection, delete_op.rkey); _ = try tree.deleteReturn(path); }, }; addProfile(profile, .stage_records_ns, elapsedNs(stage_start)); const build_start = monotonicNs(); const data_cid = try tree.rootCid(); try writeMstBlocks(&tree, &mst_blocks); const commit = try signedCommitWithKeypair(allocator, account.did, rev, data_cid, if (current) |root| root.commit_cid_raw else null, &keypair); addProfile(profile, .build_commit_ns, elapsedNs(build_start)); const sql_start = monotonicNs(); db_mutex.lockUncancelable(store_io); var db_locked = true; defer if (db_locked) db_mutex.unlock(store_io); try conn.exclusiveTransaction(); errdefer conn.rollback(); for (record_blocks.items) |block| { try conn.exec( \\INSERT INTO repo_blocks (did, cid, data, repo_rev) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(did, cid) DO UPDATE SET \\ data = excluded.data, \\ repo_rev = COALESCE(repo_blocks.repo_rev, excluded.repo_rev) , .{ account.did, block.cid, zqlite.blob(block.data), rev }); } for (mst_blocks.items) |block| { try conn.exec( \\INSERT INTO repo_blocks (did, cid, data, repo_rev) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(did, cid) DO UPDATE SET \\ data = excluded.data, \\ repo_rev = COALESCE(repo_blocks.repo_rev, excluded.repo_rev) , .{ account.did, try cidText(allocator, block.cid_raw), zqlite.blob(block.data), rev }); } try conn.exec( \\INSERT INTO repo_blocks (did, cid, data, repo_rev) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(did, cid) DO UPDATE SET \\ data = excluded.data, \\ repo_rev = COALESCE(repo_blocks.repo_rev, excluded.repo_rev) , .{ account.did, commit.cid, zqlite.blob(commit.data), rev }); // Index the batch in op order, the way the tree was mutated. Applying every // delete before every create would let a same-key create+delete leave the row // behind while the MST says it is gone. The reference PDS walks its writes in // one ordered loop (actor-store/repo/transactor.ts indexWrites) and tranquil // resolves each delete against the running tree; this matches both. var staged_idx: usize = 0; for (resolved_ops) |op| switch (op) { .delete => |delete_op| { const uri = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ account.did, delete_op.collection, delete_op.rkey }); try conn.exec("DELETE FROM expected_blobs WHERE record_uri = ?", .{uri}); try conn.exec("DELETE FROM records WHERE did = ? AND collection = ? AND rkey = ?", .{ account.did, delete_op.collection, delete_op.rkey }); }, else => { const record = records.items[staged_idx]; staged_idx += 1; const uri = try record.uri(allocator); try conn.exec( \\INSERT INTO records (did, collection, rkey, uri, cid, rev, seq) \\VALUES (?, ?, ?, ?, ?, ?, ?) \\ON CONFLICT(did, collection, rkey) DO UPDATE SET \\ uri = excluded.uri, \\ cid = excluded.cid, \\ rev = excluded.rev, \\ seq = excluded.seq , .{ record.did, record.collection, record.rkey, uri, record.cid, record.rev, @as(i64, @intCast(seq)) }); try conn.exec("DELETE FROM expected_blobs WHERE record_uri = ?", .{uri}); const span = record_ref_spans.items[staged_idx - 1]; for (blob_refs.items[span.start..span.end]) |ref| { try conn.exec( \\INSERT INTO expected_blobs (blob_cid, record_uri) \\VALUES (?, ?) \\ON CONFLICT(blob_cid, record_uri) DO UPDATE SET blob_cid = excluded.blob_cid , .{ ref.cid, uri }); } }, }; std.debug.assert(staged_idx == records.items.len); try conn.exec( \\INSERT INTO commits (seq, did, cid, rev, prev) \\VALUES (?, ?, ?, ?, ?) , .{ @as(i64, @intCast(seq)), account.did, commit.cid, rev, if (current) |root| root.commit_cid_text else null }); const event_frame = try commitEventFrame( allocator, seq, account.did, commit.cid, rev, if (current) |root| root.rev else null, if (current) |root| root.data_cid_raw else null, commit.data, record_blocks.items, mst_blocks.items, resolved_ops, records.items, prev_record_cids, ); try conn.exec( \\INSERT INTO seq_events (seq, did, commit_cid, evt) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(seq) DO UPDATE SET \\ did = excluded.did, \\ commit_cid = excluded.commit_cid, \\ evt = excluded.evt , .{ @as(i64, @intCast(seq)), account.did, commit.cid, zqlite.blob(event_frame) }); try conn.commit(); db_mutex.unlock(store_io); db_locked = false; addProfile(profile, .sql_ns, elapsedNs(sql_start)); const publish_start = monotonicNs(); eventlog.publish(seq); addProfile(profile, .event_publish_ns, elapsedNs(publish_start)); if (profile) |p| p.total_ns = elapsedNs(total_start); return .{ .commit = .{ .cid = commit.cid, .rev = rev }, .records = try records.toOwnedSlice(allocator), }; } const ProfileField = enum { validation_ns, lock_wait_ns, load_repo_ns, stage_records_ns, build_commit_ns, sql_ns, event_publish_ns, }; fn addProfile(profile: ?*WriteProfile, field: ProfileField, ns: u64) void { const p = profile orelse return; switch (field) { .validation_ns => p.validation_ns += ns, .lock_wait_ns => p.lock_wait_ns += ns, .load_repo_ns => p.load_repo_ns += ns, .stage_records_ns => p.stage_records_ns += ns, .build_commit_ns => p.build_commit_ns += ns, .sql_ns => p.sql_ns += ns, .event_publish_ns => p.event_publish_ns += ns, } } fn monotonicNs() u64 { return clock.monotonicNs(); } fn elapsedNs(start: u64) u64 { const now = monotonicNs(); return if (now >= start) now - start else 0; } pub fn revForSeq(allocator: std.mem.Allocator, seq: u64, current_rev: ?[]const u8) ![]const u8 { var timestamp_us = nowMicros(); if (current_rev) |rev| { const current_timestamp = atid.timestampMicros(rev) catch 0; if (timestamp_us <= current_timestamp) timestamp_us = current_timestamp + 1; } const tid = try atid.encode(timestamp_us, @intCast(seq % 1024)); return allocator.dupe(u8, &tid); } fn nowMicros() u64 { return @intCast(@max(0, @divTrunc(clock.nowNanos(), std.time.ns_per_us))); } pub fn get(did: []const u8, collection: []const u8, rkey: []const u8) ?Record { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); requireInitialized() catch return null; const row = conn.row( \\SELECT r.did, r.collection, r.rkey, r.cid, rb.data, r.rev, r.seq \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.did = ? AND r.collection = ? AND r.rkey = ? , .{ did, collection, rkey }) catch return null; if (row == null) return null; defer row.?.deinit(); return recordFromRow(row.?, std.heap.page_allocator) catch null; } pub fn getCid(allocator: std.mem.Allocator, did: []const u8, collection: []const u8, rkey: []const u8) ?[]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); requireInitialized() catch return null; const row = conn.row( \\SELECT cid \\FROM records \\WHERE did = ? AND collection = ? AND rkey = ? , .{ did, collection, rkey }) catch return null; if (row == null) return null; defer row.?.deinit(); return allocator.dupe(u8, row.?.text(0)) catch null; } pub const RecordBlock = struct { cid: []const u8, data: []const u8, }; pub fn getRecordBlock(allocator: std.mem.Allocator, did: []const u8, collection: []const u8, rkey: []const u8) ?RecordBlock { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); requireInitialized() catch return null; const row = conn.row( \\SELECT r.cid, rb.data \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.did = ? AND r.collection = ? AND r.rkey = ? , .{ did, collection, rkey }) catch return null; if (row == null) return null; defer row.?.deinit(); return .{ .cid = allocator.dupe(u8, row.?.text(0)) catch return null, .data = allocator.dupe(u8, row.?.nullableBlob(1) orelse return null) catch return null, }; } pub fn getByUri(uri: []const u8) ?Record { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); requireInitialized() catch return null; return getByUriLocked(uri); } pub fn listRecentRecords(allocator: std.mem.Allocator, limit: usize) ![]Record { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT r.did, r.collection, r.rkey, r.cid, rb.data, r.rev, r.seq \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\ORDER BY r.seq DESC \\LIMIT ? , .{@as(i64, @intCast(if (limit == 0) 100 else limit))}); defer rows.deinit(); var records: std.ArrayList(Record) = .empty; while (rows.next()) |row| { try records.append(allocator, try recordFromRow(row, allocator)); } if (rows.err) |err| return err; return records.toOwnedSlice(allocator); } pub fn listRecords( allocator: std.mem.Allocator, did: []const u8, collection: []const u8, limit: usize, ) ![]Record { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT r.did, r.collection, r.rkey, r.cid, rb.data, r.rev, r.seq \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.did = ? AND r.collection = ? \\ORDER BY r.seq DESC \\LIMIT ? , .{ did, collection, @as(i64, @intCast(if (limit == 0) 100 else limit)) }); defer rows.deinit(); var records: std.ArrayList(Record) = .empty; while (rows.next()) |row| { try records.append(allocator, try recordFromRow(row, allocator)); } if (rows.err) |err| return err; return records.toOwnedSlice(allocator); } pub fn listRecordsContaining( allocator: std.mem.Allocator, collection: []const u8, needle: []const u8, limit: usize, ) ![]Record { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT r.did, r.collection, r.rkey, r.cid, rb.data, r.rev, r.seq \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.collection = ? \\ORDER BY r.seq DESC \\LIMIT ? , .{ collection, @as(i64, @intCast(if (limit == 0) 100 else limit)) }); defer rows.deinit(); var records: std.ArrayList(Record) = .empty; while (rows.next()) |row| { const record = try recordFromRow(row, allocator); if (std.mem.indexOf(u8, record.value_json, needle) != null) { try records.append(allocator, record); } } if (rows.err) |err| return err; return records.toOwnedSlice(allocator); } pub fn listRecordsByDidContaining( allocator: std.mem.Allocator, did: []const u8, collection: []const u8, needle: []const u8, limit: usize, ) ![]Record { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT r.did, r.collection, r.rkey, r.cid, rb.data, r.rev, r.seq \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.did = ? AND r.collection = ? \\ORDER BY r.seq DESC \\LIMIT ? , .{ did, collection, @as(i64, @intCast(if (limit == 0) 100 else limit)) }); defer rows.deinit(); var records: std.ArrayList(Record) = .empty; while (rows.next()) |row| { const record = try recordFromRow(row, allocator); if (std.mem.indexOf(u8, record.value_json, needle) != null) { try records.append(allocator, record); } } if (rows.err) |err| return err; return records.toOwnedSlice(allocator); } fn getByUriLocked(uri: []const u8) ?Record { if (getByStoredUriLocked(uri)) |record| return record; const prefix = "at://"; if (!std.mem.startsWith(u8, uri, prefix)) return null; const rest = uri[prefix.len..]; const slash = std.mem.indexOfScalar(u8, rest, '/') orelse return null; const repo = rest[0..slash]; const account = (findAccountLocked(std.heap.page_allocator, repo) catch null) orelse return null; const normalized = std.fmt.allocPrint(std.heap.page_allocator, "at://{s}/{s}", .{ account.did, rest[slash + 1 ..] }) catch return null; return getByStoredUriLocked(normalized); } fn getByStoredUriLocked(uri: []const u8) ?Record { const row = conn.row( \\SELECT r.did, r.collection, r.rkey, r.cid, rb.data, r.rev, r.seq \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.uri = ? , .{uri}) catch return null; if (row == null) return null; defer row.?.deinit(); return recordFromRow(row.?, std.heap.page_allocator) catch null; } pub fn count(did: []const u8, collection: []const u8) usize { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); requireInitialized() catch return 0; const row = conn.row("SELECT count(*) FROM records WHERE did = ? AND collection = ?", .{ did, collection }) catch return 0; if (row == null) return 0; defer row.?.deinit(); return @intCast(row.?.int(0)); } pub fn countSubject(collection: []const u8, subject_did: []const u8) usize { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); requireInitialized() catch return 0; var rows = conn.rows( \\SELECT rb.data \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.collection = ? , .{collection}) catch return 0; defer rows.deinit(); var count_result: usize = 0; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); while (rows.next()) |row| { _ = arena.reset(.retain_capacity); const value = recordJsonFromBlock(arena.allocator(), row.nullableBlob(0) orelse "") catch continue; if (std.mem.indexOf(u8, value, subject_did) != null) count_result += 1; } return count_result; } pub fn listCollectionsJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT DISTINCT collection \\FROM records \\WHERE did = ? \\ORDER BY collection , .{did}); defer rows.deinit(); var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeByte('['); var first = true; while (rows.next()) |row| { if (!first) try out.writer.writeByte(','); first = false; try out.writer.print("{f}", .{std.json.fmt(row.text(0), .{})}); } if (rows.err) |err| return err; try out.writer.writeByte(']'); return out.toOwnedSlice(); } pub fn getAppPreferences(allocator: std.mem.Allocator, did: []const u8, namespace: []const u8) ![]AppPreference { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const like_pattern = try std.fmt.allocPrint(allocator, "{s}.%", .{namespace}); var rows = try conn.rows( \\SELECT name, value_json \\FROM app_preferences \\WHERE did = ? AND (name = ? OR name LIKE ?) \\ORDER BY id ASC , .{ did, namespace, like_pattern }); defer rows.deinit(); var preferences: std.ArrayList(AppPreference) = .empty; while (rows.next()) |row| { try preferences.append(allocator, .{ .name = try allocator.dupe(u8, row.text(0)), .value_json = try allocator.dupe(u8, row.text(1)), }); } if (rows.err) |err| return err; return preferences.toOwnedSlice(allocator); } pub fn replaceAppPreferences( allocator: std.mem.Allocator, did: []const u8, namespace: []const u8, preferences: []const AppPreference, ) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const like_pattern = try std.fmt.allocPrint(allocator, "{s}.%", .{namespace}); try conn.exclusiveTransaction(); errdefer conn.rollback(); try conn.exec( \\DELETE FROM app_preferences \\WHERE did = ? AND (name = ? OR name LIKE ?) , .{ did, namespace, like_pattern }); for (preferences) |preference| { try conn.exec( \\INSERT INTO app_preferences (did, name, value_json, updated_at) \\VALUES (?, ?, ?, unixepoch()) , .{ did, preference.name, preference.value_json }); } try conn.commit(); } pub fn putBlob( allocator: std.mem.Allocator, io: Io, account: auth.Account, data: []const u8, mime_type: []const u8, ) ![]const u8 { var reader: std.Io.Reader = .fixed(data); return (try putBlobReader(allocator, io, account, &reader, data.len, data.len, mime_type)).cid; } pub const StoredBlob = struct { cid: []const u8, size: usize, }; pub fn putBlobReader( allocator: std.mem.Allocator, io: Io, account: auth.Account, reader: *std.Io.Reader, expected_size: usize, max_size: usize, mime_type: []const u8, ) !StoredBlob { try requireInitialized(); const staged = try blobstore.stageUpload(allocator, io, account.did, reader, expected_size, max_size); errdefer blobstore.discard(staged.temp_path); const cid = try cidForBlobDigest(allocator, staged.digest); { const lane = blob_lanes.lock(store_io, cid); defer lane.unlock(); try blobstore.promote(allocator, staged.temp_path, account.did, cid); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\INSERT INTO blobs (cid, did, mime_type, size) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(did, cid) DO UPDATE SET \\ mime_type = excluded.mime_type, \\ size = excluded.size, \\ created_at = unixepoch() , .{ cid, account.did, mime_type, @as(i64, @intCast(staged.size)) }); } return .{ .cid = cid, .size = staged.size }; } const BlobGcCandidate = struct { cid: []const u8 }; pub fn collectBlobGarbage( allocator: std.mem.Allocator, grace_seconds: i64, limit: usize, ) !BlobGcResult { if (limit == 0) return .{}; var dids: std.ArrayList([]const u8) = .empty; { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT DISTINCT b.did \\FROM blobs b \\WHERE NOT EXISTS ( \\ SELECT 1 FROM expected_blobs eb \\ JOIN records r ON r.uri = eb.record_uri \\ WHERE r.did = b.did AND eb.blob_cid = b.cid \\) \\AND NOT EXISTS ( \\ SELECT 1 FROM permissioned_space_record_blobs psrb \\ WHERE psrb.repo_did = b.did AND psrb.blob_cid = b.cid \\) \\AND b.created_at <= unixepoch() - ? \\ORDER BY b.did , .{grace_seconds}); defer rows.deinit(); while (rows.next()) |row| try dids.append(allocator, try allocator.dupe(u8, row.text(0))); if (rows.err) |err| return err; } var result: BlobGcResult = .{}; for (dids.items) |did| { if (result.deleted + result.failed >= limit) break; { const lane = write_lanes.lock(store_io, did); defer lane.unlock(); const account_result = try collectBlobGarbageForDidInLane( allocator, did, grace_seconds, limit - result.deleted - result.failed, ); result.deleted += account_result.deleted; result.bytes += account_result.bytes; result.failed += account_result.failed; } } return result; } fn collectBlobGarbageForDidInLane( allocator: std.mem.Allocator, did: []const u8, grace_seconds: i64, limit: usize, ) !BlobGcResult { if (limit == 0) return .{}; var candidates: std.ArrayList(BlobGcCandidate) = .empty; { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT b.cid \\FROM blobs b \\WHERE b.did = ? \\AND NOT EXISTS ( \\ SELECT 1 FROM expected_blobs eb \\ JOIN records r ON r.uri = eb.record_uri \\ WHERE r.did = b.did AND eb.blob_cid = b.cid \\) \\AND NOT EXISTS ( \\ SELECT 1 FROM permissioned_space_record_blobs psrb \\ WHERE psrb.repo_did = b.did AND psrb.blob_cid = b.cid \\) \\AND b.created_at <= unixepoch() - ? \\ORDER BY b.created_at, b.cid \\LIMIT ? , .{ did, grace_seconds, @as(i64, @intCast(limit)) }); defer rows.deinit(); while (rows.next()) |row| { try candidates.append(allocator, .{ .cid = try allocator.dupe(u8, row.text(0)), }); } if (rows.err) |err| return err; } var result: BlobGcResult = .{}; for (candidates.items) |candidate| { const blob_lane = blob_lanes.lock(store_io, candidate.cid); defer blob_lane.unlock(); const size = blk: { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); const row = try conn.row( \\SELECT b.size \\FROM blobs b \\WHERE b.did = ? AND b.cid = ? \\AND NOT EXISTS ( \\ SELECT 1 FROM expected_blobs eb \\ JOIN records r ON r.uri = eb.record_uri \\ WHERE r.did = b.did AND eb.blob_cid = b.cid \\) \\AND NOT EXISTS ( \\ SELECT 1 FROM permissioned_space_record_blobs psrb \\ WHERE psrb.repo_did = b.did AND psrb.blob_cid = b.cid \\) \\AND b.created_at <= unixepoch() - ? , .{ did, candidate.cid, grace_seconds }); const found = row orelse continue; defer found.deinit(); break :blk @as(u64, @intCast(found.int(0))); }; blobstore.delete(allocator, did, candidate.cid) catch { result.failed += 1; continue; }; { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try conn.exec( \\DELETE FROM blobs \\WHERE did = ? AND cid = ? \\AND NOT EXISTS ( \\ SELECT 1 FROM expected_blobs eb \\ JOIN records r ON r.uri = eb.record_uri \\ WHERE r.did = blobs.did AND eb.blob_cid = blobs.cid \\) \\AND NOT EXISTS ( \\ SELECT 1 FROM permissioned_space_record_blobs psrb \\ WHERE psrb.repo_did = blobs.did AND psrb.blob_cid = blobs.cid \\) , .{ did, candidate.cid }); } result.deleted += 1; result.bytes += size; } return result; } pub fn startBlobGarbageCollector() void { if (blob_gc_worker_started.cmpxchgStrong(false, true, .acq_rel, .acquire) != null) return; const thread = std.Thread.spawn(.{}, collectBlobGarbageThread, .{}) catch |err| { blob_gc_worker_started.store(false, .release); log.err("blob gc spawn failed error={s}\n", .{@errorName(err)}); return; }; thread.detach(); } fn collectBlobGarbageThread() void { while (true) { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); const result = collectBlobGarbage(arena.allocator(), blob_gc_grace_seconds, blob_gc_batch) catch |err| blk: { log.err("blob gc failed error={s}\n", .{@errorName(err)}); break :blk null; }; if (result) |summary| { if (summary.deleted > 0 or summary.failed > 0) { log.info("blob gc deleted={d} bytes={d} failed={d}\n", .{ summary.deleted, summary.bytes, summary.failed }); } } arena.deinit(); store_io.sleep(Io.Duration.fromSeconds(blob_gc_interval_seconds), .awake) catch {}; } } pub fn getBlob( allocator: std.mem.Allocator, did: []const u8, cid: []const u8, ) ?BlobRecord { const metadata = blobMetadata(allocator, did, cid, false) orelse return null; return .{ .mime_type = metadata.mime_type, .data = blobstore.get(allocator, did, cid, metadata.size + 1) catch return null, }; } pub fn permissionedBlobReferenced(space: []const u8, repo_did: []const u8, cid: []const u8) !bool { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( \\SELECT 1 \\FROM permissioned_space_record_blobs \\WHERE space = ? AND repo_did = ? AND blob_cid = ? \\LIMIT 1 , .{ space, repo_did, cid }); if (row) |found| { found.deinit(); return true; } return false; } pub fn listPermissionedBlobs( allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, since: ?[]const u8, cursor: ?[]const u8, limit: usize, ) ![][]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const capped_limit: i64 = @intCast(@min(if (limit == 0) 500 else limit, 1000)); var rows = if (since) |rev| if (cursor) |after| try conn.rows( \\SELECT DISTINCT b.blob_cid \\FROM permissioned_space_record_blobs b \\JOIN permissioned_space_records r \\ ON r.space = b.space AND r.repo_did = b.repo_did \\ AND r.collection = b.collection AND r.rkey = b.rkey \\WHERE b.space = ? AND b.repo_did = ? AND r.repo_rev > ? AND b.blob_cid > ? \\ORDER BY b.blob_cid ASC LIMIT ? , .{ space, repo_did, rev, after, capped_limit }) else try conn.rows( \\SELECT DISTINCT b.blob_cid \\FROM permissioned_space_record_blobs b \\JOIN permissioned_space_records r \\ ON r.space = b.space AND r.repo_did = b.repo_did \\ AND r.collection = b.collection AND r.rkey = b.rkey \\WHERE b.space = ? AND b.repo_did = ? AND r.repo_rev > ? \\ORDER BY b.blob_cid ASC LIMIT ? , .{ space, repo_did, rev, capped_limit }) else if (cursor) |after| try conn.rows( \\SELECT DISTINCT blob_cid \\FROM permissioned_space_record_blobs \\WHERE space = ? AND repo_did = ? AND blob_cid > ? \\ORDER BY blob_cid ASC LIMIT ? , .{ space, repo_did, after, capped_limit }) else try conn.rows( \\SELECT DISTINCT blob_cid \\FROM permissioned_space_record_blobs \\WHERE space = ? AND repo_did = ? \\ORDER BY blob_cid ASC LIMIT ? , .{ space, repo_did, capped_limit }); defer rows.deinit(); var cids: std.ArrayList([]const u8) = .empty; while (rows.next()) |row| try cids.append(allocator, try allocator.dupe(u8, row.text(0))); if (rows.err) |err| return err; return cids.toOwnedSlice(allocator); } pub fn getPublicBlob( allocator: std.mem.Allocator, did: []const u8, cid: []const u8, ) ?BlobRecord { const metadata = blobMetadata(allocator, did, cid, true) orelse return null; return .{ .mime_type = metadata.mime_type, .data = blobstore.get(allocator, did, cid, metadata.size + 1) catch return null, }; } const BlobMetadata = struct { mime_type: []const u8, size: usize, }; fn blobMetadata( allocator: std.mem.Allocator, did: []const u8, cid: []const u8, require_public_reference: bool, ) ?BlobMetadata { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); requireInitialized() catch return null; const row = if (require_public_reference) conn.row( \\SELECT b.mime_type, b.size \\FROM blobs b \\WHERE b.did = ? AND b.cid = ? \\ AND EXISTS ( \\ SELECT 1 \\ FROM expected_blobs eb \\ JOIN records r ON r.uri = eb.record_uri \\ WHERE r.did = b.did AND eb.blob_cid = b.cid \\ ) , .{ did, cid }) catch return null else conn.row( \\SELECT mime_type, size \\FROM blobs \\WHERE did = ? AND cid = ? , .{ did, cid }) catch return null; const found = row orelse return null; defer found.deinit(); return .{ .mime_type = allocator.dupe(u8, found.text(0)) catch return null, .size = @intCast(found.int(1)), }; } pub fn writeBlobListJson(allocator: std.mem.Allocator, did: []const u8, since: ?[]const u8, cursor: ?[]const u8, limit: usize) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const page_limit = @as(i64, @intCast(if (limit == 0) 500 else limit)); var rows = if (since) |since_rev| blk: { if (cursor) |after_cid| { break :blk try conn.rows( \\SELECT DISTINCT b.cid \\FROM blobs b \\JOIN expected_blobs eb ON eb.blob_cid = b.cid \\JOIN records r ON r.uri = eb.record_uri AND r.did = b.did \\WHERE b.did = ? AND r.rev > ? AND b.cid > ? \\ORDER BY b.cid ASC \\LIMIT ? , .{ did, since_rev, after_cid, page_limit }); } break :blk try conn.rows( \\SELECT DISTINCT b.cid \\FROM blobs b \\JOIN expected_blobs eb ON eb.blob_cid = b.cid \\JOIN records r ON r.uri = eb.record_uri AND r.did = b.did \\WHERE b.did = ? AND r.rev > ? \\ORDER BY b.cid ASC \\LIMIT ? , .{ did, since_rev, page_limit }); } else blk: { if (cursor) |after_cid| { break :blk try conn.rows( \\SELECT DISTINCT b.cid \\FROM blobs b \\JOIN expected_blobs eb ON eb.blob_cid = b.cid \\JOIN records r ON r.uri = eb.record_uri AND r.did = b.did \\WHERE b.did = ? AND b.cid > ? \\ORDER BY b.cid ASC \\LIMIT ? , .{ did, after_cid, page_limit }); } break :blk try conn.rows( \\SELECT DISTINCT b.cid \\FROM blobs b \\JOIN expected_blobs eb ON eb.blob_cid = b.cid \\JOIN records r ON r.uri = eb.record_uri AND r.did = b.did \\WHERE b.did = ? \\ORDER BY b.cid ASC \\LIMIT ? , .{ did, page_limit }); }; defer rows.deinit(); var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"cids\":["); var first = true; var last_cid: ?[]const u8 = null; while (rows.next()) |row| { if (!first) try out.writer.writeByte(','); first = false; const cid = row.text(0); last_cid = try allocator.dupe(u8, cid); try out.writer.print("{f}", .{std.json.fmt(cid, .{})}); } if (rows.err) |err| return err; try out.writer.writeByte(']'); if (last_cid) |cid| try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(cid, .{})}); try out.writer.writeByte('}'); return out.toOwnedSlice(); } pub fn writeMissingBlobsJson(allocator: std.mem.Allocator, did: []const u8, cursor: ?[]const u8, limit: usize) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const page_limit = @as(i64, @intCast(if (limit == 0) 500 else limit)); var rows = if (cursor) |after_cid| try conn.rows( \\SELECT rb.blob_cid, rb.record_uri \\FROM expected_blobs rb \\JOIN records r ON r.uri = rb.record_uri \\LEFT JOIN blobs b ON b.cid = rb.blob_cid AND b.did = r.did \\WHERE r.did = ? AND b.cid IS NULL AND rb.blob_cid > ? \\ORDER BY rb.blob_cid ASC \\LIMIT ? , .{ did, after_cid, page_limit }) else try conn.rows( \\SELECT rb.blob_cid, rb.record_uri \\FROM expected_blobs rb \\JOIN records r ON r.uri = rb.record_uri \\LEFT JOIN blobs b ON b.cid = rb.blob_cid AND b.did = r.did \\WHERE r.did = ? AND b.cid IS NULL \\ORDER BY rb.blob_cid ASC \\LIMIT ? , .{ did, page_limit }); defer rows.deinit(); var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"blobs\":["); var first = true; var last_cid: ?[]const u8 = null; while (rows.next()) |row| { if (!first) try out.writer.writeByte(','); first = false; const cid = row.text(0); last_cid = try allocator.dupe(u8, cid); try out.writer.print( "{{\"cid\":{f},\"recordUri\":{f}}}", .{ std.json.fmt(cid, .{}), std.json.fmt(row.text(1), .{}) }, ); } if (rows.err) |err| return err; try out.writer.writeByte(']'); if (last_cid) |cid| try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(cid, .{})}); try out.writer.writeByte('}'); return out.toOwnedSlice(); } pub fn writeAccountStatusJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const record_count = try scalarCountLocked( "SELECT COUNT(*) FROM records WHERE did = ?", did, ); const blob_count = try scalarCountLocked( "SELECT COUNT(*) FROM blobs WHERE did = ?", did, ); const expected_blob_count = try scalarCountLocked( \\SELECT COUNT(*) \\FROM expected_blobs rb \\JOIN records r ON r.uri = rb.record_uri \\WHERE r.did = ? , did, ); const block_count = try scalarCountLocked( "SELECT COUNT(*) FROM repo_blocks WHERE did = ?", did, ); const status = try accountStatusLocked(did); const active = status.isActive(); const root = try latestRootLocked(allocator, did); return std.fmt.allocPrint( allocator, "{{\"activated\":{},\"validDid\":{},\"status\":{f},\"repoCommit\":{f},\"repoRev\":{f},\"repoBlocks\":{d},\"indexedRecords\":{d},\"privateStateValues\":0,\"expectedBlobs\":{d},\"importedBlobs\":{d}}}", .{ active, active, std.json.fmt(status.asString(), .{}), std.json.fmt(root.cid, .{}), std.json.fmt(root.rev, .{}), block_count, record_count, expected_blob_count, blob_count, }, ); } pub fn importRepo( allocator: std.mem.Allocator, account: auth.Account, commit_cid: []const u8, rev: []const u8, records: []const ImportedRecord, blocks: []const ImportedBlock, ) !void { const lane = write_lanes.lock(store_io, account.did); defer lane.unlock(); var publish_event = false; var seq: u64 = 0; db_mutex.lockUncancelable(store_io); var db_locked = true; defer if (db_locked) db_mutex.unlock(store_io); try requireInitialized(); seq = try nextSeqLocked(); try conn.exclusiveTransaction(); errdefer conn.rollback(); try conn.exec("DELETE FROM expected_blobs WHERE record_uri IN (SELECT uri FROM records WHERE did = ?)", .{account.did}); try conn.exec("DELETE FROM records WHERE did = ?", .{account.did}); try conn.exec("DELETE FROM repo_blocks WHERE did = ?", .{account.did}); try conn.exec("DELETE FROM commits WHERE did = ?", .{account.did}); for (blocks) |block| { try conn.exec( \\INSERT INTO repo_blocks (did, cid, data, repo_rev) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(did, cid) DO UPDATE SET \\ data = excluded.data, \\ repo_rev = COALESCE(repo_blocks.repo_rev, excluded.repo_rev) , .{ account.did, block.cid, zqlite.blob(block.data), rev }); } for (records) |record| { const uri = try std.fmt.allocPrint(std.heap.page_allocator, "at://{s}/{s}/{s}", .{ account.did, record.collection, record.rkey }); defer std.heap.page_allocator.free(uri); try conn.exec( \\INSERT INTO records (did, collection, rkey, uri, cid, rev, seq) \\VALUES (?, ?, ?, ?, ?, ?, ?) , .{ account.did, record.collection, record.rkey, uri, record.cid, rev, @as(i64, @intCast(seq)) }); for (record.blob_cids) |blob_cid| { try conn.exec( \\INSERT INTO expected_blobs (blob_cid, record_uri) \\VALUES (?, ?) \\ON CONFLICT(blob_cid, record_uri) DO UPDATE SET blob_cid = excluded.blob_cid , .{ blob_cid, uri }); } } try conn.exec( \\INSERT INTO commits (seq, did, cid, rev, prev) \\VALUES (?, ?, ?, ?, NULL) , .{ @as(i64, @intCast(seq)), account.did, commit_cid, rev }); publish_event = try accountActiveLocked(account.did); if (publish_event) { const event_frame = try importedCommitEventFrame(allocator, seq, account.did, commit_cid, rev, blocks); try conn.exec( \\INSERT INTO seq_events (seq, did, commit_cid, evt) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(seq) DO UPDATE SET \\ did = excluded.did, \\ commit_cid = excluded.commit_cid, \\ evt = excluded.evt , .{ @as(i64, @intCast(seq)), account.did, commit_cid, zqlite.blob(event_frame) }); } try conn.commit(); db_mutex.unlock(store_io); db_locked = false; if (publish_event) eventlog.publish(seq); } pub fn writeRepoCar(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { return writeRepoCarSince(allocator, did, null); } pub fn writeRepoCarSince(allocator: std.mem.Allocator, did: []const u8, since: ?[]const u8) ![]const u8 { if (since == null) return writeRepoCarFull(allocator, did); const path = database_path orelse return Error.StoreNotInitialized; if (std.mem.eql(u8, path, ":memory:")) { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return writeRepoCarSinceFrom(allocator, conn, did, since.?); } const read_conn = try openRepoReadConnection(path); defer read_conn.close(); try read_conn.transaction(); errdefer read_conn.rollback(); const data = try collectRepoCarSinceFrom(allocator, read_conn, did, since.?); try read_conn.commit(); return writeRepoCarData(allocator, data); } fn writeRepoCarSinceFrom( allocator: std.mem.Allocator, read_conn: zqlite.Conn, did: []const u8, since_rev: []const u8, ) ![]const u8 { const data = try collectRepoCarSinceFrom(allocator, read_conn, did, since_rev); return writeRepoCarData(allocator, data); } fn collectRepoCarSinceFrom( allocator: std.mem.Allocator, read_conn: zqlite.Conn, did: []const u8, since_rev: []const u8, ) !RepoCarData { const root = try latestRootFrom(read_conn, allocator, did); const root_raw = try zat.multibase.base32lower.decode(allocator, root.cid[1..]); var rows = try read_conn.rows( \\SELECT cid, data \\FROM repo_blocks \\WHERE did = ? AND (repo_rev IS NULL OR repo_rev > ?) \\ORDER BY repo_rev DESC, cid DESC , .{ did, since_rev }); defer rows.deinit(); var blocks: std.ArrayList(zat.car.Block) = .empty; while (rows.next()) |row| { const cid_text = row.text(0); const data = row.nullableBlob(1) orelse ""; const cid_raw = try zat.multibase.base32lower.decode(allocator, cid_text[1..]); try blocks.append(allocator, .{ .cid_raw = cid_raw, .data = try allocator.dupe(u8, data), }); } if (rows.err) |err| return err; return .{ .root_cid_raw = root_raw, .blocks = try blocks.toOwnedSlice(allocator), }; } pub fn writeRecordProofCar( allocator: std.mem.Allocator, did: []const u8, collection: []const u8, rkey: []const u8, ) ![]const u8 { const path = database_path orelse return Error.StoreNotInitialized; if (std.mem.eql(u8, path, ":memory:")) { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const data = try collectRecordProofFrom(allocator, conn, did, collection, rkey); return writeRepoCarData(allocator, data); } const read_conn = try openRepoReadConnection(path); defer read_conn.close(); try read_conn.transaction(); errdefer read_conn.rollback(); const data = try collectRecordProofFrom(allocator, read_conn, did, collection, rkey); try read_conn.commit(); return writeRepoCarData(allocator, data); } /// Collect the sync.getRecord proof CAR: the signed commit block plus the MST /// node path from the root down to where (collection, rkey) lives, and the /// record block itself when present. For an absent key the path alone is the /// exclusion proof, matching the reference implementation's `cidsForPath`. fn collectRecordProofFrom( allocator: std.mem.Allocator, read_conn: zqlite.Conn, did: []const u8, collection: []const u8, rkey: []const u8, ) !RepoCarData { const commit = try latestCommitRawFrom(read_conn, allocator, did) orelse return Error.RepoNotFound; var blocks: std.ArrayList(zat.car.Block) = .empty; try blocks.append(allocator, .{ .cid_raw = commit.commit_cid_raw, .data = commit.commit_data, }); const key = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ collection, rkey }); var current: ?[]const u8 = commit.data_cid_raw; var record_cid_raw: ?[]const u8 = null; var depth: usize = 0; walk: while (current) |node_cid_raw| { depth += 1; if (depth > 256) return Error.InvalidDagCbor; const node_cid_text = try cidText(allocator, node_cid_raw); const node_data = try repoBlockDataFrom(read_conn, allocator, did, node_cid_text) orelse return Error.MissingRecordBlock; try blocks.append(allocator, .{ .cid_raw = try allocator.dupe(u8, node_cid_raw), .data = node_data, }); const node = try zat.mst.decodeMstNode(allocator, node_data); var next: ?[]const u8 = node.left; var prev_key: []const u8 = ""; for (node.entries) |entry| { if (entry.prefix_len > prev_key.len) return Error.InvalidDagCbor; const entry_key = try allocator.alloc(u8, entry.prefix_len + entry.key_suffix.len); @memcpy(entry_key[0..entry.prefix_len], prev_key[0..entry.prefix_len]); @memcpy(entry_key[entry.prefix_len..], entry.key_suffix); prev_key = entry_key; switch (std.mem.order(u8, key, entry_key)) { .lt => { current = next; continue :walk; }, .eq => { record_cid_raw = entry.value; break :walk; }, .gt => next = entry.tree, } } current = next; } if (record_cid_raw) |raw| { const record_cid_text = try cidText(allocator, raw); const record_data = try repoBlockDataFrom(read_conn, allocator, did, record_cid_text) orelse return Error.MissingRecordBlock; try blocks.append(allocator, .{ .cid_raw = try allocator.dupe(u8, raw), .data = record_data, }); } return .{ .root_cid_raw = commit.commit_cid_raw, .blocks = try blocks.toOwnedSlice(allocator), }; } fn writeRepoCarFull(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { const path = database_path orelse return Error.StoreNotInitialized; if (std.mem.eql(u8, path, ":memory:")) { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const data = try collectRepoCarFullFrom(allocator, conn, did); return writeRepoCarData(allocator, data); } const read_conn = try openRepoReadConnection(path); defer read_conn.close(); try read_conn.transaction(); errdefer read_conn.rollback(); const data = try collectRepoCarFullFrom(allocator, read_conn, did); try read_conn.commit(); return writeRepoCarData(allocator, data); } const RepoCarData = struct { root_cid_raw: []const u8, blocks: []zat.car.Block, }; fn collectRepoCarFullFrom( allocator: std.mem.Allocator, read_conn: zqlite.Conn, did: []const u8, ) !RepoCarData { const root = try latestCommitRawFrom(read_conn, allocator, did) orelse return Error.RepoNotFound; var blocks: std.ArrayList(zat.car.Block) = .empty; try blocks.append(allocator, .{ .cid_raw = root.commit_cid_raw, .data = root.commit_data, }); var repo_block_reader = RepoBlockReader{ .allocator = allocator, .did = did, .connection = read_conn, }; try zat.mst.collectReachableBlocks( allocator, root.data_cid_raw, repo_block_reader.reader(), &blocks, .{ .include_records = true }, ); return .{ .root_cid_raw = root.commit_cid_raw, .blocks = try blocks.toOwnedSlice(allocator), }; } fn writeRepoCarData(allocator: std.mem.Allocator, data: RepoCarData) ![]const u8 { return zat.car.writeAlloc(allocator, .{ .roots = &.{.{ .raw = data.root_cid_raw }}, .blocks = data.blocks, }); } fn openRepoReadConnection(path: [:0]const u8) !zqlite.Conn { const read_conn = try zqlite.open(path.ptr, zqlite.OpenFlags.ReadOnly); errdefer read_conn.close(); try read_conn.busyTimeout(5000); try read_conn.execNoArgs("PRAGMA query_only=ON"); return read_conn; } pub fn writeRepoListJson(allocator: std.mem.Allocator, cursor: ?[]const u8, limit: usize) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const actual_limit = if (limit == 0) 500 else @min(limit, 1000); const parsed_cursor = try parseRepoListCursor(cursor); var rows = if (parsed_cursor) |after| try conn.rows( \\SELECT a.did, c.cid, c.rev, a.account_status, a.created_at \\FROM accounts a \\JOIN ( \\ SELECT did, MAX(seq) AS seq \\ FROM commits \\ GROUP BY did \\) latest ON latest.did = a.did \\JOIN commits c ON c.did = latest.did AND c.seq = latest.seq \\WHERE a.created_at > ? OR (a.created_at = ? AND a.did > ?) \\ORDER BY a.created_at ASC, a.did ASC \\LIMIT ? , .{ after.created_at, after.created_at, after.did, @as(i64, @intCast(actual_limit)) }) else try conn.rows( \\SELECT a.did, c.cid, c.rev, a.account_status, a.created_at \\FROM accounts a \\JOIN ( \\ SELECT did, MAX(seq) AS seq \\ FROM commits \\ GROUP BY did \\) latest ON latest.did = a.did \\JOIN commits c ON c.did = latest.did AND c.seq = latest.seq \\ORDER BY a.created_at ASC, a.did ASC \\LIMIT ? , .{@as(i64, @intCast(actual_limit))}); defer rows.deinit(); var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); var json: std.json.Stringify = .{ .writer = &out.writer }; try json.beginObject(); try json.objectField("repos"); try json.beginArray(); var last_created_at: ?i64 = null; var last_did: ?[]const u8 = null; while (rows.next()) |row| { const did = row.text(0); last_did = try allocator.dupe(u8, did); last_created_at = row.int(4); const status = AccountStatus.parse(row.text(3)); const active = status.isActive(); try json.beginObject(); try json.objectField("did"); try json.write(did); try json.objectField("head"); try json.write(row.text(1)); try json.objectField("rev"); try json.write(row.text(2)); try json.objectField("active"); try json.write(active); if (!active) { try json.objectField("status"); try json.write(status.asString()); } try json.endObject(); } if (rows.err) |err| return err; try json.endArray(); if (last_created_at) |created_at| { try json.objectField("cursor"); try json.write(try std.fmt.allocPrint(allocator, "{d}:{s}", .{ created_at, last_did.? })); } try json.endObject(); return out.toOwnedSlice(); } const RepoListCursor = struct { created_at: i64, did: []const u8, }; fn parseRepoListCursor(cursor: ?[]const u8) Error!?RepoListCursor { const raw = cursor orelse return null; const sep = std.mem.indexOfScalar(u8, raw, ':') orelse return Error.InvalidCursor; if (sep == 0 or sep + 1 >= raw.len) return Error.InvalidCursor; return .{ .created_at = std.fmt.parseInt(i64, raw[0..sep], 10) catch return Error.InvalidCursor, .did = raw[sep + 1 ..], }; } pub fn writeLatestCommitJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const root = try latestRootLocked(allocator, did); return std.fmt.allocPrint( allocator, "{{\"cid\":{f},\"rev\":{f}}}", .{ std.json.fmt(root.cid, .{}), std.json.fmt(root.rev, .{}) }, ); } pub fn listSeqEvents(allocator: std.mem.Allocator, cursor: u64, limit: usize) ![]SeqEvent { const path = database_path orelse return Error.StoreNotInitialized; if (std.mem.eql(u8, path, ":memory:")) { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return listSeqEventsFrom(conn, allocator, cursor, limit); } const read_conn = try openRepoReadConnection(path); defer read_conn.close(); return listSeqEventsFrom(read_conn, allocator, cursor, limit); } fn listSeqEventsFrom( read_conn: zqlite.Conn, allocator: std.mem.Allocator, cursor: u64, limit: usize, ) ![]SeqEvent { var rows = try read_conn.rows( \\SELECT seq, evt \\FROM seq_events \\WHERE seq > ? \\ORDER BY seq ASC \\LIMIT ? , .{ @as(i64, @intCast(cursor)), @as(i64, @intCast(@min(limit, 1000))) }); defer rows.deinit(); var events: std.ArrayList(SeqEvent) = .empty; while (rows.next()) |row| { try events.append(allocator, .{ .seq = @intCast(row.int(0)), .frame = try allocator.dupe(u8, row.nullableBlob(1) orelse ""), }); } if (rows.err) |err| return err; return events.toOwnedSlice(allocator); } fn insertSeqEventLocked(seq: u64, did: []const u8, commit_cid: []const u8, frame: []const u8) !void { try conn.exec( \\INSERT INTO seq_events (seq, did, commit_cid, evt) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(seq) DO UPDATE SET \\ did = excluded.did, \\ commit_cid = excluded.commit_cid, \\ evt = excluded.evt , .{ @as(i64, @intCast(seq)), did, commit_cid, zqlite.blob(frame) }); } pub fn writeRepoStatusJson(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const status = try accountStatusLocked(did); const active = status.isActive(); const root = if (active) try latestRootLocked(allocator, did) else null; var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); var json: std.json.Stringify = .{ .writer = &out.writer }; try json.beginObject(); try json.objectField("did"); try json.write(did); try json.objectField("active"); try json.write(active); if (!active) { try json.objectField("status"); try json.write(status.asString()); } else { try json.objectField("rev"); try json.write(root.?.rev); } try json.endObject(); return out.toOwnedSlice(); } pub fn writeRecordJson(allocator: std.mem.Allocator, record: Record) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); const uri = try record.uri(allocator); defer allocator.free(uri); try out.writer.print( "{{\"uri\":{f},\"cid\":{f},\"value\":{s}}}", .{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}), record.value_json }, ); return out.toOwnedSlice(); } pub fn writeListJson( allocator: std.mem.Allocator, did: []const u8, collection: []const u8, cursor: ?[]const u8, reverse: bool, limit: usize, ) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const page_limit = @as(i64, @intCast(if (limit == 0) 100 else limit)); var rows = if (cursor) |rkey_cursor| blk: { if (reverse) { break :blk try conn.rows( \\SELECT r.did, r.collection, r.rkey, r.cid, rb.data, r.rev, r.seq \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.did = ? AND r.collection = ? AND r.rkey > ? \\ORDER BY r.rkey ASC \\LIMIT ? , .{ did, collection, rkey_cursor, page_limit }); } break :blk try conn.rows( \\SELECT r.did, r.collection, r.rkey, r.cid, rb.data, r.rev, r.seq \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.did = ? AND r.collection = ? AND r.rkey < ? \\ORDER BY r.rkey DESC \\LIMIT ? , .{ did, collection, rkey_cursor, page_limit }); } else blk: { if (reverse) { break :blk try conn.rows( \\SELECT r.did, r.collection, r.rkey, r.cid, rb.data, r.rev, r.seq \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.did = ? AND r.collection = ? \\ORDER BY r.rkey ASC \\LIMIT ? , .{ did, collection, page_limit }); } break :blk try conn.rows( \\SELECT r.did, r.collection, r.rkey, r.cid, rb.data, r.rev, r.seq \\FROM records r \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE r.did = ? AND r.collection = ? \\ORDER BY r.rkey DESC \\LIMIT ? , .{ did, collection, page_limit }); }; defer rows.deinit(); var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.writeAll("{\"records\":["); var first = true; var last_rkey: ?[]const u8 = null; while (rows.next()) |row| { const record = try recordFromRow(row, std.heap.page_allocator); if (!first) try out.writer.writeByte(','); first = false; last_rkey = try allocator.dupe(u8, row.text(2)); const uri = try record.uri(allocator); defer allocator.free(uri); try out.writer.print( "{{\"uri\":{f},\"cid\":{f},\"value\":{s}}}", .{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}), record.value_json }, ); } if (rows.err) |err| return err; try out.writer.writeByte(']'); if (last_rkey) |rkey| { try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(rkey, .{})}); } try out.writer.writeByte('}'); return out.toOwnedSlice(); } pub fn generateRkey(allocator: std.mem.Allocator) ![]const u8 { return nextRkey(allocator); } pub fn prepareRecordValue( allocator: std.mem.Allocator, collection: []const u8, rkey: []const u8, value: std.json.Value, ) !PreparedRecord { if (zat.Nsid.parse(collection) == null) return Error.InvalidCollection; if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey; try validateRecordForWrite(collection, rkey, value, .known); const cbor_value = try jsonToDagCbor(allocator, value); const record_bytes = try zat.cbor.encodeAlloc(allocator, cbor_value); const record_cid = try zat.cbor.Cid.forDagCbor(allocator, record_bytes); var blob_cids: std.ArrayList([]const u8) = .empty; try collectBlobCids(allocator, cbor_value, &blob_cids); return .{ .cid = try cidText(allocator, record_cid.raw), .value_json = try stringifyValue(allocator, value), .validation_status = validationStatusForRecord(collection, .known), .blob_cids = try blob_cids.toOwnedSlice(allocator), }; } pub const CreateSpaceInput = struct { actor_did: []const u8, authority_did: []const u8, space_type: []const u8, skey: []const u8, is_authority: bool, read_managing_app: ?[]const u8, read_policy: []const u8, write_managing_app: ?[]const u8, write_policy: []const u8, app_access_json: []const u8, }; pub fn createSpace(allocator: std.mem.Allocator, input: CreateSpaceInput) !SpaceConfig { if (zat.Did.parse(input.actor_did) == null) return Error.InvalidRepoPath; if (zat.Did.parse(input.authority_did) == null) return Error.InvalidRepoPath; if (zat.Nsid.parse(input.space_type) == null) return Error.InvalidCollection; if (zat.Rkey.parse(input.skey) == null) return Error.InvalidRecordKey; if (!validSimpleSpacePolicy(input.read_policy) or !validSimpleSpacePolicy(input.write_policy)) return Error.InvalidRecordType; try validateAppAccessJson(input.app_access_json); const uri = try space_uri.SpaceUri.format(allocator, input.authority_did, input.space_type, input.skey); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); if (try getSpaceLocked(allocator, input.actor_did, uri) != null) return Error.InvalidRefreshSession; try conn.exclusiveTransaction(); errdefer conn.rollback(); try conn.exec( \\INSERT INTO permissioned_spaces ( \\ uri, authority_did, space_type, skey, read_managing_app, read_policy, write_managing_app, write_policy, app_access_json \\) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \\ON CONFLICT(uri) DO NOTHING , .{ uri, input.authority_did, input.space_type, input.skey, input.read_managing_app, input.read_policy, input.write_managing_app, input.write_policy, input.app_access_json, }); if (input.is_authority) { // Recreating a previously deleted space is allowed (per the lexicon), so // the authority's config is refreshed and the tombstone cleared. A live // space never reaches here: the guard above returns SpaceAlreadyExists. try conn.exec( \\UPDATE permissioned_spaces \\SET read_managing_app = ?, \\ read_policy = ?, \\ write_managing_app = ?, \\ write_policy = ?, \\ app_access_json = ?, \\ deleted_at = NULL \\WHERE uri = ? , .{ input.read_managing_app, input.read_policy, input.write_managing_app, input.write_policy, input.app_access_json, uri, }); } try conn.exec( \\INSERT INTO permissioned_space_repos (space, repo_did, set_hash, rev) \\VALUES (?, ?, NULL, NULL) \\ON CONFLICT(space, repo_did) DO NOTHING , .{ uri, input.authority_did }); // Upsert, not plain insert: a recreate lands on the actor row left behind by // the prior delete, which must be reactivated rather than collide. try conn.exec( \\INSERT INTO permissioned_space_actor_state (space, actor_did, is_authority) \\VALUES (?, ?, ?) \\ON CONFLICT(space, actor_did) DO UPDATE SET \\ is_authority = excluded.is_authority, \\ deleted_at = NULL , .{ uri, input.actor_did, @as(i64, if (input.is_authority) 1 else 0) }); if (input.is_authority) { try conn.exec( \\INSERT INTO simplespace_members (space, member_did) \\VALUES (?, ?) \\ON CONFLICT(space, member_did) DO NOTHING , .{ uri, input.authority_did }); } try conn.commit(); return (try getSpaceLocked(allocator, input.actor_did, uri)) orelse Error.RepoNotFound; } pub fn getSpace(allocator: std.mem.Allocator, actor_did: []const u8, uri: []const u8) !?SpaceConfig { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return getSpaceLocked(allocator, actor_did, uri); } pub fn listSpaces( allocator: std.mem.Allocator, actor_did: []const u8, maybe_did: ?[]const u8, maybe_type: ?[]const u8, maybe_cursor: ?[]const u8, limit: usize, ) ![]SpaceConfig { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const capped_limit: i64 = @intCast(@min(if (limit == 0) 50 else limit, 100)); var query: std.Io.Writer.Allocating = .init(allocator); defer query.deinit(); try query.writer.writeAll( \\SELECT s.uri, a.is_authority \\FROM permissioned_spaces s \\JOIN permissioned_space_actor_state a ON a.space = s.uri \\WHERE a.actor_did = ? AND a.deleted_at IS NULL AND s.deleted_at IS NULL ); if (maybe_did != null) try query.writer.writeAll(" AND s.authority_did = ?"); if (maybe_type != null) try query.writer.writeAll(" AND s.space_type = ?"); if (maybe_cursor != null) try query.writer.writeAll(" AND s.uri > ?"); try query.writer.writeAll(" ORDER BY s.uri ASC LIMIT ?"); var rows = if (maybe_did) |did| if (maybe_type) |space_type| if (maybe_cursor) |cursor| try conn.rows(query.written(), .{ actor_did, did, space_type, cursor, capped_limit }) else try conn.rows(query.written(), .{ actor_did, did, space_type, capped_limit }) else if (maybe_cursor) |cursor| try conn.rows(query.written(), .{ actor_did, did, cursor, capped_limit }) else try conn.rows(query.written(), .{ actor_did, did, capped_limit }) else if (maybe_type) |space_type| if (maybe_cursor) |cursor| try conn.rows(query.written(), .{ actor_did, space_type, cursor, capped_limit }) else try conn.rows(query.written(), .{ actor_did, space_type, capped_limit }) else if (maybe_cursor) |cursor| try conn.rows(query.written(), .{ actor_did, cursor, capped_limit }) else try conn.rows(query.written(), .{ actor_did, capped_limit }); defer rows.deinit(); var spaces: std.ArrayList(SpaceConfig) = .empty; while (rows.next()) |row| { var space = (try getSpaceConfigLocked(allocator, row.text(0))) orelse continue; space.is_authority = row.int(1) != 0; try spaces.append(allocator, space); } if (rows.err) |err| return err; return spaces.toOwnedSlice(allocator); } pub const SpaceAccess = enum { read, write }; pub fn updateSimpleSpaceConfig( space: []const u8, read_policy: ?SimpleSpacePolicy, write_policy: ?SimpleSpacePolicy, maybe_app_access_json: ?[]const u8, ) !void { if (read_policy) |policy| if (!validSimpleSpacePolicy(policy.policy)) return Error.InvalidRecordType; if (write_policy) |policy| if (!validSimpleSpacePolicy(policy.policy)) return Error.InvalidRecordType; if (maybe_app_access_json) |app_access| try validateAppAccessJson(app_access); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; try conn.exclusiveTransaction(); errdefer conn.rollback(); if (read_policy) |policy| { try conn.exec("UPDATE permissioned_spaces SET read_policy = ?, read_managing_app = ? WHERE uri = ?", .{ policy.policy, policy.managing_app, space }); } if (write_policy) |policy| { try conn.exec("UPDATE permissioned_spaces SET write_policy = ?, write_managing_app = ? WHERE uri = ?", .{ policy.policy, policy.managing_app, space }); } if (maybe_app_access_json) |app_access| { try conn.exec("UPDATE permissioned_spaces SET app_access_json = ? WHERE uri = ?", .{ app_access, space }); } try conn.commit(); } pub fn putSimpleSpaceMember(space: []const u8, did: []const u8, read: bool, write: bool) !void { if (zat.Did.parse(did) == null) return Error.InvalidRepoPath; db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; try conn.exec( \\INSERT INTO simplespace_members (space, member_did, read, write) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(space, member_did) DO UPDATE SET read = excluded.read, write = excluded.write , .{ space, did, @as(i64, @intFromBool(read)), @as(i64, @intFromBool(write)) }); } pub fn removeSimpleSpaceMember(space: []const u8, did: []const u8) !void { if (zat.Did.parse(did) == null) return Error.InvalidRepoPath; db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const config = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; if (std.mem.eql(u8, config.authority_did, did)) return Error.InvalidRecordType; try conn.exec("DELETE FROM simplespace_members WHERE space = ? AND member_did = ?", .{ space, did }); } pub fn listSimpleSpaceMembers( allocator: std.mem.Allocator, space: []const u8, maybe_cursor: ?[]const u8, limit: usize, ) ![]SimpleSpaceMember { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); var rows = if (maybe_cursor) |cursor| try conn.rows( \\SELECT member_did, created_at, read, write \\FROM simplespace_members \\WHERE space = ? AND member_did > ? \\ORDER BY member_did ASC \\LIMIT ? , .{ space, cursor, capped_limit }) else try conn.rows( \\SELECT member_did, created_at, read, write \\FROM simplespace_members \\WHERE space = ? \\ORDER BY member_did ASC \\LIMIT ? , .{ space, capped_limit }); defer rows.deinit(); var members: std.ArrayList(SimpleSpaceMember) = .empty; while (rows.next()) |row| { try members.append(allocator, .{ .did = try allocator.dupe(u8, row.text(0)), .created_at = row.int(1), .read = row.int(2) != 0, .write = row.int(3) != 0, }); } if (rows.err) |err| return err; return members.toOwnedSlice(allocator); } pub fn simpleSpaceMemberAllows(space: []const u8, did: []const u8, access: SpaceAccess) !bool { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row( "SELECT read, write FROM simplespace_members WHERE space = ? AND member_did = ?", .{ space, did }, ); if (row == null) return false; defer row.?.deinit(); return row.?.int(if (access == .read) 0 else 1) != 0; } pub fn listSpaceWriters(allocator: std.mem.Allocator, space: []const u8, maybe_cursor: ?[]const u8, limit: usize) ![]SpaceWriterState { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const authority = try conn.row( \\SELECT 1 \\FROM permissioned_spaces s \\JOIN permissioned_space_actor_state a \\ ON a.space = s.uri AND a.actor_did = s.authority_did \\WHERE s.uri = ? AND s.deleted_at IS NULL AND a.deleted_at IS NULL AND a.is_authority = 1 , .{space}); if (authority == null) return Error.RepoNotFound; authority.?.deinit(); const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); var rows = if (maybe_cursor) |cursor| try conn.rows( \\SELECT repo_did, hash, rev, space_rev \\FROM permissioned_space_writers \\WHERE space = ? AND space_rev > ? \\ORDER BY space_rev ASC \\LIMIT ? , .{ space, cursor, capped_limit }) else try conn.rows( \\SELECT repo_did, hash, rev, space_rev \\FROM permissioned_space_writers \\WHERE space = ? \\ORDER BY space_rev ASC \\LIMIT ? , .{ space, capped_limit }); defer rows.deinit(); var repos: std.ArrayList(SpaceWriterState) = .empty; while (rows.next()) |row| { try repos.append(allocator, .{ .repo_did = try allocator.dupe(u8, row.text(0)), .hash = try allocator.dupe(u8, row.blob(1)), .rev = try allocator.dupe(u8, row.text(2)), .space_rev = try allocator.dupe(u8, row.text(3)), }); } if (rows.err) |err| return err; return repos.toOwnedSlice(allocator); } pub fn revokeSpaceCredentials(space: []const u8, ids: []const []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.transaction(); errdefer conn.rollback(); try conn.execNoArgs("DELETE FROM permissioned_space_revocations WHERE expires_at <= unixepoch()"); for (ids) |id| try conn.exec("INSERT INTO permissioned_space_revocations(space, jti, expires_at) VALUES (?, ?, unixepoch() + 3610) ON CONFLICT(space, jti) DO UPDATE SET expires_at = excluded.expires_at", .{ space, id }); try conn.commit(); } pub fn spaceCredentialRevoked(space: []const u8, jti: []const u8) !bool { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const row = try conn.row("SELECT 1 FROM permissioned_space_revocations WHERE space = ? AND jti = ? AND expires_at > unixepoch()", .{ space, jti }); if (row) |r| { r.deinit(); return true; } return false; } pub const SpaceUpdate = struct { space_rev: []const u8, prev_space_rev: ?[]const u8, }; pub fn recordSpaceWriter(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, rev: []const u8, hash: []const u8) !?SpaceUpdate { if (hash.len != permissioned.commit_hash_bytes) return Error.InvalidRecordType; const timestamp = atid.timestampMicros(rev) catch return Error.InvalidRecordType; if (timestamp > nowMicros() + 300 * std.time.us_per_s) return error.FutureRev; db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.transaction(); errdefer conn.rollback(); const space_row = try conn.row("SELECT 1 FROM permissioned_spaces WHERE uri = ? AND deleted_at IS NULL", .{space}); if (space_row) |row| row.deinit() else return Error.RepoNotFound; const existing = try conn.row("SELECT rev FROM permissioned_space_writers WHERE space = ? AND repo_did = ?", .{ space, repo_did }); if (existing) |row| { const stale = std.mem.order(u8, rev, row.text(0)) != .gt; row.deinit(); if (stale) { try conn.commit(); return null; } } const previous = try conn.row("SELECT rev FROM permissioned_space_sequence WHERE space = ?", .{space}); const prev: ?[]const u8 = if (previous) |row| blk: { defer row.deinit(); break :blk try allocator.dupe(u8, row.text(0)); } else null; const next = try revForSeq(allocator, 0, prev); try conn.exec("INSERT INTO permissioned_space_sequence(space, rev) VALUES (?, ?) ON CONFLICT(space) DO UPDATE SET rev = excluded.rev", .{ space, next }); try conn.exec("INSERT INTO permissioned_space_writers (space, repo_did, rev, hash, space_rev) VALUES (?, ?, ?, ?, ?) ON CONFLICT(space, repo_did) DO UPDATE SET rev = excluded.rev, hash = excluded.hash, space_rev = excluded.space_rev, updated_at = unixepoch()", .{ space, repo_did, rev, zqlite.blob(hash), next }); try conn.commit(); return .{ .space_rev = next, .prev_space_rev = prev }; } pub fn markSpaceDeleted(actor_did: []const u8, space: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec("UPDATE permissioned_space_actor_state SET deleted_at = unixepoch() WHERE space = ? AND actor_did = ?", .{ space, actor_did }); try conn.exec("UPDATE permissioned_spaces SET deleted_at = unixepoch() WHERE uri = ? AND authority_did = ?", .{ space, actor_did }); } // Deletion purges everything the authority hosts for the space — its own // records/oplog/repo state and blob links, the member list, the writer // registry, and notify registrations — while the tombstoned space row keeps // answering SpaceDeleted. Members' records live in their own writer repos and // are theirs to keep (proposal: deletion does not entitle the authority to // delete all data in it); the reference PDS purges the same set. A later // recreate of the type+skey therefore starts genuinely empty. pub fn purgeAuthoritySpaceData(space: []const u8, authority_did: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exclusiveTransaction(); errdefer conn.rollback(); try conn.exec("DELETE FROM permissioned_space_record_blobs WHERE space = ? AND repo_did = ?", .{ space, authority_did }); try conn.exec("DELETE FROM permissioned_space_records WHERE space = ? AND repo_did = ?", .{ space, authority_did }); try conn.exec("DELETE FROM permissioned_space_record_oplog WHERE space = ? AND repo_did = ?", .{ space, authority_did }); try conn.exec("DELETE FROM permissioned_space_repos WHERE space = ? AND repo_did = ?", .{ space, authority_did }); try conn.exec("DELETE FROM simplespace_members WHERE space = ?", .{space}); try conn.exec("DELETE FROM permissioned_space_writers WHERE space = ?", .{space}); try conn.exec("DELETE FROM permissioned_space_notify_registrations WHERE space = ?", .{space}); try conn.exec("DELETE FROM permissioned_space_notify_outbox WHERE space = ?", .{space}); try conn.commit(); } pub fn registerSpaceNotification(space: []const u8, service_id: []const u8, service_endpoint: []const u8, expires_at: i64) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\INSERT INTO permissioned_space_notify_registrations (space, service_id, service_endpoint, expires_at) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(space, service_id) DO UPDATE SET \\ service_endpoint = excluded.service_endpoint, \\ expires_at = excluded.expires_at , .{ space, service_id, service_endpoint, expires_at }); } pub fn unregisterSpaceNotification(space: []const u8, service_id: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( "DELETE FROM permissioned_space_notify_registrations WHERE space = ? AND service_id = ?", .{ space, service_id }, ); } pub fn listNotificationRecipients(allocator: std.mem.Allocator, space: []const u8) ![]CredentialRecipient { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.execNoArgs("DELETE FROM permissioned_space_notify_registrations WHERE expires_at <= unixepoch()"); var rows = try conn.rows( \\SELECT service_id, service_endpoint, expires_at \\FROM permissioned_space_notify_registrations \\WHERE space = ? \\ORDER BY service_id ASC , .{space}); defer rows.deinit(); var out: std.ArrayList(CredentialRecipient) = .empty; while (rows.next()) |row| { try out.append(allocator, .{ .service_id = try allocator.dupe(u8, row.text(0)), .service_endpoint = try allocator.dupe(u8, row.text(1)), .expires_at = row.int(2), }); } if (rows.err) |err| return err; return out.toOwnedSlice(allocator); } pub fn loadSpaceRepoBlocks(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8) ![]permissioned.RepoRecordBlock { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); var rows = try conn.rows( \\SELECT collection, rkey, cid, value_json \\FROM permissioned_space_records \\WHERE space = ? AND repo_did = ? \\ORDER BY collection ASC, rkey ASC , .{ space, repo_did }); defer rows.deinit(); var blocks: std.ArrayList(permissioned.RepoRecordBlock) = .empty; var scratch = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer scratch.deinit(); while (rows.next()) |row| { _ = scratch.reset(.retain_capacity); const scratch_allocator = scratch.allocator(); const parsed = try std.json.parseFromSlice(std.json.Value, scratch_allocator, row.blob(3), .{}); const value = try jsonToDagCbor(scratch_allocator, parsed.value); const data = try zat.cbor.encodeAlloc(allocator, value); const cid_text = row.text(2); if (cid_text.len == 0 or cid_text[0] != 'b') return Error.InvalidDagCbor; try blocks.append(allocator, .{ .path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ row.text(0), row.text(1) }), .cid = .{ .raw = try zat.multibase.base32lower.decode(allocator, cid_text[1..]) }, .data = data, }); } if (rows.err) |err| return err; return blocks.toOwnedSlice(allocator); } pub const SpaceReplayToken = struct { jti: []const u8, expires_at: i64, }; pub fn consumeSpaceCredentialExchange(delegation: SpaceReplayToken, attestation: ?SpaceReplayToken) !bool { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exclusiveTransaction(); errdefer conn.rollback(); try conn.execNoArgs("DELETE FROM permissioned_space_used_delegations WHERE expires_at < unixepoch()"); try conn.execNoArgs("DELETE FROM permissioned_space_used_client_attestations WHERE expires_at < unixepoch()"); conn.exec( \\INSERT INTO permissioned_space_used_delegations (jti, expires_at) \\VALUES (?, ?) , .{ delegation.jti, delegation.expires_at }) catch |err| switch (err) { error.Constraint => { conn.rollback(); return false; }, else => return err, }; if (attestation) |token| { conn.exec( \\INSERT INTO permissioned_space_used_client_attestations (jti, expires_at) \\VALUES (?, ?) , .{ token.jti, token.expires_at }) catch |err| switch (err) { error.Constraint => { conn.rollback(); return false; }, else => return err, }; } try conn.commit(); return true; } pub fn putSpaceRecord( allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, prepared: PreparedRecord, ) !SpaceRecord { const results = try applySpaceWrites(allocator, space, repo_did, &.{.{ .put = .{ .collection = collection, .rkey = rkey, .prepared = prepared, } }}); return switch (results[0]) { .create => |record| record, .update => |record| record, .delete => Error.MissingRecord, }; } pub fn createSpaceRecord( allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, prepared: PreparedRecord, ) !SpaceRecord { const results = try applySpaceWrites(allocator, space, repo_did, &.{.{ .create = .{ .collection = collection, .rkey = rkey, .prepared = prepared, } }}); return switch (results[0]) { .create => |record| record, .update => |record| record, .delete => Error.MissingRecord, }; } pub fn deleteSpaceRecord( allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, ) !void { _ = try applySpaceWrites(allocator, space, repo_did, &.{.{ .delete = .{ .collection = collection, .rkey = rkey } }}); } pub fn getSpaceRecord( allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, ) !?SpaceRecord { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return getSpaceRecordLocked(allocator, space, repo_did, collection, rkey); } pub fn listSpaceRecords( allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, maybe_collection: ?[]const u8, maybe_cursor: ?[]const u8, reverse: bool, limit: usize, include_values: bool, ) ![]SpaceRecordRef { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const capped_limit: i64 = @intCast(@min(if (limit == 0) 50 else limit, 100)); const op = if (reverse) ">" else "<"; const direction = if (reverse) "ASC" else "DESC"; const cursor = maybe_cursor orelse ""; const parsed_cursor = parseSpaceRecordCursor(cursor); const cursor_collection = parsed_cursor.collection; const cursor_rkey = parsed_cursor.rkey; var query = std.Io.Writer.Allocating.init(allocator); defer query.deinit(); try query.writer.print( "SELECT collection, rkey, cid, {s} FROM permissioned_space_records WHERE space = ? AND repo_did = ?", .{if (include_values) "value_json" else "NULL"}, ); if (maybe_collection != null) try query.writer.writeAll(" AND collection = ?"); if (maybe_cursor != null and cursor_collection.len > 0 and cursor_rkey.len > 0) { try query.writer.print(" AND (collection, rkey) {s} (?, ?)", .{op}); } try query.writer.print(" ORDER BY collection {s}, rkey {s} LIMIT ?", .{ direction, direction }); var rows = if (maybe_collection) |collection| if (maybe_cursor != null and cursor_collection.len > 0 and cursor_rkey.len > 0) try conn.rows(query.written(), .{ space, repo_did, collection, cursor_collection, cursor_rkey, capped_limit }) else try conn.rows(query.written(), .{ space, repo_did, collection, capped_limit }) else if (maybe_cursor != null and cursor_collection.len > 0 and cursor_rkey.len > 0) try conn.rows(query.written(), .{ space, repo_did, cursor_collection, cursor_rkey, capped_limit }) else try conn.rows(query.written(), .{ space, repo_did, capped_limit }); defer rows.deinit(); var records: std.ArrayList(SpaceRecordRef) = .empty; while (rows.next()) |row| { try records.append(allocator, .{ .collection = try allocator.dupe(u8, row.text(0)), .rkey = try allocator.dupe(u8, row.text(1)), .cid = try allocator.dupe(u8, row.text(2)), .value_json = if (row.nullableText(3)) |value| try allocator.dupe(u8, value) else null, }); } if (rows.err) |err| return err; return records.toOwnedSlice(allocator); } pub fn applySpaceWrites( allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, ops: []const SpaceWriteOp, ) ![]SpaceWriteResult { if (zat.Did.parse(repo_did) == null) return Error.InvalidRepoPath; for (ops) |op| switch (op) { inline .create, .put, .update => |write| { if (zat.Nsid.parse(write.collection) == null) return Error.InvalidCollection; if (zat.Rkey.parse(write.rkey) == null) return Error.InvalidRecordKey; }, .delete => |write| { if (zat.Nsid.parse(write.collection) == null) return Error.InvalidCollection; if (zat.Rkey.parse(write.rkey) == null) return Error.InvalidRecordKey; }, }; const lane = write_lanes.lock(store_io, repo_did); defer lane.unlock(); var results: std.ArrayList(SpaceWriteResult) = .empty; db_mutex.lockUncancelable(store_io); var db_locked = true; defer if (db_locked) db_mutex.unlock(store_io); try requireInitialized(); _ = (try getSpaceConfigLocked(allocator, space)) orelse return Error.RepoNotFound; try conn.exclusiveTransaction(); errdefer conn.rollback(); const state = try getRepoStateLocked(allocator, space, repo_did); var set_hash = if (state.set_hash) |bytes| try permissioned.LtHash.fromBytes(bytes) else permissioned.LtHash{}; const rev = try revForSeq(allocator, try nextSeqLocked(), state.rev); var idx: i64 = 0; for (ops) |op| { switch (op) { .create => |write| { if (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey) != null) return Error.InvalidRefreshSession; try addRecordElement(allocator, &set_hash, write.collection, write.rkey, write.prepared.cid); try upsertSpaceRecordLocked(space, repo_did, write.collection, write.rkey, write.prepared, rev); try syncSpaceRecordBlobRefsLocked(space, repo_did, write.collection, write.rkey, write.prepared.blob_cids); try insertRecordOplogLocked(space, repo_did, rev, idx, "create", write.collection, write.rkey, write.prepared.cid, null); try results.append(allocator, .{ .create = (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey)) orelse return Error.MissingRecord }); }, .put => |write| { const existing = try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey); if (existing) |old| try removeRecordElement(allocator, &set_hash, old.collection, old.rkey, old.cid); try addRecordElement(allocator, &set_hash, write.collection, write.rkey, write.prepared.cid); try upsertSpaceRecordLocked(space, repo_did, write.collection, write.rkey, write.prepared, rev); try syncSpaceRecordBlobRefsLocked(space, repo_did, write.collection, write.rkey, write.prepared.blob_cids); const action = if (existing == null) "create" else "update"; try insertRecordOplogLocked(space, repo_did, rev, idx, action, write.collection, write.rkey, write.prepared.cid, if (existing) |old| old.cid else null); try results.append(allocator, .{ .update = (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey)) orelse return Error.MissingRecord }); }, .update => |write| { const existing = (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey)) orelse return Error.MissingRecord; try removeRecordElement(allocator, &set_hash, existing.collection, existing.rkey, existing.cid); try addRecordElement(allocator, &set_hash, write.collection, write.rkey, write.prepared.cid); try upsertSpaceRecordLocked(space, repo_did, write.collection, write.rkey, write.prepared, rev); try syncSpaceRecordBlobRefsLocked(space, repo_did, write.collection, write.rkey, write.prepared.blob_cids); try insertRecordOplogLocked(space, repo_did, rev, idx, "update", write.collection, write.rkey, write.prepared.cid, existing.cid); try results.append(allocator, .{ .update = (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey)) orelse return Error.MissingRecord }); }, .delete => |write| { const existing = (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey)) orelse return Error.MissingRecord; try removeRecordElement(allocator, &set_hash, existing.collection, existing.rkey, existing.cid); try deleteSpaceRecordBlobRefsLocked(space, repo_did, write.collection, write.rkey); try conn.exec( \\DELETE FROM permissioned_space_records \\WHERE space = ? AND repo_did = ? AND collection = ? AND rkey = ? , .{ space, repo_did, write.collection, write.rkey }); try insertRecordOplogLocked(space, repo_did, rev, idx, "delete", write.collection, write.rkey, null, existing.cid); try results.append(allocator, .{ .delete = {} }); }, } idx += 1; } try conn.exec( \\INSERT INTO permissioned_space_repos (space, repo_did, set_hash, rev) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(space, repo_did) DO UPDATE SET \\ set_hash = excluded.set_hash, \\ rev = excluded.rev , .{ space, repo_did, zqlite.blob(&set_hash.bytes), rev }); try conn.exec( \\INSERT INTO permissioned_space_actor_state (space, actor_did, is_authority) \\VALUES (?, ?, 0) \\ON CONFLICT(space, actor_did) DO NOTHING , .{ space, repo_did }); const digest = set_hash.digest(); try conn.exec("INSERT INTO permissioned_space_notify_outbox(space, repo_did, rev, hash, expires_at) VALUES (?, ?, ?, ?, unixepoch() + 86400) ON CONFLICT(space, repo_did) DO UPDATE SET rev = excluded.rev, hash = excluded.hash, attempts = 0, retry_at = 0, lease_until = 0, expires_at = excluded.expires_at", .{ space, repo_did, rev, zqlite.blob(&digest) }); try conn.commit(); db_mutex.unlock(store_io); db_locked = false; return results.toOwnedSlice(allocator); } pub fn getSpaceRepoState(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8) !SpaceState { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return getRepoStateLocked(allocator, space, repo_did); } pub fn listSpaceRecordOplog(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, since: ?[]const u8, cursor: ?OplogCursor, limit: usize, include_values: bool) ![]SpaceRecordOplogEntry { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); var rows = if (cursor) |position| try conn.rows( \\SELECT o.rev, o.idx, o.action, o.repo_did, o.collection, o.rkey, o.cid, o.prev, \\ CASE WHEN ? AND r.cid = o.cid THEN r.value_json END \\FROM permissioned_space_record_oplog o \\LEFT JOIN permissioned_space_records r \\ ON r.space = o.space AND r.repo_did = o.repo_did \\ AND r.collection = o.collection AND r.rkey = o.rkey \\WHERE o.space = ? AND o.repo_did = ? AND (o.rev > ? OR (o.rev = ? AND o.idx > ?)) \\ORDER BY o.rev ASC, o.idx ASC \\LIMIT ? , .{ include_values, space, repo_did, position.rev, position.rev, position.idx, capped_limit }) else if (since) |rev| try conn.rows( \\SELECT o.rev, o.idx, o.action, o.repo_did, o.collection, o.rkey, o.cid, o.prev, \\ CASE WHEN ? AND r.cid = o.cid THEN r.value_json END \\FROM permissioned_space_record_oplog o \\LEFT JOIN permissioned_space_records r \\ ON r.space = o.space AND r.repo_did = o.repo_did \\ AND r.collection = o.collection AND r.rkey = o.rkey \\WHERE o.space = ? AND o.repo_did = ? AND o.rev > ? \\ORDER BY o.rev ASC, o.idx ASC \\LIMIT ? , .{ include_values, space, repo_did, rev, capped_limit }) else try conn.rows( \\SELECT o.rev, o.idx, o.action, o.repo_did, o.collection, o.rkey, o.cid, o.prev, \\ CASE WHEN ? AND r.cid = o.cid THEN r.value_json END \\FROM permissioned_space_record_oplog o \\LEFT JOIN permissioned_space_records r \\ ON r.space = o.space AND r.repo_did = o.repo_did \\ AND r.collection = o.collection AND r.rkey = o.rkey \\WHERE o.space = ? AND o.repo_did = ? \\ORDER BY o.rev ASC, o.idx ASC \\LIMIT ? , .{ include_values, space, repo_did, capped_limit }); defer rows.deinit(); var out: std.ArrayList(SpaceRecordOplogEntry) = .empty; while (rows.next()) |row| { try out.append(allocator, .{ .rev = try allocator.dupe(u8, row.text(0)), .idx = row.int(1), .action = try allocator.dupe(u8, row.text(2)), .repo_did = try allocator.dupe(u8, row.text(3)), .collection = try allocator.dupe(u8, row.text(4)), .rkey = try allocator.dupe(u8, row.text(5)), .cid = if (row.nullableText(6)) |cid| try allocator.dupe(u8, cid) else null, .prev = if (row.nullableText(7)) |prev| try allocator.dupe(u8, prev) else null, .value_json = if (row.nullableBlob(8)) |value| try allocator.dupe(u8, value) else null, }); } if (rows.err) |err| return err; return out.toOwnedSlice(allocator); } fn getRepoStateLocked(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8) !SpaceState { const row = try conn.row( \\SELECT set_hash, rev \\FROM permissioned_space_repos \\WHERE space = ? AND repo_did = ? , .{ space, repo_did }); if (row == null) return .{ .set_hash = null, .rev = null }; defer row.?.deinit(); return .{ .set_hash = if (row.?.nullableBlob(0)) |bytes| try allocator.dupe(u8, bytes) else null, .rev = if (row.?.nullableText(1)) |rev| try allocator.dupe(u8, rev) else null, }; } fn addRecordElement(allocator: std.mem.Allocator, hash: *permissioned.LtHash, collection: []const u8, rkey: []const u8, cid: []const u8) !void { const element = try permissioned.recordElement(allocator, collection, rkey, cid); hash.add(element); } fn removeRecordElement(allocator: std.mem.Allocator, hash: *permissioned.LtHash, collection: []const u8, rkey: []const u8, cid: []const u8) !void { const element = try permissioned.recordElement(allocator, collection, rkey, cid); hash.remove(element); } fn upsertSpaceRecordLocked( space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, prepared: PreparedRecord, rev: []const u8, ) !void { try conn.exec( \\INSERT INTO permissioned_space_records (space, repo_did, collection, rkey, cid, value_json, validation_status, repo_rev, updated_at) \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, unixepoch()) \\ON CONFLICT(space, repo_did, collection, rkey) DO UPDATE SET \\ cid = excluded.cid, \\ value_json = excluded.value_json, \\ validation_status = excluded.validation_status, \\ repo_rev = excluded.repo_rev, \\ updated_at = excluded.updated_at , .{ space, repo_did, collection, rkey, prepared.cid, prepared.value_json, prepared.validation_status, rev }); } fn syncSpaceRecordBlobRefsLocked( space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, blob_cids: []const []const u8, ) !void { try deleteSpaceRecordBlobRefsLocked(space, repo_did, collection, rkey); for (blob_cids) |blob_cid| { try conn.exec( \\INSERT INTO permissioned_space_record_blobs (space, repo_did, collection, rkey, blob_cid) \\VALUES (?, ?, ?, ?, ?) \\ON CONFLICT(space, repo_did, collection, rkey, blob_cid) DO UPDATE SET \\ blob_cid = excluded.blob_cid , .{ space, repo_did, collection, rkey, blob_cid }); } } fn deleteSpaceRecordBlobRefsLocked( space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, ) !void { try conn.exec( \\DELETE FROM permissioned_space_record_blobs \\WHERE space = ? AND repo_did = ? AND collection = ? AND rkey = ? , .{ space, repo_did, collection, rkey }); } fn insertRecordOplogLocked( space: []const u8, repo_did: []const u8, rev: []const u8, idx: i64, action: []const u8, collection: []const u8, rkey: []const u8, cid: ?[]const u8, prev: ?[]const u8, ) !void { try conn.exec( \\INSERT INTO permissioned_space_record_oplog (space, repo_did, rev, idx, action, collection, rkey, cid, prev) \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) , .{ space, repo_did, rev, idx, action, collection, rkey, cid, prev }); } fn getSpaceConfigLocked(allocator: std.mem.Allocator, uri: []const u8) !?SpaceConfig { const row = try conn.row( \\SELECT s.uri, s.authority_did, s.space_type, s.skey, s.read_managing_app, s.read_policy, s.app_access_json, \\ EXISTS ( \\ SELECT 1 FROM permissioned_space_actor_state a \\ WHERE a.space = s.uri AND a.actor_did = s.authority_did \\ AND a.is_authority = 1 AND a.deleted_at IS NULL \\ ), s.write_managing_app, s.write_policy \\FROM permissioned_spaces s \\WHERE s.uri = ? AND s.deleted_at IS NULL , .{uri}); if (row == null) return null; defer row.?.deinit(); return .{ .uri = try allocator.dupe(u8, row.?.text(0)), .authority_did = try allocator.dupe(u8, row.?.text(1)), .space_type = try allocator.dupe(u8, row.?.text(2)), .skey = try allocator.dupe(u8, row.?.text(3)), .read_managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, .read_policy = try allocator.dupe(u8, row.?.text(5)), .app_access_json = try allocator.dupe(u8, row.?.text(6)), .is_authority = row.?.int(7) != 0, .write_managing_app = if (row.?.nullableText(8)) |value| try allocator.dupe(u8, value) else null, .write_policy = try allocator.dupe(u8, row.?.text(9)), .deleted_at = null, }; } fn getSpaceLocked(allocator: std.mem.Allocator, actor_did: []const u8, uri: []const u8) !?SpaceConfig { const row = try conn.row( \\SELECT s.uri, s.authority_did, s.space_type, s.skey, s.read_managing_app, s.read_policy, s.app_access_json, \\ a.is_authority, a.deleted_at, s.write_managing_app, s.write_policy \\FROM permissioned_spaces s \\JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? \\WHERE s.uri = ? AND s.deleted_at IS NULL AND a.deleted_at IS NULL , .{ actor_did, uri }); if (row == null) return null; defer row.?.deinit(); return .{ .uri = try allocator.dupe(u8, row.?.text(0)), .authority_did = try allocator.dupe(u8, row.?.text(1)), .space_type = try allocator.dupe(u8, row.?.text(2)), .skey = try allocator.dupe(u8, row.?.text(3)), .read_managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, .read_policy = try allocator.dupe(u8, row.?.text(5)), .app_access_json = try allocator.dupe(u8, row.?.text(6)), .is_authority = row.?.int(7) != 0, .deleted_at = if (row.?.nullableInt(8)) |value| value else null, .write_managing_app = if (row.?.nullableText(9)) |value| try allocator.dupe(u8, value) else null, .write_policy = try allocator.dupe(u8, row.?.text(10)), }; } fn getSpaceRecordLocked( allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, ) !?SpaceRecord { const row = try conn.row( \\SELECT space, repo_did, collection, rkey, cid, value_json, validation_status, repo_rev, updated_at \\FROM permissioned_space_records \\WHERE space = ? AND repo_did = ? AND collection = ? AND rkey = ? , .{ space, repo_did, collection, rkey }); if (row == null) return null; defer row.?.deinit(); return .{ .space = try allocator.dupe(u8, row.?.text(0)), .repo_did = try allocator.dupe(u8, row.?.text(1)), .collection = try allocator.dupe(u8, row.?.text(2)), .rkey = try allocator.dupe(u8, row.?.text(3)), .cid = try allocator.dupe(u8, row.?.text(4)), .value_json = try allocator.dupe(u8, row.?.text(5)), .validation_status = try allocator.dupe(u8, row.?.text(6)), .repo_rev = try allocator.dupe(u8, row.?.text(7)), .updated_at = row.?.int(8), }; } fn parseSpaceRecordCursor(cursor: []const u8) struct { collection: []const u8, rkey: []const u8 } { const slash = std.mem.indexOfScalar(u8, cursor, '/') orelse return .{ .collection = "", .rkey = "" }; if (slash == 0 or slash + 1 >= cursor.len) return .{ .collection = "", .rkey = "" }; return .{ .collection = cursor[0..slash], .rkey = cursor[slash + 1 ..] }; } fn validSimpleSpacePolicy(policy: []const u8) bool { return std.mem.eql(u8, policy, "member-list") or std.mem.eql(u8, policy, "public") or std.mem.eql(u8, policy, "managing-app"); } fn validateAppAccessJson(raw: []const u8) !void { var parsed = try std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, raw, .{}); defer parsed.deinit(); const object = switch (parsed.value) { .object => |object| object, else => return Error.InvalidRecordType, }; const kind = switch (object.get("type") orelse return Error.InvalidRecordType) { .string => |value| value, else => return Error.InvalidRecordType, }; if (std.mem.eql(u8, kind, "open")) return; if (!std.mem.eql(u8, kind, "allowList")) return Error.InvalidRecordType; const allowed = object.get("allowed") orelse return Error.InvalidRecordType; if (allowed != .array) return Error.InvalidRecordType; for (allowed.array.items) |item| { if (item != .string) return Error.InvalidRecordType; } } fn spaceWriterSequenceColumnExists() !bool { var rows = try conn.rows("PRAGMA table_info(permissioned_space_writers)", .{}); defer rows.deinit(); while (rows.next()) |row| { if (std.mem.eql(u8, row.text(1), "space_rev")) return true; } if (rows.err) |err| return err; return false; } pub const PendingSpaceNotification = struct { space: []const u8, repo: []const u8, rev: []const u8, hash: []const u8, attempts: i64, }; pub fn claimSpaceNotification(allocator: std.mem.Allocator) !?PendingSpaceNotification { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.transaction(); errdefer conn.rollback(); try conn.execNoArgs("DELETE FROM permissioned_space_notify_outbox WHERE expires_at <= unixepoch()"); const row = try conn.row("SELECT space, repo_did, rev, hash, attempts FROM permissioned_space_notify_outbox WHERE retry_at <= unixepoch() AND lease_until <= unixepoch() ORDER BY retry_at LIMIT 1", .{}); if (row == null) { try conn.commit(); return null; } const pending = blk: { const r = row.?; defer r.deinit(); break :blk PendingSpaceNotification{ .space = try allocator.dupe(u8, r.text(0)), .repo = try allocator.dupe(u8, r.text(1)), .rev = try allocator.dupe(u8, r.text(2)), .hash = try allocator.dupe(u8, r.blob(3)), .attempts = r.int(4) + 1, }; }; try conn.exec("UPDATE permissioned_space_notify_outbox SET attempts = ?, lease_until = unixepoch() + 120 WHERE space = ? AND repo_did = ?", .{ pending.attempts, pending.space, pending.repo }); try conn.commit(); return pending; } pub fn finishSpaceNotification(space: []const u8, repo: []const u8, rev: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec("DELETE FROM permissioned_space_notify_outbox WHERE space = ? AND repo_did = ? AND rev = ?", .{ space, repo, rev }); } pub fn retrySpaceNotification(pending: PendingSpaceNotification) !void { const delay: i64 = @min(3600, @as(i64, 5) << @as(u6, @intCast(@min(pending.attempts - 1, 10)))); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec("UPDATE permissioned_space_notify_outbox SET retry_at = unixepoch() + ?, lease_until = 0 WHERE space = ? AND repo_did = ? AND rev = ? AND attempts = ?", .{ delay, pending.space, pending.repo, pending.rev, pending.attempts }); } fn migrateSpaceSequence() !void { try conn.execNoArgs("CREATE TABLE IF NOT EXISTS permissioned_space_sequence(space TEXT PRIMARY KEY REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, rev TEXT NOT NULL)"); try conn.execNoArgs("CREATE TABLE IF NOT EXISTS permissioned_space_notify_outbox(space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, repo_did TEXT NOT NULL, rev TEXT NOT NULL, hash BLOB NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL, PRIMARY KEY(space, repo_did))"); if (!try spaceWriterSequenceColumnExists()) { try conn.transaction(); errdefer conn.rollback(); try conn.execNoArgs("ALTER TABLE permissioned_space_writers ADD COLUMN space_rev TEXT"); var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const a = arena.allocator(); var rows = try conn.rows("SELECT space, repo_did FROM permissioned_space_writers ORDER BY space, repo_did", .{}); defer rows.deinit(); var previous: ?[]const u8 = null; while (rows.next()) |row| { const rev = try revForSeq(a, 0, previous); try conn.exec("UPDATE permissioned_space_writers SET space_rev = ? WHERE space = ? AND repo_did = ?", .{ rev, row.text(0), row.text(1) }); try conn.exec("INSERT INTO permissioned_space_sequence(space, rev) VALUES (?, ?) ON CONFLICT(space) DO UPDATE SET rev = excluded.rev", .{ row.text(0), rev }); previous = rev; } if (rows.err) |err| return err; try conn.commit(); } try conn.execNoArgs("CREATE INDEX IF NOT EXISTS permissioned_space_writer_sequence ON permissioned_space_writers(space, space_rev)"); } fn migrate() !void { inline for (schema_statements) |sql| try conn.execNoArgs(sql); try conn.execNoArgs("CREATE TABLE IF NOT EXISTS permissioned_space_revocations (space TEXT NOT NULL, jti TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY(space, jti))"); try migratePermissionedDataTables(); try migrateSpaceSequence(); const had_deactivated_at = try accountsColumnExistsLocked("deactivated_at"); conn.execNoArgs("ALTER TABLE accounts ADD COLUMN email TEXT") catch {}; conn.execNoArgs("ALTER TABLE accounts ADD COLUMN email_confirmed_at INTEGER") catch {}; conn.execNoArgs("ALTER TABLE accounts ADD COLUMN auth_code TEXT") catch {}; conn.execNoArgs("ALTER TABLE accounts ADD COLUMN auth_code_expires_at INTEGER") catch {}; conn.execNoArgs("ALTER TABLE accounts ADD COLUMN pending_email TEXT") catch {}; conn.execNoArgs("ALTER TABLE accounts ADD COLUMN invites_disabled INTEGER NOT NULL DEFAULT 0") catch {}; conn.execNoArgs("ALTER TABLE accounts ADD COLUMN activated_at INTEGER") catch {}; conn.execNoArgs("ALTER TABLE accounts ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'") catch {}; conn.execNoArgs("ALTER TABLE accounts ADD COLUMN account_status_ref TEXT") catch {}; conn.execNoArgs("UPDATE accounts SET account_status = 'deactivated' WHERE activated_at IS NULL AND account_status = 'active'") catch {}; if (had_deactivated_at) { conn.execNoArgs("UPDATE accounts SET account_status = 'deactivated' WHERE deactivated_at IS NOT NULL AND account_status = 'active'") catch {}; } conn.execNoArgs("ALTER TABLE accounts ADD COLUMN signing_key_type TEXT") catch {}; conn.execNoArgs("ALTER TABLE accounts ADD COLUMN signing_key BLOB") catch {}; conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN login_hint TEXT") catch {}; conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN response_mode TEXT NOT NULL DEFAULT 'query'") catch {}; conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN auth_method TEXT") catch {}; conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN prompt TEXT") catch {}; conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN auth_method TEXT") catch {}; conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN dpop_jkt TEXT") catch {}; conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN family_id TEXT") catch {}; conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN access_expires_at INTEGER") catch {}; conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN refresh_expires_at INTEGER") catch {}; conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN previous_refresh_token TEXT") catch {}; conn.execNoArgs("ALTER TABLE oauth_tokens ADD COLUMN previous_refresh_expires_at INTEGER") catch {}; conn.execNoArgs("UPDATE oauth_tokens SET family_id = access_token WHERE family_id IS NULL") catch {}; conn.execNoArgs("UPDATE oauth_tokens SET access_expires_at = expires_at WHERE access_expires_at IS NULL") catch {}; conn.execNoArgs("UPDATE oauth_tokens SET refresh_expires_at = expires_at WHERE refresh_expires_at IS NULL") catch {}; conn.execNoArgs("ALTER TABLE session_tokens ADD COLUMN app_password_name TEXT") catch {}; conn.execNoArgs("ALTER TABLE session_tokens ADD COLUMN previous_refresh_jti TEXT") catch {}; conn.execNoArgs("ALTER TABLE session_tokens ADD COLUMN previous_refresh_expires_at INTEGER") catch {}; conn.execNoArgs("ALTER TABLE permissioned_space_records ADD COLUMN repo_rev TEXT NOT NULL DEFAULT ''") catch {}; try migrateBlobTable(); try migrateAppPreferencesTable(); conn.execNoArgs("ALTER TABLE repo_blocks ADD COLUMN repo_rev TEXT") catch {}; inline for (post_schema_statements) |sql| try conn.execNoArgs(sql); try migrateRepoBlockRevs(); try migrateRecordsTable(); try validateRecordBlocksPresent(); try migrateSeqEventsSync11(); try migrateSeqEventsSinceRev(); } fn migratePermissionedDataTables() !void { try ensureMigrationTable(); try migratePermissionedSpacesAuthorityColumns(); try migratePermissionedSpaceAuthorityFlag(); try migrateSimpleSpaceConfig(); try migratePermissionedRecordOplogRepo(); try migratePermissionedSpaceUris(); try migratePermissionedSpaceBlobRefs(); try migratePermissionedSpaceRepoHashes(); try migratePermissionedSpaceWriters(); try migratePermissionedSpaceNotifyRegistrations(); try migratePermissionedSpaceNotificationServices(); } fn migratePermissionedSpaceBlobRefs() !void { const name = "permissioned-space-blob-refs-v1"; if (try migrationApplied(name)) return; const ExistingRecord = struct { space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, value_json: []const u8, }; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); var records: std.ArrayList(ExistingRecord) = .empty; var rows = try conn.rows( \\SELECT space, repo_did, collection, rkey, value_json \\FROM permissioned_space_records , .{}); while (rows.next()) |row| { try records.append(allocator, .{ .space = try allocator.dupe(u8, row.text(0)), .repo_did = try allocator.dupe(u8, row.text(1)), .collection = try allocator.dupe(u8, row.text(2)), .rkey = try allocator.dupe(u8, row.text(3)), .value_json = try allocator.dupe(u8, row.blob(4)), }); } if (rows.err) |err| return err; rows.deinit(); try conn.exclusiveTransaction(); errdefer conn.rollback(); for (records.items) |record| { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, record.value_json, .{}); const value = try jsonToDagCbor(allocator, parsed.value); var blob_cids: std.ArrayList([]const u8) = .empty; try collectBlobCids(allocator, value, &blob_cids); try syncSpaceRecordBlobRefsLocked(record.space, record.repo_did, record.collection, record.rkey, blob_cids.items); } try markMigrationApplied(name); try conn.commit(); } fn migratePermissionedSpaceNotifyRegistrations() !void { const name = "permissioned-space-notify-registration-v1"; if (try migrationApplied(name)) return; // The prior table was never populated through a protocol endpoint and // cannot represent repo-scoped or expiring registrations. try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_space_credential_recipients"); try conn.execNoArgs( \\CREATE TABLE IF NOT EXISTS permissioned_space_notify_registrations ( \\ space TEXT NOT NULL, \\ repo_did TEXT NOT NULL DEFAULT '', \\ service_id TEXT NOT NULL DEFAULT '', \\ service_endpoint TEXT NOT NULL, \\ expires_at INTEGER NOT NULL, \\ PRIMARY KEY (space, repo_did, service_endpoint) \\) ); try markMigrationApplied(name); } fn migratePermissionedSpaceNotificationServices() !void { const name = "permissioned-space-notify-service-id"; if (try migrationApplied(name)) return; try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_space_notify_registrations"); try conn.execNoArgs( \\CREATE TABLE permissioned_space_notify_registrations ( \\ space TEXT NOT NULL, \\ service_id TEXT NOT NULL, \\ service_endpoint TEXT NOT NULL, \\ expires_at INTEGER NOT NULL, \\ PRIMARY KEY (space, service_id) \\) ); try markMigrationApplied(name); } fn migratePermissionedSpaceUris() !void { const name = "permissioned-space-at-uri"; if (try migrationApplied(name)) return; try conn.execNoArgs("PRAGMA foreign_keys = OFF"); errdefer conn.execNoArgs("PRAGMA foreign_keys = ON") catch {}; try conn.exclusiveTransaction(); errdefer conn.rollback(); const tables = [_][]const u8{ "permissioned_space_actor_state", "simplespace_members", "permissioned_space_records", "permissioned_space_record_blobs", "permissioned_space_repos", "permissioned_space_writers", "permissioned_space_record_oplog", "permissioned_space_notify_registrations", }; inline for (tables) |table| { try conn.execNoArgs( "UPDATE " ++ table ++ " SET space = (" ++ "SELECT 'at://' || authority_did || '/space/' || space_type || '/' || skey " ++ "FROM permissioned_spaces WHERE uri = " ++ table ++ ".space" ++ ") WHERE space LIKE 'ats://%'", ); } try conn.execNoArgs( \\UPDATE permissioned_spaces \\SET uri = 'at://' || authority_did || '/space/' || space_type || '/' || skey \\WHERE uri LIKE 'ats://%' ); try markMigrationApplied(name); try conn.commit(); try conn.execNoArgs("PRAGMA foreign_keys = ON"); } fn migratePermissionedSpaceRepoHashes() !void { const name = "permissioned-space-record-element-v1"; if (try migrationApplied(name)) return; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const RepoIdentity = struct { space: []const u8, did: []const u8, has_rev: bool }; var identities: std.ArrayList(RepoIdentity) = .empty; var repos = try conn.rows("SELECT space, repo_did, rev FROM permissioned_space_repos", .{}); while (repos.next()) |repo| { try identities.append(allocator, .{ .space = try allocator.dupe(u8, repo.text(0)), .did = try allocator.dupe(u8, repo.text(1)), .has_rev = repo.nullableText(2) != null, }); } if (repos.err) |err| return err; repos.deinit(); for (identities.items) |identity| { var hash: permissioned.LtHash = .{}; var record_count: usize = 0; var records = try conn.rows( \\SELECT collection, rkey, cid \\FROM permissioned_space_records \\WHERE space = ? AND repo_did = ? , .{ identity.space, identity.did }); defer records.deinit(); while (records.next()) |record| { try addRecordElement(allocator, &hash, record.text(0), record.text(1), record.text(2)); record_count += 1; } if (records.err) |err| return err; if (!identity.has_rev and record_count == 0) continue; try conn.exec( \\UPDATE permissioned_space_repos \\SET set_hash = ? \\WHERE space = ? AND repo_did = ? , .{ zqlite.blob(&hash.bytes), identity.space, identity.did }); } try markMigrationApplied(name); } fn migratePermissionedSpaceWriters() !void { const name = "permissioned-space-writer-set-v1"; if (try migrationApplied(name)) return; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const ExistingRepo = struct { space: []const u8, did: []const u8, rev: []const u8, state: []const u8, }; var existing: std.ArrayList(ExistingRepo) = .empty; var rows = try conn.rows( \\SELECT r.space, r.repo_did, r.rev, r.set_hash \\FROM permissioned_space_repos r \\JOIN permissioned_spaces s ON s.uri = r.space \\JOIN permissioned_space_actor_state a \\ ON a.space = s.uri AND a.actor_did = s.authority_did \\WHERE r.rev IS NOT NULL AND r.set_hash IS NOT NULL \\ AND s.deleted_at IS NULL AND a.deleted_at IS NULL AND a.is_authority = 1 , .{}); while (rows.next()) |row| { try existing.append(allocator, .{ .space = try allocator.dupe(u8, row.text(0)), .did = try allocator.dupe(u8, row.text(1)), .rev = try allocator.dupe(u8, row.text(2)), .state = try allocator.dupe(u8, row.blob(3)), }); } if (rows.err) |err| return err; rows.deinit(); for (existing.items) |repo| { const state = try permissioned.LtHash.fromBytes(repo.state); const digest = state.digest(); try conn.exec( \\INSERT INTO permissioned_space_writers (space, repo_did, rev, hash) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(space, repo_did) DO UPDATE SET \\ rev = excluded.rev, \\ hash = excluded.hash, \\ updated_at = unixepoch() , .{ repo.space, repo.did, repo.rev, zqlite.blob(&digest) }); } try markMigrationApplied(name); } fn migrateSimpleSpaceConfig() !void { if (try permissionedSpacesColumnExistsLocked("read_policy")) return; try conn.exclusiveTransaction(); errdefer conn.rollback(); if (!try permissionedSpacesColumnExistsLocked("policy")) { try conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN policy TEXT NOT NULL DEFAULT 'member-list'"); if (try permissionedSpacesColumnExistsLocked("is_public")) { try conn.execNoArgs("UPDATE permissioned_spaces SET policy = CASE WHEN is_public = 1 THEN 'public' ELSE 'member-list' END"); } } if (!try permissionedSpacesColumnExistsLocked("app_access_json")) { try conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN app_access_json TEXT NOT NULL DEFAULT '{\"type\":\"open\"}'"); if (try permissionedSpacesColumnExistsLocked("app_access_mode")) { try conn.execNoArgs( \\UPDATE permissioned_spaces SET app_access_json = CASE \\WHEN app_access_mode = 'deny' AND app_exceptions_json != '[]' \\THEN '{"type":"allowList","allowed":[]}' ELSE '{"type":"open"}' END ); } } try conn.execNoArgs( \\CREATE TABLE IF NOT EXISTS simplespace_members ( \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, \\ member_did TEXT NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ PRIMARY KEY (space, member_did) \\) ); try conn.execNoArgs("CREATE INDEX IF NOT EXISTS simplespace_members_member_idx ON simplespace_members (member_did, space)"); try conn.execNoArgs( \\INSERT OR IGNORE INTO simplespace_members (space, member_did) \\SELECT uri, authority_did \\FROM permissioned_spaces ); try conn.execNoArgs("ALTER TABLE permissioned_spaces RENAME COLUMN policy TO read_policy"); try conn.execNoArgs("ALTER TABLE permissioned_spaces RENAME COLUMN managing_app TO read_managing_app"); try conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN write_policy TEXT NOT NULL DEFAULT 'member-list'"); try conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN write_managing_app TEXT"); try conn.execNoArgs("UPDATE permissioned_spaces SET write_policy = read_policy, write_managing_app = read_managing_app"); try conn.execNoArgs("ALTER TABLE simplespace_members ADD COLUMN read INTEGER NOT NULL DEFAULT 1"); try conn.execNoArgs("ALTER TABLE simplespace_members ADD COLUMN write INTEGER NOT NULL DEFAULT 1"); try conn.commit(); } fn ensureMigrationTable() !void { try conn.execNoArgs( \\CREATE TABLE IF NOT EXISTS zds_migrations ( \\ name TEXT PRIMARY KEY, \\ applied_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) ); } fn accountsColumnExistsLocked(column: []const u8) !bool { var rows = try conn.rows("PRAGMA table_info(accounts)", .{}); defer rows.deinit(); while (rows.next()) |row| { if (std.mem.eql(u8, row.text(1), column)) return true; } if (rows.err) |err| return err; return false; } fn permissionedSpacesColumnExistsLocked(column: []const u8) !bool { var rows = try conn.rows("PRAGMA table_info(permissioned_spaces)", .{}); defer rows.deinit(); while (rows.next()) |row| { if (std.mem.eql(u8, row.text(1), column)) return true; } if (rows.err) |err| return err; return false; } fn permissionedSpaceActorStateColumnExistsLocked(column: []const u8) !bool { var rows = try conn.rows("PRAGMA table_info(permissioned_space_actor_state)", .{}); defer rows.deinit(); while (rows.next()) |row| { if (std.mem.eql(u8, row.text(1), column)) return true; } if (rows.err) |err| return err; return false; } fn migrationApplied(name: []const u8) !bool { const row = try conn.row("SELECT 1 FROM zds_migrations WHERE name = ?", .{name}); if (row == null) return false; defer row.?.deinit(); return true; } fn markMigrationApplied(name: []const u8) !void { try conn.exec("INSERT OR IGNORE INTO zds_migrations (name) VALUES (?)", .{name}); } fn migratePermissionedSpacesAuthorityColumns() !void { const name = "permissioned-spaces-authority-columns"; if (try migrationApplied(name)) return; if (!try permissionedSpacesColumnExistsLocked("owner_did")) { try conn.execNoArgs("CREATE INDEX IF NOT EXISTS permissioned_spaces_authority_idx ON permissioned_spaces (authority_did, space_type, uri)"); try markMigrationApplied(name); return; } try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_spaces_next"); try conn.execNoArgs( \\CREATE TABLE permissioned_spaces_next ( \\ uri TEXT PRIMARY KEY, \\ authority_did TEXT NOT NULL, \\ space_type TEXT NOT NULL, \\ skey TEXT NOT NULL, \\ managing_app TEXT, \\ is_public INTEGER NOT NULL DEFAULT 0, \\ app_access_mode TEXT NOT NULL DEFAULT 'allow', \\ app_exceptions_json TEXT NOT NULL DEFAULT '[]', \\ is_authority INTEGER NOT NULL DEFAULT 1, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ deleted_at INTEGER, \\ UNIQUE(authority_did, space_type, skey) \\) ); try conn.execNoArgs( \\INSERT INTO permissioned_spaces_next ( \\ uri, authority_did, space_type, skey, managing_app, is_public, app_access_mode, \\ app_exceptions_json, is_authority, created_at, deleted_at \\) \\SELECT uri, owner_did, space_type, skey, managing_app, is_public, app_access_mode, \\ app_exceptions_json, is_owner, created_at, deleted_at \\FROM permissioned_spaces ); try conn.execNoArgs("PRAGMA foreign_keys = OFF"); errdefer conn.execNoArgs("PRAGMA foreign_keys = ON") catch {}; try conn.execNoArgs("DROP TABLE permissioned_spaces"); try conn.execNoArgs("ALTER TABLE permissioned_spaces_next RENAME TO permissioned_spaces"); try conn.execNoArgs("PRAGMA foreign_keys = ON"); try conn.execNoArgs("CREATE INDEX IF NOT EXISTS permissioned_spaces_authority_idx ON permissioned_spaces (authority_did, space_type, uri)"); try markMigrationApplied(name); } fn migratePermissionedSpaceAuthorityFlag() !void { const name = "permissioned-space-authority-flag"; if (try migrationApplied(name)) return; if (try permissionedSpaceActorStateColumnExistsLocked("is_owner")) { try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_space_actor_state_next"); try conn.execNoArgs( \\CREATE TABLE permissioned_space_actor_state_next ( \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, \\ actor_did TEXT NOT NULL, \\ is_authority INTEGER NOT NULL DEFAULT 0, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ deleted_at INTEGER, \\ PRIMARY KEY (space, actor_did) \\) ); try conn.execNoArgs( \\INSERT INTO permissioned_space_actor_state_next ( \\ space, actor_did, is_authority, created_at, deleted_at \\) \\SELECT space, actor_did, is_owner, created_at, deleted_at \\FROM permissioned_space_actor_state ); try conn.execNoArgs("PRAGMA foreign_keys = OFF"); errdefer conn.execNoArgs("PRAGMA foreign_keys = ON") catch {}; try conn.execNoArgs("DROP TABLE permissioned_space_actor_state"); try conn.execNoArgs("ALTER TABLE permissioned_space_actor_state_next RENAME TO permissioned_space_actor_state"); try conn.execNoArgs("PRAGMA foreign_keys = ON"); } try conn.execNoArgs( \\INSERT OR IGNORE INTO permissioned_space_actor_state (space, actor_did, is_authority, deleted_at) \\SELECT s.uri, \\ CASE WHEN s.is_authority = 1 THEN s.authority_did ELSE COALESCE(r.repo_did, s.authority_did) END, \\ s.is_authority, \\ s.deleted_at \\FROM permissioned_spaces s \\LEFT JOIN permissioned_space_repos r ON r.space = s.uri \\WHERE s.is_authority = 1 ); try markMigrationApplied(name); } fn migratePermissionedRecordOplogRepo() !void { const name = "permissioned-record-oplog-repo"; if (try migrationApplied(name)) return; try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_space_record_oplog_next"); try conn.execNoArgs( \\CREATE TABLE permissioned_space_record_oplog_next ( \\ space TEXT NOT NULL, \\ repo_did TEXT NOT NULL, \\ rev TEXT NOT NULL, \\ idx INTEGER NOT NULL, \\ action TEXT NOT NULL, \\ collection TEXT NOT NULL, \\ rkey TEXT NOT NULL, \\ cid TEXT, \\ prev TEXT, \\ PRIMARY KEY (space, repo_did, rev, idx) \\) ); try conn.execNoArgs( \\INSERT INTO permissioned_space_record_oplog_next ( \\ space, repo_did, rev, idx, action, collection, rkey, cid, prev \\) \\SELECT space, '', rev, idx, action, collection, rkey, cid, prev \\FROM permissioned_space_record_oplog ); try conn.execNoArgs("DROP TABLE permissioned_space_record_oplog"); try conn.execNoArgs("ALTER TABLE permissioned_space_record_oplog_next RENAME TO permissioned_space_record_oplog"); try markMigrationApplied(name); } fn migrateSeqEventsSync11() !void { const name = "sync11-seq-events"; if (try migrationApplied(name)) return; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try conn.exclusiveTransaction(); errdefer conn.rollback(); try rebuildSeqEventsSync11Locked(allocator); try markMigrationApplied(name); try conn.commit(); } fn migrateSeqEventsSinceRev() !void { const name = "sync-since-rev-events"; if (try migrationApplied(name)) return; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try conn.exclusiveTransaction(); errdefer conn.rollback(); try rebuildSeqEventsSync11Locked(allocator); try markMigrationApplied(name); try conn.commit(); } fn migrateRecordsTable() !void { if (!try recordsTableHasValueJsonColumn()) return; try conn.execNoArgs("DROP TABLE IF EXISTS records_next"); try conn.execNoArgs( \\CREATE TABLE IF NOT EXISTS records_next ( \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ collection TEXT NOT NULL, \\ rkey TEXT NOT NULL, \\ uri TEXT NOT NULL UNIQUE, \\ cid TEXT NOT NULL, \\ rev TEXT NOT NULL, \\ seq INTEGER NOT NULL, \\ PRIMARY KEY (did, collection, rkey) \\) ); try conn.execNoArgs( \\INSERT INTO records_next (did, collection, rkey, uri, cid, rev, seq) \\SELECT did, collection, rkey, uri, cid, rev, seq \\FROM records ); try conn.execNoArgs("PRAGMA foreign_keys = OFF"); errdefer conn.execNoArgs("PRAGMA foreign_keys = ON") catch {}; try conn.execNoArgs("DROP TABLE records"); try conn.execNoArgs("ALTER TABLE records_next RENAME TO records"); try conn.execNoArgs("PRAGMA foreign_keys = ON"); try conn.execNoArgs("CREATE INDEX IF NOT EXISTS records_collection_idx ON records (did, collection, seq DESC)"); try conn.execNoArgs("CREATE INDEX IF NOT EXISTS records_cid_idx ON records (cid)"); } fn migrateRepoBlockRevs() !void { try conn.execNoArgs( \\UPDATE repo_blocks \\SET repo_rev = ( \\ SELECT r.rev \\ FROM records r \\ WHERE r.did = repo_blocks.did AND r.cid = repo_blocks.cid \\ LIMIT 1 \\) \\WHERE repo_rev IS NULL \\ AND EXISTS ( \\ SELECT 1 FROM records r \\ WHERE r.did = repo_blocks.did AND r.cid = repo_blocks.cid \\ ) ); try conn.execNoArgs( \\UPDATE repo_blocks \\SET repo_rev = ( \\ SELECT c.rev \\ FROM commits c \\ WHERE c.did = repo_blocks.did AND c.cid = repo_blocks.cid \\ LIMIT 1 \\) \\WHERE repo_rev IS NULL \\ AND EXISTS ( \\ SELECT 1 FROM commits c \\ WHERE c.did = repo_blocks.did AND c.cid = repo_blocks.cid \\ ) ); try conn.execNoArgs("CREATE INDEX IF NOT EXISTS repo_blocks_rev_idx ON repo_blocks (did, repo_rev DESC, cid DESC)"); } fn recordsTableHasValueJsonColumn() !bool { var rows = try conn.rows("PRAGMA table_info(records)", .{}); defer rows.deinit(); while (rows.next()) |row| { if (std.mem.eql(u8, row.text(1), "value_json")) return true; } if (rows.err) |err| return err; return false; } fn validateRecordBlocksPresent() !void { const row = try conn.row( \\SELECT COUNT(*) \\FROM records r \\LEFT JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid \\WHERE rb.cid IS NULL , .{}); if (row == null) return; defer row.?.deinit(); if (row.?.int(0) != 0) return Error.MissingRecordBlock; } fn migrateAppPreferencesTable() !void { if (!try appPreferencesTableHasPreferencesJsonColumn()) return; try conn.execNoArgs( \\CREATE TABLE IF NOT EXISTS app_preferences_next ( \\ id INTEGER PRIMARY KEY AUTOINCREMENT, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ name TEXT NOT NULL, \\ value_json BLOB NOT NULL, \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) ); var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); { var rows = try conn.rows( \\SELECT did, namespace, preferences_json \\FROM app_preferences , .{}); defer rows.deinit(); var preferences: std.ArrayList(AppPreference) = .empty; while (rows.next()) |row| { const did = try allocator.dupe(u8, row.text(0)); const namespace = try allocator.dupe(u8, row.text(1)); const preferences_json = try allocator.dupe(u8, row.text(2)); const parsed = std.json.parseFromSlice(std.json.Value, allocator, preferences_json, .{}) catch continue; if (parsed.value != .array) continue; for (parsed.value.array.items) |preference| { if (preference != .object) continue; const name = jsonValueString(preference, "$type") orelse continue; if (!preferenceNameInNamespace(name, namespace)) continue; const value_json = try stringifyValue(allocator, preference); try preferences.append(allocator, .{ .name = name, .value_json = value_json, }); } for (preferences.items) |preference| { try conn.exec( \\INSERT INTO app_preferences_next (did, name, value_json, updated_at) \\VALUES (?, ?, ?, unixepoch()) , .{ did, preference.name, preference.value_json }); } preferences.clearRetainingCapacity(); } if (rows.err) |err| return err; } try conn.execNoArgs("DROP TABLE app_preferences"); try conn.execNoArgs("ALTER TABLE app_preferences_next RENAME TO app_preferences"); } fn appPreferencesTableHasPreferencesJsonColumn() !bool { var rows = try conn.rows("PRAGMA table_info(app_preferences)", .{}); defer rows.deinit(); while (rows.next()) |row| { if (std.mem.eql(u8, row.text(1), "preferences_json")) return true; } if (rows.err) |err| return err; return false; } fn preferenceNameInNamespace(name: []const u8, namespace: []const u8) bool { return std.mem.eql(u8, name, namespace) or (std.mem.startsWith(u8, name, namespace) and name.len > namespace.len and name[namespace.len] == '.'); } fn jsonValueString(value: std.json.Value, key: []const u8) ?[]const u8 { return switch (value) { .object => |object| switch (object.get(key) orelse return null) { .string => |string| string, else => null, }, else => null, }; } fn migrateBlobTable() !void { if (!try blobTableHasDataColumn()) return; try conn.execNoArgs( \\CREATE TABLE IF NOT EXISTS blobs_next ( \\ cid TEXT NOT NULL, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ mime_type TEXT NOT NULL, \\ size INTEGER NOT NULL, \\ storage TEXT NOT NULL DEFAULT 'disk', \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ PRIMARY KEY (did, cid) \\) ); try conn.execNoArgs( \\INSERT OR IGNORE INTO blobs_next (cid, did, mime_type, size, storage, created_at) \\SELECT cid, did, mime_type, size, 'disk', created_at \\FROM blobs ); try conn.execNoArgs("DROP TABLE blobs"); try conn.execNoArgs("ALTER TABLE blobs_next RENAME TO blobs"); } fn blobTableHasDataColumn() !bool { var rows = try conn.rows("PRAGMA table_info(blobs)", .{}); defer rows.deinit(); while (rows.next()) |row| { if (std.mem.eql(u8, row.text(1), "data")) return true; } if (rows.err) |err| return err; return false; } pub fn getEmailInfo(allocator: std.mem.Allocator, did: []const u8) ?EmailInfo { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); requireInitialized() catch return null; const row = conn.row( \\SELECT email, email_confirmed_at, auth_code, auth_code_expires_at, pending_email \\FROM accounts \\WHERE did = ? , .{did}) catch return null; if (row == null) return null; defer row.?.deinit(); return .{ .email = allocator.dupe(u8, row.?.text(0)) catch return null, .email_confirmed = row.?.nullableInt(1) != null, .auth_code = if (row.?.nullableText(2)) |text| allocator.dupe(u8, text) catch null else null, .auth_code_expires_at = row.?.nullableInt(3), .pending_email = if (row.?.nullableText(4)) |text| allocator.dupe(u8, text) catch null else null, }; } pub fn setAuthCode(did: []const u8, code: []const u8, expires_at_ms: i64) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE accounts \\SET auth_code = ?, auth_code_expires_at = ? \\WHERE did = ? , .{ code, expires_at_ms, did }); } pub fn setPendingEmail(did: []const u8, pending_email: []const u8, code: []const u8, expires_at_ms: i64) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE accounts \\SET pending_email = ?, auth_code = ?, auth_code_expires_at = ? \\WHERE did = ? , .{ pending_email, code, expires_at_ms, did }); } pub fn validateAuthCode(did: []const u8, code: []const u8, now_ms: i64) CodeStatus { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); requireInitialized() catch return .invalid; const row = conn.row( \\SELECT auth_code, auth_code_expires_at \\FROM accounts \\WHERE did = ? , .{did}) catch return .invalid; if (row == null) return .invalid; defer row.?.deinit(); const stored = row.?.nullableText(0) orelse return .invalid; const expires_at = row.?.nullableInt(1) orelse return .invalid; if (now_ms >= expires_at) return .expired; if (!std.ascii.eqlIgnoreCase(stored, code)) return .invalid; return .valid; } pub fn clearAuthCode(did: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE accounts \\SET auth_code = NULL, \\ auth_code_expires_at = NULL \\WHERE did = ? , .{did}); } pub fn confirmEmail(did: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE accounts \\SET email_confirmed_at = unixepoch(), \\ auth_code = NULL, \\ auth_code_expires_at = NULL \\WHERE did = ? , .{did}); } pub fn updateEmail(did: []const u8, email: []const u8) !void { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); try conn.exec( \\UPDATE accounts \\SET email = ?, \\ email_confirmed_at = NULL, \\ pending_email = NULL, \\ auth_code = NULL, \\ auth_code_expires_at = NULL \\WHERE did = ? , .{ email, did }); } fn loadNextSeqLocked() !u64 { const row = try conn.row( \\SELECT COALESCE(MAX(seq), 0) + 1 \\FROM ( \\ SELECT MAX(seq) AS seq FROM commits \\ UNION ALL \\ SELECT MAX(seq) AS seq FROM seq_events \\) , .{}); if (row == null) return 1; defer row.?.deinit(); return @intCast(row.?.int(0)); } fn nextSeqLocked() !u64 { _ = try requireInitialized(); const seq = next_seq; next_seq += 1; return seq; } fn nextRkey(allocator: std.mem.Allocator) ![]const u8 { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); return nextRkeyLocked(allocator); } fn nextRkeyLocked(allocator: std.mem.Allocator) ![]const u8 { const seq = try nextSeqLocked(); const tid = try atid.encode(nowMicros(), @intCast(seq % 1024)); return allocator.dupe(u8, &tid); } fn resolveWriteOpsLocked(allocator: std.mem.Allocator, ops: []const WriteOp) ![]WriteOp { var resolved = try allocator.alloc(WriteOp, ops.len); for (ops, 0..) |op, i| { resolved[i] = switch (op) { .create => |create_op| .{ .create = .{ .collection = create_op.collection, .rkey = create_op.rkey orelse try nextRkeyLocked(allocator), .value = create_op.value, } }, .update => |update_op| .{ .update = update_op }, .delete => |delete_op| .{ .delete = delete_op }, }; } return resolved; } fn requireWriteSwapsLocked(allocator: std.mem.Allocator, did: []const u8, current: ?CurrentCommit, options: WriteOptions, ops: []const WriteOp) !void { if (options.swap_commit) |expected| { if (current == null or !std.mem.eql(u8, current.?.commit_cid_text, expected)) return Error.InvalidSwap; } for (ops) |op| switch (op) { .create => {}, .update => |update_op| try requireRecordSwapLocked(allocator, did, update_op.collection, update_op.rkey, update_op.swap), .delete => |delete_op| try requireRecordSwapLocked(allocator, did, delete_op.collection, delete_op.rkey, delete_op.swap), }; } fn requireRecordSwapLocked(allocator: std.mem.Allocator, did: []const u8, collection: []const u8, rkey: []const u8, swap: RecordSwap) !void { switch (swap) { .none => {}, .missing => { if (try currentRecordCidLocked(allocator, did, collection, rkey) != null) return Error.InvalidSwap; }, .cid => |expected| { const actual = try currentRecordCidLocked(allocator, did, collection, rkey) orelse return Error.InvalidSwap; if (!std.mem.eql(u8, actual, expected)) return Error.InvalidSwap; }, } } fn previousRecordCidsLocked(allocator: std.mem.Allocator, did: []const u8, ops: []const WriteOp) ![]?[]const u8 { var cids = try allocator.alloc(?[]const u8, ops.len); for (ops, 0..) |op, idx| { cids[idx] = switch (op) { .create => null, .update => |update_op| try currentRecordCidLocked(allocator, did, update_op.collection, update_op.rkey), .delete => |delete_op| try currentRecordCidLocked(allocator, did, delete_op.collection, delete_op.rkey), }; } return cids; } fn currentRecordCidLocked(allocator: std.mem.Allocator, did: []const u8, collection: []const u8, rkey: []const u8) !?[]const u8 { const row = try conn.row( \\SELECT cid \\FROM records \\WHERE did = ? AND collection = ? AND rkey = ? \\LIMIT 1 , .{ did, collection, rkey }); if (row == null) return null; defer row.?.deinit(); return try allocator.dupe(u8, row.?.text(0)); } fn validateRecordForWrite(collection: []const u8, rkey: ?[]const u8, value: std.json.Value, mode: ValidationMode) Error!void { const object = switch (value) { .object => |object| object, else => return Error.InvalidRecordType, }; const record_type = switch (object.get("$type") orelse return Error.InvalidRecordType) { .string => |record_type| record_type, else => return Error.InvalidRecordType, }; if (!std.mem.eql(u8, record_type, collection)) return Error.InvalidRecordType; if (mode == .require and !knownRecordType(record_type)) return Error.ValidationRequired; if (rkey) |key| try validateKnownRecordKey(record_type, key); } fn validateKnownRecordKey(record_type: []const u8, rkey: []const u8) Error!void { if (knownRecordKeyRule(record_type)) |rule| switch (rule) { .tid => { _ = atid.decode(rkey) catch return Error.InvalidRecordKey; }, .literal_self => { if (!std.mem.eql(u8, rkey, "self")) return Error.InvalidRecordKey; }, }; } const KnownRecordKeyRule = enum { tid, literal_self }; fn validationStatusForRecord(record_type: []const u8, mode: ValidationMode) []const u8 { return if (mode != .skip and knownRecordType(record_type)) "valid" else "unknown"; } fn knownRecordType(record_type: []const u8) bool { if (knownRecordKeyRule(record_type) != null) return true; const any_records = [_][]const u8{ "app.bsky.feed.generator", "com.atproto.lexicon.schema", }; for (any_records) |known| { if (std.mem.eql(u8, record_type, known)) return true; } return false; } fn knownRecordKeyRule(record_type: []const u8) ?KnownRecordKeyRule { const tid_records = [_][]const u8{ "app.bsky.feed.post", "app.bsky.feed.like", "app.bsky.feed.repost", "app.bsky.feed.threadgate", "app.bsky.feed.postgate", "app.bsky.graph.follow", "app.bsky.graph.block", "app.bsky.graph.list", "app.bsky.graph.listblock", "app.bsky.graph.listitem", "app.bsky.graph.starterpack", "app.bsky.graph.verification", }; for (tid_records) |known| { if (std.mem.eql(u8, record_type, known)) return .tid; } const self_records = [_][]const u8{ "app.bsky.actor.profile", "app.bsky.actor.status", "app.bsky.labeler.service", "app.bsky.notification.declaration", "chat.bsky.actor.declaration", "com.germnetwork.declaration", }; for (self_records) |known| { if (std.mem.eql(u8, record_type, known)) return .literal_self; } return null; } fn repoPath(allocator: std.mem.Allocator, collection: []const u8, rkey: []const u8) ![]const u8 { if (zat.Nsid.parse(collection) == null) return Error.InvalidCollection; if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey; return std.fmt.allocPrint(allocator, "{s}/{s}", .{ collection, rkey }); } fn stageRecordWrite( allocator: std.mem.Allocator, tree: *zat.mst.Mst, account: auth.Account, collection: []const u8, rkey: []const u8, value: std.json.Value, validation_mode: ValidationMode, rev: []const u8, seq: u64, blocks: *std.ArrayList(ImportedBlock), blob_refs: *std.ArrayList(BlobRef), ) !Record { const cbor_value = try jsonToDagCbor(allocator, value); const record_bytes = try zat.cbor.encodeAlloc(allocator, cbor_value); const record_cid = try zat.cbor.Cid.forDagCbor(allocator, record_bytes); const record_cid_text = try cidText(allocator, record_cid.raw); const path = try repoPath(allocator, collection, rkey); _ = try tree.putReturn(path, record_cid); try blocks.append(allocator, .{ .cid = record_cid_text, .data = record_bytes, }); const uri = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ account.did, collection, rkey }); var blobs: std.ArrayList([]const u8) = .empty; try collectBlobCids(allocator, cbor_value, &blobs); for (blobs.items) |blob_cid| { try blob_refs.append(allocator, .{ .cid = blob_cid, .uri = uri }); } return .{ .did = try allocator.dupe(u8, account.did), .collection = try allocator.dupe(u8, collection), .rkey = try allocator.dupe(u8, rkey), .cid = record_cid_text, .value_json = try stringifyValue(allocator, value), .validation_status = validationStatusForRecord(collection, validation_mode), .rev = try allocator.dupe(u8, rev), .seq = seq, }; } const CurrentCommit = struct { commit_cid_text: []const u8, commit_cid_raw: []const u8, data_cid_raw: []const u8, rev: []const u8, commit_data: []const u8, }; fn latestCommitRawLocked(allocator: std.mem.Allocator, did: []const u8) !?CurrentCommit { return latestCommitRawFrom(conn, allocator, did); } fn latestCommitRawFrom( read_conn: zqlite.Conn, allocator: std.mem.Allocator, did: []const u8, ) !?CurrentCommit { const row = try read_conn.row( \\SELECT c.cid, c.rev, rb.data \\FROM commits c \\JOIN repo_blocks rb ON rb.did = c.did AND rb.cid = c.cid \\WHERE c.did = ? \\ORDER BY c.seq DESC \\LIMIT 1 , .{did}); if (row == null) return null; defer row.?.deinit(); const commit_cid_text = try allocator.dupe(u8, row.?.text(0)); const commit_cid_raw = try cidRawFromText(allocator, commit_cid_text); const rev = try allocator.dupe(u8, row.?.text(1)); const commit_data = row.?.nullableBlob(2) orelse return null; const commit_value = try zat.cbor.decodeAll(allocator, commit_data); const data = commit_value.getCid("data") orelse return error.InvalidRepoPath; return .{ .commit_cid_text = commit_cid_text, .commit_cid_raw = commit_cid_raw, .data_cid_raw = try allocator.dupe(u8, data.raw), .rev = rev, .commit_data = try allocator.dupe(u8, commit_data), }; } fn readRepoCarLocked(allocator: std.mem.Allocator, did: []const u8) !zat.car.Car { var rows = try conn.rows( \\SELECT cid, data \\FROM repo_blocks \\WHERE did = ? \\ORDER BY cid ASC , .{did}); defer rows.deinit(); var blocks: std.ArrayList(zat.car.Block) = .empty; while (rows.next()) |row| { const data = row.nullableBlob(1) orelse ""; try blocks.append(allocator, .{ .cid_raw = try cidRawFromText(allocator, row.text(0)), .data = try allocator.dupe(u8, data), }); } if (rows.err) |err| return err; return .{ .roots = &.{}, .blocks = try blocks.toOwnedSlice(allocator) }; } fn writeMstBlocks(tree: *zat.mst.Mst, out: *std.ArrayList(zat.car.Block)) !void { try tree.collectBlocks(out); } const SignedCommit = struct { cid: []const u8, data: []const u8, }; fn signedCommit( allocator: std.mem.Allocator, did: []const u8, rev: []const u8, data_cid: zat.cbor.Cid, prev_cid_raw: ?[]const u8, ) !SignedCommit { var keypair = try signingKeypairLocked(did); return signedCommitWithKeypair(allocator, did, rev, data_cid, prev_cid_raw, &keypair); } fn signedCommitWithKeypair( allocator: std.mem.Allocator, did: []const u8, rev: []const u8, data_cid: zat.cbor.Cid, prev_cid_raw: ?[]const u8, keypair: *const zat.Keypair, ) !SignedCommit { const signed = try zat.signCommit(allocator, .{ .did = did, .rev = rev, .data = data_cid, .prev = if (prev_cid_raw) |raw| .{ .raw = raw } else null, }, keypair); defer allocator.free(signed.cid.raw); return .{ .cid = try cidText(allocator, signed.cid.raw), .data = signed.bytes, }; } fn signingKeypairLocked(did: []const u8) !zat.Keypair { const row = try conn.row( \\SELECT signing_key_type, signing_key \\FROM accounts \\WHERE did = ? , .{did}); if (row == null) return Error.MissingRecord; defer row.?.deinit(); const existing = row.?.nullableBlob(1); const key_type_text = row.?.nullableText(0) orelse "secp256k1"; const key_bytes = if (existing) |bytes| blk: { if (bytes.len != 32) return Error.InvalidDagCbor; break :blk bytes[0..32].*; } else blk: { const generated = try generateSigningKey(); try conn.exec( \\UPDATE accounts \\SET signing_key_type = 'secp256k1', signing_key = ? \\WHERE did = ? , .{ zqlite.blob(&generated), did }); break :blk generated; }; const key_type: zat.multicodec.KeyType = if (std.mem.eql(u8, key_type_text, "p256")) .p256 else .secp256k1; return zat.Keypair.fromSecretKey(key_type, key_bytes); } fn consumeReservedSigningKeyLocked(signing_key_or_did: []const u8, did: []const u8) ![32]u8 { const row = try conn.row( \\SELECT signing_key, secret_key, expires_at \\FROM reserved_signing_keys \\WHERE used_at IS NULL \\ AND expires_at > ? \\ AND (signing_key = ? OR did = ?) \\ORDER BY created_at DESC \\LIMIT 1 , .{ nowMs(), signing_key_or_did, did }); if (row == null) return Error.MissingReservedSigningKey; defer row.?.deinit(); const signing_key = row.?.text(0); const secret = row.?.blob(1); if (secret.len != 32) return Error.InvalidReservedSigningKey; const key_bytes = secret[0..32].*; try conn.exec( \\UPDATE reserved_signing_keys \\SET used_at = ? \\WHERE signing_key = ? , .{ nowMs(), signing_key }); return key_bytes; } fn generateSigningKey() ![32]u8 { var key: [32]u8 = undefined; while (true) { store_io.random(&key); if (zat.Keypair.fromSecretKey(.secp256k1, key)) |_| return key else |_| {} } } fn jsonToDagCbor(allocator: std.mem.Allocator, value: std.json.Value) !zat.cbor.Value { return switch (value) { .null => .null, .bool => |boolean| .{ .boolean = boolean }, .integer => |integer| if (integer >= 0) .{ .unsigned = @intCast(integer) } else .{ .negative = integer }, .float, .number_string => Error.InvalidDagCbor, .string => |string| .{ .text = string }, .array => |array| blk: { const items = try allocator.alloc(zat.cbor.Value, array.items.len); for (array.items, 0..) |item, idx| items[idx] = try jsonToDagCbor(allocator, item); break :blk .{ .array = items }; }, .object => |object| blk: { if (object.count() == 1) { if (object.get("$link")) |link_value| switch (link_value) { .string => |link| break :blk .{ .cid = .{ .raw = try cidRawFromText(allocator, link) } }, else => {}, }; if (object.get("$bytes")) |bytes_value| switch (bytes_value) { .string => |encoded| { const bytes = cbor_json.decodeAtBase64(allocator, encoded) catch return Error.InvalidDagCbor; break :blk .{ .bytes = bytes }; }, else => {}, }; } const entries = try allocator.alloc(zat.cbor.Value.MapEntry, object.count()); var it = object.iterator(); var idx: usize = 0; while (it.next()) |entry| : (idx += 1) { entries[idx] = .{ .key = entry.key_ptr.*, .value = try jsonToDagCbor(allocator, entry.value_ptr.*), }; } break :blk .{ .map = entries }; }, }; } fn collectBlobCids(allocator: std.mem.Allocator, value: zat.cbor.Value, out: *std.ArrayList([]const u8)) !void { switch (value) { .map => |entries| { if (cborMapText(entries, "$type")) |type_name| { if (std.mem.eql(u8, type_name, "blob")) { if (cborMapValue(entries, "ref")) |ref| switch (ref) { .cid => |cid| try out.append(allocator, try cidText(allocator, cid.raw)), .map => |ref_entries| if (cborMapText(ref_entries, "$link")) |link| try out.append(allocator, try allocator.dupe(u8, link)), else => {}, }; } } for (entries) |entry| try collectBlobCids(allocator, entry.value, out); }, .array => |items| for (items) |item| try collectBlobCids(allocator, item, out), else => {}, } } fn cborMapValue(entries: []const zat.cbor.Value.MapEntry, key: []const u8) ?zat.cbor.Value { for (entries) |entry| { if (std.mem.eql(u8, entry.key, key)) return entry.value; } return null; } fn cborMapText(entries: []const zat.cbor.Value.MapEntry, key: []const u8) ?[]const u8 { return switch (cborMapValue(entries, key) orelse return null) { .text => |text| text, else => null, }; } fn cidRawFromText(allocator: std.mem.Allocator, cid: []const u8) ![]const u8 { if (cid.len == 0 or cid[0] != 'b') return Error.InvalidDagCbor; return zat.multibase.base32lower.decode(allocator, cid[1..]); } fn cidText(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { return zat.multibase.base32lower.encode(allocator, raw); } fn recordFromRow(row: zqlite.Row, allocator: std.mem.Allocator) !Record { const record_bytes = row.nullableBlob(4) orelse return Error.MissingRecordBlock; return .{ .did = try allocator.dupe(u8, row.text(0)), .collection = try allocator.dupe(u8, row.text(1)), .rkey = try allocator.dupe(u8, row.text(2)), .cid = try allocator.dupe(u8, row.text(3)), .value_json = try recordJsonFromBlock(allocator, record_bytes), .validation_status = validationStatusForRecord(row.text(1), .known), .rev = try allocator.dupe(u8, row.text(5)), .seq = @intCast(row.int(6)), }; } fn recordJsonFromBlock(allocator: std.mem.Allocator, data: []const u8) ![]const u8 { const value = zat.cbor.decodeAll(allocator, data) catch return Error.InvalidDagCbor; return cbor_json.writeAlloc(allocator, value); } pub fn recordJsonFromDagCbor(allocator: std.mem.Allocator, data: []const u8) ![]const u8 { return recordJsonFromBlock(allocator, data); } fn oauthRequestFromRow(row: zqlite.Row, allocator: std.mem.Allocator) !OAuthRequest { return .{ .request_id = try allocator.dupe(u8, row.text(0)), .client_id = try allocator.dupe(u8, row.text(1)), .redirect_uri = try allocator.dupe(u8, row.text(2)), .scope = try allocator.dupe(u8, row.text(3)), .state = try allocator.dupe(u8, row.text(4)), .code_challenge = try allocator.dupe(u8, row.text(5)), .code_challenge_method = try allocator.dupe(u8, row.text(6)), .response_mode = try allocator.dupe(u8, row.text(7)), .login_hint = if (row.nullableText(8)) |text| try allocator.dupe(u8, text) else null, .dpop_jkt = if (row.nullableText(9)) |text| try allocator.dupe(u8, text) else null, .expires_at = row.int(10), .sub = if (row.nullableText(11)) |text| try allocator.dupe(u8, text) else null, .code = if (row.nullableText(12)) |text| try allocator.dupe(u8, text) else null, .auth_method = if (row.nullableText(13)) |text| try allocator.dupe(u8, text) else null, .prompt = if (row.nullableText(14)) |text| try allocator.dupe(u8, text) else null, }; } fn appPasswordFromRow(row: zqlite.Row, allocator: std.mem.Allocator) !AppPassword { return .{ .name = try allocator.dupe(u8, row.text(0)), .password_hash = try allocator.dupe(u8, row.text(1)), .created_at = row.int(2), .privileged = row.int(3) != 0, .scopes = if (row.nullableText(4)) |text| try allocator.dupe(u8, text) else null, .created_by_controller_did = if (row.nullableText(5)) |text| try allocator.dupe(u8, text) else null, }; } fn sessionsFromRows(allocator: std.mem.Allocator, rows: anytype) ![]SessionInfo { var out: std.ArrayList(SessionInfo) = .empty; while (rows.next()) |row| { try out.append(allocator, .{ .id = try allocator.dupe(u8, row.text(0)), .did = try allocator.dupe(u8, row.text(1)), .handle = try allocator.dupe(u8, row.text(2)), .auth_method = try allocator.dupe(u8, row.text(3)), .app_password_name = if (row.nullableText(4)) |text| try allocator.dupe(u8, text) else null, .controller_did = if (row.nullableText(5)) |text| try allocator.dupe(u8, text) else null, .created_at = row.int(6), .updated_at = row.int(7), .last_used_at = row.nullableInt(8), .access_expires_at = row.int(9), .refresh_expires_at = row.int(10), .revoked_at = row.nullableInt(11), .active = row.int(12) != 0, }); } return out.toOwnedSlice(allocator); } fn oauthGrantsFromRows(allocator: std.mem.Allocator, rows: anytype) ![]OAuthGrantInfo { var out: std.ArrayList(OAuthGrantInfo) = .empty; while (rows.next()) |row| { try out.append(allocator, .{ .did = try allocator.dupe(u8, row.text(0)), .handle = try allocator.dupe(u8, row.text(1)), .client_id = try allocator.dupe(u8, row.text(2)), .scope = try allocator.dupe(u8, row.text(3)), .created_at = row.int(4), .expires_at = row.int(5), .revoked_at = row.nullableInt(6), .auth_method = if (row.nullableText(7)) |text| try allocator.dupe(u8, text) else null, .active = row.int(8) != 0, }); } return out.toOwnedSlice(allocator); } fn stringifyValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); try out.writer.print("{f}", .{std.json.fmt(value, .{})}); return out.toOwnedSlice(); } const Root = struct { cid: []const u8, rev: []const u8, }; fn latestRootLocked(allocator: std.mem.Allocator, did: []const u8) !Root { return latestRootFrom(conn, allocator, did); } fn latestRootFrom( read_conn: zqlite.Conn, allocator: std.mem.Allocator, did: []const u8, ) !Root { const row = try read_conn.row( \\SELECT cid, rev \\FROM commits \\WHERE did = ? \\ORDER BY seq DESC \\LIMIT 1 , .{did}); if (row == null) return Error.RepoNotFound; defer row.?.deinit(); return .{ .cid = try allocator.dupe(u8, row.?.text(0)), .rev = try allocator.dupe(u8, row.?.text(1)), }; } fn rebuildSeqEventsSync11Locked(allocator: std.mem.Allocator) !void { var rows = try conn.rows( \\SELECT c.seq, c.did, c.cid, c.rev, c.prev, p.rev \\FROM commits c \\LEFT JOIN commits p ON p.did = c.did AND p.cid = c.prev \\ORDER BY c.seq ASC , .{}); defer rows.deinit(); var commits: std.ArrayList(struct { seq: u64, did: []const u8, cid: []const u8, rev: []const u8, prev: ?[]const u8, since_rev: ?[]const u8, }) = .empty; while (rows.next()) |row| { try commits.append(allocator, .{ .seq = @intCast(row.int(0)), .did = try allocator.dupe(u8, row.text(1)), .cid = try allocator.dupe(u8, row.text(2)), .rev = try allocator.dupe(u8, row.text(3)), .prev = if (row.nullableText(4)) |prev| try allocator.dupe(u8, prev) else null, .since_rev = if (row.nullableText(5)) |rev| try allocator.dupe(u8, rev) else null, }); } if (rows.err) |err| return err; for (commits.items) |commit| { { var commit_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer commit_arena.deinit(); const commit_allocator = commit_arena.allocator(); const repo_car = writeRepoCarFromLocked(commit_allocator, commit.did, commit.cid) catch continue; const prev_data_raw = if (commit.prev) |prev_commit| commitDataCidRawLocked(commit_allocator, commit.did, prev_commit) catch null else null; const frame = commitEventFrameFromCar(commit_allocator, commit.seq, commit.did, commit.cid, commit.rev, commit.since_rev, prev_data_raw, repo_car, &.{}) catch continue; try conn.exec( \\INSERT INTO seq_events (seq, did, commit_cid, evt) \\VALUES (?, ?, ?, ?) \\ON CONFLICT(seq) DO UPDATE SET \\ did = excluded.did, \\ commit_cid = excluded.commit_cid, \\ evt = excluded.evt , .{ @as(i64, @intCast(commit.seq)), commit.did, commit.cid, zqlite.blob(frame) }); } } } fn commitDataCidRawLocked(allocator: std.mem.Allocator, did: []const u8, commit_cid: []const u8) ![]const u8 { const row = try conn.row( \\SELECT data \\FROM repo_blocks \\WHERE did = ? AND cid = ? \\LIMIT 1 , .{ did, commit_cid }); if (row == null) return Error.MissingRecordBlock; defer row.?.deinit(); const commit_data = row.?.nullableBlob(0) orelse return Error.MissingRecordBlock; const commit_value = try zat.cbor.decodeAll(allocator, commit_data); const data = commit_value.getCid("data") orelse return Error.InvalidRepoPath; return try allocator.dupe(u8, data.raw); } fn writeRepoCarFromLocked(allocator: std.mem.Allocator, did: []const u8, root_cid: []const u8) ![]const u8 { const root_raw = try zat.multibase.base32lower.decode(allocator, root_cid[1..]); const car_root = zat.cbor.Cid{ .raw = root_raw }; var rows = try conn.rows( \\SELECT cid, data \\FROM repo_blocks \\WHERE did = ? \\ORDER BY cid ASC , .{did}); defer rows.deinit(); var blocks: std.ArrayList(zat.car.Block) = .empty; while (rows.next()) |row| { const cid_text = row.text(0); const data = row.nullableBlob(1) orelse ""; const cid_raw = try zat.multibase.base32lower.decode(allocator, cid_text[1..]); try blocks.append(allocator, .{ .cid_raw = cid_raw, .data = try allocator.dupe(u8, data), }); } if (rows.err) |err| return err; return zat.car.writeAlloc(allocator, .{ .roots = &.{car_root}, .blocks = blocks.items, }); } fn commitEventFrame( allocator: std.mem.Allocator, seq: u64, did: []const u8, commit_cid: []const u8, rev: []const u8, since_rev: ?[]const u8, prev_data_raw: ?[]const u8, commit_data: []const u8, record_blocks: []const ImportedBlock, mst_blocks: []const zat.car.Block, ops: []const WriteOp, records: []const Record, prev_record_cids: []const ?[]const u8, ) ![]const u8 { const root_raw = try cidRawFromText(allocator, commit_cid); var blocks: std.ArrayList(zat.car.Block) = .empty; try blocks.append(allocator, .{ .cid_raw = root_raw, .data = commit_data }); for (record_blocks) |block| { try blocks.append(allocator, .{ .cid_raw = try cidRawFromText(allocator, block.cid), .data = block.data, }); } for (mst_blocks) |block| try blocks.append(allocator, block); const car_bytes = try zat.car.writeAlloc(allocator, .{ .roots = &.{.{ .raw = root_raw }}, .blocks = blocks.items, }); var op_values = try allocator.alloc(zat.firehose.CommitEventOp, ops.len); var record_idx: usize = 0; for (ops, 0..) |op, idx| switch (op) { .create => |create_op| { const record = records[record_idx]; record_idx += 1; op_values[idx] = try firehoseOp(allocator, .create, create_op.collection, record.rkey, record.cid, null); }, .update => |update_op| { const record = records[record_idx]; record_idx += 1; op_values[idx] = try firehoseOp(allocator, .update, update_op.collection, update_op.rkey, record.cid, prev_record_cids[idx]); }, .delete => |delete_op| { op_values[idx] = try firehoseOp(allocator, .delete, delete_op.collection, delete_op.rkey, null, prev_record_cids[idx]); }, }; return commitEventFrameFromCar(allocator, seq, did, commit_cid, rev, since_rev, prev_data_raw, car_bytes, op_values); } fn importedCommitEventFrame( allocator: std.mem.Allocator, seq: u64, did: []const u8, commit_cid: []const u8, rev: []const u8, blocks: []const ImportedBlock, ) ![]const u8 { var car_blocks: std.ArrayList(zat.car.Block) = .empty; for (blocks) |block| { try car_blocks.append(allocator, .{ .cid_raw = try cidRawFromText(allocator, block.cid), .data = block.data, }); } const root_raw = try cidRawFromText(allocator, commit_cid); const car_bytes = try zat.car.writeAlloc(allocator, .{ .roots = &.{.{ .raw = root_raw }}, .blocks = car_blocks.items, }); return commitEventFrameFromCar(allocator, seq, did, commit_cid, rev, null, null, car_bytes, &.{}); } fn accountEventFrame(allocator: std.mem.Allocator, seq: u64, did: []const u8, status: AccountStatus) ![]const u8 { const header = try eventHeader(allocator, "#account"); const body = if (status.isActive()) try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ .{ .key = "seq", .value = .{ .unsigned = seq } }, .{ .key = "did", .value = .{ .text = did } }, .{ .key = "active", .value = .{ .boolean = true } }, .{ .key = "time", .value = .{ .text = try nowIso(allocator) } }, } }) else try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ .{ .key = "seq", .value = .{ .unsigned = seq } }, .{ .key = "did", .value = .{ .text = did } }, .{ .key = "active", .value = .{ .boolean = false } }, .{ .key = "status", .value = .{ .text = status.asString() } }, .{ .key = "time", .value = .{ .text = try nowIso(allocator) } }, } }); return joinFrame(allocator, header, body); } fn identityEventFrame(allocator: std.mem.Allocator, seq: u64, did: []const u8, handle: []const u8) ![]const u8 { const header = try eventHeader(allocator, "#identity"); const body = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ .{ .key = "seq", .value = .{ .unsigned = seq } }, .{ .key = "did", .value = .{ .text = did } }, .{ .key = "handle", .value = .{ .text = handle } }, .{ .key = "time", .value = .{ .text = try nowIso(allocator) } }, } }); return joinFrame(allocator, header, body); } fn syncEventFrame( allocator: std.mem.Allocator, seq: u64, did: []const u8, commit_cid: []const u8, data_cid_raw: []const u8, rev: []const u8, commit_data: []const u8, ) ![]const u8 { const root_raw = try cidRawFromText(allocator, commit_cid); const car_bytes = try zat.car.writeAlloc(allocator, .{ .roots = &.{.{ .raw = data_cid_raw }}, .blocks = &.{.{ .cid_raw = root_raw, .data = commit_data }}, }); const header = try eventHeader(allocator, "#sync"); const body = try zat.cbor.encodeAlloc(allocator, .{ .map = &.{ .{ .key = "seq", .value = .{ .unsigned = seq } }, .{ .key = "did", .value = .{ .text = did } }, .{ .key = "blocks", .value = .{ .bytes = car_bytes } }, .{ .key = "rev", .value = .{ .text = rev } }, .{ .key = "time", .value = .{ .text = try nowIso(allocator) } }, } }); return joinFrame(allocator, header, body); } fn commitEventFrameFromCar( allocator: std.mem.Allocator, seq: u64, did: []const u8, commit_cid: []const u8, rev: []const u8, since_rev: ?[]const u8, prev_data_raw: ?[]const u8, car_bytes: []const u8, ops: []const zat.firehose.CommitEventOp, ) ![]const u8 { const commit_raw = try cidRawFromText(allocator, commit_cid); return zat.firehose.encodeCommitEvent(allocator, .{ .seq = @intCast(seq), .repo_did = did, .commit_cid = .{ .raw = commit_raw }, .rev = rev, .since_rev = since_rev, .prev_data = if (prev_data_raw) |raw| .{ .raw = raw } else null, .blocks = car_bytes, .ops = ops, .time = try nowIso(allocator), }); } fn eventHeader(allocator: std.mem.Allocator, tag: []const u8) ![]const u8 { return zat.cbor.encodeAlloc(allocator, .{ .map = &.{ .{ .key = "op", .value = .{ .unsigned = 1 } }, .{ .key = "t", .value = .{ .text = tag } }, } }); } fn joinFrame(allocator: std.mem.Allocator, header: []const u8, body: []const u8) ![]const u8 { var frame = try allocator.alloc(u8, header.len + body.len); @memcpy(frame[0..header.len], header); @memcpy(frame[header.len..], body); return frame; } fn firehoseOp( allocator: std.mem.Allocator, action: zat.CommitAction, collection: []const u8, rkey: []const u8, cid: ?[]const u8, prev: ?[]const u8, ) !zat.firehose.CommitEventOp { return .{ .action = action, .collection = collection, .rkey = rkey, .cid = if (cid) |c| .{ .raw = try cidRawFromText(allocator, c) } else null, .prev = if (prev) |p| .{ .raw = try cidRawFromText(allocator, p) } else null, }; } fn nowIso(allocator: std.mem.Allocator) ![]const u8 { const seconds_i64 = clock.nowSeconds(); const seconds: u64 = if (seconds_i64 < 0) 0 else @intCast(seconds_i64); const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = seconds }; const year_day = epoch_seconds.getEpochDay().calculateYearDay(); const month_day = year_day.calculateMonthDay(); const day_seconds = epoch_seconds.getDaySeconds(); return std.fmt.allocPrint( allocator, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.000Z", .{ year_day.year, @backingInt(month_day.month), month_day.day_index + 1, day_seconds.getHoursIntoDay(), day_seconds.getMinutesIntoHour(), day_seconds.getSecondsIntoMinute(), }, ); } fn scalarCountLocked(sql: [:0]const u8, did: []const u8) !u64 { const row = try conn.row(sql, .{did}); if (row == null) return 0; defer row.?.deinit(); return @intCast(row.?.int(0)); } fn accountActiveLocked(did: []const u8) !bool { return (try accountStatusLocked(did)).isActive(); } fn accountStatusLocked(did: []const u8) !AccountStatus { const row = try conn.row( \\SELECT account_status \\FROM accounts \\WHERE did = ? , .{did}); if (row == null) return Error.RepoNotFound; defer row.?.deinit(); return AccountStatus.parse(row.?.text(0)); } fn revokeAccountTokensLocked(did: []const u8) !void { try conn.exec( \\UPDATE session_tokens \\SET revoked_at = unixepoch(), updated_at = unixepoch() \\WHERE did = ? AND revoked_at IS NULL , .{did}); try conn.exec( \\UPDATE oauth_tokens \\SET revoked_at = unixepoch() \\WHERE did = ? AND revoked_at IS NULL , .{did}); } fn cidForJson(allocator: std.mem.Allocator, json: []const u8) ![]const u8 { const cid = try zat.cbor.Cid.forDagCbor(allocator, json); defer allocator.free(cid.raw); return zat.multibase.base32lower.encode(allocator, cid.raw); } fn cidForBlob(allocator: std.mem.Allocator, data: []const u8) ![]const u8 { const cid = try zat.cbor.Cid.create(allocator, 1, 0x55, 0x12, data); defer allocator.free(cid.raw); return zat.multibase.base32lower.encode(allocator, cid.raw); } fn cidForBlobDigest( allocator: std.mem.Allocator, digest: [std.crypto.hash.sha2.Sha256.digest_length]u8, ) ![]const u8 { var raw: [4 + std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; raw[0..4].* = .{ 1, 0x55, 0x12, std.crypto.hash.sha2.Sha256.digest_length }; @memcpy(raw[4..], &digest); return zat.multibase.base32lower.encode(allocator, &raw); } fn requireInitialized() !void { if (!initialized) return Error.StoreNotInitialized; } fn insertInviteCodeLocked(code: []const u8, use_count: i64, for_account: []const u8, created_by: []const u8) !void { try conn.exec( \\INSERT INTO invite_codes (code, available_uses, disabled, for_account, created_by, created_at) \\VALUES (?, ?, 0, ?, ?, unixepoch()) , .{ code, use_count, for_account, created_by }); } fn ensureInviteAvailableLocked(code: []const u8) !void { const row = try conn.row( \\SELECT available_uses, disabled, \\ (SELECT COUNT(*) FROM invite_code_uses u WHERE u.code = invite_codes.code) AS uses \\FROM invite_codes \\WHERE code = ? \\LIMIT 1 , .{code}); if (row == null) return error.InvalidInviteCode; defer row.?.deinit(); if (row.?.int(1) != 0) return error.InvalidInviteCode; if (row.?.int(2) >= row.?.int(0)) return error.InvalidInviteCode; } fn recordInviteUseLocked(code: []const u8, used_by: []const u8) !void { try conn.exec( \\INSERT INTO invite_code_uses (code, used_by, used_at) \\VALUES (?, ?, unixepoch()) , .{ code, used_by }); } fn inviteCodeUsesLocked(allocator: std.mem.Allocator, code: []const u8) ![]InviteCodeUse { var rows = try conn.rows( \\SELECT used_by, used_at \\FROM invite_code_uses \\WHERE code = ? \\ORDER BY used_at DESC, used_by , .{code}); defer rows.deinit(); var uses: std.ArrayList(InviteCodeUse) = .empty; while (rows.next()) |row| { try uses.append(allocator, .{ .used_by = try allocator.dupe(u8, row.text(0)), .used_at = row.int(1), }); } if (rows.err) |err| return err; return uses.toOwnedSlice(allocator); } fn generateInviteCode(allocator: std.mem.Allocator, public_url: []const u8) ![]const u8 { const host = publicHost(public_url); const normalized = try normalizedInviteHost(allocator, host); var first: [5]u8 = undefined; var second: [5]u8 = undefined; fillBase32(&first); fillBase32(&second); return std.fmt.allocPrint(allocator, "{s}-{s}-{s}", .{ normalized, &first, &second }); } fn publicHost(public_url: []const u8) []const u8 { const after_scheme = if (std.mem.indexOf(u8, public_url, "://")) |idx| public_url[idx + 3 ..] else public_url; const end = std.mem.indexOfAny(u8, after_scheme, "/?#") orelse after_scheme.len; const host_port = after_scheme[0..end]; if (std.mem.lastIndexOfScalar(u8, host_port, ':')) |idx| return host_port[0..idx]; return host_port; } fn normalizedInviteHost(allocator: std.mem.Allocator, host: []const u8) ![]const u8 { const out = try allocator.alloc(u8, host.len); for (host, 0..) |c, i| { out[i] = switch (c) { '.' => '-', else => std.ascii.toLower(c), }; } return out; } fn fillBase32(out: *[5]u8) void { const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; var bytes: [5]u8 = undefined; randomBytes(&bytes); for (&bytes, 0..) |b, i| out[i] = alphabet[b % alphabet.len]; } const schema_statements = [_][*:0]const u8{ \\CREATE TABLE IF NOT EXISTS accounts ( \\ did TEXT PRIMARY KEY, \\ handle TEXT NOT NULL UNIQUE, \\ email TEXT NOT NULL UNIQUE, \\ email_confirmed_at INTEGER, \\ auth_code TEXT, \\ auth_code_expires_at INTEGER, \\ pending_email TEXT, \\ invites_disabled INTEGER NOT NULL DEFAULT 0, \\ activated_at INTEGER, \\ account_status TEXT NOT NULL DEFAULT 'active', \\ account_status_ref TEXT, \\ signing_key_type TEXT, \\ signing_key BLOB, \\ password_hash TEXT NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , "CREATE UNIQUE INDEX IF NOT EXISTS accounts_handle_nocase_idx ON accounts (lower(handle))", \\CREATE TABLE IF NOT EXISTS rate_limit_events ( \\ id INTEGER PRIMARY KEY AUTOINCREMENT, \\ subject TEXT NOT NULL, \\ action TEXT NOT NULL, \\ occurred_at INTEGER NOT NULL \\) , "CREATE INDEX IF NOT EXISTS rate_limit_events_lookup_idx ON rate_limit_events (subject, action, occurred_at)", \\CREATE TABLE IF NOT EXISTS records ( \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ collection TEXT NOT NULL, \\ rkey TEXT NOT NULL, \\ uri TEXT NOT NULL UNIQUE, \\ cid TEXT NOT NULL, \\ rev TEXT NOT NULL, \\ seq INTEGER NOT NULL, \\ PRIMARY KEY (did, collection, rkey) \\) , "CREATE INDEX IF NOT EXISTS records_collection_idx ON records (did, collection, seq DESC)", "CREATE INDEX IF NOT EXISTS records_cid_idx ON records (cid)", \\CREATE TABLE IF NOT EXISTS commits ( \\ seq INTEGER PRIMARY KEY, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ cid TEXT NOT NULL, \\ rev TEXT NOT NULL, \\ prev TEXT \\) , "CREATE INDEX IF NOT EXISTS commits_did_idx ON commits (did, seq DESC)", // Startup revision backfill and commit-parent lookups match both columns. "CREATE INDEX IF NOT EXISTS commits_cid_idx ON commits (did, cid)", \\CREATE TABLE IF NOT EXISTS seq_events ( \\ seq INTEGER PRIMARY KEY, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ commit_cid TEXT NOT NULL, \\ evt BLOB NOT NULL \\) , \\CREATE TABLE IF NOT EXISTS blocks ( \\ cid TEXT PRIMARY KEY, \\ data BLOB NOT NULL \\) , \\CREATE TABLE IF NOT EXISTS blobs ( \\ cid TEXT NOT NULL, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ mime_type TEXT NOT NULL, \\ size INTEGER NOT NULL, \\ storage TEXT NOT NULL DEFAULT 'disk', \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ PRIMARY KEY (did, cid) \\) , \\CREATE TABLE IF NOT EXISTS record_blobs ( \\ blob_cid TEXT NOT NULL, \\ record_uri TEXT NOT NULL REFERENCES records(uri) ON DELETE CASCADE, \\ PRIMARY KEY (blob_cid, record_uri) \\) , \\CREATE TABLE IF NOT EXISTS app_preferences ( \\ id INTEGER PRIMARY KEY AUTOINCREMENT, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ name TEXT NOT NULL, \\ value_json BLOB NOT NULL, \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , \\CREATE TABLE IF NOT EXISTS browser_sessions ( \\ token_hash TEXT PRIMARY KEY, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ password_hash TEXT NOT NULL, \\ expires_at INTEGER NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , \\CREATE TABLE IF NOT EXISTS oauth_requests ( \\ request_id TEXT PRIMARY KEY, \\ client_id TEXT NOT NULL, \\ redirect_uri TEXT NOT NULL, \\ scope TEXT NOT NULL, \\ state TEXT NOT NULL, \\ code_challenge TEXT NOT NULL, \\ code_challenge_method TEXT NOT NULL, \\ response_mode TEXT NOT NULL DEFAULT 'query', \\ login_hint TEXT, \\ dpop_jkt TEXT, \\ expires_at INTEGER NOT NULL, \\ sub TEXT, \\ code TEXT UNIQUE, \\ auth_method TEXT, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , \\CREATE TABLE IF NOT EXISTS oauth_tokens ( \\ family_id TEXT NOT NULL UNIQUE, \\ access_token TEXT PRIMARY KEY, \\ refresh_token TEXT NOT NULL UNIQUE, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ client_id TEXT NOT NULL, \\ scope TEXT NOT NULL, \\ expires_at INTEGER NOT NULL, \\ access_expires_at INTEGER NOT NULL, \\ refresh_expires_at INTEGER NOT NULL, \\ previous_refresh_token TEXT, \\ previous_refresh_expires_at INTEGER, \\ dpop_jkt TEXT, \\ auth_method TEXT, \\ revoked_at INTEGER, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , \\CREATE TABLE IF NOT EXISTS dpop_jtis ( \\ jti TEXT PRIMARY KEY, \\ expires_at INTEGER NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , \\CREATE TABLE IF NOT EXISTS session_tokens ( \\ id TEXT PRIMARY KEY, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ access_jti TEXT NOT NULL UNIQUE, \\ refresh_jti TEXT NOT NULL UNIQUE, \\ access_expires_at INTEGER NOT NULL, \\ refresh_expires_at INTEGER NOT NULL, \\ auth_method TEXT NOT NULL, \\ controller_did TEXT, \\ app_password_name TEXT, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ last_used_at INTEGER, \\ revoked_at INTEGER, \\ previous_refresh_jti TEXT, \\ previous_refresh_expires_at INTEGER \\) , "CREATE INDEX IF NOT EXISTS session_tokens_did_idx ON session_tokens (did, created_at DESC)", "CREATE INDEX IF NOT EXISTS session_tokens_access_jti_idx ON session_tokens (access_jti)", "CREATE INDEX IF NOT EXISTS session_tokens_refresh_jti_idx ON session_tokens (refresh_jti)", "CREATE INDEX IF NOT EXISTS session_tokens_controller_idx ON session_tokens (controller_did) WHERE controller_did IS NOT NULL", "CREATE INDEX IF NOT EXISTS session_tokens_app_password_idx ON session_tokens (did, app_password_name) WHERE app_password_name IS NOT NULL", \\CREATE TABLE IF NOT EXISTS app_passwords ( \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ name TEXT NOT NULL, \\ password_hash TEXT NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ privileged INTEGER NOT NULL DEFAULT 0, \\ scopes TEXT, \\ created_by_controller_did TEXT, \\ PRIMARY KEY (did, name) \\) , "CREATE INDEX IF NOT EXISTS app_passwords_did_created_idx ON app_passwords (did, created_at DESC)", \\CREATE TABLE IF NOT EXISTS account_audit_log ( \\ id TEXT PRIMARY KEY, \\ subject_did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ actor_did TEXT NOT NULL, \\ controller_did TEXT, \\ action TEXT NOT NULL, \\ details_json TEXT NOT NULL DEFAULT '{}', \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , "CREATE INDEX IF NOT EXISTS account_audit_subject_idx ON account_audit_log (subject_did, created_at DESC)", "CREATE INDEX IF NOT EXISTS account_audit_controller_idx ON account_audit_log (controller_did, created_at DESC) WHERE controller_did IS NOT NULL", \\CREATE TABLE IF NOT EXISTS passkeys ( \\ id TEXT PRIMARY KEY, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ credential_id BLOB NOT NULL UNIQUE, \\ public_key BLOB NOT NULL, \\ sign_count INTEGER NOT NULL DEFAULT 0, \\ friendly_name TEXT, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ last_used INTEGER \\) , "CREATE INDEX IF NOT EXISTS passkeys_did_idx ON passkeys (did)", \\CREATE TABLE IF NOT EXISTS webauthn_challenges ( \\ id TEXT PRIMARY KEY, \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ challenge TEXT NOT NULL, \\ kind TEXT NOT NULL, \\ state_json TEXT NOT NULL, \\ expires_at INTEGER NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , "CREATE INDEX IF NOT EXISTS webauthn_challenges_did_kind_idx ON webauthn_challenges (did, kind)", \\CREATE TABLE IF NOT EXISTS webauthn_discoverable_challenges ( \\ request_id TEXT PRIMARY KEY REFERENCES oauth_requests(request_id) ON DELETE CASCADE, \\ challenge TEXT NOT NULL, \\ expires_at INTEGER NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , \\CREATE TABLE IF NOT EXISTS invite_codes ( \\ code TEXT PRIMARY KEY, \\ available_uses INTEGER NOT NULL, \\ disabled INTEGER NOT NULL DEFAULT 0, \\ for_account TEXT NOT NULL, \\ created_by TEXT NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , "CREATE INDEX IF NOT EXISTS invite_codes_for_account_idx ON invite_codes (for_account)", \\CREATE TABLE IF NOT EXISTS invite_code_uses ( \\ code TEXT NOT NULL REFERENCES invite_codes(code) ON DELETE CASCADE, \\ used_by TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ used_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ PRIMARY KEY (code, used_by) \\) , \\CREATE TABLE IF NOT EXISTS reserved_signing_keys ( \\ id INTEGER PRIMARY KEY AUTOINCREMENT, \\ did TEXT, \\ signing_key TEXT NOT NULL UNIQUE, \\ signing_key_type TEXT NOT NULL, \\ secret_key BLOB NOT NULL, \\ expires_at INTEGER NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), \\ used_at INTEGER \\) , "CREATE INDEX IF NOT EXISTS reserved_signing_keys_did_idx ON reserved_signing_keys (did) WHERE did IS NOT NULL", "CREATE INDEX IF NOT EXISTS reserved_signing_keys_expires_idx ON reserved_signing_keys (expires_at) WHERE used_at IS NULL", \\CREATE TABLE IF NOT EXISTS permissioned_spaces ( \\ uri TEXT PRIMARY KEY, \\ authority_did TEXT NOT NULL, \\ space_type TEXT NOT NULL, \\ skey TEXT NOT NULL, \\ read_managing_app TEXT, \\ read_policy TEXT NOT NULL DEFAULT 'member-list', \\ write_managing_app TEXT, \\ write_policy TEXT NOT NULL DEFAULT 'member-list', \\ app_access_json TEXT NOT NULL DEFAULT '{"type":"open"}', \\ is_authority INTEGER NOT NULL DEFAULT 1, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ deleted_at INTEGER, \\ UNIQUE(authority_did, space_type, skey) \\) , \\CREATE TABLE IF NOT EXISTS permissioned_space_actor_state ( \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, \\ actor_did TEXT NOT NULL, \\ is_authority INTEGER NOT NULL DEFAULT 0, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ deleted_at INTEGER, \\ PRIMARY KEY (space, actor_did) \\) , \\CREATE TABLE IF NOT EXISTS simplespace_members ( \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, \\ member_did TEXT NOT NULL, \\ read INTEGER NOT NULL DEFAULT 1, \\ write INTEGER NOT NULL DEFAULT 1, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ PRIMARY KEY (space, member_did) \\) , "CREATE INDEX IF NOT EXISTS simplespace_members_member_idx ON simplespace_members (member_did, space)", "CREATE INDEX IF NOT EXISTS permissioned_space_actor_state_actor_idx ON permissioned_space_actor_state (actor_did, space)", \\CREATE TABLE IF NOT EXISTS permissioned_space_records ( \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, \\ repo_did TEXT NOT NULL, \\ collection TEXT NOT NULL, \\ rkey TEXT NOT NULL, \\ cid TEXT NOT NULL, \\ value_json BLOB NOT NULL, \\ validation_status TEXT NOT NULL DEFAULT 'unknown', \\ repo_rev TEXT NOT NULL DEFAULT '', \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ PRIMARY KEY (space, repo_did, collection, rkey) \\) , "CREATE INDEX IF NOT EXISTS permissioned_space_records_list_idx ON permissioned_space_records (space, repo_did, collection, rkey)", \\CREATE TABLE IF NOT EXISTS permissioned_space_record_blobs ( \\ space TEXT NOT NULL, \\ repo_did TEXT NOT NULL, \\ collection TEXT NOT NULL, \\ rkey TEXT NOT NULL, \\ blob_cid TEXT NOT NULL, \\ PRIMARY KEY (space, repo_did, collection, rkey, blob_cid), \\ FOREIGN KEY (space, repo_did, collection, rkey) \\ REFERENCES permissioned_space_records(space, repo_did, collection, rkey) \\ ON DELETE CASCADE \\) , "CREATE INDEX IF NOT EXISTS permissioned_space_record_blobs_blob_idx ON permissioned_space_record_blobs (repo_did, blob_cid)", \\CREATE TABLE IF NOT EXISTS permissioned_space_repos ( \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, \\ repo_did TEXT NOT NULL, \\ set_hash BLOB, \\ rev TEXT, \\ PRIMARY KEY (space, repo_did) \\) , \\CREATE TABLE IF NOT EXISTS permissioned_space_writers ( \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, \\ repo_did TEXT NOT NULL, \\ rev TEXT NOT NULL, \\ hash BLOB NOT NULL, \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()), \\ PRIMARY KEY (space, repo_did) \\) , \\CREATE TABLE IF NOT EXISTS permissioned_space_record_oplog ( \\ space TEXT NOT NULL, \\ repo_did TEXT NOT NULL, \\ rev TEXT NOT NULL, \\ idx INTEGER NOT NULL, \\ action TEXT NOT NULL, \\ collection TEXT NOT NULL, \\ rkey TEXT NOT NULL, \\ cid TEXT, \\ prev TEXT, \\ PRIMARY KEY (space, repo_did, rev, idx) \\) , \\CREATE TABLE IF NOT EXISTS permissioned_space_notify_registrations ( \\ space TEXT NOT NULL, \\ service_id TEXT NOT NULL, \\ service_endpoint TEXT NOT NULL, \\ expires_at INTEGER NOT NULL, \\ PRIMARY KEY (space, service_id) \\) , }; const post_schema_statements = [_][*:0]const u8{ \\CREATE TABLE IF NOT EXISTS expected_blobs ( \\ blob_cid TEXT NOT NULL, \\ record_uri TEXT NOT NULL REFERENCES records(uri) ON DELETE CASCADE, \\ PRIMARY KEY (blob_cid, record_uri) \\) , "CREATE INDEX IF NOT EXISTS blobs_gc_idx ON blobs (created_at, did, cid)", "CREATE UNIQUE INDEX IF NOT EXISTS oauth_tokens_family_id_idx ON oauth_tokens (family_id)", "CREATE UNIQUE INDEX IF NOT EXISTS oauth_tokens_previous_refresh_token_idx ON oauth_tokens (previous_refresh_token) WHERE previous_refresh_token IS NOT NULL", "CREATE INDEX IF NOT EXISTS session_tokens_previous_refresh_jti_idx ON session_tokens (previous_refresh_jti) WHERE previous_refresh_jti IS NOT NULL", \\CREATE TABLE IF NOT EXISTS oauth_used_refresh_tokens ( \\ refresh_token TEXT PRIMARY KEY, \\ family_id TEXT NOT NULL REFERENCES oauth_tokens(family_id) ON DELETE CASCADE, \\ expires_at INTEGER NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , \\CREATE TABLE IF NOT EXISTS permissioned_space_used_delegations ( \\ jti TEXT PRIMARY KEY, \\ expires_at INTEGER NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , \\CREATE TABLE IF NOT EXISTS permissioned_space_used_client_attestations ( \\ jti TEXT PRIMARY KEY, \\ expires_at INTEGER NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) \\) , \\CREATE TABLE IF NOT EXISTS repo_blocks ( \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, \\ cid TEXT NOT NULL, \\ data BLOB NOT NULL, \\ repo_rev TEXT, \\ PRIMARY KEY (did, cid) \\) , "CREATE INDEX IF NOT EXISTS repo_blocks_rev_idx ON repo_blocks (did, repo_rev DESC, cid DESC)", // Backfill must not scan payload-bearing rows whose revisions are already known. "CREATE INDEX IF NOT EXISTS repo_blocks_missing_rev_idx ON repo_blocks (did, cid) WHERE repo_rev IS NULL", }; test "persists records in sqlite" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "alice.test", "alice@test.com", "password", "did:plc:cmadossymmii3izkabdbp5en", true, ); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"hello\"}", .{}); defer parsed.deinit(); const rkey = "3jzfcijpj2z2a"; const record = try create(allocator, account, "app.bsky.feed.post", rkey, parsed.value); try std.testing.expectEqualStrings(rkey, record.rkey); try std.testing.expect(!try recordsTableHasValueJsonColumn()); const block_row = try conn.row( "SELECT count(*) FROM repo_blocks WHERE did = ? AND cid = ?", .{ account.did, record.cid }, ); try std.testing.expect(block_row != null); defer block_row.?.deinit(); try std.testing.expectEqual(@as(i64, 1), block_row.?.int(0)); const fetched = get(account.did, "app.bsky.feed.post", rkey).?; try std.testing.expectEqualStrings(record.cid, fetched.cid); try std.testing.expect(std.mem.indexOf(u8, fetched.value_json, "hello") != null); const listed = try writeListJson(allocator, account.did, "app.bsky.feed.post", null, false, 10); try std.testing.expect(std.mem.indexOf(u8, listed, "hello") != null); const repo_car = try writeRepoCar(allocator, account.did); const loaded = try zat.loadCommitFromCAR(allocator, repo_car); try std.testing.expectEqualStrings(account.did, loaded.commit.did); try std.testing.expectEqualStrings(fetched.rev, loaded.commit.rev); try std.testing.expectEqualStrings((try latestRootLocked(allocator, account.did)).cid, try cidText(allocator, loaded.commit_cid)); // Legacy blocks must still recover their record and commit revisions on restart. try conn.exec("UPDATE repo_blocks SET repo_rev = NULL WHERE did = ?", .{account.did}); try migrateRepoBlockRevs(); const restored = try conn.row("SELECT repo_rev FROM repo_blocks WHERE did = ? AND cid = ?", .{ account.did, record.cid }); try std.testing.expect(restored != null); defer restored.?.deinit(); try std.testing.expectEqualStrings(fetched.rev, restored.?.text(0)); const root = try latestRootLocked(allocator, account.did); const restored_commit = try conn.row("SELECT repo_rev FROM repo_blocks WHERE did = ? AND cid = ?", .{ account.did, root.cid }); try std.testing.expect(restored_commit != null); defer restored_commit.?.deinit(); try std.testing.expectEqualStrings(root.rev, restored_commit.?.text(0)); const tree = try zat.mst.Mst.loadFromBlocks(allocator, loaded.repo_car, loaded.commit.data_cid); const found = tree.get("app.bsky.feed.post/3jzfcijpj2z2a") orelse return error.MissingRecord; try std.testing.expectEqualStrings(record.cid, try cidText(allocator, found.raw)); try conn.exec( "DELETE FROM repo_blocks WHERE did = ? AND cid = ?", .{ account.did, record.cid }, ); try std.testing.expect(get(account.did, "app.bsky.feed.post", rkey) == null); } test "active account resolution excludes unavailable identities" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "Alice.Example.COM", "alice-active@test.com", "password", "did:plc:activehandle", true, ); try std.testing.expect((try findActiveAccount(allocator, "alice.example.com")) != null); try setAccountActive(account.did, false); try std.testing.expect((try findAccount(allocator, "alice.example.com")) != null); try std.testing.expect((try findActiveAccount(allocator, "alice.example.com")) == null); } test "durable rate limits are isolated by subject and window" { try init(std.Options.debug_io, ":memory:"); defer close(); try consumeRateLimit("did:plc:alice", "identity.updateHandle", 1_000, 1_000, 2); try consumeRateLimit("did:plc:alice", "identity.updateHandle", 1_500, 1_000, 2); try std.testing.expectError( error.RateLimitExceeded, consumeRateLimit("did:plc:alice", "identity.updateHandle", 1_750, 1_000, 2), ); try consumeRateLimit("did:plc:bob", "identity.updateHandle", 1_750, 1_000, 2); try consumeRateLimit("did:plc:alice", "identity.updateHandle", 2_100, 1_000, 2); } test "repo writes enforce swap and explicit validation preconditions" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "swaps.test", "swaps@test.com", "password", "did:plc:swapscheck", true, ); const first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"before\"}", .{}); defer first.deinit(); const rkey = "3jzfcijpj2z2b"; const created_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", .rkey = rkey, .value = first.value, } }}, .{}); const first_cid = created_result.records[0].cid; const second = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"after\"}", .{}); defer second.deinit(); try std.testing.expectError(Error.InvalidSwap, applyWritesWithOptions(allocator, account, &.{.{ .update = .{ .collection = "app.bsky.feed.post", .rkey = rkey, .value = second.value, } }}, .{ .swap_commit = "bafkreiwrongcommit" })); try std.testing.expectError(Error.InvalidSwap, applyWritesWithOptions(allocator, account, &.{.{ .update = .{ .collection = "app.bsky.feed.post", .rkey = rkey, .value = second.value, .swap = .{ .cid = "bafkreiwrongrecord" }, } }}, .{})); const updated_result = try applyWritesWithOptions(allocator, account, &.{.{ .update = .{ .collection = "app.bsky.feed.post", .rkey = rkey, .value = second.value, .swap = .{ .cid = first_cid }, } }}, .{ .swap_commit = created_result.commit.cid }); try std.testing.expectEqualStrings("after", (try std.json.parseFromSlice(std.json.Value, allocator, updated_result.records[0].value_json, .{})).value.object.get("text").?.string); const custom = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"example.test.record\",\"text\":\"custom\"}", .{}); defer custom.deinit(); try std.testing.expectError(Error.ValidationRequired, applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "example.test.record", .rkey = "custom", .value = custom.value, } }}, .{ .validate = .require })); const custom_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "example.test.record", .rkey = "custom", .value = custom.value, } }}, .{ .validate = .skip }); try std.testing.expectEqualStrings("unknown", custom_result.records[0].validation_status); } test "repo writes lazily load existing MST blocks for mutation" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "lazy.test", "lazy@test.com", "password", "did:plc:lazywritecheck", true, ); var create_ops: [160]WriteOp = undefined; var create_parsed: [160]std.json.Parsed(std.json.Value) = undefined; var create_rkeys: [160][atid.encoded_len]u8 = undefined; for (&create_ops, &create_parsed, 0..) |*op, *parsed, i| { parsed.* = try std.json.parseFromSlice( std.json.Value, allocator, try std.fmt.allocPrint(allocator, "{{\"$type\":\"app.bsky.feed.post\",\"text\":\"record {d}\"}}", .{i}), .{}, ); create_rkeys[i] = try atid.encode(1_700_000_000_000_000 + i, 0); op.* = .{ .create = .{ .collection = "app.bsky.feed.post", .rkey = &create_rkeys[i], .value = parsed.value, } }; } defer for (&create_parsed) |*parsed| parsed.deinit(); _ = try applyWritesWithOptions(allocator, account, &create_ops, .{}); const update = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"updated\"}", .{}); defer update.deinit(); const update_rkey = create_rkeys[77]; const update_result = try applyWritesWithOptions(allocator, account, &.{.{ .update = .{ .collection = "app.bsky.feed.post", .rkey = &update_rkey, .value = update.value, } }}, .{}); const delete_rkey = create_rkeys[12]; _ = try applyWritesWithOptions(allocator, account, &.{.{ .delete = .{ .collection = "app.bsky.feed.post", .rkey = &delete_rkey, } }}, .{}); const repo_car = try writeRepoCar(allocator, account.did); const loaded = try zat.loadCommitFromCAR(allocator, repo_car); var tree = try zat.mst.Mst.loadFromBlocks(allocator, loaded.repo_car, loaded.commit.data_cid); const updated_path = try std.fmt.allocPrint(allocator, "app.bsky.feed.post/{s}", .{update_rkey}); const updated = tree.get(updated_path) orelse return error.MissingRecord; try std.testing.expectEqualStrings(update_result.records[0].cid, try cidText(allocator, updated.raw)); const deleted_path = try std.fmt.allocPrint(allocator, "app.bsky.feed.post/{s}", .{delete_rkey}); try std.testing.expect(tree.get(deleted_path) == null); const retained_rkey = create_rkeys[159]; const retained_path = try std.fmt.allocPrint(allocator, "app.bsky.feed.post/{s}", .{retained_rkey}); const retained = tree.get(retained_path) orelse return error.MissingRecord; try std.testing.expectEqualStrings((get(account.did, "app.bsky.feed.post", &retained_rkey) orelse return error.MissingRecord).cid, try cidText(allocator, retained.raw)); } test "commit firehose since uses previous repo rev" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "firehose-since.test", "firehose-since@test.com", "password", "did:plc:firehosesince", true, ); const first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"first\"}", .{}); defer first.deinit(); const first_rkey = "3jzfcijpj2z2c"; const first_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", .rkey = first_rkey, .value = first.value, } }}, .{}); const second = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"second\"}", .{}); defer second.deinit(); const second_rkey = "3jzfcijpj2z2d"; const second_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", .rkey = second_rkey, .value = second.value, } }}, .{}); try conn.exec("DELETE FROM seq_events WHERE did = ?", .{account.did}); try rebuildSeqEventsSync11Locked(allocator); const events = try listSeqEvents(allocator, 0, 20); var found_second = false; for (events) |event| { const decoded = zat.firehose.decodeFrame(allocator, event.frame) catch continue; switch (decoded) { .commit => |commit| { if (!std.mem.eql(u8, commit.repo, account.did)) continue; if (!std.mem.eql(u8, commit.rev, second_result.commit.rev)) continue; found_second = true; try std.testing.expect(commit.since != null); try std.testing.expectEqualStrings(first_result.commit.rev, commit.since.?); try std.testing.expect(!std.mem.eql(u8, first_result.commit.cid, commit.since.?)); }, else => {}, } } try std.testing.expect(found_second); } test "listing sequenced events does not perform event repair" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "firehose-read.test", "firehose-read@test.com", "password", "did:plc:firehoseread", true, ); const value = try std.json.parseFromSlice( std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"sequenced at write time\"}", .{}, ); defer value.deinit(); _ = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", .rkey = "3jzfcijpj2z2e", .value = value.value, } }}, .{}); const before = try scalarCountLocked("SELECT COUNT(*) FROM seq_events WHERE did = ?", account.did); try std.testing.expect(before > 0); try conn.exec("DELETE FROM seq_events WHERE did = ?", .{account.did}); const events = try listSeqEvents(allocator, 0, 100); for (events) |event| { const decoded = zat.firehose.decodeFrame(allocator, event.frame) catch continue; switch (decoded) { .commit => |commit| try std.testing.expect(!std.mem.eql(u8, commit.repo, account.did)), else => {}, } } try std.testing.expectEqual( @as(u64, 0), try scalarCountLocked("SELECT COUNT(*) FROM seq_events WHERE did = ?", account.did), ); } test "getRepo since filters repo blocks by revision" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "since.test", "since@test.com", "password", "did:plc:sincecheck", true, ); const first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"first\"}", .{}); defer first.deinit(); const first_rkey = "3jzfcijpj2z2e"; const first_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", .rkey = first_rkey, .value = first.value, } }}, .{}); const second = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"second\"}", .{}); defer second.deinit(); const second_rkey = "3jzfcijpj2z2f"; const second_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", .rkey = second_rkey, .value = second.value, } }}, .{}); const full_car = try zat.car.read(allocator, try writeRepoCar(allocator, account.did)); const diff_car = try zat.car.read(allocator, try writeRepoCarSince(allocator, account.did, first_result.commit.rev)); const empty_diff_car = try zat.car.read(allocator, try writeRepoCarSince(allocator, account.did, second_result.commit.rev)); try std.testing.expect(diff_car.blocks.len < full_car.blocks.len); try std.testing.expect(diff_car.blocks.len > 0); try std.testing.expectEqual(@as(usize, 0), empty_diff_car.blocks.len); try std.testing.expectEqualStrings(second_result.commit.cid, try cidText(allocator, empty_diff_car.roots[0].raw)); } fn carContainsCid(allocator: std.mem.Allocator, c: zat.car.Car, cid: []const u8) !bool { for (c.blocks) |block| { if (std.mem.eql(u8, cid, try cidText(allocator, block.cid_raw))) return true; } return false; } test "full getRepo exports only current reachable record blocks" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "reachable.test", "reachable@test.com", "password", "did:plc:reachablecheck", true, ); const first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"first\"}", .{}); defer first.deinit(); const rkey = "3jzfcijpj2z2g"; const first_result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", .rkey = rkey, .value = first.value, } }}, .{}); const first_cid = first_result.records[0].cid; const second = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"second\"}", .{}); defer second.deinit(); const second_result = try applyWritesWithOptions(allocator, account, &.{.{ .update = .{ .collection = "app.bsky.feed.post", .rkey = rkey, .value = second.value, } }}, .{}); const second_cid = second_result.records[0].cid; const updated_car = try zat.car.read(allocator, try writeRepoCar(allocator, account.did)); try std.testing.expect(!try carContainsCid(allocator, updated_car, first_cid)); try std.testing.expect(try carContainsCid(allocator, updated_car, second_cid)); _ = try applyWritesWithOptions(allocator, account, &.{.{ .delete = .{ .collection = "app.bsky.feed.post", .rkey = rkey, } }}, .{}); const deleted_car = try zat.car.read(allocator, try writeRepoCar(allocator, account.did)); try std.testing.expect(!try carContainsCid(allocator, deleted_car, first_cid)); try std.testing.expect(!try carContainsCid(allocator, deleted_car, second_cid)); } test "record proof car carries commit, mst path, and record" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "proof.test", "proof@test.com", "password", "did:plc:proofcheck", true, ); var last_commit_cid: []const u8 = ""; var target_cid: []const u8 = ""; const target_rkey = "3jzfcijpj2z2m"; for (0..24) |i| { const value = try std.json.parseFromSlice(std.json.Value, allocator, try std.fmt.allocPrint(allocator, "{{\"$type\":\"app.bsky.feed.post\",\"text\":\"post {d}\"}}", .{i}), .{}); const rkey = try std.fmt.allocPrint(allocator, "3jzfcijpj2a{c}{c}", .{ 'a' + @as(u8, @intCast(i / 12)), 'a' + @as(u8, @intCast(i % 12)) }); const result = try applyWritesWithOptions(allocator, account, &.{.{ .create = .{ .collection = "app.bsky.feed.post", .rkey = if (i == 11) target_rkey else rkey, .value = value.value, } }}, .{}); last_commit_cid = result.commit.cid; if (i == 11) target_cid = result.records[0].cid; } const proof_car = try zat.car.read(allocator, try writeRecordProofCar(allocator, account.did, "app.bsky.feed.post", target_rkey)); try std.testing.expectEqualStrings(last_commit_cid, try cidText(allocator, proof_car.roots[0].raw)); try std.testing.expect(try carContainsCid(allocator, proof_car, target_cid)); const full_car = try zat.car.read(allocator, try writeRepoCar(allocator, account.did)); try std.testing.expect(proof_car.blocks.len < full_car.blocks.len); const commit_data = zat.car.findBlock(proof_car, proof_car.roots[0].raw) orelse return error.TestUnexpectedResult; const commit_value = try zat.cbor.decodeAll(allocator, commit_data); const data_cid = commit_value.getCid("data") orelse return error.TestUnexpectedResult; const tree = try zat.mst.Mst.loadFromBlocks(allocator, proof_car, data_cid.raw); const key = try std.fmt.allocPrint(allocator, "app.bsky.feed.post/{s}", .{target_rkey}); const found = tree.get(key) orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings(target_cid, try cidText(allocator, found.raw)); const exclusion_car = try zat.car.read(allocator, try writeRecordProofCar(allocator, account.did, "app.bsky.feed.post", "3jzfcijpj2zzz")); try std.testing.expectEqualStrings(last_commit_cid, try cidText(allocator, exclusion_car.roots[0].raw)); try std.testing.expect(exclusion_car.blocks.len >= 2); const exclusion_tree = try zat.mst.Mst.loadFromBlocks(allocator, exclusion_car, data_cid.raw); try std.testing.expect(exclusion_tree.get("app.bsky.feed.post/3jzfcijpj2zzz") == null); try std.testing.expectError(Error.RepoNotFound, writeRecordProofCar(allocator, "did:plc:noproofrepo", "app.bsky.feed.post", "3jzfcijpj2z2m")); } test "file-backed repo export snapshot does not block writes" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const root_len = try tmp.dir.realPath(std.testing.io, &path_buf); const db_path = try std.fmt.allocPrint(allocator, "{s}/zds.sqlite3", .{path_buf[0..root_len]}); try init(std.Options.debug_io, db_path); defer close(); const account = try createAccount( allocator, "export-snapshot.test", "export-snapshot@test.com", "password", "did:plc:exportsnapshot", true, ); const first = try std.json.parseFromSlice( std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"before snapshot\"}", .{}, ); defer first.deinit(); const first_rkey = "3jzfcijpj2z2g"; _ = try create(allocator, account, "app.bsky.feed.post", first_rkey, first.value); const read_conn = try openRepoReadConnection(database_path.?); defer read_conn.close(); try read_conn.transaction(); errdefer read_conn.rollback(); const snapshot = try collectRepoCarFullFrom(allocator, read_conn, account.did); const second = try std.json.parseFromSlice( std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"during snapshot\"}", .{}, ); defer second.deinit(); const second_rkey = "3jzfcijpj2z2h"; _ = try create(allocator, account, "app.bsky.feed.post", second_rkey, second.value); try read_conn.commit(); const first_path = try std.fmt.allocPrint(allocator, "app.bsky.feed.post/{s}", .{first_rkey}); const second_path = try std.fmt.allocPrint(allocator, "app.bsky.feed.post/{s}", .{second_rkey}); const snapshot_car = try writeRepoCarData(allocator, snapshot); const loaded_snapshot = try zat.loadCommitFromCAR(allocator, snapshot_car); var snapshot_tree = try zat.mst.Mst.loadFromBlocks( allocator, loaded_snapshot.repo_car, loaded_snapshot.commit.data_cid, ); try std.testing.expect(snapshot_tree.get(first_path) != null); try std.testing.expect(snapshot_tree.get(second_path) == null); const current_car = try writeRepoCar(allocator, account.did); const loaded_current = try zat.loadCommitFromCAR(allocator, current_car); var current_tree = try zat.mst.Mst.loadFromBlocks( allocator, loaded_current.repo_car, loaded_current.commit.data_cid, ); try std.testing.expect(current_tree.get(second_path) != null); } test "permissioned spaces store self-owned records outside public repo" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "space.test", "space@test.com", "password", "did:plc:spaceauthorityalice", true, ); const space = try createSpace(allocator, .{ .actor_did = account.did, .authority_did = account.did, .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = true, .read_managing_app = "did:web:plyr.fm", .read_policy = "member-list", .write_managing_app = "did:web:plyr.fm", .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); try std.testing.expectEqualStrings("at://did:plc:spaceauthorityalice/space/fm.plyr.privateMedia/self", space.uri); try std.testing.expect(space.is_authority); try std.testing.expectError(Error.InvalidRefreshSession, createSpace(allocator, .{ .actor_did = account.did, .authority_did = account.did, .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = true, .read_managing_app = null, .read_policy = "member-list", .write_managing_app = null, .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", })); const parsed = try std.json.parseFromSlice( std.json.Value, allocator, "{\"$type\":\"fm.plyr.track\",\"title\":\"secret track\",\"audioBlob\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku\"},\"mimeType\":\"audio/mpeg\",\"size\":12}}", .{}, ); defer parsed.deinit(); const prepared = try prepareRecordValue(allocator, "fm.plyr.track", "track-one", parsed.value); const stored = try putSpaceRecord(allocator, space.uri, account.did, "fm.plyr.track", "track-one", prepared); try std.testing.expectEqualStrings("track-one", stored.rkey); try std.testing.expectEqualStrings("unknown", stored.validation_status); try std.testing.expect(get(account.did, "fm.plyr.track", "track-one") == null); try expectSpaceBlobRefCount(space.uri, account.did, "fm.plyr.track", "track-one", "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", 1); try std.testing.expect(try permissionedBlobReferenced(space.uri, account.did, "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku")); try std.testing.expect(getPublicBlob(allocator, account.did, "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku") == null); const found = (try getSpaceRecord(allocator, space.uri, account.did, "fm.plyr.track", "track-one")).?; try std.testing.expectEqualStrings(stored.cid, found.cid); try std.testing.expect(std.mem.indexOf(u8, found.value_json, "secret track") != null); const listed = try listSpaceRecords(allocator, space.uri, account.did, "fm.plyr.track", null, false, 50, true); try std.testing.expectEqual(@as(usize, 1), listed.len); try std.testing.expectEqualStrings("fm.plyr.track", listed[0].collection); try std.testing.expectEqualStrings("track-one", listed[0].rkey); try std.testing.expect(listed[0].value_json != null); try std.testing.expect(std.mem.indexOf(u8, listed[0].value_json.?, "secret track") != null); const listed_refs = try listSpaceRecords(allocator, space.uri, account.did, "fm.plyr.track", null, false, 50, false); try std.testing.expectEqual(@as(usize, 1), listed_refs.len); try std.testing.expect(listed_refs[0].value_json == null); const updated_parsed = try std.json.parseFromSlice( std.json.Value, allocator, "{\"$type\":\"fm.plyr.track\",\"title\":\"secret track\",\"audioBlob\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkreibm6jgipb22ignhvbmf6avdkvuevprpyx6y6722fsxk7wxiofq4wu\"},\"mimeType\":\"audio/mpeg\",\"size\":12}}", .{}, ); defer updated_parsed.deinit(); const updated_prepared = try prepareRecordValue(allocator, "fm.plyr.track", "track-one", updated_parsed.value); _ = try putSpaceRecord(allocator, space.uri, account.did, "fm.plyr.track", "track-one", updated_prepared); try expectSpaceBlobRefCount(space.uri, account.did, "fm.plyr.track", "track-one", "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", 0); try expectSpaceBlobRefCount(space.uri, account.did, "fm.plyr.track", "track-one", "bafkreibm6jgipb22ignhvbmf6avdkvuevprpyx6y6722fsxk7wxiofq4wu", 1); try std.testing.expect(!try permissionedBlobReferenced(space.uri, account.did, "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku")); try std.testing.expect(try permissionedBlobReferenced(space.uri, account.did, "bafkreibm6jgipb22ignhvbmf6avdkvuevprpyx6y6722fsxk7wxiofq4wu")); try deleteSpaceRecord(allocator, space.uri, account.did, "fm.plyr.track", "track-one"); try expectSpaceBlobRefCount(space.uri, account.did, "fm.plyr.track", "track-one", "bafkreibm6jgipb22ignhvbmf6avdkvuevprpyx6y6722fsxk7wxiofq4wu", 0); try std.testing.expect(!try permissionedBlobReferenced(space.uri, account.did, "bafkreibm6jgipb22ignhvbmf6avdkvuevprpyx6y6722fsxk7wxiofq4wu")); } test "permissioned blob ref migration backfills existing records" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const did = "did:plc:spaceblobmigration"; const space = try createSpace(allocator, .{ .actor_did = did, .authority_did = did, .space_type = "fm.example.private", .skey = "self", .is_authority = true, .read_managing_app = null, .read_policy = "member-list", .write_managing_app = null, .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); const parsed = try std.json.parseFromSlice( std.json.Value, allocator, "{\"$type\":\"fm.example.note\",\"media\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku\"},\"mimeType\":\"text/plain\",\"size\":12}}", .{}, ); defer parsed.deinit(); const prepared = try prepareRecordValue(allocator, "fm.example.note", "one", parsed.value); _ = try putSpaceRecord(allocator, space.uri, did, "fm.example.note", "one", prepared); try conn.execNoArgs("DELETE FROM permissioned_space_record_blobs"); try conn.exec("DELETE FROM zds_migrations WHERE name = ?", .{"permissioned-space-blob-refs-v1"}); try migratePermissionedSpaceBlobRefs(); try expectSpaceBlobRefCount(space.uri, did, "fm.example.note", "one", "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", 1); } test "permissioned space foundation migration preserves rows and rebuilds hashes" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const did = "did:plc:spacemigrationalice"; const canonical = "at://did:plc:spacemigrationalice/space/fm.example.private/self"; const legacy = "ats://did:plc:spacemigrationalice/fm.example.private/self"; const space = try createSpace(allocator, .{ .actor_did = did, .authority_did = did, .space_type = "fm.example.private", .skey = "self", .is_authority = true, .read_managing_app = null, .read_policy = "member-list", .write_managing_app = null, .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); try std.testing.expectEqualStrings(canonical, space.uri); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"fm.example.note\",\"text\":\"private\"}", .{}); defer parsed.deinit(); const prepared = try prepareRecordValue(allocator, "fm.example.note", "one", parsed.value); const record = try putSpaceRecord(allocator, canonical, did, "fm.example.note", "one", prepared); try conn.exec( \\INSERT INTO permissioned_space_record_blobs (space, repo_did, collection, rkey, blob_cid) \\VALUES (?, ?, ?, ?, ?) , .{ canonical, did, "fm.example.note", "one", "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }); try conn.exec( \\INSERT INTO permissioned_space_notify_registrations (space, service_id, service_endpoint, expires_at) \\VALUES (?, ?, ?, 4102444800) , .{ canonical, "did:web:sync.example", "https://sync.example" }); try conn.execNoArgs("PRAGMA foreign_keys = OFF"); inline for (.{ "permissioned_space_actor_state", "simplespace_members", "permissioned_space_records", "permissioned_space_record_blobs", "permissioned_space_repos", "permissioned_space_writers", "permissioned_space_record_oplog", "permissioned_space_notify_registrations", }) |table| { try conn.exec("UPDATE " ++ table ++ " SET space = ? WHERE space = ?", .{ legacy, canonical }); } try conn.exec("UPDATE permissioned_spaces SET uri = ? WHERE uri = ?", .{ legacy, canonical }); try conn.exec("UPDATE permissioned_space_repos SET set_hash = zeroblob(?)", .{permissioned.lthash_state_bytes}); try conn.exec("DELETE FROM zds_migrations WHERE name IN (?, ?, ?)", .{ "permissioned-space-at-uri", "permissioned-space-record-element-v1", "permissioned-space-writer-set-v1" }); try conn.execNoArgs("PRAGMA foreign_keys = ON"); try migratePermissionedDataTables(); inline for (.{ "permissioned_spaces", "permissioned_space_actor_state", "simplespace_members", "permissioned_space_records", "permissioned_space_record_blobs", "permissioned_space_repos", "permissioned_space_writers", "permissioned_space_record_oplog", "permissioned_space_notify_registrations", }) |table| { const column = comptime if (std.mem.eql(u8, table, "permissioned_spaces")) "uri" else "space"; const row = (try conn.row("SELECT count(*) FROM " ++ table ++ " WHERE " ++ column ++ " = ?", .{canonical})).?; defer row.deinit(); try std.testing.expect(row.int(0) > 0); } const state = try getSpaceRepoState(allocator, canonical, did); var expected: permissioned.LtHash = .{}; try addRecordElement(allocator, &expected, record.collection, record.rkey, record.cid); try std.testing.expectEqualSlices(u8, &expected.bytes, state.set_hash.?); const writers = try listSpaceWriters(allocator, canonical, null, 50); try std.testing.expectEqual(@as(usize, 1), writers.len); try std.testing.expectEqualStrings(did, writers[0].repo_did); try std.testing.expectEqualSlices(u8, &expected.digest(), writers[0].hash); } test "permissioned notification registrations are service keyed and expire" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const space = "at://did:plc:authority/space/fm.example.private/self"; try registerSpaceNotification(space, "did:web:whole.example#syncer", "https://whole.example", 4102444800); try registerSpaceNotification(space, "did:web:repo.example#syncer", "https://repo.example", 4102444800); try registerSpaceNotification(space, "did:web:expired.example#syncer", "https://expired.example", 1); const whole = try listNotificationRecipients(allocator, space); try std.testing.expectEqual(@as(usize, 2), whole.len); try std.testing.expectEqualStrings("did:web:repo.example#syncer", whole[0].service_id); try std.testing.expectEqualStrings("https://repo.example", whole[0].service_endpoint); try std.testing.expectEqualStrings("did:web:whole.example#syncer", whole[1].service_id); try std.testing.expectEqualStrings("https://whole.example", whole[1].service_endpoint); try unregisterSpaceNotification(space, "did:web:whole.example#syncer"); try unregisterSpaceNotification(space, "did:web:whole.example#syncer"); const after_unregister = try listNotificationRecipients(allocator, space); try std.testing.expectEqual(@as(usize, 1), after_unregister.len); } test "permissioned notification migration replaces endpoint registrations" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); try conn.execNoArgs("DROP TABLE permissioned_space_notify_registrations"); try conn.execNoArgs( \\CREATE TABLE permissioned_space_notify_registrations ( \\ space TEXT NOT NULL, \\ repo_did TEXT NOT NULL DEFAULT '', \\ service_endpoint TEXT NOT NULL, \\ expires_at INTEGER NOT NULL, \\ PRIMARY KEY (space, repo_did, service_endpoint) \\) ); try conn.execNoArgs("INSERT INTO permissioned_space_notify_registrations VALUES ('at://did:plc:old/space/fm.old/x', '', 'https://old.example', 4102444800)"); try conn.exec("DELETE FROM zds_migrations WHERE name = ?", .{"permissioned-space-notify-service-id"}); try migratePermissionedSpaceNotificationServices(); try registerSpaceNotification("at://did:plc:new/space/fm.new/x", "did:web:sync.example", "https://sync.example", 4102444800); const registrations = try listNotificationRecipients(allocator, "at://did:plc:new/space/fm.new/x"); try std.testing.expectEqual(@as(usize, 1), registrations.len); const old = (try conn.row("SELECT count(*) FROM permissioned_space_notify_registrations WHERE service_endpoint = 'https://old.example'", .{})).?; defer old.deinit(); try std.testing.expectEqual(@as(i64, 0), old.int(0)); } test "permissioned credential exchange tokens are one use" { try init(std.Options.debug_io, ":memory:"); defer close(); const first = SpaceReplayToken{ .jti = "delegation-one", .expires_at = 4_000_000_000 }; const attestation = SpaceReplayToken{ .jti = "attestation-one", .expires_at = 4_000_000_000 }; try std.testing.expect(try consumeSpaceCredentialExchange(first, attestation)); try std.testing.expect(!try consumeSpaceCredentialExchange(first, null)); const second = SpaceReplayToken{ .jti = "delegation-two", .expires_at = 4_000_000_000 }; try std.testing.expect(!try consumeSpaceCredentialExchange(second, attestation)); try std.testing.expect(try consumeSpaceCredentialExchange(second, null)); } fn expectSpaceBlobRefCount( space: []const u8, repo_did: []const u8, collection: []const u8, rkey: []const u8, blob_cid: []const u8, expected: i64, ) !void { const row = try conn.row( \\SELECT COUNT(*) \\FROM permissioned_space_record_blobs \\WHERE space = ? AND repo_did = ? AND collection = ? AND rkey = ? AND blob_cid = ? , .{ space, repo_did, collection, rkey, blob_cid }); defer row.?.deinit(); try std.testing.expectEqual(expected, row.?.int(0)); } test "permissioned spaces keep authority-local state per space URI" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const owner = try createAccount( allocator, "authority-space.test", "authority-space@test.com", "password", "did:plc:spaceauthoritylocal", true, ); const created = try createSpace(allocator, .{ .actor_did = owner.did, .authority_did = owner.did, .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = true, .read_managing_app = null, .read_policy = "member-list", .write_managing_app = null, .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); const owner_view = (try getSpace(allocator, owner.did, created.uri)).?; try std.testing.expect(owner_view.is_authority); try std.testing.expect(owner_view.deleted_at == null); const owner_spaces = try listSpaces(allocator, owner.did, owner.did, "fm.plyr.privateMedia", null, 50); try std.testing.expectEqual(@as(usize, 1), owner_spaces.len); try std.testing.expect(owner_spaces[0].is_authority); try markSpaceDeleted(owner.did, created.uri); try std.testing.expect((try getSpace(allocator, owner.did, created.uri)) == null); } test "permissioned space create is duplicate-checked per actor" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const owner = try createAccount(allocator, "authority-first.test", "authority-first@test.com", "password", "did:plc:ownerfirst", true); const viewer = try createAccount(allocator, "viewer-first.test", "viewer-first@test.com", "password", "did:plc:viewerfirst", true); const member = try createAccount(allocator, "member-only.test", "member-only@test.com", "password", "did:plc:memberonly", true); const viewer_row = try createSpace(allocator, .{ .actor_did = viewer.did, .authority_did = owner.did, .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = false, .read_managing_app = null, .read_policy = "member-list", .write_managing_app = null, .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); try std.testing.expect(!viewer_row.is_authority); const owner_row = try createSpace(allocator, .{ .actor_did = owner.did, .authority_did = owner.did, .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = true, .read_managing_app = "did:web:plyr.fm", .read_policy = "public", .write_managing_app = "did:web:plyr.fm", .write_policy = "public", .app_access_json = "{\"type\":\"allowList\",\"allowed\":[\"did:web:allowed.example\"]}", }); try std.testing.expect(owner_row.is_authority); try std.testing.expectEqualStrings("public", owner_row.read_policy); try std.testing.expectEqualStrings("did:web:plyr.fm", owner_row.read_managing_app.?); try putSimpleSpaceMember(owner_row.uri, member.did, true, true); const member_spaces = try listSpaces(allocator, member.did, owner.did, "fm.plyr.privateMedia", null, 50); try std.testing.expectEqual(@as(usize, 0), member_spaces.len); const viewer_spaces = try listSpaces(allocator, viewer.did, owner.did, "fm.plyr.privateMedia", null, 50); try std.testing.expectEqual(@as(usize, 1), viewer_spaces.len); try std.testing.expect(!viewer_spaces[0].is_authority); try std.testing.expectError(Error.InvalidRefreshSession, createSpace(allocator, .{ .actor_did = viewer.did, .authority_did = owner.did, .space_type = "fm.plyr.privateMedia", .skey = "self", .is_authority = false, .read_managing_app = null, .read_policy = "member-list", .write_managing_app = null, .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", })); } test "inactive repo import does not publish firehose event" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "import-inactive.test", "import-inactive@test.com", "password", "did:plc:importinactive", true, ); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"hello\"}", .{}); defer parsed.deinit(); const record = try create(allocator, account, "app.bsky.feed.post", "3jzfcijpj2z2h", parsed.value); const repo_car = try writeRepoCar(allocator, account.did); const loaded = try zat.loadCommitFromCAR(allocator, repo_car); var imported_blocks: std.ArrayList(ImportedBlock) = .empty; for (loaded.repo_car.blocks) |block| { try imported_blocks.append(allocator, .{ .cid = try cidText(allocator, block.cid_raw), .data = block.data, }); } const imported_records = [_]ImportedRecord{.{ .collection = record.collection, .rkey = record.rkey, .cid = record.cid, .blob_cids = &.{}, }}; try setAccountActive(account.did, false); const before = try scalarCountLocked("SELECT COUNT(*) FROM seq_events WHERE did = ?", account.did); try importRepo( allocator, account, try cidText(allocator, loaded.commit_cid), loaded.commit.rev, &imported_records, imported_blocks.items, ); const after = try scalarCountLocked("SELECT COUNT(*) FROM seq_events WHERE did = ?", account.did); try std.testing.expectEqual(before, after); try std.testing.expectEqualStrings((try latestRootLocked(allocator, account.did)).cid, try cidText(allocator, loaded.commit_cid)); } test "put updates record index while content comes from repo blocks" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "alice.test", "alice@test.com", "password", "did:plc:cmadossymmii3izkabdbp5en", true, ); var first = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"before\"}", .{}); defer first.deinit(); const rkey = "3jzfcijpj2z2i"; _ = try create(allocator, account, "app.bsky.feed.post", rkey, first.value); var second = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"after\"}", .{}); defer second.deinit(); const updated = try put(allocator, account, "app.bsky.feed.post", rkey, second.value); const fetched = get(account.did, "app.bsky.feed.post", rkey).?; try std.testing.expectEqualStrings(updated.cid, fetched.cid); try std.testing.expect(std.mem.indexOf(u8, fetched.value_json, "after") != null); try std.testing.expect(std.mem.indexOf(u8, fetched.value_json, "before") == null); const block_row = try conn.row( "SELECT count(*) FROM repo_blocks WHERE did = ? AND cid = ?", .{ account.did, updated.cid }, ); try std.testing.expect(block_row != null); defer block_row.?.deinit(); try std.testing.expectEqual(@as(i64, 1), block_row.?.int(0)); } test "stores blob metadata in sqlite and bytes in disk blobstore" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); const blob_root = path_buf[0..path_len]; blobstore.init(std.Options.debug_io, blob_root); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "alice.test", "alice@test.com", "password", "did:plc:cmadossymmii3izkabdbp5en", true, ); const cid = try putBlob(allocator, std.Options.debug_io, account, "hello blob", "text/plain"); const row = try conn.row("SELECT mime_type, size FROM blobs WHERE did = ? AND cid = ?", .{ account.did, cid }); try std.testing.expect(row != null); defer row.?.deinit(); try std.testing.expectEqualStrings("text/plain", row.?.text(0)); try std.testing.expectEqual(@as(i64, 10), row.?.int(1)); const blob = getBlob(allocator, account.did, cid) orelse return error.MissingRecord; try std.testing.expectEqualStrings("text/plain", blob.mime_type); try std.testing.expectEqualStrings("hello blob", blob.data); } test "stores larger blob metadata in sqlite and bytes in disk blobstore" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); const blob_root = path_buf[0..path_len]; blobstore.init(std.Options.debug_io, blob_root); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "large-blob.test", "large-blob@test.com", "password", "did:plc:largeblob", true, ); const data = try allocator.alloc(u8, 571_880); @memset(data, 0xaa); const cid = try putBlob(allocator, std.Options.debug_io, account, data, "image/jpeg"); const expected_cid = try cidForBlob(allocator, data); try std.testing.expectEqualStrings(expected_cid, cid); const row = try conn.row("SELECT mime_type, size FROM blobs WHERE did = ? AND cid = ?", .{ account.did, cid }); try std.testing.expect(row != null); defer row.?.deinit(); try std.testing.expectEqualStrings("image/jpeg", row.?.text(0)); try std.testing.expectEqual(@as(i64, 571_880), row.?.int(1)); const blob = getBlob(allocator, account.did, cid) orelse return error.MissingRecord; try std.testing.expectEqualStrings("image/jpeg", blob.mime_type); try std.testing.expectEqualSlices(u8, data, blob.data); } test "blob gc preserves fresh uploads through the untethered grace period" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); blobstore.init(std.Options.debug_io, path_buf[0..path_len]); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount(allocator, "gc-grace.test", "gc-grace@test.com", "password", "did:plc:gcgrace", true); const cid = try putBlob(allocator, std.Options.debug_io, account, "untethered", "text/plain"); const protected = try collectBlobGarbage(allocator, 60, 10); try std.testing.expectEqual(@as(usize, 0), protected.deleted); try std.testing.expect(getBlob(allocator, account.did, cid) != null); const expired = try collectBlobGarbage(allocator, 0, 10); try std.testing.expectEqual(@as(usize, 1), expired.deleted); try std.testing.expectEqual(@as(u64, 10), expired.bytes); try std.testing.expect(getBlob(allocator, account.did, cid) == null); } test "blob gc waits for the last public record reference" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); blobstore.init(std.Options.debug_io, path_buf[0..path_len]); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount(allocator, "gc-public.test", "gc-public@test.com", "password", "did:plc:gcpublic", true); const cid = try putBlob(allocator, std.Options.debug_io, account, "public", "text/plain"); const json = try std.fmt.allocPrint( allocator, "{{\"$type\":\"fm.example.blob\",\"media\":{{\"$type\":\"blob\",\"ref\":{{\"$link\":\"{s}\"}},\"mimeType\":\"text/plain\",\"size\":6}}}}", .{cid}, ); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{}); defer parsed.deinit(); _ = try create(allocator, account, "fm.example.blob", "one", parsed.value); _ = try create(allocator, account, "fm.example.blob", "two", parsed.value); _ = try delete(allocator, account, "fm.example.blob", "one"); try std.testing.expect(getBlob(allocator, account.did, cid) != null); _ = try delete(allocator, account, "fm.example.blob", "two"); const gc = try collectBlobGarbage(allocator, 0, 10); try std.testing.expectEqual(@as(usize, 1), gc.deleted); try std.testing.expect(getBlob(allocator, account.did, cid) == null); } test "blob gc follows permissioned record references" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); blobstore.init(std.Options.debug_io, path_buf[0..path_len]); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount(allocator, "gc-space.test", "gc-space@test.com", "password", "did:plc:gcspace", true); const cid = try putBlob(allocator, std.Options.debug_io, account, "private", "audio/mpeg"); const space = try createSpace(allocator, .{ .actor_did = account.did, .authority_did = account.did, .space_type = "fm.example.private", .skey = "self", .is_authority = true, .read_managing_app = null, .read_policy = "member-list", .write_managing_app = null, .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); const json = try std.fmt.allocPrint( allocator, "{{\"$type\":\"fm.example.privateBlob\",\"media\":{{\"$type\":\"blob\",\"ref\":{{\"$link\":\"{s}\"}},\"mimeType\":\"audio/mpeg\",\"size\":7}}}}", .{cid}, ); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{}); defer parsed.deinit(); const prepared = try prepareRecordValue(allocator, "fm.example.privateBlob", "one", parsed.value); _ = try putSpaceRecord(allocator, space.uri, account.did, "fm.example.privateBlob", "one", prepared); try std.testing.expect(getBlob(allocator, account.did, cid) != null); try deleteSpaceRecord(allocator, space.uri, account.did, "fm.example.privateBlob", "one"); const gc = try collectBlobGarbage(allocator, 0, 10); try std.testing.expectEqual(@as(usize, 1), gc.deleted); try std.testing.expect(getBlob(allocator, account.did, cid) == null); } test "blob visibility follows public and permissioned references independently" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); blobstore.init(std.Options.debug_io, path_buf[0..path_len]); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount(allocator, "blob-access.test", "blob-access@test.com", "password", "did:plc:blobaccess", true); const cid = try putBlob(allocator, std.Options.debug_io, account, "shared", "text/plain"); const space = try createSpace(allocator, .{ .actor_did = account.did, .authority_did = account.did, .space_type = "fm.example.private", .skey = "self", .is_authority = true, .read_managing_app = null, .read_policy = "member-list", .write_managing_app = null, .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); const json = try std.fmt.allocPrint( allocator, "{{\"$type\":\"fm.example.blob\",\"media\":{{\"$type\":\"blob\",\"ref\":{{\"$link\":\"{s}\"}},\"mimeType\":\"text/plain\",\"size\":6}}}}", .{cid}, ); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{}); defer parsed.deinit(); const prepared = try prepareRecordValue(allocator, "fm.example.blob", "private", parsed.value); try std.testing.expect(getPublicBlob(allocator, account.did, cid) == null); try std.testing.expect(!try permissionedBlobReferenced(space.uri, account.did, cid)); try std.testing.expectEqual(@as(usize, 0), (try collectBlobGarbage(allocator, 60, 10)).deleted); _ = try putSpaceRecord(allocator, space.uri, account.did, "fm.example.blob", "private", prepared); try std.testing.expect(getPublicBlob(allocator, account.did, cid) == null); try std.testing.expect(try permissionedBlobReferenced(space.uri, account.did, cid)); try std.testing.expectEqual(@as(usize, 0), (try collectBlobGarbage(allocator, 0, 10)).deleted); _ = try create(allocator, account, "fm.example.blob", "public", parsed.value); try std.testing.expect(getPublicBlob(allocator, account.did, cid) != null); try std.testing.expect(try permissionedBlobReferenced(space.uri, account.did, cid)); try deleteSpaceRecord(allocator, space.uri, account.did, "fm.example.blob", "private"); try std.testing.expect(getPublicBlob(allocator, account.did, cid) != null); try std.testing.expect(!try permissionedBlobReferenced(space.uri, account.did, cid)); try std.testing.expectEqual(@as(usize, 0), (try collectBlobGarbage(allocator, 0, 10)).deleted); _ = try putSpaceRecord(allocator, space.uri, account.did, "fm.example.blob", "private", prepared); _ = try delete(allocator, account, "fm.example.blob", "public"); try std.testing.expect(getPublicBlob(allocator, account.did, cid) == null); try std.testing.expect(try permissionedBlobReferenced(space.uri, account.did, cid)); try std.testing.expectEqual(@as(usize, 0), (try collectBlobGarbage(allocator, 0, 10)).deleted); try deleteSpaceRecord(allocator, space.uri, account.did, "fm.example.blob", "private"); try std.testing.expectEqual(@as(usize, 1), (try collectBlobGarbage(allocator, 0, 10)).deleted); try std.testing.expect(getBlob(allocator, account.did, cid) == null); } test "re-upload refreshes the untethered grace period" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); blobstore.init(std.Options.debug_io, path_buf[0..path_len]); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount(allocator, "gc-reupload.test", "gc-reupload@test.com", "password", "did:plc:gcreupload", true); const cid = try putBlob(allocator, std.Options.debug_io, account, "re-uploaded", "text/plain"); try conn.exec("UPDATE blobs SET created_at = unixepoch() - 120 WHERE did = ? AND cid = ?", .{ account.did, cid }); const same_cid = try putBlob(allocator, std.Options.debug_io, account, "re-uploaded", "text/plain"); try std.testing.expectEqualStrings(cid, same_cid); const gc = try collectBlobGarbage(allocator, 60, 10); try std.testing.expectEqual(@as(usize, 0), gc.deleted); try std.testing.expect(getBlob(allocator, account.did, cid) != null); } test "invite codes gate account creation and record uses" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const code = try createInviteCode(allocator, "https://pds.example.com", 1, "admin", "admin"); try std.testing.expect(std.mem.startsWith(u8, code, "pds-example-com-")); try std.testing.expect(try inviteCodeIsAvailable(code)); var signing_key: [32]u8 = @splat(0); signing_key[31] = 1; const account = try createAccountWithSigningKeyAndInvite( allocator, "invited.test", "invited@test.com", "password", "did:plc:invited", true, signing_key, code, ); try std.testing.expectEqualStrings("did:plc:invited", account.did); try std.testing.expect(!try inviteCodeIsAvailable(code)); var other_key: [32]u8 = @splat(0); other_key[31] = 2; try std.testing.expectError(error.InvalidInviteCode, createAccountWithSigningKeyAndInvite( allocator, "second.test", "second@test.com", "password", "did:plc:second", true, other_key, code, )); const codes = try getAccountInviteCodes(allocator, "admin", true); try std.testing.expectEqual(@as(usize, 1), codes.len); try std.testing.expectEqual(@as(usize, 1), codes[0].uses.len); try std.testing.expectEqualStrings("did:plc:invited", codes[0].uses[0].used_by); } test "account handle updates are unique and immediately resolvable" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const alice = try createAccount( allocator, "alice.test", "alice@test.com", "password", "did:plc:alice", true, ); _ = try createAccount( allocator, "bob.test", "bob@test.com", "password", "did:plc:bob", true, ); try updateAccountHandle(alice.did, "alice-new.test"); try std.testing.expect((try findAccount(allocator, "alice.test")) == null); const updated = (try findAccount(allocator, "ALICE-NEW.TEST")) orelse return error.AccountNotFound; try std.testing.expectEqualStrings(alice.did, updated.did); try std.testing.expectError(error.HandleNotAvailable, updateAccountHandle(alice.did, "BOB.TEST")); try std.testing.expectError(error.AccountNotFound, updateAccountHandle("did:plc:missing", "missing.test")); } test "reserved signing keys are one-use account signing keys" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const did = "did:plc:reserved"; const reserved = try reserveSigningKey(allocator, did); try std.testing.expectEqualStrings(did, reserved.did.?); try std.testing.expect(std.mem.startsWith(u8, reserved.signing_key, "did:key:")); const consumed = try consumeReservedSigningKey(reserved.signing_key, did); try std.testing.expectEqualSlices(u8, &reserved.secret_key, &consumed); try std.testing.expectError(error.MissingReservedSigningKey, consumeReservedSigningKey(reserved.signing_key, did)); } test "stores discoverable passkey challenges by oauth request" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); try putOAuthRequest( "req-discoverable", "https://client.example.com/oauth-client-metadata.json", "https://client.example.com/callback", "atproto", "state", "challenge", "S256", "query", null, null, 4102444800, ); try putDiscoverableWebAuthnChallenge("req-discoverable", "webauthn-challenge", 4102444800); const stored = (try getDiscoverableWebAuthnChallenge(allocator, "req-discoverable")) orelse return error.MissingRecord; try std.testing.expectEqualStrings("req-discoverable", stored.request_id); try std.testing.expectEqualStrings("webauthn-challenge", stored.challenge); try std.testing.expectEqual(@as(i64, 4102444800), stored.expires_at); try deleteDiscoverableWebAuthnChallenge("req-discoverable"); try std.testing.expect(try getDiscoverableWebAuthnChallenge(allocator, "req-discoverable") == null); } test "oauth authorization codes remain readable until atomically consumed" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); try putOAuthRequest( "req-code", "https://client.example.com/oauth-client-metadata.json", "https://client.example.com/callback", "atproto", "state", "challenge", "S256", "query", null, "dpop-thumbprint", 4102444800, ); try authorizeOAuthRequest("req-code", "did:plc:code", "authorization-code", "passkey"); const before = (try getOAuthRequestByCode(allocator, "authorization-code")) orelse return error.MissingRecord; try std.testing.expectEqualStrings("req-code", before.request_id); try std.testing.expectEqualStrings("authorization-code", before.code.?); try std.testing.expect(!try consumeOAuthCode("wrong-request", "authorization-code")); try std.testing.expect((try getOAuthRequestByCode(allocator, "authorization-code")) != null); try std.testing.expect(try consumeOAuthCode("req-code", "authorization-code")); try std.testing.expect((try getOAuthRequestByCode(allocator, "authorization-code")) == null); try std.testing.expect(!try consumeOAuthCode("req-code", "authorization-code")); } test "oauth app sessions stay active after access token expiry until revoked" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "oauth-session.test", "oauth-session@test.com", "password", "did:plc:oauthsession", true, ); try putOAuthToken( account.did, "https://client.example.com/oauth-client-metadata.json", "atproto", "expired-access", "current-refresh", 1, 4102444800, null, "passkey", ); const active = try listOAuthGrantsForAccount(allocator, account.did, true, 50); try std.testing.expectEqual(@as(usize, 1), active.len); try std.testing.expect(active[0].active); try std.testing.expectEqualStrings("passkey", active[0].auth_method.?); try revokeOAuthToken("current-refresh"); try std.testing.expectEqual(@as(usize, 0), (try listOAuthGrantsForAccount(allocator, account.did, true, 50)).len); const all = try listOAuthGrantsForAccount(allocator, account.did, false, 50); try std.testing.expectEqual(@as(usize, 1), all.len); try std.testing.expect(!all[0].active); } test "oauth refresh rotation keeps one previous refresh token in grace" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "oauth-rotation.test", "oauth-rotation@test.com", "password", "did:plc:oauthrotation", true, ); try putOAuthToken( account.did, "https://client.example.com/oauth-client-metadata.json", "atproto", "access-one", "refresh-one", 100, 4102444800, "jkt-one", "passkey", ); const access_one = (try getOAuthToken(allocator, "access-one")) orelse return error.MissingRecord; try std.testing.expectEqual(OAuthToken.Kind.access, access_one.kind); try std.testing.expectEqualStrings("access-one", access_one.family_id); try std.testing.expectEqual(@as(i64, 100), access_one.access_expires_at); try std.testing.expectEqual(@as(i64, 4102444800), access_one.refresh_expires_at); try rotateOAuthToken("refresh-one", "access-two", "refresh-two", 200, 4102444801, 300); try std.testing.expect(try getOAuthToken(allocator, "access-one") == null); const previous = (try getOAuthToken(allocator, "refresh-one")) orelse return error.MissingRecord; try std.testing.expectEqual(OAuthToken.Kind.previous_refresh, previous.kind); try std.testing.expectEqualStrings("access-two", previous.access_token); try std.testing.expectEqualStrings("refresh-two", previous.refresh_token); try std.testing.expectEqual(@as(i64, 300), previous.previous_refresh_expires_at.?); const current = (try getOAuthToken(allocator, "refresh-two")) orelse return error.MissingRecord; try std.testing.expectEqual(OAuthToken.Kind.refresh, current.kind); try std.testing.expectEqual(@as(i64, 200), current.access_expires_at); try std.testing.expectEqual(@as(i64, 4102444801), current.refresh_expires_at); try rotateOAuthToken("refresh-two", "access-three", "refresh-three", 400, 4102444802, 500); const used = (try getOAuthToken(allocator, "refresh-one")) orelse return error.MissingRecord; try std.testing.expectEqual(OAuthToken.Kind.used_refresh, used.kind); try std.testing.expectEqualStrings("access-one", used.family_id); try std.testing.expectEqualStrings("refresh-three", used.refresh_token); try revokeOAuthToken("refresh-one"); const revoked = (try getOAuthToken(allocator, "refresh-three")) orelse return error.MissingRecord; try std.testing.expect(revoked.revoked); } test "session tokens rotate with a replay grace window and report why a jti is refused" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "session.test", "session@test.com", "password", "did:plc:session", true, ); const session_id = try createSessionTokenRow( allocator, account.did, "access-1", "refresh-1", 4102444800, 4102444800, "password", null, null, ); try std.testing.expect(std.mem.startsWith(u8, session_id, "ses-")); try std.testing.expectEqual(SessionTokenStatus.active, try sessionTokenStatus(account.did, "access-1", "com.atproto.access")); try std.testing.expectEqual(SessionTokenStatus.active, try sessionTokenStatus(account.did, "refresh-1", "com.atproto.refresh")); try std.testing.expectEqual(SessionTokenStatus.unknown, try sessionTokenStatus(account.did, "refresh-1", "com.atproto.access")); try std.testing.expectEqual(SessionTokenStatus.unknown, try sessionTokenStatus(account.did, "nope", "com.atproto.refresh")); const first = (try refreshSessionByJti(allocator, account.did, "refresh-1")) orelse return error.MissingRecord; try std.testing.expectEqualStrings(session_id, first.id); try std.testing.expectEqualStrings("password", first.auth_method); try std.testing.expect(!first.replayed); try rotateSessionToken(session_id, "refresh-1", "access-2", "refresh-2", 4102444800, 4102444800, 30); try std.testing.expectError(error.InvalidRefreshSession, rotateSessionToken(session_id, "refresh-1", "access-3", "refresh-3", 4102444800, 4102444800, 30)); try std.testing.expectEqual(SessionTokenStatus.active, try sessionTokenStatus(account.did, "access-2", "com.atproto.access")); try std.testing.expectEqual(SessionTokenStatus.active, try sessionTokenStatus(account.did, "refresh-2", "com.atproto.refresh")); try std.testing.expectEqual(SessionTokenStatus.unknown, try sessionTokenStatus(account.did, "access-1", "com.atproto.access")); // the retired jti stays usable inside the grace window and resolves to the successor try std.testing.expectEqual(SessionTokenStatus.active, try sessionTokenStatus(account.did, "refresh-1", "com.atproto.refresh")); const replay = (try refreshSessionByJti(allocator, account.did, "refresh-1")) orelse return error.MissingRecord; try std.testing.expect(replay.replayed); try std.testing.expectEqualStrings("access-2", replay.access_jti); try std.testing.expectEqualStrings("refresh-2", replay.refresh_jti); // a closed grace window refuses the retired jti as expired try rotateSessionToken(session_id, "refresh-2", "access-3", "refresh-3", 4102444800, 4102444800, 0); try std.testing.expectEqual(SessionTokenStatus.expired, try sessionTokenStatus(account.did, "refresh-2", "com.atproto.refresh")); try std.testing.expect((try refreshSessionByJti(allocator, account.did, "refresh-2")) == null); try std.testing.expectEqual(SessionTokenStatus.unknown, try sessionTokenStatus(account.did, "refresh-1", "com.atproto.refresh")); const active_sessions = try listSessionsForAccount(allocator, account.did, true, 50); try std.testing.expectEqual(@as(usize, 1), active_sessions.len); try std.testing.expectEqualStrings(session_id, active_sessions[0].id); try std.testing.expect(active_sessions[0].active); try revokeSessionToken(account.did, "refresh-3"); try std.testing.expectEqual(SessionTokenStatus.revoked, try sessionTokenStatus(account.did, "access-3", "com.atproto.access")); try std.testing.expectEqual(SessionTokenStatus.revoked, try sessionTokenStatus(account.did, "refresh-3", "com.atproto.refresh")); try std.testing.expect((try refreshSessionByJti(allocator, account.did, "refresh-3")) == null); try std.testing.expectEqual(@as(usize, 0), (try listSessionsForAccount(allocator, account.did, true, 50)).len); const inactive_sessions = try listSessionsForAccount(allocator, account.did, false, 50); try std.testing.expectEqual(@as(usize, 1), inactive_sessions.len); try std.testing.expect(!inactive_sessions[0].active); _ = try createSessionTokenRow(allocator, account.did, "stale-access", "stale-refresh", 1, 1, "password", null, null); try std.testing.expectEqual(SessionTokenStatus.expired, try sessionTokenStatus(account.did, "stale-access", "com.atproto.access")); try std.testing.expectEqual(SessionTokenStatus.expired, try sessionTokenStatus(account.did, "stale-refresh", "com.atproto.refresh")); } test "account takedown sets precise sync status and revokes tokens" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "status.test", "status@test.com", "password", "did:plc:status", true, ); var record_json = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"app.bsky.feed.post\",\"text\":\"hello\"}", .{}); defer record_json.deinit(); _ = try create(allocator, account, "app.bsky.feed.post", "3jzfcijpj2z2j", record_json.value); _ = try createSessionTokenRow( allocator, account.did, "status-access", "status-refresh", 4102444800, 4102444800, "password", null, null, ); try std.testing.expectEqual(SessionTokenStatus.active, try sessionTokenStatus(account.did, "status-access", "com.atproto.access")); try setAccountTakendown(account.did, true, "mod-ref-1"); try std.testing.expectEqual(AccountStatus.takendown, try accountStatus(account.did)); try std.testing.expectEqual(SessionTokenStatus.revoked, try sessionTokenStatus(account.did, "status-access", "com.atproto.access")); const status_json = try writeRepoStatusJson(allocator, account.did); try std.testing.expect(std.mem.indexOf(u8, status_json, "\"active\":false") != null); try std.testing.expect(std.mem.indexOf(u8, status_json, "\"status\":\"takendown\"") != null); try std.testing.expect(std.mem.indexOf(u8, status_json, "\"rev\"") == null); const frame = try accountEventFrame(allocator, 1, account.did, .takendown); try std.testing.expect(std.mem.indexOf(u8, frame, "takendown") != null); try setAccountTakendown(account.did, false, null); try std.testing.expectEqual(AccountStatus.active, try accountStatus(account.did)); } test "app passwords are durable matchable and revoke their sessions" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "app-pass.test", "app-pass@test.com", "password", "did:plc:apppass", true, ); const salt: [16]u8 = @splat(0); const password_hash = try auth.hashPassword(allocator, "aaaa-bbbb-cccc-dddd", salt); const created = try createAppPassword( allocator, account.did, "device one", password_hash, false, "transition:generic", null, ); try std.testing.expectEqualStrings("device one", created.name); try std.testing.expectEqual(false, created.privileged); const listed = try listAppPasswords(allocator, account.did); try std.testing.expectEqual(@as(usize, 1), listed.len); try std.testing.expectEqualStrings("device one", listed[0].name); try std.testing.expectEqualStrings("transition:generic", listed[0].scopes.?); const matched = (try findMatchingAppPassword(allocator, account.did, "aaaa-bbbb-cccc-dddd")) orelse return error.MissingRecord; try std.testing.expectEqualStrings("device one", matched.name); try std.testing.expect(try findMatchingAppPassword(allocator, account.did, "wrong-password") == null); _ = try createSessionTokenRow( allocator, account.did, "app-access-1", "app-refresh-1", 4102444800, 4102444800, "app_password", null, "device one", ); const method = (try sessionAuthMethod(allocator, account.did, "app-access-1")) orelse return error.MissingRecord; try std.testing.expectEqualStrings("app_password", method); try revokeAppPassword(account.did, "device one"); try std.testing.expect(try getAppPasswordByName(allocator, account.did, "device one") == null); try std.testing.expect(try sessionAuthMethod(allocator, account.did, "app-access-1") == null); try std.testing.expectEqual(SessionTokenStatus.revoked, try sessionTokenStatus(account.did, "app-access-1", "com.atproto.access")); } test "account audit log stores subject actor and controller fields" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount( allocator, "audit.test", "audit@test.com", "password", "did:plc:audit", true, ); try recordAuditEvent(account.did, account.did, "did:plc:controller", "repo_write", "{\"collection\":\"app.bsky.feed.post\"}"); const row = try conn.row( \\SELECT subject_did, actor_did, controller_did, action, details_json \\FROM account_audit_log \\WHERE subject_did = ? , .{account.did}); try std.testing.expect(row != null); defer row.?.deinit(); try std.testing.expectEqualStrings(account.did, row.?.text(0)); try std.testing.expectEqualStrings(account.did, row.?.text(1)); try std.testing.expectEqualStrings("did:plc:controller", row.?.text(2)); try std.testing.expectEqualStrings("repo_write", row.?.text(3)); try std.testing.expect(std.mem.indexOf(u8, row.?.text(4), "app.bsky.feed.post") != null); } test "commit event encoding uses Zat firehose builder" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); const cid = "bafyreifmpxapiafzeml4ns5nedutwbsnjw5obdganycdy6mhmb2mxth5aq"; const rev = try atid.encode(1_700_000_123_456_789, 1); const since = try atid.encode(1_700_000_123_456_000, 1); const prev_data_raw = try cidRawFromText(allocator, cid); const ops = try allocator.alloc(zat.firehose.CommitEventOp, 3); ops[0] = try firehoseOp(allocator, .create, "sh.tangled.string", "zds-create", cid, null); ops[1] = try firehoseOp(allocator, .update, "sh.tangled.string", "zds-update", cid, cid); ops[2] = try firehoseOp(allocator, .delete, "sh.tangled.string", "zds-delete", null, cid); const frame = try commitEventFrameFromCar( allocator, 1, "did:plc:b64lsctzqnzpv6vd4ry3qktw", cid, &rev, &since, prev_data_raw, "not-a-real-car-for-this-encoding-test", ops, ); const decoded = try zat.firehose.decodeFrame(allocator, frame); try std.testing.expectEqual(@as(i64, 1), decoded.commit.seq); try std.testing.expectEqualStrings("did:plc:b64lsctzqnzpv6vd4ry3qktw", decoded.commit.repo); try std.testing.expectEqualStrings(&rev, decoded.commit.rev); try std.testing.expectEqualStrings(&since, decoded.commit.since.?); try std.testing.expectEqualSlices(u8, prev_data_raw, decoded.commit.prev_data.?.raw); try std.testing.expectEqual(@as(usize, 3), decoded.commit.ops.len); try std.testing.expectEqualStrings("sh.tangled.string/zds-create", decoded.commit.ops[0].path); try std.testing.expectEqualSlices(u8, ops[0].cid.?.raw, decoded.commit.ops[0].cid.?.raw); try std.testing.expectEqualStrings("sh.tangled.string/zds-update", decoded.commit.ops[1].path); try std.testing.expectEqualSlices(u8, ops[1].cid.?.raw, decoded.commit.ops[1].cid.?.raw); try std.testing.expectEqualSlices(u8, ops[1].prev.?.raw, decoded.commit.ops[1].prev.?.raw); try std.testing.expectEqualStrings("sh.tangled.string/zds-delete", decoded.commit.ops[2].path); try std.testing.expect(decoded.commit.ops[2].cid == null); try std.testing.expectEqualSlices(u8, ops[2].prev.?.raw, decoded.commit.ops[2].prev.?.raw); } test "Zat firehose builder rejects commit CID since rev" { const allocator = std.testing.allocator; const cid = "bafyreifmpxapiafzeml4ns5nedutwbsnjw5obdganycdy6mhmb2mxth5aq"; const commit_raw = try cidRawFromText(allocator, cid); defer allocator.free(commit_raw); const rev = try atid.encode(1_700_000_123_456_789, 1); try std.testing.expectError(error.InvalidSince, zat.firehose.encodeCommitEvent(allocator, .{ .seq = 1, .repo_did = "did:plc:b64lsctzqnzpv6vd4ry3qktw", .commit_cid = .{ .raw = commit_raw }, .rev = &rev, .since_rev = cid, .prev_data = null, .blocks = "not-a-real-car-for-this-encoding-test", .ops = &.{}, .time = "2026-06-22T00:00:00.000Z", })); } test "repo rev generation does not move behind current head" { const allocator = std.testing.allocator; const future_tid = try atid.encode(nowMicros() + std.time.us_per_s, 0); const rev = try revForSeq(allocator, 42, &future_tid); defer allocator.free(rev); const previous = try atid.timestampMicros(&future_tid); const next = try atid.timestampMicros(rev); try std.testing.expect(next > previous); try std.testing.expect(std.mem.lessThan(u8, &future_tid, rev)); } test "a create commit followed by a delete commit leaves no record behind" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount(allocator, "batch.test", "batch@test.com", "password", "did:plc:batchcd", true); const doc = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"io.atcr.manifest\"}", .{}); defer doc.deinit(); const rkey = "1b00000000000000000000000000000000000000000000000000000000000000"; _ = try applyWritesWithOptions(allocator, account, &.{ .{ .create = .{ .collection = "io.atcr.manifest", .rkey = rkey, .value = doc.value } }, }, .{ .validate = .skip }); _ = try applyWritesWithOptions(allocator, account, &.{ .{ .delete = .{ .collection = "io.atcr.manifest", .rkey = rkey } }, }, .{ .validate = .skip }); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try std.testing.expectEqual(@as(u64, 0), try scalarCountLocked("SELECT COUNT(*) FROM records WHERE did = ?", account.did)); } test "a same-key create and delete in one batch leaves no record, matching the reference" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount(allocator, "batch2.test", "batch2@test.com", "password", "did:plc:batchone", true); const doc = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"io.atcr.manifest\"}", .{}); defer doc.deinit(); const rkey = "3jzfcijpj2z2b"; // the tree applies these in order, so the key is gone; the index must agree _ = try applyWritesWithOptions(allocator, account, &.{ .{ .create = .{ .collection = "io.atcr.manifest", .rkey = rkey, .value = doc.value } }, .{ .delete = .{ .collection = "io.atcr.manifest", .rkey = rkey } }, }, .{ .validate = .skip }); const root = blk: { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try std.testing.expectEqual(@as(u64, 0), try scalarCountLocked("SELECT COUNT(*) FROM records WHERE did = ?", account.did)); break :blk (try latestCommitRawLocked(allocator, account.did)).?; }; // and the MST must agree with the index: no such key in the tree var reader = RepoBlockReader{ .allocator = allocator, .did = account.did }; var tree = try zat.mst.Mst.loadLazy(allocator, root.data_cid_raw, reader.reader()); try std.testing.expect(tree.get("io.atcr.manifest/" ++ rkey) == null); } test "a delete then create of the same key in one batch keeps the record" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount(allocator, "batch3.test", "batch3@test.com", "password", "did:plc:batchtwo", true); const doc = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"io.atcr.manifest\"}", .{}); defer doc.deinit(); const rkey = "3jzfcijpj2z2c"; _ = try applyWritesWithOptions(allocator, account, &.{ .{ .create = .{ .collection = "io.atcr.manifest", .rkey = rkey, .value = doc.value } }, }, .{ .validate = .skip }); // the reverse order: the create wins, and the row must survive the earlier delete _ = try applyWritesWithOptions(allocator, account, &.{ .{ .delete = .{ .collection = "io.atcr.manifest", .rkey = rkey } }, .{ .create = .{ .collection = "io.atcr.manifest", .rkey = rkey, .value = doc.value } }, }, .{ .validate = .skip }); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try std.testing.expectEqual(@as(u64, 1), try scalarCountLocked("SELECT COUNT(*) FROM records WHERE did = ?", account.did)); } test "a batch maps each record's blob refs to that record" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path_len = try tmp.dir.realPath(std.testing.io, &path_buf); blobstore.init(std.Options.debug_io, path_buf[0..path_len]); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount(allocator, "refspan.test", "refspan@test.com", "password", "did:plc:refspan", true); const cid_a = try putBlob(allocator, std.Options.debug_io, account, "alpha!", "text/plain"); const cid_b = try putBlob(allocator, std.Options.debug_io, account, "bravo!", "text/plain"); const body = struct { fn make(a: std.mem.Allocator, cid: []const u8) ![]const u8 { return std.fmt.allocPrint( a, "{{\"$type\":\"fm.example.blob\",\"media\":{{\"$type\":\"blob\",\"ref\":{{\"$link\":\"{s}\"}},\"mimeType\":\"text/plain\",\"size\":6}}}}", .{cid}, ); } }; const doc_a = try std.json.parseFromSlice(std.json.Value, allocator, try body.make(allocator, cid_a), .{}); defer doc_a.deinit(); const doc_b = try std.json.parseFromSlice(std.json.Value, allocator, try body.make(allocator, cid_b), .{}); defer doc_b.deinit(); // both records in ONE batch, so their ref spans must not be crossed _ = try applyWritesWithOptions(allocator, account, &.{ .{ .create = .{ .collection = "fm.example.blob", .rkey = "aaaaaaaaaaaaa", .value = doc_a.value } }, .{ .create = .{ .collection = "fm.example.blob", .rkey = "bbbbbbbbbbbbb", .value = doc_b.value } }, }, .{ .validate = .skip }); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); for ([_][2][]const u8{ .{ "aaaaaaaaaaaaa", cid_a }, .{ "bbbbbbbbbbbbb", cid_b } }) |want| { const uri = try std.fmt.allocPrint(allocator, "at://{s}/fm.example.blob/{s}", .{ account.did, want[0] }); const row = try conn.row("SELECT blob_cid FROM expected_blobs WHERE record_uri = ?", .{uri}); try std.testing.expect(row != null); defer row.?.deinit(); try std.testing.expectEqualStrings(want[1], row.?.text(0)); } } test "remembered browser credentials expire and revoke independently of OAuth" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const account = try createAccount(allocator, "remember.test", "remember@test.com", "password", "did:plc:remember", true); try putBrowserSession("digest", account, 4102444800); try std.testing.expect(try getBrowserSession(allocator, "forged", 1000) == null); try std.testing.expectEqualStrings(account.did, (try getBrowserSession(allocator, "digest", 1000)).?.did); try std.testing.expect(try getBrowserSession(allocator, "digest", 4102444800) == null); try conn.exec("UPDATE accounts SET account_status = 'deactivated' WHERE did = ?", .{account.did}); try std.testing.expect(try getBrowserSession(allocator, "digest", 1000) == null); try conn.exec("UPDATE accounts SET account_status = 'active', password_hash = 'changed' WHERE did = ?", .{account.did}); try std.testing.expect(try getBrowserSession(allocator, "digest", 1000) == null); try conn.exec("UPDATE accounts SET password_hash = ? WHERE did = ?", .{ account.password, account.did }); try deleteBrowserSession("digest"); try std.testing.expect(try getBrowserSession(allocator, "digest", 1000) == null); } test "startup next sequence uses both histories including gaps and empty tables" { try init(std.Options.debug_io, ":memory:"); defer close(); try std.testing.expectEqual(@as(u64, 1), try loadNextSeqLocked()); try conn.execNoArgs("INSERT INTO accounts (did, handle, email, password_hash) VALUES ('did:plc:sequence', 'sequence.test', 'sequence@example.test', 'unused')"); try conn.execNoArgs("INSERT INTO commits (seq, did, cid, rev) VALUES (7, 'did:plc:sequence', 'commit', 'rev')"); try std.testing.expectEqual(@as(u64, 8), try loadNextSeqLocked()); try conn.execNoArgs("INSERT INTO seq_events (seq, did, commit_cid, evt) VALUES (3, 'did:plc:sequence', 'commit', X'A0')"); try std.testing.expectEqual(@as(u64, 8), try loadNextSeqLocked()); try conn.execNoArgs("INSERT INTO seq_events (seq, did, commit_cid, evt) VALUES (19, 'did:plc:sequence', 'commit', X'A0')"); try std.testing.expectEqual(@as(u64, 20), try loadNextSeqLocked()); try conn.execNoArgs("DELETE FROM commits"); try std.testing.expectEqual(@as(u64, 20), try loadNextSeqLocked()); } test "account access groups count beyond pages and isolate detail queries" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const owner = try createAccount(allocator, "access.test", "access@test.com", "password", "did:plc:access", true); const other = try createAccount(allocator, "other.test", "other@test.com", "password", "did:plc:otheraccess", true); const client = "https://api-stg.example/client.json"; for (0..226) |i| { try putOAuthToken(owner.did, client, try std.fmt.allocPrint(allocator, "scope-{d}", .{i}), try std.fmt.allocPrint(allocator, "access-{d}", .{i}), try std.fmt.allocPrint(allocator, "refresh-{d}", .{i}), 100, 4102444800, "jkt", "password"); } try putOAuthToken(other.did, client, "other-scope", "other-access", "other-refresh", 100, 4102444800, "other-jkt", "password"); try putOAuthToken(owner.did, "https://expired.example", "atproto", "old-access", "old-refresh", 1, 2, "jkt", "password"); try putOAuthToken(owner.did, "https://revoked.example", "atproto", "rev-access", "rev-refresh", 100, 4102444800, "jkt", "password"); try revokeOAuthTokenFamily("rev-access"); const summaries = try listAccountAccess(allocator, owner.did, .{ .limit = 1 }); try std.testing.expectEqual(@as(i64, 226), summaries.authorized); try std.testing.expectEqual(@as(i64, 228), summaries.total); try std.testing.expectEqual(@as(usize, 1), summaries.groups.len); try std.testing.expectEqual(@as(i64, 226), summaries.groups[0].count); try std.testing.expect(summaries.next == null); var seen: std.StringHashMap(void) = .init(allocator); var offset: i64 = 0; while (true) { const page = try listAccountAccess(allocator, owner.did, .{ .group = client, .offset = offset }); try std.testing.expect(page.grants.len <= 20); for (page.grants) |grant| { try std.testing.expectEqualStrings(owner.did, grant.did); try std.testing.expect(!seen.contains(grant.scope)); try seen.put(grant.scope, {}); } offset = page.next orelse break; } try std.testing.expectEqual(@as(usize, 226), seen.count()); const past = try listAccountAccess(allocator, owner.did, .{ .state = .past, .limit = 1 }); try std.testing.expectEqual(@as(i64, 1), past.next.?); const past_second = try listAccountAccess(allocator, owner.did, .{ .state = .past, .offset = past.next.? }); try std.testing.expectEqual(@as(usize, 1), past_second.groups.len); try std.testing.expect(!std.mem.eql(u8, past.groups[0].key, past_second.groups[0].key)); const search = try listAccountAccess(allocator, owner.did, .{ .search = "API-STG" }); try std.testing.expectEqual(@as(usize, 1), search.groups.len); const empty = try listAccountAccess(allocator, owner.did, .{ .group = "unknown" }); try std.testing.expectEqual(@as(usize, 0), empty.grants.len); _ = try createSessionTokenRow(allocator, owner.did, "pw-a", "pw-r", 100, 4102444800, "password", null, null); _ = try createSessionTokenRow(allocator, owner.did, "app-a", "app-r", 100, 4102444800, "app_password", null, "phone"); _ = try createSessionTokenRow(allocator, other.did, "other-a", "other-r", 100, 4102444800, "password", null, null); const passwords = try listAccountAccess(allocator, owner.did, .{ .kind = .password }); try std.testing.expectEqual(@as(i64, 2), passwords.authorized); try std.testing.expectEqual(@as(usize, 2), passwords.groups.len); const details = try listAccountAccess(allocator, owner.did, .{ .kind = .password, .group = "password:" }); try std.testing.expectEqual(@as(usize, 1), details.sessions.len); try std.testing.expectEqualStrings(owner.did, details.sessions[0].did); } test "simplespace migration preserves policies and members and does not reset new permissions" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const owner = try createAccount(a, "migration.test", "migration@test.com", "password", "did:plc:migration", true); for ([_][]const u8{ "public", "member-list", "managing-app" }) |policy| { const space = try createSpace(a, .{ .actor_did = owner.did, .authority_did = owner.did, .space_type = "test.migration.space", .skey = policy, .is_authority = true, .read_policy = policy, .write_policy = policy, .read_managing_app = "did:web:manager.test#service", .write_managing_app = "did:web:manager.test#service", .app_access_json = "{\"type\":\"allowList\",\"allowed\":[\"https://client.test\"]}", }); try putSimpleSpaceMember(space.uri, "did:plc:member", true, true); } // Recreate the previous schema, including older columns that must not // overwrite a subsequently configured policy during the upgrade. try conn.execNoArgs("ALTER TABLE permissioned_spaces DROP COLUMN write_policy"); try conn.execNoArgs("ALTER TABLE permissioned_spaces DROP COLUMN write_managing_app"); try conn.execNoArgs("ALTER TABLE permissioned_spaces RENAME COLUMN read_policy TO policy"); try conn.execNoArgs("ALTER TABLE permissioned_spaces RENAME COLUMN read_managing_app TO managing_app"); try conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0"); try conn.execNoArgs("ALTER TABLE simplespace_members DROP COLUMN read"); try conn.execNoArgs("ALTER TABLE simplespace_members DROP COLUMN write"); try migrateSimpleSpaceConfig(); for ([_][]const u8{ "public", "member-list", "managing-app" }) |policy| { const uri = try std.fmt.allocPrint(a, "at://{s}/space/test.migration.space/{s}", .{ owner.did, policy }); const space = (try getSpace(a, owner.did, uri)).?; try std.testing.expectEqualStrings(policy, space.read_policy); try std.testing.expectEqualStrings(policy, space.write_policy); try std.testing.expectEqualStrings(space.read_managing_app.?, space.write_managing_app.?); try std.testing.expectEqualStrings("{\"type\":\"allowList\",\"allowed\":[\"https://client.test\"]}", space.app_access_json); const members = try listSimpleSpaceMembers(a, uri, null, 100); try std.testing.expectEqual(@as(usize, 2), members.len); for (members) |member| try std.testing.expect(member.read and member.write); try putSimpleSpaceMember(uri, "did:plc:member", true, false); try updateSimpleSpaceConfig(uri, null, .{ .policy = "public", .managing_app = null }, null); } try migrateSimpleSpaceConfig(); const uri = "at://did:plc:migration/space/test.migration.space/member-list"; try std.testing.expect(!try simpleSpaceMemberAllows(uri, "did:plc:member", .write)); try std.testing.expect(try simpleSpaceMemberAllows(uri, "did:plc:member", .read)); const updated = (try getSpace(a, owner.did, uri)).?; try std.testing.expectEqualStrings("member-list", updated.read_policy); try std.testing.expectEqualStrings("public", updated.write_policy); try std.testing.expect(updated.write_managing_app == null); try std.testing.expect(!try permissionedSpacesColumnExistsLocked("policy")); } test "simplespace member upserts replace access in both directions" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); try init(std.Options.debug_io, ":memory:"); defer close(); const owner = try createAccount(a, "members.test", "members@test.com", "password", "did:plc:members", true); const space = try createSpace(a, .{ .actor_did = owner.did, .authority_did = owner.did, .space_type = "test.members.space", .skey = "self", .is_authority = true, .read_policy = "member-list", .write_policy = "member-list", .read_managing_app = null, .write_managing_app = null, .app_access_json = "{\"type\":\"open\"}", }); for ([_][2]bool{ .{ true, false }, .{ false, true }, .{ false, false }, .{ true, true } }) |access| { try putSimpleSpaceMember(space.uri, "did:plc:member", access[0], access[1]); try std.testing.expectEqual(access[0], try simpleSpaceMemberAllows(space.uri, "did:plc:member", .read)); try std.testing.expectEqual(access[1], try simpleSpaceMemberAllows(space.uri, "did:plc:member", .write)); try std.testing.expectEqual(@as(usize, 2), (try listSimpleSpaceMembers(a, space.uri, null, 100)).len); } try removeSimpleSpaceMember(space.uri, "did:plc:member"); try std.testing.expect(!try simpleSpaceMemberAllows(space.uri, "did:plc:member", .read)); try std.testing.expect(!try simpleSpaceMemberAllows(space.uri, "did:plc:member", .write)); } test "space sequence, revocations and notification retries survive restart" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const root_len = try tmp.dir.realPath(std.testing.io, &path_buf); const path = try std.fmt.allocPrint(a, "{s}/spaces.sqlite3", .{path_buf[0..root_len]}); try init(std.Options.debug_io, path); defer close(); const did = "did:plc:sequence"; const space = try createSpace(a, .{ .actor_did = did, .authority_did = did, .space_type = "test.sequence.space", .skey = "self", .is_authority = true, .read_managing_app = null, .read_policy = "member-list", .write_managing_app = null, .write_policy = "member-list", .app_access_json = "{\"type\":\"open\"}", }); const first = try atid.encode(nowMicros() - 1000, 0); const second = try atid.encode(nowMicros(), 0); const hash: [32]u8 = @splat(1); const one = (try recordSpaceWriter(a, space.uri, did, &first, &hash)).?; try std.testing.expect(one.prev_space_rev == null); try std.testing.expect(try recordSpaceWriter(a, space.uri, did, &first, &hash) == null); const two = (try recordSpaceWriter(a, space.uri, "did:plc:another", &second, &hash)).?; try std.testing.expectEqualStrings(one.space_rev, two.prev_space_rev.?); const future = try atid.encode(nowMicros() + 301 * std.time.us_per_s, 0); try std.testing.expectError(error.FutureRev, recordSpaceWriter(a, space.uri, did, &future, &hash)); const page = try listSpaceWriters(a, space.uri, one.space_rev, 1); try std.testing.expectEqual(@as(usize, 1), page.len); try std.testing.expectEqualStrings("did:plc:another", page[0].repo_did); try std.testing.expectEqual(@as(usize, 0), (try listSpaceWriters(a, space.uri, two.space_rev, 1)).len); try std.testing.expectEqual(@as(usize, 0), (try listSpaceWriters(a, space.uri, "zzz", 1)).len); try revokeSpaceCredentials(space.uri, &.{ "revoked", "revoked" }); try std.testing.expect(!try spaceCredentialRevoked("other", "revoked")); const value = try std.json.parseFromSlice(std.json.Value, a, "{\"$type\":\"test.sequence.record\",\"text\":\"one\"}", .{}); const prepared = try prepareRecordValue(a, "test.sequence.record", "one", value.value); _ = try putSpaceRecord(a, space.uri, did, "test.sequence.record", "one", prepared); const pending = (try claimSpaceNotification(a)).?; try std.testing.expect(try claimSpaceNotification(a) == null); _ = try putSpaceRecord(a, space.uri, did, "test.sequence.record", "two", prepared); try finishSpaceNotification(pending.space, pending.repo, pending.rev); close(); try init(std.Options.debug_io, path); try std.testing.expect(try spaceCredentialRevoked(space.uri, "revoked")); const latest = (try claimSpaceNotification(a)).?; try std.testing.expect(!std.mem.eql(u8, latest.rev, pending.rev)); try retrySpaceNotification(latest); try std.testing.expect(try claimSpaceNotification(a) == null); try conn.execNoArgs("UPDATE permissioned_space_notify_outbox SET retry_at = 0, lease_until = 0"); const retry = (try claimSpaceNotification(a)).?; try std.testing.expectEqual(@as(i64, 2), retry.attempts); try finishSpaceNotification(retry.space, retry.repo, retry.rev); try std.testing.expect(try claimSpaceNotification(a) == null); const three = (try recordSpaceWriter(a, space.uri, did, &second, &hash)).?; try std.testing.expectEqualStrings(two.space_rev, three.prev_space_rev.?); try std.testing.expect(try recordSpaceWriter(a, space.uri, did, &first, &hash) == null); try conn.execNoArgs("DROP INDEX permissioned_space_writer_sequence"); try conn.execNoArgs("ALTER TABLE permissioned_space_writers DROP COLUMN space_rev"); try conn.execNoArgs("DELETE FROM permissioned_space_sequence"); close(); try init(std.Options.debug_io, path); const migrated = try listSpaceWriters(a, space.uri, null, 10); try std.testing.expectEqual(@as(usize, 2), migrated.len); try std.testing.expect(zat.Tid.parse(migrated[0].space_rev) != null); try std.testing.expect(std.mem.lessThan(u8, migrated[0].space_rev, migrated[1].space_rev)); close(); try init(std.Options.debug_io, path); const reopened = try listSpaceWriters(a, space.uri, migrated[0].space_rev, 10); try std.testing.expectEqual(@as(usize, 1), reopened.len); try std.testing.expectEqualStrings(migrated[1].space_rev, reopened[0].space_rev); }