atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

harden password session storage

zzstoatzz (May 29, 2026, 1:03 AM -0500) 0c0d305a d49e0ed2

+339 -12
+3
AGENTS.md
··· 49 49 - Passkeys are stored as account credentials: credential ID, public key, 50 50 signature counter, friendly name, and timestamps. Password login remains 51 51 available unless an explicit account policy changes that. 52 + - Password sessions are storage-backed token families. Accept session JWTs only 53 + when their JTI is active, and keep subject/actor/controller fields distinct in 54 + account-plane audit work. 52 55 - OAuth should follow the ATProto OAuth profile and JOSE behavior without 53 56 relaxing stricter repo-signature verification semantics. 54 57 - Appview behavior belongs behind the generic proxy path, not in local
+10
docs/architecture.md
··· 17 17 login/security surface. 18 18 - SQLite stores account, repo, commit, token, OAuth, blob metadata, and identity 19 19 state. 20 + - Password sessions are durable rows keyed by access and refresh JWT IDs. Refresh 21 + rotates the stored token family instead of trusting self-contained JWTs alone. 22 + - Account-plane audit events store subject, actor, and optional controller DID 23 + fields. That mirrors Tranquil's delegation-ready model without exposing user 24 + delegation before the surrounding account controls exist. 20 25 - Invite codes follow the official PDS split between code metadata and recorded 21 26 uses; account creation consumes a code while holding the store lock. 22 27 - Blob bytes live in the disk blobstore rooted at `ZDS_BLOBSTORE_PATH`. ··· 56 61 resource-server checks. 57 62 58 63 Password sessions and app passwords match the reference PDS account model. 64 + Session JWTs are accepted only when their JTI is present in the active session 65 + table, so revocation and refresh rotation can be enforced by storage. The 66 + session schema already carries an optional `controller_did` column for future 67 + delegated-account flows, following Tranquil's controller-vs-subject split. 68 + 59 69 Passkeys are an additional OAuth login credential inspired by Tranquil, backed 60 70 by the local `webauthn` dependency and stored in SQLite. Discoverable passkey 61 71 login is supported so an account can be chosen by the authenticator during the
+7
docs/development.md
··· 51 51 - Passkey support should stay close to Tranquil's WebAuthn model: account-owned 52 52 credential rows plus challenge rows, with browser prompts opened directly from 53 53 user gestures. 54 + - Account-plane security state should stay durable. Password sessions are not 55 + just signed JWTs; storage tracks active access/refresh JTIs, refresh rotation, 56 + revocation state, auth method, and optional future controller DID. 57 + - When adding account-management behavior, record subject DID, actor DID, and 58 + optional controller DID separately. That keeps the path toward Tranquil-style 59 + delegated accounts explicit instead of embedding policy in ad hoc token 60 + strings. 54 61 - Storage shape follows the official PDS SQLite model where practical: record 55 62 index rows identify current CIDs, and canonical record content is stored as 56 63 DAG-CBOR blocks.
+37 -9
src/atproto/server.zig
··· 162 162 }; 163 163 log.debug("xrpc createAccount ok did={s} handle={s}\n", .{ account.did, account.handle }); 164 164 165 - const access = try auth.createSessionJwt(allocator, "access", account); 166 - const refresh = try auth.createSessionJwt(allocator, "refresh", account); 167 - const body_out = try sessionJson(allocator, account, access, refresh); 165 + const issued = try issueSessionTokens(allocator, account, "account_create"); 166 + const body_out = try sessionJson(allocator, account, issued.access.token, issued.refresh.token); 168 167 try http_api.json(request, .ok, body_out); 169 168 } 170 169 ··· 554 553 } 555 554 log.debug("xrpc createSession ok identifier={s} did={s} handle={s}\n", .{ identifier, account.did, account.handle }); 556 555 557 - const access = try auth.createSessionJwt(arena.allocator(), "access", account); 558 - const refresh = try auth.createSessionJwt(arena.allocator(), "refresh", account); 556 + const issued = try issueSessionTokens(arena.allocator(), account, "password"); 559 557 const info = store.getEmailInfo(arena.allocator(), account.did) orelse { 560 558 return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 561 559 }; 562 560 const active = store.isAccountActive(account.did); 563 561 564 - const body_out = try sessionJsonWithInfo(arena.allocator(), account, info.email, info.email_confirmed, active, access, refresh); 562 + const body_out = try sessionJsonWithInfo(arena.allocator(), account, info.email, info.email_confirmed, active, issued.access.token, issued.refresh.token); 565 563 try http_api.json(request, .ok, body_out); 566 564 } 567 565 ··· 575 573 error.InvalidToken => return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 576 574 }; 577 575 const account = auth_ctx.account; 578 - const access = try auth.createSessionJwt(allocator, "access", account); 579 - const refresh = try auth.createSessionJwt(allocator, "refresh", account); 576 + const old_claims = currentBearerClaims(request, allocator) catch { 577 + return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 578 + }; 579 + defer allocator.free(old_claims.did); 580 + defer allocator.free(old_claims.scope); 581 + defer allocator.free(old_claims.jti); 582 + const issued = try auth.createSessionPair(allocator, account); 583 + try store.rotateSessionToken(account.did, old_claims.jti, issued.access.jti, issued.refresh.jti, issued.access.exp, issued.refresh.exp); 584 + try store.recordAuditEvent(account.did, account.did, null, "session_refreshed", "{}"); 580 585 const info = store.getEmailInfo(allocator, account.did) orelse { 581 586 return http_api.xrpcError(request, .not_found, "AccountNotFound", "Account not found"); 582 587 }; 583 588 const active = store.isAccountActive(account.did); 584 - const body_out = try sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, active, access, refresh); 589 + const body_out = try sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, active, issued.access.token, issued.refresh.token); 585 590 try http_api.json(request, .ok, body_out); 586 591 } 587 592 ··· 611 616 ) ![]const u8 { 612 617 const info = store.getEmailInfo(allocator, account.did) orelse return error.AccountNotFound; 613 618 return sessionJsonWithInfo(allocator, account, info.email, info.email_confirmed, store.isAccountActive(account.did), access, refresh); 619 + } 620 + 621 + fn issueSessionTokens(allocator: std.mem.Allocator, account: auth.Account, auth_method: []const u8) !auth.SessionPair { 622 + const issued = try auth.createSessionPair(allocator, account); 623 + _ = try store.createSessionTokenRow( 624 + allocator, 625 + account.did, 626 + issued.access.jti, 627 + issued.refresh.jti, 628 + issued.access.exp, 629 + issued.refresh.exp, 630 + auth_method, 631 + null, 632 + ); 633 + try store.recordAuditEvent(account.did, account.did, null, "session_created", "{}"); 634 + return issued; 635 + } 636 + 637 + fn currentBearerClaims(request: *http.Server.Request, allocator: std.mem.Allocator) !auth.TokenClaims { 638 + const auth_header = http_api.headerValue(request, "authorization") orelse return error.AuthRequired; 639 + if (!std.ascii.startsWithIgnoreCase(auth_header, "bearer ")) return error.AuthRequired; 640 + const token = std.mem.trim(u8, auth_header["bearer ".len..], " \t"); 641 + return auth.claimsFromSessionJwt(allocator, token) orelse error.InvalidToken; 614 642 } 615 643 616 644 fn sessionJsonWithInfo(
+47 -3
src/auth/tokens.zig
··· 12 12 pub const TokenClaims = struct { 13 13 did: []const u8, 14 14 scope: []const u8, 15 + jti: []const u8, 16 + exp: i64, 15 17 }; 16 18 17 19 var jwt_counter: usize = 0; ··· 49 51 50 52 const password_hash_rounds: u32 = 100_000; 51 53 54 + pub const SessionToken = struct { 55 + token: []const u8, 56 + jti: []const u8, 57 + iat: i64, 58 + exp: i64, 59 + }; 60 + 61 + pub const SessionPair = struct { 62 + access: SessionToken, 63 + refresh: SessionToken, 64 + }; 65 + 52 66 pub fn createSessionJwt(allocator: std.mem.Allocator, comptime kind: []const u8, account: Account) ![]const u8 { 67 + const token = try createSessionToken(allocator, kind, account); 68 + allocator.free(token.jti); 69 + return token.token; 70 + } 71 + 72 + pub fn createSessionPair(allocator: std.mem.Allocator, account: Account) !SessionPair { 73 + const access = try createSessionToken(allocator, "access", account); 74 + errdefer { 75 + allocator.free(access.token); 76 + allocator.free(access.jti); 77 + } 78 + const refresh = try createSessionToken(allocator, "refresh", account); 79 + return .{ .access = access, .refresh = refresh }; 80 + } 81 + 82 + pub fn createSessionToken(allocator: std.mem.Allocator, comptime kind: []const u8, account: Account) !SessionToken { 53 83 const header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; 54 84 const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic); 55 85 var ts: std.posix.timespec = undefined; ··· 60 90 const iat = timestamp.sec; 61 91 const lifetime: i64 = if (std.mem.eql(u8, kind, "refresh")) 14 * 24 * 60 * 60 else 2 * 60 * 60; 62 92 const exp = iat + lifetime; 93 + const jti_text = try std.fmt.allocPrint(allocator, "zds-{d}-{d}-{d}", .{ timestamp.sec, timestamp.nsec, jti }); 94 + errdefer allocator.free(jti_text); 63 95 const payload = try std.fmt.allocPrint( 64 96 allocator, 65 - "{{\"sub\":\"{s}\",\"scope\":\"com.atproto.{s}\",\"iat\":{d},\"exp\":{d},\"jti\":\"zds-{d}-{d}-{d}\"}}", 66 - .{ account.did, kind, iat, exp, timestamp.sec, timestamp.nsec, jti }, 97 + "{{\"sub\":\"{s}\",\"scope\":\"com.atproto.{s}\",\"iat\":{d},\"exp\":{d},\"jti\":\"{s}\"}}", 98 + .{ account.did, kind, iat, exp, jti_text }, 67 99 ); 68 100 defer allocator.free(payload); 69 101 ··· 78 110 std.crypto.auth.hmac.sha2.HmacSha256.create(&signature, signing_input, config.jwtSecret()); 79 111 const signature_b64 = try zat.jwt.base64UrlEncode(allocator, &signature); 80 112 defer allocator.free(signature_b64); 81 - return std.fmt.allocPrint(allocator, "{s}.{s}", .{ signing_input, signature_b64 }); 113 + return .{ 114 + .token = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ signing_input, signature_b64 }), 115 + .jti = jti_text, 116 + .iat = iat, 117 + .exp = exp, 118 + }; 82 119 } 83 120 84 121 pub fn createServiceJwt( ··· 146 183 pub fn subjectFromSessionJwt(allocator: std.mem.Allocator, token: []const u8) ?[]const u8 { 147 184 const claims = claimsFromSessionJwt(allocator, token) orelse return null; 148 185 allocator.free(claims.scope); 186 + allocator.free(claims.jti); 149 187 return claims.did; 150 188 } 151 189 ··· 185 223 if (zat.Did.parse(subject) == null) return null; 186 224 const scope = zat.json.getString(parsed.value, "scope") orelse return null; 187 225 if (!std.mem.startsWith(u8, scope, "com.atproto.")) return null; 226 + const jti = zat.json.getString(parsed.value, "jti") orelse return null; 227 + const exp = zat.json.getInt(parsed.value, "exp") orelse return null; 228 + if (exp < unixNow()) return null; 188 229 return .{ 189 230 .did = allocator.dupe(u8, subject) catch return null, 190 231 .scope = allocator.dupe(u8, scope) catch return null, 232 + .jti = allocator.dupe(u8, jti) catch return null, 233 + .exp = exp, 191 234 }; 192 235 } 193 236 ··· 199 242 const claims = claimsFromSessionJwt(std.testing.allocator, token).?; 200 243 defer std.testing.allocator.free(claims.did); 201 244 defer std.testing.allocator.free(claims.scope); 245 + defer std.testing.allocator.free(claims.jti); 202 246 try std.testing.expectEqualStrings(alice.did, claims.did); 203 247 } 204 248
+2
src/http/api.zig
··· 34 34 const claims = auth.claimsFromSessionJwt(allocator, token) orelse return error.InvalidToken; 35 35 defer allocator.free(claims.did); 36 36 defer allocator.free(claims.scope); 37 + defer allocator.free(claims.jti); 37 38 if (!std.mem.eql(u8, claims.scope, scope)) return error.InvalidToken; 38 39 const account = (store.findAccount(allocator, claims.did) catch null) orelse return error.InvalidToken; 39 40 ··· 44 45 return .{ .account = account, .oauth_scope = row.scope }; 45 46 } 46 47 48 + if (!(store.sessionTokenIsActive(claims.did, claims.jti, scope) catch false)) return error.InvalidToken; 47 49 return .{ .account = account, .oauth_scope = null }; 48 50 } 49 51
+233
src/storage/store.zig
··· 20 20 RepoNotFound, 21 21 InvalidRepoPath, 22 22 InvalidDagCbor, 23 + InvalidRefreshSession, 23 24 StoreNotInitialized, 24 25 }; 25 26 ··· 86 87 refresh_token: []const u8, 87 88 expires_at: i64, 88 89 revoked: bool, 90 + }; 91 + 92 + pub const SessionTokenRow = struct { 93 + id: []const u8, 94 + did: []const u8, 95 + access_jti: []const u8, 96 + refresh_jti: []const u8, 97 + auth_method: []const u8, 98 + controller_did: ?[]const u8, 99 + created_at: i64, 100 + updated_at: i64, 101 + revoked_at: ?i64, 102 + }; 103 + 104 + pub const AuditEvent = struct { 105 + id: []const u8, 106 + subject_did: []const u8, 107 + actor_did: []const u8, 108 + controller_did: ?[]const u8, 109 + action: []const u8, 110 + details_json: []const u8, 111 + created_at: i64, 89 112 }; 90 113 91 114 pub const Passkey = struct { ··· 916 939 \\SET revoked_at = unixepoch() 917 940 \\WHERE access_token = ? OR refresh_token = ? 918 941 , .{ token, token }); 942 + } 943 + 944 + pub fn createSessionTokenRow( 945 + allocator: std.mem.Allocator, 946 + did: []const u8, 947 + access_jti: []const u8, 948 + refresh_jti: []const u8, 949 + access_expires_at: i64, 950 + refresh_expires_at: i64, 951 + auth_method: []const u8, 952 + controller_did: ?[]const u8, 953 + ) ![]const u8 { 954 + db_mutex.lockUncancelable(store_io); 955 + defer db_mutex.unlock(store_io); 956 + try requireInitialized(); 957 + const id = try randomToken(allocator, "ses-", 16); 958 + errdefer allocator.free(id); 959 + try conn.exec( 960 + \\INSERT INTO session_tokens 961 + \\ (id, did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, auth_method, controller_did) 962 + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?) 963 + , .{ id, did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, auth_method, controller_did }); 964 + return id; 965 + } 966 + 967 + pub fn sessionTokenIsActive(did: []const u8, jti: []const u8, scope: []const u8) !bool { 968 + db_mutex.lockUncancelable(store_io); 969 + defer db_mutex.unlock(store_io); 970 + try requireInitialized(); 971 + const is_refresh = std.mem.eql(u8, scope, "com.atproto.refresh"); 972 + const row = if (is_refresh) 973 + try conn.row( 974 + \\SELECT id 975 + \\FROM session_tokens 976 + \\WHERE did = ? AND refresh_jti = ? AND refresh_expires_at > unixepoch() AND revoked_at IS NULL 977 + \\LIMIT 1 978 + , .{ did, jti }) 979 + else 980 + try conn.row( 981 + \\SELECT id 982 + \\FROM session_tokens 983 + \\WHERE did = ? AND access_jti = ? AND access_expires_at > unixepoch() AND revoked_at IS NULL 984 + \\LIMIT 1 985 + , .{ did, jti }); 986 + if (row) |found| { 987 + defer found.deinit(); 988 + try conn.exec("UPDATE session_tokens SET last_used_at = unixepoch() WHERE id = ?", .{found.text(0)}); 989 + return true; 990 + } 991 + return false; 992 + } 993 + 994 + pub fn rotateSessionToken( 995 + did: []const u8, 996 + old_refresh_jti: []const u8, 997 + new_access_jti: []const u8, 998 + new_refresh_jti: []const u8, 999 + access_expires_at: i64, 1000 + refresh_expires_at: i64, 1001 + ) !void { 1002 + db_mutex.lockUncancelable(store_io); 1003 + defer db_mutex.unlock(store_io); 1004 + try requireInitialized(); 1005 + const row = try conn.row( 1006 + \\SELECT id 1007 + \\FROM session_tokens 1008 + \\WHERE did = ? AND refresh_jti = ? AND refresh_expires_at > unixepoch() AND revoked_at IS NULL 1009 + \\LIMIT 1 1010 + , .{ did, old_refresh_jti }); 1011 + if (row == null) return error.InvalidRefreshSession; 1012 + defer row.?.deinit(); 1013 + try conn.exec( 1014 + \\UPDATE session_tokens 1015 + \\SET access_jti = ?, refresh_jti = ?, access_expires_at = ?, refresh_expires_at = ?, updated_at = unixepoch(), last_used_at = unixepoch() 1016 + \\WHERE id = ? 1017 + , .{ new_access_jti, new_refresh_jti, access_expires_at, refresh_expires_at, row.?.text(0) }); 1018 + } 1019 + 1020 + pub fn revokeSessionToken(did: []const u8, jti: []const u8) !void { 1021 + db_mutex.lockUncancelable(store_io); 1022 + defer db_mutex.unlock(store_io); 1023 + try requireInitialized(); 1024 + try conn.exec( 1025 + \\UPDATE session_tokens 1026 + \\SET revoked_at = unixepoch(), updated_at = unixepoch() 1027 + \\WHERE did = ? AND (access_jti = ? OR refresh_jti = ?) 1028 + , .{ did, jti, jti }); 1029 + } 1030 + 1031 + pub fn recordAuditEvent( 1032 + subject_did: []const u8, 1033 + actor_did: []const u8, 1034 + controller_did: ?[]const u8, 1035 + action: []const u8, 1036 + details_json: []const u8, 1037 + ) !void { 1038 + db_mutex.lockUncancelable(store_io); 1039 + defer db_mutex.unlock(store_io); 1040 + try requireInitialized(); 1041 + const id = try randomToken(std.heap.page_allocator, "aud-", 16); 1042 + defer std.heap.page_allocator.free(id); 1043 + try conn.exec( 1044 + \\INSERT INTO account_audit_log 1045 + \\ (id, subject_did, actor_did, controller_did, action, details_json) 1046 + \\VALUES (?, ?, ?, ?, ?, ?) 1047 + , .{ id, subject_did, actor_did, controller_did, action, details_json }); 919 1048 } 920 1049 921 1050 pub fn putWebAuthnChallenge(did: []const u8, kind: []const u8, challenge: []const u8, state_json: []const u8, expires_at: i64) !void { ··· 3547 3676 \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) 3548 3677 \\) 3549 3678 , 3679 + \\CREATE TABLE IF NOT EXISTS session_tokens ( 3680 + \\ id TEXT PRIMARY KEY, 3681 + \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 3682 + \\ access_jti TEXT NOT NULL UNIQUE, 3683 + \\ refresh_jti TEXT NOT NULL UNIQUE, 3684 + \\ access_expires_at INTEGER NOT NULL, 3685 + \\ refresh_expires_at INTEGER NOT NULL, 3686 + \\ auth_method TEXT NOT NULL, 3687 + \\ controller_did TEXT, 3688 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 3689 + \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()), 3690 + \\ last_used_at INTEGER, 3691 + \\ revoked_at INTEGER 3692 + \\) 3693 + , 3694 + "CREATE INDEX IF NOT EXISTS session_tokens_did_idx ON session_tokens (did, created_at DESC)", 3695 + "CREATE INDEX IF NOT EXISTS session_tokens_access_jti_idx ON session_tokens (access_jti)", 3696 + "CREATE INDEX IF NOT EXISTS session_tokens_refresh_jti_idx ON session_tokens (refresh_jti)", 3697 + "CREATE INDEX IF NOT EXISTS session_tokens_controller_idx ON session_tokens (controller_did) WHERE controller_did IS NOT NULL", 3698 + \\CREATE TABLE IF NOT EXISTS account_audit_log ( 3699 + \\ id TEXT PRIMARY KEY, 3700 + \\ subject_did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, 3701 + \\ actor_did TEXT NOT NULL, 3702 + \\ controller_did TEXT, 3703 + \\ action TEXT NOT NULL, 3704 + \\ details_json TEXT NOT NULL DEFAULT '{}', 3705 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) 3706 + \\) 3707 + , 3708 + "CREATE INDEX IF NOT EXISTS account_audit_subject_idx ON account_audit_log (subject_did, created_at DESC)", 3709 + "CREATE INDEX IF NOT EXISTS account_audit_controller_idx ON account_audit_log (controller_did, created_at DESC) WHERE controller_did IS NOT NULL", 3550 3710 \\CREATE TABLE IF NOT EXISTS passkeys ( 3551 3711 \\ id TEXT PRIMARY KEY, 3552 3712 \\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE, ··· 3885 4045 3886 4046 try deleteDiscoverableWebAuthnChallenge("req-discoverable"); 3887 4047 try std.testing.expect(try getDiscoverableWebAuthnChallenge(allocator, "req-discoverable") == null); 4048 + } 4049 + 4050 + test "session tokens are durable and refresh rotation invalidates old jti" { 4051 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 4052 + defer arena.deinit(); 4053 + const allocator = arena.allocator(); 4054 + 4055 + try init(std.Options.debug_io, ":memory:"); 4056 + defer close(); 4057 + 4058 + const account = try createAccount( 4059 + allocator, 4060 + "session.test", 4061 + "session@test.com", 4062 + "password", 4063 + "did:plc:session", 4064 + true, 4065 + ); 4066 + 4067 + const session_id = try createSessionTokenRow( 4068 + allocator, 4069 + account.did, 4070 + "access-1", 4071 + "refresh-1", 4072 + 4102444800, 4073 + 4102444800, 4074 + "password", 4075 + null, 4076 + ); 4077 + try std.testing.expect(std.mem.startsWith(u8, session_id, "ses-")); 4078 + try std.testing.expect(try sessionTokenIsActive(account.did, "access-1", "com.atproto.access")); 4079 + try std.testing.expect(try sessionTokenIsActive(account.did, "refresh-1", "com.atproto.refresh")); 4080 + 4081 + try rotateSessionToken(account.did, "refresh-1", "access-2", "refresh-2", 4102444800, 4102444800); 4082 + try std.testing.expect(!try sessionTokenIsActive(account.did, "refresh-1", "com.atproto.refresh")); 4083 + try std.testing.expect(try sessionTokenIsActive(account.did, "access-2", "com.atproto.access")); 4084 + try std.testing.expect(try sessionTokenIsActive(account.did, "refresh-2", "com.atproto.refresh")); 4085 + 4086 + try revokeSessionToken(account.did, "refresh-2"); 4087 + try std.testing.expect(!try sessionTokenIsActive(account.did, "access-2", "com.atproto.access")); 4088 + try std.testing.expect(!try sessionTokenIsActive(account.did, "refresh-2", "com.atproto.refresh")); 4089 + } 4090 + 4091 + test "account audit log stores subject actor and controller fields" { 4092 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 4093 + defer arena.deinit(); 4094 + const allocator = arena.allocator(); 4095 + 4096 + try init(std.Options.debug_io, ":memory:"); 4097 + defer close(); 4098 + 4099 + const account = try createAccount( 4100 + allocator, 4101 + "audit.test", 4102 + "audit@test.com", 4103 + "password", 4104 + "did:plc:audit", 4105 + true, 4106 + ); 4107 + try recordAuditEvent(account.did, account.did, "did:plc:controller", "repo_write", "{\"collection\":\"app.bsky.feed.post\"}"); 4108 + 4109 + const row = try conn.row( 4110 + \\SELECT subject_did, actor_did, controller_did, action, details_json 4111 + \\FROM account_audit_log 4112 + \\WHERE subject_did = ? 4113 + , .{account.did}); 4114 + try std.testing.expect(row != null); 4115 + defer row.?.deinit(); 4116 + try std.testing.expectEqualStrings(account.did, row.?.text(0)); 4117 + try std.testing.expectEqualStrings(account.did, row.?.text(1)); 4118 + try std.testing.expectEqualStrings("did:plc:controller", row.?.text(2)); 4119 + try std.testing.expectEqualStrings("repo_write", row.?.text(3)); 4120 + try std.testing.expect(std.mem.indexOf(u8, row.?.text(4), "app.bsky.feed.post") != null); 3888 4121 } 3889 4122 3890 4123 test "commit event encoding owns returned firehose op entries" {