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

Configure Feed

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

Align permissioned data with simplespace split

zzstoatzz (Jul 1, 2026, 1:30 PM -0500) 039e59e0 47e444b6

+624 -227
+6 -5
README.md
··· 141 141 - `/account` is the resident account hub. It includes passkey and app-password 142 142 management, app access/session views, email/account status controls, and the 143 143 experimental permissioned-space record browser. 144 - - `com.atproto.space.*` permissioned-data routes are an experimental prototype 145 - and operator gated with `ZDS_PERMISSIONED_DATA`. ZDS implements enough storage 146 - and credential substrate for local experiments, but the upstream proposal is 147 - still moving and this surface is not a stable compatibility contract. 144 + - Permissioned-data routes are experimental and operator gated with 145 + `ZDS_PERMISSIONED_DATA`. ZDS keeps protocol routes under 146 + `com.atproto.space.*` and baseline PDS-managed space management under 147 + `com.atproto.simplespace.*`, but the upstream proposal is still moving and 148 + this surface is not a stable compatibility contract. 148 149 149 150 ## references 150 151 151 152 - [Tranquil PDS](https://tangled.org/tranquil.farm/tranquil-pds) 152 153 - [Pegasus](https://tangled.org/futur.blue/pegasus) 153 154 - [Bluesky PDS](https://github.com/bluesky-social/pds) 154 - - [Bluesky atproto PDS package](https://github.com/bluesky-social/atproto/tree/main/packages/pds) 155 + - [haileyok/cocoon](https://github.com/haileyok/cocoon)
+30 -3
bench/main.zig
··· 628 628 629 629 std.debug.print("\n=== zds permissioned-space benchmarks ===\n", .{}); 630 630 (try benchSpaceListSpaces(allocator, state.account, space.uri)).print(); 631 + (try benchSimpleSpaceListMembers(allocator, space.uri)).print(); 631 632 (try benchSpaceWrite(allocator, state.account, space.uri, records)).print(); 632 633 (try benchSpaceGetRecord(allocator, state.account, space.uri, records)).print(); 633 634 (try benchSpaceListRecords(allocator, state.account, space.uri, records)).print(); 635 + (try benchSpaceListRepos(allocator, space.uri)).print(); 634 636 (try benchSpaceOplog(allocator, state.account, space.uri)).print(); 635 637 (try benchSpaceBlob(allocator, state.account, cid)).print(); 636 638 } ··· 647 649 .skey = "self", 648 650 .is_authority = true, 649 651 .managing_app = "https://api-stg.plyr.fm", 650 - .is_public = false, 651 - .app_access_mode = "allow", 652 - .app_exceptions_json = "[]", 652 + .policy = "member-list", 653 + .app_access_json = "{\"type\":\"open\"}", 653 654 }); 654 655 for (0..records) |i| { 655 656 const value = try spaceRecordValue(allocator, i); ··· 701 702 return .{ .name = "space createRecord", .ops = iterations, .elapsed_ns = nowNs() - start }; 702 703 } 703 704 705 + fn benchSimpleSpaceListMembers(allocator: std.mem.Allocator, space: []const u8) !BenchResult { 706 + var arena = std.heap.ArenaAllocator.init(allocator); 707 + defer arena.deinit(); 708 + const iterations: usize = 500; 709 + const start = nowNs(); 710 + for (0..iterations) |_| { 711 + _ = arena.reset(.retain_capacity); 712 + const members = try zds.storage.store.listSimpleSpaceMembers(arena.allocator(), space, null, 50); 713 + if (members.len == 0) return error.MissingRecords; 714 + } 715 + return .{ .name = "simplespace listMembers", .ops = iterations, .elapsed_ns = nowNs() - start }; 716 + } 717 + 704 718 fn benchSpaceGetRecord(allocator: std.mem.Allocator, account: zds.auth.tokens.Account, space: []const u8, records: usize) !BenchResult { 705 719 var arena = std.heap.ArenaAllocator.init(allocator); 706 720 defer arena.deinit(); ··· 727 741 if (records > 0 and found.len == 0) return error.MissingRecords; 728 742 } 729 743 return .{ .name = "space listRecords", .ops = iterations, .elapsed_ns = nowNs() - start }; 744 + } 745 + 746 + fn benchSpaceListRepos(allocator: std.mem.Allocator, space: []const u8) !BenchResult { 747 + var arena = std.heap.ArenaAllocator.init(allocator); 748 + defer arena.deinit(); 749 + const iterations: usize = 500; 750 + const start = nowNs(); 751 + for (0..iterations) |_| { 752 + _ = arena.reset(.retain_capacity); 753 + const repos = try zds.storage.store.listSpaceRepos(arena.allocator(), space, null, 50); 754 + if (repos.len == 0) return error.MissingRecords; 755 + } 756 + return .{ .name = "space listRepos", .ops = iterations, .elapsed_ns = nowNs() - start }; 730 757 } 731 758 732 759 fn benchSpaceOplog(allocator: std.mem.Allocator, account: zds.auth.tokens.Account, space: []const u8) !BenchResult {
+6 -7
docs/operations.md
··· 114 114 - `ZDS_INVITE_REQUIRED`: set to `true` to require invite codes for account 115 115 creation. `describeServer` reflects this value. 116 116 - `ZDS_PERMISSIONED_DATA`: set to `true` to opt into experimental 117 - `com.atproto.space.*` permissioned-data routes. Default: disabled. The 118 - current implementation is a prototype, not a stable protocol contract. It 119 - provides space lifecycle, private records, grants, credentials, writer repo 120 - state/oplogs, ranged blob reads, and notify routes for local experiments. 121 - Reader/group semantics remain application policy, while the newer proposal's 122 - `com.atproto.simplespace.*` baseline is tracked as future alignment work. 123 - Use `just smoke-permissioned` for the experimental smoke lane. 117 + permissioned-data routes. Default: disabled. ZDS exposes protocol reads and 118 + writes under `com.atproto.space.*`, and baseline PDS-managed space management 119 + under `com.atproto.simplespace.*`. The implementation remains experimental: 120 + reader/group semantics should stay in application policy, while `simplespace` 121 + only provides the small required baseline. Use `just smoke-permissioned` for 122 + the experimental smoke lane. 124 123 - Passkeys do not require a separate deployment secret. WebAuthn RP ID is 125 124 derived from the public URL host. 126 125
+8 -3
docs/permissioned-data-proposal-94.md
··· 185 185 It also introduces simple policies such as `public`, `member-list`, and 186 186 `managing-app`, plus a managing-app access check hook. 187 187 188 - This is not the same as reviving the older generic member-list experiment. If 189 - the proposal keeps `simplespace`, ZDS should treat it as a small baseline space 190 - management API while still leaving richer application/group semantics to apps. 188 + This is not the same as reviving the older generic member-list experiment. ZDS 189 + now treats it as a small baseline space management API while still leaving 190 + richer application/group semantics to apps. 191 191 192 192 Some commentary describes the boring access model as a list of DIDs. In ZDS 193 193 terms, do not read that as permission to restore the old protocol member-list 194 194 sync surface. Treat it as space-management policy, especially for 195 195 `com.atproto.simplespace.*` and managing-app credential minting. 196 + 197 + Implementation note: ZDS exposes the proposal namespace directly and does not 198 + carry the old `com.atproto.space.createSpace` / `updateSpaceConfig` / 199 + `deleteSpace` lifecycle surface. HappyView keeps compatibility aliases for its 200 + own older namespace; ZDS intentionally does not. 196 201 197 202 ## useful tests before more surface 198 203
+25 -22
docs/permissioned-data.md
··· 34 34 The upstream design is still moving. The May branch included protocol-level 35 35 member lists. The later discussion moved toward making space credentials the 36 36 protocol substrate and leaving reader/group semantics to applications or 37 - space-host policy. Proposal PR #94 keeps that broad direction, but also sketches 38 - a baseline `com.atproto.simplespace.*` management surface for PDS-managed 39 - spaces. ZDS has not implemented that newer proposal shape yet. 37 + space-host policy. Proposal PR #94 keeps that broad direction and defines a 38 + baseline `com.atproto.simplespace.*` management surface for PDS-managed spaces. 39 + ZDS implements that namespace split without legacy aliases. 40 40 41 41 Feedback should respect the research behind the sketch and avoid overfitting to 42 42 ZDS or plyr.fm. Name concrete implementation pressure, but frame it as input for ··· 50 50 51 51 ## current prototype surface 52 52 53 - ZDS currently keeps a pre-proposal-94 permissioned-data substrate: 53 + ZDS splits protocol data routes from baseline PDS management routes: 54 54 55 - - space lifecycle: `createSpace`, `getSpace`, `listSpaces`, 56 - `updateSpaceConfig`, `deleteSpace` 57 - - records and blobs: `createRecord`, `putRecord`, `deleteRecord`, 58 - `applyWrites`, `getRecord`, `listRecords`, `getBlob` 59 - - writer state: `getRepoState`, `listRepoOps`, `notifyWrite` 60 - - credentials and deletion fanout: `getDelegationToken`, `getSpaceCredential`, 61 - `notifySpaceDeleted` 55 + - `com.atproto.space.*`: `getSpace`, `listSpaces`, `listRepos`, 56 + `getDelegationToken`, `getSpaceCredential`, records, blobs, writer state, 57 + write notifications, and deletion notifications 58 + - `com.atproto.simplespace.*`: `createSpace`, `updateSpace`, `deleteSpace`, 59 + `addMember`, `removeMember`, and `listMembers` 62 60 63 61 ZDS uses the proposal names for the credential and sync-read surface: 64 62 `getDelegationToken` and `listRepoOps`. 65 63 66 - ZDS deliberately does not expose the older protocol member-list routes: 64 + ZDS deliberately does not expose the older protocol member-list routes or the 65 + pre-proposal lifecycle names: 67 66 68 - - `addMember` 69 - - `removeMember` 70 67 - `getMembers` 71 68 - `getMemberState` 72 69 - `getMemberOplog` 73 70 - `notifyMembership` 71 + - `com.atproto.space.createSpace` 72 + - `com.atproto.space.updateSpaceConfig` 73 + - `com.atproto.space.deleteSpace` 74 74 75 75 `getSpace` and `listSpaces` do not return `members` or `isMember`. 76 76 ··· 85 85 portable access state, it should model that state as application records in a 86 86 permissioned space rather than relying on a universal PDS-wide member list. 87 87 88 - The newer proposal draft may still require a small PDS-managed 89 - `com.atproto.simplespace.*` baseline for simple spaces. That is a compatibility 90 - target to evaluate, not permission to rebuild the older generic member-list 91 - experiment. 88 + `simplespace` membership is stored only as baseline space-management policy 89 + state. It is not exposed as a protocol sync surface. For a `member-list` space, 90 + ZDS mints credentials and allows bearer reads/writes only for listed DIDs. For 91 + `public`, any requester can be authorized. `managing-app` is represented in 92 + config, but ZDS currently fails closed until the `checkUserAccess` service-auth 93 + hook is implemented. 92 94 93 - For private spaces, ZDS currently mints space credentials only to the space 94 - authority DID unless the space is public. That is the conservative default until 95 - the protocol settles on an application or space-host policy hook for broader 96 - credential issuance. 95 + `appAccess` follows the proposal shape: `{"type":"open"}` or 96 + `{"type":"allowList","allowed":[...]}`. ZDS can enforce the literal client ID 97 + it sees in a delegation token; client attestation remains an explicit gap. 97 98 98 99 Writer notifications are keyed by writer repo. `notifyWrite` verifies service 99 100 auth from the writer repo to the space DID, then fans out to registered ··· 107 108 - `permissioned_spaces`: canonical authority-space config keyed by space URI 108 109 - `permissioned_space_actor_state`: actor-local authority/deleted state keyed by 109 110 `(space, actor_did)` 111 + - `simplespace_members`: baseline PDS-managed member-list policy state keyed by 112 + `(space, member_did)` 110 113 - `permissioned_space_records`: current records keyed by 111 114 `(space, repo_did, collection, rkey)` with CID, DAG-CBOR value, repo revision, 112 115 and indexed timestamp
+235 -84
src/atproto/space.zig
··· 1 - //! Experimental `com.atproto.space.*` permissioned-data XRPC handlers. 1 + //! Experimental permissioned-data XRPC handlers. 2 2 //! 3 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. 4 + //! the only HTTP entry point for `com.atproto.space.*` and 5 + //! `com.atproto.simplespace.*` behavior while the upstream protocol is still 6 + //! experimental. 6 7 7 8 const std = @import("std"); 8 9 const auth = @import("../auth/tokens.zig"); ··· 29 30 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid XRPC path"); 30 31 }; 31 32 32 - if (std.mem.eql(u8, method, "com.atproto.space.createSpace")) return createSpace(request); 33 33 if (std.mem.eql(u8, method, "com.atproto.space.getSpace")) return getSpace(request); 34 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); 35 + if (std.mem.eql(u8, method, "com.atproto.space.listRepos")) return listRepos(request); 37 36 if (std.mem.eql(u8, method, "com.atproto.space.getDelegationToken")) return getDelegationToken(request); 38 37 if (std.mem.eql(u8, method, "com.atproto.space.createRecord")) return createRecord(request); 39 38 if (std.mem.eql(u8, method, "com.atproto.space.putRecord")) return putRecord(request); ··· 47 46 if (std.mem.eql(u8, method, "com.atproto.space.getSpaceCredential")) return getSpaceCredential(request); 48 47 if (std.mem.eql(u8, method, "com.atproto.space.notifyWrite")) return notifyWrite(request); 49 48 if (std.mem.eql(u8, method, "com.atproto.space.notifySpaceDeleted")) return notifySpaceDeleted(request); 49 + if (std.mem.eql(u8, method, "com.atproto.simplespace.createSpace")) return createSimpleSpace(request); 50 + if (std.mem.eql(u8, method, "com.atproto.simplespace.updateSpace")) return updateSimpleSpace(request); 51 + if (std.mem.eql(u8, method, "com.atproto.simplespace.deleteSpace")) return deleteSimpleSpace(request); 52 + if (std.mem.eql(u8, method, "com.atproto.simplespace.addMember")) return addSimpleSpaceMember(request); 53 + if (std.mem.eql(u8, method, "com.atproto.simplespace.removeMember")) return removeSimpleSpaceMember(request); 54 + if (std.mem.eql(u8, method, "com.atproto.simplespace.listMembers")) return listSimpleSpaceMembers(request); 50 55 51 56 return http_api.xrpcError( 52 57 request, ··· 56 61 ); 57 62 } 58 63 59 - fn createSpace(request: *http_api.Request) !void { 64 + fn createSimpleSpace(request: *http_api.Request) !void { 60 65 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 61 66 defer arena.deinit(); 62 67 const allocator = arena.allocator(); ··· 67 72 else => return err, 68 73 }; 69 74 const input = parsed.value; 70 - const authority = zat.json.getString(input, "authority") orelse { 71 - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing authority"); 72 - }; 75 + const authority = auth_ctx.account.did; 73 76 const space_type = zat.json.getString(input, "type") orelse { 74 77 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing type"); 75 78 }; 76 79 const skey = zat.json.getString(input, "skey") orelse try store.generateRkey(allocator); 77 80 try requireSpaceScope(request, auth_ctx.oauth_scope, space_type, authority, skey, .manage, null); 78 81 const managing_app = zat.json.getString(input, "managingApp"); 79 - const is_public = valueBool(input, "isPublic") orelse false; 80 - const app_access_mode = zat.json.getString(input, "appAccessMode") orelse "allow"; 81 - const app_exceptions_json = appExceptionsJson(allocator, input) catch |err| switch (err) { 82 - error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "appExceptions must be an array"), 82 + const policy = zat.json.getString(input, "policy") orelse "member-list"; 83 + if (!validPolicy(policy)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid policy"); 84 + const app_access_json = appAccessJson(allocator, input) catch |err| switch (err) { 85 + error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid appAccess"), 83 86 else => return err, 84 87 }; 85 88 ··· 88 91 .authority_did = authority, 89 92 .space_type = space_type, 90 93 .skey = skey, 91 - .is_authority = std.mem.eql(u8, authority, auth_ctx.account.did), 94 + .is_authority = true, 92 95 .managing_app = managing_app, 93 - .is_public = is_public, 94 - .app_access_mode = app_access_mode, 95 - .app_exceptions_json = app_exceptions_json, 96 + .policy = policy, 97 + .app_access_json = app_access_json, 96 98 }) catch |err| switch (err) { 97 99 error.InvalidRepoPath => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid authority"), 98 100 error.InvalidCollection => return http_api.xrpcError(request, .bad_request, "InvalidType", "Invalid space type"), 99 101 error.InvalidRecordKey => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid skey"), 100 - error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid appAccessMode"), 102 + error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid simplespace config"), 101 103 error.InvalidRefreshSession => return http_api.xrpcError(request, .bad_request, "SpaceAlreadyExists", "Space already exists"), 102 104 else => return err, 103 105 }; 104 106 105 - const body = try std.fmt.allocPrint(allocator, "{{\"uri\":{f}}}", .{std.json.fmt(space.uri, .{})}); 107 + const body = try std.fmt.allocPrint(allocator, "{{\"uri\":{f},\"config\":{s}}}", .{ std.json.fmt(space.uri, .{}), try spaceConfigJson(allocator, space) }); 106 108 return http_api.json(request, .ok, body); 107 109 } 108 110 ··· 119 121 const parsed = parseSpaceUri(space_uri) orelse { 120 122 return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 121 123 }; 122 - if (!std.mem.eql(u8, parsed.did, auth_ctx.account.did)) { 123 - return http_api.xrpcError(request, .forbidden, "NotSpaceAuthority", "Not the space authority"); 124 - } 125 - try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .manage, null); 124 + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .read, null); 126 125 const space = (try store.getSpace(allocator, auth_ctx.account.did, space_uri)) orelse { 127 126 return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 128 127 }; 129 - if (!space.is_authority) { 130 - return http_api.xrpcError(request, .forbidden, "NotSpaceAuthority", "Not the space authority"); 131 - } 132 128 if (space.deleted_at != null) { 133 129 return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 134 130 } 135 - return http_api.json(request, .ok, try authoritySpaceJson(allocator, space)); 131 + return http_api.json(request, .ok, try spaceConfigJson(allocator, space)); 136 132 } 137 133 138 134 fn listSpaces(request: *http_api.Request) !void { ··· 172 168 return http_api.json(request, .ok, out.written()); 173 169 } 174 170 175 - fn updateSpaceConfig(request: *http_api.Request) !void { 171 + fn listRepos(request: *http_api.Request) !void { 172 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 173 + defer arena.deinit(); 174 + const allocator = arena.allocator(); 175 + var space_buf: [1024]u8 = undefined; 176 + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse { 177 + return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 178 + }; 179 + _ = requireReadAccess(request, allocator, space, null) catch return; 180 + var cursor_buf: [256]u8 = undefined; 181 + const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); 182 + const repos = try store.listSpaceRepos(allocator, space, maybe_cursor, http_api.queryLimit(request.url.raw, 50)); 183 + var out: std.Io.Writer.Allocating = .init(allocator); 184 + defer out.deinit(); 185 + try out.writer.writeAll("{\"repos\":["); 186 + for (repos, 0..) |repo, idx| { 187 + if (idx != 0) try out.writer.writeByte(','); 188 + try out.writer.print("{{\"did\":{f},\"rev\":", .{std.json.fmt(repo.repo_did, .{})}); 189 + if (repo.rev) |rev| try out.writer.print("{f}", .{std.json.fmt(rev, .{})}) else try out.writer.writeAll("null"); 190 + try out.writer.writeAll(",\"hash\":"); 191 + if (repo.set_hash) |hash| try out.writer.print("{f}", .{std.json.fmt(try hexLower(allocator, hash), .{})}) else try out.writer.writeAll("null"); 192 + try out.writer.writeByte('}'); 193 + } 194 + try out.writer.writeByte(']'); 195 + if (repos.len > 0) try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(repos[repos.len - 1].repo_did, .{})}); 196 + try out.writer.writeByte('}'); 197 + return http_api.json(request, .ok, out.written()); 198 + } 199 + 200 + fn addSimpleSpaceMember(request: *http_api.Request) !void { 201 + return mutateSimpleSpaceMember(request, .add); 202 + } 203 + 204 + fn removeSimpleSpaceMember(request: *http_api.Request) !void { 205 + return mutateSimpleSpaceMember(request, .remove); 206 + } 207 + 208 + const MemberMutation = enum { add, remove }; 209 + 210 + fn mutateSimpleSpaceMember(request: *http_api.Request, mutation: MemberMutation) !void { 211 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 212 + defer arena.deinit(); 213 + const allocator = arena.allocator(); 214 + const auth_ctx = requireAccount(request, allocator) catch return; 215 + const parsed_body = parseBody(request, allocator, 16 * 1024) catch |err| switch (err) { 216 + error.HandledResponse => return, 217 + else => return err, 218 + }; 219 + const input = parsed_body.value; 220 + const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 221 + const member = zat.json.getString(input, "did") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 222 + try requireSimpleSpaceAuthority(request, allocator, auth_ctx, space); 223 + (switch (mutation) { 224 + .add => store.addSimpleSpaceMember(space, member), 225 + .remove => store.removeSimpleSpaceMember(space, member), 226 + }) catch |err| switch (err) { 227 + error.InvalidRepoPath => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid DID"), 228 + error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), 229 + error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Cannot remove the space authority"), 230 + else => return err, 231 + }; 232 + return http_api.json(request, .ok, "{}"); 233 + } 234 + 235 + fn listSimpleSpaceMembers(request: *http_api.Request) !void { 236 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 237 + defer arena.deinit(); 238 + const allocator = arena.allocator(); 239 + const auth_ctx = requireAccount(request, allocator) catch return; 240 + var space_buf: [1024]u8 = undefined; 241 + const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 242 + try requireSimpleSpaceAuthority(request, allocator, auth_ctx, space); 243 + var cursor_buf: [256]u8 = undefined; 244 + const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); 245 + const members = try store.listSimpleSpaceMembers(allocator, space, maybe_cursor, http_api.queryLimit(request.url.raw, 50)); 246 + var out: std.Io.Writer.Allocating = .init(allocator); 247 + defer out.deinit(); 248 + try out.writer.writeAll("{\"members\":["); 249 + for (members, 0..) |member, idx| { 250 + if (idx != 0) try out.writer.writeByte(','); 251 + try out.writer.print("{{\"did\":{f},\"createdAt\":{}}}", .{ std.json.fmt(member.did, .{}), member.created_at }); 252 + } 253 + try out.writer.writeByte(']'); 254 + if (members.len > 0) try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(members[members.len - 1].did, .{})}); 255 + try out.writer.writeByte('}'); 256 + return http_api.json(request, .ok, out.written()); 257 + } 258 + 259 + fn updateSimpleSpace(request: *http_api.Request) !void { 176 260 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 177 261 defer arena.deinit(); 178 262 const allocator = arena.allocator(); ··· 194 278 .object => |object| if (object.get("managingApp")) |value| value == .string and value.string.len == 0 else false, 195 279 else => false, 196 280 }; 197 - const app_exceptions_json = appExceptionsJson(allocator, input) catch |err| switch (err) { 198 - error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "appExceptions must be an array"), 281 + const app_access_json = appAccessJson(allocator, input) catch |err| switch (err) { 282 + error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid appAccess"), 199 283 else => return err, 200 284 }; 201 - const has_exceptions = switch (input) { 202 - .object => |object| object.get("appExceptions") != null, 285 + const has_app_access = switch (input) { 286 + .object => |object| object.get("appAccess") != null, 203 287 else => false, 204 288 }; 205 - store.updateSpaceConfig( 289 + const maybe_policy = zat.json.getString(input, "policy"); 290 + if (maybe_policy) |policy| if (!validPolicy(policy)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid policy"); 291 + store.updateSimpleSpaceConfig( 206 292 space, 207 293 managing_app, 208 294 clear_managing_app, 209 - valueBool(input, "isPublic"), 210 - zat.json.getString(input, "appAccessMode"), 211 - if (has_exceptions) app_exceptions_json else null, 295 + maybe_policy, 296 + if (has_app_access) app_access_json else null, 212 297 ) catch |err| switch (err) { 213 298 error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), 214 - error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid appAccessMode"), 299 + error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid simplespace config"), 215 300 else => return err, 216 301 }; 217 302 return http_api.json(request, .ok, "{}"); 218 303 } 219 304 220 - fn deleteSpace(request: *http_api.Request) !void { 305 + fn deleteSimpleSpace(request: *http_api.Request) !void { 221 306 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 222 307 defer arena.deinit(); 223 308 const allocator = arena.allocator(); ··· 601 686 if (config_row.deleted_at != null) { 602 687 return http_api.xrpcError(request, .bad_request, "SpaceDeleted", "Space has been deleted"); 603 688 } 604 - if (!config_row.is_public and !std.mem.eql(u8, delegation.requester_did, parsed.did)) { 689 + if (!try spacePolicyAllowsRequester(allocator, config_row, delegation.requester_did, delegation.client_id)) { 605 690 return http_api.xrpcError(request, .forbidden, "NotPermitted", "The space host did not grant access to this requester"); 606 691 } 607 692 if (!spaceAllowsClient(allocator, config_row, delegation.client_id)) { ··· 908 993 ) !void { 909 994 const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 910 995 try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, action, collection); 911 - _ = allocator; 996 + const visible = try store.getSpace(allocator, auth_ctx.account.did, space); 997 + if (visible == null or visible.?.deleted_at != null) { 998 + try http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to write in this space"); 999 + return error.HandledResponse; 1000 + } 1001 + } 1002 + 1003 + fn requireSimpleSpaceAuthority( 1004 + request: *http_api.Request, 1005 + allocator: std.mem.Allocator, 1006 + auth_ctx: http_api.BearerAccount, 1007 + space: []const u8, 1008 + ) !void { 1009 + const parsed = parseSpaceUri(space) orelse { 1010 + try http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 1011 + return error.HandledResponse; 1012 + }; 1013 + if (!std.mem.eql(u8, parsed.did, auth_ctx.account.did)) { 1014 + try http_api.xrpcError(request, .forbidden, "NotSpaceAuthority", "Not the space authority"); 1015 + return error.HandledResponse; 1016 + } 1017 + try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .manage, null); 1018 + const existing = (try store.getSpace(allocator, auth_ctx.account.did, space)) orelse { 1019 + try http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 1020 + return error.HandledResponse; 1021 + }; 1022 + if (!existing.is_authority or existing.deleted_at != null) { 1023 + try http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 1024 + return error.HandledResponse; 1025 + } 912 1026 } 913 1027 914 1028 const ReadAuth = union(enum) { ··· 923 1037 }; 924 1038 if (http_api.requireBearerAccess(request, allocator)) |auth_ctx| { 925 1039 try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .read, collection); 1040 + const visible = try store.getSpace(allocator, auth_ctx.account.did, space); 1041 + if (visible == null or visible.?.deleted_at != null) { 1042 + try http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to read this space"); 1043 + return error.HandledResponse; 1044 + } 926 1045 return .{ .bearer = auth_ctx }; 927 1046 } else |_| {} 928 1047 ··· 1047 1166 }; 1048 1167 } 1049 1168 1050 - fn appExceptionsJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 1051 - const exceptions = switch (value) { 1052 - .object => |object| object.get("appExceptions") orelse return "[]", 1053 - else => return "[]", 1169 + fn spaceAllowsClient(allocator: std.mem.Allocator, space: store.SpaceConfig, client_id: []const u8) bool { 1170 + const parsed = std.json.parseFromSlice(std.json.Value, allocator, space.app_access_json, .{}) catch return false; 1171 + defer parsed.deinit(); 1172 + const object = switch (parsed.value) { 1173 + .object => |object| object, 1174 + else => return false, 1054 1175 }; 1055 - if (exceptions != .array) return error.InvalidRecordType; 1056 - return try std.fmt.allocPrint(allocator, "{f}", .{std.json.fmt(exceptions, .{})}); 1176 + const kind = switch (object.get("type") orelse return false) { 1177 + .string => |value| value, 1178 + else => return false, 1179 + }; 1180 + if (std.mem.eql(u8, kind, "open")) return true; 1181 + if (std.mem.eql(u8, kind, "allowList")) { 1182 + const allowed = object.get("allowed") orelse return false; 1183 + if (allowed != .array) return false; 1184 + for (allowed.array.items) |item| { 1185 + if (item == .string and std.mem.eql(u8, item.string, client_id)) return true; 1186 + } 1187 + } 1188 + return false; 1189 + } 1190 + 1191 + fn spacePolicyAllowsRequester( 1192 + allocator: std.mem.Allocator, 1193 + space: store.SpaceConfig, 1194 + requester_did: []const u8, 1195 + client_id: []const u8, 1196 + ) !bool { 1197 + _ = allocator; 1198 + if (std.mem.eql(u8, space.policy, "public")) return true; 1199 + if (std.mem.eql(u8, space.policy, "member-list")) return store.simpleSpaceHasMember(space.uri, requester_did); 1200 + if (std.mem.eql(u8, space.policy, "managing-app")) { 1201 + _ = client_id; 1202 + return false; 1203 + } 1204 + return false; 1057 1205 } 1058 1206 1059 - fn spaceAllowsClient(allocator: std.mem.Allocator, space: store.SpaceConfig, client_id: []const u8) bool { 1060 - const parsed = std.json.parseFromSlice(std.json.Value, allocator, space.app_exceptions_json, .{}) catch return false; 1061 - defer parsed.deinit(); 1062 - const listed = switch (parsed.value) { 1063 - .array => |array| blk: { 1064 - for (array.items) |item| { 1065 - if (item == .string and std.mem.eql(u8, item.string, client_id)) break :blk true; 1066 - } 1067 - break :blk false; 1068 - }, 1069 - else => false, 1207 + fn validPolicy(policy: []const u8) bool { 1208 + return std.mem.eql(u8, policy, "member-list") or 1209 + std.mem.eql(u8, policy, "public") or 1210 + std.mem.eql(u8, policy, "managing-app"); 1211 + } 1212 + 1213 + fn hexLower(allocator: std.mem.Allocator, bytes: []const u8) ![]const u8 { 1214 + const alphabet = "0123456789abcdef"; 1215 + const out = try allocator.alloc(u8, bytes.len * 2); 1216 + for (bytes, 0..) |byte, idx| { 1217 + out[idx * 2] = alphabet[byte >> 4]; 1218 + out[idx * 2 + 1] = alphabet[byte & 0x0f]; 1219 + } 1220 + return out; 1221 + } 1222 + 1223 + fn appAccessJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { 1224 + const app_access = switch (value) { 1225 + .object => |object| object.get("appAccess") orelse return "{\"type\":\"open\"}", 1226 + else => return "{\"type\":\"open\"}", 1227 + }; 1228 + const object = switch (app_access) { 1229 + .object => |object| object, 1230 + else => return error.InvalidRecordType, 1231 + }; 1232 + const kind = switch (object.get("type") orelse return error.InvalidRecordType) { 1233 + .string => |text| text, 1234 + else => return error.InvalidRecordType, 1070 1235 }; 1071 - if (std.mem.eql(u8, space.app_access_mode, "allow")) return !listed; 1072 - if (std.mem.eql(u8, space.app_access_mode, "deny")) return listed; 1073 - return false; 1236 + if (std.mem.eql(u8, kind, "open")) return try std.fmt.allocPrint(allocator, "{{\"type\":\"open\"}}", .{}); 1237 + if (!std.mem.eql(u8, kind, "allowList")) return error.InvalidRecordType; 1238 + const allowed = object.get("allowed") orelse return error.InvalidRecordType; 1239 + if (allowed != .array) return error.InvalidRecordType; 1240 + for (allowed.array.items) |item| { 1241 + if (item != .string) return error.InvalidRecordType; 1242 + } 1243 + return try std.fmt.allocPrint(allocator, "{f}", .{std.json.fmt(app_access, .{})}); 1074 1244 } 1075 1245 1076 1246 fn spaceConfigJson(allocator: std.mem.Allocator, space: store.SpaceConfig) ![]const u8 { 1077 1247 var out: std.Io.Writer.Allocating = .init(allocator); 1078 1248 defer out.deinit(); 1079 1249 try out.writer.print( 1080 - "{{\"uri\":{f},\"authority\":{f},\"type\":{f},\"skey\":{f},\"isAuthority\":{},\"isPublic\":{},\"appAccessMode\":{f}", 1250 + "{{\"uri\":{f},\"authority\":{f},\"type\":{f},\"skey\":{f},\"isAuthority\":{},\"config\":{{\"$type\":\"com.atproto.simplespace.defs#spaceConfig\",\"policy\":{f},\"appAccess\":{s}", 1081 1251 .{ 1082 1252 std.json.fmt(space.uri, .{}), 1083 1253 std.json.fmt(space.authority_did, .{}), 1084 1254 std.json.fmt(space.space_type, .{}), 1085 1255 std.json.fmt(space.skey, .{}), 1086 1256 space.is_authority, 1087 - space.is_public, 1088 - std.json.fmt(space.app_access_mode, .{}), 1257 + std.json.fmt(space.policy, .{}), 1258 + space.app_access_json, 1089 1259 }, 1090 1260 ); 1091 1261 if (space.managing_app) |managing_app| { 1092 1262 try out.writer.print(",\"managingApp\":{f}", .{std.json.fmt(managing_app, .{})}); 1093 1263 } 1094 - try out.writer.print(",\"appExceptions\":{s}}}", .{space.app_exceptions_json}); 1095 - return out.toOwnedSlice(); 1096 - } 1097 - 1098 - fn authoritySpaceJson(allocator: std.mem.Allocator, space: store.SpaceConfig) ![]const u8 { 1099 - var out: std.Io.Writer.Allocating = .init(allocator); 1100 - defer out.deinit(); 1101 - try out.writer.print( 1102 - "{{\"uri\":{f},\"isAuthority\":{},\"isPublic\":{},\"appAccessMode\":{f}", 1103 - .{ 1104 - std.json.fmt(space.uri, .{}), 1105 - space.is_authority, 1106 - space.is_public, 1107 - std.json.fmt(space.app_access_mode, .{}), 1108 - }, 1109 - ); 1110 - if (space.managing_app) |managing_app| { 1111 - try out.writer.print(",\"managingApp\":{f}", .{std.json.fmt(managing_app, .{})}); 1112 - } 1113 - try out.writer.print(",\"appExceptions\":{s}}}", .{space.app_exceptions_json}); 1264 + try out.writer.writeAll("}}}"); 1114 1265 return out.toOwnedSlice(); 1115 1266 } 1116 1267
+12 -1
src/http/api.zig
··· 335 335 const default_cors_allow_headers = "atproto-accept-labelers, atproto-proxy, authorization, content-type, dpop, x-bsky-topics"; 336 336 337 337 pub fn respond(request: *Request, status: http.Status, body: []const u8, headers: []const http.Header) !void { 338 - _ = request; 339 338 const res = response(); 339 + if (wantsConnectionClose(headers)) request.conn.handover = .close; 340 340 try setHeaders(res, headers); 341 341 res.setStatus(status); 342 342 res.body = try res.arena.dupe(u8, body); ··· 361 361 362 362 fn setHeaders(res: *Response, headers: []const http.Header) !void { 363 363 for (headers) |header| try addHeader(res, header.name, header.value); 364 + } 365 + 366 + fn wantsConnectionClose(headers: []const http.Header) bool { 367 + for (headers) |header| { 368 + if (std.ascii.eqlIgnoreCase(header.name, "connection") and 369 + std.ascii.eqlIgnoreCase(header.value, "close")) 370 + { 371 + return true; 372 + } 373 + } 374 + return false; 364 375 } 365 376 366 377 pub fn addResponseHeader(name: []const u8, value: []const u8) !void {
+10 -4
src/http/router.zig
··· 213 213 .{ .route = .identity_submit_plc_operation, .method = "POST", .path = "/xrpc/com.atproto.identity.submitPlcOperation", .group = "identity", .auth = "bearer", .summary = "Submit a PLC operation." }, 214 214 .{ .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"} }, 215 215 216 - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.createSpace", .group = "space", .auth = "experimental bearer", .summary = "Create a permissioned data space.", .body = &.{ "authority", "type", "skey", "managingApp", "isPublic", "appAccessMode", "appExceptions" }, .notes = permissioned_data_note }, 217 216 .{ .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 = permissioned_data_note }, 218 217 .{ .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 = &.{ "authority", "type", "limit", "cursor" }, .notes = permissioned_data_note }, 219 - .{ .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 = permissioned_data_note }, 220 - .{ .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 = permissioned_data_note }, 218 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listRepos", .group = "space", .auth = "experimental bearer or space credential", .summary = "List known writer repos in a permissioned data space.", .params = &.{ "space", "limit", "cursor" }, .notes = permissioned_data_note }, 221 219 222 220 .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getDelegationToken", .group = "space", .auth = "experimental OAuth", .summary = "Create a delegation token for exchange with a space authority.", .params = &.{"space"}, .notes = permissioned_data_note }, 223 221 ··· 234 232 235 233 .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.getSpaceCredential", .group = "space", .auth = "experimental delegation token", .summary = "Exchange a delegation token for a space credential.", .body = &.{ "space", "notifyEndpoint" }, .notes = permissioned_data_note }, 236 234 .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.notifySpaceDeleted", .group = "space", .auth = "experimental service", .summary = "Notify a repo host or syncing service that a space was deleted.", .body = &.{"space"}, .notes = permissioned_data_note }, 235 + 236 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.createSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Create a baseline PDS-managed permissioned data space.", .body = &.{ "type", "skey", "managingApp", "policy", "appAccess" }, .notes = permissioned_data_note }, 237 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.updateSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Update baseline PDS-managed space configuration.", .body = &.{ "space", "managingApp", "policy", "appAccess" }, .notes = permissioned_data_note }, 238 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.deleteSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Delete a baseline PDS-managed space.", .body = &.{"space"}, .notes = permissioned_data_note }, 239 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.addMember", .group = "simplespace", .auth = "experimental bearer", .summary = "Add a DID to a simplespace member-list policy.", .body = &.{ "space", "did" }, .notes = permissioned_data_note }, 240 + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.removeMember", .group = "simplespace", .auth = "experimental bearer", .summary = "Remove a DID from a simplespace member-list policy.", .body = &.{ "space", "did" }, .notes = permissioned_data_note }, 241 + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.simplespace.listMembers", .group = "simplespace", .auth = "experimental bearer", .summary = "List DIDs in a simplespace member-list policy.", .params = &.{ "space", "limit", "cursor" }, .notes = permissioned_data_note }, 237 242 }; 238 243 239 244 pub fn route(method: httpz.Method, target: []const u8) Route { ··· 334 339 try std.testing.expectEqual(Route.sync_get_latest_commit, route(.GET, "/xrpc/com.atproto.sync.getLatestCommit?did=did%3Aplc%3Aabc")); 335 340 try std.testing.expectEqual(Route.sync_subscribe_repos, route(.GET, "/xrpc/com.atproto.sync.subscribeRepos?cursor=0")); 336 341 try std.testing.expectEqual(Route.sync_request_crawl, route(.POST, "/xrpc/com.atproto.sync.requestCrawl")); 337 - try std.testing.expectEqual(Route.permissioned_data, route(.POST, "/xrpc/com.atproto.space.createSpace")); 342 + try std.testing.expectEqual(Route.permissioned_data, route(.POST, "/xrpc/com.atproto.simplespace.createSpace")); 338 343 try std.testing.expectEqual(Route.permissioned_data, route(.GET, "/xrpc/com.atproto.space.listRecords?space=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fspace%2Fkey")); 339 344 try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/app.bsky.ageassurance.getState?countryCode=US")); 340 345 try std.testing.expectEqual(Route.identity_resolve_handle, route(.GET, "/xrpc/com.atproto.identity.resolveHandle?handle=alice.test")); ··· 345 350 try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/com.atproto.server.createSession")); 346 351 try std.testing.expectEqual(Route.not_found, route(.POST, "/xrpc/dev.zat.account.listSessions")); 347 352 try std.testing.expectEqual(Route.not_found, route(.GET, "/xrpc/com.atproto.space.createSpace")); 353 + try std.testing.expectEqual(Route.not_found, route(.POST, "/xrpc/com.atproto.space.createSpace")); 348 354 }
+283 -93
src/storage/store.zig
··· 302 302 space_type: []const u8, 303 303 skey: []const u8, 304 304 managing_app: ?[]const u8, 305 - is_public: bool, 306 - app_access_mode: []const u8, 307 - app_exceptions_json: []const u8, 305 + policy: []const u8, 306 + app_access_json: []const u8, 308 307 is_authority: bool, 309 308 deleted_at: ?i64, 309 + }; 310 + 311 + pub const SimpleSpaceMember = struct { 312 + did: []const u8, 313 + created_at: i64, 310 314 }; 311 315 312 316 pub const SpaceRecord = struct { ··· 343 347 }; 344 348 345 349 pub const SpaceState = struct { 350 + set_hash: ?[]const u8, 351 + rev: ?[]const u8, 352 + }; 353 + 354 + pub const SpaceRepoState = struct { 355 + repo_did: []const u8, 346 356 set_hash: ?[]const u8, 347 357 rev: ?[]const u8, 348 358 }; ··· 3050 3060 skey: []const u8, 3051 3061 is_authority: bool, 3052 3062 managing_app: ?[]const u8, 3053 - is_public: bool, 3054 - app_access_mode: []const u8, 3055 - app_exceptions_json: []const u8, 3063 + policy: []const u8, 3064 + app_access_json: []const u8, 3056 3065 }; 3057 3066 3058 3067 pub fn createSpace(allocator: std.mem.Allocator, input: CreateSpaceInput) !SpaceConfig { ··· 3060 3069 if (zat.Did.parse(input.authority_did) == null) return Error.InvalidRepoPath; 3061 3070 if (zat.Nsid.parse(input.space_type) == null) return Error.InvalidCollection; 3062 3071 if (zat.Rkey.parse(input.skey) == null) return Error.InvalidRecordKey; 3063 - if (!std.mem.eql(u8, input.app_access_mode, "allow") and !std.mem.eql(u8, input.app_access_mode, "deny")) { 3064 - return Error.InvalidRecordType; 3065 - } 3072 + if (!validSimpleSpacePolicy(input.policy)) return Error.InvalidRecordType; 3073 + try validateAppAccessJson(input.app_access_json); 3066 3074 3067 3075 const uri = try std.fmt.allocPrint(allocator, "ats://{s}/{s}/{s}", .{ input.authority_did, input.space_type, input.skey }); 3068 3076 ··· 3076 3084 errdefer conn.rollback(); 3077 3085 try conn.exec( 3078 3086 \\INSERT INTO permissioned_spaces ( 3079 - \\ uri, authority_did, space_type, skey, managing_app, is_public, app_access_mode, app_exceptions_json 3080 - \\) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 3087 + \\ uri, authority_did, space_type, skey, managing_app, policy, app_access_json 3088 + \\) VALUES (?, ?, ?, ?, ?, ?, ?) 3081 3089 \\ON CONFLICT(uri) DO NOTHING 3082 3090 , .{ 3083 3091 uri, ··· 3085 3093 input.space_type, 3086 3094 input.skey, 3087 3095 input.managing_app, 3088 - @as(i64, if (input.is_public) 1 else 0), 3089 - input.app_access_mode, 3090 - input.app_exceptions_json, 3096 + input.policy, 3097 + input.app_access_json, 3091 3098 }); 3092 3099 if (input.is_authority) { 3093 3100 try conn.exec( 3094 3101 \\UPDATE permissioned_spaces 3095 3102 \\SET managing_app = ?, 3096 - \\ is_public = ?, 3097 - \\ app_access_mode = ?, 3098 - \\ app_exceptions_json = ? 3103 + \\ policy = ?, 3104 + \\ app_access_json = ? 3099 3105 \\WHERE uri = ? 3100 3106 , .{ 3101 3107 input.managing_app, 3102 - @as(i64, if (input.is_public) 1 else 0), 3103 - input.app_access_mode, 3104 - input.app_exceptions_json, 3108 + input.policy, 3109 + input.app_access_json, 3105 3110 uri, 3106 3111 }); 3107 3112 } ··· 3114 3119 \\INSERT INTO permissioned_space_actor_state (space, actor_did, is_authority) 3115 3120 \\VALUES (?, ?, ?) 3116 3121 , .{ uri, input.actor_did, @as(i64, if (input.is_authority) 1 else 0) }); 3122 + if (input.is_authority) { 3123 + try conn.exec( 3124 + \\INSERT INTO simplespace_members (space, member_did) 3125 + \\VALUES (?, ?) 3126 + \\ON CONFLICT(space, member_did) DO NOTHING 3127 + , .{ uri, input.authority_did }); 3128 + } 3117 3129 try conn.commit(); 3118 3130 3119 3131 return (try getSpaceLocked(allocator, input.actor_did, uri)) orelse Error.RepoNotFound; ··· 3145 3157 try conn.rows( 3146 3158 \\SELECT s.uri 3147 3159 \\FROM permissioned_spaces s 3148 - \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3149 - \\WHERE a.actor_did = ? AND s.authority_did = ? AND s.space_type = ? AND s.uri > ? AND a.is_authority = 1 3160 + \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? 3161 + \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? 3162 + \\WHERE s.authority_did = ? AND s.space_type = ? AND s.uri > ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') 3150 3163 \\ORDER BY s.uri ASC 3151 3164 \\LIMIT ? 3152 - , .{ actor_did, did, space_type, cursor, capped_limit }) 3165 + , .{ actor_did, actor_did, did, space_type, cursor, capped_limit }) 3153 3166 else 3154 3167 try conn.rows( 3155 3168 \\SELECT s.uri 3156 3169 \\FROM permissioned_spaces s 3157 - \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3158 - \\WHERE a.actor_did = ? AND s.authority_did = ? AND s.space_type = ? AND a.is_authority = 1 3170 + \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? 3171 + \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? 3172 + \\WHERE s.authority_did = ? AND s.space_type = ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') 3159 3173 \\ORDER BY s.uri ASC 3160 3174 \\LIMIT ? 3161 - , .{ actor_did, did, space_type, capped_limit }) 3175 + , .{ actor_did, actor_did, did, space_type, capped_limit }) 3162 3176 else if (maybe_cursor) |cursor| 3163 3177 try conn.rows( 3164 3178 \\SELECT s.uri 3165 3179 \\FROM permissioned_spaces s 3166 - \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3167 - \\WHERE a.actor_did = ? AND s.authority_did = ? AND s.uri > ? AND a.is_authority = 1 3180 + \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? 3181 + \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? 3182 + \\WHERE s.authority_did = ? AND s.uri > ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') 3168 3183 \\ORDER BY s.uri ASC 3169 3184 \\LIMIT ? 3170 - , .{ actor_did, did, cursor, capped_limit }) 3185 + , .{ actor_did, actor_did, did, cursor, capped_limit }) 3171 3186 else 3172 3187 try conn.rows( 3173 3188 \\SELECT s.uri 3174 3189 \\FROM permissioned_spaces s 3175 - \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3176 - \\WHERE a.actor_did = ? AND s.authority_did = ? AND a.is_authority = 1 3190 + \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? 3191 + \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? 3192 + \\WHERE s.authority_did = ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') 3177 3193 \\ORDER BY s.uri ASC 3178 3194 \\LIMIT ? 3179 - , .{ actor_did, did, capped_limit }) 3195 + , .{ actor_did, actor_did, did, capped_limit }) 3180 3196 else if (maybe_type) |space_type| 3181 3197 if (maybe_cursor) |cursor| 3182 3198 try conn.rows( 3183 3199 \\SELECT s.uri 3184 3200 \\FROM permissioned_spaces s 3185 - \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3186 - \\WHERE a.actor_did = ? AND s.space_type = ? AND s.uri > ? AND a.is_authority = 1 3201 + \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? 3202 + \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? 3203 + \\WHERE s.space_type = ? AND s.uri > ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') 3187 3204 \\ORDER BY s.uri ASC 3188 3205 \\LIMIT ? 3189 - , .{ actor_did, space_type, cursor, capped_limit }) 3206 + , .{ actor_did, actor_did, space_type, cursor, capped_limit }) 3190 3207 else 3191 3208 try conn.rows( 3192 3209 \\SELECT s.uri 3193 3210 \\FROM permissioned_spaces s 3194 - \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3195 - \\WHERE a.actor_did = ? AND s.space_type = ? AND a.is_authority = 1 3211 + \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? 3212 + \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? 3213 + \\WHERE s.space_type = ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') 3196 3214 \\ORDER BY s.uri ASC 3197 3215 \\LIMIT ? 3198 - , .{ actor_did, space_type, capped_limit }) 3216 + , .{ actor_did, actor_did, space_type, capped_limit }) 3199 3217 else if (maybe_cursor) |cursor| 3200 3218 try conn.rows( 3201 3219 \\SELECT s.uri 3202 3220 \\FROM permissioned_spaces s 3203 - \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3204 - \\WHERE a.actor_did = ? AND s.uri > ? AND a.is_authority = 1 3221 + \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? 3222 + \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? 3223 + \\WHERE s.uri > ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') 3205 3224 \\ORDER BY s.uri ASC 3206 3225 \\LIMIT ? 3207 - , .{ actor_did, cursor, capped_limit }) 3226 + , .{ actor_did, actor_did, cursor, capped_limit }) 3208 3227 else 3209 3228 try conn.rows( 3210 3229 \\SELECT s.uri 3211 3230 \\FROM permissioned_spaces s 3212 - \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3213 - \\WHERE a.actor_did = ? AND a.is_authority = 1 3231 + \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? 3232 + \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? 3233 + \\WHERE s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') 3214 3234 \\ORDER BY s.uri ASC 3215 3235 \\LIMIT ? 3216 - , .{ actor_did, capped_limit }); 3236 + , .{ actor_did, actor_did, capped_limit }); 3217 3237 defer rows.deinit(); 3218 3238 3219 3239 var spaces: std.ArrayList(SpaceConfig) = .empty; ··· 3226 3246 return spaces.toOwnedSlice(allocator); 3227 3247 } 3228 3248 3229 - pub fn updateSpaceConfig( 3249 + pub fn updateSimpleSpaceConfig( 3230 3250 space: []const u8, 3231 3251 managing_app: ?[]const u8, 3232 3252 clear_managing_app: bool, 3233 - maybe_is_public: ?bool, 3234 - maybe_app_access_mode: ?[]const u8, 3235 - maybe_app_exceptions_json: ?[]const u8, 3253 + maybe_policy: ?[]const u8, 3254 + maybe_app_access_json: ?[]const u8, 3236 3255 ) !void { 3237 - if (maybe_app_access_mode) |mode| { 3238 - if (!std.mem.eql(u8, mode, "allow") and !std.mem.eql(u8, mode, "deny")) return Error.InvalidRecordType; 3239 - } 3256 + if (maybe_policy) |policy| if (!validSimpleSpacePolicy(policy)) return Error.InvalidRecordType; 3257 + if (maybe_app_access_json) |app_access| try validateAppAccessJson(app_access); 3240 3258 db_mutex.lockUncancelable(store_io); 3241 3259 defer db_mutex.unlock(store_io); 3242 3260 try requireInitialized(); ··· 3246 3264 } else if (clear_managing_app) { 3247 3265 try conn.exec("UPDATE permissioned_spaces SET managing_app = NULL WHERE uri = ?", .{space}); 3248 3266 } 3249 - if (maybe_is_public) |is_public| { 3250 - try conn.exec("UPDATE permissioned_spaces SET is_public = ? WHERE uri = ?", .{ @as(i64, if (is_public) 1 else 0), space }); 3267 + if (maybe_policy) |policy| { 3268 + try conn.exec("UPDATE permissioned_spaces SET policy = ? WHERE uri = ?", .{ policy, space }); 3251 3269 } 3252 - if (maybe_app_access_mode) |mode| { 3253 - try conn.exec("UPDATE permissioned_spaces SET app_access_mode = ? WHERE uri = ?", .{ mode, space }); 3270 + if (maybe_app_access_json) |app_access| { 3271 + try conn.exec("UPDATE permissioned_spaces SET app_access_json = ? WHERE uri = ?", .{ app_access, space }); 3254 3272 } 3255 - if (maybe_app_exceptions_json) |exceptions| { 3256 - try conn.exec("UPDATE permissioned_spaces SET app_exceptions_json = ? WHERE uri = ?", .{ exceptions, space }); 3273 + } 3274 + 3275 + pub fn addSimpleSpaceMember(space: []const u8, did: []const u8) !void { 3276 + if (zat.Did.parse(did) == null) return Error.InvalidRepoPath; 3277 + db_mutex.lockUncancelable(store_io); 3278 + defer db_mutex.unlock(store_io); 3279 + try requireInitialized(); 3280 + _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; 3281 + try conn.exec( 3282 + \\INSERT INTO simplespace_members (space, member_did) 3283 + \\VALUES (?, ?) 3284 + \\ON CONFLICT(space, member_did) DO NOTHING 3285 + , .{ space, did }); 3286 + } 3287 + 3288 + pub fn removeSimpleSpaceMember(space: []const u8, did: []const u8) !void { 3289 + if (zat.Did.parse(did) == null) return Error.InvalidRepoPath; 3290 + db_mutex.lockUncancelable(store_io); 3291 + defer db_mutex.unlock(store_io); 3292 + try requireInitialized(); 3293 + const config = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; 3294 + if (std.mem.eql(u8, config.authority_did, did)) return Error.InvalidRecordType; 3295 + try conn.exec("DELETE FROM simplespace_members WHERE space = ? AND member_did = ?", .{ space, did }); 3296 + } 3297 + 3298 + pub fn listSimpleSpaceMembers( 3299 + allocator: std.mem.Allocator, 3300 + space: []const u8, 3301 + maybe_cursor: ?[]const u8, 3302 + limit: usize, 3303 + ) ![]SimpleSpaceMember { 3304 + db_mutex.lockUncancelable(store_io); 3305 + defer db_mutex.unlock(store_io); 3306 + try requireInitialized(); 3307 + _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; 3308 + const capped_limit: i64 = @intCast(@min(if (limit == 0) 50 else limit, 100)); 3309 + var rows = if (maybe_cursor) |cursor| 3310 + try conn.rows( 3311 + \\SELECT member_did, created_at 3312 + \\FROM simplespace_members 3313 + \\WHERE space = ? AND member_did > ? 3314 + \\ORDER BY member_did ASC 3315 + \\LIMIT ? 3316 + , .{ space, cursor, capped_limit }) 3317 + else 3318 + try conn.rows( 3319 + \\SELECT member_did, created_at 3320 + \\FROM simplespace_members 3321 + \\WHERE space = ? 3322 + \\ORDER BY member_did ASC 3323 + \\LIMIT ? 3324 + , .{ space, capped_limit }); 3325 + defer rows.deinit(); 3326 + var members: std.ArrayList(SimpleSpaceMember) = .empty; 3327 + while (rows.next()) |row| { 3328 + try members.append(allocator, .{ 3329 + .did = try allocator.dupe(u8, row.text(0)), 3330 + .created_at = row.int(1), 3331 + }); 3257 3332 } 3333 + if (rows.err) |err| return err; 3334 + return members.toOwnedSlice(allocator); 3335 + } 3336 + 3337 + pub fn simpleSpaceHasMember(space: []const u8, did: []const u8) !bool { 3338 + db_mutex.lockUncancelable(store_io); 3339 + defer db_mutex.unlock(store_io); 3340 + try requireInitialized(); 3341 + const row = try conn.row( 3342 + "SELECT 1 FROM simplespace_members WHERE space = ? AND member_did = ?", 3343 + .{ space, did }, 3344 + ); 3345 + if (row == null) return false; 3346 + defer row.?.deinit(); 3347 + return true; 3348 + } 3349 + 3350 + pub fn listSpaceRepos(allocator: std.mem.Allocator, space: []const u8, maybe_cursor: ?[]const u8, limit: usize) ![]SpaceRepoState { 3351 + db_mutex.lockUncancelable(store_io); 3352 + defer db_mutex.unlock(store_io); 3353 + try requireInitialized(); 3354 + _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; 3355 + const capped_limit: i64 = @intCast(@min(if (limit == 0) 50 else limit, 100)); 3356 + var rows = if (maybe_cursor) |cursor| 3357 + try conn.rows( 3358 + \\SELECT repo_did, set_hash, rev 3359 + \\FROM permissioned_space_repos 3360 + \\WHERE space = ? AND repo_did > ? 3361 + \\ORDER BY repo_did ASC 3362 + \\LIMIT ? 3363 + , .{ space, cursor, capped_limit }) 3364 + else 3365 + try conn.rows( 3366 + \\SELECT repo_did, set_hash, rev 3367 + \\FROM permissioned_space_repos 3368 + \\WHERE space = ? 3369 + \\ORDER BY repo_did ASC 3370 + \\LIMIT ? 3371 + , .{ space, capped_limit }); 3372 + defer rows.deinit(); 3373 + var repos: std.ArrayList(SpaceRepoState) = .empty; 3374 + while (rows.next()) |row| { 3375 + try repos.append(allocator, .{ 3376 + .repo_did = try allocator.dupe(u8, row.text(0)), 3377 + .set_hash = if (row.nullableBlob(1)) |bytes| try allocator.dupe(u8, bytes) else null, 3378 + .rev = if (row.nullableText(2)) |rev| try allocator.dupe(u8, rev) else null, 3379 + }); 3380 + } 3381 + if (rows.err) |err| return err; 3382 + return repos.toOwnedSlice(allocator); 3258 3383 } 3259 3384 3260 3385 pub fn markSpaceDeleted(actor_did: []const u8, space: []const u8) !void { ··· 3262 3387 defer db_mutex.unlock(store_io); 3263 3388 try requireInitialized(); 3264 3389 try conn.exec("UPDATE permissioned_space_actor_state SET deleted_at = unixepoch() WHERE space = ? AND actor_did = ?", .{ space, actor_did }); 3390 + try conn.exec("UPDATE permissioned_spaces SET deleted_at = unixepoch() WHERE uri = ? AND authority_did = ?", .{ space, actor_did }); 3265 3391 } 3266 3392 3267 3393 pub fn purgeAuthoritySpaceData(space: []const u8) !void { ··· 3621 3747 3622 3748 fn getSpaceConfigLocked(allocator: std.mem.Allocator, uri: []const u8) !?SpaceConfig { 3623 3749 const row = try conn.row( 3624 - \\SELECT uri, authority_did, space_type, skey, managing_app, is_public, app_access_mode, app_exceptions_json 3750 + \\SELECT uri, authority_did, space_type, skey, managing_app, policy, app_access_json 3625 3751 \\FROM permissioned_spaces 3626 3752 \\WHERE uri = ? 3627 3753 , .{uri}); ··· 3634 3760 .space_type = try allocator.dupe(u8, row.?.text(2)), 3635 3761 .skey = try allocator.dupe(u8, row.?.text(3)), 3636 3762 .managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, 3637 - .is_public = row.?.int(5) != 0, 3638 - .app_access_mode = try allocator.dupe(u8, row.?.text(6)), 3639 - .app_exceptions_json = try allocator.dupe(u8, row.?.text(7)), 3763 + .policy = try allocator.dupe(u8, row.?.text(5)), 3764 + .app_access_json = try allocator.dupe(u8, row.?.text(6)), 3640 3765 .is_authority = false, 3641 3766 .deleted_at = null, 3642 3767 }; ··· 3644 3769 3645 3770 fn getSpaceLocked(allocator: std.mem.Allocator, actor_did: []const u8, uri: []const u8) !?SpaceConfig { 3646 3771 const row = try conn.row( 3647 - \\SELECT s.uri, s.authority_did, s.space_type, s.skey, s.managing_app, s.is_public, s.app_access_mode, s.app_exceptions_json, 3648 - \\ a.is_authority, a.deleted_at 3772 + \\SELECT s.uri, s.authority_did, s.space_type, s.skey, s.managing_app, s.policy, s.app_access_json, 3773 + \\ a.is_authority, COALESCE(a.deleted_at, s.deleted_at) 3649 3774 \\FROM permissioned_spaces s 3650 - \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3651 - \\WHERE s.uri = ? AND a.actor_did = ? 3652 - , .{ uri, actor_did }); 3775 + \\LEFT JOIN permissioned_space_actor_state a ON a.space = s.uri AND a.actor_did = ? 3776 + \\LEFT JOIN simplespace_members m ON m.space = s.uri AND m.member_did = ? 3777 + \\WHERE s.uri = ? AND s.deleted_at IS NULL AND (a.is_authority = 1 OR m.member_did IS NOT NULL OR s.policy = 'public') 3778 + , .{ actor_did, actor_did, uri }); 3653 3779 if (row == null) return null; 3654 3780 defer row.?.deinit(); 3655 3781 ··· 3659 3785 .space_type = try allocator.dupe(u8, row.?.text(2)), 3660 3786 .skey = try allocator.dupe(u8, row.?.text(3)), 3661 3787 .managing_app = if (row.?.nullableText(4)) |value| try allocator.dupe(u8, value) else null, 3662 - .is_public = row.?.int(5) != 0, 3663 - .app_access_mode = try allocator.dupe(u8, row.?.text(6)), 3664 - .app_exceptions_json = try allocator.dupe(u8, row.?.text(7)), 3665 - .is_authority = row.?.int(8) != 0, 3666 - .deleted_at = if (row.?.nullableInt(9)) |value| value else null, 3788 + .policy = try allocator.dupe(u8, row.?.text(5)), 3789 + .app_access_json = try allocator.dupe(u8, row.?.text(6)), 3790 + .is_authority = (row.?.nullableInt(7) orelse 0) != 0, 3791 + .deleted_at = if (row.?.nullableInt(8)) |value| value else null, 3667 3792 }; 3668 3793 } 3669 3794 ··· 3722 3847 return .{ .authority_did = authority_did, .space_type = space_type, .skey = skey }; 3723 3848 } 3724 3849 3850 + fn validSimpleSpacePolicy(policy: []const u8) bool { 3851 + return std.mem.eql(u8, policy, "member-list") or 3852 + std.mem.eql(u8, policy, "public") or 3853 + std.mem.eql(u8, policy, "managing-app"); 3854 + } 3855 + 3856 + fn validateAppAccessJson(raw: []const u8) !void { 3857 + var parsed = try std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, raw, .{}); 3858 + defer parsed.deinit(); 3859 + const object = switch (parsed.value) { 3860 + .object => |object| object, 3861 + else => return Error.InvalidRecordType, 3862 + }; 3863 + const kind = switch (object.get("type") orelse return Error.InvalidRecordType) { 3864 + .string => |value| value, 3865 + else => return Error.InvalidRecordType, 3866 + }; 3867 + if (std.mem.eql(u8, kind, "open")) return; 3868 + if (!std.mem.eql(u8, kind, "allowList")) return Error.InvalidRecordType; 3869 + const allowed = object.get("allowed") orelse return Error.InvalidRecordType; 3870 + if (allowed != .array) return Error.InvalidRecordType; 3871 + for (allowed.array.items) |item| { 3872 + if (item != .string) return Error.InvalidRecordType; 3873 + } 3874 + } 3875 + 3725 3876 fn migrate() !void { 3726 3877 inline for (schema_statements) |sql| try conn.execNoArgs(sql); 3727 3878 try migratePermissionedDataTables(); ··· 3771 3922 try ensureMigrationTable(); 3772 3923 try migratePermissionedSpacesAuthorityColumns(); 3773 3924 try migratePermissionedSpaceAuthorityFlag(); 3925 + try migrateSimpleSpaceConfig(); 3774 3926 try migratePermissionedRecordOplogRepo(); 3927 + } 3928 + 3929 + fn migrateSimpleSpaceConfig() !void { 3930 + conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN policy TEXT NOT NULL DEFAULT 'member-list'") catch {}; 3931 + conn.execNoArgs("ALTER TABLE permissioned_spaces ADD COLUMN app_access_json TEXT NOT NULL DEFAULT '{\"type\":\"open\"}'") catch {}; 3932 + conn.execNoArgs( 3933 + \\UPDATE permissioned_spaces 3934 + \\SET policy = CASE WHEN is_public = 1 THEN 'public' ELSE 'member-list' END 3935 + \\WHERE EXISTS ( 3936 + \\ SELECT 1 FROM pragma_table_info('permissioned_spaces') WHERE name = 'is_public' 3937 + \\) 3938 + ) catch {}; 3939 + conn.execNoArgs( 3940 + \\UPDATE permissioned_spaces 3941 + \\SET app_access_json = CASE 3942 + \\ WHEN app_access_mode = 'deny' AND app_exceptions_json != '[]' 3943 + \\ THEN '{"type":"allowList","allowed":[]}' 3944 + \\ ELSE '{"type":"open"}' 3945 + \\END 3946 + \\WHERE EXISTS ( 3947 + \\ SELECT 1 FROM pragma_table_info('permissioned_spaces') WHERE name = 'app_access_mode' 3948 + \\) 3949 + ) catch {}; 3950 + try conn.execNoArgs( 3951 + \\CREATE TABLE IF NOT EXISTS simplespace_members ( 3952 + \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 3953 + \\ member_did TEXT NOT NULL, 3954 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 3955 + \\ PRIMARY KEY (space, member_did) 3956 + \\) 3957 + ); 3958 + try conn.execNoArgs("CREATE INDEX IF NOT EXISTS simplespace_members_member_idx ON simplespace_members (member_did, space)"); 3959 + try conn.execNoArgs( 3960 + \\INSERT OR IGNORE INTO simplespace_members (space, member_did) 3961 + \\SELECT uri, authority_did 3962 + \\FROM permissioned_spaces 3963 + ); 3775 3964 } 3776 3965 3777 3966 fn ensureMigrationTable() !void { ··· 5584 5773 \\ space_type TEXT NOT NULL, 5585 5774 \\ skey TEXT NOT NULL, 5586 5775 \\ managing_app TEXT, 5587 - \\ is_public INTEGER NOT NULL DEFAULT 0, 5588 - \\ app_access_mode TEXT NOT NULL DEFAULT 'allow', 5589 - \\ app_exceptions_json TEXT NOT NULL DEFAULT '[]', 5776 + \\ policy TEXT NOT NULL DEFAULT 'member-list', 5777 + \\ app_access_json TEXT NOT NULL DEFAULT '{"type":"open"}', 5590 5778 \\ is_authority INTEGER NOT NULL DEFAULT 1, 5591 5779 \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 5592 5780 \\ deleted_at INTEGER, ··· 5602 5790 \\ PRIMARY KEY (space, actor_did) 5603 5791 \\) 5604 5792 , 5793 + \\CREATE TABLE IF NOT EXISTS simplespace_members ( 5794 + \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 5795 + \\ member_did TEXT NOT NULL, 5796 + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 5797 + \\ PRIMARY KEY (space, member_did) 5798 + \\) 5799 + , 5800 + "CREATE INDEX IF NOT EXISTS simplespace_members_member_idx ON simplespace_members (member_did, space)", 5605 5801 "CREATE INDEX IF NOT EXISTS permissioned_space_actor_state_actor_idx ON permissioned_space_actor_state (actor_did, space)", 5606 5802 \\CREATE TABLE IF NOT EXISTS permissioned_space_records ( 5607 5803 \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, ··· 6032 6228 .skey = "self", 6033 6229 .is_authority = true, 6034 6230 .managing_app = "did:web:plyr.fm", 6035 - .is_public = false, 6036 - .app_access_mode = "allow", 6037 - .app_exceptions_json = "[]", 6231 + .policy = "member-list", 6232 + .app_access_json = "{\"type\":\"open\"}", 6038 6233 }); 6039 6234 try std.testing.expectEqualStrings("ats://did:plc:spaceauthorityalice/fm.plyr.privateMedia/self", space.uri); 6040 6235 try std.testing.expect(space.is_authority); ··· 6046 6241 .skey = "self", 6047 6242 .is_authority = true, 6048 6243 .managing_app = null, 6049 - .is_public = false, 6050 - .app_access_mode = "allow", 6051 - .app_exceptions_json = "[]", 6244 + .policy = "member-list", 6245 + .app_access_json = "{\"type\":\"open\"}", 6052 6246 })); 6053 6247 6054 6248 const parsed = try std.json.parseFromSlice( ··· 6098 6292 .skey = "self", 6099 6293 .is_authority = true, 6100 6294 .managing_app = null, 6101 - .is_public = false, 6102 - .app_access_mode = "allow", 6103 - .app_exceptions_json = "[]", 6295 + .policy = "member-list", 6296 + .app_access_json = "{\"type\":\"open\"}", 6104 6297 }); 6105 6298 6106 6299 const owner_view = (try getSpace(allocator, owner.did, created.uri)).?; ··· 6134 6327 .skey = "self", 6135 6328 .is_authority = false, 6136 6329 .managing_app = null, 6137 - .is_public = false, 6138 - .app_access_mode = "allow", 6139 - .app_exceptions_json = "[]", 6330 + .policy = "member-list", 6331 + .app_access_json = "{\"type\":\"open\"}", 6140 6332 }); 6141 6333 try std.testing.expect(!viewer_row.is_authority); 6142 6334 ··· 6147 6339 .skey = "self", 6148 6340 .is_authority = true, 6149 6341 .managing_app = "did:web:plyr.fm", 6150 - .is_public = true, 6151 - .app_access_mode = "deny", 6152 - .app_exceptions_json = "[\"did:web:allowed.example\"]", 6342 + .policy = "public", 6343 + .app_access_json = "{\"type\":\"allowList\",\"allowed\":[\"did:web:allowed.example\"]}", 6153 6344 }); 6154 6345 try std.testing.expect(owner_row.is_authority); 6155 - try std.testing.expect(owner_row.is_public); 6346 + try std.testing.expectEqualStrings("public", owner_row.policy); 6156 6347 try std.testing.expectEqualStrings("did:web:plyr.fm", owner_row.managing_app.?); 6157 6348 6158 6349 try std.testing.expectError(Error.InvalidRefreshSession, createSpace(allocator, .{ ··· 6162 6353 .skey = "self", 6163 6354 .is_authority = false, 6164 6355 .managing_app = null, 6165 - .is_public = false, 6166 - .app_access_mode = "allow", 6167 - .app_exceptions_json = "[]", 6356 + .policy = "member-list", 6357 + .app_access_json = "{\"type\":\"open\"}", 6168 6358 })); 6169 6359 } 6170 6360
+9 -5
tools/smoke-permissioned.sh
··· 66 66 "$base/xrpc/com.atproto.sync.getBlob?did=did:plc:permissionsmoke&cid=$blob_cid") 67 67 test "$public_blob_status" = "404" 68 68 69 - space_create=$(curl -fsS -X POST "$base/xrpc/com.atproto.space.createSpace" \ 69 + space_create=$(curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ 70 70 -H "authorization: Bearer $token" \ 71 71 -H 'content-type: application/json' \ 72 - --data '{"authority":"did:plc:permissionsmoke","type":"fm.plyr.privateMedia","skey":"self","managingApp":"did:web:plyr.fm","isPublic":false,"appAccessMode":"allow","appExceptions":[]}') 72 + --data '{"type":"fm.plyr.privateMedia","skey":"self","managingApp":"did:web:plyr.fm","policy":"member-list","appAccess":{"type":"open"}}') 73 73 printf '%s' "$space_create" | grep -q '"uri":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self"' 74 - ! printf '%s' "$space_create" | grep -q '"config":' 74 + printf '%s' "$space_create" | grep -q '"config":' 75 75 76 - space_duplicate_status=$(curl -sS -o /tmp/zds-space-duplicate.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.space.createSpace" \ 76 + space_duplicate_status=$(curl -sS -o /tmp/zds-space-duplicate.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ 77 77 -H "authorization: Bearer $token" \ 78 78 -H 'content-type: application/json' \ 79 - --data '{"authority":"did:plc:permissionsmoke","type":"fm.plyr.privateMedia","skey":"self","isPublic":false}') 79 + --data '{"type":"fm.plyr.privateMedia","skey":"self","policy":"member-list"}') 80 80 test "$space_duplicate_status" = "400" 81 81 grep -q '"error":"SpaceAlreadyExists"' /tmp/zds-space-duplicate.json 82 82 83 83 space_get=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getSpace?space=$encoded_space") 84 84 printf '%s' "$space_get" | grep -q '"isAuthority":true' 85 85 printf '%s' "$space_get" | grep -q '"managingApp":"did:web:plyr.fm"' 86 + printf '%s' "$space_get" | grep -q '"policy":"member-list"' 86 87 ! printf '%s' "$space_get" | grep -q '"members":' 87 88 88 89 space_list=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listSpaces?authority=did:plc:permissionsmoke&type=fm.plyr.privateMedia") ··· 90 91 printf '%s' "$space_list" | grep -q '"isAuthority":true' 91 92 printf '%s' "$space_list" | grep -q '"cursor":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self"' 92 93 ! printf '%s' "$space_list" | grep -q '"isMember":' 94 + 95 + space_members=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.simplespace.listMembers?space=$encoded_space") 96 + printf '%s' "$space_members" | grep -q '"did":"did:plc:permissionsmoke"' 93 97 94 98 space_record=$(curl -fsS -X POST "$base/xrpc/com.atproto.space.createRecord" \ 95 99 -H "authorization: Bearer $token" \