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

Configure Feed

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

Add permissioned data and operator diagnostics

zzstoatzz (Jun 8, 2026, 11:52 PM -0500) 811af5cd d861e68b

+4814 -52
+68
PLYR_OAUTH_PAR_HANDOFF.md
··· 1 + # ZDS `/oauth/par` rejecting plyr.fm's permission-set includes → login broken on pds.zat.dev 2 + 3 + **From:** plyr.fm side investigation (2026-06-09 ~01:20 UTC) 4 + **Symptom:** signing into plyr.fm staging as a `pds.zat.dev` account (e.g. `bufo.uk`, 5 + `did:plc:b64lsctzqnzpv6vd4ry3qktw`) fails. Accounts on *other* PDSes (e.g. 6 + `zzstoatzz.io` on `pds.zzstoatzz.io`) sign in fine. So this is specific to ZDS, 7 + and it is **new** — `bufo.uk` signed in successfully at 23:34 UTC, then broke. 8 + Something changed ZDS-side since then (a redeploy?). 9 + 10 + ## Where it fails 11 + 12 + plyr's OAuth client (`atproto_oauth`) dies at the **PAR step** of 13 + `start_authorization`, *after* successfully fetching ZDS's `.well-known` 14 + metadata (both 200) and *before* a usable PAR response. On plyr's side the 15 + exception arrived with an empty `str()` (we just shipped a fix to log the real 16 + type/traceback — next failed login will show it precisely in our logfire). 17 + 18 + ## The ZDS-side lead (your logs) 19 + 20 + `fly logs -a zds-pds` shows, for plyr's client: 21 + 22 + ``` 23 + error oauth validate include failed scope=include:fm.plyr.stg.privateMedia err=InvalidPermissionSet 24 + error oauth authorize scope_html failed client=https://api-stg.plyr.fm/oauth-client-metadata.json \ 25 + scope=atproto blob:*/* include:fm.plyr.stg.authFullApp include:fm.plyr.stg.privateMedia \ 26 + err=InvalidPermissionSet 27 + ``` 28 + 29 + So ZDS is rejecting plyr's permission-set `include:` scopes with 30 + **`InvalidPermissionSet`**. plyr's *current* (post-revert) login requests only: 31 + 32 + ``` 33 + atproto blob:*/* include:fm.plyr.stg.authFullApp 34 + ``` 35 + 36 + If ZDS now fails to resolve **`include:fm.plyr.stg.authFullApp`** the same way it 37 + failed `privateMedia`, PAR aborts and login dies. 38 + 39 + ## What to check 40 + 41 + 1. Resolve `include:fm.plyr.stg.authFullApp` (and `include:fm.plyr.stg.privateMedia`) 42 + from ZDS right now — why `InvalidPermissionSet`? The permission-set records are 43 + published as `com.atproto.lexicon.schema` on the **plyr.fm** account 44 + `did:plc:vs3hnzq2daqbszxlysywzy54` (PDS `chalciporus.us-west.host.bsky.network`), 45 + resolved via DNS `_lexicon.stg.plyr.fm` TXT → that DID. Confirm ZDS can still 46 + fetch + parse those records (DNS, DID resolution, getRecord, `buildExpandedScope`). 47 + This worked earlier today, so suspect a regression in the last ZDS deploy. 48 + 49 + 2. `/oauth/par` returns **HTTP 500** on bad input rather than a clean 4xx OAuth 50 + error. Example (my probe, missing client assertion): 51 + `error oauth client_auth rejected missing_assertion_type` → `failed to serve 52 + /oauth/par: MissingFormField` → 500. A 500 (especially with an empty/aborted 53 + body) surfaces on the client as an opaque `httpx.RemoteProtocolError`. Returning 54 + a proper `{"error": "...", "error_description": "..."}` 4xx would make failures 55 + debuggable on both sides. 56 + 57 + ## Please ignore in the logs 58 + 59 + My manual `/oauth/par` probes around **01:19–01:20 UTC** (`client=test`, and 60 + `client=...plyr...` *without* a client assertion) are mine, not plyr's — they 61 + lack the confidential-client assertion and are not representative. plyr always 62 + sends `client_assertion` (private_key_jwt). 63 + 64 + ## To reproduce faithfully 65 + 66 + Have a `pds.zat.dev` account sign into `https://stg.plyr.fm` and watch the 67 + `/oauth/par` handler + permission-set resolution for 68 + `client=https://api-stg.plyr.fm/oauth-client-metadata.json`.
+24
bench/README.md
··· 57 57 58 58 Each run uses temporary SQLite and blobstore state. 59 59 60 + ## permissioned data 61 + 62 + Permissioned-data benchmarks are separate from the public repo/appview/sync 63 + benchmarks. The goal is not broad coverage yet; it is to catch obviously bad 64 + access patterns introduced by the experimental `com.atproto.space.*` storage 65 + model. 66 + 67 + When adding the first permissioned-data probe, keep it out of `all` until the 68 + protocol shape settles. A useful first pass should seed one self-owned space, 69 + one member repo, and one small blob, then measure these paths as distinct units 70 + of work: 71 + 72 + - `createSpace` with owner auto-membership 73 + - `addMember` / `removeMember` and member LtHash/oplog updates 74 + - `createRecord` or `applyWrites` into a space member repo 75 + - `getRecord` by `(space, repo, collection, rkey)` 76 + - `listRecords`, limit 50, index-only response shape 77 + - ranged `getBlob` 78 + - `getRepoOplog` and `getMemberOplog` catch-up reads 79 + 80 + Comparison against Daniel's permissioned-data branch should live in this 81 + section once we have a repeatable way to run that PDS locally. Until then, keep 82 + ZDS numbers as local trend data and compare only equivalent operations. 83 + 60 84 ## methodology 61 85 62 86 - Build ZDS with `-Doptimize=ReleaseFast`.
+22
docs/benchmarking.md
··· 95 95 resolution. Larger records and index-only list probes are tracked as separate 96 96 comparison gaps instead of being folded into the current table. 97 97 98 + ## permissioned data 99 + 100 + Permissioned-data measurements should stay separate from the existing repo, 101 + blob, sync, and HTTP route matrices. The experimental `com.atproto.space.*` 102 + surface has its own storage model, actor-local space state, LtHash-backed 103 + member/record state, oplogs, credentials, and ranged blob reads, so mixing it 104 + into the public repo tables would hide the actual access pattern. 105 + 106 + The first benchmark slice should be small and diagnostic rather than exhaustive: 107 + 108 + - seed one self-owned private space and one member repo. 109 + - measure `createSpace`, member add/remove, record write, record read, 110 + `listRecords`, ranged `getBlob`, and oplog catch-up separately. 111 + - report p50/p95/p99/max for the concurrent paths, but keep write, read, blob, 112 + and oplog rows separate. 113 + - compare to Daniel's permissioned-data branch only after we can run that PDS 114 + locally with the same seeded work. 115 + 116 + The question for this slice is simply whether any route has an obviously bad 117 + query shape, full-table scan, unnecessary blob materialization, or avoidable 118 + global lock hold. It does not need to become a giant protocol benchmark suite. 119 + 98 120 ## HTTP route path 99 121 100 122 `just bench http` starts a local ReleaseFast ZDS server and measures a seeded
+10 -3
docs/operations.md
··· 36 36 state. Treat the numbers as local trend data; compare runs on the same machine 37 37 and optimize against the shape of the curve, not a single absolute value. 38 38 39 - See [bench/README.md](../bench/README.md) for the Tranquil comparison target. 39 + Keep experimental permissioned-data probes separate from this baseline. Their 40 + first job is to catch obviously bad access patterns in `com.atproto.space.*`, 41 + not to expand the normal benchmark gate. 42 + 43 + See [bench/README.md](../bench/README.md) for the Tranquil comparison target 44 + and the permissioned-data benchmark notes. 40 45 41 46 ## local server 42 47 ··· 75 80 creation. `describeServer` reflects this value. 76 81 - `ZDS_PERMISSIONED_DATA`: set to `true` to opt into experimental 77 82 `com.atproto.space.*` permissioned-data routes. Default: disabled. The 78 - current route slice is gated and non-mutating until space storage is 79 - implemented. 83 + current implementation is intended to track the upstream experimental 84 + permissioned-data branch, including space lifecycle, membership, private 85 + records, grants, credentials, oplogs, ranged blob reads, and notify routes. 86 + Use `just smoke-permissioned` for the experimental smoke lane. 80 87 - Passkeys do not require a separate deployment secret. WebAuthn RP ID is 81 88 derived from the public URL host. 82 89
+62 -21
docs/permissioned-data.md
··· 11 11 ``` 12 12 13 13 Until that flag is enabled, ZDS should reject permissioned-data routes as 14 - unimplemented and should not create or mutate space tables. 14 + unimplemented and should not create or mutate permissioned-space rows. 15 15 16 - The first implementation slice wires the flag and route gate only. Even when 17 - enabled, the space methods return `MethodNotImplemented` until storage and 18 - semantics are implemented. 16 + When enabled, ZDS should expose the full experimental 17 + `com.atproto.space.*` surface described by the current permissioned-data 18 + lexicons. Downstream apps should treat the namespace as experimental protocol 19 + support, not as a plyr.fm-specific private-media slice. 19 20 20 21 ## references 21 22 ··· 56 57 57 58 ## storage shape 58 59 59 - The reference branch uses space-scoped tables: 60 + The reference branch stores these tables inside each actor store: 60 61 61 62 - `space`: URI, owner/member flags, app access policy, creation/deletion 62 63 timestamps ··· 69 70 - `space_member_oplog`: incremental membership changes by `(space, rev, idx)` 70 71 - `space_credential_recipient`: registered services to notify for writes 71 72 72 - ZDS currently has one public repo namespace per DID: `records`, `repo_blocks`, 73 + ZDS has one public repo namespace per DID: `records`, `repo_blocks`, 73 74 `commits`, `seq_events`, `blobs`, plus account/auth state. Permissioned data 74 - should not be squeezed into those public repo tables. It needs a new 75 - space-scoped storage module, probably `src/atproto/space.zig` plus space 76 - storage helpers in `src/storage/store.zig` or a split-out storage module once 77 - the shape is clear. 75 + must not be squeezed into those public repo tables. It uses space-scoped 76 + storage in `src/storage/store.zig` and route behavior in 77 + `src/atproto/space.zig`. 78 + 79 + Because ZDS stores many actor repos in one SQLite database instead of one 80 + actor-store per DID, it must preserve the same actor-local semantics explicitly: 81 + 82 + - canonical space config lives in `permissioned_spaces`, keyed by space URI 83 + - actor-local owner/member/deleted state lives in 84 + `permissioned_space_actor_state`, keyed by `(space, actor_did)` 85 + - member-list state remains space-owner state 86 + - record state and oplogs carry the member `repo_did` explicitly 87 + 88 + Do not collapse actor-local space state back into a single global row. The same 89 + space URI can legitimately have a different local view for different actors on 90 + the same ZDS process. 78 91 79 92 ## Zat first 80 93 ··· 103 116 Do not edit or patch the sibling `zat` repo from ZDS without stopping and 104 117 making the proposed Zat change explicit. 105 118 106 - ## first milestone 119 + ## implementation milestones 107 120 108 - Implement in this order: 121 + Current implementation notes: 109 122 110 - 1. Add the feature flag and route gating, still with no functional space 111 - storage. 112 - 2. Add a storage-only space state model and tests: create space, add/remove 113 - member, write/delete record, update record/member oplogs. 114 - 3. Add LtHash/set commitment support, using Zat if it exists by then or a small 115 - internal module if it does not. 116 - 4. Expose the smallest route slice: `createSpace`, `addMember`, 117 - `createRecord`, `listRecords`, `getRecord`, `getRepoState`. 118 - 5. Add grants, space credentials, member/repo oplog sync, and notify fanout. 123 + 1. Permissioned data remains behind `ZDS_PERMISSIONED_DATA`. 124 + 2. Spaces, members, member/repo LtHash state, record oplogs, member oplogs, 125 + grants, owner-signed space credentials, credential-gated reads, ranged blob 126 + reads, and best-effort notify fanout live in ZDS. 127 + 3. The current branch is still experimental because the upstream proposal is 128 + experimental. Do not narrow future work to one downstream app's needs unless 129 + the protocol shape itself narrows. 119 130 120 131 Each step should keep permissioned data dark unless `ZDS_PERMISSIONED_DATA` is 121 132 enabled. 133 + 134 + ## code boundaries 135 + 136 + Permissioned data is experimental code and should stay segmented: 137 + 138 + - HTTP dispatch and XRPC semantics live in `src/atproto/space.zig`. 139 + - Experimental protocol primitives live in 140 + `src/internal/permissioned_data.zig`. 141 + - OAuth scope parsing for `space:` scopes lives in `src/internal/scopes.zig`. 142 + - Persistent state lives in permissioned-data tables in 143 + `src/storage/store.zig`. 144 + - API-reference surfacing is marked experimental through the `space` router 145 + group and `x-zds-experimental`. 146 + 147 + Normal repo writes, sync events, account auth, and appview proxy behavior should 148 + not learn permissioned-data special cases unless the protocol requires a shared 149 + primitive. Prefer a permissioned-data helper over scattering conditionals into 150 + stable PDS code. 151 + 152 + ## checks 153 + 154 + Run both smoke lanes when touching this area: 155 + 156 + ```sh 157 + just test 158 + just smoke 159 + just smoke-permissioned 160 + git diff --check 161 + zig zen 162 + ```
+4
justfile
··· 8 8 smoke: 9 9 tools/smoke.sh 10 10 11 + # run the experimental permissioned-data endpoint smoke test 12 + smoke-permissioned: 13 + tools/smoke-permissioned.sh 14 + 11 15 # mint an invite code with the configured admin token 12 16 invite pds="https://pds.zat.dev" use_count="1" for_account="": 13 17 #!/usr/bin/env bash
+17 -4
src/atproto/oauth.zig
··· 86 86 const body = try http_api.readBodyAlloc(request, allocator, 64 * 1024); 87 87 if (isJsonContent(content_type)) { 88 88 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); 89 - return parWithParams(request, allocator, JsonParams{ .json = parsed.value }); 89 + return parWithParamsHandled(request, allocator, JsonParams{ .json = parsed.value }); 90 90 } 91 - return parWithParams(request, allocator, Form{ .body = body }); 91 + return parWithParamsHandled(request, allocator, Form{ .body = body }); 92 + } 93 + 94 + fn parWithParamsHandled(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { 95 + parWithParams(request, allocator, params) catch |err| switch (err) { 96 + error.MissingFormField => return, 97 + else => return err, 98 + }; 92 99 } 93 100 94 101 fn parWithParams(request: *http_api.Request, allocator: std.mem.Allocator, params: anytype) !void { ··· 435 442 const client_id = try requireParam(request, params, allocator, "client_id"); 436 443 const client_metadata = try fetchClientMetadataForAuth(request, allocator, client_id); 437 444 try requireClientAuth(request, allocator, params, client_id, client_metadata.value); 438 - const token_row = (try store.getOAuthToken(allocator, refresh)) orelse return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); 439 - if (token_row.revoked or token_row.expires_at < now() or !std.mem.eql(u8, token_row.client_id, client_id)) { 445 + const token_row = (try store.getOAuthToken(allocator, refresh)) orelse { 446 + log.err("oauth refresh invalid_grant: refresh token not found token_len={d} client={s}\n", .{ refresh.len, client_id }); 447 + return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); 448 + }; 449 + const ts = now(); 450 + const client_id_match = std.mem.eql(u8, token_row.client_id, client_id); 451 + if (token_row.revoked or token_row.expires_at < ts or !client_id_match) { 452 + log.err("oauth refresh invalid_grant: revoked={} expires_at={d} now={d} client_id_match={} row_client={s} request_client={s} did={s}\n", .{ token_row.revoked, token_row.expires_at, ts, client_id_match, token_row.client_id, client_id, token_row.did }); 440 453 return oauthError(request, .bad_request, "invalid_grant", "Invalid refresh token"); 441 454 } 442 455 const account = (try store.findAccount(allocator, token_row.did)) orelse return oauthError(request, .bad_request, "invalid_grant", "Account not found");
+32
src/atproto/oauth/permission_sets.zig
··· 524 524 try std.testing.expectEqualStrings("repo:fm.plyr.track repo:fm.plyr.like", expanded); 525 525 } 526 526 527 + test "plyr staging auth permission set expands repo collections" { 528 + const allocator = std.testing.allocator; 529 + const text = 530 + \\{"value":{"defs":{"main":{"type":"permission-set","title":"Full plyr.fm Access","detail":"Provides full access to all plyr.fm features including uploading and managing tracks, playlists, likes, and comments.","permissions":[{"type":"permission","resource":"repo","action":["create","update","delete"],"collection":["fm.plyr.stg.track","fm.plyr.stg.like","fm.plyr.stg.comment","fm.plyr.stg.list","fm.plyr.stg.actor.profile"]}]}}}} 531 + ; 532 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); 533 + defer parsed.deinit(); 534 + const main = try mainPermissionSet(parsed); 535 + const expanded = try buildExpandedScope(allocator, "fm.plyr.stg.authFullApp", null, main); 536 + defer allocator.free(expanded); 537 + try std.testing.expectEqualStrings( 538 + "repo:fm.plyr.stg.track repo:fm.plyr.stg.like repo:fm.plyr.stg.comment repo:fm.plyr.stg.list repo:fm.plyr.stg.actor.profile", 539 + expanded, 540 + ); 541 + } 542 + 527 543 test "permission set expansion rejects mixed-authority repo permission" { 528 544 const allocator = std.testing.allocator; 529 545 const text = ··· 625 641 expanded, 626 642 ); 627 643 } 644 + 645 + test "plyr staging private media permission set expands all space actions" { 646 + const allocator = std.testing.allocator; 647 + const text = 648 + \\{"value":{"defs":{"main":{"type":"permission-set","title":"plyr.fm Private Media","detail":"Access to your private audio - a permissioned space on your PDS that only you (and apps you grant) can read.","permissions":[{"type":"permission","resource":"space","action":["read","create","update","delete","manage"],"space":["fm.plyr.stg.privateMedia"],"skey":["self"],"did":["*"],"collection":["fm.plyr.stg.track"]}]}}}} 649 + ; 650 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); 651 + defer parsed.deinit(); 652 + const main = try mainPermissionSet(parsed); 653 + const expanded = try buildExpandedScope(allocator, "fm.plyr.stg.privateMedia", null, main); 654 + defer allocator.free(expanded); 655 + try std.testing.expectEqualStrings( 656 + "space:fm.plyr.stg.privateMedia?action=read&did=*&skey=self space:fm.plyr.stg.privateMedia?action=create&did=*&skey=self&collection=fm.plyr.stg.track space:fm.plyr.stg.privateMedia?action=update&did=*&skey=self&collection=fm.plyr.stg.track space:fm.plyr.stg.privateMedia?action=delete&did=*&skey=self&collection=fm.plyr.stg.track space:fm.plyr.stg.privateMedia?action=manage&did=*&skey=self", 657 + expanded, 658 + ); 659 + }
+15 -3
src/atproto/proxy.zig
··· 96 96 const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{service_token}); 97 97 98 98 log.debug("xrpc proxy fetch method={s} upstream={s} extra_headers={d}\n", .{ method, upstream_url, extra_count }); 99 - const result = proxyTransport().fetch(.{ 99 + const fetch_options: zat.HttpTransport.FetchOptions = .{ 100 100 .url = upstream_url, 101 101 .method = http_api.toStdMethod(request.method), 102 102 .payload = payload, ··· 104 104 .accept = "application/json", 105 105 .extra_headers = extra_buf[0..extra_count], 106 106 .max_response_size = 10 * 1024 * 1024, 107 - }) catch |err| { 107 + }; 108 + const result = proxyFetch(fetch_options) catch |err| { 108 109 log.err("xrpc proxy fetch failed method={s} upstream={s} err={s}\n", .{ method, upstream_url, @errorName(err) }); 109 110 return http_api.xrpcError(request, .bad_gateway, "UpstreamFailure", "Failed to reach proxied service"); 110 111 }; ··· 114 115 return http_api.json(request, result.status, result.body); 115 116 } 116 117 118 + fn proxyFetch(options: zat.HttpTransport.FetchOptions) !zat.HttpTransport.FetchResult { 119 + return proxyTransport().fetch(options) catch |err| switch (err) { 120 + error.HttpConnectionClosing => { 121 + var transport = zat.HttpTransport.init(store.currentIo(), proxy_transport_allocator); 122 + transport.keep_alive = false; 123 + defer transport.deinit(); 124 + return transport.fetch(options); 125 + }, 126 + else => return err, 127 + }; 128 + } 129 + 117 130 fn proxyTransport() *zat.HttpTransport { 118 131 if (!proxy_transport_ready) { 119 132 proxy_transport_state = zat.HttpTransport.init(store.currentIo(), proxy_transport_allocator); 120 - proxy_transport_state.keep_alive = false; 121 133 proxy_transport_ready = true; 122 134 } 123 135 return &proxy_transport_state;
+126
src/atproto/server.zig
··· 274 274 return http_api.json(request, .ok, try out.toOwnedSlice()); 275 275 } 276 276 277 + pub fn listSessions(request: *http_api.Request) !void { 278 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 279 + defer arena.deinit(); 280 + const allocator = arena.allocator(); 281 + const auth_ctx = requireAccountAccess(request, allocator) catch return; 282 + try requirePasswordSession(request, allocator, auth_ctx.account.did); 283 + const options = sessionListOptions(request); 284 + const sessions = try store.listSessionsForAccount(allocator, auth_ctx.account.did, options.active_only, options.limit); 285 + const grants = try store.listOAuthGrantsForAccount(allocator, auth_ctx.account.did, options.active_only, options.limit); 286 + const body = try sessionsJson(allocator, sessions, grants); 287 + return http_api.json(request, .ok, body); 288 + } 289 + 290 + pub fn adminListSessions(request: *http_api.Request) !void { 291 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 292 + defer arena.deinit(); 293 + const allocator = arena.allocator(); 294 + requireAdminToken(request) catch return; 295 + const options = sessionListOptions(request); 296 + const sessions = try store.listSessionsForAllAccounts(allocator, options.active_only, options.limit); 297 + const grants = try store.listOAuthGrantsForAllAccounts(allocator, options.active_only, options.limit); 298 + const body = try sessionsJson(allocator, sessions, grants); 299 + return http_api.json(request, .ok, body); 300 + } 301 + 277 302 pub fn createAppPassword(request: *http_api.Request) !void { 278 303 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 279 304 defer arena.deinit(); ··· 499 524 try writer.print("{{\"usedBy\":{f},\"usedAt\":{f}}}", .{ std.json.fmt(use.used_by, .{}), std.json.fmt(used_at, .{}) }); 500 525 } 501 526 try writer.writeAll("]}"); 527 + } 528 + 529 + const SessionListOptions = struct { 530 + active_only: bool = true, 531 + limit: i64 = 100, 532 + }; 533 + 534 + fn sessionListOptions(request: *http_api.Request) SessionListOptions { 535 + var active_buf: [16]u8 = undefined; 536 + const active_only = if (http_api.queryParam(request.url.raw, "active", &active_buf)) |value| 537 + !(std.mem.eql(u8, value, "false") or std.mem.eql(u8, value, "0")) 538 + else 539 + true; 540 + var limit_buf: [16]u8 = undefined; 541 + const parsed_limit = if (http_api.queryParam(request.url.raw, "limit", &limit_buf)) |value| 542 + std.fmt.parseInt(i64, value, 10) catch 100 543 + else 544 + 100; 545 + return .{ .active_only = active_only, .limit = std.math.clamp(parsed_limit, 1, 500) }; 546 + } 547 + 548 + fn sessionsJson(allocator: std.mem.Allocator, sessions: []const store.SessionInfo, grants: []const store.OAuthGrantInfo) ![]const u8 { 549 + var out: std.Io.Writer.Allocating = .init(allocator); 550 + defer out.deinit(); 551 + try out.writer.writeAll("{\"sessions\":["); 552 + for (sessions, 0..) |session, idx| { 553 + if (idx != 0) try out.writer.writeByte(','); 554 + try writeSessionJson(allocator, &out.writer, session); 555 + } 556 + try out.writer.writeAll("],\"oauthGrants\":["); 557 + for (grants, 0..) |grant, idx| { 558 + if (idx != 0) try out.writer.writeByte(','); 559 + try writeOAuthGrantJson(allocator, &out.writer, grant); 560 + } 561 + try out.writer.writeAll("]}"); 562 + return out.toOwnedSlice(); 563 + } 564 + 565 + fn writeSessionJson(allocator: std.mem.Allocator, writer: anytype, session: store.SessionInfo) !void { 566 + const created_at = try isoTimestamp(allocator, session.created_at); 567 + const updated_at = try isoTimestamp(allocator, session.updated_at); 568 + const access_expires_at = try isoTimestamp(allocator, session.access_expires_at); 569 + const refresh_expires_at = try isoTimestamp(allocator, session.refresh_expires_at); 570 + try writer.print( 571 + "{{\"id\":{f},\"did\":{f},\"handle\":{f},\"authMethod\":{f},\"appPasswordName\":", 572 + .{ 573 + std.json.fmt(session.id, .{}), 574 + std.json.fmt(session.did, .{}), 575 + std.json.fmt(session.handle, .{}), 576 + std.json.fmt(session.auth_method, .{}), 577 + }, 578 + ); 579 + try writeOptionalString(writer, session.app_password_name); 580 + try writer.writeAll(",\"controllerDid\":"); 581 + try writeOptionalString(writer, session.controller_did); 582 + try writer.print( 583 + ",\"createdAt\":{f},\"updatedAt\":{f},\"lastUsedAt\":", 584 + .{ std.json.fmt(created_at, .{}), std.json.fmt(updated_at, .{}) }, 585 + ); 586 + try writeOptionalTimestamp(allocator, writer, session.last_used_at); 587 + try writer.print( 588 + ",\"accessExpiresAt\":{f},\"refreshExpiresAt\":{f},\"revokedAt\":", 589 + .{ std.json.fmt(access_expires_at, .{}), std.json.fmt(refresh_expires_at, .{}) }, 590 + ); 591 + try writeOptionalTimestamp(allocator, writer, session.revoked_at); 592 + try writer.print(",\"active\":{}}}", .{session.active}); 593 + } 594 + 595 + fn writeOAuthGrantJson(allocator: std.mem.Allocator, writer: anytype, grant: store.OAuthGrantInfo) !void { 596 + const created_at = try isoTimestamp(allocator, grant.created_at); 597 + const expires_at = try isoTimestamp(allocator, grant.expires_at); 598 + try writer.print( 599 + "{{\"did\":{f},\"handle\":{f},\"clientId\":{f},\"scope\":{f},\"createdAt\":{f},\"expiresAt\":{f},\"revokedAt\":", 600 + .{ 601 + std.json.fmt(grant.did, .{}), 602 + std.json.fmt(grant.handle, .{}), 603 + std.json.fmt(grant.client_id, .{}), 604 + std.json.fmt(grant.scope, .{}), 605 + std.json.fmt(created_at, .{}), 606 + std.json.fmt(expires_at, .{}), 607 + }, 608 + ); 609 + try writeOptionalTimestamp(allocator, writer, grant.revoked_at); 610 + try writer.print(",\"active\":{}}}", .{grant.active}); 611 + } 612 + 613 + fn writeOptionalString(writer: anytype, value: ?[]const u8) !void { 614 + if (value) |text| { 615 + try writer.print("{f}", .{std.json.fmt(text, .{})}); 616 + } else { 617 + try writer.writeAll("null"); 618 + } 619 + } 620 + 621 + fn writeOptionalTimestamp(allocator: std.mem.Allocator, writer: anytype, value: ?i64) !void { 622 + if (value) |seconds| { 623 + const text = try isoTimestamp(allocator, seconds); 624 + try writer.print("{f}", .{std.json.fmt(text, .{})}); 625 + } else { 626 + try writer.writeAll("null"); 627 + } 502 628 } 503 629 504 630 fn isoTimestamp(allocator: std.mem.Allocator, seconds: i64) ![]const u8 {
+1289
src/atproto/space.zig
··· 1 + //! Experimental `com.atproto.space.*` permissioned-data XRPC handlers. 2 + //! 3 + //! This module is intentionally feature-flagged at dispatch and should remain 4 + //! the only HTTP entry point for permissioned-data behavior while the upstream 5 + //! protocol is still experimental. 6 + 7 + const std = @import("std"); 8 + const auth = @import("../auth/tokens.zig"); 1 9 const config = @import("../core/config.zig"); 2 10 const http_api = @import("../http/api.zig"); 11 + const permissioned = @import("../internal/permissioned_data.zig"); 12 + const scopes = @import("../internal/scopes.zig"); 13 + const store = @import("../storage/store.zig"); 14 + const zat = @import("zat"); 15 + 16 + const http = std.http; 3 17 4 18 pub fn dispatch(request: *http_api.Request) !void { 5 19 if (!config.permissionedData()) { ··· 11 25 ); 12 26 } 13 27 28 + const method = xrpcMethod(request.url.raw) orelse { 29 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid XRPC path"); 30 + }; 31 + 32 + if (std.mem.eql(u8, method, "com.atproto.space.createSpace")) return createSpace(request); 33 + if (std.mem.eql(u8, method, "com.atproto.space.getSpace")) return getSpace(request); 34 + if (std.mem.eql(u8, method, "com.atproto.space.listSpaces")) return listSpaces(request); 35 + if (std.mem.eql(u8, method, "com.atproto.space.updateSpaceConfig")) return updateSpaceConfig(request); 36 + if (std.mem.eql(u8, method, "com.atproto.space.deleteSpace")) return deleteSpace(request); 37 + if (std.mem.eql(u8, method, "com.atproto.space.addMember")) return addMember(request); 38 + if (std.mem.eql(u8, method, "com.atproto.space.removeMember")) return removeMember(request); 39 + if (std.mem.eql(u8, method, "com.atproto.space.getMembers")) return getMembers(request); 40 + if (std.mem.eql(u8, method, "com.atproto.space.getMemberState")) return getMemberState(request); 41 + if (std.mem.eql(u8, method, "com.atproto.space.getMemberOplog")) return getMemberOplog(request); 42 + if (std.mem.eql(u8, method, "com.atproto.space.getMemberGrant")) return getMemberGrant(request); 43 + if (std.mem.eql(u8, method, "com.atproto.space.createRecord")) return createRecord(request); 44 + if (std.mem.eql(u8, method, "com.atproto.space.putRecord")) return putRecord(request); 45 + if (std.mem.eql(u8, method, "com.atproto.space.deleteRecord")) return deleteRecord(request); 46 + if (std.mem.eql(u8, method, "com.atproto.space.applyWrites")) return applyWrites(request); 47 + if (std.mem.eql(u8, method, "com.atproto.space.getRecord")) return getRecord(request); 48 + if (std.mem.eql(u8, method, "com.atproto.space.listRecords")) return listRecords(request); 49 + if (std.mem.eql(u8, method, "com.atproto.space.getBlob")) return getBlob(request); 50 + if (std.mem.eql(u8, method, "com.atproto.space.getRepoState")) return getRepoState(request); 51 + if (std.mem.eql(u8, method, "com.atproto.space.getRepoOplog")) return getRepoOplog(request); 52 + if (std.mem.eql(u8, method, "com.atproto.space.getSpaceCredential")) return getSpaceCredential(request); 53 + if (std.mem.eql(u8, method, "com.atproto.space.notifyMembership")) return notifyMembership(request); 54 + if (std.mem.eql(u8, method, "com.atproto.space.notifyWrite")) return notifyWrite(request); 55 + if (std.mem.eql(u8, method, "com.atproto.space.notifySpaceDeleted")) return notifySpaceDeleted(request); 56 + 14 57 return http_api.xrpcError( 15 58 request, 16 59 .not_implemented, ··· 18 61 "Permissioned data is enabled but this method is not implemented yet", 19 62 ); 20 63 } 64 + 65 + fn createSpace(request: *http_api.Request) !void { 66 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 67 + defer arena.deinit(); 68 + const allocator = arena.allocator(); 69 + 70 + const auth_ctx = requireAccount(request, allocator) catch return; 71 + const parsed = parseBody(request, allocator, 64 * 1024) catch |err| switch (err) { 72 + error.HandledResponse => return, 73 + else => return err, 74 + }; 75 + const input = parsed.value; 76 + const did = zat.json.getString(input, "did") orelse { 77 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 78 + }; 79 + const space_type = zat.json.getString(input, "type") orelse { 80 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing type"); 81 + }; 82 + const skey = zat.json.getString(input, "skey") orelse try store.generateRkey(allocator); 83 + try requireSpaceScope(request, auth_ctx.oauth_scope, space_type, did, skey, .manage, null); 84 + const managing_app = zat.json.getString(input, "managingApp"); 85 + const is_public = valueBool(input, "isPublic") orelse false; 86 + const app_access_mode = zat.json.getString(input, "appAccessMode") orelse "allow"; 87 + const app_exceptions_json = appExceptionsJson(allocator, input) catch |err| switch (err) { 88 + error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "appExceptions must be an array"), 89 + else => return err, 90 + }; 91 + 92 + const space = store.createSpace(allocator, .{ 93 + .actor_did = auth_ctx.account.did, 94 + .owner_did = did, 95 + .space_type = space_type, 96 + .skey = skey, 97 + .is_owner = std.mem.eql(u8, did, auth_ctx.account.did), 98 + .managing_app = managing_app, 99 + .is_public = is_public, 100 + .app_access_mode = app_access_mode, 101 + .app_exceptions_json = app_exceptions_json, 102 + }) catch |err| switch (err) { 103 + error.InvalidRepoPath => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid did"), 104 + error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidType", "Invalid space type"), 105 + error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid skey"), 106 + error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid appAccessMode"), 107 + error.InvalidRefreshSession => return http_api.xrpcError(request, .bad_request, "SpaceAlreadyExists", "Space already exists"), 108 + else => return err, 109 + }; 110 + 111 + const body = try std.fmt.allocPrint(allocator, "{{\"uri\":{f}}}", .{std.json.fmt(space.uri, .{})}); 112 + return http_api.json(request, .ok, body); 113 + } 114 + 115 + fn getSpace(request: *http_api.Request) !void { 116 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 117 + defer arena.deinit(); 118 + const allocator = arena.allocator(); 119 + 120 + const auth_ctx = requireAccount(request, allocator) catch return; 121 + var space_buf: [1024]u8 = undefined; 122 + const space_uri = http_api.queryParam(request.url.raw, "space", &space_buf) orelse { 123 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 124 + }; 125 + const parsed = parseSpaceUri(space_uri) orelse { 126 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 127 + }; 128 + if (!std.mem.eql(u8, parsed.did, auth_ctx.account.did)) { 129 + return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); 130 + } 131 + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .manage, null); 132 + const space = (try store.getSpace(allocator, auth_ctx.account.did, space_uri)) orelse { 133 + return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 134 + }; 135 + if (!space.is_owner) { 136 + return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); 137 + } 138 + if (space.deleted_at != null) { 139 + return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 140 + } 141 + return http_api.json(request, .ok, try ownerSpaceJson(allocator, space)); 142 + } 143 + 144 + fn listSpaces(request: *http_api.Request) !void { 145 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 146 + defer arena.deinit(); 147 + const allocator = arena.allocator(); 148 + 149 + const auth_ctx = requireAccount(request, allocator) catch return; 150 + var did_buf: [256]u8 = undefined; 151 + const maybe_did = http_api.queryParam(request.url.raw, "did", &did_buf); 152 + var type_buf: [320]u8 = undefined; 153 + const maybe_type = http_api.queryParam(request.url.raw, "type", &type_buf); 154 + if (maybe_type) |space_type| { 155 + if (zat.Nsid.parse(space_type) == null) return http_api.xrpcError(request, .bad_request, "InvalidType", "Invalid space type"); 156 + } 157 + if (auth_ctx.oauth_scope) |scope_text| { 158 + if (!scopes.spaceAllows(scope_text, .read, maybe_type orelse "*", maybe_did orelse "*", "*", null)) { 159 + return http_api.xrpcError(request, .forbidden, "InsufficientScope", "OAuth token does not grant the requested space operation"); 160 + } 161 + } 162 + var cursor_buf: [1024]u8 = undefined; 163 + const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); 164 + const spaces = try store.listSpaces(allocator, auth_ctx.account.did, maybe_did, maybe_type, maybe_cursor, http_api.queryLimit(request.url.raw, 50)); 165 + 166 + var out: std.Io.Writer.Allocating = .init(allocator); 167 + defer out.deinit(); 168 + try out.writer.writeAll("{\"spaces\":["); 169 + for (spaces, 0..) |space, idx| { 170 + if (idx != 0) try out.writer.writeByte(','); 171 + try out.writer.print("{{\"uri\":{f},\"isOwner\":{}}}", .{ std.json.fmt(space.uri, .{}), space.is_owner }); 172 + } 173 + try out.writer.writeByte(']'); 174 + if (spaces.len > 0) { 175 + try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(spaces[spaces.len - 1].uri, .{})}); 176 + } 177 + try out.writer.writeByte('}'); 178 + return http_api.json(request, .ok, out.written()); 179 + } 180 + 181 + fn updateSpaceConfig(request: *http_api.Request) !void { 182 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 183 + defer arena.deinit(); 184 + const allocator = arena.allocator(); 185 + const auth_ctx = requireAccount(request, allocator) catch return; 186 + const parsed_body = parseBody(request, allocator, 64 * 1024) catch |err| switch (err) { 187 + error.HandledResponse => return, 188 + else => return err, 189 + }; 190 + const input = parsed_body.value; 191 + const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 192 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 193 + if (!std.mem.eql(u8, parsed.did, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); 194 + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .manage, null); 195 + const existing = (try store.getSpace(allocator, auth_ctx.account.did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 196 + if (!existing.is_owner) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); 197 + if (existing.deleted_at != null) return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 198 + const managing_app = zat.json.getString(input, "managingApp"); 199 + const clear_managing_app = switch (input) { 200 + .object => |object| if (object.get("managingApp")) |value| value == .string and value.string.len == 0 else false, 201 + else => false, 202 + }; 203 + const app_exceptions_json = appExceptionsJson(allocator, input) catch |err| switch (err) { 204 + error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "appExceptions must be an array"), 205 + else => return err, 206 + }; 207 + const has_exceptions = switch (input) { 208 + .object => |object| object.get("appExceptions") != null, 209 + else => false, 210 + }; 211 + store.updateSpaceConfig( 212 + space, 213 + managing_app, 214 + clear_managing_app, 215 + valueBool(input, "isPublic"), 216 + zat.json.getString(input, "appAccessMode"), 217 + if (has_exceptions) app_exceptions_json else null, 218 + ) catch |err| switch (err) { 219 + error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), 220 + error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid appAccessMode"), 221 + else => return err, 222 + }; 223 + return http_api.json(request, .ok, "{}"); 224 + } 225 + 226 + fn deleteSpace(request: *http_api.Request) !void { 227 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 228 + defer arena.deinit(); 229 + const allocator = arena.allocator(); 230 + const auth_ctx = requireAccount(request, allocator) catch return; 231 + const parsed_body = parseBody(request, allocator, 16 * 1024) catch |err| switch (err) { 232 + error.HandledResponse => return, 233 + else => return err, 234 + }; 235 + const space = zat.json.getString(parsed_body.value, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 236 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 237 + if (!std.mem.eql(u8, parsed.did, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); 238 + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .manage, null); 239 + const existing = try store.getSpace(allocator, auth_ctx.account.did, space); 240 + if (existing == null) return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 241 + if (!existing.?.is_owner) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); 242 + if (existing.?.deleted_at != null) return http_api.json(request, .ok, "{}"); 243 + const recipients = try store.listCredentialRecipients(allocator, space); 244 + try store.markSpaceDeleted(auth_ctx.account.did, space); 245 + try store.purgeOwnerSpaceData(space); 246 + if (existing) |space_row| fireNotifySpaceDeleted(allocator, auth_ctx.account, space, space_row.members, recipients) catch {}; 247 + return http_api.json(request, .ok, "{}"); 248 + } 249 + 250 + fn addMember(request: *http_api.Request) !void { 251 + return mutateMember(request, true); 252 + } 253 + 254 + fn removeMember(request: *http_api.Request) !void { 255 + return mutateMember(request, false); 256 + } 257 + 258 + fn mutateMember(request: *http_api.Request, add: bool) !void { 259 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 260 + defer arena.deinit(); 261 + const allocator = arena.allocator(); 262 + const auth_ctx = requireAccount(request, allocator) catch return; 263 + const parsed_body = parseBody(request, allocator, 16 * 1024) catch |err| switch (err) { 264 + error.HandledResponse => return, 265 + else => return err, 266 + }; 267 + const input = parsed_body.value; 268 + const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 269 + const member = zat.json.getString(input, "did") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 270 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 271 + if (!std.mem.eql(u8, parsed.did, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); 272 + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .manage, null); 273 + const existing = (try store.getSpace(allocator, auth_ctx.account.did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 274 + if (!existing.is_owner) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); 275 + _ = (if (add) 276 + store.addSpaceMember(allocator, space, member) 277 + else 278 + store.removeSpaceMember(allocator, space, member)) catch |err| switch (err) { 279 + error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), 280 + error.InvalidRefreshSession => return http_api.xrpcError(request, .bad_request, "MemberAlreadyExists", "Member already exists"), 281 + error.MissingRecord => return http_api.xrpcError(request, .bad_request, "MemberNotFound", "Member not found"), 282 + else => return err, 283 + }; 284 + fireNotifyMembership(allocator, auth_ctx.account, space, member, add) catch {}; 285 + return http_api.json(request, .ok, "{}"); 286 + } 287 + 288 + fn getMembers(request: *http_api.Request) !void { 289 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 290 + defer arena.deinit(); 291 + const allocator = arena.allocator(); 292 + var space_buf: [1024]u8 = undefined; 293 + const space_uri = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 294 + _ = requireReadAccess(request, allocator, space_uri, null) catch return; 295 + const parsed = parseSpaceUri(space_uri) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 296 + _ = (try store.getSpace(allocator, parsed.did, space_uri)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 297 + var cursor_buf: [256]u8 = undefined; 298 + const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); 299 + const limit = http_api.queryLimit(request.url.raw, 100); 300 + const members = try store.listSpaceMembers(allocator, space_uri, maybe_cursor, limit); 301 + var out: std.Io.Writer.Allocating = .init(allocator); 302 + defer out.deinit(); 303 + try out.writer.writeAll("{\"members\":["); 304 + for (members, 0..) |member, idx| { 305 + if (idx != 0) try out.writer.writeByte(','); 306 + try out.writer.print("{{\"did\":{f}}}", .{std.json.fmt(member, .{})}); 307 + } 308 + try out.writer.writeByte(']'); 309 + if (members.len > 0 and members.len == @min(if (limit == 0) 100 else limit, 1000)) { 310 + try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(members[members.len - 1], .{})}); 311 + } 312 + try out.writer.writeByte('}'); 313 + return http_api.json(request, .ok, out.written()); 314 + } 315 + 316 + fn getMemberState(request: *http_api.Request) !void { 317 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 318 + defer arena.deinit(); 319 + const allocator = arena.allocator(); 320 + var space_buf: [1024]u8 = undefined; 321 + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 322 + _ = requireReadAccess(request, allocator, space, null) catch return; 323 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 324 + const state = try store.getSpaceMemberState(allocator, space); 325 + return writeSignedState(request, allocator, space, parsed.did, parsed.did, .members, state); 326 + } 327 + 328 + fn getMemberOplog(request: *http_api.Request) !void { 329 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 330 + defer arena.deinit(); 331 + const allocator = arena.allocator(); 332 + var space_buf: [1024]u8 = undefined; 333 + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 334 + _ = requireReadAccess(request, allocator, space, null) catch return; 335 + var since_buf: [128]u8 = undefined; 336 + const since = http_api.queryParam(request.url.raw, "since", &since_buf); 337 + const limit = http_api.queryLimit(request.url.raw, 100); 338 + const ops = try store.listSpaceMemberOplog(allocator, space, since, limit); 339 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 340 + const state = try store.getSpaceMemberState(allocator, space); 341 + var out: std.Io.Writer.Allocating = .init(allocator); 342 + defer out.deinit(); 343 + try out.writer.writeAll("{\"ops\":["); 344 + for (ops, 0..) |op, idx| { 345 + if (idx != 0) try out.writer.writeByte(','); 346 + try out.writer.print("{{\"rev\":{f},\"action\":{f},\"did\":{f}}}", .{ std.json.fmt(op.rev, .{}), std.json.fmt(op.action, .{}), std.json.fmt(op.did, .{}) }); 347 + } 348 + try out.writer.writeByte(']'); 349 + if (ops.len < @min(if (limit == 0) 100 else limit, 1000)) { 350 + if (try signedCommitJson(allocator, space, parsed.did, parsed.did, .members, state)) |commit_json| { 351 + try out.writer.print(",\"commit\":{s}", .{commit_json}); 352 + } 353 + } 354 + try out.writer.writeByte('}'); 355 + return http_api.json(request, .ok, out.written()); 356 + } 357 + 358 + fn getMemberGrant(request: *http_api.Request) !void { 359 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 360 + defer arena.deinit(); 361 + const allocator = arena.allocator(); 362 + const auth_ctx = requireAccount(request, allocator) catch return; 363 + const client_id = auth_ctx.oauth_client_id orelse "unknown"; 364 + var space_buf: [1024]u8 = undefined; 365 + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 366 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 367 + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .read, null); 368 + var keypair = store.signingKeypair(auth_ctx.account.did) catch return http_api.xrpcError(request, .not_found, "RepoNotFound", "Signing key not found"); 369 + const grant = try permissioned.createMemberGrant(allocator, store.currentIo(), auth_ctx.account.did, parsed.did, space, client_id, &keypair); 370 + return http_api.json(request, .ok, try std.fmt.allocPrint(allocator, "{{\"grant\":{f}}}", .{std.json.fmt(grant, .{})})); 371 + } 372 + 373 + fn createRecord(request: *http_api.Request) !void { 374 + return writeSpaceRecord(request, .create); 375 + } 376 + 377 + fn putRecord(request: *http_api.Request) !void { 378 + return writeSpaceRecord(request, .put); 379 + } 380 + 381 + const WriteMode = enum { create, put }; 382 + 383 + fn deleteRecord(request: *http_api.Request) !void { 384 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 385 + defer arena.deinit(); 386 + const allocator = arena.allocator(); 387 + const auth_ctx = requireAccount(request, allocator) catch return; 388 + const parsed_body = parseBody(request, allocator, 64 * 1024) catch |err| switch (err) { 389 + error.HandledResponse => return, 390 + else => return err, 391 + }; 392 + const input = parsed_body.value; 393 + const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 394 + const repo = zat.json.getString(input, "repo") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 395 + if (!std.mem.eql(u8, repo, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "InvalidRepo", "repo must match authenticated user"); 396 + const collection = zat.json.getString(input, "collection") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); 397 + const rkey = zat.json.getString(input, "rkey") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey"); 398 + try requireSpaceAccess(request, allocator, auth_ctx, space, .delete, collection); 399 + store.deleteSpaceRecord(allocator, space, repo, collection, rkey) catch |err| switch (err) { 400 + error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), 401 + error.MissingRecord => return http_api.xrpcError(request, .not_found, "RecordNotFound", "Record not found"), 402 + error.InvalidRefreshSession => return http_api.xrpcError(request, .forbidden, "NotAMember", "Not a member of the space"), 403 + else => return err, 404 + }; 405 + return http_api.json(request, .ok, "{}"); 406 + } 407 + 408 + fn applyWrites(request: *http_api.Request) !void { 409 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 410 + defer arena.deinit(); 411 + const allocator = arena.allocator(); 412 + const auth_ctx = requireAccount(request, allocator) catch return; 413 + const parsed_body = parseBody(request, allocator, 512 * 1024) catch |err| switch (err) { 414 + error.HandledResponse => return, 415 + else => return err, 416 + }; 417 + const input = parsed_body.value; 418 + const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 419 + const repo = zat.json.getString(input, "repo") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 420 + if (!std.mem.eql(u8, repo, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "InvalidRepo", "repo must match authenticated user"); 421 + const writes_value = switch (input) { 422 + .object => |object| object.get("writes") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing writes"), 423 + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid body"), 424 + }; 425 + if (writes_value != .array) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "writes must be an array"); 426 + var ops: std.ArrayList(store.SpaceWriteOp) = .empty; 427 + for (writes_value.array.items) |write| { 428 + const write_type = zat.json.getString(write, "$type") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "write missing $type"); 429 + const collection = zat.json.getString(write, "collection") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "write missing collection"); 430 + if (std.mem.endsWith(u8, write_type, "#create")) { 431 + try requireSpaceAccess(request, allocator, auth_ctx, space, .create, collection); 432 + const rkey = zat.json.getString(write, "rkey") orelse try store.generateRkey(allocator); 433 + const value = switch (write) { 434 + .object => |object| object.get("value") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "create missing value"), 435 + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid write"), 436 + }; 437 + const prepared = try prepareSpaceRecord(request, allocator, collection, rkey, value); 438 + try ops.append(allocator, .{ .create = .{ .collection = collection, .rkey = rkey, .prepared = prepared } }); 439 + } else if (std.mem.endsWith(u8, write_type, "#update")) { 440 + try requireSpaceAccess(request, allocator, auth_ctx, space, .update, collection); 441 + const rkey = zat.json.getString(write, "rkey") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "update missing rkey"); 442 + const value = switch (write) { 443 + .object => |object| object.get("value") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "update missing value"), 444 + else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid write"), 445 + }; 446 + const prepared = try prepareSpaceRecord(request, allocator, collection, rkey, value); 447 + try ops.append(allocator, .{ .update = .{ .collection = collection, .rkey = rkey, .prepared = prepared } }); 448 + } else if (std.mem.endsWith(u8, write_type, "#delete")) { 449 + try requireSpaceAccess(request, allocator, auth_ctx, space, .delete, collection); 450 + const rkey = zat.json.getString(write, "rkey") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "delete missing rkey"); 451 + try ops.append(allocator, .{ .delete = .{ .collection = collection, .rkey = rkey } }); 452 + } else { 453 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Unknown write type"); 454 + } 455 + } 456 + const results = store.applySpaceWrites(allocator, space, repo, ops.items) catch |err| switch (err) { 457 + error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), 458 + error.MissingRecord => return http_api.xrpcError(request, .not_found, "RecordNotFound", "Record not found"), 459 + error.InvalidRefreshSession => return http_api.xrpcError(request, .forbidden, "NotAMember", "Not a member of the space"), 460 + else => return err, 461 + }; 462 + const state = try store.getSpaceRepoState(allocator, space, repo); 463 + if (state.rev) |rev| fireNotifyWrite(allocator, auth_ctx.account, space, repo, rev) catch {}; 464 + var out: std.Io.Writer.Allocating = .init(allocator); 465 + defer out.deinit(); 466 + try out.writer.writeAll("{\"results\":["); 467 + for (results, 0..) |result, idx| { 468 + if (idx != 0) try out.writer.writeByte(','); 469 + switch (result) { 470 + .create => |record| try out.writer.print("{{\"$type\":\"com.atproto.space.applyWrites#createResult\",\"uri\":{f},\"cid\":{f}}}", .{ std.json.fmt(try record.uri(allocator), .{}), std.json.fmt(record.cid, .{}) }), 471 + .update => |record| try out.writer.print("{{\"$type\":\"com.atproto.space.applyWrites#updateResult\",\"uri\":{f},\"cid\":{f}}}", .{ std.json.fmt(try record.uri(allocator), .{}), std.json.fmt(record.cid, .{}) }), 472 + .delete => try out.writer.writeAll("{\"$type\":\"com.atproto.space.applyWrites#deleteResult\"}"), 473 + } 474 + } 475 + try out.writer.writeAll("]}"); 476 + return http_api.json(request, .ok, out.written()); 477 + } 478 + 479 + fn writeSpaceRecord(request: *http_api.Request, mode: WriteMode) !void { 480 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 481 + defer arena.deinit(); 482 + const allocator = arena.allocator(); 483 + 484 + const auth_ctx = requireAccount(request, allocator) catch return; 485 + const parsed = parseBody(request, allocator, 256 * 1024) catch |err| switch (err) { 486 + error.HandledResponse => return, 487 + else => return err, 488 + }; 489 + const input = parsed.value; 490 + const space = zat.json.getString(input, "space") orelse { 491 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 492 + }; 493 + const repo = zat.json.getString(input, "repo") orelse { 494 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 495 + }; 496 + if (!std.mem.eql(u8, repo, auth_ctx.account.did)) { 497 + return http_api.xrpcError(request, .forbidden, "InvalidRepo", "repo must match authenticated user"); 498 + } 499 + const collection = zat.json.getString(input, "collection") orelse { 500 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); 501 + }; 502 + switch (mode) { 503 + .create => try requireSpaceAccess(request, allocator, auth_ctx, space, .create, collection), 504 + .put => { 505 + try requireSpaceAccess(request, allocator, auth_ctx, space, .create, collection); 506 + try requireSpaceAccess(request, allocator, auth_ctx, space, .update, collection); 507 + }, 508 + } 509 + const rkey = zat.json.getString(input, "rkey") orelse switch (mode) { 510 + .create => try store.generateRkey(allocator), 511 + .put => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey"), 512 + }; 513 + const record = recordBodyValue(input) orelse { 514 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing record"); 515 + }; 516 + const prepared = store.prepareRecordValue(allocator, collection, rkey, record) catch |err| switch (err) { 517 + error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"), 518 + error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"), 519 + error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid record type"), 520 + error.InvalidDagCbor => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "record is outside the atproto data model"), 521 + else => return err, 522 + }; 523 + const stored = switch (mode) { 524 + .create => store.createSpaceRecord(allocator, space, repo, collection, rkey, prepared), 525 + .put => store.putSpaceRecord(allocator, space, repo, collection, rkey, prepared), 526 + } catch |err| switch (err) { 527 + error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), 528 + error.InvalidRefreshSession => return http_api.xrpcError(request, .forbidden, "NotAMember", "Not a member of the space"), 529 + error.MissingRecord => return http_api.xrpcError(request, .bad_request, "RecordNotFound", "Record not found"), 530 + else => return err, 531 + }; 532 + fireNotifyWrite(allocator, auth_ctx.account, space, repo, stored.repo_rev) catch {}; 533 + const uri = try stored.uri(allocator); 534 + const body = try std.fmt.allocPrint( 535 + allocator, 536 + "{{\"uri\":{f},\"cid\":{f}}}", 537 + .{ std.json.fmt(uri, .{}), std.json.fmt(stored.cid, .{}) }, 538 + ); 539 + return http_api.json(request, .ok, body); 540 + } 541 + 542 + fn getRecord(request: *http_api.Request) !void { 543 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 544 + defer arena.deinit(); 545 + const allocator = arena.allocator(); 546 + 547 + const params = recordParams(request, allocator) catch |err| switch (err) { 548 + error.HandledResponse => return, 549 + else => return err, 550 + }; 551 + _ = requireReadAccess(request, allocator, params.space, params.collection) catch return; 552 + const record = (try store.getSpaceRecord(allocator, params.space, params.repo, params.collection, params.rkey)) orelse { 553 + return http_api.xrpcError(request, .not_found, "RecordNotFound", "Record not found"); 554 + }; 555 + const uri = try record.uri(allocator); 556 + const body = try std.fmt.allocPrint( 557 + allocator, 558 + "{{\"uri\":{f},\"cid\":{f},\"value\":{s}}}", 559 + .{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}), record.value_json }, 560 + ); 561 + return http_api.json(request, .ok, body); 562 + } 563 + 564 + fn listRecords(request: *http_api.Request) !void { 565 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 566 + defer arena.deinit(); 567 + const allocator = arena.allocator(); 568 + 569 + var space_buf: [1024]u8 = undefined; 570 + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse { 571 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 572 + }; 573 + var repo_buf: [256]u8 = undefined; 574 + const repo = http_api.queryParam(request.url.raw, "repo", &repo_buf) orelse { 575 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 576 + }; 577 + var collection_buf: [320]u8 = undefined; 578 + const maybe_collection = http_api.queryParam(request.url.raw, "collection", &collection_buf); 579 + _ = requireReadAccess(request, allocator, space, maybe_collection) catch return; 580 + var cursor_buf: [1024]u8 = undefined; 581 + const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); 582 + var reverse_buf: [8]u8 = undefined; 583 + const reverse = if (http_api.queryParam(request.url.raw, "reverse", &reverse_buf)) |value| 584 + std.mem.eql(u8, value, "true") 585 + else 586 + false; 587 + 588 + const records = try store.listSpaceRecords( 589 + allocator, 590 + space, 591 + repo, 592 + maybe_collection, 593 + maybe_cursor, 594 + reverse, 595 + http_api.queryLimit(request.url.raw, 50), 596 + ); 597 + var out: std.Io.Writer.Allocating = .init(allocator); 598 + defer out.deinit(); 599 + try out.writer.writeAll("{\"records\":["); 600 + for (records, 0..) |record, idx| { 601 + if (idx != 0) try out.writer.writeByte(','); 602 + try out.writer.print( 603 + "{{\"collection\":{f},\"rkey\":{f},\"cid\":{f}}}", 604 + .{ std.json.fmt(record.collection, .{}), std.json.fmt(record.rkey, .{}), std.json.fmt(record.cid, .{}) }, 605 + ); 606 + } 607 + try out.writer.writeByte(']'); 608 + if (records.len > 0) { 609 + const last = records[records.len - 1]; 610 + try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(try std.fmt.allocPrint(allocator, "{s}/{s}", .{ last.collection, last.rkey }), .{})}); 611 + } 612 + try out.writer.writeByte('}'); 613 + return http_api.json(request, .ok, out.written()); 614 + } 615 + 616 + fn getBlob(request: *http_api.Request) !void { 617 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 618 + defer arena.deinit(); 619 + const allocator = arena.allocator(); 620 + 621 + var space_buf: [1024]u8 = undefined; 622 + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse { 623 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 624 + }; 625 + var repo_buf: [256]u8 = undefined; 626 + const repo = http_api.queryParam(request.url.raw, "repo", &repo_buf) orelse { 627 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 628 + }; 629 + var cid_buf: [256]u8 = undefined; 630 + const cid = http_api.queryParam(request.url.raw, "cid", &cid_buf) orelse { 631 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing cid"); 632 + }; 633 + _ = requireReadAccess(request, allocator, space, null) catch return; 634 + const blob = store.getBlob(allocator, repo, cid) orelse { 635 + return http_api.xrpcError(request, .not_found, "BlobNotFound", "Blob not found"); 636 + }; 637 + return writeBlobResponse(request, allocator, cid, blob); 638 + } 639 + 640 + fn getRepoState(request: *http_api.Request) !void { 641 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 642 + defer arena.deinit(); 643 + const allocator = arena.allocator(); 644 + var space_buf: [1024]u8 = undefined; 645 + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 646 + var repo_buf: [256]u8 = undefined; 647 + const repo = http_api.queryParam(request.url.raw, "repo", &repo_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 648 + _ = requireReadAccess(request, allocator, space, null) catch return; 649 + const state = try store.getSpaceRepoState(allocator, space, repo); 650 + return writeSignedState(request, allocator, space, repo, repo, .records, state); 651 + } 652 + 653 + fn getRepoOplog(request: *http_api.Request) !void { 654 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 655 + defer arena.deinit(); 656 + const allocator = arena.allocator(); 657 + var space_buf: [1024]u8 = undefined; 658 + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 659 + var repo_buf: [256]u8 = undefined; 660 + const repo = http_api.queryParam(request.url.raw, "repo", &repo_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 661 + _ = requireReadAccess(request, allocator, space, null) catch return; 662 + var since_buf: [128]u8 = undefined; 663 + const since = http_api.queryParam(request.url.raw, "since", &since_buf); 664 + const limit = http_api.queryLimit(request.url.raw, 100); 665 + const ops = try store.listSpaceRecordOplog(allocator, space, repo, since, limit); 666 + const state = try store.getSpaceRepoState(allocator, space, repo); 667 + var out: std.Io.Writer.Allocating = .init(allocator); 668 + defer out.deinit(); 669 + try out.writer.writeAll("{\"ops\":["); 670 + for (ops, 0..) |op, idx| { 671 + if (idx != 0) try out.writer.writeByte(','); 672 + try out.writer.print( 673 + "{{\"rev\":{f},\"action\":{f},\"collection\":{f},\"rkey\":{f},\"cid\":", 674 + .{ std.json.fmt(op.rev, .{}), std.json.fmt(op.action, .{}), std.json.fmt(op.collection, .{}), std.json.fmt(op.rkey, .{}) }, 675 + ); 676 + if (op.cid) |cid_value| try out.writer.print("{f}", .{std.json.fmt(cid_value, .{})}) else try out.writer.writeAll("null"); 677 + try out.writer.writeAll(",\"prev\":"); 678 + if (op.prev) |prev_value| try out.writer.print("{f}", .{std.json.fmt(prev_value, .{})}) else try out.writer.writeAll("null"); 679 + try out.writer.writeByte('}'); 680 + } 681 + try out.writer.writeByte(']'); 682 + if (ops.len < @min(if (limit == 0) 100 else limit, 1000)) { 683 + if (try signedCommitJson(allocator, space, repo, repo, .records, state)) |commit_json| { 684 + try out.writer.print(",\"commit\":{s}", .{commit_json}); 685 + } 686 + } 687 + try out.writer.writeByte('}'); 688 + return http_api.json(request, .ok, out.written()); 689 + } 690 + 691 + fn getSpaceCredential(request: *http_api.Request) !void { 692 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 693 + defer arena.deinit(); 694 + const allocator = arena.allocator(); 695 + const token = bearerToken(request) orelse return http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); 696 + const member_did = permissioned.unverifiedStringClaim(allocator, token, "iss") catch { 697 + return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid member grant"); 698 + }; 699 + const public_key = signingKeyForDid(allocator, member_did) catch { 700 + return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve member grant issuer did"); 701 + }; 702 + const grant = permissioned.verifyMemberGrant(allocator, token, public_key) catch { 703 + return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid member grant"); 704 + }; 705 + const parsed_body = parseBody(request, allocator, 32 * 1024) catch |err| switch (err) { 706 + error.HandledResponse => return, 707 + else => return err, 708 + }; 709 + const input = parsed_body.value; 710 + const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 711 + if (!std.mem.eql(u8, grant.space, space)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Grant space mismatch"); 712 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 713 + if (!std.mem.eql(u8, grant.owner_did, parsed.did)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Grant owner mismatch"); 714 + const config_row = (try store.getSpace(allocator, parsed.did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 715 + if (config_row.deleted_at != null) { 716 + return http_api.xrpcError(request, .bad_request, "SpaceDeleted", "Space has been deleted"); 717 + } 718 + if (!config_row.is_public and !try store.spaceHasMember(allocator, space, grant.member_did)) { 719 + return http_api.xrpcError(request, .forbidden, "NotAMember", "Not a member of the space"); 720 + } 721 + if (!spaceAllowsClient(allocator, config_row, grant.client_id)) { 722 + return http_api.xrpcError(request, .forbidden, "AppNotPermitted", "OAuth client is not allowed for this space"); 723 + } 724 + var keypair = store.signingKeypair(parsed.did) catch return http_api.xrpcError(request, .not_found, "RepoNotFound", "Owner signing key not found"); 725 + const credential = try permissioned.createSpaceCredential(allocator, store.currentIo(), parsed.did, space, grant.client_id, &keypair); 726 + if (zat.json.getString(input, "notifyEndpoint")) |endpoint| { 727 + try store.recordCredentialRecipient(space, grant.member_did, endpoint); 728 + } 729 + return http_api.json(request, .ok, try std.fmt.allocPrint(allocator, "{{\"credential\":{f}}}", .{std.json.fmt(credential, .{})})); 730 + } 731 + 732 + fn notifyMembership(request: *http_api.Request) !void { 733 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 734 + defer arena.deinit(); 735 + const allocator = arena.allocator(); 736 + const parsed_body = parseBody(request, allocator, 16 * 1024) catch |err| switch (err) { 737 + error.HandledResponse => return, 738 + else => return err, 739 + }; 740 + const input = parsed_body.value; 741 + const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 742 + const member = zat.json.getString(input, "did") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 743 + const is_member = valueBool(input, "isMember") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing isMember"); 744 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 745 + const service = requireServiceAuth(request, allocator, "com.atproto.space.notifyMembership") catch return; 746 + if (!std.mem.eql(u8, service.issuer_did, parsed.did)) return http_api.xrpcError(request, .unauthorized, "InvalidToken", "JWT issuer must be the space DID"); 747 + if (!std.mem.eql(u8, service.audience, member)) return http_api.xrpcError(request, .unauthorized, "BadJwtAudience", "JWT audience must be the member DID"); 748 + if ((store.findAccount(allocator, member) catch null) == null) return http_api.xrpcError(request, .bad_request, "AccountNotFound", "Account not found on this PDS"); 749 + try store.setSpaceMembership(allocator, space, member, is_member); 750 + return http_api.json(request, .ok, "{}"); 751 + } 752 + 753 + fn notifyWrite(request: *http_api.Request) !void { 754 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 755 + defer arena.deinit(); 756 + const allocator = arena.allocator(); 757 + const parsed_body = parseBody(request, allocator, 16 * 1024) catch |err| switch (err) { 758 + error.HandledResponse => return, 759 + else => return err, 760 + }; 761 + const input = parsed_body.value; 762 + const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 763 + const repo = zat.json.getString(input, "repo") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing repo"); 764 + const rev = zat.json.getString(input, "rev") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rev"); 765 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 766 + const service = requireServiceAuth(request, allocator, "com.atproto.space.notifyWrite") catch return; 767 + if (!std.mem.eql(u8, service.issuer_did, repo)) return http_api.xrpcError(request, .unauthorized, "InvalidToken", "JWT issuer must be the writer repo DID"); 768 + if (!std.mem.eql(u8, service.audience, parsed.did)) return http_api.xrpcError(request, .unauthorized, "BadJwtAudience", "JWT audience must be the space DID"); 769 + const owner = (store.findAccount(allocator, parsed.did) catch null) orelse return http_api.json(request, .ok, "{}"); 770 + if (!try store.spaceHasMember(allocator, space, repo)) return http_api.xrpcError(request, .forbidden, "NotAMember", "Writer is not a member of the space"); 771 + try fanoutNotifyWriteToRecipients(allocator, owner, space, repo, rev); 772 + return http_api.json(request, .ok, "{}"); 773 + } 774 + 775 + fn notifySpaceDeleted(request: *http_api.Request) !void { 776 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 777 + defer arena.deinit(); 778 + const allocator = arena.allocator(); 779 + const parsed_body = parseBody(request, allocator, 16 * 1024) catch |err| switch (err) { 780 + error.HandledResponse => return, 781 + else => return err, 782 + }; 783 + const space = zat.json.getString(parsed_body.value, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 784 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 785 + const service = requireServiceAuth(request, allocator, "com.atproto.space.notifySpaceDeleted") catch return; 786 + if (!std.mem.eql(u8, service.issuer_did, parsed.did)) return http_api.xrpcError(request, .unauthorized, "InvalidToken", "JWT issuer must be the space DID"); 787 + if (zat.Did.parse(service.audience) == null and zat.Handle.parse(service.audience) == null) return http_api.json(request, .ok, "{}"); 788 + if ((store.findAccount(allocator, service.audience) catch null) == null) return http_api.json(request, .ok, "{}"); 789 + if ((try store.getSpace(allocator, service.audience, space)) == null) return http_api.json(request, .ok, "{}"); 790 + try store.markSpaceDeleted(service.audience, space); 791 + return http_api.json(request, .ok, "{}"); 792 + } 793 + 794 + const RecordParams = struct { 795 + space: []const u8, 796 + repo: []const u8, 797 + collection: []const u8, 798 + rkey: []const u8, 799 + }; 800 + 801 + fn recordParams(request: *http_api.Request, allocator: std.mem.Allocator) !RecordParams { 802 + var space_buf: [1024]u8 = undefined; 803 + var repo_buf: [256]u8 = undefined; 804 + var collection_buf: [320]u8 = undefined; 805 + var rkey_buf: [512]u8 = undefined; 806 + return .{ 807 + .space = try requiredQueryParam(request, allocator, "space", &space_buf, "Missing space"), 808 + .repo = try requiredQueryParam(request, allocator, "repo", &repo_buf, "Missing repo"), 809 + .collection = try requiredQueryParam(request, allocator, "collection", &collection_buf, "Missing collection"), 810 + .rkey = try requiredQueryParam(request, allocator, "rkey", &rkey_buf, "Missing rkey"), 811 + }; 812 + } 813 + 814 + fn requiredQueryParam( 815 + request: *http_api.Request, 816 + allocator: std.mem.Allocator, 817 + name: []const u8, 818 + buf: []u8, 819 + message: []const u8, 820 + ) ![]const u8 { 821 + const value = http_api.queryParam(request.url.raw, name, buf) orelse { 822 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", message); 823 + return error.HandledResponse; 824 + }; 825 + return allocator.dupe(u8, value); 826 + } 827 + 828 + fn writeBlobResponse(request: *http_api.Request, allocator: std.mem.Allocator, cid: []const u8, blob: store.BlobRecord) !void { 829 + const range = http_api.headerValue(request, "range"); 830 + const selected = parseRange(range, blob.data.len) catch { 831 + const headers = [_]http.Header{ 832 + .{ .name = "content-range", .value = try std.fmt.allocPrint(allocator, "bytes */{d}", .{blob.data.len}) }, 833 + .{ .name = "accept-ranges", .value = "bytes" }, 834 + .{ .name = "access-control-allow-origin", .value = "*" }, 835 + }; 836 + return http_api.respond(request, .range_not_satisfiable, "", &headers); 837 + }; 838 + const start = selected.start; 839 + const end_inclusive = selected.end_inclusive; 840 + const body = blob.data[start .. end_inclusive + 1]; 841 + const status: http.Status = if (range == null) .ok else .partial_content; 842 + const content_range = try std.fmt.allocPrint(allocator, "bytes {d}-{d}/{d}", .{ start, end_inclusive, blob.data.len }); 843 + const disposition = try std.fmt.allocPrint(allocator, "attachment; filename=\"{s}\"", .{cid}); 844 + const headers = [_]http.Header{ 845 + .{ .name = "content-type", .value = if (blob.mime_type.len > 0) blob.mime_type else "application/octet-stream" }, 846 + .{ .name = "accept-ranges", .value = "bytes" }, 847 + .{ .name = "content-range", .value = content_range }, 848 + .{ .name = "x-content-type-options", .value = "nosniff" }, 849 + .{ .name = "content-disposition", .value = disposition }, 850 + .{ .name = "content-security-policy", .value = "default-src 'none'; sandbox" }, 851 + .{ .name = "access-control-allow-origin", .value = "*" }, 852 + .{ .name = "access-control-allow-private-network", .value = "true" }, 853 + }; 854 + return http_api.respond(request, status, if (request.method == .HEAD) "" else body, &headers); 855 + } 856 + 857 + fn parseRange(maybe_range: ?[]const u8, size: usize) !struct { start: usize, end_inclusive: usize } { 858 + if (size == 0) return error.InvalidRange; 859 + const raw = maybe_range orelse return .{ .start = 0, .end_inclusive = size - 1 }; 860 + if (!std.mem.startsWith(u8, raw, "bytes=")) return error.InvalidRange; 861 + const spec = raw["bytes=".len..]; 862 + const dash = std.mem.indexOfScalar(u8, spec, '-') orelse return error.InvalidRange; 863 + if (dash == 0) { 864 + const suffix = try std.fmt.parseInt(usize, spec[1..], 10); 865 + if (suffix == 0) return error.InvalidRange; 866 + const start = if (suffix >= size) 0 else size - suffix; 867 + return .{ .start = start, .end_inclusive = size - 1 }; 868 + } 869 + const start = try std.fmt.parseInt(usize, spec[0..dash], 10); 870 + const end = if (dash + 1 < spec.len) try std.fmt.parseInt(usize, spec[dash + 1 ..], 10) else size - 1; 871 + if (start >= size or end < start) return error.InvalidRange; 872 + return .{ .start = start, .end_inclusive = @min(end, size - 1) }; 873 + } 874 + 875 + fn bearerToken(request: *http_api.Request) ?[]const u8 { 876 + const raw_header = http_api.headerValue(request, "authorization") orelse return null; 877 + if (!std.ascii.startsWithIgnoreCase(raw_header, "bearer ")) return null; 878 + return std.mem.trim(u8, raw_header["bearer ".len..], " \t"); 879 + } 880 + 881 + fn signingKeyForDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 882 + const parsed = zat.Did.parse(did) orelse return error.InvalidDid; 883 + var resolver = zat.DidResolver.init(store.currentIo(), allocator); 884 + defer resolver.deinit(); 885 + var doc = try resolver.resolve(parsed); 886 + defer doc.deinit(); 887 + const signing_key = doc.signingKey() orelse return error.MissingSigningKey; 888 + return allocator.dupe(u8, signing_key.public_key_multibase); 889 + } 890 + 891 + const ServiceAuth = struct { 892 + issuer_did: []const u8, 893 + audience: []const u8, 894 + }; 895 + 896 + fn requireServiceAuth(request: *http_api.Request, allocator: std.mem.Allocator, method: []const u8) !ServiceAuth { 897 + const token = bearerToken(request) orelse { 898 + try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); 899 + return error.HandledResponse; 900 + }; 901 + var jwt = zat.Jwt.parse(allocator, token) catch { 902 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 903 + return error.HandledResponse; 904 + }; 905 + defer jwt.deinit(); 906 + const issuer_did = issuerDid(jwt.payload.iss); 907 + if (zat.Did.parse(issuer_did) == null or jwt.isExpired(store.currentIo())) { 908 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 909 + return error.HandledResponse; 910 + } 911 + const lxm = jwt.payload.lxm orelse { 912 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 913 + return error.HandledResponse; 914 + }; 915 + if (!std.mem.eql(u8, lxm, method) and !std.mem.eql(u8, lxm, "*")) { 916 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "JWT lxm mismatch"); 917 + return error.HandledResponse; 918 + } 919 + const public_key = signingKeyForDid(allocator, issuer_did) catch { 920 + try http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve jwt issuer did"); 921 + return error.HandledResponse; 922 + }; 923 + jwt.verify(public_key) catch { 924 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 925 + return error.HandledResponse; 926 + }; 927 + return .{ 928 + .issuer_did = try allocator.dupe(u8, issuer_did), 929 + .audience = try allocator.dupe(u8, jwt.payload.aud), 930 + }; 931 + } 932 + 933 + fn issuerDid(iss: []const u8) []const u8 { 934 + return if (std.mem.indexOfScalar(u8, iss, '#')) |idx| iss[0..idx] else iss; 935 + } 936 + 937 + fn serviceAudienceMatchesPds(aud: []const u8) bool { 938 + if (std.mem.eql(u8, aud, config.serverDid())) return true; 939 + if (!std.mem.startsWith(u8, aud, config.serverDid())) return false; 940 + return std.mem.eql(u8, aud[config.serverDid().len..], "#atproto_pds"); 941 + } 942 + 943 + fn pdsEndpointForDid(allocator: std.mem.Allocator, did: []const u8) ![]const u8 { 944 + const parsed = zat.Did.parse(did) orelse return error.InvalidDid; 945 + var resolver = zat.DidResolver.init(store.currentIo(), allocator); 946 + defer resolver.deinit(); 947 + var doc = try resolver.resolve(parsed); 948 + defer doc.deinit(); 949 + const endpoint = doc.pdsEndpoint() orelse return error.MissingPdsEndpoint; 950 + return allocator.dupe(u8, endpoint); 951 + } 952 + 953 + fn postSignedXrpc( 954 + allocator: std.mem.Allocator, 955 + account: auth.Account, 956 + endpoint: []const u8, 957 + audience: []const u8, 958 + method: []const u8, 959 + body: []const u8, 960 + ) !void { 961 + var keypair = try store.signingKeypair(account.did); 962 + const token = try auth.createServiceJwtWithKeypair(allocator, account, audience, method, null, &keypair); 963 + const authorization = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token}); 964 + const url = try std.fmt.allocPrint(allocator, "{s}/xrpc/{s}", .{ endpoint, method }); 965 + var transport = zat.HttpTransport.init(store.currentIo(), allocator); 966 + defer transport.deinit(); 967 + const result = try transport.fetch(.{ 968 + .url = url, 969 + .method = .POST, 970 + .payload = body, 971 + .authorization = authorization, 972 + .max_response_size = 64 * 1024, 973 + }); 974 + defer allocator.free(result.body); 975 + if (@intFromEnum(result.status) >= 500) return error.RemoteServerError; 976 + } 977 + 978 + fn fireNotifyMembership(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, member: []const u8, is_member: bool) !void { 979 + const endpoint = try pdsEndpointForDid(allocator, member); 980 + const body = try std.fmt.allocPrint( 981 + allocator, 982 + "{{\"space\":{f},\"did\":{f},\"isMember\":{}}}", 983 + .{ std.json.fmt(space, .{}), std.json.fmt(member, .{}), is_member }, 984 + ); 985 + return postSignedXrpc(allocator, account, endpoint, member, "com.atproto.space.notifyMembership", body); 986 + } 987 + 988 + fn fireNotifyWrite(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, repo: []const u8, rev: []const u8) !void { 989 + const parsed = parseSpaceUri(space) orelse return error.InvalidSpaceUri; 990 + if (!std.mem.eql(u8, repo, parsed.did)) { 991 + const endpoint = try pdsEndpointForDid(allocator, parsed.did); 992 + const body = try std.fmt.allocPrint( 993 + allocator, 994 + "{{\"space\":{f},\"repo\":{f},\"rev\":{f}}}", 995 + .{ std.json.fmt(space, .{}), std.json.fmt(repo, .{}), std.json.fmt(rev, .{}) }, 996 + ); 997 + try postSignedXrpc(allocator, account, endpoint, parsed.did, "com.atproto.space.notifyWrite", body); 998 + } 999 + if (std.mem.eql(u8, account.did, parsed.did)) { 1000 + try fanoutNotifyWriteToRecipients(allocator, account, space, repo, rev); 1001 + } 1002 + } 1003 + 1004 + fn fanoutNotifyWriteToRecipients(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, repo: []const u8, rev: []const u8) !void { 1005 + const recipients = try store.listCredentialRecipients(allocator, space); 1006 + for (recipients) |recipient| { 1007 + const body = try std.fmt.allocPrint( 1008 + allocator, 1009 + "{{\"space\":{f},\"repo\":{f},\"rev\":{f}}}", 1010 + .{ std.json.fmt(space, .{}), std.json.fmt(repo, .{}), std.json.fmt(rev, .{}) }, 1011 + ); 1012 + postSignedXrpc(allocator, account, recipient.service_endpoint, recipient.service_did, "com.atproto.space.notifyWrite", body) catch {}; 1013 + } 1014 + } 1015 + 1016 + fn fireNotifySpaceDeleted(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, members: []const []const u8, recipients: []const store.CredentialRecipient) !void { 1017 + const body = try std.fmt.allocPrint(allocator, "{{\"space\":{f}}}", .{std.json.fmt(space, .{})}); 1018 + for (members) |member| { 1019 + if (std.mem.eql(u8, member, account.did)) continue; 1020 + const endpoint = pdsEndpointForDid(allocator, member) catch continue; 1021 + postSignedXrpc(allocator, account, endpoint, member, "com.atproto.space.notifySpaceDeleted", body) catch {}; 1022 + } 1023 + for (recipients) |recipient| { 1024 + postSignedXrpc(allocator, account, recipient.service_endpoint, recipient.service_did, "com.atproto.space.notifySpaceDeleted", body) catch {}; 1025 + } 1026 + } 1027 + 1028 + fn requireAccount(request: *http_api.Request, allocator: std.mem.Allocator) !http_api.BearerAccount { 1029 + return http_api.requireBearerAccess(request, allocator) catch |err| { 1030 + switch (err) { 1031 + error.AuthRequired => try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"), 1032 + error.InvalidToken => try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"), 1033 + } 1034 + return error.HandledResponse; 1035 + }; 1036 + } 1037 + 1038 + fn requireSpaceScope( 1039 + request: *http_api.Request, 1040 + maybe_scope: ?[]const u8, 1041 + space_type: []const u8, 1042 + did: []const u8, 1043 + skey: []const u8, 1044 + action: scopes.SpaceAction, 1045 + collection: ?[]const u8, 1046 + ) !void { 1047 + if (maybe_scope == null) return; 1048 + if (scopes.spaceAllows(maybe_scope, action, space_type, did, skey, collection)) return; 1049 + return http_api.xrpcError(request, .forbidden, "InsufficientScope", "OAuth token does not grant the requested space operation"); 1050 + } 1051 + 1052 + fn requireSpaceAccess( 1053 + request: *http_api.Request, 1054 + allocator: std.mem.Allocator, 1055 + auth_ctx: http_api.BearerAccount, 1056 + space: []const u8, 1057 + action: scopes.SpaceAction, 1058 + collection: ?[]const u8, 1059 + ) !void { 1060 + const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 1061 + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, action, collection); 1062 + _ = allocator; 1063 + } 1064 + 1065 + const ReadAuth = union(enum) { 1066 + bearer: http_api.BearerAccount, 1067 + space_credential: permissioned.SpaceCredential, 1068 + }; 1069 + 1070 + fn requireReadAccess(request: *http_api.Request, allocator: std.mem.Allocator, space: []const u8, collection: ?[]const u8) !ReadAuth { 1071 + const parsed = parseSpaceUri(space) orelse { 1072 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 1073 + return error.HandledResponse; 1074 + }; 1075 + if (http_api.requireBearerAccess(request, allocator)) |auth_ctx| { 1076 + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .read, collection); 1077 + return .{ .bearer = auth_ctx }; 1078 + } else |_| {} 1079 + 1080 + const token = bearerToken(request) orelse { 1081 + try http_api.xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"); 1082 + return error.HandledResponse; 1083 + }; 1084 + const issuer = permissioned.unverifiedStringClaim(allocator, token, "iss") catch { 1085 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"); 1086 + return error.HandledResponse; 1087 + }; 1088 + if (!std.mem.eql(u8, issuer, parsed.did)) { 1089 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Space credential issuer must be the space owner"); 1090 + return error.HandledResponse; 1091 + } 1092 + const public_key = signingKeyForDid(allocator, issuer) catch { 1093 + try http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve credential issuer did"); 1094 + return error.HandledResponse; 1095 + }; 1096 + const credential = permissioned.verifySpaceCredential(allocator, token, public_key) catch { 1097 + try http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid space credential"); 1098 + return error.HandledResponse; 1099 + }; 1100 + if (!std.mem.eql(u8, credential.space, space)) { 1101 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Credential space mismatch"); 1102 + return error.HandledResponse; 1103 + } 1104 + return .{ .space_credential = credential }; 1105 + } 1106 + 1107 + fn prepareSpaceRecord( 1108 + request: *http_api.Request, 1109 + allocator: std.mem.Allocator, 1110 + collection: []const u8, 1111 + rkey: []const u8, 1112 + value: std.json.Value, 1113 + ) !store.PreparedRecord { 1114 + return store.prepareRecordValue(allocator, collection, rkey, value) catch |err| switch (err) { 1115 + error.InvalidCollection => { 1116 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"); 1117 + return error.HandledResponse; 1118 + }, 1119 + error.InvalidRecordKey => { 1120 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"); 1121 + return error.HandledResponse; 1122 + }, 1123 + error.InvalidRecordType => { 1124 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid record type"); 1125 + return error.HandledResponse; 1126 + }, 1127 + error.InvalidDagCbor => { 1128 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "record is outside the atproto data model"); 1129 + return error.HandledResponse; 1130 + }, 1131 + else => return err, 1132 + }; 1133 + } 1134 + 1135 + fn writeSignedState( 1136 + request: *http_api.Request, 1137 + allocator: std.mem.Allocator, 1138 + space: []const u8, 1139 + signer_did: []const u8, 1140 + user_did: []const u8, 1141 + scope: permissioned.SpaceContext.Scope, 1142 + state: store.SpaceState, 1143 + ) !void { 1144 + if (try signedCommitJson(allocator, space, signer_did, user_did, scope, state)) |commit_json| { 1145 + return http_api.json(request, .ok, try std.fmt.allocPrint(allocator, "{{\"commit\":{s}}}", .{commit_json})); 1146 + } 1147 + return http_api.json(request, .ok, "{}"); 1148 + } 1149 + 1150 + fn signedCommitJson( 1151 + allocator: std.mem.Allocator, 1152 + space: []const u8, 1153 + signer_did: []const u8, 1154 + user_did: []const u8, 1155 + scope: permissioned.SpaceContext.Scope, 1156 + state: store.SpaceState, 1157 + ) !?[]const u8 { 1158 + const set_hash = state.set_hash orelse return null; 1159 + const rev = state.rev orelse return null; 1160 + const parsed = parseSpaceUri(space) orelse return error.InvalidSpaceUri; 1161 + var keypair = try store.signingKeypair(signer_did); 1162 + const commit = try permissioned.createCommit(allocator, store.currentIo(), set_hash, .{ 1163 + .space_did = parsed.did, 1164 + .space_type = parsed.space_type, 1165 + .space_key = parsed.skey, 1166 + .user_did = user_did, 1167 + .rev = rev, 1168 + .scope = scope, 1169 + }, &keypair); 1170 + return try permissioned.signedCommitJson(allocator, commit); 1171 + } 1172 + 1173 + fn parseBody(request: *http_api.Request, allocator: std.mem.Allocator, max_len: usize) !std.json.Parsed(std.json.Value) { 1174 + const body = http_api.readBodyAlloc(request, allocator, max_len) catch |err| switch (err) { 1175 + error.BodyTooLarge => { 1176 + try http_api.xrpcError(request, .payload_too_large, "InvalidRequest", "Request body too large"); 1177 + return error.HandledResponse; 1178 + }, 1179 + else => return err, 1180 + }; 1181 + return http_api.parseJsonBody(request, allocator, body); 1182 + } 1183 + 1184 + fn recordBodyValue(value: std.json.Value) ?std.json.Value { 1185 + return switch (value) { 1186 + .object => |object| object.get("record"), 1187 + else => null, 1188 + }; 1189 + } 1190 + 1191 + fn valueBool(value: std.json.Value, key: []const u8) ?bool { 1192 + return switch (value) { 1193 + .object => |object| switch (object.get(key) orelse return null) { 1194 + .bool => |boolean| boolean, 1195 + else => null, 1196 + }, 1197 + else => null, 1198 + }; 1199 + } 1200 + 1201 + fn appExceptionsJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 1202 + const exceptions = switch (value) { 1203 + .object => |object| object.get("appExceptions") orelse return "[]", 1204 + else => return "[]", 1205 + }; 1206 + if (exceptions != .array) return error.InvalidRecordType; 1207 + return try std.fmt.allocPrint(allocator, "{f}", .{std.json.fmt(exceptions, .{})}); 1208 + } 1209 + 1210 + fn spaceAllowsClient(allocator: std.mem.Allocator, space: store.SpaceConfig, client_id: []const u8) bool { 1211 + const parsed = std.json.parseFromSlice(std.json.Value, allocator, space.app_exceptions_json, .{}) catch return false; 1212 + defer parsed.deinit(); 1213 + const listed = switch (parsed.value) { 1214 + .array => |array| blk: { 1215 + for (array.items) |item| { 1216 + if (item == .string and std.mem.eql(u8, item.string, client_id)) break :blk true; 1217 + } 1218 + break :blk false; 1219 + }, 1220 + else => false, 1221 + }; 1222 + if (std.mem.eql(u8, space.app_access_mode, "allow")) return !listed; 1223 + if (std.mem.eql(u8, space.app_access_mode, "deny")) return listed; 1224 + return false; 1225 + } 1226 + 1227 + fn spaceConfigJson(allocator: std.mem.Allocator, space: store.SpaceConfig) ![]const u8 { 1228 + var out: std.Io.Writer.Allocating = .init(allocator); 1229 + defer out.deinit(); 1230 + try out.writer.print( 1231 + "{{\"uri\":{f},\"did\":{f},\"type\":{f},\"skey\":{f},\"isOwner\":{},\"isMember\":{},\"isPublic\":{},\"appAccessMode\":{f}", 1232 + .{ 1233 + std.json.fmt(space.uri, .{}), 1234 + std.json.fmt(space.owner_did, .{}), 1235 + std.json.fmt(space.space_type, .{}), 1236 + std.json.fmt(space.skey, .{}), 1237 + space.is_owner, 1238 + space.is_member, 1239 + space.is_public, 1240 + std.json.fmt(space.app_access_mode, .{}), 1241 + }, 1242 + ); 1243 + if (space.managing_app) |managing_app| { 1244 + try out.writer.print(",\"managingApp\":{f}", .{std.json.fmt(managing_app, .{})}); 1245 + } 1246 + try out.writer.print(",\"appExceptions\":{s},\"members\":[", .{space.app_exceptions_json}); 1247 + for (space.members, 0..) |member, idx| { 1248 + if (idx != 0) try out.writer.writeByte(','); 1249 + try out.writer.print("{f}", .{std.json.fmt(member, .{})}); 1250 + } 1251 + try out.writer.writeAll("]}"); 1252 + return out.toOwnedSlice(); 1253 + } 1254 + 1255 + fn ownerSpaceJson(allocator: std.mem.Allocator, space: store.SpaceConfig) ![]const u8 { 1256 + var out: std.Io.Writer.Allocating = .init(allocator); 1257 + defer out.deinit(); 1258 + try out.writer.print( 1259 + "{{\"uri\":{f},\"isOwner\":{},\"isPublic\":{},\"appAccessMode\":{f}", 1260 + .{ 1261 + std.json.fmt(space.uri, .{}), 1262 + space.is_owner, 1263 + space.is_public, 1264 + std.json.fmt(space.app_access_mode, .{}), 1265 + }, 1266 + ); 1267 + if (space.managing_app) |managing_app| { 1268 + try out.writer.print(",\"managingApp\":{f}", .{std.json.fmt(managing_app, .{})}); 1269 + } 1270 + try out.writer.print(",\"appExceptions\":{s}}}", .{space.app_exceptions_json}); 1271 + return out.toOwnedSlice(); 1272 + } 1273 + 1274 + const SpaceUriParts = struct { 1275 + did: []const u8, 1276 + space_type: []const u8, 1277 + skey: []const u8, 1278 + }; 1279 + 1280 + fn parseSpaceUri(uri: []const u8) ?SpaceUriParts { 1281 + const prefix = "ats://"; 1282 + if (!std.mem.startsWith(u8, uri, prefix)) return null; 1283 + const rest = uri[prefix.len..]; 1284 + const first = std.mem.indexOfScalar(u8, rest, '/') orelse return null; 1285 + const did = rest[0..first]; 1286 + if (zat.Did.parse(did) == null) return null; 1287 + const after_did = rest[first + 1 ..]; 1288 + const second = std.mem.indexOfScalar(u8, after_did, '/') orelse return null; 1289 + const space_type = after_did[0..second]; 1290 + if (zat.Nsid.parse(space_type) == null) return null; 1291 + const skey = after_did[second + 1 ..]; 1292 + if (zat.Rkey.parse(skey) == null) return null; 1293 + return .{ .did = did, .space_type = space_type, .skey = skey }; 1294 + } 1295 + 1296 + fn xrpcMethod(target: []const u8) ?[]const u8 { 1297 + const prefix = "/xrpc/"; 1298 + if (!std.mem.startsWith(u8, target, prefix)) return null; 1299 + const rest = target[prefix.len..]; 1300 + const end = std.mem.indexOfScalar(u8, rest, '?') orelse rest.len; 1301 + return rest[0..end]; 1302 + } 1303 + 1304 + test "parses permissioned space uris" { 1305 + const parsed = parseSpaceUri("ats://did:plc:abc/fm.plyr.privateMedia/self").?; 1306 + try std.testing.expectEqualStrings("did:plc:abc", parsed.did); 1307 + try std.testing.expectEqualStrings("fm.plyr.privateMedia", parsed.space_type); 1308 + try std.testing.expectEqualStrings("self", parsed.skey); 1309 + }
+1 -1
src/atproto/sync.zig
··· 177 177 const cid = http_api.queryParam(request.url.raw, "cid", &cid_buf) orelse { 178 178 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing cid"); 179 179 }; 180 - const blob = store.getBlob(allocator, did, cid) orelse { 180 + const blob = store.getPublicBlob(allocator, did, cid) orelse { 181 181 return http_api.xrpcError(request, .not_found, "BlobNotFound", "Blob not found"); 182 182 }; 183 183 const disposition = try std.fmt.allocPrint(allocator, "attachment; filename=\"{s}\"", .{cid});
+43 -8
src/http/api.zig
··· 1 1 const std = @import("std"); 2 2 const auth = @import("../auth/tokens.zig"); 3 + const log = @import("../core/log.zig"); 3 4 const store = @import("../storage/store.zig"); 4 5 const httpz = @import("httpz"); 5 6 ··· 17 18 active_response = null; 18 19 } 19 20 21 + pub fn currentStatus() u16 { 22 + return response().status; 23 + } 24 + 20 25 fn response() *Response { 21 26 return active_response orelse @panic("http response not bound"); 22 27 } ··· 24 29 pub const BearerAccount = struct { 25 30 account: auth.Account, 26 31 oauth_scope: ?[]const u8, 32 + oauth_client_id: ?[]const u8 = null, 27 33 }; 28 34 29 35 pub fn requireBearerAccount(request: *const Request, allocator: std.mem.Allocator) !auth.Account { ··· 48 54 return error.AuthRequired; 49 55 50 56 const token = std.mem.trim(u8, auth_header[token_start..], " \t"); 51 - const claims = auth.claimsFromSessionJwt(allocator, token) orelse return error.InvalidToken; 57 + const claims = auth.claimsFromSessionJwt(allocator, token) orelse { 58 + log.err("bearer auth invalid: jwt parse/verify failed token_len={d}\n", .{token.len}); 59 + return error.InvalidToken; 60 + }; 52 61 defer allocator.free(claims.did); 53 62 defer allocator.free(claims.scope); 54 63 defer allocator.free(claims.jti); 55 - if (!scopeAllows(claims.scope, scope)) return error.InvalidToken; 56 - const account = (store.findAccount(allocator, claims.did) catch null) orelse return error.InvalidToken; 64 + if (!scopeAllows(claims.scope, scope)) { 65 + log.err("bearer auth invalid: session scope reject claims_scope={s} required={s} did={s}\n", .{ claims.scope, scope, claims.did }); 66 + return error.InvalidToken; 67 + } 68 + const account = (store.findAccount(allocator, claims.did) catch |err| { 69 + log.err("bearer auth invalid: account lookup failed did={s} err={s}\n", .{ claims.did, @errorName(err) }); 70 + return error.InvalidToken; 71 + }) orelse { 72 + log.err("bearer auth invalid: account not found did={s}\n", .{claims.did}); 73 + return error.InvalidToken; 74 + }; 57 75 58 - const oauth_token = store.getOAuthToken(allocator, token) catch return error.InvalidToken; 76 + const oauth_token = store.getOAuthToken(allocator, token) catch |err| { 77 + log.err("bearer auth invalid: oauth token lookup failed did={s} err={s}\n", .{ claims.did, @errorName(err) }); 78 + return error.InvalidToken; 79 + }; 59 80 if (oauth_token) |row| { 60 - if (row.revoked or row.expires_at < now()) return error.InvalidToken; 61 - if (!std.mem.eql(u8, row.did, claims.did)) return error.InvalidToken; 62 - return .{ .account = account, .oauth_scope = row.scope }; 81 + const ts = now(); 82 + if (row.revoked or row.expires_at < ts) { 83 + log.err("bearer auth invalid: oauth row revoked={} expires_at={d} now={d} did={s}\n", .{ row.revoked, row.expires_at, ts, row.did }); 84 + return error.InvalidToken; 85 + } 86 + if (!std.mem.eql(u8, row.did, claims.did)) { 87 + log.err("bearer auth invalid: oauth row did mismatch row_did={s} claims_did={s}\n", .{ row.did, claims.did }); 88 + return error.InvalidToken; 89 + } 90 + return .{ .account = account, .oauth_scope = row.scope, .oauth_client_id = row.client_id }; 63 91 } 64 92 65 - if (!(store.sessionTokenIsActive(claims.did, claims.jti, claims.scope) catch false)) return error.InvalidToken; 93 + const active = store.sessionTokenIsActive(claims.did, claims.jti, claims.scope) catch |err| { 94 + log.err("bearer auth invalid: session token active lookup failed did={s} jti={s} scope={s} err={s}\n", .{ claims.did, claims.jti, claims.scope, @errorName(err) }); 95 + return error.InvalidToken; 96 + }; 97 + if (!active) { 98 + log.err("bearer auth invalid: session token inactive did={s} jti={s} scope={s}\n", .{ claims.did, claims.jti, claims.scope }); 99 + return error.InvalidToken; 100 + } 66 101 return .{ .account = account, .oauth_scope = null }; 67 102 } 68 103
+1
src/http/landing/mod.zig
··· 183 183 \\ <div class="system-links"> 184 184 \\ <a href="/api"><strong>api reference</strong><span>/api</span></a> 185 185 \\ <a href="/api/openapi.json"><strong>openapi json</strong><span>/api/openapi.json</span></a> 186 + \\ <a href="/stats"><strong>stats</strong><span>/stats</span></a> 186 187 \\ <a href="/xrpc/_health"><strong>health</strong><span>/xrpc/_health</span></a> 187 188 \\ <a href="/xrpc/com.atproto.server.describeServer"><strong>server</strong><span>describeServer</span></a> 188 189 \\ <a href="/.well-known/did.json"><strong>did document</strong><span>/.well-known/did.json</span></a>
+16
src/http/router.zig
··· 7 7 root, 8 8 api_docs, 9 9 api_openapi, 10 + stats_page, 10 11 favicon, 11 12 og_image, 12 13 health, ··· 23 24 oauth_passkey_finish, 24 25 passkeys_page, 25 26 security_page, 27 + admin_sessions_page, 28 + zds_list_account_sessions, 29 + zds_list_admin_sessions, 26 30 atproto_did, 27 31 describe_server, 28 32 reserve_signing_key, ··· 76 80 identity_submit_plc_operation, 77 81 identity_resolve_handle, 78 82 permissioned_data, 83 + proxy_xrpc, 79 84 not_found, 80 85 }; 81 86 ··· 103 108 .{ .route = .api_docs, .method = "HEAD", .path = "/api", .group = "zds", .auth = "public", .summary = "Endpoint inventory probe without a response body." }, 104 109 .{ .route = .api_openapi, .method = "GET", .path = "/api/openapi.json", .group = "zds", .auth = "public", .summary = "OpenAPI 3.1 export generated from the same endpoint inventory." }, 105 110 .{ .route = .api_openapi, .method = "HEAD", .path = "/api/openapi.json", .group = "zds", .auth = "public", .summary = "OpenAPI export probe without a response body." }, 111 + .{ .route = .stats_page, .method = "GET", .path = "/stats", .group = "zds", .auth = "public", .summary = "Operational health and route latency page." }, 112 + .{ .route = .stats_page, .method = "HEAD", .path = "/stats", .group = "zds", .auth = "public", .summary = "Stats page probe without a response body." }, 106 113 .{ .route = .root, .method = "GET", .path = "/", .group = "zds", .auth = "public", .summary = "Public landing page for the PDS." }, 107 114 .{ .route = .root, .method = "HEAD", .path = "/", .group = "zds", .auth = "public", .summary = "Landing page probe." }, 108 115 .{ .route = .favicon, .method = "GET", .path = "/favicon.svg", .group = "zds", .auth = "public", .summary = "SVG favicon." }, ··· 126 133 .{ .route = .oauth_passkey_finish, .method = "POST", .path = "/oauth/passkey/finish", .group = "oauth", .auth = "browser", .summary = "Passkey login completion for OAuth flows." }, 127 134 .{ .route = .security_page, .method = "GET", .path = "/security", .group = "account", .auth = "session", .summary = "Security settings page." }, 128 135 .{ .route = .passkeys_page, .method = "GET", .path = "/passkeys", .group = "account", .auth = "session", .summary = "Compatibility redirect to security settings." }, 136 + .{ .route = .admin_sessions_page, .method = "GET", .path = "/admin/sessions", .group = "zds", .auth = "admin", .summary = "Operator session inventory page." }, 137 + .{ .route = .zds_list_account_sessions, .method = "GET", .path = "/xrpc/dev.zat.account.listSessions", .group = "zds", .auth = "bearer", .summary = "List active account sessions and OAuth grants for the signed-in account.", .params = &.{ "active", "limit" } }, 138 + .{ .route = .zds_list_admin_sessions, .method = "GET", .path = "/xrpc/dev.zat.admin.listSessions", .group = "zds", .auth = "admin", .summary = "List active sessions and OAuth grants across accounts on this PDS.", .params = &.{ "active", "limit" } }, 129 139 130 140 .{ .route = .describe_server, .method = "GET", .path = "/xrpc/com.atproto.server.describeServer", .group = "server", .auth = "public", .summary = "Describe account creation capabilities for this PDS." }, 131 141 .{ .route = .reserve_signing_key, .method = "POST", .path = "/xrpc/com.atproto.server.reserveSigningKey", .group = "server", .auth = "public", .summary = "Reserve a signing key for account creation." }, ··· 251 261 try std.testing.expectEqual(Route.api_docs, route(.GET, "/api?group=repo")); 252 262 try std.testing.expectEqual(Route.api_openapi, route(.GET, "/api/openapi.json")); 253 263 try std.testing.expectEqual(Route.api_openapi, route(.HEAD, "/api/openapi.json")); 264 + try std.testing.expectEqual(Route.stats_page, route(.GET, "/stats")); 265 + try std.testing.expectEqual(Route.stats_page, route(.HEAD, "/stats")); 254 266 try std.testing.expectEqual(Route.favicon, route(.GET, "/favicon.svg")); 255 267 try std.testing.expectEqual(Route.favicon, route(.HEAD, "/favicon.svg")); 256 268 try std.testing.expectEqual(Route.og_image, route(.GET, "/og-image")); ··· 268 280 try std.testing.expectEqual(Route.oauth_passkey_finish, route(.POST, "/oauth/passkey/finish")); 269 281 try std.testing.expectEqual(Route.passkeys_page, route(.GET, "/passkeys")); 270 282 try std.testing.expectEqual(Route.security_page, route(.GET, "/security")); 283 + try std.testing.expectEqual(Route.admin_sessions_page, route(.GET, "/admin/sessions")); 284 + try std.testing.expectEqual(Route.zds_list_account_sessions, route(.GET, "/xrpc/dev.zat.account.listSessions")); 285 + try std.testing.expectEqual(Route.zds_list_admin_sessions, route(.GET, "/xrpc/dev.zat.admin.listSessions")); 271 286 try std.testing.expectEqual(Route.atproto_did, route(.GET, "/.well-known/atproto-did")); 272 287 try std.testing.expectEqual(Route.describe_server, route(.GET, "/xrpc/com.atproto.server.describeServer")); 273 288 try std.testing.expectEqual(Route.reserve_signing_key, route(.POST, "/xrpc/com.atproto.server.reserveSigningKey")); ··· 309 324 test "does not route wrong methods" { 310 325 try std.testing.expectEqual(Route.not_found, route(.POST, "/xrpc/_health")); 311 326 try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/com.atproto.server.createSession")); 327 + try std.testing.expectEqual(Route.not_found, route(.POST, "/xrpc/dev.zat.account.listSessions")); 312 328 try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/com.atproto.space.createSpace")); 313 329 }
+52 -1
src/http/server.zig
··· 15 15 const passkeys = @import("../internal/passkeys.zig"); 16 16 const landing = @import("landing/mod.zig"); 17 17 const router = @import("router.zig"); 18 + const stats = @import("stats.zig"); 19 + const telemetry = @import("../internal/telemetry.zig"); 18 20 19 21 const http = std.http; 20 22 const corsPreflight = http_api.corsPreflight; ··· 42 44 43 45 fn serveRequest(app: *App, request: *httpz.Request) !void { 44 46 const route = router.route(request.method, request.url.raw); 47 + const start_us = telemetry.start(); 48 + var telemetry_route = route; 49 + var telemetry_class: telemetry.Class = if (route == .not_found) .unknown else .local; 50 + var telemetry_label = telemetryLabel(route, request); 51 + var handler_failed = true; 52 + const record_telemetry = route != .stats_page; 53 + defer if (record_telemetry) telemetry.record( 54 + telemetry_route, 55 + request.method, 56 + telemetry_class, 57 + telemetry_label, 58 + start_us, 59 + if (handler_failed) 500 else http_api.currentStatus(), 60 + handler_failed, 61 + ); 45 62 log.debug("http {s} {s} route={s} start\n", .{ methodName(request), request.url.raw, @tagName(route) }); 46 63 47 64 if (atproto_proxy.shouldProxy(request)) { 48 - return atproto_proxy.xrpcProxy(request); 65 + telemetry_route = .proxy_xrpc; 66 + telemetry_class = .proxy; 67 + telemetry_label = stripQuery(request.url.raw); 68 + try atproto_proxy.xrpcProxy(request); 69 + handler_failed = false; 70 + return; 49 71 } 50 72 51 73 switch (route) { ··· 53 75 .root => try landing.serve(request), 54 76 .api_docs => try docs.serve(request), 55 77 .api_openapi => try docs.serveOpenApi(request), 78 + .stats_page => try stats.serve(request), 56 79 .favicon => try landing.serveFavicon(request), 57 80 .og_image => try landing.serveOgImage(request), 58 81 .health => try health(request), ··· 69 92 .oauth_passkey_finish => try passkeys.loginFinish(request), 70 93 .passkeys_page => try passkeys.redirectToSecurity(request), 71 94 .security_page => try passkeys.securityPage(request), 95 + .admin_sessions_page => try passkeys.adminSessionsPage(request), 96 + .zds_list_account_sessions => try atproto_server.listSessions(request), 97 + .zds_list_admin_sessions => try atproto_server.adminListSessions(request), 72 98 .atproto_did => try atproto_server.atprotoDid(request), 73 99 .describe_server => try atproto_server.describeServer(request), 74 100 .reserve_signing_key => try atproto_server.reserveSigningKey(request), ··· 122 148 .identity_submit_plc_operation => try atproto_identity.submitPlcOperation(request), 123 149 .identity_resolve_handle => try atproto_identity.resolveHandle(request), 124 150 .permissioned_data => try atproto_space.dispatch(request), 151 + .proxy_xrpc => unreachable, 125 152 .not_found => try xrpcError(request, .not_found, "UnknownMethod", "Unknown XRPC method"), 126 153 } 154 + handler_failed = false; 127 155 } 128 156 }; 129 157 130 158 pub fn listen(io: std.Io, options: Options) !void { 159 + telemetry.init(io); 131 160 var app: App = .{ .io = io }; 132 161 var server = try httpz.Server(*App).init(io, std.heap.smp_allocator, .{ 133 162 .address = .{ .ip = .{ .ip4 = try std.Io.net.Ip4Address.parse(options.host, options.port) } }, ··· 173 202 .OTHER => request.method_string, 174 203 }; 175 204 } 205 + 206 + fn telemetryLabel(route: router.Route, request: *const httpz.Request) []const u8 { 207 + if (route == .not_found) return stripQuery(request.url.raw); 208 + for (router.endpoints) |endpoint| { 209 + if (endpoint.route == route and methodMatches(endpoint.method, request.method)) return endpoint.path; 210 + } 211 + return @tagName(route); 212 + } 213 + 214 + fn methodMatches(expected: []const u8, actual: httpz.Method) bool { 215 + return switch (actual) { 216 + .GET => std.mem.eql(u8, expected, "GET"), 217 + .HEAD => std.mem.eql(u8, expected, "HEAD"), 218 + .POST => std.mem.eql(u8, expected, "POST"), 219 + else => false, 220 + }; 221 + } 222 + 223 + fn stripQuery(target: []const u8) []const u8 { 224 + const end = std.mem.indexOfScalar(u8, target, '?') orelse target.len; 225 + return target[0..end]; 226 + }
+379
src/http/stats.zig
··· 1 + const std = @import("std"); 2 + const http_api = @import("api.zig"); 3 + const router = @import("router.zig"); 4 + const telemetry = @import("../internal/telemetry.zig"); 5 + 6 + const http = std.http; 7 + 8 + const Row = struct { 9 + stats: telemetry.EndpointStats, 10 + path: []const u8, 11 + group: []const u8, 12 + }; 13 + 14 + const TrafficSummary = struct { 15 + requests: u64 = 0, 16 + failures: u64 = 0, 17 + writes: u64 = 0, 18 + proxy_count: u64 = 0, 19 + proxy_p95_ms: f64 = 0, 20 + }; 21 + 22 + const TROUBLE_US = 1000 * std.time.us_per_ms; 23 + const TROUBLE_LIMIT = 12; 24 + 25 + pub fn serve(request: *http_api.Request) !void { 26 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 27 + defer arena.deinit(); 28 + const body = try render(arena.allocator()); 29 + try http_api.respond(request, .ok, body, &html_headers); 30 + } 31 + 32 + fn render(allocator: std.mem.Allocator) ![]const u8 { 33 + const snap = telemetry.snapshot(); 34 + const rows = try collectRows(allocator, snap); 35 + sortRows(rows); 36 + 37 + const service_rows = try filterServiceRows(allocator, rows); 38 + const summary = summarizeTraffic(service_rows); 39 + const appview_rows = try renderRouteRows(allocator, try filterAppviewRows(allocator, service_rows), "no appview proxy samples yet."); 40 + const pds_rows = try renderRouteRows(allocator, try filterPdsRows(allocator, service_rows), "no local PDS samples yet."); 41 + const write_rows = try renderWriteRows(allocator, try filterWrites(allocator, rows)); 42 + const trouble_rows = try renderTroubleRows(allocator, snap); 43 + const uptime = try formatDuration(allocator, snap.uptime_s); 44 + const failure_rate = if (summary.requests == 0) 45 + "0%" 46 + else 47 + try std.fmt.allocPrint(allocator, "{d:.1}%", .{@as(f64, @floatFromInt(summary.failures)) * 100.0 / @as(f64, @floatFromInt(summary.requests))}); 48 + const proxy_p95 = try formatMaybeMs(allocator, summary.proxy_count, summary.proxy_p95_ms); 49 + 50 + return std.fmt.allocPrint(allocator, 51 + \\<!doctype html> 52 + \\<html lang="en"> 53 + \\<head> 54 + \\ <meta charset="utf-8"> 55 + \\ <meta name="viewport" content="width=device-width, initial-scale=1"> 56 + \\ <title>zds / health</title> 57 + \\ <style> 58 + \\ :root{{--bg:#070807;--panel:#101210;--panel-2:#151915;--line:#273026;--text:#f0f1ec;--muted:#aaa69b;--dim:#69645c;--accent:#91adff;--green:#39c178;--pink:#f05f8e;color-scheme:dark}} 59 + \\ @media (prefers-color-scheme:light){{:root{{--bg:#f6f4ed;--panel:#fffdf7;--panel-2:#ece8dd;--line:#d8d0c0;--text:#161412;--muted:#625c53;--dim:#8f8679;--accent:#315dcb;--green:#1b7340;--pink:#b72e5c;color-scheme:light}}}} 60 + \\ *{{box-sizing:border-box}}html{{background:var(--bg)}}body{{margin:0;min-height:100vh;background:linear-gradient(135deg,color-mix(in srgb,var(--green) 13%,var(--bg)),var(--bg) 58%);color:var(--text);font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}} 61 + \\ a{{color:inherit}}.shell{{width:min(100%,780px);margin:0 auto;padding:22px 16px 40px}}header{{display:flex;justify-content:space-between;align-items:center;gap:14px;margin-bottom:26px;color:var(--muted);font-size:12px}}.brand{{color:var(--text);font-weight:700;text-decoration:none}}nav{{display:flex;gap:10px;flex-wrap:wrap}}nav a{{color:var(--muted);text-decoration:none}}nav a:hover{{color:var(--text)}}h1{{font-size:clamp(34px,8vw,68px);line-height:.95;margin:0 0 10px;letter-spacing:0}}.lede{{margin:0 0 22px;color:var(--muted);max-width:64ch}} 62 + \\ .metrics{{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:0 0 18px}}.metric{{border:1px solid var(--line);background:color-mix(in srgb,var(--panel) 88%,transparent);border-radius:8px;padding:12px}}.label{{color:var(--dim);font-size:11px;text-transform:lowercase}}.value{{font-size:22px;color:var(--text);font-variant-numeric:tabular-nums}} 63 + \\ section{{margin-top:18px}}.section-head{{display:flex;justify-content:space-between;align-items:end;gap:12px;margin-bottom:8px}}h2{{font-size:13px;margin:0;color:var(--text);font-weight:700}}.hint{{color:var(--dim);font-size:11px;margin:0}}.table-wrap{{overflow:auto;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--panel) 86%,transparent)}}table{{width:100%;border-collapse:collapse;min-width:720px}}th,td{{padding:9px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 74%,transparent);text-align:left;vertical-align:top}}th{{position:sticky;top:0;background:var(--panel-2);color:var(--dim);font-size:11px;font-weight:400;text-transform:lowercase}}td{{font-variant-numeric:tabular-nums}}tr:last-child td{{border-bottom:0}}.method{{display:inline-block;border-radius:7px;padding:2px 5px;color:#071008;font-size:11px;font-weight:700}}.get{{background:#7dd3fc}}.head{{background:#f5d85d}}.post{{background:var(--green)}}.options,.other{{background:var(--muted)}}.path{{overflow-wrap:anywhere;color:var(--text)}}.group{{color:var(--dim)}}.empty{{padding:16px;color:var(--dim)}}.bad{{color:var(--pink)}}.good{{color:var(--green)}}@media (max-width:620px){{.shell{{padding-inline:12px}}header{{align-items:flex-start;flex-direction:column}}.value{{font-size:19px}}table{{min-width:0}}th,td{{padding:8px 7px}}th:nth-child(3),td:nth-child(3),th:nth-child(7),td:nth-child(7),th:nth-child(8),td:nth-child(8),th:nth-child(9),td:nth-child(9){{display:none}}}} 64 + \\ </style> 65 + \\</head> 66 + \\<body> 67 + \\ <main class="shell"> 68 + \\ <header><a class="brand" href="/">zds</a><nav><a href="/">home</a><a href="/api">api</a><a href="/xrpc/_health">health</a></nav></header> 69 + \\ <h1>pds health</h1> 70 + \\ <p class="lede">Live health for this PDS. Counts reset when the service restarts; this page does not count its own refreshes.</p> 71 + \\ <div class="metrics"> 72 + \\ <div class="metric"><div class="label">status</div><div class="value good">ok</div></div> 73 + \\ <div class="metric"><div class="label">uptime</div><div class="value">{s}</div></div> 74 + \\ <div class="metric"><div class="label">api requests</div><div class="value">{d}</div></div> 75 + \\ <div class="metric"><div class="label">writes</div><div class="value">{d}</div></div> 76 + \\ <div class="metric"><div class="label">appview p95</div><div class="value">{s}</div></div> 77 + \\ <div class="metric"><div class="label">failures</div><div class="value {s}">{d}</div></div> 78 + \\ <div class="metric"><div class="label">failure rate</div><div class="value">{s}</div></div> 79 + \\ </div> 80 + \\ <section> 81 + \\ <div class="section-head"><h2>needs attention</h2><p class="hint">newest first; only 1s+ requests or server failures</p></div> 82 + \\ <div class="table-wrap">{s}</div> 83 + \\ </section> 84 + \\ <section> 85 + \\ <div class="section-head"><h2>writes</h2><p class="hint">likes usually createRecord; batches use applyWrites</p></div> 86 + \\ <div class="table-wrap">{s}</div> 87 + \\ </section> 88 + \\ <section> 89 + \\ <div class="section-head"><h2>appview proxy</h2><p class="hint">client reads forwarded to Bluesky/chat appviews</p></div> 90 + \\ <div class="table-wrap">{s}</div> 91 + \\ </section> 92 + \\ <section> 93 + \\ <div class="section-head"><h2>pds routes</h2><p class="hint">local repository, sync, auth, and preference routes</p></div> 94 + \\ <div class="table-wrap">{s}</div> 95 + \\ </section> 96 + \\ </main> 97 + \\</body> 98 + \\</html> 99 + , .{ 100 + uptime, 101 + summary.requests, 102 + summary.writes, 103 + proxy_p95, 104 + if (summary.failures == 0) "good" else "bad", 105 + summary.failures, 106 + failure_rate, 107 + trouble_rows, 108 + write_rows, 109 + appview_rows, 110 + pds_rows, 111 + }); 112 + } 113 + 114 + fn collectRows(allocator: std.mem.Allocator, snap: telemetry.Snapshot) ![]Row { 115 + var rows: std.ArrayList(Row) = .empty; 116 + for (0..@typeInfo(router.Route).@"enum".fields.len) |route_idx| { 117 + for (0..@typeInfo(telemetry.Method).@"enum".fields.len) |method_idx| { 118 + const stats = snap.routes[route_idx][method_idx]; 119 + if (stats.count == 0) continue; 120 + const meta = endpointMeta(stats.route, stats.method); 121 + try rows.append(allocator, .{ .stats = stats, .path = meta.path, .group = meta.group }); 122 + } 123 + } 124 + return rows.toOwnedSlice(allocator); 125 + } 126 + 127 + fn renderRows(allocator: std.mem.Allocator, rows: []const Row) ![]const u8 { 128 + if (rows.len == 0) return allocator.dupe(u8, "<div class=\"empty\">no samples yet.</div>"); 129 + var out: std.Io.Writer.Allocating = .init(allocator); 130 + defer out.deinit(); 131 + try out.writer.writeAll("<table><thead><tr><th>method</th><th>route</th><th>group</th><th>count</th><th>p50</th><th>p95</th><th>avg</th><th>max</th><th>fail</th></tr></thead><tbody>"); 132 + for (rows) |row| { 133 + try out.writer.print( 134 + "<tr><td><span class=\"method {s}\">{s}</span></td><td class=\"path\">{s}</td><td class=\"group\">{s}</td><td>{d}</td><td>{d:.1}ms</td><td>{d:.1}ms</td><td>{d:.1}ms</td><td>{d:.1}ms</td><td>{d}</td></tr>", 135 + .{ 136 + @tagName(row.stats.method), 137 + row.stats.method.label(), 138 + row.path, 139 + row.group, 140 + row.stats.count, 141 + row.stats.p50_ms, 142 + row.stats.p95_ms, 143 + row.stats.avg_ms, 144 + row.stats.max_ms, 145 + row.stats.failure_count, 146 + }, 147 + ); 148 + } 149 + try out.writer.writeAll("</tbody></table>"); 150 + return out.toOwnedSlice(); 151 + } 152 + 153 + fn renderRouteRows(allocator: std.mem.Allocator, rows: []const Row, empty: []const u8) ![]const u8 { 154 + if (rows.len == 0) return std.fmt.allocPrint(allocator, "<div class=\"empty\">{s}</div>", .{empty}); 155 + var out: std.Io.Writer.Allocating = .init(allocator); 156 + defer out.deinit(); 157 + try out.writer.writeAll("<table><thead><tr><th>method</th><th>route</th><th>count</th><th>p50</th><th>p95</th><th>max</th><th>fail</th></tr></thead><tbody>"); 158 + for (rows) |row| { 159 + try out.writer.print( 160 + "<tr><td><span class=\"method {s}\">{s}</span></td><td class=\"path\">{s}</td><td>{d}</td><td>{d:.1}ms</td><td>{d:.1}ms</td><td>{d:.1}ms</td><td>{d}</td></tr>", 161 + .{ 162 + @tagName(row.stats.method), 163 + row.stats.method.label(), 164 + row.path, 165 + row.stats.count, 166 + row.stats.p50_ms, 167 + row.stats.p95_ms, 168 + row.stats.max_ms, 169 + row.stats.failure_count, 170 + }, 171 + ); 172 + } 173 + try out.writer.writeAll("</tbody></table>"); 174 + return out.toOwnedSlice(); 175 + } 176 + 177 + fn renderWriteRows(allocator: std.mem.Allocator, rows: []const Row) ![]const u8 { 178 + if (rows.len == 0) return allocator.dupe(u8, "<div class=\"empty\">no write samples yet.</div>"); 179 + var out: std.Io.Writer.Allocating = .init(allocator); 180 + defer out.deinit(); 181 + try out.writer.writeAll("<table><thead><tr><th>kind</th><th>endpoint</th><th>count</th><th>p50</th><th>p95</th><th>max</th><th>fail</th></tr></thead><tbody>"); 182 + for (rows) |row| { 183 + try out.writer.print( 184 + "<tr><td class=\"path\">{s}</td><td class=\"path\">{s}</td><td>{d}</td><td>{d:.1}ms</td><td>{d:.1}ms</td><td>{d:.1}ms</td><td>{d}</td></tr>", 185 + .{ 186 + writeKind(row.stats.route), 187 + row.path, 188 + row.stats.count, 189 + row.stats.p50_ms, 190 + row.stats.p95_ms, 191 + row.stats.max_ms, 192 + row.stats.failure_count, 193 + }, 194 + ); 195 + } 196 + try out.writer.writeAll("</tbody></table>"); 197 + return out.toOwnedSlice(); 198 + } 199 + 200 + fn renderTroubleRows(allocator: std.mem.Allocator, snap: telemetry.Snapshot) ![]const u8 { 201 + var out: std.Io.Writer.Allocating = .init(allocator); 202 + defer out.deinit(); 203 + try out.writer.writeAll("<table><thead><tr><th>when</th><th>method</th><th>class</th><th>route</th><th>elapsed</th><th>status</th></tr></thead><tbody>"); 204 + var i = snap.slow_len; 205 + var rendered: usize = 0; 206 + while (i > 0) { 207 + i -= 1; 208 + const item = snap.slow_requests[i]; 209 + if (!needsAttention(item)) continue; 210 + try out.writer.print( 211 + "<tr><td>{d}s ago</td><td><span class=\"method {s}\">{s}</span></td><td class=\"group\">{s}</td><td class=\"path\">{s}</td><td>{d:.1}ms</td><td class=\"{s}\">{d}</td></tr>", 212 + .{ 213 + if (item.at_s > 0) @max(0, snap.started_at_s + @as(i64, @intCast(snap.uptime_s)) - item.at_s) else 0, 214 + @tagName(item.method), 215 + item.method.label(), 216 + item.class.label(), 217 + item.labelText(), 218 + @as(f64, @floatFromInt(item.elapsed_us)) / 1000.0, 219 + statusClass(item), 220 + item.status, 221 + }, 222 + ); 223 + rendered += 1; 224 + if (rendered == TROUBLE_LIMIT) break; 225 + } 226 + if (rendered == 0) return allocator.dupe(u8, "<div class=\"empty\">nothing needs attention right now.</div>"); 227 + try out.writer.writeAll("</tbody></table>"); 228 + return out.toOwnedSlice(); 229 + } 230 + 231 + fn needsAttention(item: telemetry.SlowRequest) bool { 232 + return item.failed() or item.elapsed_us >= TROUBLE_US; 233 + } 234 + 235 + fn statusClass(item: telemetry.SlowRequest) []const u8 { 236 + if (item.failed()) return "bad"; 237 + if (item.status >= 400) return "group"; 238 + return "good"; 239 + } 240 + 241 + fn filterWrites(allocator: std.mem.Allocator, rows: []const Row) ![]Row { 242 + var filtered: std.ArrayList(Row) = .empty; 243 + for (rows) |row| { 244 + if (isWriteRoute(row.stats.route, row.stats.method)) try filtered.append(allocator, row); 245 + } 246 + return filtered.toOwnedSlice(allocator); 247 + } 248 + 249 + fn filterServiceRows(allocator: std.mem.Allocator, rows: []const Row) ![]Row { 250 + var filtered: std.ArrayList(Row) = .empty; 251 + for (rows) |row| { 252 + if (!isNoiseRoute(row.stats.route)) try filtered.append(allocator, row); 253 + } 254 + return filtered.toOwnedSlice(allocator); 255 + } 256 + 257 + fn filterAppviewRows(allocator: std.mem.Allocator, rows: []const Row) ![]Row { 258 + var filtered: std.ArrayList(Row) = .empty; 259 + for (rows) |row| { 260 + if (row.stats.route == .proxy_xrpc) try filtered.append(allocator, row); 261 + } 262 + return filtered.toOwnedSlice(allocator); 263 + } 264 + 265 + fn filterPdsRows(allocator: std.mem.Allocator, rows: []const Row) ![]Row { 266 + var filtered: std.ArrayList(Row) = .empty; 267 + for (rows) |row| { 268 + if (row.stats.route != .proxy_xrpc and !isWriteRoute(row.stats.route, row.stats.method)) { 269 + try filtered.append(allocator, row); 270 + } 271 + } 272 + return filtered.toOwnedSlice(allocator); 273 + } 274 + 275 + fn summarizeTraffic(rows: []const Row) TrafficSummary { 276 + var summary: TrafficSummary = .{}; 277 + for (rows) |row| { 278 + summary.requests += row.stats.count; 279 + summary.failures += row.stats.failure_count; 280 + if (isWriteRoute(row.stats.route, row.stats.method)) summary.writes += row.stats.count; 281 + if (row.stats.route == .proxy_xrpc and row.stats.method == .get) { 282 + summary.proxy_count += row.stats.count; 283 + summary.proxy_p95_ms = @max(summary.proxy_p95_ms, row.stats.p95_ms); 284 + } 285 + } 286 + return summary; 287 + } 288 + 289 + fn sortRows(rows: []Row) void { 290 + std.mem.sort(Row, rows, {}, struct { 291 + fn lessThan(_: void, a: Row, b: Row) bool { 292 + if (a.stats.p95_ms != b.stats.p95_ms) return a.stats.p95_ms > b.stats.p95_ms; 293 + if (a.stats.max_ms != b.stats.max_ms) return a.stats.max_ms > b.stats.max_ms; 294 + return a.stats.count > b.stats.count; 295 + } 296 + }.lessThan); 297 + } 298 + 299 + fn isNoiseRoute(route: router.Route) bool { 300 + return switch (route) { 301 + .stats_page, 302 + .health, 303 + .root, 304 + .api_docs, 305 + .api_openapi, 306 + .favicon, 307 + .og_image, 308 + .cors_preflight, 309 + .not_found, 310 + => true, 311 + else => false, 312 + }; 313 + } 314 + 315 + fn isWriteRoute(route: router.Route, method: telemetry.Method) bool { 316 + if (method != .post) return false; 317 + return switch (route) { 318 + .app_preferences_put, 319 + .repo_create_record, 320 + .repo_put_record, 321 + .repo_delete_record, 322 + .repo_apply_writes, 323 + .repo_import_repo, 324 + .repo_upload_blob, 325 + .permissioned_data, 326 + => true, 327 + else => false, 328 + }; 329 + } 330 + 331 + fn writeKind(route: router.Route) []const u8 { 332 + return switch (route) { 333 + .repo_create_record => "single record create", 334 + .repo_put_record => "replace by rkey", 335 + .repo_delete_record => "record delete", 336 + .repo_apply_writes => "batched repo writes", 337 + .repo_import_repo => "repo import", 338 + .repo_upload_blob => "blob upload", 339 + .app_preferences_put => "preferences update", 340 + .permissioned_data => "permissioned-data write", 341 + else => "write", 342 + }; 343 + } 344 + 345 + const EndpointMeta = struct { path: []const u8, group: []const u8 }; 346 + 347 + fn endpointMeta(route: router.Route, method: telemetry.Method) EndpointMeta { 348 + if (route == .proxy_xrpc) return .{ .path = "proxied XRPC", .group = "proxy" }; 349 + if (route == .not_found) return .{ .path = "unmatched request", .group = "unknown" }; 350 + for (router.endpoints) |endpoint| { 351 + if (endpoint.route == route and std.mem.eql(u8, endpoint.method, method.label())) return .{ .path = endpoint.path, .group = endpoint.group }; 352 + } 353 + for (router.endpoints) |endpoint| { 354 + if (endpoint.route == route) return .{ .path = endpoint.path, .group = endpoint.group }; 355 + } 356 + return .{ .path = @tagName(route), .group = "internal" }; 357 + } 358 + 359 + fn formatDuration(allocator: std.mem.Allocator, seconds: u64) ![]const u8 { 360 + const days = seconds / 86_400; 361 + const hours = (seconds % 86_400) / 3_600; 362 + const minutes = (seconds % 3_600) / 60; 363 + if (days > 0) return std.fmt.allocPrint(allocator, "{d}d {d}h", .{ days, hours }); 364 + if (hours > 0) return std.fmt.allocPrint(allocator, "{d}h {d}m", .{ hours, minutes }); 365 + return std.fmt.allocPrint(allocator, "{d}m", .{minutes}); 366 + } 367 + 368 + fn formatMaybeMs(allocator: std.mem.Allocator, count: u64, ms: f64) ![]const u8 { 369 + if (count == 0) return allocator.dupe(u8, "none"); 370 + return std.fmt.allocPrint(allocator, "{d:.1}ms", .{ms}); 371 + } 372 + 373 + const html_headers = [_]http.Header{ 374 + .{ .name = "content-type", .value = "text/html; charset=utf-8" }, 375 + .{ .name = "cache-control", .value = "no-store" }, 376 + .{ .name = "access-control-allow-origin", .value = "*" }, 377 + .{ .name = "access-control-allow-private-network", .value = "true" }, 378 + .{ .name = "connection", .value = "close" }, 379 + };
+3 -3
src/internal/api_reference/html.zig
··· 138 138 ); 139 139 try writer.print( 140 140 \\<details class="endpoint" data-group={f} data-method={f} data-search={f}{s}> 141 - \\ <summary><span class="method {s}">{s}</span><code>{s}</code><span class="labels">{s}<span class="auth">{s}</span></span></summary> 141 + \\ <summary><span class="method {s}">{s}</span><span class="summary-main"><code>{s}</code><span class="labels">{s}<span class="auth">{s}</span></span></span></summary> 142 142 \\ <div class="body"> 143 143 \\ <p>{s}</p> 144 144 , .{ ··· 208 208 \\*{box-sizing:border-box}html{min-height:100%;background:var(--accent-bg);overflow-x:hidden}body{position:relative;margin:0;min-height:100vh;overflow-x:hidden;background:radial-gradient(circle at 20% 0%,color-mix(in srgb,var(--green) 15%,transparent),transparent 35%),radial-gradient(circle at 94% 18%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 32%),linear-gradient(135deg,var(--accent-bg),var(--bg) 58%);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}body::before,body::after{content:"";position:fixed;inset:-18vmax;z-index:0;pointer-events:none}body::before{background:linear-gradient(108deg,transparent 0 22%,rgba(255,255,255,.10) 24%,transparent 27% 52%,rgba(255,255,255,.055) 54%,transparent 58%),linear-gradient(72deg,transparent 0 38%,color-mix(in srgb,var(--accent) 11%,transparent) 44%,transparent 52%);filter:blur(20px) saturate(1.15);opacity:.58;transform:rotate(-4deg)}body::after{background:radial-gradient(ellipse at 22% 32%,rgba(255,255,255,.09),transparent 28%),radial-gradient(ellipse at 72% 18%,color-mix(in srgb,var(--green) 14%,transparent),transparent 31%),radial-gradient(ellipse at 66% 78%,color-mix(in srgb,var(--pink) 12%,transparent),transparent 30%);filter:blur(34px);opacity:.44}a{color:inherit}.shell{position:relative;z-index:1;width:min(100%,780px);margin:0 auto;padding:18px 16px 46px}header{position:relative;z-index:10;display:flex;justify-content:space-between;align-items:center;gap:12px;min-height:50px;margin:0 0 26px;padding:8px 0;color:var(--muted);font-size:12px}.brand{flex:0 0 auto;display:inline-flex;align-items:center;color:var(--text);text-decoration:none;font-weight:700;font-size:14px;letter-spacing:0}.brand:hover{opacity:.82}nav{min-width:0;display:flex;align-items:center;justify-content:flex-end;gap:7px;padding:4px;border:1px solid color-mix(in srgb,var(--line) 58%,white 14%);border-radius:18px;background:linear-gradient(110deg,rgba(255,255,255,.16),transparent 22% 76%,rgba(255,255,255,.08)),radial-gradient(circle at 20% 0%,color-mix(in srgb,var(--green) 12%,transparent),transparent 46%),color-mix(in srgb,var(--accent-panel) 72%,transparent);box-shadow:inset 0 1px 0 rgba(255,255,255,.22),inset 0 -18px 42px rgba(255,255,255,.035),0 8px 28px rgba(0,0,0,.12);backdrop-filter:blur(26px) saturate(1.26);-webkit-backdrop-filter:blur(26px) saturate(1.26)}nav a{color:var(--muted);text-decoration:none}.icon-link,.nav-link{position:relative;min-width:34px;height:34px;display:inline-grid;place-items:center;border:1px solid color-mix(in srgb,var(--line) 74%,white 10%);border-radius:12px;background:radial-gradient(circle at 30% 18%,rgba(255,255,255,.20),transparent 32%),linear-gradient(145deg,rgba(255,255,255,.12),rgba(255,255,255,.03)),color-mix(in srgb,var(--accent-panel) 74%,transparent);color:var(--muted);text-decoration:none;box-shadow:inset 0 1px 0 rgba(255,255,255,.18),0 10px 28px rgba(0,0,0,.14)}.nav-link{padding:0 11px}.logo-link::after{content:"";position:absolute;inset:5px;border-radius:9px;background:color-mix(in srgb,var(--glass) 62%,var(--accent-panel));box-shadow:inset 0 1px 0 rgba(255,255,255,.18)}.icon-link img{position:relative;z-index:1;width:22px;height:22px;object-fit:contain;display:block;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))}.icon-link:hover,.nav-link:hover{color:var(--text);border-color:color-mix(in srgb,var(--green) 55%,var(--line));background:color-mix(in srgb,var(--accent-panel-2) 86%,transparent)} 209 209 \\.intro{display:grid;gap:13px;margin:52px 0 26px}.eyebrow{margin:0;color:var(--green);font-size:12px;font-weight:700;text-transform:lowercase}.eyebrow::before{content:"/";color:var(--dim);margin-right:2px}h1{margin:0;color:var(--text);font-size:clamp(42px,10vw,82px);line-height:.95;letter-spacing:0;text-transform:lowercase}.lede{max-width:none;margin:0;color:var(--muted);font-size:15px}.lede code{color:#7dd3fc}.xrpc-expression,.route-expression{display:inline-flex;white-space:nowrap}.xrpc-expression{gap:.35ch}.stats{display:flex;gap:9px;flex-wrap:wrap;margin-top:5px}.stats span{border:1px solid color-mix(in srgb,var(--line) 70%,white 8%);border-radius:12px;padding:9px 11px;color:var(--muted);background:color-mix(in srgb,var(--accent-panel) 64%,transparent);box-shadow:inset 0 1px 0 rgba(255,255,255,.10)}.stats strong{color:var(--text)} 210 210 \\.tools{position:relative;z-index:5;padding:12px 0 15px;margin-bottom:12px;border-bottom:1px solid color-mix(in srgb,var(--line) 58%,transparent)}.search{display:grid;gap:7px;margin-bottom:11px}.search span{font-size:11px;text-transform:lowercase;color:var(--dim)}.search input{width:100%;border:1px solid color-mix(in srgb,var(--line) 76%,white 6%);border-radius:12px;background:color-mix(in srgb,var(--panel) 86%,transparent);color:var(--text);padding:12px 13px;font:inherit;outline:none;box-shadow:inset 0 1px 0 rgba(255,255,255,.08)}.search input:focus{border-color:var(--focus)}.chips{display:flex;flex-wrap:wrap;gap:7px}.chip{border:1px solid color-mix(in srgb,var(--line) 78%,white 6%);border-radius:999px;background:color-mix(in srgb,var(--accent-panel) 50%,transparent);color:var(--muted);padding:7px 10px;font:inherit;font-size:12px;cursor:pointer;text-transform:lowercase}.chip.active{background:var(--text);border-color:var(--text);color:var(--bg)}.chip span{opacity:.68} 211 - \\.endpoints{display:grid;gap:9px}.endpoint{border:1px solid color-mix(in srgb,var(--line) 76%,white 6%);border-radius:12px;background:color-mix(in srgb,var(--panel) 88%,transparent);box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 14px 38px rgba(0,0,0,.16);overflow:hidden}.endpoint[hidden]{display:none}.endpoint summary{list-style:none;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:11px;align-items:center;padding:13px 14px;cursor:pointer}.endpoint summary::-webkit-details-marker{display:none}.endpoint code{font-family:inherit;overflow-wrap:anywhere}.method{font-size:11px;font-weight:700;border-radius:8px;padding:4px 6px;color:#071008}.method.get{background:#7dd3fc}.method.post{background:var(--green)}.method.head{background:#f5d85d}.labels{display:flex;gap:6px;align-items:center;justify-content:flex-end;flex-wrap:wrap}.badge,.auth{font-size:11px;border:1px solid color-mix(in srgb,var(--line) 76%,white 5%);border-radius:999px;padding:4px 7px;white-space:nowrap;text-transform:lowercase}.auth{color:var(--muted)}.badge.experimental{color:#071008;background:color-mix(in srgb,var(--pink) 82%,white 16%);border-color:color-mix(in srgb,var(--pink) 84%,white 6%)}.body{border-top:1px solid color-mix(in srgb,var(--line) 78%,transparent);padding:14px;display:grid;gap:14px}.body p{margin:0;color:var(--muted);line-height:1.55}.experimental-copy{border:1px solid color-mix(in srgb,var(--pink) 42%,var(--line));border-radius:10px;padding:10px;background:color-mix(in srgb,var(--pink) 8%,transparent)}.experimental-copy code{color:var(--text)}.nsid{display:flex;gap:8px;align-items:center;color:var(--muted);min-width:0}.nsid span{font-size:10px;text-transform:lowercase;color:var(--pink)}.nsid code{color:var(--text);min-width:0}.meta-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px}.meta-grid>div{border:1px solid color-mix(in srgb,var(--line) 78%,white 4%);border-radius:10px;padding:10px;background:rgba(255,255,255,.018)}h2{font-size:11px;text-transform:lowercase;color:var(--dim);font-weight:400;margin:0 0 7px}ul{margin:0;padding-left:18px}.muted{color:var(--dim)}.compact{font-size:12px}.note{border-left:2px solid var(--pink);padding-left:10px}.snippet{position:relative}.copy{position:absolute;right:8px;top:8px;border:1px solid color-mix(in srgb,var(--line) 78%,white 6%);border-radius:10px;background:color-mix(in srgb,var(--panel) 88%,transparent);color:var(--muted);font:inherit;font-size:11px;padding:5px 8px;cursor:pointer;text-transform:lowercase}.copy:hover,.copy:focus{color:var(--text);border-color:color-mix(in srgb,var(--green) 55%,var(--line));outline:none}.copy.done{background:var(--green);border-color:var(--green);color:#071008}pre{margin:0;white-space:pre-wrap;background:#090b09;color:#d6ead8;border-radius:10px;border:1px solid color-mix(in srgb,var(--line) 78%,white 4%);padding:13px 78px 13px 11px;overflow:auto;font:inherit;font-size:13px} 211 + \\.endpoints{display:grid;gap:9px}.endpoint{border:1px solid color-mix(in srgb,var(--line) 76%,white 6%);border-radius:12px;background:color-mix(in srgb,var(--panel) 88%,transparent);box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 14px 38px rgba(0,0,0,.16);overflow:hidden}.endpoint[hidden]{display:none}.endpoint summary{list-style:none;display:grid;grid-template-columns:auto minmax(0,1fr);gap:11px;align-items:start;padding:13px 14px;cursor:pointer}.endpoint summary::-webkit-details-marker{display:none}.endpoint code{font-family:inherit;overflow-wrap:anywhere}.summary-main{min-width:0;display:grid;gap:7px}.summary-main>code{min-width:0;line-height:1.35;word-break:normal}.method{font-size:11px;font-weight:700;border-radius:8px;padding:4px 6px;color:#071008}.method.get{background:#7dd3fc}.method.post{background:var(--green)}.method.head{background:#f5d85d}.labels{display:flex;gap:6px;align-items:center;justify-content:flex-start;flex-wrap:wrap}.badge,.auth{font-size:11px;border:1px solid color-mix(in srgb,var(--line) 76%,white 5%);border-radius:999px;padding:4px 7px;white-space:nowrap;text-transform:lowercase}.auth{color:var(--muted)}.badge.experimental{color:#071008;background:color-mix(in srgb,var(--pink) 82%,white 16%);border-color:color-mix(in srgb,var(--pink) 84%,white 6%)}.body{border-top:1px solid color-mix(in srgb,var(--line) 78%,transparent);padding:14px;display:grid;gap:14px}.body p{margin:0;color:var(--muted);line-height:1.55}.experimental-copy{border:1px solid color-mix(in srgb,var(--pink) 42%,var(--line));border-radius:10px;padding:10px;background:color-mix(in srgb,var(--pink) 8%,transparent)}.experimental-copy code{color:var(--text)}.nsid{display:flex;gap:8px;align-items:center;color:var(--muted);min-width:0}.nsid span{font-size:10px;text-transform:lowercase;color:var(--pink)}.nsid code{color:var(--text);min-width:0}.meta-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px}.meta-grid>div{border:1px solid color-mix(in srgb,var(--line) 78%,white 4%);border-radius:10px;padding:10px;background:rgba(255,255,255,.018)}h2{font-size:11px;text-transform:lowercase;color:var(--dim);font-weight:400;margin:0 0 7px}ul{margin:0;padding-left:18px}.muted{color:var(--dim)}.compact{font-size:12px}.note{border-left:2px solid var(--pink);padding-left:10px}.snippet{position:relative}.copy{position:absolute;right:8px;top:8px;border:1px solid color-mix(in srgb,var(--line) 78%,white 6%);border-radius:10px;background:color-mix(in srgb,var(--panel) 88%,transparent);color:var(--muted);font:inherit;font-size:11px;padding:5px 8px;cursor:pointer;text-transform:lowercase}.copy:hover,.copy:focus{color:var(--text);border-color:color-mix(in srgb,var(--green) 55%,var(--line));outline:none}.copy.done{background:var(--green);border-color:var(--green);color:#071008}pre{margin:0;white-space:pre-wrap;background:#090b09;color:#d6ead8;border-radius:10px;border:1px solid color-mix(in srgb,var(--line) 78%,white 4%);padding:13px 78px 13px 11px;overflow:auto;font:inherit;font-size:13px} 212 212 \\@media (min-width:680px){.shell{padding-top:24px}nav{gap:8px}} 213 - \\@media (max-width:720px){.intro{margin-top:34px}.endpoint summary{grid-template-columns:auto minmax(0,1fr)}.endpoint summary .auth{grid-column:2}.meta-grid{grid-template-columns:1fr}nav{justify-content:flex-end}.lede{font-size:14px}} 213 + \\@media (max-width:720px){.intro{margin-top:34px}.meta-grid{grid-template-columns:1fr}nav{justify-content:flex-end}.lede{font-size:14px}} 214 214 \\@media (max-width:430px){.shell{padding-left:14px;padding-right:14px}header{gap:8px}nav{gap:4px;padding:3px;border-radius:16px}.icon-link,.nav-link{min-width:32px;height:32px;border-radius:10px}.logo-link::after{inset:5px;border-radius:8px}.icon-link img{width:20px;height:20px}.nav-link{padding:0 9px}} 215 215 \\@media (prefers-color-scheme:light){:root{--bg:#f6f4ed;--panel:#fffdf7;--panel-2:#ece8dd;--line:#d8d0c0;--text:#161412;--muted:#625c53;--dim:#8f8679;--accent:#315dcb;--green:#1b7340;--pink:#b72e5c;--focus:#163f9f;--glass:rgba(255,255,255,.72);--accent-bg:color-mix(in srgb,var(--green) 11%,var(--bg));--accent-panel:color-mix(in srgb,var(--green) 5%,var(--panel));--accent-panel-2:color-mix(in srgb,var(--accent) 5%,var(--panel-2));color-scheme:light}pre{background:#101510;color:#ecf7ec}} 216 216 ;
+2 -2
src/internal/api_reference/openapi.zig
··· 51 51 52 52 fn endpointDescription(endpoint: Endpoint) []const u8 { 53 53 if (model.isExperimental(endpoint)) { 54 - return "Experimental permissioned-data method gated by ZDS_PERMISSIONED_DATA. Request and response schemas are intentionally sparse in this MVP."; 54 + return "Experimental permissioned-data method gated by ZDS_PERMISSIONED_DATA."; 55 55 } 56 - return "Generated from the ZDS endpoint inventory. Request and response schemas are intentionally sparse in this MVP."; 56 + return "Generated from the ZDS endpoint inventory."; 57 57 }
+102 -2
src/internal/passkeys.zig
··· 94 94 \\.security-section{{margin-top:22px;border-top:1px solid var(--line);padding-top:16px}}.security-section h2{{font-size:1rem;margin:0 0 6px}}.security-section p{{margin-top:0}} 95 95 \\.credential-row{{display:flex;align-items:center;gap:10px;justify-content:space-between;border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin:8px 0;background:color-mix(in srgb,var(--field) 76%,transparent)}} 96 96 \\.credential-name{{font-weight:760;overflow:hidden;text-overflow:ellipsis}}.credential-meta{{display:block;color:var(--muted);font-size:.82rem;font-weight:400}}.credential-actions{{display:flex;gap:8px;flex:0 0 auto}}.credential-row button{{width:auto;margin:0;padding:7px 10px;background:transparent;color:var(--text);border-color:var(--line)}}.credential-row button.danger{{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,var(--line))}} 97 + \\.table-wrap{{margin-top:8px;overflow:auto;border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 76%,transparent)}}table{{width:100%;min-width:680px;border-collapse:collapse}}th,td{{padding:9px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}}th{{color:var(--muted);font-weight:500;font-size:.78rem}}td{{font-size:.88rem}}tr:last-child td{{border-bottom:0}}.mono{{overflow-wrap:anywhere}}.pill{{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--muted)}} 97 98 \\.generated{{display:none;margin-top:14px;border:1px solid color-mix(in srgb,var(--green) 42%,var(--line));border-radius:9px;padding:12px;background:color-mix(in srgb,var(--green) 9%,transparent)}}.generated.on{{display:block}}.generated code{{display:block;margin-top:6px;font-size:1.1rem;color:var(--text);overflow-wrap:anywhere}}.inline{{display:flex;gap:10px;align-items:center}}.inline input{{flex:1}}.checkbox{{display:flex;gap:8px;align-items:center;margin:10px 0 2px;color:var(--muted);font-weight:400}}.checkbox input{{width:auto}}.field-help{{margin:0 0 12px 24px;font-size:.86rem;color:var(--muted)}} 98 99 \\</style> 99 100 \\</head> ··· 133 134 \\<button id="create-app-password" type="button">create app password</button> 134 135 \\<div id="generated-app-password" class="generated" aria-live="polite"></div> 135 136 \\</section> 137 + \\<section class="security-section"> 138 + \\<h2>legacy API sessions</h2> 139 + \\<p class="hint">Active com.atproto.server sessions for this account, including account password and app-password tokens.</p> 140 + \\<div id="session-items"></div> 141 + \\</section> 142 + \\<section class="security-section"> 143 + \\<h2>OAuth app grants</h2> 144 + \\<p class="hint">Active ATProto OAuth grants issued to applications. Passkey OAuth sign-ins appear here.</p> 145 + \\<div id="oauth-grant-items"></div> 146 + \\</section> 136 147 \\</section> 137 148 \\</section> 138 149 \\</main> ··· 150 161 public_host, 151 162 securityScript, 152 163 }); 164 + try respondHtml(request, .ok, body); 165 + } 166 + 167 + pub fn adminSessionsPage(request: *http_api.Request) !void { 168 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 169 + defer arena.deinit(); 170 + const allocator = arena.allocator(); 171 + const public_url = config.publicUrl(); 172 + const public_host = try htmlEscape(allocator, displayHost(public_url)); 173 + const body = try std.fmt.allocPrint(allocator, 174 + \\<!doctype html> 175 + \\<html lang="en"> 176 + \\<head> 177 + \\<meta charset="utf-8"> 178 + \\<meta name="viewport" content="width=device-width, initial-scale=1"> 179 + \\<title>zds sessions</title> 180 + \\<link rel="icon" href="/favicon.svg" type="image/svg+xml"> 181 + \\<style> 182 + \\:root{{color-scheme:dark;--bg:#070807;--panel:#101510;--line:#263326;--text:#f1f2ec;--muted:#a7a299;--accent:#8fb0ff;--green:#37c978;--field:#101410;--bad:#ffb4a8}} 183 + \\@media (prefers-color-scheme:light){{:root{{--bg:#f6f4ed;--panel:#fffdf7;--line:#d8d0c0;--text:#161412;--muted:#625c53;--accent:#315dcb;--green:#1b7340;--field:#fffaf1;--bad:#9f1d1d;color-scheme:light}}}} 184 + \\*{{box-sizing:border-box}}body{{margin:0;min-height:100vh;background:radial-gradient(circle at 18% 0,color-mix(in srgb,var(--green) 20%,transparent),transparent 34%),var(--bg);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}} 185 + \\.shell{{width:min(100%,780px);margin:0 auto;padding:28px 16px 42px}}a{{color:inherit}}.brand{{display:inline-flex;margin-bottom:58px;text-decoration:none;font-weight:800}}h1{{margin:0;font-size:clamp(46px,16vw,96px);line-height:.88;letter-spacing:0}}p{{color:var(--muted);margin:14px 0 0;max-width:60ch}}strong{{color:var(--text)}} 186 + \\.panel{{margin-top:30px;border:1px solid var(--line);border-radius:10px;background:linear-gradient(135deg,color-mix(in srgb,var(--green) 10%,transparent),transparent 46%),linear-gradient(315deg,color-mix(in srgb,var(--accent) 8%,transparent),transparent 44%),var(--panel);padding:18px}} 187 + \\label{{display:block;margin:15px 0 6px;font-weight:760}}input{{width:100%;font:inherit;color:var(--text);background:var(--field);border:1px solid var(--line);border-radius:9px;padding:12px}}button{{width:100%;margin-top:16px;border:1px solid color-mix(in srgb,var(--accent) 60%,var(--line));border-radius:10px;background:var(--accent);color:#061021;font:inherit;font-weight:800;padding:12px;cursor:pointer}} 188 + \\.status{{min-height:1.5em;color:var(--muted)}}.error{{color:var(--bad)}}.toolbar{{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-top:14px}}.hint{{font-size:.92rem;color:var(--muted);margin:8px 0 0}}.timezone{{font-size:.82rem;color:var(--muted);margin-top:4px}}.chooser{{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:18px}}.choice{{width:auto;margin:0;text-align:left;border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 70%,transparent);color:var(--text);padding:11px;cursor:pointer}}.choice[aria-pressed="true"]{{border-color:color-mix(in srgb,var(--accent) 72%,var(--line));background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 17%,transparent),color-mix(in srgb,var(--field) 78%,transparent))}}.choice span{{display:block;color:var(--muted);font-size:.78rem;font-weight:500}}.choice strong{{display:block;margin-top:4px;font-size:1.35rem}}.filters{{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}}.filter{{width:auto;margin:0;padding:7px 10px;border-radius:999px;background:transparent;color:var(--muted);border-color:var(--line)}}.filter[aria-pressed="true"]{{color:#061021;background:var(--accent);border-color:var(--accent)}}.section{{border-top:1px solid var(--line);padding-top:16px;margin-top:18px}}.section-head{{display:flex;gap:12px;align-items:end;justify-content:space-between}}.section h2{{font-size:1rem;margin:0}}.list{{display:grid;gap:9px;margin-top:10px}}.session-card{{border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 76%,transparent);padding:11px 12px}}.session-card.usable{{border-color:color-mix(in srgb,var(--green) 44%,var(--line))}}.session-card.ended{{opacity:.82}}.card-top{{display:flex;align-items:start;justify-content:space-between;gap:12px}}.who,.what{{font-weight:800;overflow-wrap:anywhere}}.did,.scope{{display:block;margin-top:2px;color:var(--muted);font-size:.82rem;overflow-wrap:anywhere}}.pill{{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:3px 8px;font-size:.78rem;font-weight:800;white-space:nowrap}}.pill.usable{{color:#061021;background:var(--green);border-color:var(--green)}}.pill.ended{{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 55%,var(--line));background:color-mix(in srgb,var(--bad) 7%,transparent)}}.card-grid{{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:10px}}.stamp{{border-top:1px solid color-mix(in srgb,var(--line) 70%,transparent);padding-top:7px;color:var(--text)}}.stamp span{{display:block;color:var(--muted);font-size:.72rem}}.pager{{display:flex;align-items:center;gap:8px;justify-content:flex-end;color:var(--muted);font-size:.82rem}}.pager button{{width:auto;margin:0;padding:6px 9px;background:transparent;color:var(--text);border-color:var(--line)}}.empty{{border:1px dashed var(--line);border-radius:9px;padding:14px;color:var(--muted);margin-top:10px}}@media (max-width:680px){{.chooser{{grid-template-columns:1fr}}.card-grid{{grid-template-columns:1fr}}.section-head{{display:block}}.pager{{justify-content:flex-start;margin-top:8px}}}} 189 + \\</style> 190 + \\</head> 191 + \\<body> 192 + \\<main class="shell"> 193 + \\<a class="brand" href="/">zds</a> 194 + \\<h1>sessions</h1> 195 + \\<p>Operator view for app authorizations and legacy API sessions across accounts hosted on <strong>{s}</strong>.</p> 196 + \\<section class="panel"> 197 + \\<form id="admin-form"> 198 + \\<label for="token">admin token</label> 199 + \\<input id="token" name="token" type="password" autocomplete="off" required> 200 + \\<button>load sessions</button> 201 + \\</form> 202 + \\<p id="status" class="status"></p> 203 + \\<p id="timezone" class="timezone"></p> 204 + \\<div class="chooser" id="kind-chooser" hidden> 205 + \\<button class="choice" type="button" data-kind="grants" aria-pressed="true"><span>OAuth app grants</span><strong id="grant-count">0</strong></button> 206 + \\<button class="choice" type="button" data-kind="sessions" aria-pressed="false"><span>legacy API sessions</span><strong id="session-count">0</strong></button> 207 + \\</div> 208 + \\<div class="filters" id="filters" hidden> 209 + \\<button class="filter" type="button" data-filter="usable" aria-pressed="true">usable now</button> 210 + \\<button class="filter" type="button" data-filter="ended" aria-pressed="false">ended</button> 211 + \\<button class="filter" type="button" data-filter="all" aria-pressed="false">all</button> 212 + \\</div> 213 + \\<section class="section"> 214 + \\<div class="section-head"><div><h2 id="list-title">OAuth app grants</h2> 215 + \\<p id="list-hint" class="hint">Apps authorized through ATProto OAuth. Usable means the grant has not expired or been revoked.</p> 216 + \\</div><div id="pager" class="pager"></div></div> 217 + \\<div id="items"></div> 218 + \\</section> 219 + \\</section> 220 + \\</main> 221 + \\<script>{s}</script> 222 + \\</body> 223 + \\</html> 224 + , .{ public_host, adminSessionsScript }); 153 225 try respondHtml(request, .ok, body); 154 226 } 155 227 ··· 536 608 \\const prepCreate=(o)=>{const p=o.publicKey;p.challenge=b64ToBuf(p.challenge);p.user.id=b64ToBuf(p.user.id);p.excludeCredentials=(p.excludeCredentials||[]).map(c=>({...c,id:b64ToBuf(c.id)}));return o}; 537 609 \\const serialize=(c)=>({id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{clientDataJSON:bufToB64(c.response.clientDataJSON),attestationObject:bufToB64(c.response.attestationObject)}}); 538 610 \\let accessJwt=null,handle=null; 539 - \\const status=document.querySelector('#status'),accountBox=document.querySelector('#account'),itemsRoot=document.querySelector('#passkey-items'),appPasswordRoot=document.querySelector('#app-password-items'),loginForm=document.querySelector('#login-form'),accountLabel=document.querySelector('#account-label'),generatedAppPassword=document.querySelector('#generated-app-password'); 611 + \\const status=document.querySelector('#status'),accountBox=document.querySelector('#account'),itemsRoot=document.querySelector('#passkey-items'),appPasswordRoot=document.querySelector('#app-password-items'),sessionRoot=document.querySelector('#session-items'),oauthGrantRoot=document.querySelector('#oauth-grant-items'),loginForm=document.querySelector('#login-form'),accountLabel=document.querySelector('#account-label'),generatedAppPassword=document.querySelector('#generated-app-password'); 540 612 \\const authed=(url,opts={})=>fetch(url,{...opts,headers:{...(opts.headers||{}),authorization:`Bearer ${accessJwt}`}}); 541 613 \\const fail=async(r,msg)=>{if(r.ok)return r.json();let body={};try{body=await r.json()}catch{}throw new Error(body.message||body.error||msg)}; 542 614 \\const when=(seconds)=>seconds?new Date(seconds*1000).toLocaleString():'never used'; 543 615 \\const credentialRow=(name,meta)=>{const row=document.createElement('div');row.className='credential-row';const label=document.createElement('span');label.className='credential-name';label.textContent=name;const small=document.createElement('span');small.className='credential-meta';small.textContent=meta;label.append(small);const actions=document.createElement('span');actions.className='credential-actions';row.append(label,actions);return{row,actions}}; 616 + \\const empty=(root,text)=>{root.textContent='';const p=document.createElement('p');p.className='hint';p.textContent=text;root.append(p)}; 617 + \\const table=(root,cols,items,row)=>{root.textContent='';if(!items.length)return empty(root,'No active entries.');const wrap=document.createElement('div');wrap.className='table-wrap';const t=document.createElement('table');const thead=document.createElement('thead');const tr=document.createElement('tr');for(const col of cols){const th=document.createElement('th');th.textContent=col;tr.append(th)}thead.append(tr);const tbody=document.createElement('tbody');for(const item of items){const values=row(item);const tr=document.createElement('tr');for(const value of values){const td=document.createElement('td');if(value&&value.nodeType)td.append(value);else td.textContent=value??'';tr.append(td)}tbody.append(tr)}t.append(thead,tbody);wrap.append(t);root.append(wrap)}; 618 + \\const mono=(text)=>{const span=document.createElement('span');span.className='mono';span.textContent=text||'';return span}; 619 + \\const time=(value)=>value?new Date(value).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'never'; 620 + \\const methodLabel=(s)=>{if(s.appPasswordName)return`app password: ${s.appPasswordName}`;if(s.authMethod==='password')return'account password session';if(s.authMethod==='app_password')return'app password session';if(s.authMethod==='app_password_privileged')return'privileged app password session';return s.authMethod}; 621 + \\const showSessions=(items)=>table(sessionRoot,['method','created','last used','refresh expires'],items,s=>[methodLabel(s),time(s.createdAt),time(s.lastUsedAt),time(s.refreshExpiresAt)]); 622 + \\const showOAuthGrants=(items)=>table(oauthGrantRoot,['client','created','expires','scope'],items,g=>[mono(g.clientId),time(g.createdAt),time(g.expiresAt),mono(g.scope)]); 544 623 \\const showPasskeys=(items)=>{itemsRoot.textContent='';if(!items.length){const p=document.createElement('p');p.className='hint';p.textContent='No passkeys are saved for this account yet.';itemsRoot.append(p);return}for(const item of items){const view=credentialRow(item.friendlyName||'unnamed passkey',`last used: ${when(item.lastUsed)}`);const rename=document.createElement('button');rename.type='button';rename.textContent='rename';rename.onclick=async()=>{const name=prompt('Passkey name',item.friendlyName||'');if(!name)return;await fail(await authed('/xrpc/com.atproto.server.updatePasskey',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:item.id,friendlyName:name})}),'rename failed');await load()};const del=document.createElement('button');del.type='button';del.className='danger';del.textContent='delete';del.onclick=async()=>{if(!confirm('Delete this passkey from this PDS? Your browser or password manager may still keep its copy.'))return;await fail(await authed('/xrpc/com.atproto.server.deletePasskey',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:item.id})}),'delete failed');await load()};view.actions.append(rename,del);itemsRoot.append(view.row)}}; 545 624 \\const showAppPasswords=(items)=>{appPasswordRoot.textContent='';if(!items.length){const p=document.createElement('p');p.className='hint';p.textContent='No app passwords are active for this account.';appPasswordRoot.append(p);return}for(const item of items){const meta=`created ${item.createdAt}${item.privileged?' · privileged':''}`;const view=credentialRow(item.name,meta);const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='revoke';revoke.onclick=async()=>{if(!confirm(`Revoke app password "${item.name}"? Existing sessions created with it will be revoked too.`))return;await fail(await authed('/xrpc/com.atproto.server.revokeAppPassword',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:item.name})}),'revoke failed');generatedAppPassword.classList.remove('on');generatedAppPassword.textContent='';await load()};view.actions.append(revoke);appPasswordRoot.append(view.row)}}; 546 - \\const load=async()=>{const [passkeys,passwords]=await Promise.all([fail(await authed('/xrpc/com.atproto.server.listPasskeys'),'failed to load passkeys'),fail(await authed('/xrpc/com.atproto.server.listAppPasswords'),'failed to load app passwords')]);showPasskeys(passkeys.passkeys||[]);showAppPasswords(passwords.passwords||[])}; 625 + \\const load=async()=>{const [passkeys,passwords,sessions]=await Promise.all([fail(await authed('/xrpc/com.atproto.server.listPasskeys'),'failed to load passkeys'),fail(await authed('/xrpc/com.atproto.server.listAppPasswords'),'failed to load app passwords'),fail(await authed('/xrpc/dev.zat.account.listSessions'),'failed to load sessions')]);showPasskeys(passkeys.passkeys||[]);showAppPasswords(passwords.passwords||[]);showSessions(sessions.sessions||[]);showOAuthGrants(sessions.oauthGrants||[])}; 547 626 \\loginForm.addEventListener('submit',async(e)=>{e.preventDefault();status.className='status';try{const f=e.currentTarget;status.textContent='signing in...';const session=await fail(await fetch('/xrpc/com.atproto.server.createSession',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({identifier:f.identifier.value,password:f.password.value})}),'sign in failed');accessJwt=session.accessJwt;handle=session.handle;loginForm.classList.add('off');accountBox.classList.add('on');accountLabel.innerHTML=`signed in as <strong>${handle}</strong>`;status.textContent='';await load()}catch(err){status.className='status error';status.textContent=err.message||String(err)}}); 548 627 \\document.querySelector('#add-passkey').addEventListener('click',async()=>{status.className='status';try{const friendlyName=document.querySelector('#friendly').value;status.textContent='opening browser passkey prompt...';const start=await fail(await authed('/xrpc/com.atproto.server.startPasskeyRegistration',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({friendlyName})}),'registration failed');const credential=await navigator.credentials.create(prepCreate(start.options));await fail(await authed('/xrpc/com.atproto.server.finishPasskeyRegistration',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({friendlyName,credential:serialize(credential)})}),'registration failed');status.className='status ok';status.textContent='Passkey saved.';await load()}catch(err){status.className='status error';status.textContent=(err.name?err.name+': ':'')+(err.message||String(err))}}); 549 628 \\document.querySelector('#create-app-password').addEventListener('click',async()=>{status.className='status';try{const name=document.querySelector('#app-password-name').value.trim();const privileged=document.querySelector('#app-password-privileged').checked;if(!name)throw new Error('Choose a name for this app password.');status.textContent='creating app password...';const created=await fail(await authed('/xrpc/com.atproto.server.createAppPassword',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,privileged})}),'create failed');generatedAppPassword.classList.add('on');generatedAppPassword.innerHTML='Copy this app password now. It will not be shown again.<code></code>';generatedAppPassword.querySelector('code').textContent=created.password;status.className='status ok';status.textContent='App password created.';document.querySelector('#app-password-name').value='';await load()}catch(err){status.className='status error';status.textContent=err.message||String(err)}}); 629 + ; 630 + 631 + const adminSessionsScript = 632 + \\const status=document.querySelector('#status'),form=document.querySelector('#admin-form'),chooser=document.querySelector('#kind-chooser'),filters=document.querySelector('#filters'),itemsRoot=document.querySelector('#items'),pagerRoot=document.querySelector('#pager'),title=document.querySelector('#list-title'),hint=document.querySelector('#list-hint'),timezone=document.querySelector('#timezone'),grantCount=document.querySelector('#grant-count'),sessionCount=document.querySelector('#session-count'); 633 + \\const pageSize=8; 634 + \\let sessions=[],grants=[],kind='grants',filter='usable',page=0; 635 + \\timezone.textContent=`times shown in ${Intl.DateTimeFormat().resolvedOptions().timeZone || 'your local timezone'}`; 636 + \\const fail=async(r,msg)=>{if(r.ok)return r.json();let body={};try{body=await r.json()}catch{}throw new Error(body.message||body.error||msg)}; 637 + \\const empty=(text)=>{itemsRoot.textContent='';pagerRoot.textContent='';const p=document.createElement('div');p.className='empty';p.textContent=text;itemsRoot.append(p)}; 638 + \\const time=(value)=>{if(!value)return{short:'never',full:'never'};const d=new Date(value);return{short:d.toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}),full:d.toLocaleString([], {dateStyle:'full',timeStyle:'long'})}}; 639 + \\const methodLabel=(s)=>{if(s.appPasswordName)return`app password: ${s.appPasswordName}`;if(s.authMethod==='password')return'account password session';if(s.authMethod==='app_password')return'app password session';if(s.authMethod==='app_password_privileged')return'privileged app password session';return s.authMethod}; 640 + \\const shownItems=()=>{const source=kind==='grants'?grants:sessions;if(filter==='all')return source;if(filter==='usable')return source.filter(item=>item.active);return source.filter(item=>!item.active)}; 641 + \\const pill=(usable)=>{const span=document.createElement('span');span.className=`pill ${usable?'usable':'ended'}`;span.textContent=usable?'usable now':'ended';span.title=usable?'Not revoked and not expired.':'Expired or revoked.';return span}; 642 + \\const stamp=(label,value)=>{const div=document.createElement('div');div.className='stamp';const span=document.createElement('span');span.textContent=label;const t=time(value);const body=document.createElement('time');body.textContent=t.short;body.title=t.full;body.dateTime=value||'';div.append(span,body);return div}; 643 + \\const card=(item)=>{const isGrant=kind==='grants';const el=document.createElement('article');el.className=`session-card ${item.active?'usable':'ended'}`;const top=document.createElement('div');top.className='card-top';const main=document.createElement('div');const who=document.createElement('div');who.className='who';who.textContent=item.handle||item.did||'unknown account';const sub=document.createElement('span');sub.className='did';sub.textContent=item.did||'';main.append(who,sub);top.append(main,pill(item.active));const what=document.createElement('div');what.className='what';what.textContent=isGrant?item.clientId:methodLabel(item);const scope=document.createElement('span');scope.className='scope';scope.textContent=isGrant?item.scope:(item.controllerDid?`controller ${item.controllerDid}`:'legacy com.atproto.server session');what.append(scope);const grid=document.createElement('div');grid.className='card-grid';if(isGrant){grid.append(stamp('authorized',item.createdAt),stamp('grant expires',item.expiresAt),stamp('revoked',item.revokedAt))}else{grid.append(stamp('created',item.createdAt),stamp('last used',item.lastUsedAt),stamp('refresh token expires',item.refreshExpiresAt))}el.append(top,what,grid);return el}; 644 + \\const renderPager=(total)=>{pagerRoot.textContent='';if(total<=pageSize)return;const pages=Math.ceil(total/pageSize);const prev=document.createElement('button');prev.type='button';prev.textContent='prev';prev.disabled=page===0;prev.onclick=()=>{page--;render()};const label=document.createElement('span');label.textContent=`${page+1}/${pages}`;const next=document.createElement('button');next.type='button';next.textContent='next';next.disabled=page>=pages-1;next.onclick=()=>{page++;render()};pagerRoot.append(prev,label,next)}; 645 + \\const renderControls=()=>{grantCount.textContent=`${grants.filter(g=>g.active).length} usable / ${grants.length} total`;sessionCount.textContent=`${sessions.filter(s=>s.active).length} usable / ${sessions.length} total`;for(const b of chooser.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.kind===kind));for(const b of filters.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.filter===filter))}; 646 + \\const render=()=>{renderControls();const isGrant=kind==='grants';title.textContent=isGrant?'OAuth app grants':'legacy API sessions';hint.textContent=isGrant?'Apps authorized through ATProto OAuth. Usable means the grant has not expired or been revoked.':'Tokens from com.atproto.server.createSession, including account password and app-password sessions. Passkey sign-in for OAuth appears under OAuth app grants.';const items=shownItems();if(!items.length)return empty(filter==='usable'?'Nothing usable right now.':filter==='ended'?'No ended entries.':'No entries.');const maxPage=Math.max(0,Math.ceil(items.length/pageSize)-1);if(page>maxPage)page=maxPage;itemsRoot.textContent='';const list=document.createElement('div');list.className='list';for(const item of items.slice(page*pageSize,page*pageSize+pageSize))list.append(card(item));itemsRoot.append(list);renderPager(items.length)}; 647 + \\chooser.addEventListener('click',(e)=>{const b=e.target.closest('button[data-kind]');if(!b)return;kind=b.dataset.kind;page=0;render()}); 648 + \\filters.addEventListener('click',(e)=>{const b=e.target.closest('button[data-filter]');if(!b)return;filter=b.dataset.filter;page=0;render()}); 649 + \\form.addEventListener('submit',async(e)=>{e.preventDefault();status.className='status';try{status.textContent='loading authorizations...';const token=form.token.value.trim();const data=await fail(await fetch('/xrpc/dev.zat.admin.listSessions?active=false&limit=500',{headers:{authorization:`Bearer ${token}`}}),'failed to load authorizations');sessions=data.sessions||[];grants=data.oauthGrants||[];chooser.hidden=false;filters.hidden=false;kind='grants';filter='usable';page=0;render();status.textContent=''}catch(err){status.className='status error';status.textContent=err.message||String(err)}}); 550 650 ; 551 651 552 652 test "validates webauthn credential key through tangled dependency" {
+424
src/internal/permissioned_data.zig
··· 1 + //! Experimental permissioned-data primitives for `com.atproto.space.*`. 2 + //! 3 + //! Keep protocol mechanics here so the experimental LtHash, signed-commit, 4 + //! member-grant, and space-credential code does not sprawl through stable PDS 5 + //! auth, repo, or sync modules. 6 + 7 + const std = @import("std"); 8 + const zat = @import("zat"); 9 + 10 + const Blake3 = std.crypto.hash.Blake3; 11 + const HkdfSha256 = std.crypto.kdf.hkdf.HkdfSha256; 12 + const HmacSha256 = std.crypto.auth.hmac.sha2.HmacSha256; 13 + const Sha256 = std.crypto.hash.sha2.Sha256; 14 + 15 + pub const lthash_lanes = 1024; 16 + pub const lthash_lane_bytes = 2; 17 + pub const lthash_state_bytes = lthash_lanes * lthash_lane_bytes; 18 + pub const commit_hash_bytes = Sha256.digest_length; 19 + 20 + pub const LtHash = struct { 21 + bytes: [lthash_state_bytes]u8 = .{0} ** lthash_state_bytes, 22 + 23 + pub fn fromBytes(bytes: []const u8) !LtHash { 24 + if (bytes.len != lthash_state_bytes) return error.InvalidLtHashState; 25 + var hash: LtHash = .{}; 26 + @memcpy(&hash.bytes, bytes); 27 + return hash; 28 + } 29 + 30 + pub fn add(self: *LtHash, element: []const u8) void { 31 + var expanded = expandElement(element); 32 + self.apply(&expanded, .add); 33 + } 34 + 35 + pub fn remove(self: *LtHash, element: []const u8) void { 36 + var expanded = expandElement(element); 37 + self.apply(&expanded, .remove); 38 + } 39 + 40 + pub fn digest(self: *const LtHash) [commit_hash_bytes]u8 { 41 + var out: [commit_hash_bytes]u8 = undefined; 42 + Sha256.hash(&self.bytes, &out, .{}); 43 + return out; 44 + } 45 + 46 + pub fn equals(self: *const LtHash, other: *const LtHash) bool { 47 + return std.mem.eql(u8, &self.bytes, &other.bytes); 48 + } 49 + 50 + fn apply(self: *LtHash, expanded: *const [lthash_state_bytes]u8, op: enum { add, remove }) void { 51 + var lane: usize = 0; 52 + while (lane < lthash_lanes) : (lane += 1) { 53 + const offset = lane * lthash_lane_bytes; 54 + const current = std.mem.readInt(u16, self.bytes[offset..][0..2], .little); 55 + const incoming = std.mem.readInt(u16, expanded[offset..][0..2], .little); 56 + const next = switch (op) { 57 + .add => current +% incoming, 58 + .remove => current -% incoming, 59 + }; 60 + std.mem.writeInt(u16, self.bytes[offset..][0..2], next, .little); 61 + } 62 + } 63 + }; 64 + 65 + pub const SpaceContext = struct { 66 + space_did: []const u8, 67 + space_type: []const u8, 68 + space_key: []const u8, 69 + user_did: []const u8, 70 + rev: []const u8, 71 + scope: Scope, 72 + 73 + pub const Scope = enum { 74 + records, 75 + members, 76 + 77 + pub fn text(self: Scope) []const u8 { 78 + return switch (self) { 79 + .records => "records", 80 + .members => "members", 81 + }; 82 + } 83 + }; 84 + }; 85 + 86 + pub const SignedCommit = struct { 87 + hash: [32]u8, 88 + hmac: [32]u8, 89 + ikm: [32]u8, 90 + sig: [64]u8, 91 + rev: []const u8, 92 + }; 93 + 94 + pub const MemberGrant = struct { 95 + member_did: []const u8, 96 + owner_did: []const u8, 97 + space: []const u8, 98 + client_id: []const u8, 99 + exp: i64, 100 + }; 101 + 102 + pub const SpaceCredential = struct { 103 + owner_did: []const u8, 104 + space: []const u8, 105 + client_id: []const u8, 106 + exp: i64, 107 + }; 108 + 109 + pub fn recordElement(allocator: std.mem.Allocator, collection: []const u8, rkey: []const u8, cid: []const u8) ![]const u8 { 110 + return std.fmt.allocPrint(allocator, "{s}/{s}:{s}", .{ collection, rkey, cid }); 111 + } 112 + 113 + pub fn createCommit( 114 + allocator: std.mem.Allocator, 115 + io: std.Io, 116 + state: []const u8, 117 + ctx: SpaceContext, 118 + keypair: *const zat.Keypair, 119 + ) !SignedCommit { 120 + const lthash = try LtHash.fromBytes(state); 121 + var ikm: [32]u8 = undefined; 122 + io.random(&ikm); 123 + const hmac = try commitHmac(allocator, &ikm, &lthash.digest(), ctx); 124 + const sig = try keypair.sign(&ikm); 125 + return .{ 126 + .hash = lthash.digest(), 127 + .hmac = hmac, 128 + .ikm = ikm, 129 + .sig = sig.bytes, 130 + .rev = ctx.rev, 131 + }; 132 + } 133 + 134 + pub fn signedCommitJson(allocator: std.mem.Allocator, commit: SignedCommit) ![]const u8 { 135 + const hash = try zat.jwt.base64UrlEncode(allocator, &commit.hash); 136 + defer allocator.free(hash); 137 + const hmac = try zat.jwt.base64UrlEncode(allocator, &commit.hmac); 138 + defer allocator.free(hmac); 139 + const ikm = try zat.jwt.base64UrlEncode(allocator, &commit.ikm); 140 + defer allocator.free(ikm); 141 + const sig = try zat.jwt.base64UrlEncode(allocator, &commit.sig); 142 + defer allocator.free(sig); 143 + return std.fmt.allocPrint( 144 + allocator, 145 + "{{\"hash\":{f},\"hmac\":{f},\"ikm\":{f},\"sig\":{f},\"rev\":{f}}}", 146 + .{ std.json.fmt(hash, .{}), std.json.fmt(hmac, .{}), std.json.fmt(ikm, .{}), std.json.fmt(sig, .{}), std.json.fmt(commit.rev, .{}) }, 147 + ); 148 + } 149 + 150 + pub fn createMemberGrant( 151 + allocator: std.mem.Allocator, 152 + io: std.Io, 153 + member_did: []const u8, 154 + owner_did: []const u8, 155 + space: []const u8, 156 + client_id: []const u8, 157 + keypair: *const zat.Keypair, 158 + ) ![]const u8 { 159 + const iat = unixNow(); 160 + const exp = iat + 300; 161 + const jti = try randomTokenId(allocator, io); 162 + defer allocator.free(jti); 163 + const header = try std.fmt.allocPrint(allocator, "{{\"typ\":\"space_member_grant\",\"alg\":\"{s}\"}}", .{@tagName(keypair.algorithm())}); 164 + defer allocator.free(header); 165 + const payload = try std.fmt.allocPrint( 166 + allocator, 167 + "{{\"iss\":{f},\"aud\":{f},\"space\":{f},\"clientId\":{f},\"lxm\":\"com.atproto.space.getSpaceCredential\",\"iat\":{d},\"exp\":{d},\"jti\":{f}}}", 168 + .{ std.json.fmt(member_did, .{}), std.json.fmt(owner_did, .{}), std.json.fmt(space, .{}), std.json.fmt(client_id, .{}), iat, exp, std.json.fmt(jti, .{}) }, 169 + ); 170 + defer allocator.free(payload); 171 + return zat.oauth.createJwt(allocator, header, payload, keypair); 172 + } 173 + 174 + pub fn createSpaceCredential( 175 + allocator: std.mem.Allocator, 176 + io: std.Io, 177 + owner_did: []const u8, 178 + space: []const u8, 179 + client_id: []const u8, 180 + keypair: *const zat.Keypair, 181 + ) ![]const u8 { 182 + const iat = unixNow(); 183 + const exp = iat + 7200; 184 + const jti = try randomTokenId(allocator, io); 185 + defer allocator.free(jti); 186 + const header = try std.fmt.allocPrint(allocator, "{{\"typ\":\"space_credential\",\"alg\":\"{s}\"}}", .{@tagName(keypair.algorithm())}); 187 + defer allocator.free(header); 188 + const payload = try std.fmt.allocPrint( 189 + allocator, 190 + "{{\"iss\":{f},\"space\":{f},\"clientId\":{f},\"iat\":{d},\"exp\":{d},\"jti\":{f}}}", 191 + .{ std.json.fmt(owner_did, .{}), std.json.fmt(space, .{}), std.json.fmt(client_id, .{}), iat, exp, std.json.fmt(jti, .{}) }, 192 + ); 193 + defer allocator.free(payload); 194 + return zat.oauth.createJwt(allocator, header, payload, keypair); 195 + } 196 + 197 + pub fn verifyMemberGrant(allocator: std.mem.Allocator, token: []const u8, public_key_multibase: []const u8) !MemberGrant { 198 + const parsed = try parseAndVerifyJwt(allocator, token, public_key_multibase, "space_member_grant"); 199 + defer parsed.deinit(); 200 + const lxm = zat.json.getString(parsed.payload.value, "lxm") orelse return error.InvalidJwt; 201 + if (!std.mem.eql(u8, lxm, "com.atproto.space.getSpaceCredential")) return error.InvalidJwt; 202 + const exp = zat.json.getInt(parsed.payload.value, "exp") orelse return error.InvalidJwt; 203 + if (exp < unixNow()) return error.ExpiredJwt; 204 + return .{ 205 + .member_did = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "iss") orelse return error.InvalidJwt), 206 + .owner_did = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "aud") orelse return error.InvalidJwt), 207 + .space = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "space") orelse return error.InvalidJwt), 208 + .client_id = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "clientId") orelse return error.InvalidJwt), 209 + .exp = exp, 210 + }; 211 + } 212 + 213 + pub fn verifySpaceCredential(allocator: std.mem.Allocator, token: []const u8, public_key_multibase: []const u8) !SpaceCredential { 214 + const parsed = try parseAndVerifyJwt(allocator, token, public_key_multibase, "space_credential"); 215 + defer parsed.deinit(); 216 + const exp = zat.json.getInt(parsed.payload.value, "exp") orelse return error.InvalidJwt; 217 + if (exp < unixNow()) return error.ExpiredJwt; 218 + return .{ 219 + .owner_did = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "iss") orelse return error.InvalidJwt), 220 + .space = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "space") orelse return error.InvalidJwt), 221 + .client_id = try allocator.dupe(u8, zat.json.getString(parsed.payload.value, "clientId") orelse return error.InvalidJwt), 222 + .exp = exp, 223 + }; 224 + } 225 + 226 + pub fn unverifiedStringClaim(allocator: std.mem.Allocator, token: []const u8, claim: []const u8) ![]const u8 { 227 + var parts: [3][]const u8 = undefined; 228 + var part_count: usize = 0; 229 + var it = std.mem.splitScalar(u8, token, '.'); 230 + while (it.next()) |part| { 231 + if (part_count >= 3) return error.InvalidJwt; 232 + parts[part_count] = part; 233 + part_count += 1; 234 + } 235 + if (part_count != 3) return error.InvalidJwt; 236 + const payload_json = try zat.jwt.base64UrlDecode(allocator, parts[1]); 237 + defer allocator.free(payload_json); 238 + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}); 239 + defer parsed.deinit(); 240 + return allocator.dupe(u8, zat.json.getString(parsed.value, claim) orelse return error.InvalidJwt); 241 + } 242 + 243 + fn expandElement(element: []const u8) [lthash_state_bytes]u8 { 244 + var hasher = Blake3.init(.{}); 245 + hasher.update(element); 246 + var out: [lthash_state_bytes]u8 = undefined; 247 + hasher.final(&out); 248 + return out; 249 + } 250 + 251 + const ParsedJwt = struct { 252 + allocator: std.mem.Allocator, 253 + header: std.json.Parsed(std.json.Value), 254 + payload: std.json.Parsed(std.json.Value), 255 + signature: []u8, 256 + signed_input: []const u8, 257 + 258 + fn deinit(self: ParsedJwt) void { 259 + self.header.deinit(); 260 + self.payload.deinit(); 261 + self.allocator.free(self.signature); 262 + } 263 + }; 264 + 265 + fn parseAndVerifyJwt(allocator: std.mem.Allocator, token: []const u8, public_key_multibase: []const u8, expected_typ: []const u8) !ParsedJwt { 266 + var parts: [3][]const u8 = undefined; 267 + var part_count: usize = 0; 268 + var it = std.mem.splitScalar(u8, token, '.'); 269 + while (it.next()) |part| { 270 + if (part_count >= 3) return error.InvalidJwt; 271 + parts[part_count] = part; 272 + part_count += 1; 273 + } 274 + if (part_count != 3) return error.InvalidJwt; 275 + const signed_input = token[0 .. parts[0].len + 1 + parts[1].len]; 276 + 277 + const header_json = try zat.jwt.base64UrlDecode(allocator, parts[0]); 278 + defer allocator.free(header_json); 279 + const payload_json = try zat.jwt.base64UrlDecode(allocator, parts[1]); 280 + defer allocator.free(payload_json); 281 + const header = try std.json.parseFromSlice(std.json.Value, allocator, header_json, .{}); 282 + errdefer header.deinit(); 283 + const payload = try std.json.parseFromSlice(std.json.Value, allocator, payload_json, .{}); 284 + errdefer payload.deinit(); 285 + const signature = try zat.jwt.base64UrlDecode(allocator, parts[2]); 286 + errdefer allocator.free(signature); 287 + if (signature.len != 64) return error.InvalidJwt; 288 + 289 + const typ = zat.json.getString(header.value, "typ") orelse return error.InvalidJwt; 290 + if (!std.mem.eql(u8, typ, expected_typ)) return error.InvalidJwt; 291 + const alg_text = zat.json.getString(header.value, "alg") orelse return error.InvalidJwt; 292 + const alg = zat.jwt.Algorithm.fromString(alg_text) orelse return error.InvalidJwt; 293 + const key_bytes = try zat.multibase.decode(allocator, public_key_multibase); 294 + defer allocator.free(key_bytes); 295 + const parsed_key = try zat.multicodec.parsePublicKey(key_bytes); 296 + switch (alg) { 297 + .ES256K => if (parsed_key.key_type != .secp256k1) return error.InvalidJwt, 298 + .ES256 => if (parsed_key.key_type != .p256) return error.InvalidJwt, 299 + } 300 + try zat.jwt.verifyJose(alg, signed_input, signature, parsed_key.raw); 301 + return .{ .allocator = allocator, .header = header, .payload = payload, .signature = signature, .signed_input = signed_input }; 302 + } 303 + 304 + fn randomTokenId(allocator: std.mem.Allocator, io: std.Io) ![]const u8 { 305 + var bytes: [16]u8 = undefined; 306 + io.random(&bytes); 307 + return zat.jwt.base64UrlEncode(allocator, &bytes); 308 + } 309 + 310 + fn unixNow() i64 { 311 + var ts: std.posix.timespec = undefined; 312 + return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { 313 + .SUCCESS => ts.sec, 314 + else => 0, 315 + }; 316 + } 317 + 318 + fn commitHmac(allocator: std.mem.Allocator, ikm: *const [32]u8, hash: *const [32]u8, ctx: SpaceContext) ![32]u8 { 319 + const info = try commitInfo(allocator, ctx); 320 + defer allocator.free(info); 321 + const prk = HkdfSha256.extract("", ikm); 322 + var derived: [32]u8 = undefined; 323 + HkdfSha256.expand(&derived, info, prk); 324 + var out: [32]u8 = undefined; 325 + HmacSha256.create(&out, hash, &derived); 326 + return out; 327 + } 328 + 329 + fn commitInfo(allocator: std.mem.Allocator, ctx: SpaceContext) ![]const u8 { 330 + var out: std.Io.Writer.Allocating = .init(allocator); 331 + try out.writer.writeAll("atproto-space-v1"); 332 + try writeInfoField(&out.writer, ctx.space_did); 333 + try writeInfoField(&out.writer, ctx.space_type); 334 + try writeInfoField(&out.writer, ctx.space_key); 335 + try writeInfoField(&out.writer, ctx.user_did); 336 + try writeInfoField(&out.writer, ctx.rev); 337 + try writeInfoField(&out.writer, ctx.scope.text()); 338 + return out.toOwnedSlice(); 339 + } 340 + 341 + fn writeInfoField(writer: *std.Io.Writer, value: []const u8) !void { 342 + if (value.len > std.math.maxInt(u16)) return error.FieldTooLong; 343 + var len: [2]u8 = undefined; 344 + std.mem.writeInt(u16, &len, @intCast(value.len), .big); 345 + try writer.writeAll(&len); 346 + try writer.writeAll(value); 347 + } 348 + 349 + test "LtHash locks empty digest" { 350 + const hash: LtHash = .{}; 351 + try std.testing.expectEqualStrings( 352 + "e5a00aa9991ac8a5ee3109844d84a55583bd20572ad3ffcd42792f3c36b183ad", 353 + &std.fmt.bytesToHex(hash.digest(), .lower), 354 + ); 355 + } 356 + 357 + test "LtHash add remove and ordering" { 358 + var first: LtHash = .{}; 359 + var second: LtHash = .{}; 360 + first.add("alpha"); 361 + first.add("beta"); 362 + second.add("beta"); 363 + second.add("alpha"); 364 + try std.testing.expect(first.equals(&second)); 365 + first.remove("alpha"); 366 + first.remove("beta"); 367 + const empty: LtHash = .{}; 368 + try std.testing.expect(first.equals(&empty)); 369 + } 370 + 371 + test "LtHash snapshot vector" { 372 + var hash: LtHash = .{}; 373 + hash.add("atproto"); 374 + hash.add("space"); 375 + const expected = 376 + "34a3d4a5e9f4ae93fdb1580c14a8e8013342cd303117862c89ec906b462706aaaa26ffa4ae4159c4c6c2b81cc0fa83582d7c05c31ae64e302284dd1bfccad846c94b2e9b4d704b9d21cf8953d28610f5a0af710c99958f107a28a3b1a71017ebd47a14e184d95ca4affc534cba38882b46a6e52350c836ad7b5374246bf354f52dcff8b7dae5255b410b70ed5dfd54ac94ffa083dfb3708c9d923190399a65d108ef05d7ec928e98bbf865f1229a1b497ec09458a9a08d17631a4fa28dc96d384748d544660b116e25066b5b012f7d94fe6e1e8f4b45e9588523a714390e1f61f6a65f21b4e230973b055ae371cd697f4d2733ee0bdf20fde6e934b222cf065ed61a765f09ab8332d36bcaffedaaff483c4f0ba19c2c684e3f1744b073e89ab901d208e0bcf60f9918b820bbb5bb005085d7e2e71df2ad4be0c2d879cce17ea2a46e9d71ef9708715b0a5f34680cb8977f13d9082b141d9638df35eafce0693845a4ab810fd213bbd0dbd12e74d5297e11c9f601df603eb357d80669eacb97051df757a500b16d0edacc8d556e108430e868eae78312985110b20692a3738300221d361d30d02274d662b22808f381564aa966f117383e6b923663dd28c1cca46b8099938fde309ce91a7e99b188f152db7e691752757cf897883be25cf0a9fda51106e6c735f55ff9e77cad2cd8f5041d0c746175eddeb70762efec9604d31254e9ddd9ded2b186e45b40010d595068b94cfb7017553986402646424038fb06cd0d91711caccee3a1e53a1b6849e5b61bb6bc432a1466f3c2bfb9c2503e02e3c8dade31afa7c57b50adb9fbe95706bfec45036a16664184ce3ef4c906b161a640f64708b12c6361f1fab2c14f3fa25281c1cc722b3913a5cdcbfe91690ff98096809271cdf9b9f814d6644227a3d80b4bfd195f352755ae89bc79329d7aa11eed4321b2fffaf7c1a927c394fd59508815ed3881dbf5a17b23e26aa8ea2bb5f0b3c9b207007a96eb1638d499bb211f594cfe0420457b1c0cf827ea14629324c07c087e71509b22dbdcfe2c985e4539c096df8fc02ffe932bf28d68df58abe162eca7683a41f85203d11d9d6decfe157139814b259f2f94fda2fc2f4527e94bd56c0bbfe5344befac7213eaf3acff4cd7148d95ad6babc3381d2abf97b4526787378579519c362e7692817ca885c967f003a65fb76cbcdfe8dd90c48c298c5e2935364957cf3b9a5bfb73a1534341e5fe8c9dc5f9a15afee4fdd2c7d5b6b2cd50112c83bf7901b28575e162fd91565153a2e165f8cc6d0bab13ce0b910eadbc9aff9316531d1fc15bb8aba1f94e0178a43e5935dffaa7d00fa7c57439074f1ae95ef7aa34f76a6d797c722a76ffcf5aeb69bf7490a7237935091e86517201d24d8a68a24bf0c606322a91a34650765151e56bf355f89aeb14bb598d1b1bb19e5508d5d0452117bfec1da43c3533c9cc350ec68f898dc4ff7360a6246f3fd0263fa0aa759699bb29e1f6935b59df39df0f46633b83e3f4cf420a4a9a1ea3331f2b0168007d1f58e40e2b7cb1e5da28477be11f3cdf8798660023bcef29b4c057398a8f7f96c61f2ea983e91a8dc3b4dc9dfba9c50d9060d5618e10cc5bac7bbb6517466c4b112dd9b642a80297f48d2aa3fd231663f508a065bf73e35f7d39c5095330ef8b6b25df0a08e43cfd3d278b5151a845f8e9fd7c1e52a8338716b1f2275a7279a0245e3d5e1de68f0f15d4ac73476a368dc9d8544fc082c11995b80c9ea5812ad2bcc9510d9e1a56d0441fb0cfe0350e5e90dcde9dfb5f174d1639d53c4c42253133fed45e6da16b9da15defe621d27bcfb7a61224474390b5756f1ca7565ae81477793a8c3607aede9ee5805d5b69d67cdb9036f4931f2d4c04df415111dc4f0d143d101fb85a653e940a85a3aee71b8941fe2191dfdbc0a4d4cc7fce6e4ac91aa3f9c679afc0e6314ed492ab1ff9d105f1f0b967a13a45c56b27f66e01376bcdedab1c2ef7e3aa38a37ba7a8fef22682ab8e58e24ae1c6236c365f4198b8de1cf5a052623e6f8c2073b0c3efa464dd435986fd80e0afeea36b0fb1b06c6b1e58b40673563cbe45434defb3b194e3e481896847236cb91154d2badda206655e5be4f36f97ccc38cfca49a66fd1be468ba97dec7ff7f56b37d77d1acd345d2f3e4143643bd4c6f6ac904aa34cc2eb39b16e89d8367a8579821f143804b20f15427224db7618f4bef582c9607a4603022bbfc156f54a85199cc05781c844e517a9074f4037e292ffe74f8c4d923b2a220d6b2da1e91822fd574081a0244e5de9007b358311ffc1f02f7133076302790b71e1f73116ee0dc6fd95290bf1fda2a1c0ebeb669f2d03cda8dc3e817d9e2893f9bd193e4d5908ed4f080452686e8a2a84374a0f678823ead2d8d1f45b245d338955503803471cb732d1f00369e989c016cd718a63a5e3454cf505c69d546ba3da3d8d7fc049d2b7d980a5c7a44bce02d23ad056d9cbe4d61d690c0c7afd3d1d80c3b1a5daf31a2acd4d00de7090b2531c0c9e3a999eca6bd69982328235a65466578469ab96f5aff8d8016d893e92bc3aaf8e603e9910f83b5519953bdbd0c1d94e0cae48889c4d37ac53f47f3d9d340ace07a657b052b800960f79d08e01032759796103e037e1c8af7c1373d953ad50c28cfbbefd0ac3ef1287a7d3efeb3e7416925e21d2122937d39460699945407cb6962f2f31824989510f91c8c558b58b7f09ddccb4c21a3977bb6332a962edbffb86e0a743c217ab39d18125be525cba097ed98895c827246b9e3fa1e965003cf6e919122969b8635051a3ceeeaf9fb45adee52543a2e61c0f75a4ca62c7af10498117fcfb7f296c54176a3b2a1f83c124495afdbcb85dcb0abb2b46c"; 377 + try std.testing.expectEqualStrings(expected, &std.fmt.bytesToHex(hash.bytes, .lower)); 378 + } 379 + 380 + test "member grant and space credential round trip" { 381 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 382 + defer arena.deinit(); 383 + const allocator = arena.allocator(); 384 + 385 + var keypair = try zat.Keypair.fromSecretKey(.p256, .{ 386 + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 387 + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 388 + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 389 + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 390 + }); 391 + const did_key = try keypair.did(allocator); 392 + const public_key_multibase = did_key["did:key:".len..]; 393 + 394 + const grant_token = try createMemberGrant( 395 + allocator, 396 + std.Options.debug_io, 397 + "did:plc:membergrant", 398 + "did:plc:spaceowner", 399 + "ats://did:plc:spaceowner/fm.plyr.privateMedia/self", 400 + "https://plyr.fm/oauth-client.json", 401 + &keypair, 402 + ); 403 + const grant = try verifyMemberGrant(allocator, grant_token, public_key_multibase); 404 + try std.testing.expectEqualStrings("did:plc:membergrant", grant.member_did); 405 + try std.testing.expectEqualStrings("did:plc:spaceowner", grant.owner_did); 406 + try std.testing.expectEqualStrings("ats://did:plc:spaceowner/fm.plyr.privateMedia/self", grant.space); 407 + try std.testing.expectEqualStrings("https://plyr.fm/oauth-client.json", grant.client_id); 408 + 409 + const credential_token = try createSpaceCredential( 410 + allocator, 411 + std.Options.debug_io, 412 + "did:plc:spaceowner", 413 + grant.space, 414 + grant.client_id, 415 + &keypair, 416 + ); 417 + const credential = try verifySpaceCredential(allocator, credential_token, public_key_multibase); 418 + try std.testing.expectEqualStrings("did:plc:spaceowner", credential.owner_did); 419 + try std.testing.expectEqualStrings(grant.space, credential.space); 420 + try std.testing.expectEqualStrings(grant.client_id, credential.client_id); 421 + 422 + try std.testing.expectError(error.InvalidJwt, verifySpaceCredential(allocator, grant_token, public_key_multibase)); 423 + try std.testing.expectError(error.InvalidJwt, verifyMemberGrant(allocator, credential_token, public_key_multibase)); 424 + }
+87
src/internal/scopes.zig
··· 1 1 const std = @import("std"); 2 2 3 3 pub const RepoAction = enum { create, update, delete }; 4 + pub const SpaceAction = enum { read, create, update, delete, manage }; 4 5 pub const AccountAction = enum { read, manage }; 5 6 pub const AccountAttr = enum { email, repo, status, wildcard }; 6 7 pub const IdentityAttr = enum { handle, wildcard }; ··· 43 44 if (std.mem.eql(u8, scope, "transition:generic")) return true; 44 45 if (!std.mem.startsWith(u8, scope, "rpc:")) continue; 45 46 if (rpcScopeMatches(scope, aud, lxm)) return true; 47 + } 48 + return false; 49 + } 50 + 51 + pub fn spaceAllows( 52 + maybe_scope: ?[]const u8, 53 + action: SpaceAction, 54 + space_type: []const u8, 55 + did: []const u8, 56 + skey: []const u8, 57 + collection: ?[]const u8, 58 + ) bool { 59 + if (!oauthNeedsGranularCheck(maybe_scope)) return true; 60 + var scope_iter = std.mem.splitScalar(u8, maybe_scope.?, ' '); 61 + while (scope_iter.next()) |scope| { 62 + if (scope.len == 0) continue; 63 + if (std.mem.eql(u8, scope, "transition:generic")) return true; 64 + if (!std.mem.startsWith(u8, scope, "space:")) continue; 65 + if (spaceScopeMatches(scope, action, space_type, did, skey, collection)) return true; 46 66 } 47 67 return false; 48 68 } ··· 227 247 return false; 228 248 } 229 249 250 + fn spaceScopeMatches( 251 + scope: []const u8, 252 + action: SpaceAction, 253 + space_type: []const u8, 254 + did: []const u8, 255 + skey: []const u8, 256 + collection: ?[]const u8, 257 + ) bool { 258 + const query_start = std.mem.indexOfScalar(u8, scope, '?'); 259 + const base = if (query_start) |idx| scope[0..idx] else scope; 260 + if (!spaceTypeMatches(base, space_type)) return false; 261 + if (query_start == null) return true; 262 + 263 + var saw_collection = false; 264 + var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); 265 + while (params.next()) |param| { 266 + if (std.mem.startsWith(u8, param, "action=")) { 267 + if (!std.mem.eql(u8, param["action=".len..], spaceActionName(action))) return false; 268 + } else if (std.mem.startsWith(u8, param, "did=")) { 269 + const allowed = param["did=".len..]; 270 + if (!std.mem.eql(u8, allowed, "*") and !std.mem.eql(u8, allowed, did)) return false; 271 + } else if (std.mem.startsWith(u8, param, "skey=")) { 272 + const allowed = param["skey=".len..]; 273 + if (!std.mem.eql(u8, allowed, "*") and !std.mem.eql(u8, allowed, skey)) return false; 274 + } else if (std.mem.startsWith(u8, param, "collection=")) { 275 + saw_collection = true; 276 + const requested = collection orelse return false; 277 + const allowed = param["collection=".len..]; 278 + if (!collectionPatternMatches(allowed, requested)) return false; 279 + } 280 + } 281 + return !saw_collection or collection != null; 282 + } 283 + 284 + fn spaceTypeMatches(base: []const u8, space_type: []const u8) bool { 285 + const prefix = "space:"; 286 + if (!std.mem.startsWith(u8, base, prefix)) return false; 287 + const allowed = base[prefix.len..]; 288 + return collectionPatternMatches(allowed, space_type); 289 + } 290 + 291 + fn collectionPatternMatches(pattern: []const u8, value: []const u8) bool { 292 + if (std.mem.eql(u8, pattern, "*") or std.mem.eql(u8, pattern, value)) return true; 293 + if (std.mem.endsWith(u8, pattern, ".*")) { 294 + const prefix = pattern[0 .. pattern.len - 2]; 295 + return std.mem.startsWith(u8, value, prefix) and value.len > prefix.len and value[prefix.len] == '.'; 296 + } 297 + return false; 298 + } 299 + 300 + fn spaceActionName(action: SpaceAction) []const u8 { 301 + return switch (action) { 302 + .read => "read", 303 + .create => "create", 304 + .update => "update", 305 + .delete => "delete", 306 + .manage => "manage", 307 + }; 308 + } 309 + 230 310 test "repo scopes constrain action and collection" { 231 311 try std.testing.expect(repoAllows("repo:*?action=create", .create, "app.bsky.feed.post")); 232 312 try std.testing.expect(!repoAllows("repo:*?action=create", .delete, "app.bsky.feed.post")); ··· 246 326 try std.testing.expect(rpcAllows("rpc:app.bsky.feed.*?aud=*", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getFeed")); 247 327 try std.testing.expect(!rpcAllows("rpc:app.bsky.feed.*?aud=did:web:api.bsky.app#bsky_appview", "did:web:video.bsky.app#video", "app.bsky.feed.getFeed")); 248 328 try std.testing.expect(!rpcAllows("repo:*", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getFeed")); 329 + } 330 + 331 + test "space scopes constrain type identity key action and collection" { 332 + try std.testing.expect(spaceAllows("space:fm.plyr.privateMedia?action=create&did=did:plc:alice&skey=self&collection=fm.plyr.track", .create, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); 333 + try std.testing.expect(!spaceAllows("space:fm.plyr.privateMedia?action=read&did=did:plc:alice&skey=self", .create, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); 334 + try std.testing.expect(spaceAllows("space:fm.plyr.*?action=read&did=*&skey=*", .read, "fm.plyr.privateMedia", "did:plc:bob", "records", null)); 335 + try std.testing.expect(!spaceAllows("repo:*", .read, "fm.plyr.privateMedia", "did:plc:bob", "records", null)); 249 336 } 250 337 251 338 test "account scopes constrain attribute and action" {
+258
src/internal/telemetry.zig
··· 1 + const std = @import("std"); 2 + const httpz = @import("httpz"); 3 + const router = @import("../http/router.zig"); 4 + 5 + const Io = std.Io; 6 + const Route = router.Route; 7 + 8 + const SAMPLE_COUNT = 512; 9 + const SLOW_COUNT = 32; 10 + const LABEL_LEN = 192; 11 + const ROUTE_COUNT = @typeInfo(Route).@"enum".fields.len; 12 + const METHOD_COUNT = @typeInfo(Method).@"enum".fields.len; 13 + 14 + pub const Method = enum { 15 + get, 16 + head, 17 + post, 18 + options, 19 + other, 20 + 21 + pub fn fromHttpz(method: httpz.Method) Method { 22 + return switch (method) { 23 + .GET => .get, 24 + .HEAD => .head, 25 + .POST => .post, 26 + .OPTIONS => .options, 27 + else => .other, 28 + }; 29 + } 30 + 31 + pub fn label(method: Method) []const u8 { 32 + return switch (method) { 33 + .get => "GET", 34 + .head => "HEAD", 35 + .post => "POST", 36 + .options => "OPTIONS", 37 + .other => "OTHER", 38 + }; 39 + } 40 + }; 41 + 42 + pub const Class = enum { 43 + local, 44 + proxy, 45 + unknown, 46 + 47 + pub fn label(class: Class) []const u8 { 48 + return switch (class) { 49 + .local => "local", 50 + .proxy => "proxy", 51 + .unknown => "unknown", 52 + }; 53 + } 54 + }; 55 + 56 + pub const EndpointStats = struct { 57 + route: Route, 58 + method: Method, 59 + count: u64, 60 + failure_count: u64, 61 + avg_ms: f64, 62 + p50_ms: f64, 63 + p95_ms: f64, 64 + max_ms: f64, 65 + }; 66 + 67 + pub const SlowRequest = struct { 68 + route: Route = .not_found, 69 + method: Method = .other, 70 + class: Class = .unknown, 71 + at_s: i64 = 0, 72 + elapsed_us: u32 = 0, 73 + status: u16 = 0, 74 + handler_failed: bool = false, 75 + label: [LABEL_LEN]u8 = [_]u8{0} ** LABEL_LEN, 76 + label_len: usize = 0, 77 + 78 + pub fn labelText(self: *const SlowRequest) []const u8 { 79 + return self.label[0..self.label_len]; 80 + } 81 + 82 + pub fn failed(self: *const SlowRequest) bool { 83 + return self.handler_failed or self.status >= 500; 84 + } 85 + }; 86 + 87 + pub const Snapshot = struct { 88 + started_at_s: i64, 89 + uptime_s: u64, 90 + total_requests: u64, 91 + total_failures: u64, 92 + routes: [ROUTE_COUNT][METHOD_COUNT]EndpointStats, 93 + slow_requests: [SLOW_COUNT]SlowRequest, 94 + slow_len: usize, 95 + }; 96 + 97 + const LatencyBuffer = struct { 98 + samples: [SAMPLE_COUNT]u32 = .{0} ** SAMPLE_COUNT, 99 + count: usize = 0, 100 + head: usize = 0, 101 + total_count: u64 = 0, 102 + failure_count: u64 = 0, 103 + 104 + fn record(self: *LatencyBuffer, elapsed_us: u32, failed: bool) void { 105 + self.samples[self.head] = elapsed_us; 106 + self.head = (self.head + 1) % SAMPLE_COUNT; 107 + if (self.count < SAMPLE_COUNT) self.count += 1; 108 + self.total_count += 1; 109 + if (failed) self.failure_count += 1; 110 + } 111 + }; 112 + 113 + var global_io: ?Io = null; 114 + var mutex: Io.Mutex = .init; 115 + var started_at_s: i64 = 0; 116 + var buffers = [_][METHOD_COUNT]LatencyBuffer{[_]LatencyBuffer{.{}} ** METHOD_COUNT} ** ROUTE_COUNT; 117 + var slow_requests = [_]SlowRequest{.{}} ** SLOW_COUNT; 118 + var slow_next: usize = 0; 119 + var slow_len: usize = 0; 120 + var total_requests: u64 = 0; 121 + var total_failures: u64 = 0; 122 + 123 + pub fn init(io: Io) void { 124 + global_io = io; 125 + started_at_s = timestampSeconds(io); 126 + } 127 + 128 + pub fn start() i64 { 129 + const io = global_io orelse return 0; 130 + return Io.Timestamp.now(io, .awake).toMicroseconds(); 131 + } 132 + 133 + pub fn record(route: Route, method: httpz.Method, class: Class, label: []const u8, start_us: i64, status: u16, handler_failed: bool) void { 134 + const io = global_io orelse return; 135 + const now_us = Io.Timestamp.now(io, .awake).toMicroseconds(); 136 + const elapsed_us: u32 = @intCast(@min(@max(0, now_us - start_us), std.math.maxInt(u32))); 137 + const method_key = Method.fromHttpz(method); 138 + const failed = handler_failed or status >= 500; 139 + 140 + mutex.lockUncancelable(io); 141 + defer mutex.unlock(io); 142 + 143 + buffers[@intFromEnum(route)][@intFromEnum(method_key)].record(elapsed_us, failed); 144 + total_requests += 1; 145 + if (failed) total_failures += 1; 146 + 147 + if (elapsed_us >= 250 * std.time.us_per_ms or failed) { 148 + var slow: SlowRequest = .{ 149 + .route = route, 150 + .method = method_key, 151 + .class = class, 152 + .at_s = timestampSeconds(io), 153 + .elapsed_us = elapsed_us, 154 + .status = status, 155 + .handler_failed = handler_failed, 156 + }; 157 + slow.label_len = @min(label.len, LABEL_LEN); 158 + @memcpy(slow.label[0..slow.label_len], label[0..slow.label_len]); 159 + slow_requests[slow_next] = slow; 160 + slow_next = (slow_next + 1) % SLOW_COUNT; 161 + if (slow_len < SLOW_COUNT) slow_len += 1; 162 + } 163 + } 164 + 165 + pub fn snapshot() Snapshot { 166 + const io = global_io orelse return emptySnapshot(); 167 + mutex.lockUncancelable(io); 168 + defer mutex.unlock(io); 169 + 170 + var routes: [ROUTE_COUNT][METHOD_COUNT]EndpointStats = undefined; 171 + for (&routes, 0..) |*method_stats, route_idx| { 172 + for (method_stats, 0..) |*stats, method_idx| { 173 + stats.* = summarize(@enumFromInt(route_idx), @enumFromInt(method_idx), buffers[route_idx][method_idx]); 174 + } 175 + } 176 + 177 + var slow: [SLOW_COUNT]SlowRequest = [_]SlowRequest{.{}} ** SLOW_COUNT; 178 + for (0..slow_len) |i| { 179 + const idx = (slow_next + SLOW_COUNT - slow_len + i) % SLOW_COUNT; 180 + slow[i] = slow_requests[idx]; 181 + } 182 + 183 + const now_s = timestampSeconds(io); 184 + return .{ 185 + .started_at_s = started_at_s, 186 + .uptime_s = @intCast(@max(0, now_s - started_at_s)), 187 + .total_requests = total_requests, 188 + .total_failures = total_failures, 189 + .routes = routes, 190 + .slow_requests = slow, 191 + .slow_len = slow_len, 192 + }; 193 + } 194 + 195 + fn emptySnapshot() Snapshot { 196 + var routes: [ROUTE_COUNT][METHOD_COUNT]EndpointStats = undefined; 197 + for (&routes, 0..) |*method_stats, route_idx| { 198 + for (method_stats, 0..) |*stats, method_idx| { 199 + stats.* = .{ 200 + .route = @enumFromInt(route_idx), 201 + .method = @enumFromInt(method_idx), 202 + .count = 0, 203 + .failure_count = 0, 204 + .avg_ms = 0, 205 + .p50_ms = 0, 206 + .p95_ms = 0, 207 + .max_ms = 0, 208 + }; 209 + } 210 + } 211 + return .{ 212 + .started_at_s = 0, 213 + .uptime_s = 0, 214 + .total_requests = 0, 215 + .total_failures = 0, 216 + .routes = routes, 217 + .slow_requests = [_]SlowRequest{.{}} ** SLOW_COUNT, 218 + .slow_len = 0, 219 + }; 220 + } 221 + 222 + fn summarize(route: Route, method: Method, buffer: LatencyBuffer) EndpointStats { 223 + if (buffer.count == 0) { 224 + return .{ 225 + .route = route, 226 + .method = method, 227 + .count = 0, 228 + .failure_count = buffer.failure_count, 229 + .avg_ms = 0, 230 + .p50_ms = 0, 231 + .p95_ms = 0, 232 + .max_ms = 0, 233 + }; 234 + } 235 + 236 + var sorted: [SAMPLE_COUNT]u32 = undefined; 237 + @memcpy(sorted[0..buffer.count], buffer.samples[0..buffer.count]); 238 + std.mem.sort(u32, sorted[0..buffer.count], {}, std.sort.asc(u32)); 239 + 240 + var sum: u64 = 0; 241 + for (sorted[0..buffer.count]) |value| sum += value; 242 + const p95_idx = @min(buffer.count - 1, (buffer.count * 95) / 100); 243 + 244 + return .{ 245 + .route = route, 246 + .method = method, 247 + .count = buffer.total_count, 248 + .failure_count = buffer.failure_count, 249 + .avg_ms = @as(f64, @floatFromInt(sum)) / @as(f64, @floatFromInt(buffer.count)) / 1000.0, 250 + .p50_ms = @as(f64, @floatFromInt(sorted[buffer.count / 2])) / 1000.0, 251 + .p95_ms = @as(f64, @floatFromInt(sorted[p95_idx])) / 1000.0, 252 + .max_ms = @as(f64, @floatFromInt(sorted[buffer.count - 1])) / 1000.0, 253 + }; 254 + } 255 + 256 + fn timestampSeconds(io: Io) i64 { 257 + return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s)); 258 + }
+1
src/root.zig
··· 35 35 pub const api_reference = @import("internal/api_reference/root.zig"); 36 36 pub const cli = @import("internal/cli.zig"); 37 37 pub const passkeys = @import("internal/passkeys.zig"); 38 + pub const permissioned_data = @import("internal/permissioned_data.zig"); 38 39 pub const scopes = @import("internal/scopes.zig"); 39 40 pub const sharded_locks = @import("internal/sharded_locks.zig"); 40 41 };
+1643 -4
src/storage/store.zig
··· 4 4 const blobstore = @import("blobstore.zig"); 5 5 const cbor_json = @import("../internal/cbor_json.zig"); 6 6 const eventlog = @import("eventlog.zig"); 7 + const permissioned = @import("../internal/permissioned_data.zig"); 7 8 const sharded_locks = @import("../internal/sharded_locks.zig"); 8 9 const zat = @import("zat"); 9 10 const zqlite = @import("zqlite"); ··· 87 88 refresh_token: []const u8, 88 89 expires_at: i64, 89 90 revoked: bool, 91 + }; 92 + 93 + pub const SessionInfo = struct { 94 + id: []const u8, 95 + did: []const u8, 96 + handle: []const u8, 97 + auth_method: []const u8, 98 + app_password_name: ?[]const u8, 99 + controller_did: ?[]const u8, 100 + created_at: i64, 101 + updated_at: i64, 102 + last_used_at: ?i64, 103 + access_expires_at: i64, 104 + refresh_expires_at: i64, 105 + revoked_at: ?i64, 106 + active: bool, 107 + }; 108 + 109 + pub const OAuthGrantInfo = struct { 110 + did: []const u8, 111 + handle: []const u8, 112 + client_id: []const u8, 113 + scope: []const u8, 114 + created_at: i64, 115 + expires_at: i64, 116 + revoked_at: ?i64, 117 + active: bool, 90 118 }; 91 119 92 120 pub const SessionTokenRow = struct { ··· 221 249 pub const WriteResult = struct { 222 250 commit: CommitInfo, 223 251 records: []Record, 252 + }; 253 + 254 + pub const SpaceConfig = struct { 255 + uri: []const u8, 256 + owner_did: []const u8, 257 + space_type: []const u8, 258 + skey: []const u8, 259 + managing_app: ?[]const u8, 260 + is_public: bool, 261 + app_access_mode: []const u8, 262 + app_exceptions_json: []const u8, 263 + is_owner: bool, 264 + is_member: bool, 265 + deleted_at: ?i64, 266 + members: []const []const u8, 267 + }; 268 + 269 + pub const SpaceRecord = struct { 270 + space: []const u8, 271 + repo_did: []const u8, 272 + collection: []const u8, 273 + rkey: []const u8, 274 + cid: []const u8, 275 + value_json: []const u8, 276 + validation_status: []const u8, 277 + repo_rev: []const u8, 278 + updated_at: i64, 279 + 280 + pub fn uri(self: SpaceRecord, allocator: std.mem.Allocator) ![]const u8 { 281 + return std.fmt.allocPrint(allocator, "{s}/{s}/{s}/{s}", .{ self.space, self.repo_did, self.collection, self.rkey }); 282 + } 283 + }; 284 + 285 + pub const SpaceRecordRef = struct { 286 + collection: []const u8, 287 + rkey: []const u8, 288 + cid: []const u8, 289 + }; 290 + 291 + pub const SpaceRecordOplogEntry = struct { 292 + rev: []const u8, 293 + idx: i64, 294 + action: []const u8, 295 + repo_did: []const u8, 296 + collection: []const u8, 297 + rkey: []const u8, 298 + cid: ?[]const u8, 299 + prev: ?[]const u8, 300 + }; 301 + 302 + pub const SpaceMemberOplogEntry = struct { 303 + rev: []const u8, 304 + idx: i64, 305 + action: []const u8, 306 + did: []const u8, 307 + }; 308 + 309 + pub const SpaceState = struct { 310 + set_hash: ?[]const u8, 311 + rev: ?[]const u8, 312 + }; 313 + 314 + pub const CredentialRecipient = struct { 315 + service_did: []const u8, 316 + service_endpoint: []const u8, 317 + last_issued_at: i64, 318 + }; 319 + 320 + pub const PreparedRecord = struct { 321 + cid: []const u8, 322 + value_json: []const u8, 323 + validation_status: []const u8, 324 + }; 325 + 326 + pub const SpaceWriteOp = union(enum) { 327 + create: struct { collection: []const u8, rkey: []const u8, prepared: PreparedRecord }, 328 + put: struct { collection: []const u8, rkey: []const u8, prepared: PreparedRecord }, 329 + update: struct { collection: []const u8, rkey: []const u8, prepared: PreparedRecord }, 330 + delete: struct { collection: []const u8, rkey: []const u8 }, 331 + }; 332 + 333 + pub const SpaceWriteResult = union(enum) { 334 + create: SpaceRecord, 335 + update: SpaceRecord, 336 + delete: void, 224 337 }; 225 338 226 339 pub const WriteProfile = struct { ··· 950 1063 , .{ token, token }); 951 1064 } 952 1065 1066 + pub fn listOAuthGrantsForAccount(allocator: std.mem.Allocator, did: []const u8, active_only: bool, limit: i64) ![]OAuthGrantInfo { 1067 + db_mutex.lockUncancelable(store_io); 1068 + defer db_mutex.unlock(store_io); 1069 + try requireInitialized(); 1070 + const sql = if (active_only) 1071 + \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, 1072 + \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active 1073 + \\FROM oauth_tokens t 1074 + \\JOIN accounts a ON a.did = t.did 1075 + \\WHERE t.did = ? AND t.revoked_at IS NULL AND t.expires_at > unixepoch() 1076 + \\ORDER BY t.created_at DESC 1077 + \\LIMIT ? 1078 + else 1079 + \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, 1080 + \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active 1081 + \\FROM oauth_tokens t 1082 + \\JOIN accounts a ON a.did = t.did 1083 + \\WHERE t.did = ? 1084 + \\ORDER BY t.created_at DESC 1085 + \\LIMIT ? 1086 + ; 1087 + var rows = try conn.rows(sql, .{ did, limit }); 1088 + defer rows.deinit(); 1089 + return try oauthGrantsFromRows(allocator, &rows); 1090 + } 1091 + 1092 + pub fn listOAuthGrantsForAllAccounts(allocator: std.mem.Allocator, active_only: bool, limit: i64) ![]OAuthGrantInfo { 1093 + db_mutex.lockUncancelable(store_io); 1094 + defer db_mutex.unlock(store_io); 1095 + try requireInitialized(); 1096 + const sql = if (active_only) 1097 + \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, 1098 + \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active 1099 + \\FROM oauth_tokens t 1100 + \\JOIN accounts a ON a.did = t.did 1101 + \\WHERE t.revoked_at IS NULL AND t.expires_at > unixepoch() 1102 + \\ORDER BY t.created_at DESC 1103 + \\LIMIT ? 1104 + else 1105 + \\SELECT t.did, a.handle, t.client_id, t.scope, t.created_at, t.expires_at, t.revoked_at, 1106 + \\ CASE WHEN t.revoked_at IS NULL AND t.expires_at > unixepoch() THEN 1 ELSE 0 END AS active 1107 + \\FROM oauth_tokens t 1108 + \\JOIN accounts a ON a.did = t.did 1109 + \\ORDER BY t.created_at DESC 1110 + \\LIMIT ? 1111 + ; 1112 + var rows = try conn.rows(sql, .{limit}); 1113 + defer rows.deinit(); 1114 + return try oauthGrantsFromRows(allocator, &rows); 1115 + } 1116 + 953 1117 pub fn createSessionTokenRow( 954 1118 allocator: std.mem.Allocator, 955 1119 did: []const u8, ··· 974 1138 return id; 975 1139 } 976 1140 1141 + pub fn listSessionsForAccount(allocator: std.mem.Allocator, did: []const u8, active_only: bool, limit: i64) ![]SessionInfo { 1142 + db_mutex.lockUncancelable(store_io); 1143 + defer db_mutex.unlock(store_io); 1144 + try requireInitialized(); 1145 + const sql = if (active_only) 1146 + \\SELECT s.id, s.did, a.handle, s.auth_method, s.app_password_name, s.controller_did, 1147 + \\ s.created_at, s.updated_at, s.last_used_at, s.access_expires_at, s.refresh_expires_at, s.revoked_at, 1148 + \\ 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 1149 + \\FROM session_tokens s 1150 + \\JOIN accounts a ON a.did = s.did 1151 + \\WHERE s.did = ? AND s.revoked_at IS NULL AND (s.access_expires_at > unixepoch() OR s.refresh_expires_at > unixepoch()) 1152 + \\ORDER BY COALESCE(s.last_used_at, s.updated_at, s.created_at) DESC 1153 + \\LIMIT ? 1154 + else 1155 + \\SELECT s.id, s.did, a.handle, s.auth_method, s.app_password_name, s.controller_did, 1156 + \\ s.created_at, s.updated_at, s.last_used_at, s.access_expires_at, s.refresh_expires_at, s.revoked_at, 1157 + \\ 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 1158 + \\FROM session_tokens s 1159 + \\JOIN accounts a ON a.did = s.did 1160 + \\WHERE s.did = ? 1161 + \\ORDER BY COALESCE(s.last_used_at, s.updated_at, s.created_at) DESC 1162 + \\LIMIT ? 1163 + ; 1164 + var rows = try conn.rows(sql, .{ did, limit }); 1165 + defer rows.deinit(); 1166 + return try sessionsFromRows(allocator, &rows); 1167 + } 1168 + 1169 + pub fn listSessionsForAllAccounts(allocator: std.mem.Allocator, active_only: bool, limit: i64) ![]SessionInfo { 1170 + db_mutex.lockUncancelable(store_io); 1171 + defer db_mutex.unlock(store_io); 1172 + try requireInitialized(); 1173 + const sql = if (active_only) 1174 + \\SELECT s.id, s.did, a.handle, s.auth_method, s.app_password_name, s.controller_did, 1175 + \\ s.created_at, s.updated_at, s.last_used_at, s.access_expires_at, s.refresh_expires_at, s.revoked_at, 1176 + \\ 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 1177 + \\FROM session_tokens s 1178 + \\JOIN accounts a ON a.did = s.did 1179 + \\WHERE s.revoked_at IS NULL AND (s.access_expires_at > unixepoch() OR s.refresh_expires_at > unixepoch()) 1180 + \\ORDER BY COALESCE(s.last_used_at, s.updated_at, s.created_at) DESC 1181 + \\LIMIT ? 1182 + else 1183 + \\SELECT s.id, s.did, a.handle, s.auth_method, s.app_password_name, s.controller_did, 1184 + \\ s.created_at, s.updated_at, s.last_used_at, s.access_expires_at, s.refresh_expires_at, s.revoked_at, 1185 + \\ 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 1186 + \\FROM session_tokens s 1187 + \\JOIN accounts a ON a.did = s.did 1188 + \\ORDER BY COALESCE(s.last_used_at, s.updated_at, s.created_at) DESC 1189 + \\LIMIT ? 1190 + ; 1191 + var rows = try conn.rows(sql, .{limit}); 1192 + defer rows.deinit(); 1193 + return try sessionsFromRows(allocator, &rows); 1194 + } 1195 + 977 1196 pub fn createAppPassword( 978 1197 allocator: std.mem.Allocator, 979 1198 did: []const u8, ··· 1955 2174 }; 1956 2175 } 1957 2176 2177 + pub fn getPublicBlob( 2178 + allocator: std.mem.Allocator, 2179 + did: []const u8, 2180 + cid: []const u8, 2181 + ) ?BlobRecord { 2182 + db_mutex.lockUncancelable(store_io); 2183 + defer db_mutex.unlock(store_io); 2184 + requireInitialized() catch return null; 2185 + const row = conn.row( 2186 + \\SELECT b.mime_type, b.size 2187 + \\FROM blobs b 2188 + \\WHERE b.did = ? AND b.cid = ? 2189 + \\ AND EXISTS ( 2190 + \\ SELECT 1 2191 + \\ FROM expected_blobs eb 2192 + \\ JOIN records r ON r.uri = eb.record_uri 2193 + \\ WHERE r.did = b.did AND eb.blob_cid = b.cid 2194 + \\ ) 2195 + , .{ did, cid }) catch return null; 2196 + const found = row orelse return null; 2197 + defer found.deinit(); 2198 + const size: usize = @intCast(found.int(1)); 2199 + return .{ 2200 + .mime_type = allocator.dupe(u8, found.text(0)) catch return null, 2201 + .data = blobstore.get(allocator, did, cid, size + 1) catch return null, 2202 + }; 2203 + } 2204 + 1958 2205 pub fn writeBlobListJson(allocator: std.mem.Allocator, did: []const u8, limit: usize) ![]const u8 { 1959 2206 db_mutex.lockUncancelable(store_io); 1960 2207 defer db_mutex.unlock(store_io); 1961 2208 try requireInitialized(); 1962 2209 1963 2210 var rows = try conn.rows( 1964 - \\SELECT cid 1965 - \\FROM blobs 1966 - \\WHERE did = ? 1967 - \\ORDER BY created_at ASC, cid ASC 2211 + \\SELECT DISTINCT b.cid 2212 + \\FROM blobs b 2213 + \\JOIN expected_blobs eb ON eb.blob_cid = b.cid 2214 + \\JOIN records r ON r.uri = eb.record_uri AND r.did = b.did 2215 + \\WHERE b.did = ? 2216 + \\ORDER BY b.cid ASC 1968 2217 \\LIMIT ? 1969 2218 , .{ did, @as(i64, @intCast(if (limit == 0) 500 else limit)) }); 1970 2219 defer rows.deinit(); ··· 2343 2592 return out.toOwnedSlice(); 2344 2593 } 2345 2594 2595 + pub fn generateRkey(allocator: std.mem.Allocator) ![]const u8 { 2596 + return nextRkey(allocator); 2597 + } 2598 + 2599 + pub fn prepareRecordValue( 2600 + allocator: std.mem.Allocator, 2601 + collection: []const u8, 2602 + rkey: []const u8, 2603 + value: std.json.Value, 2604 + ) !PreparedRecord { 2605 + if (zat.Nsid.parse(collection) == null) return Error.InvalidCollection; 2606 + if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey; 2607 + try validateRecordForWrite(collection, rkey, value); 2608 + 2609 + const cbor_value = try jsonToDagCbor(allocator, value); 2610 + const record_bytes = try zat.cbor.encodeAlloc(allocator, cbor_value); 2611 + const record_cid = try zat.cbor.Cid.forDagCbor(allocator, record_bytes); 2612 + return .{ 2613 + .cid = try cidText(allocator, record_cid.raw), 2614 + .value_json = try stringifyValue(allocator, value), 2615 + .validation_status = validationStatusForRecord(collection), 2616 + }; 2617 + } 2618 + 2619 + pub const CreateSpaceInput = struct { 2620 + actor_did: []const u8, 2621 + owner_did: []const u8, 2622 + space_type: []const u8, 2623 + skey: []const u8, 2624 + is_owner: bool, 2625 + managing_app: ?[]const u8, 2626 + is_public: bool, 2627 + app_access_mode: []const u8, 2628 + app_exceptions_json: []const u8, 2629 + }; 2630 + 2631 + pub fn createSpace(allocator: std.mem.Allocator, input: CreateSpaceInput) !SpaceConfig { 2632 + if (zat.Did.parse(input.actor_did) == null) return Error.InvalidRepoPath; 2633 + if (zat.Did.parse(input.owner_did) == null) return Error.InvalidRepoPath; 2634 + if (zat.Nsid.parse(input.space_type) == null) return Error.InvalidCollection; 2635 + if (zat.Rkey.parse(input.skey) == null) return Error.InvalidRecordKey; 2636 + if (!std.mem.eql(u8, input.app_access_mode, "allow") and !std.mem.eql(u8, input.app_access_mode, "deny")) { 2637 + return Error.InvalidRecordType; 2638 + } 2639 + 2640 + const uri = try std.fmt.allocPrint(allocator, "ats://{s}/{s}/{s}", .{ input.owner_did, input.space_type, input.skey }); 2641 + 2642 + db_mutex.lockUncancelable(store_io); 2643 + defer db_mutex.unlock(store_io); 2644 + try requireInitialized(); 2645 + 2646 + if (try getSpaceLocked(allocator, input.actor_did, uri) != null) return Error.InvalidRefreshSession; 2647 + 2648 + try conn.exclusiveTransaction(); 2649 + errdefer conn.rollback(); 2650 + try conn.exec( 2651 + \\INSERT INTO permissioned_spaces ( 2652 + \\ uri, owner_did, space_type, skey, managing_app, is_public, app_access_mode, app_exceptions_json 2653 + \\) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 2654 + \\ON CONFLICT(uri) DO NOTHING 2655 + , .{ 2656 + uri, 2657 + input.owner_did, 2658 + input.space_type, 2659 + input.skey, 2660 + input.managing_app, 2661 + @as(i64, if (input.is_public) 1 else 0), 2662 + input.app_access_mode, 2663 + input.app_exceptions_json, 2664 + }); 2665 + if (input.is_owner) { 2666 + try conn.exec( 2667 + \\UPDATE permissioned_spaces 2668 + \\SET managing_app = ?, 2669 + \\ is_public = ?, 2670 + \\ app_access_mode = ?, 2671 + \\ app_exceptions_json = ? 2672 + \\WHERE uri = ? 2673 + , .{ 2674 + input.managing_app, 2675 + @as(i64, if (input.is_public) 1 else 0), 2676 + input.app_access_mode, 2677 + input.app_exceptions_json, 2678 + uri, 2679 + }); 2680 + } 2681 + try conn.exec( 2682 + \\INSERT INTO permissioned_space_repos (space, repo_did, set_hash, rev) 2683 + \\VALUES (?, ?, NULL, NULL) 2684 + \\ON CONFLICT(space, repo_did) DO NOTHING 2685 + , .{ uri, input.owner_did }); 2686 + try conn.exec( 2687 + \\INSERT INTO permissioned_space_member_state (space, set_hash, rev) 2688 + \\VALUES (?, NULL, NULL) 2689 + \\ON CONFLICT(space) DO NOTHING 2690 + , .{uri}); 2691 + try conn.exec( 2692 + \\INSERT INTO permissioned_space_actor_state (space, actor_did, is_owner, is_member) 2693 + \\VALUES (?, ?, ?, 0) 2694 + , .{ uri, input.actor_did, @as(i64, if (input.is_owner) 1 else 0) }); 2695 + if (input.is_owner) { 2696 + _ = try addSpaceMemberLocked(allocator, uri, input.owner_did); 2697 + try conn.exec("UPDATE permissioned_space_actor_state SET is_member = 1 WHERE space = ? AND actor_did = ?", .{ uri, input.actor_did }); 2698 + } 2699 + try conn.commit(); 2700 + 2701 + return (try getSpaceLocked(allocator, input.actor_did, uri)) orelse Error.RepoNotFound; 2702 + } 2703 + 2704 + pub fn getSpace(allocator: std.mem.Allocator, actor_did: []const u8, uri: []const u8) !?SpaceConfig { 2705 + db_mutex.lockUncancelable(store_io); 2706 + defer db_mutex.unlock(store_io); 2707 + try requireInitialized(); 2708 + return getSpaceLocked(allocator, actor_did, uri); 2709 + } 2710 + 2711 + pub fn listSpaces( 2712 + allocator: std.mem.Allocator, 2713 + actor_did: []const u8, 2714 + maybe_did: ?[]const u8, 2715 + maybe_type: ?[]const u8, 2716 + maybe_cursor: ?[]const u8, 2717 + limit: usize, 2718 + ) ![]SpaceConfig { 2719 + db_mutex.lockUncancelable(store_io); 2720 + defer db_mutex.unlock(store_io); 2721 + try requireInitialized(); 2722 + 2723 + const capped_limit: i64 = @intCast(@min(if (limit == 0) 50 else limit, 100)); 2724 + var rows = if (maybe_did) |did| 2725 + if (maybe_type) |space_type| 2726 + if (maybe_cursor) |cursor| 2727 + try conn.rows( 2728 + \\SELECT s.uri 2729 + \\FROM permissioned_spaces s 2730 + \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2731 + \\WHERE a.actor_did = ? AND s.owner_did = ? AND s.space_type = ? AND s.uri > ? AND (a.is_owner = 1 OR a.is_member = 1) 2732 + \\ORDER BY s.uri ASC 2733 + \\LIMIT ? 2734 + , .{ actor_did, did, space_type, cursor, capped_limit }) 2735 + else 2736 + try conn.rows( 2737 + \\SELECT s.uri 2738 + \\FROM permissioned_spaces s 2739 + \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2740 + \\WHERE a.actor_did = ? AND s.owner_did = ? AND s.space_type = ? AND (a.is_owner = 1 OR a.is_member = 1) 2741 + \\ORDER BY s.uri ASC 2742 + \\LIMIT ? 2743 + , .{ actor_did, did, space_type, capped_limit }) 2744 + else if (maybe_cursor) |cursor| 2745 + try conn.rows( 2746 + \\SELECT s.uri 2747 + \\FROM permissioned_spaces s 2748 + \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2749 + \\WHERE a.actor_did = ? AND s.owner_did = ? AND s.uri > ? AND (a.is_owner = 1 OR a.is_member = 1) 2750 + \\ORDER BY s.uri ASC 2751 + \\LIMIT ? 2752 + , .{ actor_did, did, cursor, capped_limit }) 2753 + else 2754 + try conn.rows( 2755 + \\SELECT s.uri 2756 + \\FROM permissioned_spaces s 2757 + \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2758 + \\WHERE a.actor_did = ? AND s.owner_did = ? AND (a.is_owner = 1 OR a.is_member = 1) 2759 + \\ORDER BY s.uri ASC 2760 + \\LIMIT ? 2761 + , .{ actor_did, did, capped_limit }) 2762 + else if (maybe_type) |space_type| 2763 + if (maybe_cursor) |cursor| 2764 + try conn.rows( 2765 + \\SELECT s.uri 2766 + \\FROM permissioned_spaces s 2767 + \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2768 + \\WHERE a.actor_did = ? AND s.space_type = ? AND s.uri > ? AND (a.is_owner = 1 OR a.is_member = 1) 2769 + \\ORDER BY s.uri ASC 2770 + \\LIMIT ? 2771 + , .{ actor_did, space_type, cursor, capped_limit }) 2772 + else 2773 + try conn.rows( 2774 + \\SELECT s.uri 2775 + \\FROM permissioned_spaces s 2776 + \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2777 + \\WHERE a.actor_did = ? AND s.space_type = ? AND (a.is_owner = 1 OR a.is_member = 1) 2778 + \\ORDER BY s.uri ASC 2779 + \\LIMIT ? 2780 + , .{ actor_did, space_type, capped_limit }) 2781 + else if (maybe_cursor) |cursor| 2782 + try conn.rows( 2783 + \\SELECT s.uri 2784 + \\FROM permissioned_spaces s 2785 + \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2786 + \\WHERE a.actor_did = ? AND s.uri > ? AND (a.is_owner = 1 OR a.is_member = 1) 2787 + \\ORDER BY s.uri ASC 2788 + \\LIMIT ? 2789 + , .{ actor_did, cursor, capped_limit }) 2790 + else 2791 + try conn.rows( 2792 + \\SELECT s.uri 2793 + \\FROM permissioned_spaces s 2794 + \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2795 + \\WHERE a.actor_did = ? AND (a.is_owner = 1 OR a.is_member = 1) 2796 + \\ORDER BY s.uri ASC 2797 + \\LIMIT ? 2798 + , .{ actor_did, capped_limit }); 2799 + defer rows.deinit(); 2800 + 2801 + var spaces: std.ArrayList(SpaceConfig) = .empty; 2802 + while (rows.next()) |row| { 2803 + if (try getSpaceLocked(allocator, actor_did, row.text(0))) |space| { 2804 + try spaces.append(allocator, space); 2805 + } 2806 + } 2807 + if (rows.err) |err| return err; 2808 + return spaces.toOwnedSlice(allocator); 2809 + } 2810 + 2811 + pub fn updateSpaceConfig( 2812 + space: []const u8, 2813 + managing_app: ?[]const u8, 2814 + clear_managing_app: bool, 2815 + maybe_is_public: ?bool, 2816 + maybe_app_access_mode: ?[]const u8, 2817 + maybe_app_exceptions_json: ?[]const u8, 2818 + ) !void { 2819 + if (maybe_app_access_mode) |mode| { 2820 + if (!std.mem.eql(u8, mode, "allow") and !std.mem.eql(u8, mode, "deny")) return Error.InvalidRecordType; 2821 + } 2822 + db_mutex.lockUncancelable(store_io); 2823 + defer db_mutex.unlock(store_io); 2824 + try requireInitialized(); 2825 + _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; 2826 + if (managing_app) |value| { 2827 + try conn.exec("UPDATE permissioned_spaces SET managing_app = ? WHERE uri = ?", .{ value, space }); 2828 + } else if (clear_managing_app) { 2829 + try conn.exec("UPDATE permissioned_spaces SET managing_app = NULL WHERE uri = ?", .{space}); 2830 + } 2831 + if (maybe_is_public) |is_public| { 2832 + try conn.exec("UPDATE permissioned_spaces SET is_public = ? WHERE uri = ?", .{ @as(i64, if (is_public) 1 else 0), space }); 2833 + } 2834 + if (maybe_app_access_mode) |mode| { 2835 + try conn.exec("UPDATE permissioned_spaces SET app_access_mode = ? WHERE uri = ?", .{ mode, space }); 2836 + } 2837 + if (maybe_app_exceptions_json) |exceptions| { 2838 + try conn.exec("UPDATE permissioned_spaces SET app_exceptions_json = ? WHERE uri = ?", .{ exceptions, space }); 2839 + } 2840 + } 2841 + 2842 + pub fn markSpaceDeleted(actor_did: []const u8, space: []const u8) !void { 2843 + db_mutex.lockUncancelable(store_io); 2844 + defer db_mutex.unlock(store_io); 2845 + try requireInitialized(); 2846 + try conn.exec("UPDATE permissioned_space_actor_state SET deleted_at = unixepoch() WHERE space = ? AND actor_did = ?", .{ space, actor_did }); 2847 + } 2848 + 2849 + pub fn purgeOwnerSpaceData(space: []const u8) !void { 2850 + db_mutex.lockUncancelable(store_io); 2851 + defer db_mutex.unlock(store_io); 2852 + try requireInitialized(); 2853 + try conn.exclusiveTransaction(); 2854 + errdefer conn.rollback(); 2855 + try conn.exec("DELETE FROM permissioned_space_members WHERE space = ?", .{space}); 2856 + try conn.exec("DELETE FROM permissioned_space_member_state WHERE space = ?", .{space}); 2857 + try conn.exec("DELETE FROM permissioned_space_member_oplog WHERE space = ?", .{space}); 2858 + try conn.exec("DELETE FROM permissioned_space_credential_recipients WHERE space = ?", .{space}); 2859 + try conn.commit(); 2860 + } 2861 + 2862 + pub fn setSpaceMembership(allocator: std.mem.Allocator, space: []const u8, member_did: []const u8, is_member: bool) !void { 2863 + if (zat.Did.parse(member_did) == null) return Error.InvalidRepoPath; 2864 + db_mutex.lockUncancelable(store_io); 2865 + defer db_mutex.unlock(store_io); 2866 + try requireInitialized(); 2867 + if (try getSpaceConfigLocked(allocator, space) == null) { 2868 + const parts = parseSpaceParts(space) orelse return Error.InvalidRepoPath; 2869 + try conn.exec( 2870 + \\INSERT INTO permissioned_spaces ( 2871 + \\ uri, owner_did, space_type, skey, managing_app, is_public, app_access_mode, app_exceptions_json 2872 + \\) VALUES (?, ?, ?, ?, NULL, 0, 'allow', '[]') 2873 + \\ON CONFLICT(uri) DO NOTHING 2874 + , .{ space, parts.owner_did, parts.space_type, parts.skey }); 2875 + } 2876 + try conn.exec( 2877 + \\INSERT INTO permissioned_space_actor_state (space, actor_did, is_owner, is_member) 2878 + \\VALUES (?, ?, 0, ?) 2879 + \\ON CONFLICT(space, actor_did) DO UPDATE SET 2880 + \\ is_member = excluded.is_member 2881 + , .{ space, member_did, @as(i64, if (is_member) 1 else 0) }); 2882 + if (is_member) { 2883 + try conn.exec( 2884 + \\INSERT INTO permissioned_space_repos (space, repo_did, set_hash, rev) 2885 + \\VALUES (?, ?, NULL, NULL) 2886 + \\ON CONFLICT(space, repo_did) DO NOTHING 2887 + , .{ space, member_did }); 2888 + } 2889 + } 2890 + 2891 + pub fn spaceHasMember(allocator: std.mem.Allocator, space: []const u8, did: []const u8) !bool { 2892 + _ = allocator; 2893 + db_mutex.lockUncancelable(store_io); 2894 + defer db_mutex.unlock(store_io); 2895 + try requireInitialized(); 2896 + return spaceHasMemberLocked(space, did); 2897 + } 2898 + 2899 + pub fn listSpaceMembers(allocator: std.mem.Allocator, space: []const u8, maybe_cursor: ?[]const u8, limit: usize) ![]const []const u8 { 2900 + db_mutex.lockUncancelable(store_io); 2901 + defer db_mutex.unlock(store_io); 2902 + try requireInitialized(); 2903 + return listSpaceMembersPageLocked(allocator, space, maybe_cursor, limit); 2904 + } 2905 + 2906 + pub fn recordCredentialRecipient(space: []const u8, service_did: []const u8, service_endpoint: []const u8) !void { 2907 + db_mutex.lockUncancelable(store_io); 2908 + defer db_mutex.unlock(store_io); 2909 + try requireInitialized(); 2910 + try conn.exec( 2911 + \\INSERT INTO permissioned_space_credential_recipients (space, service_did, service_endpoint, last_issued_at) 2912 + \\VALUES (?, ?, ?, unixepoch()) 2913 + \\ON CONFLICT(space, service_did) DO UPDATE SET 2914 + \\ service_endpoint = excluded.service_endpoint, 2915 + \\ last_issued_at = excluded.last_issued_at 2916 + , .{ space, service_did, service_endpoint }); 2917 + } 2918 + 2919 + pub fn listCredentialRecipients(allocator: std.mem.Allocator, space: []const u8) ![]CredentialRecipient { 2920 + db_mutex.lockUncancelable(store_io); 2921 + defer db_mutex.unlock(store_io); 2922 + try requireInitialized(); 2923 + var rows = try conn.rows( 2924 + \\SELECT service_did, service_endpoint, last_issued_at 2925 + \\FROM permissioned_space_credential_recipients 2926 + \\WHERE space = ? 2927 + \\ORDER BY service_did ASC 2928 + , .{space}); 2929 + defer rows.deinit(); 2930 + var out: std.ArrayList(CredentialRecipient) = .empty; 2931 + while (rows.next()) |row| { 2932 + try out.append(allocator, .{ 2933 + .service_did = try allocator.dupe(u8, row.text(0)), 2934 + .service_endpoint = try allocator.dupe(u8, row.text(1)), 2935 + .last_issued_at = row.int(2), 2936 + }); 2937 + } 2938 + if (rows.err) |err| return err; 2939 + return out.toOwnedSlice(allocator); 2940 + } 2941 + 2942 + pub fn putSpaceRecord( 2943 + allocator: std.mem.Allocator, 2944 + space: []const u8, 2945 + repo_did: []const u8, 2946 + collection: []const u8, 2947 + rkey: []const u8, 2948 + prepared: PreparedRecord, 2949 + ) !SpaceRecord { 2950 + const results = try applySpaceWrites(allocator, space, repo_did, &.{.{ .put = .{ 2951 + .collection = collection, 2952 + .rkey = rkey, 2953 + .prepared = prepared, 2954 + } }}); 2955 + return switch (results[0]) { 2956 + .create => |record| record, 2957 + .update => |record| record, 2958 + .delete => Error.MissingRecord, 2959 + }; 2960 + } 2961 + 2962 + pub fn createSpaceRecord( 2963 + allocator: std.mem.Allocator, 2964 + space: []const u8, 2965 + repo_did: []const u8, 2966 + collection: []const u8, 2967 + rkey: []const u8, 2968 + prepared: PreparedRecord, 2969 + ) !SpaceRecord { 2970 + const results = try applySpaceWrites(allocator, space, repo_did, &.{.{ .create = .{ 2971 + .collection = collection, 2972 + .rkey = rkey, 2973 + .prepared = prepared, 2974 + } }}); 2975 + return switch (results[0]) { 2976 + .create => |record| record, 2977 + .update => |record| record, 2978 + .delete => Error.MissingRecord, 2979 + }; 2980 + } 2981 + 2982 + pub fn deleteSpaceRecord( 2983 + allocator: std.mem.Allocator, 2984 + space: []const u8, 2985 + repo_did: []const u8, 2986 + collection: []const u8, 2987 + rkey: []const u8, 2988 + ) !void { 2989 + _ = try applySpaceWrites(allocator, space, repo_did, &.{.{ .delete = .{ .collection = collection, .rkey = rkey } }}); 2990 + } 2991 + 2992 + pub fn getSpaceRecord( 2993 + allocator: std.mem.Allocator, 2994 + space: []const u8, 2995 + repo_did: []const u8, 2996 + collection: []const u8, 2997 + rkey: []const u8, 2998 + ) !?SpaceRecord { 2999 + db_mutex.lockUncancelable(store_io); 3000 + defer db_mutex.unlock(store_io); 3001 + try requireInitialized(); 3002 + return getSpaceRecordLocked(allocator, space, repo_did, collection, rkey); 3003 + } 3004 + 3005 + pub fn listSpaceRecords( 3006 + allocator: std.mem.Allocator, 3007 + space: []const u8, 3008 + repo_did: []const u8, 3009 + maybe_collection: ?[]const u8, 3010 + maybe_cursor: ?[]const u8, 3011 + reverse: bool, 3012 + limit: usize, 3013 + ) ![]SpaceRecordRef { 3014 + db_mutex.lockUncancelable(store_io); 3015 + defer db_mutex.unlock(store_io); 3016 + try requireInitialized(); 3017 + 3018 + const capped_limit: i64 = @intCast(@min(if (limit == 0) 50 else limit, 100)); 3019 + const op = if (reverse) ">" else "<"; 3020 + const direction = if (reverse) "ASC" else "DESC"; 3021 + const cursor = maybe_cursor orelse ""; 3022 + const parsed_cursor = parseSpaceRecordCursor(cursor); 3023 + const cursor_collection = parsed_cursor.collection; 3024 + const cursor_rkey = parsed_cursor.rkey; 3025 + 3026 + var query = std.Io.Writer.Allocating.init(allocator); 3027 + defer query.deinit(); 3028 + try query.writer.print( 3029 + "SELECT collection, rkey, cid FROM permissioned_space_records WHERE space = ? AND repo_did = ?", 3030 + .{}, 3031 + ); 3032 + if (maybe_collection != null) try query.writer.writeAll(" AND collection = ?"); 3033 + if (maybe_cursor != null and cursor_collection.len > 0 and cursor_rkey.len > 0) { 3034 + try query.writer.print(" AND (collection, rkey) {s} (?, ?)", .{op}); 3035 + } 3036 + try query.writer.print(" ORDER BY collection {s}, rkey {s} LIMIT ?", .{ direction, direction }); 3037 + 3038 + var rows = if (maybe_collection) |collection| 3039 + if (maybe_cursor != null and cursor_collection.len > 0 and cursor_rkey.len > 0) 3040 + try conn.rows(query.written(), .{ space, repo_did, collection, cursor_collection, cursor_rkey, capped_limit }) 3041 + else 3042 + try conn.rows(query.written(), .{ space, repo_did, collection, capped_limit }) 3043 + else if (maybe_cursor != null and cursor_collection.len > 0 and cursor_rkey.len > 0) 3044 + try conn.rows(query.written(), .{ space, repo_did, cursor_collection, cursor_rkey, capped_limit }) 3045 + else 3046 + try conn.rows(query.written(), .{ space, repo_did, capped_limit }); 3047 + defer rows.deinit(); 3048 + 3049 + var records: std.ArrayList(SpaceRecordRef) = .empty; 3050 + while (rows.next()) |row| { 3051 + try records.append(allocator, .{ 3052 + .collection = try allocator.dupe(u8, row.text(0)), 3053 + .rkey = try allocator.dupe(u8, row.text(1)), 3054 + .cid = try allocator.dupe(u8, row.text(2)), 3055 + }); 3056 + } 3057 + if (rows.err) |err| return err; 3058 + return records.toOwnedSlice(allocator); 3059 + } 3060 + 3061 + pub fn applySpaceWrites( 3062 + allocator: std.mem.Allocator, 3063 + space: []const u8, 3064 + repo_did: []const u8, 3065 + ops: []const SpaceWriteOp, 3066 + ) ![]SpaceWriteResult { 3067 + if (zat.Did.parse(repo_did) == null) return Error.InvalidRepoPath; 3068 + for (ops) |op| switch (op) { 3069 + inline .create, .put, .update => |write| { 3070 + if (zat.Nsid.parse(write.collection) == null) return Error.InvalidCollection; 3071 + if (zat.Rkey.parse(write.rkey) == null) return Error.InvalidRecordKey; 3072 + }, 3073 + .delete => |write| { 3074 + if (zat.Nsid.parse(write.collection) == null) return Error.InvalidCollection; 3075 + if (zat.Rkey.parse(write.rkey) == null) return Error.InvalidRecordKey; 3076 + }, 3077 + }; 3078 + 3079 + db_mutex.lockUncancelable(store_io); 3080 + defer db_mutex.unlock(store_io); 3081 + try requireInitialized(); 3082 + _ = (try getSpaceConfigLocked(allocator, space)) orelse return Error.RepoNotFound; 3083 + if (!try actorIsSpaceParticipantLocked(space, repo_did)) return Error.InvalidRefreshSession; 3084 + 3085 + try conn.exclusiveTransaction(); 3086 + errdefer conn.rollback(); 3087 + 3088 + const state = try getRepoStateLocked(allocator, space, repo_did); 3089 + var set_hash = if (state.set_hash) |bytes| try permissioned.LtHash.fromBytes(bytes) else permissioned.LtHash{}; 3090 + const rev = try nextRkeyLocked(allocator); 3091 + 3092 + var results: std.ArrayList(SpaceWriteResult) = .empty; 3093 + var idx: i64 = 0; 3094 + for (ops) |op| { 3095 + switch (op) { 3096 + .create => |write| { 3097 + if (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey) != null) return Error.InvalidRefreshSession; 3098 + try addRecordElement(allocator, &set_hash, write.collection, write.rkey, write.prepared.cid); 3099 + try upsertSpaceRecordLocked(space, repo_did, write.collection, write.rkey, write.prepared, rev); 3100 + try insertRecordOplogLocked(space, repo_did, rev, idx, "create", write.collection, write.rkey, write.prepared.cid, null); 3101 + try results.append(allocator, .{ .create = (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey)) orelse return Error.MissingRecord }); 3102 + }, 3103 + .put => |write| { 3104 + const existing = try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey); 3105 + if (existing) |old| try removeRecordElement(allocator, &set_hash, old.collection, old.rkey, old.cid); 3106 + try addRecordElement(allocator, &set_hash, write.collection, write.rkey, write.prepared.cid); 3107 + try upsertSpaceRecordLocked(space, repo_did, write.collection, write.rkey, write.prepared, rev); 3108 + const action = if (existing == null) "create" else "update"; 3109 + try insertRecordOplogLocked(space, repo_did, rev, idx, action, write.collection, write.rkey, write.prepared.cid, if (existing) |old| old.cid else null); 3110 + try results.append(allocator, .{ .update = (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey)) orelse return Error.MissingRecord }); 3111 + }, 3112 + .update => |write| { 3113 + const existing = (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey)) orelse return Error.MissingRecord; 3114 + try removeRecordElement(allocator, &set_hash, existing.collection, existing.rkey, existing.cid); 3115 + try addRecordElement(allocator, &set_hash, write.collection, write.rkey, write.prepared.cid); 3116 + try upsertSpaceRecordLocked(space, repo_did, write.collection, write.rkey, write.prepared, rev); 3117 + try insertRecordOplogLocked(space, repo_did, rev, idx, "update", write.collection, write.rkey, write.prepared.cid, existing.cid); 3118 + try results.append(allocator, .{ .update = (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey)) orelse return Error.MissingRecord }); 3119 + }, 3120 + .delete => |write| { 3121 + const existing = (try getSpaceRecordLocked(allocator, space, repo_did, write.collection, write.rkey)) orelse return Error.MissingRecord; 3122 + try removeRecordElement(allocator, &set_hash, existing.collection, existing.rkey, existing.cid); 3123 + try conn.exec( 3124 + \\DELETE FROM permissioned_space_records 3125 + \\WHERE space = ? AND repo_did = ? AND collection = ? AND rkey = ? 3126 + , .{ space, repo_did, write.collection, write.rkey }); 3127 + try insertRecordOplogLocked(space, repo_did, rev, idx, "delete", write.collection, write.rkey, null, existing.cid); 3128 + try results.append(allocator, .{ .delete = {} }); 3129 + }, 3130 + } 3131 + idx += 1; 3132 + } 3133 + 3134 + try conn.exec( 3135 + \\INSERT INTO permissioned_space_repos (space, repo_did, set_hash, rev) 3136 + \\VALUES (?, ?, ?, ?) 3137 + \\ON CONFLICT(space, repo_did) DO UPDATE SET 3138 + \\ set_hash = excluded.set_hash, 3139 + \\ rev = excluded.rev 3140 + , .{ space, repo_did, zqlite.blob(&set_hash.bytes), rev }); 3141 + try conn.commit(); 3142 + return results.toOwnedSlice(allocator); 3143 + } 3144 + 3145 + pub fn addSpaceMember(allocator: std.mem.Allocator, space: []const u8, did: []const u8) ![]const u8 { 3146 + db_mutex.lockUncancelable(store_io); 3147 + defer db_mutex.unlock(store_io); 3148 + try requireInitialized(); 3149 + try conn.exclusiveTransaction(); 3150 + errdefer conn.rollback(); 3151 + const rev = try addSpaceMemberLocked(allocator, space, did); 3152 + try conn.commit(); 3153 + return rev; 3154 + } 3155 + 3156 + pub fn removeSpaceMember(allocator: std.mem.Allocator, space: []const u8, did: []const u8) ![]const u8 { 3157 + db_mutex.lockUncancelable(store_io); 3158 + defer db_mutex.unlock(store_io); 3159 + try requireInitialized(); 3160 + try conn.exclusiveTransaction(); 3161 + errdefer conn.rollback(); 3162 + const rev = try removeSpaceMemberLocked(allocator, space, did); 3163 + try conn.commit(); 3164 + return rev; 3165 + } 3166 + 3167 + pub fn getSpaceRepoState(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8) !SpaceState { 3168 + db_mutex.lockUncancelable(store_io); 3169 + defer db_mutex.unlock(store_io); 3170 + try requireInitialized(); 3171 + return getRepoStateLocked(allocator, space, repo_did); 3172 + } 3173 + 3174 + pub fn getSpaceMemberState(allocator: std.mem.Allocator, space: []const u8) !SpaceState { 3175 + db_mutex.lockUncancelable(store_io); 3176 + defer db_mutex.unlock(store_io); 3177 + try requireInitialized(); 3178 + return getMemberStateLocked(allocator, space); 3179 + } 3180 + 3181 + pub fn listSpaceRecordOplog(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, since: ?[]const u8, limit: usize) ![]SpaceRecordOplogEntry { 3182 + db_mutex.lockUncancelable(store_io); 3183 + defer db_mutex.unlock(store_io); 3184 + try requireInitialized(); 3185 + const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); 3186 + var rows = if (since) |rev| 3187 + try conn.rows( 3188 + \\SELECT rev, idx, action, repo_did, collection, rkey, cid, prev 3189 + \\FROM permissioned_space_record_oplog 3190 + \\WHERE space = ? AND repo_did = ? AND rev > ? 3191 + \\ORDER BY rev ASC, idx ASC 3192 + \\LIMIT ? 3193 + , .{ space, repo_did, rev, capped_limit }) 3194 + else 3195 + try conn.rows( 3196 + \\SELECT rev, idx, action, repo_did, collection, rkey, cid, prev 3197 + \\FROM permissioned_space_record_oplog 3198 + \\WHERE space = ? AND repo_did = ? 3199 + \\ORDER BY rev ASC, idx ASC 3200 + \\LIMIT ? 3201 + , .{ space, repo_did, capped_limit }); 3202 + defer rows.deinit(); 3203 + var out: std.ArrayList(SpaceRecordOplogEntry) = .empty; 3204 + while (rows.next()) |row| { 3205 + try out.append(allocator, .{ 3206 + .rev = try allocator.dupe(u8, row.text(0)), 3207 + .idx = row.int(1), 3208 + .action = try allocator.dupe(u8, row.text(2)), 3209 + .repo_did = try allocator.dupe(u8, row.text(3)), 3210 + .collection = try allocator.dupe(u8, row.text(4)), 3211 + .rkey = try allocator.dupe(u8, row.text(5)), 3212 + .cid = if (row.nullableText(6)) |cid| try allocator.dupe(u8, cid) else null, 3213 + .prev = if (row.nullableText(7)) |prev| try allocator.dupe(u8, prev) else null, 3214 + }); 3215 + } 3216 + if (rows.err) |err| return err; 3217 + return out.toOwnedSlice(allocator); 3218 + } 3219 + 3220 + pub fn listSpaceMemberOplog(allocator: std.mem.Allocator, space: []const u8, since: ?[]const u8, limit: usize) ![]SpaceMemberOplogEntry { 3221 + db_mutex.lockUncancelable(store_io); 3222 + defer db_mutex.unlock(store_io); 3223 + try requireInitialized(); 3224 + const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); 3225 + var rows = if (since) |rev| 3226 + try conn.rows( 3227 + \\SELECT rev, idx, action, did 3228 + \\FROM permissioned_space_member_oplog 3229 + \\WHERE space = ? AND rev > ? 3230 + \\ORDER BY rev ASC, idx ASC 3231 + \\LIMIT ? 3232 + , .{ space, rev, capped_limit }) 3233 + else 3234 + try conn.rows( 3235 + \\SELECT rev, idx, action, did 3236 + \\FROM permissioned_space_member_oplog 3237 + \\WHERE space = ? 3238 + \\ORDER BY rev ASC, idx ASC 3239 + \\LIMIT ? 3240 + , .{ space, capped_limit }); 3241 + defer rows.deinit(); 3242 + var out: std.ArrayList(SpaceMemberOplogEntry) = .empty; 3243 + while (rows.next()) |row| { 3244 + try out.append(allocator, .{ 3245 + .rev = try allocator.dupe(u8, row.text(0)), 3246 + .idx = row.int(1), 3247 + .action = try allocator.dupe(u8, row.text(2)), 3248 + .did = try allocator.dupe(u8, row.text(3)), 3249 + }); 3250 + } 3251 + if (rows.err) |err| return err; 3252 + return out.toOwnedSlice(allocator); 3253 + } 3254 + 3255 + fn addSpaceMemberLocked(allocator: std.mem.Allocator, space: []const u8, did: []const u8) ![]const u8 { 3256 + if (zat.Did.parse(did) == null) return Error.InvalidRepoPath; 3257 + _ = (try getSpaceConfigLocked(allocator, space)) orelse return Error.RepoNotFound; 3258 + if (try spaceHasMemberLocked(space, did)) return Error.InvalidRefreshSession; 3259 + const state = try getMemberStateLocked(allocator, space); 3260 + var set_hash = if (state.set_hash) |bytes| try permissioned.LtHash.fromBytes(bytes) else permissioned.LtHash{}; 3261 + set_hash.add(did); 3262 + const rev = try nextRkeyLocked(allocator); 3263 + try conn.exec( 3264 + \\INSERT INTO permissioned_space_members (space, did, member_rev) 3265 + \\VALUES (?, ?, ?) 3266 + , .{ space, did, rev }); 3267 + try conn.exec( 3268 + \\INSERT INTO permissioned_space_member_oplog (space, rev, idx, action, did) 3269 + \\VALUES (?, ?, 0, 'add', ?) 3270 + , .{ space, rev, did }); 3271 + try conn.exec( 3272 + \\INSERT INTO permissioned_space_member_state (space, set_hash, rev) 3273 + \\VALUES (?, ?, ?) 3274 + \\ON CONFLICT(space) DO UPDATE SET 3275 + \\ set_hash = excluded.set_hash, 3276 + \\ rev = excluded.rev 3277 + , .{ space, zqlite.blob(&set_hash.bytes), rev }); 3278 + return rev; 3279 + } 3280 + 3281 + fn removeSpaceMemberLocked(allocator: std.mem.Allocator, space: []const u8, did: []const u8) ![]const u8 { 3282 + if (zat.Did.parse(did) == null) return Error.InvalidRepoPath; 3283 + _ = (try getSpaceConfigLocked(allocator, space)) orelse return Error.RepoNotFound; 3284 + if (!try spaceHasMemberLocked(space, did)) return Error.MissingRecord; 3285 + const state = try getMemberStateLocked(allocator, space); 3286 + var set_hash = if (state.set_hash) |bytes| try permissioned.LtHash.fromBytes(bytes) else permissioned.LtHash{}; 3287 + set_hash.remove(did); 3288 + const rev = try nextRkeyLocked(allocator); 3289 + try conn.exec( 3290 + \\DELETE FROM permissioned_space_members 3291 + \\WHERE space = ? AND did = ? 3292 + , .{ space, did }); 3293 + try conn.exec( 3294 + \\INSERT INTO permissioned_space_member_oplog (space, rev, idx, action, did) 3295 + \\VALUES (?, ?, 0, 'remove', ?) 3296 + , .{ space, rev, did }); 3297 + try conn.exec( 3298 + \\INSERT INTO permissioned_space_member_state (space, set_hash, rev) 3299 + \\VALUES (?, ?, ?) 3300 + \\ON CONFLICT(space) DO UPDATE SET 3301 + \\ set_hash = excluded.set_hash, 3302 + \\ rev = excluded.rev 3303 + , .{ space, zqlite.blob(&set_hash.bytes), rev }); 3304 + return rev; 3305 + } 3306 + 3307 + fn getRepoStateLocked(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8) !SpaceState { 3308 + const row = try conn.row( 3309 + \\SELECT set_hash, rev 3310 + \\FROM permissioned_space_repos 3311 + \\WHERE space = ? AND repo_did = ? 3312 + , .{ space, repo_did }); 3313 + if (row == null) return .{ .set_hash = null, .rev = null }; 3314 + defer row.?.deinit(); 3315 + return .{ 3316 + .set_hash = if (row.?.nullableBlob(0)) |bytes| try allocator.dupe(u8, bytes) else null, 3317 + .rev = if (row.?.nullableText(1)) |rev| try allocator.dupe(u8, rev) else null, 3318 + }; 3319 + } 3320 + 3321 + fn getMemberStateLocked(allocator: std.mem.Allocator, space: []const u8) !SpaceState { 3322 + const row = try conn.row( 3323 + \\SELECT set_hash, rev 3324 + \\FROM permissioned_space_member_state 3325 + \\WHERE space = ? 3326 + , .{space}); 3327 + if (row == null) return .{ .set_hash = null, .rev = null }; 3328 + defer row.?.deinit(); 3329 + return .{ 3330 + .set_hash = if (row.?.nullableBlob(0)) |bytes| try allocator.dupe(u8, bytes) else null, 3331 + .rev = if (row.?.nullableText(1)) |rev| try allocator.dupe(u8, rev) else null, 3332 + }; 3333 + } 3334 + 3335 + fn addRecordElement(allocator: std.mem.Allocator, hash: *permissioned.LtHash, collection: []const u8, rkey: []const u8, cid: []const u8) !void { 3336 + const element = try permissioned.recordElement(allocator, collection, rkey, cid); 3337 + hash.add(element); 3338 + } 3339 + 3340 + fn removeRecordElement(allocator: std.mem.Allocator, hash: *permissioned.LtHash, collection: []const u8, rkey: []const u8, cid: []const u8) !void { 3341 + const element = try permissioned.recordElement(allocator, collection, rkey, cid); 3342 + hash.remove(element); 3343 + } 3344 + 3345 + fn upsertSpaceRecordLocked( 3346 + space: []const u8, 3347 + repo_did: []const u8, 3348 + collection: []const u8, 3349 + rkey: []const u8, 3350 + prepared: PreparedRecord, 3351 + rev: []const u8, 3352 + ) !void { 3353 + try conn.exec( 3354 + \\INSERT INTO permissioned_space_records (space, repo_did, collection, rkey, cid, value_json, validation_status, repo_rev, updated_at) 3355 + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, unixepoch()) 3356 + \\ON CONFLICT(space, repo_did, collection, rkey) DO UPDATE SET 3357 + \\ cid = excluded.cid, 3358 + \\ value_json = excluded.value_json, 3359 + \\ validation_status = excluded.validation_status, 3360 + \\ repo_rev = excluded.repo_rev, 3361 + \\ updated_at = excluded.updated_at 3362 + , .{ space, repo_did, collection, rkey, prepared.cid, prepared.value_json, prepared.validation_status, rev }); 3363 + } 3364 + 3365 + fn insertRecordOplogLocked( 3366 + space: []const u8, 3367 + repo_did: []const u8, 3368 + rev: []const u8, 3369 + idx: i64, 3370 + action: []const u8, 3371 + collection: []const u8, 3372 + rkey: []const u8, 3373 + cid: ?[]const u8, 3374 + prev: ?[]const u8, 3375 + ) !void { 3376 + try conn.exec( 3377 + \\INSERT INTO permissioned_space_record_oplog (space, repo_did, rev, idx, action, collection, rkey, cid, prev) 3378 + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 3379 + , .{ space, repo_did, rev, idx, action, collection, rkey, cid, prev }); 3380 + } 3381 + 3382 + fn getSpaceConfigLocked(allocator: std.mem.Allocator, uri: []const u8) !?SpaceConfig { 3383 + const row = try conn.row( 3384 + \\SELECT uri, owner_did, space_type, skey, managing_app, is_public, app_access_mode, app_exceptions_json 3385 + \\FROM permissioned_spaces 3386 + \\WHERE uri = ? 3387 + , .{uri}); 3388 + if (row == null) return null; 3389 + defer row.?.deinit(); 3390 + 3391 + return .{ 3392 + .uri = try allocator.dupe(u8, row.?.text(0)), 3393 + .owner_did = try allocator.dupe(u8, row.?.text(1)), 3394 + .space_type = try allocator.dupe(u8, row.?.text(2)), 3395 + .skey = try allocator.dupe(u8, row.?.text(3)), 3396 + .managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, 3397 + .is_public = row.?.int(5) != 0, 3398 + .app_access_mode = try allocator.dupe(u8, row.?.text(6)), 3399 + .app_exceptions_json = try allocator.dupe(u8, row.?.text(7)), 3400 + .is_owner = false, 3401 + .is_member = false, 3402 + .deleted_at = null, 3403 + .members = try listSpaceMembersLocked(allocator, uri), 3404 + }; 3405 + } 3406 + 3407 + fn getSpaceLocked(allocator: std.mem.Allocator, actor_did: []const u8, uri: []const u8) !?SpaceConfig { 3408 + const row = try conn.row( 3409 + \\SELECT s.uri, s.owner_did, s.space_type, s.skey, s.managing_app, s.is_public, s.app_access_mode, s.app_exceptions_json, 3410 + \\ a.is_owner, a.is_member, a.deleted_at 3411 + \\FROM permissioned_spaces s 3412 + \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3413 + \\WHERE s.uri = ? AND a.actor_did = ? 3414 + , .{ uri, actor_did }); 3415 + if (row == null) return null; 3416 + defer row.?.deinit(); 3417 + 3418 + return .{ 3419 + .uri = try allocator.dupe(u8, row.?.text(0)), 3420 + .owner_did = try allocator.dupe(u8, row.?.text(1)), 3421 + .space_type = try allocator.dupe(u8, row.?.text(2)), 3422 + .skey = try allocator.dupe(u8, row.?.text(3)), 3423 + .managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, 3424 + .is_public = row.?.int(5) != 0, 3425 + .app_access_mode = try allocator.dupe(u8, row.?.text(6)), 3426 + .app_exceptions_json = try allocator.dupe(u8, row.?.text(7)), 3427 + .is_owner = row.?.int(8) != 0, 3428 + .is_member = row.?.int(9) != 0, 3429 + .deleted_at = if (row.?.nullableInt(10)) |value| value else null, 3430 + .members = try listSpaceMembersLocked(allocator, uri), 3431 + }; 3432 + } 3433 + 3434 + fn listSpaceMembersLocked(allocator: std.mem.Allocator, space: []const u8) ![]const []const u8 { 3435 + return listSpaceMembersPageLocked(allocator, space, null, 10000); 3436 + } 3437 + 3438 + fn listSpaceMembersPageLocked(allocator: std.mem.Allocator, space: []const u8, maybe_cursor: ?[]const u8, limit: usize) ![]const []const u8 { 3439 + const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); 3440 + var rows = if (maybe_cursor) |cursor| 3441 + try conn.rows( 3442 + \\SELECT did 3443 + \\FROM permissioned_space_members 3444 + \\WHERE space = ? AND did > ? 3445 + \\ORDER BY did ASC 3446 + \\LIMIT ? 3447 + , .{ space, cursor, capped_limit }) 3448 + else 3449 + try conn.rows( 3450 + \\SELECT did 3451 + \\FROM permissioned_space_members 3452 + \\WHERE space = ? 3453 + \\ORDER BY did ASC 3454 + \\LIMIT ? 3455 + , .{ space, capped_limit }); 3456 + defer rows.deinit(); 3457 + var members: std.ArrayList([]const u8) = .empty; 3458 + while (rows.next()) |row| try members.append(allocator, try allocator.dupe(u8, row.text(0))); 3459 + if (rows.err) |err| return err; 3460 + return members.toOwnedSlice(allocator); 3461 + } 3462 + 3463 + fn spaceHasMemberLocked(space: []const u8, did: []const u8) !bool { 3464 + const row = try conn.row( 3465 + \\SELECT 1 3466 + \\FROM permissioned_space_members 3467 + \\WHERE space = ? AND did = ? 3468 + \\LIMIT 1 3469 + , .{ space, did }); 3470 + if (row == null) return false; 3471 + defer row.?.deinit(); 3472 + return true; 3473 + } 3474 + 3475 + fn actorIsSpaceParticipantLocked(space: []const u8, actor_did: []const u8) !bool { 3476 + const row = try conn.row( 3477 + \\SELECT 1 3478 + \\FROM permissioned_space_actor_state 3479 + \\WHERE space = ? AND actor_did = ? AND (is_owner = 1 OR is_member = 1) 3480 + \\LIMIT 1 3481 + , .{ space, actor_did }); 3482 + if (row == null) return false; 3483 + defer row.?.deinit(); 3484 + return true; 3485 + } 3486 + 3487 + fn getSpaceRecordLocked( 3488 + allocator: std.mem.Allocator, 3489 + space: []const u8, 3490 + repo_did: []const u8, 3491 + collection: []const u8, 3492 + rkey: []const u8, 3493 + ) !?SpaceRecord { 3494 + const row = try conn.row( 3495 + \\SELECT space, repo_did, collection, rkey, cid, value_json, validation_status, repo_rev, updated_at 3496 + \\FROM permissioned_space_records 3497 + \\WHERE space = ? AND repo_did = ? AND collection = ? AND rkey = ? 3498 + , .{ space, repo_did, collection, rkey }); 3499 + if (row == null) return null; 3500 + defer row.?.deinit(); 3501 + return .{ 3502 + .space = try allocator.dupe(u8, row.?.text(0)), 3503 + .repo_did = try allocator.dupe(u8, row.?.text(1)), 3504 + .collection = try allocator.dupe(u8, row.?.text(2)), 3505 + .rkey = try allocator.dupe(u8, row.?.text(3)), 3506 + .cid = try allocator.dupe(u8, row.?.text(4)), 3507 + .value_json = try allocator.dupe(u8, row.?.text(5)), 3508 + .validation_status = try allocator.dupe(u8, row.?.text(6)), 3509 + .repo_rev = try allocator.dupe(u8, row.?.text(7)), 3510 + .updated_at = row.?.int(8), 3511 + }; 3512 + } 3513 + 3514 + fn parseSpaceRecordCursor(cursor: []const u8) struct { collection: []const u8, rkey: []const u8 } { 3515 + const slash = std.mem.indexOfScalar(u8, cursor, '/') orelse return .{ .collection = "", .rkey = "" }; 3516 + if (slash == 0 or slash + 1 >= cursor.len) return .{ .collection = "", .rkey = "" }; 3517 + return .{ .collection = cursor[0..slash], .rkey = cursor[slash + 1 ..] }; 3518 + } 3519 + 3520 + const SpaceParts = struct { 3521 + owner_did: []const u8, 3522 + space_type: []const u8, 3523 + skey: []const u8, 3524 + }; 3525 + 3526 + fn parseSpaceParts(uri: []const u8) ?SpaceParts { 3527 + const prefix = "ats://"; 3528 + if (!std.mem.startsWith(u8, uri, prefix)) return null; 3529 + const rest = uri[prefix.len..]; 3530 + const first = std.mem.indexOfScalar(u8, rest, '/') orelse return null; 3531 + const owner_did = rest[0..first]; 3532 + if (zat.Did.parse(owner_did) == null) return null; 3533 + const after_did = rest[first + 1 ..]; 3534 + const second = std.mem.indexOfScalar(u8, after_did, '/') orelse return null; 3535 + const space_type = after_did[0..second]; 3536 + if (zat.Nsid.parse(space_type) == null) return null; 3537 + const skey = after_did[second + 1 ..]; 3538 + if (zat.Rkey.parse(skey) == null) return null; 3539 + return .{ .owner_did = owner_did, .space_type = space_type, .skey = skey }; 3540 + } 3541 + 2346 3542 fn migrate() !void { 2347 3543 inline for (schema_statements) |sql| try conn.execNoArgs(sql); 3544 + try migratePermissionedDataTables(); 2348 3545 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN email TEXT") catch {}; 2349 3546 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN email_confirmed_at INTEGER") catch {}; 2350 3547 conn.execNoArgs("ALTER TABLE accounts ADD COLUMN auth_code TEXT") catch {}; ··· 2358 3555 conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN login_hint TEXT") catch {}; 2359 3556 conn.execNoArgs("ALTER TABLE oauth_requests ADD COLUMN response_mode TEXT NOT NULL DEFAULT 'query'") catch {}; 2360 3557 conn.execNoArgs("ALTER TABLE session_tokens ADD COLUMN app_password_name TEXT") catch {}; 3558 + conn.execNoArgs("ALTER TABLE permissioned_space_records ADD COLUMN repo_rev TEXT NOT NULL DEFAULT ''") catch {}; 2361 3559 try migrateBlobTable(); 2362 3560 try migrateAppPreferencesTable(); 2363 3561 inline for (post_schema_statements) |sql| try conn.execNoArgs(sql); ··· 2366 3564 try migrateSeqEventsSync11(); 2367 3565 } 2368 3566 3567 + fn migratePermissionedDataTables() !void { 3568 + try conn.execNoArgs( 3569 + \\CREATE TABLE IF NOT EXISTS zds_migrations ( 3570 + \\ name TEXT PRIMARY KEY, 3571 + \\ applied_at INTEGER NOT NULL DEFAULT (unixepoch()) 3572 + \\) 3573 + ); 3574 + try migratePermissionedSpacesOwnerFk(); 3575 + try migratePermissionedSpaceActorState(); 3576 + try migratePermissionedRecordOplogRepo(); 3577 + } 3578 + 3579 + fn migrationApplied(name: []const u8) !bool { 3580 + const row = try conn.row("SELECT 1 FROM zds_migrations WHERE name = ?", .{name}); 3581 + if (row == null) return false; 3582 + defer row.?.deinit(); 3583 + return true; 3584 + } 3585 + 3586 + fn markMigrationApplied(name: []const u8) !void { 3587 + try conn.exec("INSERT OR IGNORE INTO zds_migrations (name) VALUES (?)", .{name}); 3588 + } 3589 + 3590 + fn migratePermissionedSpacesOwnerFk() !void { 3591 + const name = "permissioned-spaces-owner-fk"; 3592 + if (try migrationApplied(name)) return; 3593 + try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_spaces_next"); 3594 + try conn.execNoArgs( 3595 + \\CREATE TABLE permissioned_spaces_next ( 3596 + \\ uri TEXT PRIMARY KEY, 3597 + \\ owner_did TEXT NOT NULL, 3598 + \\ space_type TEXT NOT NULL, 3599 + \\ skey TEXT NOT NULL, 3600 + \\ managing_app TEXT, 3601 + \\ is_public INTEGER NOT NULL DEFAULT 0, 3602 + \\ app_access_mode TEXT NOT NULL DEFAULT 'allow', 3603 + \\ app_exceptions_json TEXT NOT NULL DEFAULT '[]', 3604 + \\ is_owner INTEGER NOT NULL DEFAULT 1, 3605 + \\ is_member INTEGER NOT NULL DEFAULT 0, 3606 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 3607 + \\ deleted_at INTEGER, 3608 + \\ UNIQUE(owner_did, space_type, skey) 3609 + \\) 3610 + ); 3611 + try conn.execNoArgs( 3612 + \\INSERT INTO permissioned_spaces_next ( 3613 + \\ uri, owner_did, space_type, skey, managing_app, is_public, app_access_mode, 3614 + \\ app_exceptions_json, is_owner, is_member, created_at, deleted_at 3615 + \\) 3616 + \\SELECT uri, owner_did, space_type, skey, managing_app, is_public, app_access_mode, 3617 + \\ app_exceptions_json, is_owner, is_member, created_at, deleted_at 3618 + \\FROM permissioned_spaces 3619 + ); 3620 + try conn.execNoArgs("PRAGMA foreign_keys = OFF"); 3621 + errdefer conn.execNoArgs("PRAGMA foreign_keys = ON") catch {}; 3622 + try conn.execNoArgs("DROP TABLE permissioned_spaces"); 3623 + try conn.execNoArgs("ALTER TABLE permissioned_spaces_next RENAME TO permissioned_spaces"); 3624 + try conn.execNoArgs("PRAGMA foreign_keys = ON"); 3625 + try conn.execNoArgs("CREATE INDEX IF NOT EXISTS permissioned_spaces_owner_idx ON permissioned_spaces (owner_did, space_type, uri)"); 3626 + try markMigrationApplied(name); 3627 + } 3628 + 3629 + fn migratePermissionedSpaceActorState() !void { 3630 + const name = "permissioned-space-actor-state"; 3631 + if (try migrationApplied(name)) return; 3632 + try conn.execNoArgs( 3633 + \\INSERT OR IGNORE INTO permissioned_space_actor_state (space, actor_did, is_owner, is_member, deleted_at) 3634 + \\SELECT s.uri, 3635 + \\ CASE WHEN s.is_owner = 1 THEN s.owner_did ELSE COALESCE(r.repo_did, s.owner_did) END, 3636 + \\ s.is_owner, 3637 + \\ s.is_member, 3638 + \\ s.deleted_at 3639 + \\FROM permissioned_spaces s 3640 + \\LEFT JOIN permissioned_space_repos r ON r.space = s.uri 3641 + \\WHERE s.is_owner = 1 OR s.is_member = 1 3642 + ); 3643 + try markMigrationApplied(name); 3644 + } 3645 + 3646 + fn migratePermissionedRecordOplogRepo() !void { 3647 + const name = "permissioned-record-oplog-repo"; 3648 + if (try migrationApplied(name)) return; 3649 + try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_space_record_oplog_next"); 3650 + try conn.execNoArgs( 3651 + \\CREATE TABLE permissioned_space_record_oplog_next ( 3652 + \\ space TEXT NOT NULL, 3653 + \\ repo_did TEXT NOT NULL, 3654 + \\ rev TEXT NOT NULL, 3655 + \\ idx INTEGER NOT NULL, 3656 + \\ action TEXT NOT NULL, 3657 + \\ collection TEXT NOT NULL, 3658 + \\ rkey TEXT NOT NULL, 3659 + \\ cid TEXT, 3660 + \\ prev TEXT, 3661 + \\ PRIMARY KEY (space, repo_did, rev, idx) 3662 + \\) 3663 + ); 3664 + try conn.execNoArgs( 3665 + \\INSERT INTO permissioned_space_record_oplog_next ( 3666 + \\ space, repo_did, rev, idx, action, collection, rkey, cid, prev 3667 + \\) 3668 + \\SELECT space, '', rev, idx, action, collection, rkey, cid, prev 3669 + \\FROM permissioned_space_record_oplog 3670 + ); 3671 + try conn.execNoArgs("DROP TABLE permissioned_space_record_oplog"); 3672 + try conn.execNoArgs("ALTER TABLE permissioned_space_record_oplog_next RENAME TO permissioned_space_record_oplog"); 3673 + try markMigrationApplied(name); 3674 + } 3675 + 2369 3676 fn migrateSeqEventsSync11() !void { 2370 3677 try conn.execNoArgs( 2371 3678 \\CREATE TABLE IF NOT EXISTS zds_migrations ( ··· 3157 4464 }; 3158 4465 } 3159 4466 4467 + fn sessionsFromRows(allocator: std.mem.Allocator, rows: anytype) ![]SessionInfo { 4468 + var out: std.ArrayList(SessionInfo) = .empty; 4469 + while (rows.next()) |row| { 4470 + try out.append(allocator, .{ 4471 + .id = try allocator.dupe(u8, row.text(0)), 4472 + .did = try allocator.dupe(u8, row.text(1)), 4473 + .handle = try allocator.dupe(u8, row.text(2)), 4474 + .auth_method = try allocator.dupe(u8, row.text(3)), 4475 + .app_password_name = if (row.nullableText(4)) |text| try allocator.dupe(u8, text) else null, 4476 + .controller_did = if (row.nullableText(5)) |text| try allocator.dupe(u8, text) else null, 4477 + .created_at = row.int(6), 4478 + .updated_at = row.int(7), 4479 + .last_used_at = row.nullableInt(8), 4480 + .access_expires_at = row.int(9), 4481 + .refresh_expires_at = row.int(10), 4482 + .revoked_at = row.nullableInt(11), 4483 + .active = row.int(12) != 0, 4484 + }); 4485 + } 4486 + return out.toOwnedSlice(allocator); 4487 + } 4488 + 4489 + fn oauthGrantsFromRows(allocator: std.mem.Allocator, rows: anytype) ![]OAuthGrantInfo { 4490 + var out: std.ArrayList(OAuthGrantInfo) = .empty; 4491 + while (rows.next()) |row| { 4492 + try out.append(allocator, .{ 4493 + .did = try allocator.dupe(u8, row.text(0)), 4494 + .handle = try allocator.dupe(u8, row.text(1)), 4495 + .client_id = try allocator.dupe(u8, row.text(2)), 4496 + .scope = try allocator.dupe(u8, row.text(3)), 4497 + .created_at = row.int(4), 4498 + .expires_at = row.int(5), 4499 + .revoked_at = row.nullableInt(6), 4500 + .active = row.int(7) != 0, 4501 + }); 4502 + } 4503 + return out.toOwnedSlice(allocator); 4504 + } 4505 + 3160 4506 fn stringifyValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 3161 4507 var out: std.Io.Writer.Allocating = .init(allocator); 3162 4508 defer out.deinit(); ··· 3858 5204 , 3859 5205 "CREATE INDEX IF NOT EXISTS reserved_signing_keys_did_idx ON reserved_signing_keys (did) WHERE did IS NOT NULL", 3860 5206 "CREATE INDEX IF NOT EXISTS reserved_signing_keys_expires_idx ON reserved_signing_keys (expires_at) WHERE used_at IS NULL", 5207 + \\CREATE TABLE IF NOT EXISTS permissioned_spaces ( 5208 + \\ uri TEXT PRIMARY KEY, 5209 + \\ owner_did TEXT NOT NULL, 5210 + \\ space_type TEXT NOT NULL, 5211 + \\ skey TEXT NOT NULL, 5212 + \\ managing_app TEXT, 5213 + \\ is_public INTEGER NOT NULL DEFAULT 0, 5214 + \\ app_access_mode TEXT NOT NULL DEFAULT 'allow', 5215 + \\ app_exceptions_json TEXT NOT NULL DEFAULT '[]', 5216 + \\ is_owner INTEGER NOT NULL DEFAULT 1, 5217 + \\ is_member INTEGER NOT NULL DEFAULT 0, 5218 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 5219 + \\ deleted_at INTEGER, 5220 + \\ UNIQUE(owner_did, space_type, skey) 5221 + \\) 5222 + , 5223 + "CREATE INDEX IF NOT EXISTS permissioned_spaces_owner_idx ON permissioned_spaces (owner_did, space_type, uri)", 5224 + \\CREATE TABLE IF NOT EXISTS permissioned_space_actor_state ( 5225 + \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 5226 + \\ actor_did TEXT NOT NULL, 5227 + \\ is_owner INTEGER NOT NULL DEFAULT 0, 5228 + \\ is_member INTEGER NOT NULL DEFAULT 0, 5229 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 5230 + \\ deleted_at INTEGER, 5231 + \\ PRIMARY KEY (space, actor_did) 5232 + \\) 5233 + , 5234 + "CREATE INDEX IF NOT EXISTS permissioned_space_actor_state_actor_idx ON permissioned_space_actor_state (actor_did, space)", 5235 + \\CREATE TABLE IF NOT EXISTS permissioned_space_members ( 5236 + \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 5237 + \\ did TEXT NOT NULL, 5238 + \\ member_rev TEXT NOT NULL DEFAULT '', 5239 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 5240 + \\ PRIMARY KEY (space, did) 5241 + \\) 5242 + , 5243 + \\CREATE TABLE IF NOT EXISTS permissioned_space_records ( 5244 + \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 5245 + \\ repo_did TEXT NOT NULL, 5246 + \\ collection TEXT NOT NULL, 5247 + \\ rkey TEXT NOT NULL, 5248 + \\ cid TEXT NOT NULL, 5249 + \\ value_json BLOB NOT NULL, 5250 + \\ validation_status TEXT NOT NULL DEFAULT 'unknown', 5251 + \\ repo_rev TEXT NOT NULL DEFAULT '', 5252 + \\ updated_at INTEGER NOT NULL DEFAULT (unixepoch()), 5253 + \\ PRIMARY KEY (space, repo_did, collection, rkey) 5254 + \\) 5255 + , 5256 + "CREATE INDEX IF NOT EXISTS permissioned_space_records_list_idx ON permissioned_space_records (space, repo_did, collection, rkey)", 5257 + \\CREATE TABLE IF NOT EXISTS permissioned_space_repos ( 5258 + \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 5259 + \\ repo_did TEXT NOT NULL, 5260 + \\ set_hash BLOB, 5261 + \\ rev TEXT, 5262 + \\ PRIMARY KEY (space, repo_did) 5263 + \\) 5264 + , 5265 + \\CREATE TABLE IF NOT EXISTS permissioned_space_member_state ( 5266 + \\ space TEXT PRIMARY KEY REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 5267 + \\ set_hash BLOB, 5268 + \\ rev TEXT 5269 + \\) 5270 + , 5271 + \\CREATE TABLE IF NOT EXISTS permissioned_space_record_oplog ( 5272 + \\ space TEXT NOT NULL, 5273 + \\ repo_did TEXT NOT NULL, 5274 + \\ rev TEXT NOT NULL, 5275 + \\ idx INTEGER NOT NULL, 5276 + \\ action TEXT NOT NULL, 5277 + \\ collection TEXT NOT NULL, 5278 + \\ rkey TEXT NOT NULL, 5279 + \\ cid TEXT, 5280 + \\ prev TEXT, 5281 + \\ PRIMARY KEY (space, repo_did, rev, idx) 5282 + \\) 5283 + , 5284 + \\CREATE TABLE IF NOT EXISTS permissioned_space_member_oplog ( 5285 + \\ space TEXT NOT NULL, 5286 + \\ rev TEXT NOT NULL, 5287 + \\ idx INTEGER NOT NULL, 5288 + \\ action TEXT NOT NULL, 5289 + \\ did TEXT NOT NULL, 5290 + \\ PRIMARY KEY (space, rev, idx) 5291 + \\) 5292 + , 5293 + \\CREATE TABLE IF NOT EXISTS permissioned_space_credential_recipients ( 5294 + \\ space TEXT NOT NULL, 5295 + \\ service_did TEXT NOT NULL, 5296 + \\ service_endpoint TEXT NOT NULL, 5297 + \\ last_issued_at INTEGER NOT NULL, 5298 + \\ PRIMARY KEY (space, service_did) 5299 + \\) 5300 + , 3861 5301 }; 3862 5302 3863 5303 const post_schema_statements = [_][*:0]const u8{ ··· 3929 5369 .{ account.did, record.cid }, 3930 5370 ); 3931 5371 try std.testing.expect(get(account.did, "app.bsky.feed.post", "3ztest") == null); 5372 + } 5373 + 5374 + test "permissioned spaces store self-owned records outside public repo" { 5375 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 5376 + defer arena.deinit(); 5377 + const allocator = arena.allocator(); 5378 + 5379 + try init(std.Options.debug_io, ":memory:"); 5380 + defer close(); 5381 + 5382 + const account = try createAccount( 5383 + allocator, 5384 + "space.test", 5385 + "space@test.com", 5386 + "password", 5387 + "did:plc:spaceowneralice", 5388 + true, 5389 + ); 5390 + 5391 + const space = try createSpace(allocator, .{ 5392 + .actor_did = account.did, 5393 + .owner_did = account.did, 5394 + .space_type = "fm.plyr.privateMedia", 5395 + .skey = "self", 5396 + .is_owner = true, 5397 + .managing_app = "did:web:plyr.fm", 5398 + .is_public = false, 5399 + .app_access_mode = "allow", 5400 + .app_exceptions_json = "[]", 5401 + }); 5402 + try std.testing.expectEqualStrings("ats://did:plc:spaceowneralice/fm.plyr.privateMedia/self", space.uri); 5403 + try std.testing.expect(space.is_owner); 5404 + try std.testing.expect(space.is_member); 5405 + try std.testing.expectEqual(@as(usize, 1), space.members.len); 5406 + try std.testing.expectEqualStrings(account.did, space.members[0]); 5407 + 5408 + try std.testing.expectError(Error.InvalidRefreshSession, createSpace(allocator, .{ 5409 + .actor_did = account.did, 5410 + .owner_did = account.did, 5411 + .space_type = "fm.plyr.privateMedia", 5412 + .skey = "self", 5413 + .is_owner = true, 5414 + .managing_app = null, 5415 + .is_public = false, 5416 + .app_access_mode = "allow", 5417 + .app_exceptions_json = "[]", 5418 + })); 5419 + 5420 + const parsed = try std.json.parseFromSlice( 5421 + std.json.Value, 5422 + allocator, 5423 + "{\"$type\":\"fm.plyr.track\",\"title\":\"secret track\",\"audioBlob\":{\"$type\":\"blob\",\"ref\":{\"$link\":\"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku\"},\"mimeType\":\"audio/mpeg\",\"size\":12}}", 5424 + .{}, 5425 + ); 5426 + defer parsed.deinit(); 5427 + 5428 + const prepared = try prepareRecordValue(allocator, "fm.plyr.track", "track-one", parsed.value); 5429 + const stored = try putSpaceRecord(allocator, space.uri, account.did, "fm.plyr.track", "track-one", prepared); 5430 + try std.testing.expectEqualStrings("track-one", stored.rkey); 5431 + try std.testing.expectEqualStrings("unknown", stored.validation_status); 5432 + try std.testing.expect(get(account.did, "fm.plyr.track", "track-one") == null); 5433 + 5434 + const found = (try getSpaceRecord(allocator, space.uri, account.did, "fm.plyr.track", "track-one")).?; 5435 + try std.testing.expectEqualStrings(stored.cid, found.cid); 5436 + try std.testing.expect(std.mem.indexOf(u8, found.value_json, "secret track") != null); 5437 + 5438 + const listed = try listSpaceRecords(allocator, space.uri, account.did, "fm.plyr.track", null, false, 50); 5439 + try std.testing.expectEqual(@as(usize, 1), listed.len); 5440 + try std.testing.expectEqualStrings("fm.plyr.track", listed[0].collection); 5441 + try std.testing.expectEqualStrings("track-one", listed[0].rkey); 5442 + } 5443 + 5444 + test "permissioned spaces keep actor-local state per space URI" { 5445 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 5446 + defer arena.deinit(); 5447 + const allocator = arena.allocator(); 5448 + 5449 + try init(std.Options.debug_io, ":memory:"); 5450 + defer close(); 5451 + 5452 + const owner = try createAccount( 5453 + allocator, 5454 + "owner-space.test", 5455 + "owner-space@test.com", 5456 + "password", 5457 + "did:plc:spaceownerlocal", 5458 + true, 5459 + ); 5460 + const member = try createAccount( 5461 + allocator, 5462 + "member-space.test", 5463 + "member-space@test.com", 5464 + "password", 5465 + "did:plc:spacememberlocal", 5466 + true, 5467 + ); 5468 + 5469 + const created = try createSpace(allocator, .{ 5470 + .actor_did = owner.did, 5471 + .owner_did = owner.did, 5472 + .space_type = "fm.plyr.privateMedia", 5473 + .skey = "self", 5474 + .is_owner = true, 5475 + .managing_app = null, 5476 + .is_public = false, 5477 + .app_access_mode = "allow", 5478 + .app_exceptions_json = "[]", 5479 + }); 5480 + 5481 + try setSpaceMembership(allocator, created.uri, member.did, true); 5482 + 5483 + const owner_view = (try getSpace(allocator, owner.did, created.uri)).?; 5484 + try std.testing.expect(owner_view.is_owner); 5485 + try std.testing.expect(owner_view.is_member); 5486 + try std.testing.expect(owner_view.deleted_at == null); 5487 + 5488 + const member_view = (try getSpace(allocator, member.did, created.uri)).?; 5489 + try std.testing.expect(!member_view.is_owner); 5490 + try std.testing.expect(member_view.is_member); 5491 + try std.testing.expect(member_view.deleted_at == null); 5492 + 5493 + const owner_spaces = try listSpaces(allocator, owner.did, owner.did, "fm.plyr.privateMedia", null, 50); 5494 + try std.testing.expectEqual(@as(usize, 1), owner_spaces.len); 5495 + try std.testing.expect(owner_spaces[0].is_owner); 5496 + 5497 + const member_spaces = try listSpaces(allocator, member.did, owner.did, "fm.plyr.privateMedia", null, 50); 5498 + try std.testing.expectEqual(@as(usize, 1), member_spaces.len); 5499 + try std.testing.expect(!member_spaces[0].is_owner); 5500 + try std.testing.expect(member_spaces[0].is_member); 5501 + 5502 + try markSpaceDeleted(member.did, created.uri); 5503 + const deleted_member_view = (try getSpace(allocator, member.did, created.uri)).?; 5504 + try std.testing.expect(deleted_member_view.deleted_at != null); 5505 + const still_active_owner_view = (try getSpace(allocator, owner.did, created.uri)).?; 5506 + try std.testing.expect(still_active_owner_view.deleted_at == null); 5507 + } 5508 + 5509 + test "permissioned space create is duplicate-checked per actor" { 5510 + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 5511 + defer arena.deinit(); 5512 + const allocator = arena.allocator(); 5513 + 5514 + try init(std.Options.debug_io, ":memory:"); 5515 + defer close(); 5516 + 5517 + const owner = try createAccount(allocator, "owner-first.test", "owner-first@test.com", "password", "did:plc:ownerfirst", true); 5518 + const viewer = try createAccount(allocator, "viewer-first.test", "viewer-first@test.com", "password", "did:plc:viewerfirst", true); 5519 + 5520 + const viewer_row = try createSpace(allocator, .{ 5521 + .actor_did = viewer.did, 5522 + .owner_did = owner.did, 5523 + .space_type = "fm.plyr.privateMedia", 5524 + .skey = "self", 5525 + .is_owner = false, 5526 + .managing_app = null, 5527 + .is_public = false, 5528 + .app_access_mode = "allow", 5529 + .app_exceptions_json = "[]", 5530 + }); 5531 + try std.testing.expect(!viewer_row.is_owner); 5532 + try std.testing.expect(!viewer_row.is_member); 5533 + 5534 + const owner_row = try createSpace(allocator, .{ 5535 + .actor_did = owner.did, 5536 + .owner_did = owner.did, 5537 + .space_type = "fm.plyr.privateMedia", 5538 + .skey = "self", 5539 + .is_owner = true, 5540 + .managing_app = "did:web:plyr.fm", 5541 + .is_public = true, 5542 + .app_access_mode = "deny", 5543 + .app_exceptions_json = "[\"did:web:allowed.example\"]", 5544 + }); 5545 + try std.testing.expect(owner_row.is_owner); 5546 + try std.testing.expect(owner_row.is_member); 5547 + try std.testing.expect(owner_row.is_public); 5548 + try std.testing.expectEqualStrings("did:web:plyr.fm", owner_row.managing_app.?); 5549 + 5550 + try std.testing.expectError(Error.InvalidRefreshSession, createSpace(allocator, .{ 5551 + .actor_did = viewer.did, 5552 + .owner_did = owner.did, 5553 + .space_type = "fm.plyr.privateMedia", 5554 + .skey = "self", 5555 + .is_owner = false, 5556 + .managing_app = null, 5557 + .is_public = false, 5558 + .app_access_mode = "allow", 5559 + .app_exceptions_json = "[]", 5560 + })); 3932 5561 } 3933 5562 3934 5563 test "inactive repo import does not publish firehose event" { ··· 4227 5856 try std.testing.expect(try sessionTokenIsActive(account.did, "access-2", "com.atproto.access")); 4228 5857 try std.testing.expect(try sessionTokenIsActive(account.did, "refresh-2", "com.atproto.refresh")); 4229 5858 5859 + const active_sessions = try listSessionsForAccount(allocator, account.did, true, 50); 5860 + try std.testing.expectEqual(@as(usize, 1), active_sessions.len); 5861 + try std.testing.expectEqualStrings(session_id, active_sessions[0].id); 5862 + try std.testing.expect(active_sessions[0].active); 5863 + 4230 5864 try revokeSessionToken(account.did, "refresh-2"); 4231 5865 try std.testing.expect(!try sessionTokenIsActive(account.did, "access-2", "com.atproto.access")); 4232 5866 try std.testing.expect(!try sessionTokenIsActive(account.did, "refresh-2", "com.atproto.refresh")); 5867 + try std.testing.expectEqual(@as(usize, 0), (try listSessionsForAccount(allocator, account.did, true, 50)).len); 5868 + 5869 + const inactive_sessions = try listSessionsForAccount(allocator, account.did, false, 50); 5870 + try std.testing.expectEqual(@as(usize, 1), inactive_sessions.len); 5871 + try std.testing.expect(!inactive_sessions[0].active); 4233 5872 } 4234 5873 4235 5874 test "app passwords are durable matchable and revoke their sessions" {
+123
tools/smoke-permissioned.sh
··· 1 + #!/usr/bin/env sh 2 + set -eu 3 + 4 + root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) 5 + port="${ZDS_PERMISSIONED_SMOKE_PORT:-2586}" 6 + db="${TMPDIR:-/tmp}/zds-permissioned-smoke.sqlite3" 7 + blob_root="${TMPDIR:-/tmp}/zds-permissioned-smoke-blobs" 8 + log="${TMPDIR:-/tmp}/zds-permissioned-smoke.log" 9 + base="http://127.0.0.1:${port}" 10 + space_uri="ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self" 11 + encoded_space=$(printf '%s' "$space_uri" | jq -sRr @uri) 12 + 13 + cleanup() { 14 + if [ -n "${server_pid:-}" ]; then 15 + kill "$server_pid" 2>/dev/null || true 16 + wait "$server_pid" 2>/dev/null || true 17 + fi 18 + } 19 + trap cleanup EXIT INT TERM 20 + 21 + cd "$root" 22 + rm -f "$db" "$db-wal" "$db-shm" "$log" 23 + rm -rf "$blob_root" 24 + mkdir -p "$blob_root" 25 + 26 + zig build 27 + ZDS_PERMISSIONED_DATA=true zig build run -- \ 28 + --host 127.0.0.1 \ 29 + --port "$port" \ 30 + --db "$db" \ 31 + --blobstore-path "$blob_root" \ 32 + --public-url "$base" \ 33 + --server-did did:web:localhost \ 34 + --handle-domains .test \ 35 + --admin-token permissioned-smoke-admin-token \ 36 + >"$log" 2>&1 & 37 + server_pid=$! 38 + 39 + i=0 40 + while [ "$i" -lt 80 ]; do 41 + if curl -fsS "$base/xrpc/_health" >/dev/null 2>&1; then 42 + break 43 + fi 44 + i=$((i + 1)) 45 + sleep 0.1 46 + done 47 + 48 + curl -fsS "$base/xrpc/_health" >/dev/null 49 + sqlite3 "$db" "insert into accounts (did, handle, email, password_hash, activated_at, email_confirmed_at) values ('did:plc:permissionsmoke', 'permissioned-smoke.test', 'permissioned-smoke@test.com', 'password', unixepoch(), unixepoch())" 50 + 51 + session=$(curl -fsS -X POST "$base/xrpc/com.atproto.server.createSession" \ 52 + -H 'content-type: application/json' \ 53 + --data '{"identifier":"permissioned-smoke.test","password":"password"}') 54 + token=$(printf '%s' "$session" | sed -n 's/.*"accessJwt":"\([^"]*\)".*/\1/p') 55 + test -n "$token" 56 + 57 + blob_payload="${TMPDIR:-/tmp}/zds-permissioned-smoke-blob.jpg" 58 + printf '\377\330\377\340zds-permissioned-smoke' > "$blob_payload" 59 + blob=$(curl -fsS -X POST "$base/xrpc/com.atproto.repo.uploadBlob" \ 60 + -H "authorization: Bearer $token" \ 61 + -H 'content-type: image/jpeg' \ 62 + --data-binary "@$blob_payload") 63 + blob_cid=$(printf '%s' "$blob" | sed -n 's/.*"\$link":"\([^"]*\)".*/\1/p') 64 + test -n "$blob_cid" 65 + public_blob_status=$(curl -sS -o /tmp/zds-space-public-blob.bin -w '%{http_code}' \ 66 + "$base/xrpc/com.atproto.sync.getBlob?did=did:plc:permissionsmoke&cid=$blob_cid") 67 + test "$public_blob_status" = "404" 68 + 69 + space_create=$(curl -fsS -X POST "$base/xrpc/com.atproto.space.createSpace" \ 70 + -H "authorization: Bearer $token" \ 71 + -H 'content-type: application/json' \ 72 + --data '{"did":"did:plc:permissionsmoke","type":"fm.plyr.privateMedia","skey":"self","managingApp":"did:web:plyr.fm","isPublic":false,"appAccessMode":"allow","appExceptions":[]}') 73 + printf '%s' "$space_create" | grep -q '"uri":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self"' 74 + ! printf '%s' "$space_create" | grep -q '"config":' 75 + 76 + space_duplicate_status=$(curl -sS -o /tmp/zds-space-duplicate.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.space.createSpace" \ 77 + -H "authorization: Bearer $token" \ 78 + -H 'content-type: application/json' \ 79 + --data '{"did":"did:plc:permissionsmoke","type":"fm.plyr.privateMedia","skey":"self","isPublic":false}') 80 + test "$space_duplicate_status" = "400" 81 + grep -q '"error":"SpaceAlreadyExists"' /tmp/zds-space-duplicate.json 82 + 83 + space_get=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getSpace?space=$encoded_space") 84 + printf '%s' "$space_get" | grep -q '"isOwner":true' 85 + printf '%s' "$space_get" | grep -q '"managingApp":"did:web:plyr.fm"' 86 + ! printf '%s' "$space_get" | grep -q '"members":' 87 + 88 + space_list=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listSpaces?did=did:plc:permissionsmoke&type=fm.plyr.privateMedia") 89 + printf '%s' "$space_list" | grep -q '"uri":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self"' 90 + printf '%s' "$space_list" | grep -q '"isOwner":true' 91 + printf '%s' "$space_list" | grep -q '"cursor":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self"' 92 + ! printf '%s' "$space_list" | grep -q '"isMember":' 93 + 94 + space_members=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getMembers?space=$encoded_space&limit=1") 95 + printf '%s' "$space_members" | grep -q '"did":"did:plc:permissionsmoke"' 96 + printf '%s' "$space_members" | grep -q '"cursor":"did:plc:permissionsmoke"' 97 + 98 + space_record=$(curl -fsS -X POST "$base/xrpc/com.atproto.space.createRecord" \ 99 + -H "authorization: Bearer $token" \ 100 + -H 'content-type: application/json' \ 101 + --data "$(jq -nc --arg space "$space_uri" --arg cid "$blob_cid" '{space:$space,repo:"did:plc:permissionsmoke",collection:"fm.plyr.track",rkey:"track-one",record:{"$type":"fm.plyr.track",title:"private smoke",audioBlob:{"$type":"blob",ref:{"$link":$cid},mimeType:"image/jpeg",size:12}}}')") 102 + printf '%s' "$space_record" | grep -q '"uri":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self/did:plc:permissionsmoke/fm.plyr.track/track-one"' 103 + printf '%s' "$space_record" | grep -q '"cid":"' 104 + ! printf '%s' "$space_record" | grep -q '"validationStatus":' 105 + 106 + space_record_get=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getRecord?space=$encoded_space&repo=did:plc:permissionsmoke&collection=fm.plyr.track&rkey=track-one") 107 + printf '%s' "$space_record_get" | grep -q '"title":"private smoke"' 108 + public_blob_status=$(curl -sS -o /tmp/zds-space-public-blob.bin -w '%{http_code}' \ 109 + "$base/xrpc/com.atproto.sync.getBlob?did=did:plc:permissionsmoke&cid=$blob_cid") 110 + test "$public_blob_status" = "404" 111 + 112 + space_records=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listRecords?space=$encoded_space&repo=did:plc:permissionsmoke&collection=fm.plyr.track&limit=1") 113 + printf '%s' "$space_records" | grep -q '"collection":"fm.plyr.track"' 114 + printf '%s' "$space_records" | grep -q '"cursor":"fm.plyr.track/track-one"' 115 + 116 + space_blob_status=$(curl -sS -o /tmp/zds-space-blob-range.bin -w '%{http_code}' \ 117 + -H "authorization: Bearer $token" \ 118 + -H 'range: bytes=0-2' \ 119 + "$base/xrpc/com.atproto.space.getBlob?space=$encoded_space&repo=did:plc:permissionsmoke&cid=$blob_cid") 120 + test "$space_blob_status" = "206" 121 + test "$(wc -c < /tmp/zds-space-blob-range.bin | tr -d ' ')" = "3" 122 + 123 + echo "zds permissioned smoke ok"
+10
tools/smoke.sh
··· 45 45 done 46 46 47 47 curl -fsS "$base/xrpc/_health" >/dev/null 48 + stats_page=$(curl -fsS "$base/stats") 49 + printf '%s' "$stats_page" | grep -q '<h1>pds health</h1>' 50 + printf '%s' "$stats_page" | grep -q '<h2>writes</h2>' 48 51 curl -fsS "$base/xrpc/com.atproto.server.describeServer" >/dev/null 49 52 sqlite3 "$db" "insert into accounts (did, handle, email, password_hash, activated_at, email_confirmed_at) values ('did:plc:smoketest', 'smoke.test', 'smoke@test.com', 'password', unixepoch(), unixepoch())" 50 53 sqlite3 "$db" "insert into oauth_requests (request_id, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, login_hint, expires_at) values ('smoke-oauth', 'https://client.example/oauth-client.json', 'https://client.example/callback', 'repo:*?action=create blob:*/*', 'state', 'challenge', 'S256', 'smoke.test', unixepoch() + 600)" ··· 145 148 printf '%s' "$blob" | grep -q '"mimeType":"image/jpeg"' 146 149 blob_cid=$(printf '%s' "$blob" | sed -n 's/.*"\$link":"\([^"]*\)".*/\1/p') 147 150 test -n "$blob_cid" 151 + blob_head=$(curl -sSI -o /dev/null -w '%{http_code}' "$base/xrpc/com.atproto.sync.getBlob?did=did:plc:smoketest&cid=$blob_cid") 152 + test "$blob_head" = "404" 153 + blob_ref=$(curl -fsS -X POST "$base/xrpc/com.atproto.repo.createRecord" \ 154 + -H "authorization: Bearer $token" \ 155 + -H 'content-type: application/json' \ 156 + --data "$(jq -nc --arg cid "$blob_cid" '{repo:"did:plc:smoketest",collection:"dev.zds.smoke.blob",record:{"$type":"dev.zds.smoke.blob",blob:{"$type":"blob",ref:{"$link":$cid},mimeType:"image/jpeg",size:13}}}')") 157 + printf '%s' "$blob_ref" | grep -q '"uri":"at://did:plc:smoketest/dev.zds.smoke.blob/' 148 158 blob_head=$(curl -sSI -o /dev/null -w '%{http_code}' "$base/xrpc/com.atproto.sync.getBlob?did=did:plc:smoketest&cid=$blob_cid") 149 159 test "$blob_head" = "200" 150 160