Permissioned data - #5187
Conversation
dholms
commented
Jul 2, 2026
Covers record CRUD, space management, credential flow, and sync
protocol for the permissioned data ("spaces") system.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Multi-op atomic commits in oplog (rev + idx) - isMember flag on space table for membership lifecycle - Separate space_member_state table for owner-only commitment - JWT typ headers for domain discrimination (space_member_grant, space_credential) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
28 tasks across 7 phases: core refactoring, credentials, DB migration, actor store updates, lexicons, endpoints, tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rename SpaceStorage interface to SpaceRepoStorage and MemoryStorage class to MemoryRepoStorage to better reflect their purpose and make room for SpaceMembersStorage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add comprehensive tests for SpaceMembers class including member addition/removal, duplicate detection, order-independent setHash, and storage reload. Also add domain separation test verifying that commits created with 'records' scope cannot be verified with 'members' scope and vice versa. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements JWT creation and verification for two credential types: - MemberGrant: short-lived (5 min) token for members to request space credentials - SpaceCredential: longer-lived (2 hour default) token for space access Both use manual JWT encoding/decoding following atproto patterns, with proper signature verification, expiry checking, and type validation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds new database schema types for space repo state, member state, oplogs, and credential recipients. Updates migration 003 to restructure the space and space_member tables by moving setHash/rev to separate state tables and adding isMember flag to space table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…or new table structure Updated SpaceReader to read from new space_repo and space_member_state tables: - getSpace() now returns isMember instead of setHash/rev - getSetHash() and getRev() read from space_repo table - Added getRepoState() and getMemberState() methods - Added getRepoOplog() and getMemberOplog() for oplog queries - Added getCredentialRecipients() for credential flow - listMembers() now includes memberRev field Updated SpaceTransactor with new commit and state management: - createSpace() now creates space_repo and space_member_state rows - Renamed applyCommit() to applyRepoCommit() with oplog tracking - Added applyMemberCommit() for member operations - Added updateMembership() to manage isMember flag - Added recordCredentialRecipient() for credential tracking - Updated deleteSpace() to clean up all related tables - Updated addMember() to include memberRev field Created new SQL storage adapters: - SqlRepoStorage implements SpaceRepoStorage - SqlMembersStorage implements SpaceMembersStorage - Replaced ScopedSpaceRepoStorage with SqlRepoStorage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Updates PDS endpoints to use the restructured actor store: - Rename ScopedSpaceRepoStorage to SqlRepoStorage in CRUD endpoints - Replace applyCommit() with applyRepoCommit() - Replace getRev() with getRepoState() for swap commit checks - Update addMember/removeMember to use SpaceMembers class with proper set hash tracking and member commits - Update notifyMembership to use updateMembership() instead of deleteSpace() when removing membership, preserving data while updating membership flag Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…points Implements Tasks 21, 22, and 23 for the permissioned data protocol. Task 21: Created 5 sync endpoints: - getRepoState: Get sync state for a user's repo in a space - getRepoOplog: Get operation log for incremental repo sync - getMemberState: Get sync state for space member list - getMemberOplog: Get operation log for member list sync - getMembers: Get full member list for a space All sync endpoints use spaceCredentialAuth and validate that the credential's space matches the requested space. Member endpoints read from the space owner's actor store. Task 22: Created notifyWrite endpoint: - Uses serviceAuth for PDS-to-PDS communication - When space owner receives notification, fans out to credential recipients - Fire-and-forget delivery (best effort, not guaranteed) Task 23: Updated space/index.ts to register all new endpoints including getMemberGrant and getSpaceCredential that were created earlier. All endpoints properly handle branded string types (DidString, NsidString, etc.) from the generated lexicon types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add comprehensive integration tests for the permissioned data protocol: - Oplog and state tracking: verify record operations produce oplog entries, setHash updates, and batch writes use same rev with different idx - Credential flow: test member grant/credential exchange, non-member rejection, and credential usage for sync endpoints - Sync flow: test full and incremental record sync via oplog, and member list sync Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
To me, it feels quite unintuitive that a space has to be created with com.atproto.simplespace.createSpace or app.example.complexspace.newSpace, and then retrieved with com.atproto.space.getSpace.
It feels even more unintuitive, and unfortunate, that a user can make their community go from "member based" to "public" to "private", but can never in the future go from simplespace to complexspace.
I am strongly against this dual way of configuring space access (the "polity type", and then the "policy type"'s config). Even naming them is awkward: is "simplespace" a "config", a "policy", a "policy type" ?. I think we should have only one way of configuring access:
- Either have multiple policy types that have a single behavior each ("simplespace", "publicspace", "privatespace", "managedspace", and more custom 3rd party policies in the future)
- Or a single policy type that has a configurable behavior (ie merge all the "simplespace" stuff under "com.atproto.space")
If we do the former (which I believe is the right call), spaces would all be created with com.atproto.space.createSpace. That endpoint would have a required policy property, that can be any string token (e.g. com.atproto.space.defs#publicspace).
Then, based on that policy value, more endpoints become available to configure the space:
simplespaceenables the member list management endpoints (addMember, etc.)managedspacehas endpoints to configure the managing app (likegetConfig,setManagingApp, etc.)publicspaceandprivatespacemight not really need any config
We could codify this under a new lexicon type but I don't think we need to go that way. A simple convention around "policy X enables endpoints Y" (which can be setup as each TOKEN's description) is enough IMO.
| for (const collection of def.collections) { | ||
| yield collection | ||
| } |
There was a problem hiding this comment.
| for (const collection of def.collections) { | |
| yield collection | |
| } | |
| yield* def.collections |
| "lexicon": 1, | ||
| "id": "com.atproto.space.defs", | ||
| "defs": { | ||
| "signedCommit": { |
There was a problem hiding this comment.
Why not make this it's own lexicon document:
$type: "com.atproto.space.signedCommit"
instead of:
$type: "com.atproto.space.defs#signedCommit"
| @@ -0,0 +1,80 @@ | |||
| import { IdResolver, getHandle } from '@atproto/identity' | |||
There was a problem hiding this comment.
Note that I've been trying to avoid @atproto/identity because it does not support "safeFetch" and is suceptible to SSRF attacks.
There was a problem hiding this comment.
See @atproto-labs/handle-resolver-node and @atproto-labs/identity-resolver for alternatives that support custom (safe) fetch implementation