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

Configure Feed

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

Gate permissioned data routes

zzstoatzz (Jun 4, 2026, 12:04 AM -0500) 0aa7fff9 1310548f

+234 -5
+4
docs/operations.md
··· 73 73 including invite-code minting. 74 74 - `ZDS_INVITE_REQUIRED`: set to `true` to require invite codes for account 75 75 creation. `describeServer` reflects this value. 76 + - `ZDS_PERMISSIONED_DATA`: set to `true` to opt into experimental 77 + `com.atproto.space.*` permissioned-data routes. Default: disabled. The 78 + current route slice is gated and non-mutating until space storage is 79 + implemented. 76 80 - Passkeys do not require a separate deployment secret. WebAuthn RP ID is 77 81 derived from the public URL host. 78 82
+121
docs/permissioned-data.md
··· 1 + # permissioned data plan 2 + 3 + Permissioned data support is experimental. ZDS should ship it behind an 4 + operator opt-in flag, off by default, before any `com.atproto.space.*` route is 5 + publicly reachable. 6 + 7 + Planned flag: 8 + 9 + ```sh 10 + ZDS_PERMISSIONED_DATA=true 11 + ``` 12 + 13 + Until that flag is enabled, ZDS should reject permissioned-data routes as 14 + unimplemented and should not create or mutate space tables. 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. 19 + 20 + ## references 21 + 22 + - Discourse: <https://discourse.atprotocol.community/t/permissioned-data-pds-lexicons/879> 23 + - Branch: <https://github.com/bluesky-social/atproto/tree/permissioned-data> 24 + - Lexicons: 25 + <https://github.com/bluesky-social/atproto/tree/permissioned-data/lexicons/com/atproto/space> 26 + - Reference implementation path: 27 + `packages/pds/src/actor-store/space` on the permissioned-data branch 28 + 29 + The Discourse post rates the lexicon shape higher than the implementation. Use 30 + the lexicons as the main contract, and use the branch implementation to 31 + understand storage and flow rather than copying it blindly. 32 + 33 + Feedback should respect the research already behind the sketch and avoid 34 + overfitting to ZDS's idiosyncratic needs. The protocol is trying to cover likely 35 + use cases from large shared spaces down to small contextual ones, while leaving 36 + substantial application-specific authorization and UX in user land. ZDS feedback 37 + should name concrete implementation pressure, but frame it as design input for 38 + that broader scope. 39 + 40 + ## surface 41 + 42 + The branch adds these `com.atproto.space.*` methods: 43 + 44 + - space lifecycle: `createSpace`, `getSpace`, `listSpaces`, 45 + `updateSpaceConfig`, `deleteSpace` 46 + - membership: `addMember`, `removeMember`, `getMembers`, `getMemberState`, 47 + `getMemberOplog`, `getMemberGrant`, `notifyMembership` 48 + - records: `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`, 49 + `getRecord`, `listRecords`, `getBlob`, `getRepoState`, `getRepoOplog`, 50 + `notifyWrite` 51 + - credentials and deletion fanout: `getSpaceCredential`, `notifySpaceDeleted` 52 + 53 + This is not a thin wrapper around public repo writes. It is a separate 54 + space-scoped data model with its own read perimeter, write notifications, 55 + member state, record state, oplogs, and signed set commitments. 56 + 57 + ## storage shape 58 + 59 + The reference branch uses space-scoped tables: 60 + 61 + - `space`: URI, owner/member flags, app access policy, creation/deletion 62 + timestamps 63 + - `space_member`: member DIDs and member revision 64 + - `space_record`: current records by `(space, collection, rkey)` with CID, 65 + DAG-CBOR value, repo revision, and indexed timestamp 66 + - `space_repo`: record set hash and current repo revision 67 + - `space_member_state`: member set hash and current member revision 68 + - `space_record_oplog`: incremental record changes by `(space, rev, idx)` 69 + - `space_member_oplog`: incremental membership changes by `(space, rev, idx)` 70 + - `space_credential_recipient`: registered services to notify for writes 71 + 72 + ZDS currently has one public repo namespace per DID: `records`, `repo_blocks`, 73 + `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. 78 + 79 + ## Zat first 80 + 81 + Before implementing local primitives, check Zat's current public API. Recent 82 + Zat work is relevant: 83 + 84 + - `zat.mst.Mst` has the performance-oriented middle layer ZDS already uses: 85 + lazy CID stubs, cached clean-node CIDs, direct DAG-CBOR node serialization, 86 + borrowed-key insertion, `collectBlocks`, and ordered `walk`. 87 + - `zat.car.streamBlocks` supports zero-allocation CAR iteration for large repo 88 + workflows. 89 + - `zat.cbor` owns DAG-CBOR values, CIDs, encoding, and parsing helpers. 90 + - `zat.Tid`, `zat.Did`, `zat.Nsid`, `zat.Rkey`, and `zat.AtUri` own syntax 91 + parsing and formatting for standard AT Protocol identifiers. 92 + - `zat.Keypair`, `zat.jwt`, `verifyJose`, and strict repo verification helpers 93 + keep the JOSE-vs-repo-signature distinction explicit. 94 + - `zat.oauth` owns OAuth client/primitive helpers, while ZDS owns PDS policy, 95 + stored grants, and route behavior. 96 + 97 + Likely future Zat candidates: 98 + 99 + - `SpaceUri` parsing/formatting 100 + - LtHash and set commitment primitives 101 + - member-grant and space-credential JWT helpers 102 + 103 + Do not edit or patch the sibling `zat` repo from ZDS without stopping and 104 + making the proposed Zat change explicit. 105 + 106 + ## first milestone 107 + 108 + Implement in this order: 109 + 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. 119 + 120 + Each step should keep permissioned data dark unless `ZDS_PERMISSIONED_DATA` is 121 + enabled.
+1
fly.toml
··· 14 14 ZDS_BLOB_UPLOAD_LIMIT = "100000000" 15 15 ZDS_BLOBSTORE_PATH = "/data/blobs" 16 16 ZDS_INVITE_REQUIRED = "true" 17 + ZDS_PERMISSIONED_DATA = "true" 17 18 18 19 [[mounts]] 19 20 source = "zds_data"
+20
src/atproto/space.zig
··· 1 + const config = @import("../core/config.zig"); 2 + const http_api = @import("../http/api.zig"); 3 + 4 + pub fn dispatch(request: *http_api.Request) !void { 5 + if (!config.permissionedData()) { 6 + return http_api.xrpcError( 7 + request, 8 + .not_implemented, 9 + "MethodNotImplemented", 10 + "Permissioned data is experimental and disabled on this PDS", 11 + ); 12 + } 13 + 14 + return http_api.xrpcError( 15 + request, 16 + .not_implemented, 17 + "MethodNotImplemented", 18 + "Permissioned data is enabled but this method is not implemented yet", 19 + ); 20 + }
+9
src/core/config.zig
··· 17 17 var jwt_secret_value: []const u8 = "zds-local-development-jwt-secret-change-me"; 18 18 var admin_token_value: ?[]const u8 = null; 19 19 var invite_required_value: bool = false; 20 + var permissioned_data_value: bool = false; 20 21 var log_level_value: LogLevel = .info; 21 22 22 23 pub const LogLevel = enum(u2) { ··· 91 92 92 93 pub fn inviteRequired() bool { 93 94 return invite_required_value; 95 + } 96 + 97 + pub fn permissionedData() bool { 98 + return permissioned_data_value; 94 99 } 95 100 96 101 pub fn logLevel() LogLevel { ··· 167 172 168 173 pub fn setInviteRequired(value: bool) void { 169 174 invite_required_value = value; 175 + } 176 + 177 + pub fn setPermissionedData(value: bool) void { 178 + permissioned_data_value = value; 170 179 } 171 180 172 181 pub fn setLogLevel(value: LogLevel) void {
+32
src/http/router.zig
··· 75 75 identity_sign_plc_operation, 76 76 identity_submit_plc_operation, 77 77 identity_resolve_handle, 78 + permissioned_data, 78 79 not_found, 79 80 }; 80 81 ··· 186 187 .{ .route = .identity_sign_plc_operation, .method = "POST", .path = "/xrpc/com.atproto.identity.signPlcOperation", .group = "identity", .auth = "bearer", .summary = "Sign a PLC operation." }, 187 188 .{ .route = .identity_submit_plc_operation, .method = "POST", .path = "/xrpc/com.atproto.identity.submitPlcOperation", .group = "identity", .auth = "bearer", .summary = "Submit a PLC operation." }, 188 189 .{ .route = .identity_resolve_handle, .method = "GET", .path = "/xrpc/com.atproto.identity.resolveHandle", .group = "identity", .auth = "public", .summary = "Resolve a handle to a DID.", .params = &.{"handle"} }, 190 + 191 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.createSpace", .group = "space", .auth = "experimental bearer", .summary = "Create a permissioned data space.", .body = &.{ "did", "type", "skey", "managingApp", "isPublic", "appAccessMode", "appExceptions" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 192 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getSpace", .group = "space", .auth = "experimental bearer", .summary = "Read permissioned data space configuration.", .params = &.{"space"}, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 193 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listSpaces", .group = "space", .auth = "experimental bearer", .summary = "List spaces the authenticated user participates in.", .params = &.{ "did", "type", "limit", "cursor" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 194 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.updateSpaceConfig", .group = "space", .auth = "experimental bearer", .summary = "Update permissioned data space configuration.", .body = &.{ "space", "managingApp", "isPublic", "appAccessMode", "appExceptions" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 195 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.deleteSpace", .group = "space", .auth = "experimental bearer", .summary = "Tombstone a permissioned data space.", .body = &.{"space"}, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 196 + 197 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.addMember", .group = "space", .auth = "experimental bearer", .summary = "Add a DID to a permissioned data space member list.", .body = &.{ "space", "did" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 198 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.removeMember", .group = "space", .auth = "experimental bearer", .summary = "Remove a DID from a permissioned data space member list.", .body = &.{ "space", "did" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 199 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getMembers", .group = "space", .auth = "experimental bearer or space credential", .summary = "List members in a permissioned data space.", .params = &.{ "space", "limit", "cursor" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 200 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getMemberState", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read current member-list commitment state for a space.", .params = &.{"space"}, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 201 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getMemberOplog", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read incremental member-list operations for a space.", .params = &.{ "space", "since", "limit" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 202 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getMemberGrant", .group = "space", .auth = "experimental OAuth", .summary = "Create a member grant for exchange with a space owner.", .params = &.{"space"}, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 203 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifyMembership", .group = "space", .auth = "experimental service", .summary = "Notify a member PDS of a space membership change.", .body = &.{ "space", "did", "isMember" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 204 + 205 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.createRecord", .group = "space", .auth = "experimental bearer", .summary = "Create a record inside a permissioned data space.", .body = &.{ "space", "repo", "collection", "rkey", "validate", "record" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 206 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.putRecord", .group = "space", .auth = "experimental bearer", .summary = "Create or update a record inside a permissioned data space.", .body = &.{ "space", "repo", "collection", "rkey", "validate", "record" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 207 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.deleteRecord", .group = "space", .auth = "experimental bearer", .summary = "Delete a record inside a permissioned data space.", .body = &.{ "space", "repo", "collection", "rkey" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 208 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.applyWrites", .group = "space", .auth = "experimental bearer", .summary = "Apply a batch of writes inside a permissioned data space.", .body = &.{ "space", "repo", "validate", "writes" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 209 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getRecord", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read a record from a permissioned data space.", .params = &.{ "space", "repo", "collection", "rkey" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 210 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listRecords", .group = "space", .auth = "experimental bearer or space credential", .summary = "List record keys and CIDs in a permissioned data space.", .params = &.{ "space", "repo", "collection", "limit", "cursor", "reverse" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 211 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getBlob", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read a blob referenced from a permissioned data record.", .params = &.{ "space", "repo", "cid" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 212 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getRepoState", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read current record-set commitment state for a member repo in a space.", .params = &.{ "space", "repo" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 213 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getRepoOplog", .group = "space", .auth = "experimental bearer or space credential", .summary = "Read incremental record operations for a member repo in a space.", .params = &.{ "space", "repo", "since", "limit" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 214 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifyWrite", .group = "space", .auth = "experimental service", .summary = "Notify a space owner or syncing service of a permissioned data write.", .body = &.{ "space", "repo", "rev" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 215 + 216 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.getSpaceCredential", .group = "space", .auth = "experimental member grant", .summary = "Exchange a member grant for a space credential.", .body = &.{ "space", "notifyEndpoint" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 217 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifySpaceDeleted", .group = "space", .auth = "experimental service", .summary = "Notify a member PDS or syncing service that a space was deleted.", .body = &.{"space"}, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 189 218 }; 190 219 191 220 pub fn route(method: httpz.Method, target: []const u8) Route { ··· 271 300 try std.testing.expectEqual(Route.sync_get_latest_commit, route(.GET, "/xrpc/com.atproto.sync.getLatestCommit?did=did%3Aplc%3Aabc")); 272 301 try std.testing.expectEqual(Route.sync_subscribe_repos, route(.GET, "/xrpc/com.atproto.sync.subscribeRepos?cursor=0")); 273 302 try std.testing.expectEqual(Route.sync_request_crawl, route(.POST, "/xrpc/com.atproto.sync.requestCrawl")); 303 + try std.testing.expectEqual(Route.permissioned_data, route(.POST, "/xrpc/com.atproto.space.createSpace")); 304 + try std.testing.expectEqual(Route.permissioned_data, route(.GET, "/xrpc/com.atproto.space.listRecords?space=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fspace%2Fkey")); 274 305 try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/app.bsky.ageassurance.getState?countryCode=US")); 275 306 try std.testing.expectEqual(Route.identity_resolve_handle, route(.GET, "/xrpc/com.atproto.identity.resolveHandle?handle=alice.test")); 276 307 } ··· 278 309 test "does not route wrong methods" { 279 310 try std.testing.expectEqual(Route.not_found, route(.POST, "/xrpc/_health")); 280 311 try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/com.atproto.server.createSession")); 312 + try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/com.atproto.space.createSpace")); 281 313 }
+2
src/http/server.zig
··· 5 5 const atproto_proxy = @import("../atproto/proxy.zig"); 6 6 const atproto_repo = @import("../atproto/repo.zig"); 7 7 const atproto_server = @import("../atproto/server.zig"); 8 + const atproto_space = @import("../atproto/space.zig"); 8 9 const atproto_sync = @import("../atproto/sync.zig"); 9 10 const build_options = @import("build_options"); 10 11 const httpz = @import("httpz"); ··· 120 121 .identity_sign_plc_operation => try atproto_identity.signPlcOperation(request), 121 122 .identity_submit_plc_operation => try atproto_identity.submitPlcOperation(request), 122 123 .identity_resolve_handle => try atproto_identity.resolveHandle(request), 124 + .permissioned_data => try atproto_space.dispatch(request), 123 125 .not_found => try xrpcError(request, .not_found, "UnknownMethod", "Unknown XRPC method"), 124 126 } 125 127 }
+17 -3
src/internal/api_reference/html.zig
··· 63 63 \\ <span><strong>{d}</strong> operations</span> 64 64 \\ <span><strong>{d}</strong> groups</span> 65 65 \\ <span><strong>{d}</strong> xrpc methods</span> 66 + \\ <span><strong>{d}</strong> experimental</span> 67 + \\ <span><strong>{s}</strong> permissioned data</span> 66 68 \\ </div> 67 69 \\ </section> 68 70 \\ <section class="tools" aria-label="filters"> ··· 85 87 endpoints.len, 86 88 model.groupCountTotal(), 87 89 model.xrpcCount(), 90 + model.experimentalCount(), 91 + if (config.permissionedData()) "enabled" else "disabled", 88 92 group_buttons.written(), 89 93 cards.written(), 90 94 script, ··· 125 129 const details_open = if (open) " open" else ""; 126 130 const nsid = endpoint.nsid(); 127 131 const curl = try renderCurl(allocator, endpoint); 128 - const search_text = try std.fmt.allocPrint(allocator, "{s} {s} {s} {s} {s}", .{ endpoint.method, endpoint.path, endpoint.group, endpoint.auth, endpoint.summary }); 132 + const experimental = model.isExperimental(endpoint); 133 + const experimental_badge = if (experimental) "<span class=\"badge experimental\">experimental</span>" else ""; 134 + const search_text = try std.fmt.allocPrint( 135 + allocator, 136 + "{s} {s} {s} {s} {s} {s}", 137 + .{ endpoint.method, endpoint.path, endpoint.group, endpoint.auth, endpoint.summary, if (experimental) "experimental" else "" }, 138 + ); 129 139 try writer.print( 130 140 \\<details class="endpoint" data-group={f} data-method={f} data-search={f}{s}> 131 - \\ <summary><span class="method {s}">{s}</span><code>{s}</code><span class="auth">{s}</span></summary> 141 + \\ <summary><span class="method {s}">{s}</span><code>{s}</code><span class="labels">{s}<span class="auth">{s}</span></span></summary> 132 142 \\ <div class="body"> 133 143 \\ <p>{s}</p> 134 144 , .{ ··· 139 149 method_class, 140 150 try model.escapeHtml(allocator, endpoint.method), 141 151 try model.escapeHtml(allocator, endpoint.path), 152 + experimental_badge, 142 153 try model.escapeHtml(allocator, endpoint.auth), 143 154 try model.escapeHtml(allocator, endpoint.summary), 144 155 }); ··· 147 158 try writer.print("<div class=\"nsid\"><span>NSID</span><code>{s}</code></div>", .{try model.escapeHtml(allocator, id)}); 148 159 } 149 160 try renderMetadata(allocator, writer, endpoint); 161 + if (experimental) { 162 + try writer.writeAll("<p class=\"experimental-copy\">Experimental. Availability is controlled by <code>ZDS_PERMISSIONED_DATA</code>; semantics may change while the protocol is still being shaped.</p>"); 163 + } 150 164 if (endpoint.notes.len > 0) { 151 165 try writer.print("<p class=\"note\">{s}</p>", .{try model.escapeHtml(allocator, endpoint.notes)}); 152 166 } ··· 194 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)} 195 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)} 196 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} 197 - \\.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}.auth{font-size:11px;color:var(--muted);border:1px solid color-mix(in srgb,var(--line) 76%,white 5%);border-radius:999px;padding:4px 7px;white-space:nowrap;text-transform:lowercase}.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}.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) 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} 198 212 \\@media (min-width:680px){.shell{padding-top:24px}nav{gap:8px}} 199 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}} 200 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}}
+14
src/internal/api_reference/model.zig
··· 15 15 "repo", 16 16 "sync", 17 17 "identity", 18 + "space", 18 19 }; 19 20 20 21 pub fn groupCount(group: []const u8) usize { ··· 39 40 if (endpoint.nsid() != null) count += 1; 40 41 } 41 42 return count; 43 + } 44 + 45 + pub fn experimentalCount() usize { 46 + var count: usize = 0; 47 + for (endpoints) |endpoint| { 48 + if (isExperimental(endpoint)) count += 1; 49 + } 50 + return count; 51 + } 52 + 53 + pub fn isExperimental(endpoint: Endpoint) bool { 54 + return std.mem.eql(u8, endpoint.group, "space"); 42 55 } 43 56 44 57 pub fn pathSeenBefore(index: usize) bool { ··· 80 93 test "endpoint inventory covers XRPC paths" { 81 94 try std.testing.expect(endpoints.len > 50); 82 95 try std.testing.expect(xrpcCount() > 30); 96 + try std.testing.expect(experimentalCount() > 0); 83 97 for (endpoints) |endpoint| { 84 98 if (endpoint.nsid()) |id| { 85 99 try std.testing.expect(id.len > 0);
+10 -2
src/internal/api_reference/openapi.zig
··· 34 34 35 35 fn renderOperation(writer: *std.Io.Writer, endpoint: Endpoint) !void { 36 36 try writer.print( 37 - \\{f}:{{"summary":{f},"tags":[{f}],"description":{f},"responses":{{"200":{{"description":"OK"}}}}}} 37 + \\{f}:{{"summary":{f},"tags":[{f}],"description":{f} 38 38 , .{ 39 39 std.json.fmt(model.methodLower(endpoint.method), .{}), 40 40 std.json.fmt(endpoint.summary, .{}), 41 41 std.json.fmt(endpoint.group, .{}), 42 42 std.json.fmt(endpointDescription(endpoint), .{}), 43 43 }); 44 + if (model.isExperimental(endpoint)) { 45 + try writer.writeAll(\\,"x-zds-experimental":true 46 + ); 47 + } 48 + try writer.writeAll(\\,"responses":{"200":{"description":"OK"}}} 49 + ); 44 50 } 45 51 46 52 fn endpointDescription(endpoint: Endpoint) []const u8 { 47 - _ = endpoint; 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."; 55 + } 48 56 return "Generated from the ZDS endpoint inventory. Request and response schemas are intentionally sparse in this MVP."; 49 57 }
+2
src/internal/cli.zig
··· 21 21 jwt_secret: ?[]const u8 = null, 22 22 admin_token: ?[]const u8 = null, 23 23 invite_required: bool = false, 24 + permissioned_data: bool = false, 24 25 log_level: ?[]const u8 = null, 25 26 }; 26 27 ··· 70 71 .jwt_secret = env("ZDS_JWT_SECRET"), 71 72 .admin_token = env("ZDS_ADMIN_TOKEN"), 72 73 .invite_required = envBool("ZDS_INVITE_REQUIRED"), 74 + .permissioned_data = envBool("ZDS_PERMISSIONED_DATA"), 73 75 .log_level = env("ZDS_LOG_LEVEL"), 74 76 }; 75 77 if (envBool("ZDS_DEBUG")) options.log_level = "debug";
+1
src/main.zig
··· 39 39 if (options.jwt_secret) |value| zds.core.config.setJwtSecret(value); 40 40 zds.core.config.setAdminToken(options.admin_token); 41 41 zds.core.config.setInviteRequired(options.invite_required); 42 + zds.core.config.setPermissionedData(options.permissioned_data); 42 43 if (options.log_level) |value| { 43 44 const parsed = zds.core.config.parseLogLevel(value) orelse return error.InvalidLogLevel; 44 45 zds.core.config.setLogLevel(parsed);
+1
src/root.zig
··· 5 5 pub const repo = @import("atproto/repo.zig"); 6 6 pub const proxy = @import("atproto/proxy.zig"); 7 7 pub const server = @import("atproto/server.zig"); 8 + pub const space = @import("atproto/space.zig"); 8 9 pub const sync = @import("atproto/sync.zig"); 9 10 }; 10 11