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

Configure Feed

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

Remove protocol member lists from spaces

zzstoatzz (Jun 9, 2026, 10:29 PM -0500) ab25aff2 75c1a358

+50 -520
+4 -5
bench/README.md
··· 66 66 67 67 When adding the first permissioned-data probe, keep it out of `all` until the 68 68 protocol shape settles. A useful first pass should seed one self-owned space, 69 - one member repo, and one small blob, then measure these paths as distinct units 69 + one writer repo, and one small blob, then measure these paths as distinct units 70 70 of work: 71 71 72 - - `createSpace` with owner auto-membership 73 - - `addMember` / `removeMember` and member LtHash/oplog updates 74 - - `createRecord` or `applyWrites` into a space member repo 72 + - `createSpace` 73 + - `createRecord` or `applyWrites` into a space writer repo 75 74 - `getRecord` by `(space, repo, collection, rkey)` 76 75 - `listRecords`, limit 50, index-only response shape 77 76 - ranged `getBlob` 78 - - `getRepoOplog` and `getMemberOplog` catch-up reads 77 + - `getRepoOplog` catch-up reads 79 78 80 79 Comparison against Daniel's permissioned-data branch should live in this 81 80 section once we have a repeatable way to run that PDS locally. Until then, keep
+6 -163
src/atproto/space.zig
··· 34 34 if (std.mem.eql(u8, method, "com.atproto.space.listSpaces")) return listSpaces(request); 35 35 if (std.mem.eql(u8, method, "com.atproto.space.updateSpaceConfig")) return updateSpaceConfig(request); 36 36 if (std.mem.eql(u8, method, "com.atproto.space.deleteSpace")) return deleteSpace(request); 37 - if (std.mem.eql(u8, method, "com.atproto.space.addMember")) return addMember(request); 38 - if (std.mem.eql(u8, method, "com.atproto.space.removeMember")) return removeMember(request); 39 - if (std.mem.eql(u8, method, "com.atproto.space.getMembers")) return getMembers(request); 40 - if (std.mem.eql(u8, method, "com.atproto.space.getMemberState")) return getMemberState(request); 41 - if (std.mem.eql(u8, method, "com.atproto.space.getMemberOplog")) return getMemberOplog(request); 42 37 if (std.mem.eql(u8, method, "com.atproto.space.getMemberGrant")) return getMemberGrant(request); 43 38 if (std.mem.eql(u8, method, "com.atproto.space.createRecord")) return createRecord(request); 44 39 if (std.mem.eql(u8, method, "com.atproto.space.putRecord")) return putRecord(request); ··· 50 45 if (std.mem.eql(u8, method, "com.atproto.space.getRepoState")) return getRepoState(request); 51 46 if (std.mem.eql(u8, method, "com.atproto.space.getRepoOplog")) return getRepoOplog(request); 52 47 if (std.mem.eql(u8, method, "com.atproto.space.getSpaceCredential")) return getSpaceCredential(request); 53 - if (std.mem.eql(u8, method, "com.atproto.space.notifyMembership")) return notifyMembership(request); 54 48 if (std.mem.eql(u8, method, "com.atproto.space.notifyWrite")) return notifyWrite(request); 55 49 if (std.mem.eql(u8, method, "com.atproto.space.notifySpaceDeleted")) return notifySpaceDeleted(request); 56 50 ··· 243 237 const recipients = try store.listCredentialRecipients(allocator, space); 244 238 try store.markSpaceDeleted(auth_ctx.account.did, space); 245 239 try store.purgeOwnerSpaceData(space); 246 - if (existing) |space_row| fireNotifySpaceDeleted(allocator, auth_ctx.account, space, space_row.members, recipients) catch {}; 247 - return http_api.json(request, .ok, "{}"); 248 - } 249 - 250 - fn addMember(request: *http_api.Request) !void { 251 - return mutateMember(request, true); 252 - } 253 - 254 - fn removeMember(request: *http_api.Request) !void { 255 - return mutateMember(request, false); 256 - } 257 - 258 - fn mutateMember(request: *http_api.Request, add: bool) !void { 259 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 260 - defer arena.deinit(); 261 - const allocator = arena.allocator(); 262 - const auth_ctx = requireAccount(request, allocator) catch return; 263 - const parsed_body = parseBody(request, allocator, 16 * 1024) catch |err| switch (err) { 264 - error.HandledResponse => return, 265 - else => return err, 266 - }; 267 - const input = parsed_body.value; 268 - const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 269 - const member = zat.json.getString(input, "did") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 270 - const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 271 - if (!std.mem.eql(u8, parsed.did, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); 272 - try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.did, parsed.skey, .manage, null); 273 - const existing = (try store.getSpace(allocator, auth_ctx.account.did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 274 - if (!existing.is_owner) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); 275 - _ = (if (add) 276 - store.addSpaceMember(allocator, space, member) 277 - else 278 - store.removeSpaceMember(allocator, space, member)) catch |err| switch (err) { 279 - error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), 280 - error.InvalidRefreshSession => return http_api.xrpcError(request, .bad_request, "MemberAlreadyExists", "Member already exists"), 281 - error.MissingRecord => return http_api.xrpcError(request, .bad_request, "MemberNotFound", "Member not found"), 282 - else => return err, 283 - }; 284 - fireNotifyMembership(allocator, auth_ctx.account, space, member, add) catch {}; 240 + fireNotifySpaceDeleted(allocator, auth_ctx.account, space, recipients) catch {}; 285 241 return http_api.json(request, .ok, "{}"); 286 242 } 287 243 288 - fn getMembers(request: *http_api.Request) !void { 289 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 290 - defer arena.deinit(); 291 - const allocator = arena.allocator(); 292 - var space_buf: [1024]u8 = undefined; 293 - const space_uri = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 294 - _ = requireReadAccess(request, allocator, space_uri, null) catch return; 295 - const parsed = parseSpaceUri(space_uri) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 296 - _ = (try store.getSpace(allocator, parsed.did, space_uri)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); 297 - var cursor_buf: [256]u8 = undefined; 298 - const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); 299 - const limit = http_api.queryLimit(request.url.raw, 100); 300 - const members = try store.listSpaceMembers(allocator, space_uri, maybe_cursor, limit); 301 - var out: std.Io.Writer.Allocating = .init(allocator); 302 - defer out.deinit(); 303 - try out.writer.writeAll("{\"members\":["); 304 - for (members, 0..) |member, idx| { 305 - if (idx != 0) try out.writer.writeByte(','); 306 - try out.writer.print("{{\"did\":{f}}}", .{std.json.fmt(member, .{})}); 307 - } 308 - try out.writer.writeByte(']'); 309 - if (members.len > 0 and members.len == @min(if (limit == 0) 100 else limit, 1000)) { 310 - try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(members[members.len - 1], .{})}); 311 - } 312 - try out.writer.writeByte('}'); 313 - return http_api.json(request, .ok, out.written()); 314 - } 315 - 316 - fn getMemberState(request: *http_api.Request) !void { 317 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 318 - defer arena.deinit(); 319 - const allocator = arena.allocator(); 320 - var space_buf: [1024]u8 = undefined; 321 - const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 322 - _ = requireReadAccess(request, allocator, space, null) catch return; 323 - const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 324 - const state = try store.getSpaceMemberState(allocator, space); 325 - return writeSignedState(request, allocator, space, parsed.did, parsed.did, .members, state); 326 - } 327 - 328 - fn getMemberOplog(request: *http_api.Request) !void { 329 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 330 - defer arena.deinit(); 331 - const allocator = arena.allocator(); 332 - var space_buf: [1024]u8 = undefined; 333 - const space = http_api.queryParam(request.url.raw, "space", &space_buf) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 334 - _ = requireReadAccess(request, allocator, space, null) catch return; 335 - var since_buf: [128]u8 = undefined; 336 - const since = http_api.queryParam(request.url.raw, "since", &since_buf); 337 - const limit = http_api.queryLimit(request.url.raw, 100); 338 - const ops = try store.listSpaceMemberOplog(allocator, space, since, limit); 339 - const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 340 - const state = try store.getSpaceMemberState(allocator, space); 341 - var out: std.Io.Writer.Allocating = .init(allocator); 342 - defer out.deinit(); 343 - try out.writer.writeAll("{\"ops\":["); 344 - for (ops, 0..) |op, idx| { 345 - if (idx != 0) try out.writer.writeByte(','); 346 - try out.writer.print("{{\"rev\":{f},\"action\":{f},\"did\":{f}}}", .{ std.json.fmt(op.rev, .{}), std.json.fmt(op.action, .{}), std.json.fmt(op.did, .{}) }); 347 - } 348 - try out.writer.writeByte(']'); 349 - if (ops.len < @min(if (limit == 0) 100 else limit, 1000)) { 350 - if (try signedCommitJson(allocator, space, parsed.did, parsed.did, .members, state)) |commit_json| { 351 - try out.writer.print(",\"commit\":{s}", .{commit_json}); 352 - } 353 - } 354 - try out.writer.writeByte('}'); 355 - return http_api.json(request, .ok, out.written()); 356 - } 357 - 358 244 fn getMemberGrant(request: *http_api.Request) !void { 359 245 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 360 246 defer arena.deinit(); ··· 715 601 if (config_row.deleted_at != null) { 716 602 return http_api.xrpcError(request, .bad_request, "SpaceDeleted", "Space has been deleted"); 717 603 } 718 - if (!config_row.is_public and !try store.spaceHasMember(allocator, space, grant.member_did)) { 719 - return http_api.xrpcError(request, .forbidden, "NotAMember", "Not a member of the space"); 604 + if (!config_row.is_public and !std.mem.eql(u8, grant.member_did, parsed.did)) { 605 + return http_api.xrpcError(request, .forbidden, "NotPermitted", "The space host did not grant access to this requester"); 720 606 } 721 607 if (!spaceAllowsClient(allocator, config_row, grant.client_id)) { 722 608 return http_api.xrpcError(request, .forbidden, "AppNotPermitted", "OAuth client is not allowed for this space"); ··· 729 615 return http_api.json(request, .ok, try std.fmt.allocPrint(allocator, "{{\"credential\":{f}}}", .{std.json.fmt(credential, .{})})); 730 616 } 731 617 732 - fn notifyMembership(request: *http_api.Request) !void { 733 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 734 - defer arena.deinit(); 735 - const allocator = arena.allocator(); 736 - const parsed_body = parseBody(request, allocator, 16 * 1024) catch |err| switch (err) { 737 - error.HandledResponse => return, 738 - else => return err, 739 - }; 740 - const input = parsed_body.value; 741 - const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); 742 - const member = zat.json.getString(input, "did") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); 743 - const is_member = valueBool(input, "isMember") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing isMember"); 744 - const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); 745 - const service = requireServiceAuth(request, allocator, "com.atproto.space.notifyMembership") catch return; 746 - if (!std.mem.eql(u8, service.issuer_did, parsed.did)) return http_api.xrpcError(request, .unauthorized, "InvalidToken", "JWT issuer must be the space DID"); 747 - if (!std.mem.eql(u8, service.audience, member)) return http_api.xrpcError(request, .unauthorized, "BadJwtAudience", "JWT audience must be the member DID"); 748 - if ((store.findAccount(allocator, member) catch null) == null) return http_api.xrpcError(request, .bad_request, "AccountNotFound", "Account not found on this PDS"); 749 - try store.setSpaceMembership(allocator, space, member, is_member); 750 - return http_api.json(request, .ok, "{}"); 751 - } 752 - 753 618 fn notifyWrite(request: *http_api.Request) !void { 754 619 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 755 620 defer arena.deinit(); ··· 767 632 if (!std.mem.eql(u8, service.issuer_did, repo)) return http_api.xrpcError(request, .unauthorized, "InvalidToken", "JWT issuer must be the writer repo DID"); 768 633 if (!std.mem.eql(u8, service.audience, parsed.did)) return http_api.xrpcError(request, .unauthorized, "BadJwtAudience", "JWT audience must be the space DID"); 769 634 const owner = (store.findAccount(allocator, parsed.did) catch null) orelse return http_api.json(request, .ok, "{}"); 770 - if (!try store.spaceHasMember(allocator, space, repo)) return http_api.xrpcError(request, .forbidden, "NotAMember", "Writer is not a member of the space"); 771 635 try fanoutNotifyWriteToRecipients(allocator, owner, space, repo, rev); 772 636 return http_api.json(request, .ok, "{}"); 773 637 } ··· 975 839 if (@intFromEnum(result.status) >= 500) return error.RemoteServerError; 976 840 } 977 841 978 - fn fireNotifyMembership(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, member: []const u8, is_member: bool) !void { 979 - const endpoint = try pdsEndpointForDid(allocator, member); 980 - const body = try std.fmt.allocPrint( 981 - allocator, 982 - "{{\"space\":{f},\"did\":{f},\"isMember\":{}}}", 983 - .{ std.json.fmt(space, .{}), std.json.fmt(member, .{}), is_member }, 984 - ); 985 - return postSignedXrpc(allocator, account, endpoint, member, "com.atproto.space.notifyMembership", body); 986 - } 987 - 988 842 fn fireNotifyWrite(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, repo: []const u8, rev: []const u8) !void { 989 843 const parsed = parseSpaceUri(space) orelse return error.InvalidSpaceUri; 990 844 if (!std.mem.eql(u8, repo, parsed.did)) { ··· 1013 867 } 1014 868 } 1015 869 1016 - fn fireNotifySpaceDeleted(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, members: []const []const u8, recipients: []const store.CredentialRecipient) !void { 870 + fn fireNotifySpaceDeleted(allocator: std.mem.Allocator, account: auth.Account, space: []const u8, recipients: []const store.CredentialRecipient) !void { 1017 871 const body = try std.fmt.allocPrint(allocator, "{{\"space\":{f}}}", .{std.json.fmt(space, .{})}); 1018 - for (members) |member| { 1019 - if (std.mem.eql(u8, member, account.did)) continue; 1020 - const endpoint = pdsEndpointForDid(allocator, member) catch continue; 1021 - postSignedXrpc(allocator, account, endpoint, member, "com.atproto.space.notifySpaceDeleted", body) catch {}; 1022 - } 1023 872 for (recipients) |recipient| { 1024 873 postSignedXrpc(allocator, account, recipient.service_endpoint, recipient.service_did, "com.atproto.space.notifySpaceDeleted", body) catch {}; 1025 874 } ··· 1228 1077 var out: std.Io.Writer.Allocating = .init(allocator); 1229 1078 defer out.deinit(); 1230 1079 try out.writer.print( 1231 - "{{\"uri\":{f},\"did\":{f},\"type\":{f},\"skey\":{f},\"isOwner\":{},\"isMember\":{},\"isPublic\":{},\"appAccessMode\":{f}", 1080 + "{{\"uri\":{f},\"did\":{f},\"type\":{f},\"skey\":{f},\"isOwner\":{},\"isPublic\":{},\"appAccessMode\":{f}", 1232 1081 .{ 1233 1082 std.json.fmt(space.uri, .{}), 1234 1083 std.json.fmt(space.owner_did, .{}), 1235 1084 std.json.fmt(space.space_type, .{}), 1236 1085 std.json.fmt(space.skey, .{}), 1237 1086 space.is_owner, 1238 - space.is_member, 1239 1087 space.is_public, 1240 1088 std.json.fmt(space.app_access_mode, .{}), 1241 1089 }, ··· 1243 1091 if (space.managing_app) |managing_app| { 1244 1092 try out.writer.print(",\"managingApp\":{f}", .{std.json.fmt(managing_app, .{})}); 1245 1093 } 1246 - try out.writer.print(",\"appExceptions\":{s},\"members\":[", .{space.app_exceptions_json}); 1247 - for (space.members, 0..) |member, idx| { 1248 - if (idx != 0) try out.writer.writeByte(','); 1249 - try out.writer.print("{f}", .{std.json.fmt(member, .{})}); 1250 - } 1251 - try out.writer.writeAll("]}"); 1094 + try out.writer.print(",\"appExceptions\":{s}}}", .{space.app_exceptions_json}); 1252 1095 return out.toOwnedSlice(); 1253 1096 } 1254 1097
+2 -8
src/http/router.zig
··· 207 207 .{ .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." }, 208 208 .{ .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." }, 209 209 210 - .{ .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." }, 211 - .{ .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." }, 212 - .{ .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." }, 213 - .{ .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." }, 214 - .{ .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." }, 215 210 .{ .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." }, 216 - .{ .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." }, 217 211 218 212 .{ .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." }, 219 213 .{ .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." }, ··· 222 216 .{ .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." }, 223 217 .{ .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." }, 224 218 .{ .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." }, 225 - .{ .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." }, 226 - .{ .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." }, 219 + .{ .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 writer repo in a space.", .params = &.{ "space", "repo" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 220 + .{ .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 writer repo in a space.", .params = &.{ "space", "repo", "since", "limit" }, .notes = "Experimental and gated by ZDS_PERMISSIONED_DATA." }, 227 221 .{ .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." }, 228 222 229 223 .{ .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." },
-2
src/internal/permissioned_data.zig
··· 72 72 73 73 pub const Scope = enum { 74 74 records, 75 - members, 76 75 77 76 pub fn text(self: Scope) []const u8 { 78 77 return switch (self) { 79 78 .records => "records", 80 - .members => "members", 81 79 }; 82 80 } 83 81 };
+4 -10
src/internal/private_spaces.zig
··· 64 64 \\<h2>record set</h2> 65 65 \\<div class="grid"> 66 66 \\<div> 67 - \\<label for="repo">member repo</label> 68 - \\<select id="repo"></select> 67 + \\<label for="repo">writer repo</label> 68 + \\<input id="repo" spellcheck="false"> 69 69 \\</div> 70 70 \\<div> 71 71 \\<label for="collection">collection, optional</label> ··· 117 117 \\async function selectSpace(uri){ 118 118 \\ selectedSpace=uri; cursor=null; $('records').innerHTML=''; $('record-detail').className='empty'; $('record-detail').textContent='Choose a record.'; $('record-count').textContent='0'; 119 119 \\ for(const btn of $('spaces').querySelectorAll('.tab')) btn.ariaSelected=btn.textContent===spaceParts(uri).type+' / '+spaceParts(uri).skey?'true':'false'; 120 - \\ const repo=$('repo'); repo.innerHTML=''; 121 - \\ const add=(did)=>{const opt=document.createElement('option');opt.value=did;opt.textContent=did;repo.appendChild(opt)}; 122 - \\ add(accountDid); 123 - \\ try{ 124 - \\ const out=await xrpc('/xrpc/com.atproto.space.getMembers',{space:uri,limit:'100'}); 125 - \\ for(const member of out.members||[]) if(member.did!==accountDid) add(member.did); 126 - \\ }catch(err){setStatus('signed in, but members could not be loaded: '+err.message,'warn')} 120 + \\ $('repo').value=accountDid; 127 121 \\} 128 122 \\async function loadSpaces(){ 129 123 \\ setStatus('loading spaces...'); ··· 145 139 \\async function loadRecords(append=false){ 146 140 \\ if(!selectedSpace){setStatus('choose a space first','error');return} 147 141 \\ const repo=$('repo').value.trim(); const collection=$('collection').value.trim(); 148 - \\ if(!repo){setStatus('choose a member repo','error');return} 142 + \\ if(!repo){setStatus('enter a writer repo DID','error');return} 149 143 \\ setStatus('loading records...'); 150 144 \\ const params={space:selectedSpace,repo,limit:'50'}; if(collection) params.collection=collection; if(append&&cursor) params.cursor=cursor; 151 145 \\ const out=await xrpc('/xrpc/com.atproto.space.listRecords',params);
+30 -329
src/storage/store.zig
··· 264 264 app_access_mode: []const u8, 265 265 app_exceptions_json: []const u8, 266 266 is_owner: bool, 267 - is_member: bool, 268 267 deleted_at: ?i64, 269 - members: []const []const u8, 270 268 }; 271 269 272 270 pub const SpaceRecord = struct { ··· 300 298 rkey: []const u8, 301 299 cid: ?[]const u8, 302 300 prev: ?[]const u8, 303 - }; 304 - 305 - pub const SpaceMemberOplogEntry = struct { 306 - rev: []const u8, 307 - idx: i64, 308 - action: []const u8, 309 - did: []const u8, 310 301 }; 311 302 312 303 pub const SpaceState = struct { ··· 2689 2680 \\ON CONFLICT(space, repo_did) DO NOTHING 2690 2681 , .{ uri, input.owner_did }); 2691 2682 try conn.exec( 2692 - \\INSERT INTO permissioned_space_member_state (space, set_hash, rev) 2693 - \\VALUES (?, NULL, NULL) 2694 - \\ON CONFLICT(space) DO NOTHING 2695 - , .{uri}); 2696 - try conn.exec( 2697 - \\INSERT INTO permissioned_space_actor_state (space, actor_did, is_owner, is_member) 2698 - \\VALUES (?, ?, ?, 0) 2683 + \\INSERT INTO permissioned_space_actor_state (space, actor_did, is_owner) 2684 + \\VALUES (?, ?, ?) 2699 2685 , .{ uri, input.actor_did, @as(i64, if (input.is_owner) 1 else 0) }); 2700 - if (input.is_owner) { 2701 - _ = try addSpaceMemberLocked(allocator, uri, input.owner_did); 2702 - try conn.exec("UPDATE permissioned_space_actor_state SET is_member = 1 WHERE space = ? AND actor_did = ?", .{ uri, input.actor_did }); 2703 - } 2704 2686 try conn.commit(); 2705 2687 2706 2688 return (try getSpaceLocked(allocator, input.actor_did, uri)) orelse Error.RepoNotFound; ··· 2733 2715 \\SELECT s.uri 2734 2716 \\FROM permissioned_spaces s 2735 2717 \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2736 - \\WHERE a.actor_did = ? AND s.owner_did = ? AND s.space_type = ? AND s.uri > ? AND (a.is_owner = 1 OR a.is_member = 1) 2718 + \\WHERE a.actor_did = ? AND s.owner_did = ? AND s.space_type = ? AND s.uri > ? AND a.is_owner = 1 2737 2719 \\ORDER BY s.uri ASC 2738 2720 \\LIMIT ? 2739 2721 , .{ actor_did, did, space_type, cursor, capped_limit }) ··· 2742 2724 \\SELECT s.uri 2743 2725 \\FROM permissioned_spaces s 2744 2726 \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2745 - \\WHERE a.actor_did = ? AND s.owner_did = ? AND s.space_type = ? AND (a.is_owner = 1 OR a.is_member = 1) 2727 + \\WHERE a.actor_did = ? AND s.owner_did = ? AND s.space_type = ? AND a.is_owner = 1 2746 2728 \\ORDER BY s.uri ASC 2747 2729 \\LIMIT ? 2748 2730 , .{ actor_did, did, space_type, capped_limit }) ··· 2751 2733 \\SELECT s.uri 2752 2734 \\FROM permissioned_spaces s 2753 2735 \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2754 - \\WHERE a.actor_did = ? AND s.owner_did = ? AND s.uri > ? AND (a.is_owner = 1 OR a.is_member = 1) 2736 + \\WHERE a.actor_did = ? AND s.owner_did = ? AND s.uri > ? AND a.is_owner = 1 2755 2737 \\ORDER BY s.uri ASC 2756 2738 \\LIMIT ? 2757 2739 , .{ actor_did, did, cursor, capped_limit }) ··· 2760 2742 \\SELECT s.uri 2761 2743 \\FROM permissioned_spaces s 2762 2744 \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2763 - \\WHERE a.actor_did = ? AND s.owner_did = ? AND (a.is_owner = 1 OR a.is_member = 1) 2745 + \\WHERE a.actor_did = ? AND s.owner_did = ? AND a.is_owner = 1 2764 2746 \\ORDER BY s.uri ASC 2765 2747 \\LIMIT ? 2766 2748 , .{ actor_did, did, capped_limit }) ··· 2770 2752 \\SELECT s.uri 2771 2753 \\FROM permissioned_spaces s 2772 2754 \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2773 - \\WHERE a.actor_did = ? AND s.space_type = ? AND s.uri > ? AND (a.is_owner = 1 OR a.is_member = 1) 2755 + \\WHERE a.actor_did = ? AND s.space_type = ? AND s.uri > ? AND a.is_owner = 1 2774 2756 \\ORDER BY s.uri ASC 2775 2757 \\LIMIT ? 2776 2758 , .{ actor_did, space_type, cursor, capped_limit }) ··· 2779 2761 \\SELECT s.uri 2780 2762 \\FROM permissioned_spaces s 2781 2763 \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2782 - \\WHERE a.actor_did = ? AND s.space_type = ? AND (a.is_owner = 1 OR a.is_member = 1) 2764 + \\WHERE a.actor_did = ? AND s.space_type = ? AND a.is_owner = 1 2783 2765 \\ORDER BY s.uri ASC 2784 2766 \\LIMIT ? 2785 2767 , .{ actor_did, space_type, capped_limit }) ··· 2788 2770 \\SELECT s.uri 2789 2771 \\FROM permissioned_spaces s 2790 2772 \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2791 - \\WHERE a.actor_did = ? AND s.uri > ? AND (a.is_owner = 1 OR a.is_member = 1) 2773 + \\WHERE a.actor_did = ? AND s.uri > ? AND a.is_owner = 1 2792 2774 \\ORDER BY s.uri ASC 2793 2775 \\LIMIT ? 2794 2776 , .{ actor_did, cursor, capped_limit }) ··· 2797 2779 \\SELECT s.uri 2798 2780 \\FROM permissioned_spaces s 2799 2781 \\JOIN permissioned_space_actor_state a ON a.space = s.uri 2800 - \\WHERE a.actor_did = ? AND (a.is_owner = 1 OR a.is_member = 1) 2782 + \\WHERE a.actor_did = ? AND a.is_owner = 1 2801 2783 \\ORDER BY s.uri ASC 2802 2784 \\LIMIT ? 2803 2785 , .{ actor_did, capped_limit }); ··· 2857 2839 try requireInitialized(); 2858 2840 try conn.exclusiveTransaction(); 2859 2841 errdefer conn.rollback(); 2860 - try conn.exec("DELETE FROM permissioned_space_members WHERE space = ?", .{space}); 2861 - try conn.exec("DELETE FROM permissioned_space_member_state WHERE space = ?", .{space}); 2862 - try conn.exec("DELETE FROM permissioned_space_member_oplog WHERE space = ?", .{space}); 2863 2842 try conn.exec("DELETE FROM permissioned_space_credential_recipients WHERE space = ?", .{space}); 2864 2843 try conn.commit(); 2865 2844 } 2866 2845 2867 - pub fn setSpaceMembership(allocator: std.mem.Allocator, space: []const u8, member_did: []const u8, is_member: bool) !void { 2868 - if (zat.Did.parse(member_did) == null) return Error.InvalidRepoPath; 2869 - db_mutex.lockUncancelable(store_io); 2870 - defer db_mutex.unlock(store_io); 2871 - try requireInitialized(); 2872 - if (try getSpaceConfigLocked(allocator, space) == null) { 2873 - const parts = parseSpaceParts(space) orelse return Error.InvalidRepoPath; 2874 - try conn.exec( 2875 - \\INSERT INTO permissioned_spaces ( 2876 - \\ uri, owner_did, space_type, skey, managing_app, is_public, app_access_mode, app_exceptions_json 2877 - \\) VALUES (?, ?, ?, ?, NULL, 0, 'allow', '[]') 2878 - \\ON CONFLICT(uri) DO NOTHING 2879 - , .{ space, parts.owner_did, parts.space_type, parts.skey }); 2880 - } 2881 - try conn.exec( 2882 - \\INSERT INTO permissioned_space_actor_state (space, actor_did, is_owner, is_member) 2883 - \\VALUES (?, ?, 0, ?) 2884 - \\ON CONFLICT(space, actor_did) DO UPDATE SET 2885 - \\ is_member = excluded.is_member 2886 - , .{ space, member_did, @as(i64, if (is_member) 1 else 0) }); 2887 - if (is_member) { 2888 - try conn.exec( 2889 - \\INSERT INTO permissioned_space_repos (space, repo_did, set_hash, rev) 2890 - \\VALUES (?, ?, NULL, NULL) 2891 - \\ON CONFLICT(space, repo_did) DO NOTHING 2892 - , .{ space, member_did }); 2893 - } 2894 - } 2895 - 2896 - pub fn spaceHasMember(allocator: std.mem.Allocator, space: []const u8, did: []const u8) !bool { 2897 - _ = allocator; 2898 - db_mutex.lockUncancelable(store_io); 2899 - defer db_mutex.unlock(store_io); 2900 - try requireInitialized(); 2901 - return spaceHasMemberLocked(space, did); 2902 - } 2903 - 2904 - pub fn listSpaceMembers(allocator: std.mem.Allocator, space: []const u8, maybe_cursor: ?[]const u8, limit: usize) ![]const []const u8 { 2905 - db_mutex.lockUncancelable(store_io); 2906 - defer db_mutex.unlock(store_io); 2907 - try requireInitialized(); 2908 - return listSpaceMembersPageLocked(allocator, space, maybe_cursor, limit); 2909 - } 2910 - 2911 2846 pub fn recordCredentialRecipient(space: []const u8, service_did: []const u8, service_endpoint: []const u8) !void { 2912 2847 db_mutex.lockUncancelable(store_io); 2913 2848 defer db_mutex.unlock(store_io); ··· 3085 3020 defer db_mutex.unlock(store_io); 3086 3021 try requireInitialized(); 3087 3022 _ = (try getSpaceConfigLocked(allocator, space)) orelse return Error.RepoNotFound; 3088 - if (!try actorIsSpaceParticipantLocked(space, repo_did)) return Error.InvalidRefreshSession; 3089 3023 3090 3024 try conn.exclusiveTransaction(); 3091 3025 errdefer conn.rollback(); ··· 3147 3081 return results.toOwnedSlice(allocator); 3148 3082 } 3149 3083 3150 - pub fn addSpaceMember(allocator: std.mem.Allocator, space: []const u8, did: []const u8) ![]const u8 { 3151 - db_mutex.lockUncancelable(store_io); 3152 - defer db_mutex.unlock(store_io); 3153 - try requireInitialized(); 3154 - try conn.exclusiveTransaction(); 3155 - errdefer conn.rollback(); 3156 - const rev = try addSpaceMemberLocked(allocator, space, did); 3157 - try conn.commit(); 3158 - return rev; 3159 - } 3160 - 3161 - pub fn removeSpaceMember(allocator: std.mem.Allocator, space: []const u8, did: []const u8) ![]const u8 { 3162 - db_mutex.lockUncancelable(store_io); 3163 - defer db_mutex.unlock(store_io); 3164 - try requireInitialized(); 3165 - try conn.exclusiveTransaction(); 3166 - errdefer conn.rollback(); 3167 - const rev = try removeSpaceMemberLocked(allocator, space, did); 3168 - try conn.commit(); 3169 - return rev; 3170 - } 3171 - 3172 3084 pub fn getSpaceRepoState(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8) !SpaceState { 3173 3085 db_mutex.lockUncancelable(store_io); 3174 3086 defer db_mutex.unlock(store_io); 3175 3087 try requireInitialized(); 3176 3088 return getRepoStateLocked(allocator, space, repo_did); 3177 - } 3178 - 3179 - pub fn getSpaceMemberState(allocator: std.mem.Allocator, space: []const u8) !SpaceState { 3180 - db_mutex.lockUncancelable(store_io); 3181 - defer db_mutex.unlock(store_io); 3182 - try requireInitialized(); 3183 - return getMemberStateLocked(allocator, space); 3184 3089 } 3185 3090 3186 3091 pub fn listSpaceRecordOplog(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, since: ?[]const u8, limit: usize) ![]SpaceRecordOplogEntry { ··· 3222 3127 return out.toOwnedSlice(allocator); 3223 3128 } 3224 3129 3225 - pub fn listSpaceMemberOplog(allocator: std.mem.Allocator, space: []const u8, since: ?[]const u8, limit: usize) ![]SpaceMemberOplogEntry { 3226 - db_mutex.lockUncancelable(store_io); 3227 - defer db_mutex.unlock(store_io); 3228 - try requireInitialized(); 3229 - const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); 3230 - var rows = if (since) |rev| 3231 - try conn.rows( 3232 - \\SELECT rev, idx, action, did 3233 - \\FROM permissioned_space_member_oplog 3234 - \\WHERE space = ? AND rev > ? 3235 - \\ORDER BY rev ASC, idx ASC 3236 - \\LIMIT ? 3237 - , .{ space, rev, capped_limit }) 3238 - else 3239 - try conn.rows( 3240 - \\SELECT rev, idx, action, did 3241 - \\FROM permissioned_space_member_oplog 3242 - \\WHERE space = ? 3243 - \\ORDER BY rev ASC, idx ASC 3244 - \\LIMIT ? 3245 - , .{ space, capped_limit }); 3246 - defer rows.deinit(); 3247 - var out: std.ArrayList(SpaceMemberOplogEntry) = .empty; 3248 - while (rows.next()) |row| { 3249 - try out.append(allocator, .{ 3250 - .rev = try allocator.dupe(u8, row.text(0)), 3251 - .idx = row.int(1), 3252 - .action = try allocator.dupe(u8, row.text(2)), 3253 - .did = try allocator.dupe(u8, row.text(3)), 3254 - }); 3255 - } 3256 - if (rows.err) |err| return err; 3257 - return out.toOwnedSlice(allocator); 3258 - } 3259 - 3260 - fn addSpaceMemberLocked(allocator: std.mem.Allocator, space: []const u8, did: []const u8) ![]const u8 { 3261 - if (zat.Did.parse(did) == null) return Error.InvalidRepoPath; 3262 - _ = (try getSpaceConfigLocked(allocator, space)) orelse return Error.RepoNotFound; 3263 - if (try spaceHasMemberLocked(space, did)) return Error.InvalidRefreshSession; 3264 - const state = try getMemberStateLocked(allocator, space); 3265 - var set_hash = if (state.set_hash) |bytes| try permissioned.LtHash.fromBytes(bytes) else permissioned.LtHash{}; 3266 - set_hash.add(did); 3267 - const rev = try nextRkeyLocked(allocator); 3268 - try conn.exec( 3269 - \\INSERT INTO permissioned_space_members (space, did, member_rev) 3270 - \\VALUES (?, ?, ?) 3271 - , .{ space, did, rev }); 3272 - try conn.exec( 3273 - \\INSERT INTO permissioned_space_member_oplog (space, rev, idx, action, did) 3274 - \\VALUES (?, ?, 0, 'add', ?) 3275 - , .{ space, rev, did }); 3276 - try conn.exec( 3277 - \\INSERT INTO permissioned_space_member_state (space, set_hash, rev) 3278 - \\VALUES (?, ?, ?) 3279 - \\ON CONFLICT(space) DO UPDATE SET 3280 - \\ set_hash = excluded.set_hash, 3281 - \\ rev = excluded.rev 3282 - , .{ space, zqlite.blob(&set_hash.bytes), rev }); 3283 - return rev; 3284 - } 3285 - 3286 - fn removeSpaceMemberLocked(allocator: std.mem.Allocator, space: []const u8, did: []const u8) ![]const u8 { 3287 - if (zat.Did.parse(did) == null) return Error.InvalidRepoPath; 3288 - _ = (try getSpaceConfigLocked(allocator, space)) orelse return Error.RepoNotFound; 3289 - if (!try spaceHasMemberLocked(space, did)) return Error.MissingRecord; 3290 - const state = try getMemberStateLocked(allocator, space); 3291 - var set_hash = if (state.set_hash) |bytes| try permissioned.LtHash.fromBytes(bytes) else permissioned.LtHash{}; 3292 - set_hash.remove(did); 3293 - const rev = try nextRkeyLocked(allocator); 3294 - try conn.exec( 3295 - \\DELETE FROM permissioned_space_members 3296 - \\WHERE space = ? AND did = ? 3297 - , .{ space, did }); 3298 - try conn.exec( 3299 - \\INSERT INTO permissioned_space_member_oplog (space, rev, idx, action, did) 3300 - \\VALUES (?, ?, 0, 'remove', ?) 3301 - , .{ space, rev, did }); 3302 - try conn.exec( 3303 - \\INSERT INTO permissioned_space_member_state (space, set_hash, rev) 3304 - \\VALUES (?, ?, ?) 3305 - \\ON CONFLICT(space) DO UPDATE SET 3306 - \\ set_hash = excluded.set_hash, 3307 - \\ rev = excluded.rev 3308 - , .{ space, zqlite.blob(&set_hash.bytes), rev }); 3309 - return rev; 3310 - } 3311 - 3312 3130 fn getRepoStateLocked(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8) !SpaceState { 3313 3131 const row = try conn.row( 3314 3132 \\SELECT set_hash, rev ··· 3323 3141 }; 3324 3142 } 3325 3143 3326 - fn getMemberStateLocked(allocator: std.mem.Allocator, space: []const u8) !SpaceState { 3327 - const row = try conn.row( 3328 - \\SELECT set_hash, rev 3329 - \\FROM permissioned_space_member_state 3330 - \\WHERE space = ? 3331 - , .{space}); 3332 - if (row == null) return .{ .set_hash = null, .rev = null }; 3333 - defer row.?.deinit(); 3334 - return .{ 3335 - .set_hash = if (row.?.nullableBlob(0)) |bytes| try allocator.dupe(u8, bytes) else null, 3336 - .rev = if (row.?.nullableText(1)) |rev| try allocator.dupe(u8, rev) else null, 3337 - }; 3338 - } 3339 - 3340 3144 fn addRecordElement(allocator: std.mem.Allocator, hash: *permissioned.LtHash, collection: []const u8, rkey: []const u8, cid: []const u8) !void { 3341 3145 const element = try permissioned.recordElement(allocator, collection, rkey, cid); 3342 3146 hash.add(element); ··· 3403 3207 .app_access_mode = try allocator.dupe(u8, row.?.text(6)), 3404 3208 .app_exceptions_json = try allocator.dupe(u8, row.?.text(7)), 3405 3209 .is_owner = false, 3406 - .is_member = false, 3407 3210 .deleted_at = null, 3408 - .members = try listSpaceMembersLocked(allocator, uri), 3409 3211 }; 3410 3212 } 3411 3213 3412 3214 fn getSpaceLocked(allocator: std.mem.Allocator, actor_did: []const u8, uri: []const u8) !?SpaceConfig { 3413 3215 const row = try conn.row( 3414 3216 \\SELECT s.uri, s.owner_did, s.space_type, s.skey, s.managing_app, s.is_public, s.app_access_mode, s.app_exceptions_json, 3415 - \\ a.is_owner, a.is_member, a.deleted_at 3217 + \\ a.is_owner, a.deleted_at 3416 3218 \\FROM permissioned_spaces s 3417 3219 \\JOIN permissioned_space_actor_state a ON a.space = s.uri 3418 3220 \\WHERE s.uri = ? AND a.actor_did = ? ··· 3430 3232 .app_access_mode = try allocator.dupe(u8, row.?.text(6)), 3431 3233 .app_exceptions_json = try allocator.dupe(u8, row.?.text(7)), 3432 3234 .is_owner = row.?.int(8) != 0, 3433 - .is_member = row.?.int(9) != 0, 3434 - .deleted_at = if (row.?.nullableInt(10)) |value| value else null, 3435 - .members = try listSpaceMembersLocked(allocator, uri), 3235 + .deleted_at = if (row.?.nullableInt(9)) |value| value else null, 3436 3236 }; 3437 3237 } 3438 3238 3439 - fn listSpaceMembersLocked(allocator: std.mem.Allocator, space: []const u8) ![]const []const u8 { 3440 - return listSpaceMembersPageLocked(allocator, space, null, 10000); 3441 - } 3442 - 3443 - fn listSpaceMembersPageLocked(allocator: std.mem.Allocator, space: []const u8, maybe_cursor: ?[]const u8, limit: usize) ![]const []const u8 { 3444 - const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); 3445 - var rows = if (maybe_cursor) |cursor| 3446 - try conn.rows( 3447 - \\SELECT did 3448 - \\FROM permissioned_space_members 3449 - \\WHERE space = ? AND did > ? 3450 - \\ORDER BY did ASC 3451 - \\LIMIT ? 3452 - , .{ space, cursor, capped_limit }) 3453 - else 3454 - try conn.rows( 3455 - \\SELECT did 3456 - \\FROM permissioned_space_members 3457 - \\WHERE space = ? 3458 - \\ORDER BY did ASC 3459 - \\LIMIT ? 3460 - , .{ space, capped_limit }); 3461 - defer rows.deinit(); 3462 - var members: std.ArrayList([]const u8) = .empty; 3463 - while (rows.next()) |row| try members.append(allocator, try allocator.dupe(u8, row.text(0))); 3464 - if (rows.err) |err| return err; 3465 - return members.toOwnedSlice(allocator); 3466 - } 3467 - 3468 - fn spaceHasMemberLocked(space: []const u8, did: []const u8) !bool { 3469 - const row = try conn.row( 3470 - \\SELECT 1 3471 - \\FROM permissioned_space_members 3472 - \\WHERE space = ? AND did = ? 3473 - \\LIMIT 1 3474 - , .{ space, did }); 3475 - if (row == null) return false; 3476 - defer row.?.deinit(); 3477 - return true; 3478 - } 3479 - 3480 - fn actorIsSpaceParticipantLocked(space: []const u8, actor_did: []const u8) !bool { 3481 - const row = try conn.row( 3482 - \\SELECT 1 3483 - \\FROM permissioned_space_actor_state 3484 - \\WHERE space = ? AND actor_did = ? AND (is_owner = 1 OR is_member = 1) 3485 - \\LIMIT 1 3486 - , .{ space, actor_did }); 3487 - if (row == null) return false; 3488 - defer row.?.deinit(); 3489 - return true; 3490 - } 3491 - 3492 3239 fn getSpaceRecordLocked( 3493 3240 allocator: std.mem.Allocator, 3494 3241 space: []const u8, ··· 3581 3328 try migratePermissionedSpacesOwnerFk(); 3582 3329 try migratePermissionedSpaceActorState(); 3583 3330 try migratePermissionedRecordOplogRepo(); 3331 + try migratePermissionedDropMemberLists(); 3584 3332 } 3585 3333 3586 3334 fn migrationApplied(name: []const u8) !bool { ··· 3609 3357 \\ app_access_mode TEXT NOT NULL DEFAULT 'allow', 3610 3358 \\ app_exceptions_json TEXT NOT NULL DEFAULT '[]', 3611 3359 \\ is_owner INTEGER NOT NULL DEFAULT 1, 3612 - \\ is_member INTEGER NOT NULL DEFAULT 0, 3613 3360 \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 3614 3361 \\ deleted_at INTEGER, 3615 3362 \\ UNIQUE(owner_did, space_type, skey) ··· 3618 3365 try conn.execNoArgs( 3619 3366 \\INSERT INTO permissioned_spaces_next ( 3620 3367 \\ uri, owner_did, space_type, skey, managing_app, is_public, app_access_mode, 3621 - \\ app_exceptions_json, is_owner, is_member, created_at, deleted_at 3368 + \\ app_exceptions_json, is_owner, created_at, deleted_at 3622 3369 \\) 3623 3370 \\SELECT uri, owner_did, space_type, skey, managing_app, is_public, app_access_mode, 3624 - \\ app_exceptions_json, is_owner, is_member, created_at, deleted_at 3371 + \\ app_exceptions_json, is_owner, created_at, deleted_at 3625 3372 \\FROM permissioned_spaces 3626 3373 ); 3627 3374 try conn.execNoArgs("PRAGMA foreign_keys = OFF"); ··· 3637 3384 const name = "permissioned-space-actor-state"; 3638 3385 if (try migrationApplied(name)) return; 3639 3386 try conn.execNoArgs( 3640 - \\INSERT OR IGNORE INTO permissioned_space_actor_state (space, actor_did, is_owner, is_member, deleted_at) 3387 + \\INSERT OR IGNORE INTO permissioned_space_actor_state (space, actor_did, is_owner, deleted_at) 3641 3388 \\SELECT s.uri, 3642 3389 \\ CASE WHEN s.is_owner = 1 THEN s.owner_did ELSE COALESCE(r.repo_did, s.owner_did) END, 3643 3390 \\ s.is_owner, 3644 - \\ s.is_member, 3645 3391 \\ s.deleted_at 3646 3392 \\FROM permissioned_spaces s 3647 3393 \\LEFT JOIN permissioned_space_repos r ON r.space = s.uri 3648 - \\WHERE s.is_owner = 1 OR s.is_member = 1 3394 + \\WHERE s.is_owner = 1 3649 3395 ); 3650 3396 try markMigrationApplied(name); 3651 3397 } ··· 3677 3423 ); 3678 3424 try conn.execNoArgs("DROP TABLE permissioned_space_record_oplog"); 3679 3425 try conn.execNoArgs("ALTER TABLE permissioned_space_record_oplog_next RENAME TO permissioned_space_record_oplog"); 3426 + try markMigrationApplied(name); 3427 + } 3428 + 3429 + fn migratePermissionedDropMemberLists() !void { 3430 + const name = "permissioned-drop-member-lists"; 3431 + if (try migrationApplied(name)) return; 3432 + try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_space_members"); 3433 + try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_space_member_state"); 3434 + try conn.execNoArgs("DROP TABLE IF EXISTS permissioned_space_member_oplog"); 3680 3435 try markMigrationApplied(name); 3681 3436 } 3682 3437 ··· 5225 4980 \\ app_access_mode TEXT NOT NULL DEFAULT 'allow', 5226 4981 \\ app_exceptions_json TEXT NOT NULL DEFAULT '[]', 5227 4982 \\ is_owner INTEGER NOT NULL DEFAULT 1, 5228 - \\ is_member INTEGER NOT NULL DEFAULT 0, 5229 4983 \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 5230 4984 \\ deleted_at INTEGER, 5231 4985 \\ UNIQUE(owner_did, space_type, skey) ··· 5236 4990 \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 5237 4991 \\ actor_did TEXT NOT NULL, 5238 4992 \\ is_owner INTEGER NOT NULL DEFAULT 0, 5239 - \\ is_member INTEGER NOT NULL DEFAULT 0, 5240 4993 \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 5241 4994 \\ deleted_at INTEGER, 5242 4995 \\ PRIMARY KEY (space, actor_did) 5243 4996 \\) 5244 4997 , 5245 4998 "CREATE INDEX IF NOT EXISTS permissioned_space_actor_state_actor_idx ON permissioned_space_actor_state (actor_did, space)", 5246 - \\CREATE TABLE IF NOT EXISTS permissioned_space_members ( 5247 - \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 5248 - \\ did TEXT NOT NULL, 5249 - \\ member_rev TEXT NOT NULL DEFAULT '', 5250 - \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()), 5251 - \\ PRIMARY KEY (space, did) 5252 - \\) 5253 - , 5254 4999 \\CREATE TABLE IF NOT EXISTS permissioned_space_records ( 5255 5000 \\ space TEXT NOT NULL REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 5256 5001 \\ repo_did TEXT NOT NULL, ··· 5271 5016 \\ set_hash BLOB, 5272 5017 \\ rev TEXT, 5273 5018 \\ PRIMARY KEY (space, repo_did) 5274 - \\) 5275 - , 5276 - \\CREATE TABLE IF NOT EXISTS permissioned_space_member_state ( 5277 - \\ space TEXT PRIMARY KEY REFERENCES permissioned_spaces(uri) ON DELETE CASCADE, 5278 - \\ set_hash BLOB, 5279 - \\ rev TEXT 5280 5019 \\) 5281 5020 , 5282 5021 \\CREATE TABLE IF NOT EXISTS permissioned_space_record_oplog ( ··· 5292 5031 \\ PRIMARY KEY (space, repo_did, rev, idx) 5293 5032 \\) 5294 5033 , 5295 - \\CREATE TABLE IF NOT EXISTS permissioned_space_member_oplog ( 5296 - \\ space TEXT NOT NULL, 5297 - \\ rev TEXT NOT NULL, 5298 - \\ idx INTEGER NOT NULL, 5299 - \\ action TEXT NOT NULL, 5300 - \\ did TEXT NOT NULL, 5301 - \\ PRIMARY KEY (space, rev, idx) 5302 - \\) 5303 - , 5304 5034 \\CREATE TABLE IF NOT EXISTS permissioned_space_credential_recipients ( 5305 5035 \\ space TEXT NOT NULL, 5306 5036 \\ service_did TEXT NOT NULL, ··· 5412 5142 }); 5413 5143 try std.testing.expectEqualStrings("ats://did:plc:spaceowneralice/fm.plyr.privateMedia/self", space.uri); 5414 5144 try std.testing.expect(space.is_owner); 5415 - try std.testing.expect(space.is_member); 5416 - try std.testing.expectEqual(@as(usize, 1), space.members.len); 5417 - try std.testing.expectEqualStrings(account.did, space.members[0]); 5418 5145 5419 5146 try std.testing.expectError(Error.InvalidRefreshSession, createSpace(allocator, .{ 5420 5147 .actor_did = account.did, ··· 5452 5179 try std.testing.expectEqualStrings("track-one", listed[0].rkey); 5453 5180 } 5454 5181 5455 - test "permissioned spaces keep actor-local state per space URI" { 5182 + test "permissioned spaces keep owner-local state per space URI" { 5456 5183 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 5457 5184 defer arena.deinit(); 5458 5185 const allocator = arena.allocator(); ··· 5468 5195 "did:plc:spaceownerlocal", 5469 5196 true, 5470 5197 ); 5471 - const member = try createAccount( 5472 - allocator, 5473 - "member-space.test", 5474 - "member-space@test.com", 5475 - "password", 5476 - "did:plc:spacememberlocal", 5477 - true, 5478 - ); 5479 - 5480 5198 const created = try createSpace(allocator, .{ 5481 5199 .actor_did = owner.did, 5482 5200 .owner_did = owner.did, ··· 5489 5207 .app_exceptions_json = "[]", 5490 5208 }); 5491 5209 5492 - try setSpaceMembership(allocator, created.uri, member.did, true); 5493 - 5494 5210 const owner_view = (try getSpace(allocator, owner.did, created.uri)).?; 5495 5211 try std.testing.expect(owner_view.is_owner); 5496 - try std.testing.expect(owner_view.is_member); 5497 5212 try std.testing.expect(owner_view.deleted_at == null); 5498 - 5499 - const member_view = (try getSpace(allocator, member.did, created.uri)).?; 5500 - try std.testing.expect(!member_view.is_owner); 5501 - try std.testing.expect(member_view.is_member); 5502 - try std.testing.expect(member_view.deleted_at == null); 5503 5213 5504 5214 const owner_spaces = try listSpaces(allocator, owner.did, owner.did, "fm.plyr.privateMedia", null, 50); 5505 5215 try std.testing.expectEqual(@as(usize, 1), owner_spaces.len); 5506 5216 try std.testing.expect(owner_spaces[0].is_owner); 5507 5217 5508 - const member_spaces = try listSpaces(allocator, member.did, owner.did, "fm.plyr.privateMedia", null, 50); 5509 - try std.testing.expectEqual(@as(usize, 1), member_spaces.len); 5510 - try std.testing.expect(!member_spaces[0].is_owner); 5511 - try std.testing.expect(member_spaces[0].is_member); 5512 - 5513 - try markSpaceDeleted(member.did, created.uri); 5514 - const deleted_member_view = (try getSpace(allocator, member.did, created.uri)).?; 5515 - try std.testing.expect(deleted_member_view.deleted_at != null); 5516 - const still_active_owner_view = (try getSpace(allocator, owner.did, created.uri)).?; 5517 - try std.testing.expect(still_active_owner_view.deleted_at == null); 5218 + try markSpaceDeleted(owner.did, created.uri); 5219 + const deleted_owner_view = (try getSpace(allocator, owner.did, created.uri)).?; 5220 + try std.testing.expect(deleted_owner_view.deleted_at != null); 5518 5221 } 5519 5222 5520 5223 test "permissioned space create is duplicate-checked per actor" { ··· 5540 5243 .app_exceptions_json = "[]", 5541 5244 }); 5542 5245 try std.testing.expect(!viewer_row.is_owner); 5543 - try std.testing.expect(!viewer_row.is_member); 5544 5246 5545 5247 const owner_row = try createSpace(allocator, .{ 5546 5248 .actor_did = owner.did, ··· 5554 5256 .app_exceptions_json = "[\"did:web:allowed.example\"]", 5555 5257 }); 5556 5258 try std.testing.expect(owner_row.is_owner); 5557 - try std.testing.expect(owner_row.is_member); 5558 5259 try std.testing.expect(owner_row.is_public); 5559 5260 try std.testing.expectEqualStrings("did:web:plyr.fm", owner_row.managing_app.?); 5560 5261
+4 -3
tools/smoke-permissioned.sh
··· 91 91 printf '%s' "$space_list" | grep -q '"cursor":"ats://did:plc:permissionsmoke/fm.plyr.privateMedia/self"' 92 92 ! printf '%s' "$space_list" | grep -q '"isMember":' 93 93 94 - space_members=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getMembers?space=$encoded_space&limit=1") 95 - printf '%s' "$space_members" | grep -q '"did":"did:plc:permissionsmoke"' 96 - printf '%s' "$space_members" | grep -q '"cursor":"did:plc:permissionsmoke"' 94 + space_members_status=$(curl -sS -o /tmp/zds-space-members.json -w '%{http_code}' \ 95 + -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getMembers?space=$encoded_space&limit=1") 96 + test "$space_members_status" = "404" 97 + grep -q '"error":"UnknownMethod"' /tmp/zds-space-members.json 97 98 98 99 space_record=$(curl -fsS -X POST "$base/xrpc/com.atproto.space.createRecord" \ 99 100 -H "authorization: Bearer $token" \