diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a8e33..1a24a92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ Reconstructed from git history for everything up to `v0.1.1`; kept by hand from is not there answers `{}` instead of `RecordNotFound`. - `getSpaceCredential` uses the lexicon's `InvalidDelegationToken` for a bad or mismatched delegation token (was `InvalidToken` / `InvalidRequest`). + - a write into a space this account has no local row for materializes the writer repo + instead of answering `NotPermitted`. The writer's PDS keeps only the writer's own repo; the + authority decides who is a writer when `notifyWrite` arrives, as in the reference PDS. This is + what let a zds account post a note on a board hosted elsewhere. Before, the only way to + materialize was the pre-alpha `createSpace {did: }` call. + - creating a record that already exists answers `RecordAlreadyExists` (was `NotPermitted`). ## 0.3.0 — 2026-08-20 diff --git a/src/atproto/space.zig b/src/atproto/space.zig index 0d66af9..ee259cf 100644 --- a/src/atproto/space.zig +++ b/src/atproto/space.zig @@ -395,11 +395,11 @@ fn deleteRecord(request: *http_api.Request) !void { if (!std.mem.eql(u8, repo, auth_ctx.account.did)) return http_api.xrpcError(request, .forbidden, "InvalidRepo", "repo must match authenticated user"); const collection = zat.json.getString(input, "collection") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); const rkey = zat.json.getString(input, "rkey") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey"); - try requireSpaceAccess(request, allocator, auth_ctx, space, .delete, collection); + requireSpaceAccess(request, allocator, auth_ctx, space, .delete, collection) catch return; 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 => {}, - error.InvalidRefreshSession => return http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to write in this space"), + error.InvalidRefreshSession => return http_api.xrpcError(request, .bad_request, "RecordAlreadyExists", "Record already exists"), else => return err, }; return http_api.json(request, .ok, "{}"); @@ -428,7 +428,7 @@ fn applyWrites(request: *http_api.Request) !void { const write_type = zat.json.getString(write, "$type") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "write missing $type"); const collection = zat.json.getString(write, "collection") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "write missing collection"); if (std.mem.endsWith(u8, write_type, "#create")) { - try requireSpaceAccess(request, allocator, auth_ctx, space, .create, collection); + requireSpaceAccess(request, allocator, auth_ctx, space, .create, collection) catch return; const rkey = zat.json.getString(write, "rkey") orelse try store.generateRkey(allocator); const value = switch (write) { .object => |object| object.get("value") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "create missing value"), @@ -437,7 +437,7 @@ fn applyWrites(request: *http_api.Request) !void { const prepared = try prepareSpaceRecord(request, allocator, collection, rkey, value); try ops.append(allocator, .{ .create = .{ .collection = collection, .rkey = rkey, .prepared = prepared } }); } else if (std.mem.endsWith(u8, write_type, "#update")) { - try requireSpaceAccess(request, allocator, auth_ctx, space, .update, collection); + requireSpaceAccess(request, allocator, auth_ctx, space, .update, collection) catch return; const rkey = zat.json.getString(write, "rkey") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "update missing rkey"); const value = switch (write) { .object => |object| object.get("value") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "update missing value"), @@ -446,7 +446,7 @@ fn applyWrites(request: *http_api.Request) !void { const prepared = try prepareSpaceRecord(request, allocator, collection, rkey, value); try ops.append(allocator, .{ .update = .{ .collection = collection, .rkey = rkey, .prepared = prepared } }); } else if (std.mem.endsWith(u8, write_type, "#delete")) { - try requireSpaceAccess(request, allocator, auth_ctx, space, .delete, collection); + requireSpaceAccess(request, allocator, auth_ctx, space, .delete, collection) catch return; const rkey = zat.json.getString(write, "rkey") orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "delete missing rkey"); try ops.append(allocator, .{ .delete = .{ .collection = collection, .rkey = rkey } }); } else { @@ -456,7 +456,7 @@ fn applyWrites(request: *http_api.Request) !void { const results = store.applySpaceWrites(allocator, space, repo, ops.items) 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.InvalidRefreshSession => return http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to write in this space"), + error.InvalidRefreshSession => return http_api.xrpcError(request, .bad_request, "RecordAlreadyExists", "Record already exists"), else => return err, }; const state = try store.getSpaceRepoState(allocator, space, repo); @@ -500,10 +500,10 @@ fn writeSpaceRecord(request: *http_api.Request, mode: WriteMode) !void { return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Missing collection"); }; switch (mode) { - .create => try requireSpaceAccess(request, allocator, auth_ctx, space, .create, collection), + .create => requireSpaceAccess(request, allocator, auth_ctx, space, .create, collection) catch return, .put => { - try requireSpaceAccess(request, allocator, auth_ctx, space, .create, collection); - try requireSpaceAccess(request, allocator, auth_ctx, space, .update, collection); + requireSpaceAccess(request, allocator, auth_ctx, space, .create, collection) catch return; + requireSpaceAccess(request, allocator, auth_ctx, space, .update, collection) catch return; }, } const rkey = zat.json.getString(input, "rkey") orelse switch (mode) { @@ -525,7 +525,7 @@ fn writeSpaceRecord(request: *http_api.Request, mode: WriteMode) !void { .put => store.putSpaceRecord(allocator, space, repo, collection, rkey, prepared), } catch |err| switch (err) { error.RepoNotFound => return http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space not found"), - error.InvalidRefreshSession => return http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to write in this space"), + error.InvalidRefreshSession => return http_api.xrpcError(request, .bad_request, "RecordAlreadyExists", "Record already exists"), error.MissingRecord => return http_api.xrpcError(request, .bad_request, "RecordNotFound", "Record not found"), else => return err, }; @@ -1332,11 +1332,27 @@ fn requireSpaceAccess( ) !void { const parsed = parseSpaceUri(space) orelse return http_api.xrpcError(request, .bad_request, "InvalidRequest", "Invalid space URI"); try requireSpaceScope(request, auth_ctx.oauth_scope, parsed.space_type, parsed.authority_did, parsed.skey, action, collection); - const visible = try store.getSpace(allocator, auth_ctx.account.did, space); - if (visible == null or visible.?.deleted_at != null) { - try http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to write in this space"); + if (try store.getSpace(allocator, auth_ctx.account.did, space)) |visible| { + if (visible.deleted_at == null) return; + try http_api.xrpcError(request, .not_found, "SpaceNotFound", "Space has been deleted"); return error.HandledResponse; } + // A writer's PDS needs no prior knowledge of a space: it keeps the writer's + // own repo, and the authority decides who counts as a writer when + // notifyWrite arrives. First write materializes the local repo. + _ = store.createSpace(allocator, .{ + .actor_did = auth_ctx.account.did, + .authority_did = parsed.authority_did, + .space_type = parsed.space_type, + .skey = parsed.skey, + .is_authority = false, + .managing_app = null, + .policy = "member-list", + .app_access_json = "{\"type\":\"open\"}", + }) catch { + try http_api.xrpcError(request, .forbidden, "NotPermitted", "Not permitted to write in this space"); + return error.HandledResponse; + }; } fn requireSimpleSpaceAuthority( diff --git a/tools/smoke-permissioned.sh b/tools/smoke-permissioned.sh index 57190aa..180350b 100755 --- a/tools/smoke-permissioned.sh +++ b/tools/smoke-permissioned.sh @@ -250,6 +250,23 @@ space_op_refs=$(curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atpr printf '%s' "$space_op_refs" | grep -q '"collection":"fm.plyr.track"' ! printf '%s' "$space_op_refs" | grep -q '"value":' +# writing into a space whose authority lives elsewhere needs no local setup: +# the first write materializes the writer's repo here +remote_space_uri="at://did:plc:remoteauthority/space/my.bulletin.board/self" +remote_write=$(curl -fsS -X POST "$base/xrpc/com.atproto.space.createRecord" \ + -H "authorization: Bearer $token" \ + -H 'content-type: application/json' \ + --data "$(jq -nc --arg space "$remote_space_uri" '{space:$space,repo:"did:plc:permissionsmoke",collection:"my.bulletin.post",rkey:"note-one",record:{"$type":"my.bulletin.post",text:"hi"}}')") +printf '%s' "$remote_write" | grep -q '"uri":"at://did:plc:remoteauthority/space/my.bulletin.board/self/did:plc:permissionsmoke/my.bulletin.post/note-one"' +curl -fsS -H "authorization: Bearer $token" "$base/xrpc/com.atproto.space.listSpaces?did=did:plc:remoteauthority" \ + | grep -q '"uri":"at://did:plc:remoteauthority/space/my.bulletin.board/self"' +duplicate_write_status=$(curl -sS -o /tmp/zds-space-duplicate-record.json -w '%{http_code}' -X POST "$base/xrpc/com.atproto.space.createRecord" \ + -H "authorization: Bearer $token" \ + -H 'content-type: application/json' \ + --data "$(jq -nc --arg space "$remote_space_uri" '{space:$space,repo:"did:plc:permissionsmoke",collection:"my.bulletin.post",rkey:"note-one",record:{"$type":"my.bulletin.post",text:"again"}}')") +test "$duplicate_write_status" = "400" +grep -q '"error":"RecordAlreadyExists"' /tmp/zds-space-duplicate-record.json + curl -fsS -X POST "$base/xrpc/com.atproto.space.deleteRecord" \ -H "authorization: Bearer $token" \ -H 'content-type: application/json' \