diff --git a/packages/docs/docs/experimental/spaces.md b/packages/docs/docs/experimental/spaces.md new file mode 100644 index 0000000..41d9e0e --- /dev/null +++ b/packages/docs/docs/experimental/spaces.md @@ -0,0 +1,102 @@ +# Permissioned Spaces + +:::caution Experimental +Permissioned Spaces are experimental and the API will change. This implementation follows Daniel Holmgren's [Permissioned Data Diaries](https://dholms.leaflet.pub/3meluqcwky22a) and aligns structurally with the `permissioned-data` branch on `bluesky-social/atproto`, but uses a `dev.happyview` namespace to allow iteration while the official spec stabilizes. +::: + +Spaces are containers for permissioned data in atproto. Unlike regular public records that live in a user's repo, space records are gated by membership — only members can read or write data within a space. + +## Concepts + +A **space** is identified by three components: + +- **Space DID** — the space's own decentralized identifier (for personal spaces, this is the user's DID) +- **Type** — the space type as an NSID, describing the modality (e.g. a forum, a group chat, a photo album) +- **Space key (skey)** — a short string differentiating multiple spaces of the same type + +These form the space URI: `ats:////` + +A **space record** adds three more components to the URI: the author's DID, the collection NSID, and the record key: + +``` +ats://///// +``` + +## Feature flag + +In HappyView, spaces are gated behind the `feature.spaces_enabled` instance setting. Enable it in the dashboard under **Settings** or via the admin API: + +```sh +curl -X PUT http://127.0.0.1:3000/admin/settings/feature.spaces_enabled \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"value": "true"}' +``` + +When disabled, all `/xrpc/dev.happyview.space.*` endpoints return `501 Not Implemented`. + +## Endpoints + +All space endpoints live under the `dev.happyview.space` namespace and require [DPoP authentication](../getting-started/authentication.md). + +| Endpoint | Method | Description | +| ---------------------------------------- | ------ | ------------------------------------- | +| `dev.happyview.space.createSpace` | POST | Create a space | +| `dev.happyview.space.getSpace` | GET | Get a space by URI | +| `dev.happyview.space.listSpaces` | GET | List spaces by membership | +| `dev.happyview.space.updateSpace` | POST | Update space metadata | +| `dev.happyview.space.deleteSpace` | POST | Delete a space | +| `dev.happyview.space.createRecord` | POST | Create a record (auto-generated rkey) | +| `dev.happyview.space.putRecord` | POST | Write a record | +| `dev.happyview.space.getRecord` | GET | Get a record | +| `dev.happyview.space.listRecords` | GET | List records | +| `dev.happyview.space.deleteRecord` | POST | Delete a record | +| `dev.happyview.space.applyWrites` | POST | Batch write operations | +| `dev.happyview.space.addMember` | POST | Add a member | +| `dev.happyview.space.removeMember` | POST | Remove a member | +| `dev.happyview.space.listMembers` | GET | List resolved members | +| `dev.happyview.space.createInvite` | POST | Create an invite | +| `dev.happyview.space.redeemInvite` | POST | Redeem an invite | +| `dev.happyview.space.revokeInvite` | POST | Revoke an invite | +| `dev.happyview.space.listInvites` | GET | List invites | +| `dev.happyview.space.getMemberGrant` | POST | Prove membership (step 1) | +| `dev.happyview.space.getSpaceCredential` | POST | Get a space credential (step 2) | + +## Access model + +Spaces have an **access mode** that controls third-party app access: + +- **`default_allow`** — any app can access (with optional denylist) +- **`default_deny`** — only explicitly allowed apps can access + +Individual users access spaces through **membership**. Members have either `read` or `write` access. Write access implies read. The space creator is automatically added as a write member. + +Spaces also support **delegation** — adding another space as a member, which transitively grants access to all members of the delegated space. + +## Divergences from the reference spec + +HappyView mostly mirrors [Daniel Holmgren's `permissioned-data` branch](https://github.com/bluesky-social/atproto/tree/permissioned-data) but diverges in some areas. These will narrow as the official spec stabilizes. + +### HappyView extensions (not in the reference branch) + +- **`isDelegation` on members** allows spaces to be members of other spaces +- **`displayName`, `description`, `accessMode` on spaces** — the reference space model is minimal (`uri`, `isOwner`, `isMember`, `createdAt`) +- **`appAllowlist` / `appDenylist` / `managingAppDid`** — app-level access control layer +- **`config` object** on spaces (e.g. `membershipPublic`, `recordsPublic`) +- **Invite system** — `createInvite`, `redeemInvite`, `revokeInvite`, `listInvites` +- **`read` / `write` access levels** — the reference branch treats membership as binary + +### Reference features not yet implemented + +- **Oplogs** — `getRepoOplog`, `getMemberOplog`, `getRepoState`, `getMemberState` (sync primitives for space data) +- **Push notifications** — `notifyWrite`, `notifyMembership` (service-to-service event delivery) +- **Space-scoped blobs** — `uploadBlob` for blobs within a space context +- **Owner record deletion** — in the reference branch the space owner can delete any record; HappyView restricts `deleteRecord` to the record's author only + +## Next steps + +- [Managing Spaces](spaces/managing-spaces.md) — create, update, and delete spaces +- [Members](spaces/members.md) — manage membership and delegation +- [Records](spaces/records.md) — read and write permissioned data +- [Credentials](spaces/credentials.md) — cross-service authentication for spaces +- [Invites](spaces/invites.md) — invite-based membership diff --git a/packages/docs/docs/experimental/spaces/changelog.md b/packages/docs/docs/experimental/spaces/changelog.md new file mode 100644 index 0000000..deaf36e --- /dev/null +++ b/packages/docs/docs/experimental/spaces/changelog.md @@ -0,0 +1,64 @@ +# Changelog + +## Latest + +### New endpoints + +- **`createRecord`:** create a record with an auto-generated TID rkey instead of requiring the caller to supply one +- **`applyWrites`:** batch multiple create, update, and delete operations in a single request + +### Optimistic concurrency + +- **`swapRecord`:** optional CID-based concurrency guard on `putRecord`, `deleteRecord`, and individual operations within `applyWrites`. Returns `409 Conflict` when the record's current CID doesn't match. +- **`swapCommit`:** optional revision-based concurrency guard on `applyWrites`. Asserts the space's current revision before applying any writes. Returns `409 Conflict` on mismatch. +- Spaces now track a `revision` field (TID) that advances on every write. + +### Space DID separation + +- Spaces now have their own `did` field, distinct from the `owner_did` of the space creator. For personal spaces these are the same DID; multi-party spaces will have their own DID. +- All URI construction and lookups use the space's DID. Ownership checks use `owner_did`. +- New database migration adds the `did` column to the `spaces` table. + +### Two-step credential flow + +- Replaced the single `getCredential` endpoint with a two-step flow: + 1. **`getMemberGrant`:** proves membership and returns an HMAC-SHA256 grant (5-minute TTL) + 2. **`getSpaceCredential`:** exchanges the grant for an ES256 space credential JWT (4-hour TTL) +- Removed the `refreshCredential` endpoint (just repeat the two-step flow) + +### Bearer auth for space credentials + +- Space credentials are now passed as standard `Authorization: Bearer ` instead of a custom `X-Space-Credential` header. HappyView distinguishes credentials from other Bearer tokens by checking the JWT `typ` header (`space_credential`), matching Dan's reference implementation. +- No DPoP auth or client key needed when authenticating via space credential. + +### Endpoint naming + +- Space CRUD endpoints renamed to verbNoun format: `space.create` → `space.createSpace`, `space.get` → `space.getSpace`, `space.list` → `space.listSpaces`, `space.update` → `space.updateSpace`, `space.delete` → `space.deleteSpace`. +- Invite endpoints moved out of the `invite.*` sub-namespace: `invite.create` → `space.createInvite`, `invite.redeem` → `space.redeemInvite`, `invite.revoke` → `space.revokeInvite`, `invite.list` → `space.listInvites`. +- Old endpoint names are still available as legacy aliases and will be removed in a future release. + +### Bug fixes + +- Fixed `WriteOp` serde deserialization. `swapRecord` fields in `update` and `delete` operations now correctly deserialize from camelCase JSON. +- Credential `iss` claim now uses the space's DID instead of the owner's DID. +- `SpaceUri` parsing updated to use `did` (space DID) instead of `owner_did`. + +--- + +## v2.5.0 + +_Released 2026-05-05_ + +Initial release of Permissioned Spaces behind the `feature.spaces_enabled` experimental flag. + +### Features + +- Space CRUD: `create`, `get`, `list`, `update`, `delete` +- Record operations: `putRecord`, `getRecord`, `listRecords`, `deleteRecord` +- Membership management: `addMember`, `removeMember`, `listMembers` +- Invite system: `invite.create`, `invite.redeem`, `invite.revoke`, `invite.list` +- `ats://` URI scheme for addressing permissioned data +- Access model with `default_allow` / `default_deny` modes and app allowlists/denylists +- Space credentials for cross-service read access via `X-Space-Credential` header +- Delegation: adding a space as a member transitively grants access to its members +- Lua scripting context includes space metadata (`space.did`, `space.owner_did`, `space.type_nsid`, `space.skey`) diff --git a/packages/docs/docs/experimental/spaces/credentials.md b/packages/docs/docs/experimental/spaces/credentials.md new file mode 100644 index 0000000..afd8855 --- /dev/null +++ b/packages/docs/docs/experimental/spaces/credentials.md @@ -0,0 +1,128 @@ +# Credentials + +:::caution Experimental +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. +::: + +Space credentials are short-lived JWTs for cross-service access to space data. A member proves their membership to get a grant, exchanges the grant for a credential JWT, then passes it to an external service that needs to read the space's records. + +## How credentials work + +Credential issuance is a two-step process: + +```mermaid +sequenceDiagram + participant App as Client App + participant HV as HappyView + participant Svc as External Service + + App->>HV: POST dev.happyview.space.getMemberGrant
(DPoP auth, must be a member) + HV->>HV: Verify membership + HV-->>App: grant token + expiresAt + + App->>HV: POST dev.happyview.space.getSpaceCredential
(DPoP auth, grant token) + HV->>HV: Verify grant
Check app access (allow/deny list)
Sign credential with space keypair + HV-->>App: credential JWT + expiresAt + + App->>Svc: Request with Authorization: Bearer credential + Svc->>HV: Read space records
(Bearer credential) + HV->>HV: Verify credential signature
via space DID doc + HV-->>Svc: Record data +``` + +Credentials are ES256 JWTs signed with a P-256 keypair unique to each space. The keypair is generated on first credential request and stored encrypted (AES-256-GCM). + +## Step 1: Get a member grant + +The caller must be an authenticated member of the space. The grant is a short-lived token (5 minutes) that proves membership. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main" + }' +``` + +**Response:** + +```json +{ + "grant": "eyJhbGciOiJIUzI1NiJ9...", + "expiresAt": "2026-05-09T12:05:00Z" +} +``` + +## Step 2: Get a space credential + +Exchange the grant for a space credential JWT. The credential is signed by the space's keypair and has a 4-hour TTL. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "grant": "eyJhbGciOiJIUzI1NiJ9..." + }' +``` + +**Response:** + +```json +{ + "credential": "eyJhbGciOiJFUzI1NiJ9...", + "expiresAt": "2026-05-09T16:00:00Z" +} +``` + +### Credential claims + +The JWT payload contains: + +| Claim | Description | +|---|---| +| `iss` | The space's DID (who signed it) | +| `sub` | The member's DID (who it was issued to) | +| `space` | The full `ats://` space URI | +| `scope` | Access level (`read`) | +| `iat` | Issued at (Unix timestamp) | +| `exp` | Expiry (Unix timestamp) | + +## Using a credential + +Pass the credential as a standard Bearer token in the `Authorization` header. HappyView distinguishes space credentials from other tokens by checking the JWT header's `typ` field (`space_credential`). + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...' \ + -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' +``` + +No DPoP auth or client key is needed when authenticating via space credential — the credential itself is sufficient. The user's identity comes from the `sub` claim in the JWT. + +HappyView verifies the credential by resolving the issuer's DID document, extracting the signing key, and validating the JWT signature and expiry. If valid, the request is treated as if the credential's `sub` is a member of the space. + +## App access control + +Before issuing a credential, HappyView checks whether the calling app (identified by its DPoP client key) is allowed to access the space: + +- **`default_allow` mode**: any app can get credentials unless it's on the `appDenylist` +- **`default_deny` mode**: only apps on the `appAllowlist` can get credentials + +If no client key is present in the DPoP claims, the check is skipped (direct user access without an app intermediary). + +## External credential verification + +HappyView can also verify credentials issued by *other* HappyView instances or space-aware services. When a Bearer space credential is presented, HappyView: + +1. Decodes the JWT without verification to extract the `iss` (issuer DID) +2. Resolves the issuer's DID document +3. Extracts the signing key from the DID doc +4. Verifies the JWT signature and expiry +5. Checks that the `space` claim matches the requested space + +A credential issued by one instance can be used to read from another instance that hosts the same space's data. diff --git a/packages/docs/docs/experimental/spaces/invites.md b/packages/docs/docs/experimental/spaces/invites.md new file mode 100644 index 0000000..b0e15ce --- /dev/null +++ b/packages/docs/docs/experimental/spaces/invites.md @@ -0,0 +1,134 @@ +# Invites + +:::caution Experimental +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. +::: + +Invites let space owners distribute membership tokens without knowing recipients' DIDs in advance. + +:::info HappyView Extension +Invites are a HappyView-specific feature, not part of the AT Protocol spaces spec. They may be replaced by a different mechanism in the future. +::: + +## Creating an invite + +Only the space owner or a super admin can create invites. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createInvite' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "access": "write", + "maxUses": 10, + "expiresAt": "2026-06-01T00:00:00Z" + }' +``` + +**Input:** + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `space` | string | Yes | | The space this invite is for | +| `access` | string | No | `read` | Access level granted on redemption (`read` or `write`) | +| `maxUses` | integer | No | unlimited | Maximum number of times the invite can be redeemed | +| `expiresAt` | string (datetime) | No | never | When the invite expires | + +**Response (201):** + +```json +{ + "inviteId": "uuid", + "token": "a1b2c3d4e5f6...", + "access": "write", + "maxUses": 10, + "expiresAt": "2026-06-01T00:00:00Z" +} +``` + +:::warning +The `token` is only returned once. It is stored as a SHA-256 hash — HappyView cannot recover the plaintext. +::: + +## Redeeming an invite + +Any authenticated user can redeem an invite token to join the space. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "token": "a1b2c3d4e5f6..." + }' +``` + +**Response (201):** + +```json +{ + "uri": "ats://did:plc:abc123/com.example.forum/main", + "access": "write" +} +``` + +Redemption fails if: + +- The token is invalid (no matching hash found) +- The invite has been revoked +- The invite has reached its `maxUses` +- The invite has expired +- The user is already a member of the space + +## Revoking an invite + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.revokeInvite' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "inviteId": "uuid" + }' +``` + +Revoking an invite prevents future redemptions but does not remove members who already redeemed it. + +## Listing invites + +Only the space owner or a super admin can list invites. + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.listInvites?space=ats://did:plc:abc123/com.example.forum/main' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Response:** + +```json +{ + "invites": [ + { + "id": "uuid", + "access": "write", + "maxUses": 10, + "uses": 3, + "expiresAt": "2026-06-01T00:00:00Z", + "revoked": false, + "createdBy": "did:plc:abc123", + "createdAt": "2026-05-09T12:00:00Z" + } + ] +} +``` + +The token itself is never returned in list responses — only the invite metadata. diff --git a/packages/docs/docs/experimental/spaces/managing-spaces.md b/packages/docs/docs/experimental/spaces/managing-spaces.md new file mode 100644 index 0000000..caff754 --- /dev/null +++ b/packages/docs/docs/experimental/spaces/managing-spaces.md @@ -0,0 +1,135 @@ +# Managing Spaces + +:::caution Experimental +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. +::: + +## Creating a space + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "type": "com.example.forum", + "skey": "main", + "displayName": "My Forum", + "description": "A place for discussion", + "accessMode": "default_allow" + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ---------------- | ------------- | -------- | ------------------------------------------------- | +| `type` | string (NSID) | Yes | The space type; describes what this space is for | +| `skey` | string | Yes | Space key; differentiates spaces of the same type | +| `displayName` | string | No | Human-readable name | +| `description` | string | No | Description of the space | +| `accessMode` | string | No | `default_allow` (default) or `default_deny` | +| `managingAppDid` | string | No | DID of the application that manages this space | +| `config` | object | No | Space configuration (see below) | + +**Response (201):** + +```json +{ + "uri": "ats://did:plc:abc123/com.example.forum/main" +} +``` + +The creator is automatically added as a write member. Use [`dev.happyview.space.getSpace`](#getting-a-space) to retrieve the full space object. + +### Space configuration + +The `config` object supports: + +| Field | Type | Default | Description | +| ------------------ | ------- | ------- | --------------------------------------------------------- | +| `membershipPublic` | boolean | `false` | Whether the member list is visible without authentication | +| `recordsPublic` | boolean | `false` | Whether records are readable without membership | + +Additional fields are preserved as-is. + +## Getting a space + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +If `membershipPublic` is `false`, the caller must be authenticated and be a member (or the owner) to see the space. Non-members receive a `404 Not Found`. + +## Listing spaces + +Returns spaces where the authenticated user is a member. + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Parameters:** + +| Field | Type | Required | Default | Description | +| -------- | ------- | -------- | ------- | ---------------------------- | +| `limit` | integer | No | 50 | Max spaces to return (1-100) | +| `cursor` | string | No | | Pagination cursor | + +**Response:** + +```json +{ + "spaces": [ + { + "uri": "ats://did:plc:abc123/com.example.forum/main", + "isOwner": true + } + ], + "cursor": "2026-05-09T12:00:00Z" +} +``` + +## Updating a space + +Only the space owner or a HappView super admin can update a space. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.updateSpace' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "displayName": "Updated Forum Name", + "accessMode": "default_deny", + "appAllowlist": ["did:web:myapp.example.com"] + }' +``` + +All fields except `space` are optional. Only provided fields are updated. To clear an optional field, pass `null`. + +## Deleting a space + +Only the space owner or a HappyView super admin can delete a space. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{"space": "ats://did:plc:abc123/com.example.forum/main"}' +``` + +:::warning +Deleting a space does not currently cascade to records, members, or credentials. This behavior may change. +::: diff --git a/packages/docs/docs/experimental/spaces/members.md b/packages/docs/docs/experimental/spaces/members.md new file mode 100644 index 0000000..24299a8 --- /dev/null +++ b/packages/docs/docs/experimental/spaces/members.md @@ -0,0 +1,125 @@ +# Members + +:::caution Experimental +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. +::: + +Membership determines who can read and write within a space. Members have either `read` or `write` access — write implies read. + +## Adding a member + +Only the space owner or a super admin can add members. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "did:plc:newmember", + "access": "write", + "isDelegation": false + }' +``` + +**Input:** + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `space` | string | Yes | | The space to add the member to | +| `did` | string | Yes | | DID of the member (or space for delegation) | +| `access` | string | No | `read` | `read` or `write` | +| `isDelegation` | boolean | No | `false` | Whether this member is a delegated space | + +**Response (201):** + +```json +{ + "member": { + "id": "uuid", + "spaceId": "space-uuid", + "did": "did:plc:newmember", + "access": "write", + "isDelegation": false, + "grantedBy": "did:plc:abc123", + "createdAt": "2026-05-09T12:00:00Z" + } +} +``` + +## Removing a member + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.removeMember' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "did:plc:newmember" + }' +``` + +## Listing members + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +If the space's `membershipPublic` config is `true`, this endpoint is accessible without authentication. Otherwise, the caller must be authenticated and be a member. + +The response returns the **resolved** member list — delegation chains are traversed and flattened: + +```json +{ + "members": [ + { "did": "did:plc:abc123", "access": "write" }, + { "did": "did:plc:newmember", "access": "write" }, + { "did": "did:plc:delegated-user", "access": "read" } + ] +} +``` + +## Delegation + +A space can be added as a member of another space by setting `isDelegation: true`. This transitively grants access to all members of the delegated space. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "ats://did:plc:org/com.example.team/engineering", + "access": "read", + "isDelegation": true + }' +``` + +Delegation chains are resolved up to 10 levels deep. When a user appears in multiple chains, the highest access level wins (`write` > `read`). + +### Example: nested teams + +```mermaid +graph TD + A[Forum Space] -->|delegation, write| B[Engineering Team Space] + A -->|delegation, read| C[Design Team Space] + B -->|member, write| D[Alice] + B -->|member, write| E[Bob] + C -->|member, read| F[Carol] + C -->|member, read| D +``` + +In this example: +- Alice has `write` access (via Engineering) +- Bob has `write` access (via Engineering) +- Carol has `read` access (via Design) +- Alice also appears in Design, but `write` wins over `read` diff --git a/packages/docs/docs/experimental/spaces/records.md b/packages/docs/docs/experimental/spaces/records.md new file mode 100644 index 0000000..b58dfa9 --- /dev/null +++ b/packages/docs/docs/experimental/spaces/records.md @@ -0,0 +1,292 @@ +# Records + +:::caution Experimental +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. +::: + +Space records are stored separately from public AT Protocol records. They follow the same URI pattern but use the `ats://` scheme and include the space identity: + +``` +ats:// did:plc:abcdefghijklmnop1234567890 / com.example.forum / main / did:plc:author / com.example.forum.post / abcdefghijklmnop1234567890 + └── space DID ───────────────────┘ └── space type ─┘ └── skey ─┘ └── author ──┘ └── collection ──────┘ └── rkey ────────────────┘ +``` + +## Creating a record + +Requires `write` membership in the space. The rkey is auto-generated using a TID. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createRecord' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "collection": "com.example.forum.post", + "record": { + "$type": "com.example.forum.post", + "text": "Hello from the forum!", + "createdAt": "2026-05-09T12:00:00Z" + } + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------------- | -------- | ----------------------- | +| `space` | string | Yes | The space to write into | +| `collection` | string (NSID) | Yes | The record collection | +| `record` | object | Yes | The record data | + +**Response (201):** + +```json +{ + "uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3l2tkbx7225co", + "cid": "bafyrei..." +} +``` + +`createRecord` always inserts a new record. If a record with the generated URI already exists, it returns `409 Conflict`. + +## Writing a record (put) + +Requires `write` membership in the space. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.putRecord' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "record": { + "$type": "com.example.forum.post", + "text": "Hello from the forum!", + "createdAt": "2026-05-09T12:00:00Z" + } + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------------- | -------- | ---------------------------------------------------------------- | +| `space` | string | Yes | The space to write into | +| `collection` | string (NSID) | Yes | The record collection | +| `rkey` | string | Yes | The record key | +| `record` | object | Yes | The record data | +| `swapRecord` | string | No | Expected CID of the existing record (for optimistic concurrency) | + +**Response (201):** + +```json +{ + "uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3k2abc", + "cid": "bafyrei..." +} +``` + +The author DID is taken from the authenticated user. You can only write records as yourself, so the URI's author component will always be your DID. + +`putRecord` performs an upsert: if a record with the same collection + rkey already exists for this author in this space, it's overwritten. Use `swapRecord` to prevent unintended overwrites (see [Optimistic concurrency](#optimistic-concurrency) below). + +## Getting a record + +Requires `read` membership (or a valid [space credential](credentials.md)). + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Response:** + +```json +{ + "uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3k2abc", + "cid": "bafyrei...", + "value": { + "$type": "com.example.forum.post", + "text": "Hello from the forum!", + "createdAt": "2026-05-09T12:00:00Z" + } +} +``` + +## Listing records + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Parameters:** + +| Field | Type | Required | Default | Description | +| ------------ | ------- | -------- | ------- | --------------------------------- | +| `space` | string | Yes | | The space to list from | +| `repo` | string | No | | Filter by author DID | +| `collection` | string | No | | Filter by collection NSID | +| `limit` | integer | No | 50 | Max records to return (1-100) | +| `cursor` | string | No | | Pagination cursor | +| `reverse` | boolean | No | `false` | Reverse sort order (oldest first) | + +**Response:** + +```json +{ + "records": [ + { + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "cid": "bafyrei..." + } + ], + "cursor": "2026-05-09T12:00:00Z" +} +``` + +## Deleting a record + +You can only delete your own records. Requires `write` membership. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "collection": "com.example.forum.post", + "rkey": "3k2abc" + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------------- | -------- | ---------------------------------------------------------------- | +| `space` | string | Yes | The space containing the record | +| `collection` | string (NSID) | Yes | The record collection | +| `rkey` | string | Yes | The record key | +| `swapRecord` | string | No | Expected CID of the existing record (for optimistic concurrency) | + +Attempting to delete another user's record returns `403 Forbidden`. + +## Batch writes (applyWrites) + +`applyWrites` performs multiple create, update, and delete operations in a single request. Requires `write` membership. + +```sh +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.applyWrites' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "writes": [ + { + "action": "create", + "collection": "com.example.forum.post", + "value": { "$type": "com.example.forum.post", "text": "First post" } + }, + { + "action": "update", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "value": { "$type": "com.example.forum.post", "text": "Edited post" }, + "swapRecord": "bafyrei..." + }, + { + "action": "delete", + "collection": "com.example.forum.post", + "rkey": "old-post" + } + ] + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ---------------------------------------------------- | +| `space` | string | Yes | The space to write into | +| `swapCommit` | string | No | Expected space revision (for optimistic concurrency) | +| `writes` | array | Yes | List of write operations | + +Each write operation has an `action` field: + +| Action | Fields | Description | +| -------- | -------------------------------------------- | ---------------------------------------------------- | +| `create` | `collection`, `value`, `rkey?` | Insert a new record. Auto-generates rkey if omitted. | +| `update` | `collection`, `rkey`, `value`, `swapRecord?` | Upsert a record. | +| `delete` | `collection`, `rkey`, `swapRecord?` | Delete a record. | + +**Response:** + +```json +{ + "results": [ + { "uri": "ats://...", "cid": "bafyrei..." }, + { "uri": "ats://...", "cid": "bafyrei..." }, + {} + ] +} +``` + +Each entry in `results` corresponds to the write at the same index. Create and update operations return `uri` and `cid`; delete operations return an empty object. + +## Optimistic concurrency + +`swapRecord` and `swapCommit` provide optimistic concurrency control to prevent lost updates when multiple clients write to the same space. + +### swapRecord + +Pass the `swapRecord` field on `putRecord`, `deleteRecord`, or individual operations within `applyWrites`. The value is the CID of the record you expect to be replacing. If the record's current CID doesn't match, the operation fails with `409 Conflict`. + +```json +{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "record": { "text": "updated safely" }, + "swapRecord": "bafyrei_old_cid" +} +``` + +### swapCommit + +Pass the `swapCommit` field on `applyWrites` to assert the space's current revision. If another client has written to the space since you last read its state, the operation fails with `409 Conflict` before any writes are applied. + +The space's current revision is available as `revision` in the space object returned by `dev.happyview.space.getSpace`. + +```json +{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "swapCommit": "3l2tkbx7225co", + "writes": [...] +} +``` + +## Cross-service access + +Records can also be read using a [space credential](credentials.md) instead of direct membership. Pass the credential as a Bearer token: + +```sh +curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?...' \ + -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' +``` + +A feed generator or other service that isn't a direct member can use a credential issued by the space owner to read data without joining the space. No DPoP auth is needed — the credential itself authenticates the request. diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index 2fecf3e..553e6bc 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -298,6 +298,53 @@ const sidebars: SidebarsConfig = { }, ], }, + { + type: "category", + label: "Experimental", + items: [ + { + type: "category", + label: "Permissioned Spaces", + items: [ + { + type: "doc", + id: "experimental/spaces", + label: "Overview", + }, + { + type: "doc", + id: "experimental/spaces/managing-spaces", + label: "Managing Spaces", + }, + { + type: "doc", + id: "experimental/spaces/members", + label: "Members", + }, + { + type: "doc", + id: "experimental/spaces/records", + label: "Records", + }, + { + type: "doc", + id: "experimental/spaces/credentials", + label: "Credentials", + }, + { + type: "doc", + id: "experimental/spaces/invites", + label: "Invites", + }, + { + type: "doc", + id: "experimental/spaces/changelog", + label: "Changelog", + }, + ], + }, + ], + }, { type: "category", label: "Reference",