diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd8301..f3a8e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ Reconstructed from git history for everything up to `v0.1.1`; kept by hand from there. +## unreleased + +- **fix**: `com.atproto.simplespace.*` now speaks the lexicons Bluesky published for the + [spaces alpha](https://atproto.com/blog/atproto-spaces-alpha) (atproto `2f77206`). Before, + [Bulletin](https://github.com/bluesky-social/bulletin) could sign in to a zds account but + `createSpace` answered `400 Missing did`. + - `createSpace` takes `{type, skey?, policy, appAccess}`; the space is anchored on the + authenticated DID. `policy` and `appAccess` are the `com.atproto.simplespace.defs` unions + (`#publicPolicy` / `#memberListPolicy` / `#managingAppPolicy{managingApp}`, `#open` / + `#allowList{allowed}`); unknown variants answer `UnsupportedPolicy` / + `UnsupportedAppAccess`. The pre-alpha `{did, config:{policy:"member-list",…}}` body is + rejected. + - `updateSpace` takes `{space, policy?, appAccess?}` with the same unions; a policy change + replaces the managing app rather than layering a separate `managingApp` field. + - `com.atproto.simplespace.getSpace` is routed and returns `{uri, policy, appAccess}`. + `com.atproto.space.getSpace` (the pre-alpha name) still answers, with the same body. + - `listRepoOps` paginates: a full page returns a `rev/idx` `cursor` and no `commit`; the + final page carries the signed commit. Default and maximum page size follow the lexicon + (100 / 1000), as does `listRepos`, which now only emits a cursor on a full page. + - `deleteRecord` is idempotent like `com.atproto.repo.deleteRecord`; deleting a record that + is not there answers `{}` instead of `RecordNotFound`. + - `getSpaceCredential` uses the lexicon's `InvalidDelegationToken` for a bad or mismatched + delegation token (was `InvalidToken` / `InvalidRequest`). + ## 0.3.0 — 2026-08-20 - **feat**: when `--server-did` / `ZDS_SERVER_DID` is not set but `--public-url` is, the server diff --git a/README.md b/README.md index 47a1a1d..fccff20 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ an at protocol personal data server. +> as featured in [atproto spaces alpha](https://atproto.com/blog/atproto-spaces-alpha) — +> one of the PDS implementations that speaks the spaces (permissioned data) alpha. + > name credit: jim (`calabro.io`) suggested `zds`. `zds` stores atproto accounts, repos, records, blobs, sessions, oauth state, @@ -135,8 +138,10 @@ just docker-publish-release v0.3.0 - Permissioned-data routes are experimental and operator gated with `ZDS_PERMISSIONED_DATA`. ZDS keeps protocol routes under `com.atproto.space.*` and baseline PDS-managed space management under - `com.atproto.simplespace.*`, but the upstream proposal is still moving and - this surface is not a stable compatibility contract. + `com.atproto.simplespace.*`, tracking the lexicons published for the + [spaces alpha](https://atproto.com/blog/atproto-spaces-alpha). The alpha is + explicitly breaking-changes-ahead, so this surface is not a stable + compatibility contract. ## dependency posture diff --git a/bench/main.zig b/bench/main.zig index 34ff41b..c08940b 100644 --- a/bench/main.zig +++ b/bench/main.zig @@ -1026,7 +1026,7 @@ fn benchSpaceOplog(allocator: std.mem.Allocator, account: zds.auth.tokens.Accoun const start = nowNs(); for (0..iterations) |_| { _ = arena.reset(.retain_capacity); - const ops = try zds.storage.store.listSpaceRecordOplog(arena.allocator(), space, account.did, null, 100, true); + const ops = try zds.storage.store.listSpaceRecordOplog(arena.allocator(), space, account.did, null, null, 100, true); if (ops.len == 0) return error.MissingRecords; } return .{ .name = "space listRepoOps", .ops = iterations, .elapsed_ns = nowNs() - start }; diff --git a/docs/permissioned-data.md b/docs/permissioned-data.md index abbd05b..462f29d 100644 --- a/docs/permissioned-data.md +++ b/docs/permissioned-data.md @@ -18,6 +18,11 @@ even when an operator has not enabled it. ## references +- Announcement: (lists ZDS among + the spaces-compatible PDS implementations) +- Bulletin, the alpha's example app: + (creates one `my.bulletin.board` simplespace per user; the smallest real + client of this surface) - Proposal: - Diary 7, signed commits and sync: @@ -42,7 +47,10 @@ member lists. The later discussion moved toward making space credentials the protocol substrate and leaving reader/group semantics to applications or space-host policy. Proposal PR #94 keeps that broad direction and defines a baseline `com.atproto.simplespace.*` management surface for PDS-managed spaces. -ZDS implements that namespace split without legacy aliases. +The August 2026 alpha published those lexicons (atproto commit `2f77206`, +"publish all alpha packages"); ZDS tracks that commit. When checking parity, +diff against the `permissioned-data` branch tip rather than the proposal text: +the lexicons are the contract, and the branch moves after the publish. Feedback should respect the research behind the sketch and avoid overfitting to ZDS or plyr.fm. Name concrete implementation pressure, but frame it as input for @@ -62,13 +70,29 @@ ZDS splits protocol data routes from baseline PDS management routes: `getDelegationToken`, `getSpaceCredential`, records, blobs, signed commits, full and incremental repo sync, blob enumeration, notification registration and removal, write notifications, and deletion notifications -- `com.atproto.simplespace.*`: `createSpace`, `updateSpace`, `deleteSpace`, - `addMember`, `removeMember`, `listMembers`, and the managing-app - `checkUserAccess` hook +- `com.atproto.simplespace.*`: `createSpace`, `getSpace`, `updateSpace`, + `deleteSpace`, `addMember`, `removeMember`, `listMembers`, and the + managing-app `checkUserAccess` hook ZDS uses the proposal names for the credential and sync-read surface: `getDelegationToken` and `listRepoOps`. +`createSpace` anchors the space on the authenticated DID and takes +`{type, skey?, policy, appAccess}`. `policy` is one of the +`com.atproto.simplespace.defs` unions `#publicPolicy`, `#memberListPolicy`, or +`#managingAppPolicy{managingApp}`; `appAccess` is `#open` or +`#allowList{allowed}`. A variant the host does not implement answers +`UnsupportedPolicy` / `UnsupportedAppAccess`. `getSpace` returns +`{uri, policy, appAccess}` in the same shapes; `updateSpace` accepts either or +both. Internally the policy is still stored as `public` / `member-list` / +`managing-app` plus a nullable managing app, so the union is a wire shape, not +a storage change. + +`listRepoOps` pages with an opaque `rev/idx` cursor: a full page (`limit`, +default 100, max 1000) carries `cursor` and no `commit`; the page that reaches +the head of the oplog carries the signed `commit` instead. `listRepos` uses the +same default and maximum and only emits a cursor on a full page. + ZDS deliberately does not expose the older protocol member-list routes or the pre-proposal lifecycle names: @@ -80,6 +104,9 @@ pre-proposal lifecycle names: - `com.atproto.space.updateSpaceConfig` - `com.atproto.space.deleteSpace` +`com.atproto.space.getSpace` is the pre-alpha name for +`com.atproto.simplespace.getSpace`; both answer with the same body. + `getSpace` and `listSpaces` do not return `members` or `isMember`. `getSpace` accepts the proposal's space credential when called by a syncer. diff --git a/src/atproto/space.zig b/src/atproto/space.zig index 59f8f4c..0d66af9 100644 --- a/src/atproto/space.zig +++ b/src/atproto/space.zig @@ -34,7 +34,9 @@ pub fn dispatch(request: *http_api.Request) !void { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid XRPC path"); }; - if (std.mem.eql(u8, method, "com.atproto.space.getSpace")) return getSpace(request); + if (std.mem.eql(u8, method, "com.atproto.simplespace.getSpace")) return getSimpleSpace(request); + // pre-alpha name for the same query; routable until clients move. + if (std.mem.eql(u8, method, "com.atproto.space.getSpace")) return getSimpleSpace(request); if (std.mem.eql(u8, method, "com.atproto.space.listSpaces")) return listSpaces(request); if (std.mem.eql(u8, method, "com.atproto.space.listRepos")) return listRepos(request); if (std.mem.eql(u8, method, "com.atproto.space.getDelegationToken")) return getDelegationToken(request); @@ -84,38 +86,25 @@ fn createSimpleSpace(request: *http_api.Request) !void { else => return err, }; const input = parsed.value; - const authority = zat.json.getString(input, "did") orelse { - return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing did"); - }; + const authority = auth_ctx.account.did; const space_type = zat.json.getString(input, "type") orelse { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing type"); }; const skey = zat.json.getString(input, "skey") orelse try store.generateRkey(allocator); try requireSpaceScope(request, auth_ctx.oauth_scope, space_type, authority, skey, .manage_create, null); - const config_value = switch (input) { - .object => |object| object.get("config") orelse std.json.Value.null, - else => std.json.Value.null, - }; - switch (config_value) { - .null, .object => {}, - else => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid config"), - } - const managing_app = zat.json.getString(config_value, "managingApp"); - const policy = zat.json.getString(config_value, "policy") orelse "member-list"; - if (!validPolicy(policy)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid policy"); - const app_access_json = appAccessJson(allocator, config_value) catch |err| switch (err) { - error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid appAccess"), - else => return err, - }; + const policy_value = jsonField(input, "policy") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing policy"); + const policy = policyFromLex(policy_value) catch |err| return rejectPolicy(request, err); + const app_access_value = jsonField(input, "appAccess") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing appAccess"); + const app_access_json = appAccessFromLex(allocator, app_access_value) catch |err| return rejectAppAccess(request, err); const space = store.createSpace(allocator, .{ - .actor_did = auth_ctx.account.did, + .actor_did = authority, .authority_did = authority, .space_type = space_type, .skey = skey, - .is_authority = std.mem.eql(u8, authority, auth_ctx.account.did), - .managing_app = managing_app, - .policy = policy, + .is_authority = true, + .managing_app = policy.managing_app, + .policy = policy.policy, .app_access_json = app_access_json, }) catch |err| switch (err) { error.InvalidRepoPath => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid authority"), @@ -130,7 +119,7 @@ fn createSimpleSpace(request: *http_api.Request) !void { return http_api.json(request, .ok, body); } -fn getSpace(request: *http_api.Request) !void { +fn getSimpleSpace(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); @@ -156,12 +145,7 @@ fn getSpace(request: *http_api.Request) !void { if (!space.is_authority or space.deleted_at != null) { return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); } - const body = try std.fmt.allocPrint( - allocator, - "{{\"uri\":{f},\"config\":{s}}}", - .{ std.json.fmt(space.uri, .{}), try simpleSpaceConfigJson(allocator, space) }, - ); - return http_api.json(request, .ok, body); + return http_api.json(request, .ok, try simpleSpaceViewJson(allocator, space)); } fn listSpaces(request: *http_api.Request) !void { @@ -215,7 +199,8 @@ fn listRepos(request: *http_api.Request) !void { _ = requireSpaceCredential(request, allocator, space) catch return; var cursor_buf: [256]u8 = undefined; const maybe_cursor = http_api.queryParam(request.url.raw, "cursor", &cursor_buf); - const repos = store.listSpaceWriters(allocator, space, maybe_cursor, http_api.queryLimit(request.url.raw, 50)) catch |err| switch (err) { + const limit = pageLimit(request.url.raw, 100, 1000); + const repos = store.listSpaceWriters(allocator, space, maybe_cursor, limit) catch |err| switch (err) { error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), else => return err, }; @@ -230,7 +215,7 @@ fn listRepos(request: *http_api.Request) !void { try out.writer.writeByte('}'); } try out.writer.writeByte(']'); - if (repos.len > 0) try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(repos[repos.len - 1].repo_did, .{})}); + if (repos.len == limit) try out.writer.print(",\"cursor\":{f}", .{std.json.fmt(repos[repos.len - 1].repo_did, .{})}); try out.writer.writeByte('}'); return http_api.json(request, .ok, out.written()); } @@ -331,28 +316,15 @@ fn updateSimpleSpace(request: *http_api.Request) !void { const existing = (try store.getSpace(allocator, auth_ctx.account.did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); if (!existing.is_authority) return http_api.xrpcError(request, .forbidden, "NotSpaceOwner", "Not the space owner"); if (existing.deleted_at != null) return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); - const managing_app = zat.json.getString(input, "managingApp"); - const clear_managing_app = switch (input) { - .object => |object| if (object.get("managingApp")) |value| value == .string and value.string.len == 0 else false, - else => false, - }; - const app_access_json = appAccessJson(allocator, input) catch |err| switch (err) { - error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid appAccess"), - else => return err, - }; - const has_app_access = switch (input) { - .object => |object| object.get("appAccess") != null, - else => false, - }; - const maybe_policy = zat.json.getString(input, "policy"); - if (maybe_policy) |policy| if (!validPolicy(policy)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid policy"); - store.updateSimpleSpaceConfig( - space, - managing_app, - clear_managing_app, - maybe_policy, - if (has_app_access) app_access_json else null, - ) catch |err| switch (err) { + const maybe_policy: ?store.SimpleSpacePolicy = if (jsonField(input, "policy")) |value| + policyFromLex(value) catch |err| return rejectPolicy(request, err) + else + null; + const maybe_app_access: ?[]const u8 = if (jsonField(input, "appAccess")) |value| + appAccessFromLex(allocator, value) catch |err| return rejectAppAccess(request, err) + else + null; + store.updateSimpleSpaceConfig(space, maybe_policy, maybe_app_access) catch |err| switch (err) { error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), error.InvalidRecordType => return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid simplespace config"), else => return err, @@ -426,7 +398,7 @@ fn deleteRecord(request: *http_api.Request) !void { try requireSpaceAccess(request, allocator, auth_ctx, space, .delete, collection); store.deleteSpaceRecord(allocator, space, repo, collection, rkey) catch |err| switch (err) { error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), - error.MissingRecord => return http_api.xrpcError(request, .not_found, "RecordNotFound", "Record not found"), + error.MissingRecord => {}, error.InvalidRefreshSession => return http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to write in this space"), else => return err, }; @@ -788,8 +760,13 @@ fn listRepoOps(request: *http_api.Request) !void { std.mem.eql(u8, value, "true") else false; - const limit = http_api.queryLimit(request.url.raw, 100); - const ops = try store.listSpaceRecordOplog(allocator, space, repo, since, limit, !exclude_values); + var cursor_buf: [160]u8 = undefined; + const cursor: ?store.OplogCursor = if (http_api.queryParam(request.url.raw, "cursor", &cursor_buf)) |raw| + parseOplogCursor(raw) orelse return http_api.xrpcError(request, .bad_request, "MalformedCursor", "Malformed cursor") + else + null; + const limit = pageLimit(request.url.raw, 100, 1000); + const ops = try store.listSpaceRecordOplog(allocator, space, repo, since, cursor, limit, !exclude_values); const state = try store.getSpaceRepoState(allocator, space, repo); var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); @@ -807,10 +784,11 @@ fn listRepoOps(request: *http_api.Request) !void { try out.writer.writeByte('}'); } try out.writer.writeByte(']'); - if (ops.len < @min(if (limit == 0) 100 else limit, 1000)) { - if (try signedCommitJson(allocator, space, repo, repo, state)) |commit_json| { - try out.writer.print(",\"commit\":{s}", .{commit_json}); - } + if (ops.len == limit) { + const last = ops[ops.len - 1]; + try out.writer.print(",\"cursor\":\"{s}/{d}\"", .{ last.rev, last.idx }); + } else if (try signedCommitJson(allocator, space, repo, repo, state)) |commit_json| { + try out.writer.print(",\"commit\":{s}", .{commit_json}); } try out.writer.writeByte('}'); return http_api.json(request, .ok, out.written()); @@ -876,13 +854,13 @@ fn getSpaceCredential(request: *http_api.Request) !void { return http_api.xrpcError(request, .unauthorized, "InvalidDpopProof", "A valid DPoP proof is required to obtain a space credential"); }; const requester_did = permissioned.unverifiedStringClaim(allocator, token, "iss") catch { - return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid delegation token"); + return http_api.xrpcError(request, .unauthorized, "InvalidDelegationToken", "Invalid delegation token"); }; const public_key = signingKeyForDid(allocator, requester_did) catch { return http_api.xrpcError(request, .bad_gateway, "DidResolutionFailed", "could not resolve delegation token issuer did"); }; const delegation = permissioned.verifyDelegationToken(allocator, token, public_key) catch { - return http_api.xrpcError(request, .unauthorized, "InvalidToken", "Invalid delegation token"); + return http_api.xrpcError(request, .unauthorized, "InvalidDelegationToken", "Invalid delegation token"); }; const parsed_body = parseBody(request, allocator, 32 * 1024) catch |err| switch (err) { error.HandledResponse => return, @@ -890,9 +868,9 @@ fn getSpaceCredential(request: *http_api.Request) !void { }; const input = parsed_body.value; const space = zat.json.getString(input, "space") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing space"); - if (!std.mem.eql(u8, delegation.space, space)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Delegation token space mismatch"); + if (!std.mem.eql(u8, delegation.space, space)) return http_api.xrpcError(request, .bad_request, "InvalidDelegationToken", "Delegation token subject does not match requested space"); const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); - if (!std.mem.eql(u8, delegation.authority_did, parsed.authority_did)) return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Delegation token authority mismatch"); + if (!std.mem.eql(u8, delegation.authority_did, parsed.authority_did)) return http_api.xrpcError(request, .bad_request, "InvalidDelegationToken", "Delegation token audience does not match the space authority"); const config_row = (try store.getSpace(allocator, parsed.authority_did, space)) orelse return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"); if (config_row.deleted_at != null) { return http_api.xrpcError(request, .bad_request, "SpaceDeleted", "Space has been deleted"); @@ -1623,12 +1601,6 @@ fn percentEncode(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { return out.toOwnedSlice(); } -fn validPolicy(policy: []const u8) bool { - return std.mem.eql(u8, policy, "member-list") or - std.mem.eql(u8, policy, "public") or - std.mem.eql(u8, policy, "managing-app"); -} - fn atJsonBytes(allocator: std.mem.Allocator, bytes: []const u8) ![]const u8 { const encoded = try allocator.alloc(u8, std.base64.standard.Encoder.calcSize(bytes.len)); _ = std.base64.standard.Encoder.encode(encoded, bytes); @@ -1646,50 +1618,87 @@ fn parseAtJsonBytes(allocator: std.mem.Allocator, value: std.json.Value) ![]cons return bytes; } -fn appAccessJson(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { - const app_access = switch (value) { - .object => |object| object.get("appAccess") orelse return "{\"type\":\"open\"}", - else => return "{\"type\":\"open\"}", - }; - const object = switch (app_access) { - .object => |object| object, - else => return error.InvalidRecordType, +fn jsonField(value: std.json.Value, key: []const u8) ?std.json.Value { + return switch (value) { + .object => |object| object.get(key), + else => null, }; - const kind = switch (object.get("$type") orelse return error.InvalidRecordType) { - .string => |text| text, - else => return error.InvalidRecordType, +} + +fn pageLimit(target: []const u8, default: usize, max: usize) usize { + const requested = http_api.queryLimit(target, default); + return @min(if (requested == 0) default else requested, max); +} + +const public_policy_type = "com.atproto.simplespace.defs#publicPolicy"; +const member_list_policy_type = "com.atproto.simplespace.defs#memberListPolicy"; +const managing_app_policy_type = "com.atproto.simplespace.defs#managingAppPolicy"; +const open_app_access_type = "com.atproto.simplespace.defs#open"; +const allow_list_app_access_type = "com.atproto.simplespace.defs#allowList"; + +const PolicyError = error{ UnsupportedPolicy, InvalidRequest }; + +fn policyFromLex(value: std.json.Value) PolicyError!store.SimpleSpacePolicy { + if (value != .object) return error.InvalidRequest; + const kind = zat.json.getString(value, "$type") orelse return error.InvalidRequest; + if (std.mem.eql(u8, kind, public_policy_type)) return .{ .policy = "public", .managing_app = null }; + if (std.mem.eql(u8, kind, member_list_policy_type)) return .{ .policy = "member-list", .managing_app = null }; + if (std.mem.eql(u8, kind, managing_app_policy_type)) { + const managing_app = zat.json.getString(value, "managingApp") orelse return error.InvalidRequest; + if (!std.mem.startsWith(u8, managing_app, "did:")) return error.UnsupportedPolicy; + return .{ .policy = "managing-app", .managing_app = managing_app }; + } + return error.UnsupportedPolicy; +} + +fn rejectPolicy(request: *http_api.Request, err: PolicyError) !void { + return switch (err) { + error.UnsupportedPolicy => http_api.xrpcError(request, .bad_request, "UnsupportedPolicy", "The requested policy is not one this host implements"), + error.InvalidRequest => http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid policy"), }; - if (std.mem.eql(u8, kind, "com.atproto.simplespace.defs#open")) return try allocator.dupe(u8, "{\"type\":\"open\"}"); - if (!std.mem.eql(u8, kind, "com.atproto.simplespace.defs#allowList")) return error.InvalidRecordType; - const allowed = object.get("allowed") orelse return error.InvalidRecordType; - if (allowed != .array) return error.InvalidRecordType; +} + +const AppAccessError = error{ UnsupportedAppAccess, InvalidRequest, OutOfMemory }; + +fn appAccessFromLex(allocator: std.mem.Allocator, value: std.json.Value) AppAccessError![]const u8 { + if (value != .object) return error.InvalidRequest; + const kind = zat.json.getString(value, "$type") orelse return error.InvalidRequest; + if (std.mem.eql(u8, kind, open_app_access_type)) return allocator.dupe(u8, "{\"type\":\"open\"}"); + if (!std.mem.eql(u8, kind, allow_list_app_access_type)) return error.UnsupportedAppAccess; + const allowed = jsonField(value, "allowed") orelse return error.InvalidRequest; + if (allowed != .array) return error.InvalidRequest; for (allowed.array.items) |item| { - if (item != .string) return error.InvalidRecordType; + if (item != .string) return error.InvalidRequest; } var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); - try out.writer.writeAll("{\"type\":\"allowList\",\"allowed\":["); + out.writer.writeAll("{\"type\":\"allowList\",\"allowed\":[") catch return error.OutOfMemory; for (allowed.array.items, 0..) |item, idx| { - if (idx != 0) try out.writer.writeByte(','); - try out.writer.print("{f}", .{std.json.fmt(item.string, .{})}); + if (idx != 0) out.writer.writeByte(',') catch return error.OutOfMemory; + out.writer.print("{f}", .{std.json.fmt(item.string, .{})}) catch return error.OutOfMemory; } - try out.writer.writeAll("]}"); + out.writer.writeAll("]}") catch return error.OutOfMemory; return out.toOwnedSlice(); } +fn rejectAppAccess(request: *http_api.Request, err: AppAccessError) !void { + return switch (err) { + error.UnsupportedAppAccess => http_api.xrpcError(request, .bad_request, "UnsupportedAppAccess", "The requested appAccess variant is not one this host implements"), + error.InvalidRequest => http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid appAccess"), + error.OutOfMemory => error.OutOfMemory, + }; +} + fn lexAppAccessJson(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { const parsed = try std.json.parseFromSlice(std.json.Value, allocator, raw, .{}); const kind = zat.json.getString(parsed.value, "type") orelse return error.InvalidRecordType; - if (std.mem.eql(u8, kind, "open")) return allocator.dupe(u8, "{\"$type\":\"com.atproto.simplespace.defs#open\"}"); + if (std.mem.eql(u8, kind, "open")) return allocator.dupe(u8, "{\"$type\":\"" ++ open_app_access_type ++ "\"}"); if (!std.mem.eql(u8, kind, "allowList")) return error.InvalidRecordType; - const allowed = switch (parsed.value) { - .object => |object| object.get("allowed") orelse return error.InvalidRecordType, - else => return error.InvalidRecordType, - }; + const allowed = jsonField(parsed.value, "allowed") orelse return error.InvalidRecordType; if (allowed != .array) return error.InvalidRecordType; var out: std.Io.Writer.Allocating = .init(allocator); defer out.deinit(); - try out.writer.writeAll("{\"$type\":\"com.atproto.simplespace.defs#allowList\",\"allowed\":["); + try out.writer.writeAll("{\"$type\":\"" ++ allow_list_app_access_type ++ "\",\"allowed\":["); for (allowed.array.items, 0..) |item, idx| { if (item != .string) return error.InvalidRecordType; if (idx != 0) try out.writer.writeByte(','); @@ -1699,21 +1708,28 @@ fn lexAppAccessJson(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { return out.toOwnedSlice(); } -fn simpleSpaceConfigJson(allocator: std.mem.Allocator, space: store.SpaceConfig) ![]const u8 { - var out: std.Io.Writer.Allocating = .init(allocator); - defer out.deinit(); - try out.writer.print( - "{{\"$type\":\"com.atproto.simplespace.defs#spaceConfig\",\"policy\":{f},\"appAccess\":{s}", - .{ - std.json.fmt(space.policy, .{}), - try lexAppAccessJson(allocator, space.app_access_json), - }, - ); - if (space.managing_app) |managing_app| { - try out.writer.print(",\"managingApp\":{f}", .{std.json.fmt(managing_app, .{})}); +fn lexPolicyJson(allocator: std.mem.Allocator, policy: []const u8, managing_app: ?[]const u8) ![]const u8 { + if (std.mem.eql(u8, policy, "public")) return allocator.dupe(u8, "{\"$type\":\"" ++ public_policy_type ++ "\"}"); + if (std.mem.eql(u8, policy, "managing-app")) { + return std.fmt.allocPrint(allocator, "{{\"$type\":\"" ++ managing_app_policy_type ++ "\",\"managingApp\":{f}}}", .{std.json.fmt(managing_app orelse "", .{})}); } - try out.writer.writeByte('}'); - return out.toOwnedSlice(); + return allocator.dupe(u8, "{\"$type\":\"" ++ member_list_policy_type ++ "\"}"); +} + +fn simpleSpaceViewJson(allocator: std.mem.Allocator, space: store.SpaceConfig) ![]const u8 { + return std.fmt.allocPrint(allocator, "{{\"uri\":{f},\"policy\":{s},\"appAccess\":{s}}}", .{ + std.json.fmt(space.uri, .{}), + try lexPolicyJson(allocator, space.policy, space.managing_app), + try lexAppAccessJson(allocator, space.app_access_json), + }); +} + +fn parseOplogCursor(raw: []const u8) ?store.OplogCursor { + const slash = std.mem.indexOfScalar(u8, raw, '/') orelse return null; + const rev = raw[0..slash]; + if (rev.len == 0) return null; + const idx = std.fmt.parseInt(i64, raw[slash + 1 ..], 10) catch return null; + return .{ .rev = rev, .idx = idx }; } const parseSpaceUri = space_uris.SpaceUri.parse; @@ -1744,3 +1760,84 @@ test "permissioned wire hashes use AT JSON bytes" { const actual = try parseAtJsonBytes(allocator, parsed.value); try std.testing.expectEqualSlices(u8, &expected, actual); } + +test "simplespace policy unions round-trip through the stored shape" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const managing = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"com.atproto.simplespace.defs#managingAppPolicy\",\"managingApp\":\"did:web:bulletin.my#bulletin\"}", .{}); + const parsed = try policyFromLex(managing.value); + try std.testing.expectEqualStrings("managing-app", parsed.policy); + try std.testing.expectEqualStrings("did:web:bulletin.my#bulletin", parsed.managing_app.?); + try std.testing.expectEqualStrings( + "{\"$type\":\"com.atproto.simplespace.defs#managingAppPolicy\",\"managingApp\":\"did:web:bulletin.my#bulletin\"}", + try lexPolicyJson(allocator, parsed.policy, parsed.managing_app), + ); + + const public = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"com.atproto.simplespace.defs#publicPolicy\"}", .{}); + try std.testing.expectEqualStrings("public", (try policyFromLex(public.value)).policy); + const members = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"com.atproto.simplespace.defs#memberListPolicy\"}", .{}); + try std.testing.expectEqualStrings("member-list", (try policyFromLex(members.value)).policy); + + const not_a_did = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"com.atproto.simplespace.defs#managingAppPolicy\",\"managingApp\":\"https://bulletin.my\"}", .{}); + try std.testing.expectError(error.UnsupportedPolicy, policyFromLex(not_a_did.value)); + const unknown = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"com.atproto.simplespace.defs#somethingElse\"}", .{}); + try std.testing.expectError(error.UnsupportedPolicy, policyFromLex(unknown.value)); + const legacy_string = try std.json.parseFromSlice(std.json.Value, allocator, "\"member-list\"", .{}); + try std.testing.expectError(error.InvalidRequest, policyFromLex(legacy_string.value)); +} + +test "simplespace appAccess unions round-trip through the stored shape" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const open = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"com.atproto.simplespace.defs#open\"}", .{}); + const stored_open = try appAccessFromLex(allocator, open.value); + try std.testing.expectEqualStrings("{\"type\":\"open\"}", stored_open); + try std.testing.expectEqualStrings("{\"$type\":\"com.atproto.simplespace.defs#open\"}", try lexAppAccessJson(allocator, stored_open)); + + const allow = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"com.atproto.simplespace.defs#allowList\",\"allowed\":[\"https://a.example/c.json\"]}", .{}); + const stored_allow = try appAccessFromLex(allocator, allow.value); + try std.testing.expectEqualStrings("{\"type\":\"allowList\",\"allowed\":[\"https://a.example/c.json\"]}", stored_allow); + try std.testing.expectEqualStrings( + "{\"$type\":\"com.atproto.simplespace.defs#allowList\",\"allowed\":[\"https://a.example/c.json\"]}", + try lexAppAccessJson(allocator, stored_allow), + ); + + const unknown = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"com.atproto.simplespace.defs#denyList\"}", .{}); + try std.testing.expectError(error.UnsupportedAppAccess, appAccessFromLex(allocator, unknown.value)); + const missing_allowed = try std.json.parseFromSlice(std.json.Value, allocator, "{\"$type\":\"com.atproto.simplespace.defs#allowList\"}", .{}); + try std.testing.expectError(error.InvalidRequest, appAccessFromLex(allocator, missing_allowed.value)); +} + +test "simplespace getSpace view matches the alpha lexicon" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + const view = try simpleSpaceViewJson(allocator, .{ + .uri = "at://did:plc:abc/space/my.bulletin.board/self", + .authority_did = "did:plc:abc", + .space_type = "my.bulletin.board", + .skey = "self", + .managing_app = "did:web:bulletin.my#bulletin", + .policy = "managing-app", + .app_access_json = "{\"type\":\"open\"}", + .is_authority = true, + .deleted_at = null, + }); + try std.testing.expectEqualStrings( + "{\"uri\":\"at://did:plc:abc/space/my.bulletin.board/self\",\"policy\":{\"$type\":\"com.atproto.simplespace.defs#managingAppPolicy\",\"managingApp\":\"did:web:bulletin.my#bulletin\"},\"appAccess\":{\"$type\":\"com.atproto.simplespace.defs#open\"}}", + view, + ); +} + +test "listRepoOps cursors are rev/idx" { + const cursor = parseOplogCursor("3lx7abcdefg22/4").?; + try std.testing.expectEqualStrings("3lx7abcdefg22", cursor.rev); + try std.testing.expectEqual(@as(i64, 4), cursor.idx); + try std.testing.expect(parseOplogCursor("3lx7abcdefg22") == null); + try std.testing.expect(parseOplogCursor("/4") == null); + try std.testing.expect(parseOplogCursor("3lx7abcdefg22/x") == null); +} diff --git a/src/http/router.zig b/src/http/router.zig index bc14201..904986b 100644 --- a/src/http/router.zig +++ b/src/http/router.zig @@ -219,7 +219,7 @@ pub const endpoints = [_]Endpoint{ .{ .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"} }, .{ .route = .identity_update_handle, .method = "POST", .path = "/xrpc/com.atproto.identity.updateHandle", .group = "identity", .auth = "bearer", .summary = "Update the signed-in account's handle.", .body = &.{"handle"} }, - .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getSpace", .group = "space", .auth = "experimental OAuth bearer or DPoP space credential", .summary = "Read space configuration from its authority host.", .params = &.{"space"}, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.getSpace", .group = "space", .auth = "experimental OAuth bearer or DPoP space credential", .summary = "Pre-alpha name for com.atproto.simplespace.getSpace; same response.", .params = &.{"space"}, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listSpaces", .group = "space", .auth = "experimental bearer", .summary = "List permissioned repos held by the authenticated user, grouped by space.", .params = &.{ "did", "type", "limit", "cursor" }, .notes = permissioned_data_note }, .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.space.listRepos", .group = "space", .auth = "experimental DPoP space credential", .summary = "List the authority's known writer repos and commit digests.", .params = &.{ "space", "limit", "cursor" }, .notes = permissioned_data_note }, @@ -244,8 +244,9 @@ pub const endpoints = [_]Endpoint{ .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.space.getSpaceCredential", .group = "space", .auth = "experimental delegation token and DPoP proof", .summary = "Exchange a delegation token for a DPoP-bound space credential.", .body = &.{ "space", "clientAttestation" }, .notes = permissioned_data_note }, .{ .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 }, - .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.createSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Create or materialize a baseline PDS-managed permissioned data space.", .body = &.{ "did", "type", "skey", "config" }, .notes = permissioned_data_note }, - .{ .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 }, + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.createSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Create a space anchored on the authenticated user's DID.", .body = &.{ "type", "skey", "policy", "appAccess" }, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "GET", .path = "/xrpc/com.atproto.simplespace.getSpace", .group = "simplespace", .auth = "experimental OAuth bearer or DPoP space credential", .summary = "Describe a space and its policy and appAccess configuration.", .params = &.{"space"}, .notes = permissioned_data_note }, + .{ .route = .permissioned_data, .method = "POST", .path = "/xrpc/com.atproto.simplespace.updateSpace", .group = "simplespace", .auth = "experimental bearer", .summary = "Update a space's policy and/or appAccess.", .body = &.{ "space", "policy", "appAccess" }, .notes = permissioned_data_note }, .{ .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 }, .{ .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 }, .{ .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 }, diff --git a/src/storage/store.zig b/src/storage/store.zig index 0a4ba80..7336c18 100644 --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -315,6 +315,16 @@ pub const SpaceConfig = struct { deleted_at: ?i64, }; +pub const SimpleSpacePolicy = struct { + policy: []const u8, + managing_app: ?[]const u8, +}; + +pub const OplogCursor = struct { + rev: []const u8, + idx: i64, +}; + pub const SimpleSpaceMember = struct { did: []const u8, created_at: i64, @@ -3801,24 +3811,17 @@ pub fn listSpaces( pub fn updateSimpleSpaceConfig( space: []const u8, - managing_app: ?[]const u8, - clear_managing_app: bool, - maybe_policy: ?[]const u8, + maybe_policy: ?SimpleSpacePolicy, maybe_app_access_json: ?[]const u8, ) !void { - if (maybe_policy) |policy| if (!validSimpleSpacePolicy(policy)) return Error.InvalidRecordType; + if (maybe_policy) |policy| if (!validSimpleSpacePolicy(policy.policy)) return Error.InvalidRecordType; if (maybe_app_access_json) |app_access| try validateAppAccessJson(app_access); db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); _ = (try getSpaceConfigLocked(std.heap.page_allocator, space)) orelse return Error.RepoNotFound; - if (managing_app) |value| { - try conn.exec("UPDATE permissioned_spaces SET managing_app = ? WHERE uri = ?", .{ value, space }); - } else if (clear_managing_app) { - try conn.exec("UPDATE permissioned_spaces SET managing_app = NULL WHERE uri = ?", .{space}); - } if (maybe_policy) |policy| { - try conn.exec("UPDATE permissioned_spaces SET policy = ? WHERE uri = ?", .{ policy, space }); + try conn.exec("UPDATE permissioned_spaces SET policy = ?, managing_app = ? WHERE uri = ?", .{ policy.policy, policy.managing_app, space }); } if (maybe_app_access_json) |app_access| { try conn.exec("UPDATE permissioned_spaces SET app_access_json = ? WHERE uri = ?", .{ app_access, space }); @@ -3913,7 +3916,7 @@ pub fn listSpaceWriters(allocator: std.mem.Allocator, space: []const u8, maybe_c , .{space}); if (authority == null) return Error.RepoNotFound; authority.?.deinit(); - const capped_limit: i64 = @intCast(@min(if (limit == 0) 50 else limit, 100)); + const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); var rows = if (maybe_cursor) |cursor| try conn.rows( \\SELECT repo_did, hash, rev @@ -4331,12 +4334,24 @@ pub fn getSpaceRepoState(allocator: std.mem.Allocator, space: []const u8, repo_d return getRepoStateLocked(allocator, space, repo_did); } -pub fn listSpaceRecordOplog(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, since: ?[]const u8, limit: usize, include_values: bool) ![]SpaceRecordOplogEntry { +pub fn listSpaceRecordOplog(allocator: std.mem.Allocator, space: []const u8, repo_did: []const u8, since: ?[]const u8, cursor: ?OplogCursor, limit: usize, include_values: bool) ![]SpaceRecordOplogEntry { db_mutex.lockUncancelable(store_io); defer db_mutex.unlock(store_io); try requireInitialized(); const capped_limit: i64 = @intCast(@min(if (limit == 0) 100 else limit, 1000)); - var rows = if (since) |rev| + var rows = if (cursor) |position| + try conn.rows( + \\SELECT o.rev, o.idx, o.action, o.repo_did, o.collection, o.rkey, o.cid, o.prev, + \\ CASE WHEN ? AND r.cid = o.cid THEN r.value_json END + \\FROM permissioned_space_record_oplog o + \\LEFT JOIN permissioned_space_records r + \\ ON r.space = o.space AND r.repo_did = o.repo_did + \\ AND r.collection = o.collection AND r.rkey = o.rkey + \\WHERE o.space = ? AND o.repo_did = ? AND (o.rev > ? OR (o.rev = ? AND o.idx > ?)) + \\ORDER BY o.rev ASC, o.idx ASC + \\LIMIT ? + , .{ include_values, space, repo_did, position.rev, position.rev, position.idx, capped_limit }) + else if (since) |rev| try conn.rows( \\SELECT o.rev, o.idx, o.action, o.repo_did, o.collection, o.rkey, o.cid, o.prev, \\ CASE WHEN ? AND r.cid = o.cid THEN r.value_json END diff --git a/tools/smoke-permissioned.sh b/tools/smoke-permissioned.sh index 725c0db..57190aa 100755 --- a/tools/smoke-permissioned.sh +++ b/tools/smoke-permissioned.sh @@ -89,28 +89,55 @@ test "$public_blob_status" = "404" space_create=$(curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data '{"did":"did:plc:permissionsmoke","type":"fm.plyr.privateMedia","skey":"self","config":{"managingApp":"did:web:plyr.fm","policy":"member-list","appAccess":{"$type":"com.atproto.simplespace.defs#open"}}}') -printf '%s' "$space_create" | grep -q '"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self"' -! printf '%s' "$space_create" | grep -q '"config":' + --data '{"type":"fm.plyr.privateMedia","skey":"self","policy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') +test "$space_create" = '{"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self"}' + +# the pre-alpha body shape (did + config wrapper) is rejected, not silently accepted +legacy_create_status=$(curl -sS -o /tmp/zds-space-legacy-create.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ + -H "authorization: Bearer $token" \ + -H 'content-type: application/json' \ + --data '{"did":"did:plc:permissionsmoke","type":"fm.plyr.privateMedia","skey":"legacy","config":{"policy":"member-list"}}') +test "$legacy_create_status" = "400" +grep -q '"error":"InvalidRequest"' /tmp/zds-space-legacy-create.json + +unsupported_policy_status=$(curl -sS -o /tmp/zds-space-unsupported-policy.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ + -H "authorization: Bearer $token" \ + -H 'content-type: application/json' \ + --data '{"type":"fm.plyr.privateMedia","skey":"unsupported","policy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"https://plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') +test "$unsupported_policy_status" = "400" +grep -q '"error":"UnsupportedPolicy"' /tmp/zds-space-unsupported-policy.json + +unsupported_app_access_status=$(curl -sS -o /tmp/zds-space-unsupported-app-access.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ + -H "authorization: Bearer $token" \ + -H 'content-type: application/json' \ + --data '{"type":"fm.plyr.privateMedia","skey":"unsupported","policy":{"$type":"com.atproto.simplespace.defs#publicPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#denyList"}}') +test "$unsupported_app_access_status" = "400" +grep -q '"error":"UnsupportedAppAccess"' /tmp/zds-space-unsupported-app-access.json curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data '{"did":"did:plc:permissionsmoke","type":"fm.plyr.privateMedia","skey":"other","config":{"policy":"member-list","appAccess":{"$type":"com.atproto.simplespace.defs#open"}}}' \ + --data '{"type":"fm.plyr.privateMedia","skey":"other","policy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' \ | grep -q '"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/other"' space_duplicate_status=$(curl -sS -o /tmp/zds-space-duplicate.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.simplespace.createSpace" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \ - --data '{"did":"did:plc:permissionsmoke","type":"fm.plyr.privateMedia","skey":"self"}') + --data '{"type":"fm.plyr.privateMedia","skey":"self","policy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}') test "$space_duplicate_status" = "400" grep -q '"error":"SpaceAlreadyExists"' /tmp/zds-space-duplicate.json -space_get=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getSpace?space=$encoded_space") -printf '%s' "$space_get" | grep -q '"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self"' -printf '%s' "$space_get" | grep -q '"managingApp":"did:web:plyr.fm"' -printf '%s' "$space_get" | grep -q '"policy":"member-list"' -! printf '%s' "$space_get" | grep -q '"members":' +space_get=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.simplespace.getSpace?space=$encoded_space") +test "$space_get" = '{"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self","policy":{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:web:plyr.fm"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' + +# switching policy drops the managing app; the pre-alpha route name answers identically +curl -fsS -X POST "$base/xrpc/com.atproto.simplespace.updateSpace" \ + -H "authorization: Bearer $token" \ + -H 'content-type: application/json' \ + --data "$(jq -nc --arg space "$space_uri" '{space:$space,policy:{"$type":"com.atproto.simplespace.defs#memberListPolicy"}}')" \ + | grep -q '{}' +space_get_updated=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getSpace?space=$encoded_space") +test "$space_get_updated" = '{"uri":"at://did:plc:permissionsmoke/space/fm.plyr.privateMedia/self","policy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},"appAccess":{"$type":"com.atproto.simplespace.defs#open"}}' delegation=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.getDelegationToken?space=$encoded_space" | jq -r '.token') test -n "$delegation" @@ -207,12 +234,28 @@ space_ops=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto. printf '%s' "$space_ops" | grep -q '"collection":"fm.plyr.track"' printf '%s' "$space_ops" | grep -q '"value":' printf '%s' "$space_ops" | grep -q '"commit":' +! printf '%s' "$space_ops" | grep -q '"cursor":' ! printf '%s' "$space_ops" | grep -q '"action":' +# a full page carries a rev/idx cursor instead of the commit; the next page finishes with the commit +space_ops_page=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listRepoOps?space=$encoded_space&repo=did:plc:permissionsmoke&limit=1") +! printf '%s' "$space_ops_page" | grep -q '"commit":' +space_ops_cursor=$(printf '%s' "$space_ops_page" | jq -r '.cursor') +printf '%s' "$space_ops_cursor" | grep -Eq '^[0-9a-z]+/[0-9]+$' +space_ops_rest=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listRepoOps?space=$encoded_space&repo=did:plc:permissionsmoke&limit=1&cursor=$(printf '%s' "$space_ops_cursor" | jq -sRr @uri)") +test "$(printf '%s' "$space_ops_rest" | jq -r '.ops | length')" = "0" +printf '%s' "$space_ops_rest" | grep -q '"commit":' + space_op_refs=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listRepoOps?space=$encoded_space&repo=did:plc:permissionsmoke&excludeValues=true") printf '%s' "$space_op_refs" | grep -q '"collection":"fm.plyr.track"' ! printf '%s' "$space_op_refs" | grep -q '"value":' +curl -fsS -X POST "$base/xrpc/com.atproto.space.deleteRecord" \ + -H "authorization: Bearer $token" \ + -H 'content-type: application/json' \ + --data "$(jq -nc --arg space "$space_uri" '{space:$space,repo:"did:plc:permissionsmoke",collection:"fm.plyr.track",rkey:"never-existed"}')" \ + | grep -q '{}' + space_repo_headers="${TMPDIR:-/tmp}/zds-space-repo.headers" space_repo_car="${TMPDIR:-/tmp}/zds-space-repo.car" curl -fsS -D "$space_repo_headers" -o "$space_repo_car" \