From 01541c0c5b05e9efca4ddfcbdfdd909271cc0401 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 26 Jun 2026 20:19:56 -0500 Subject: [PATCH] docs: update docs for permissioned spaces updates Signed-off-by: Trezy --- .../docs/api-reference/admin/admin-api.md | 3 +- .../docs/api-reference/admin/settings.md | 1 + .../content/docs/api-reference/xrpc-api.md | 16 +- .../docs/experimental/spaces/changelog.md | 59 +++++- .../docs/experimental/spaces/credentials.md | 126 ++++++------- .../content/docs/experimental/spaces/index.md | 124 ++++++++----- .../docs/experimental/spaces/invites.md | 22 +-- .../experimental/spaces/managing-spaces.md | 95 +++++----- .../docs/experimental/spaces/members.md | 46 ++--- .../docs/experimental/spaces/records.md | 74 ++++---- .../docs/getting-started/authentication.md | 10 +- .../content/docs/getting-started/dashboard.md | 12 ++ .../docs/content/docs/guides/lua-scripting.md | 22 +++ .../docs/content/docs/guides/permissions.md | 139 ++++++++++---- packages/docs/content/docs/index.md | 3 + .../content/docs/reference/architecture.md | 175 +++++++++++++++++- .../docs/content/docs/reference/glossary.md | 20 +- 17 files changed, 669 insertions(+), 278 deletions(-) diff --git a/packages/docs/content/docs/api-reference/admin/admin-api.md b/packages/docs/content/docs/api-reference/admin/admin-api.md index f389005..85c5aa0 100644 --- a/packages/docs/content/docs/api-reference/admin/admin-api.md +++ b/packages/docs/content/docs/api-reference/admin/admin-api.md @@ -6,10 +6,11 @@ The admin API lets you manage lexicons, monitor records, run backfill jobs, and ## Auth -The admin API supports two authentication methods: +The admin API supports three authentication methods: 1. **API keys** — read/write tokens starting with `hv_`, passed as `Authorization: Bearer hv_...`. See the [API Keys guide](../../guides/api-keys.md) for details. 2. **Service auth JWT** — atproto inter-service authentication via signed JWTs. +3. **Cookie-based session auth** — signed session cookies set during the dashboard OAuth login flow. The [web dashboard](../../getting-started/dashboard.md) uses this method. In all cases the resolved DID is checked against the `users` table, and the user's permissions are loaded to authorize the request. diff --git a/packages/docs/content/docs/api-reference/admin/settings.md b/packages/docs/content/docs/api-reference/admin/settings.md index d4ecb49..791ddfe 100644 --- a/packages/docs/content/docs/api-reference/admin/settings.md +++ b/packages/docs/content/docs/api-reference/admin/settings.md @@ -74,6 +74,7 @@ Returns all key/value pairs stored in the `instance_settings` table, plus any en | `backfill_concurrent_resolution` | `BACKFILL_CONCURRENT_RESOLUTION` | `100` | How many DID document lookups to run in parallel during PDS resolution | | `backfill_retention_days` | `BACKFILL_RETENTION_DAYS` | `28` | Days to keep per-repo detail data from completed backfill jobs. `0` = keep indefinitely | | `verbose_event_logging` | `VERBOSE_EVENT_LOGGING` | `false` | Log every record index, hook execution, and hook skip to the event log. High write volume — recommended only for debugging | +| `feature.spaces_enabled` | `FEATURE_SPACES_ENABLED` | --- | Enables the experimental Permissioned Spaces API. When `"true"`, space endpoints are available. When absent or any other value, space endpoints return `404 FeatureDisabled` | ## Upsert a setting diff --git a/packages/docs/content/docs/api-reference/xrpc-api.md b/packages/docs/content/docs/api-reference/xrpc-api.md index 3db76a5..a9452eb 100644 --- a/packages/docs/content/docs/api-reference/xrpc-api.md +++ b/packages/docs/content/docs/api-reference/xrpc-api.md @@ -8,8 +8,20 @@ If a query or procedure lexicon has a [Lua script](../guides/lua-scripting.md) a ## Auth -- **Queries** (`GET /xrpc/{method}`): unauthenticated -- **Procedures** (`POST /xrpc/{method}`): require DPoP authentication (`Authorization: DPoP` + `DPoP` proof header + `X-Client-Key`) +XRPC routes accept several authentication methods: + +- **DPoP auth** — `Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key` +- **Space credentials** — `Authorization: Bearer ` (space-scoped routes only) +- **Service auth JWTs** — `Authorization: Bearer ` (inter-service calls) +- **Cookie-based session auth** — signed session cookies (used by the dashboard, falls back when no `Authorization` header is present) +- **Anonymous** — no auth headers (identity is `nil` in Lua scripts) + +Bearer API keys (`hv_*`) are rejected on XRPC routes — they are only accepted on the [admin API](admin/admin-api.md). + +Default auth behavior: + +- **Queries** (`GET /xrpc/{method}`): unauthenticated by default (identity available if provided) +- **Procedures** (`POST /xrpc/{method}`): require authentication (DPoP, session cookie, or service auth) - **getProfile**: requires auth - **uploadBlob**: requires auth diff --git a/packages/docs/content/docs/experimental/spaces/changelog.md b/packages/docs/content/docs/experimental/spaces/changelog.md index fda0ec3..2b0aae6 100644 --- a/packages/docs/content/docs/experimental/spaces/changelog.md +++ b/packages/docs/content/docs/experimental/spaces/changelog.md @@ -2,7 +2,64 @@ title: "Changelog" --- -## Latest +## Latest — Proposal 0016 Alignment + +Major restructuring to align with [AT Protocol Proposal 0016](https://github.com/bluesky-social/proposals) (Permissioned Data). + +### Namespace split + +- **Protocol routes** now live under `com.atproto.space.*` (queries, data, credentials) +- **Management routes** now live under `com.atproto.simplespace.*` (create/update/delete spaces, membership, config) +- **`dev.happyview.space.*`** endpoints remain as backward-compatible aliases until v3 +- Invite endpoints remain under `dev.happyview.space.*` as HappyView extensions + +### New terminology + +- **`owner_did` → `authority_did`** — the DID that controls the space. A separate `creator_did` tracks who originally created it. +- **`accessMode` → `mintPolicy`** — controls who can create permissioned repos: `member-list` (default), `public`, or `managing-app` +- **`appAllowlist`/`appDenylist` → `appAccess`** — controls third-party app access: `open` (default) or `allowList` +- **`getMemberGrant` → `getDelegationToken`** — renamed and changed from POST to GET. Returns a delegation token (JWT with `typ: atproto-space-delegation+jwt`, ES256K, 60-second TTL) +- **`redeemInvite` → `acceptInvite`** — renamed for clarity +- **Space credential `typ`** — changed from `space_credential` to `atproto-space-credential+jwt` +- **Space credential TTL** — reduced from 4 hours to 2 hours + +### New access level + +- **`read_self`** — a new membership access level that restricts reads to only the member's own records within the space + +### New endpoints + +- **`com.atproto.space.getRepoState`** (GET) — returns per-user repo state including LtHash state and signed commit +- **`com.atproto.space.listRepoOps`** (GET) — returns the record operation log for sync +- **`com.atproto.space.listRepos`** (GET) — lists repos (authors) in a space +- **`com.atproto.space.getBlob`** (GET) — retrieves a blob from a space +- **`com.atproto.space.registerNotify`** (POST) — registers for write notifications +- **`com.atproto.space.notifyWrite`** (POST) — pushes a write notification +- **`com.atproto.space.notifySpaceDeleted`** (POST) — pushes a space-deleted notification +- **`com.atproto.simplespace.getConfig`** (GET) — gets space configuration (mint policy, app access, managing app) +- **`com.atproto.simplespace.updateConfig`** (POST) — updates space configuration + +### Cryptographic primitives + +- **LtHash** — homomorphic set-hash for per-user repo state. 2048-byte state with 1024 little-endian uint16 lanes using BLAKE3 XOF. Supports insert/remove operations for incremental record tracking. +- **Deniable commit signatures** — users sign context (space DID + rev + random IKM) rather than content hash, producing a MAC that proves authorship without binding the user to specific content. + +### Data model changes + +- New `happyview_space_repo_state` table — per-user LtHash state + signed commit per space +- New `happyview_space_record_oplog` table — ordered record operation log per space +- New `happyview_space_notify_registrations` table — write notification registrations +- Spaces now use `authority_did` and `creator_did` instead of `owner_did` +- `mint_policy` and `app_access` columns replace `access_mode`, `app_allowlist`, `app_denylist` + +### Breaking changes + +- Feature flag disabled response changed from `501 Not Implemented` to `404` with `FeatureDisabled` error code +- Deleting a space now cascades to all associated data (records, members, repo state, oplog, notifications, credentials) + +--- + +## v2.6.0 ### New endpoints diff --git a/packages/docs/content/docs/experimental/spaces/credentials.md b/packages/docs/content/docs/experimental/spaces/credentials.md index a20bdcc..26bd1fe 100644 --- a/packages/docs/content/docs/experimental/spaces/credentials.md +++ b/packages/docs/content/docs/experimental/spaces/credentials.md @@ -6,11 +6,11 @@ title: "Credentials" 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. +Space credentials are short-lived JWTs for cross-service access to space data. A member requests a delegation token to prove their membership, exchanges the token 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: +Credential issuance is a two-step process. The delegation token is a short-lived proof of membership (60-second TTL), and the credential is the bearer token used for cross-service access (2-hour TTL). ```mermaid sequenceDiagram @@ -18,12 +18,12 @@ sequenceDiagram participant HV as HappyView participant Svc as External Service - App->>HV: POST dev.happyview.space.getMemberGrant
(DPoP auth, must be a member) + App->>HV: GET com.atproto.space.getDelegationToken
(DPoP auth, must be a member) HV->>HV: Verify membership - HV-->>App: grant token + expiresAt + HV-->>App: delegation 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 + App->>HV: POST com.atproto.space.getSpaceCredential
(DPoP auth, delegation token) + HV->>HV: Verify delegation token
Check app access
Sign credential with space keypair HV-->>App: credential JWT + expiresAt App->>Svc: Request with Authorization: Bearer credential @@ -34,93 +34,84 @@ sequenceDiagram 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 +## Step 1: Get a delegation token -The caller must be an authenticated member of the space. The grant is a short-lived token (5 minutes) that proves membership. +The caller must be an authenticated member of the space. The delegation token is a short-lived proof of membership (60-second TTL). + +Note: this endpoint is a GET request (not POST). The previous `getMemberGrant` endpoint (POST) is available as a legacy alias via `dev.happyview.space.getMemberGrant`. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant", { - method: "POST", +const params = new URLSearchParams({ + space: "ats://did:plc:abc123/com.example.forum/main", +}); +const response = await fetch(`https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, - "Content-Type": "application/json", }, - body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", - }), }); -interface GrantResponse { - grant: string; +interface DelegationTokenResponse { + delegationToken: string; expiresAt: string; } -const data: GrantResponse = await response.json(); +const data: DelegationTokenResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant", { - method: "POST", +const params = new URLSearchParams({ + space: "ats://did:plc:abc123/com.example.forum/main", +}); +const response = await fetch(`https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, - "Content-Type": "application/json", }, - body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", - }), }); const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant") + .get("https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken") + .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) - .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main" - })) .send() .await?; let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" -body := bytes.NewBufferString(`{"space": "ats://did:plc:abc123/com.example.forum/main"}`) -req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant", body) +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?space=ats%3A%2F%2Fdid%3Aplc%3Aabc123%2Fcom.example.forum%2Fmain", + nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) -req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?space=ats%3A%2F%2Fdid%3Aplc%3Aabc123%2Fcom.example.forum%2Fmain' \ -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" - }' + -H 'DPoP: ' ``` **Response:** ```json { - "grant": "eyJhbGciOiJIUzI1NiJ9...", - "expiresAt": "2026-05-09T12:05:00Z" + "delegationToken": "eyJhbGciOiJFUzI1NktFWSJ9...", + "expiresAt": "2026-05-09T12:01: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. +Exchange the delegation token for a space credential JWT. The credential is signed by the space's keypair and has a 2-hour TTL. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -129,7 +120,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s "Content-Type": "application/json", }, body: JSON.stringify({ - grant: "eyJhbGciOiJIUzI1NiJ9...", + grant: "eyJhbGciOiJFUzI1NktFWSJ9...", }), }); interface CredentialResponse { @@ -139,7 +130,7 @@ interface CredentialResponse { const data: CredentialResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -148,28 +139,28 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s "Content-Type": "application/json", }, body: JSON.stringify({ - grant: "eyJhbGciOiJIUzI1NiJ9...", + grant: "eyJhbGciOiJFUzI1NktFWSJ9...", }), }); const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential") + .post("https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "grant": "eyJhbGciOiJIUzI1NiJ9..." + "grant": "eyJhbGciOiJFUzI1NktFWSJ9..." })) .send() .await?; let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" -body := bytes.NewBufferString(`{"grant": "eyJhbGciOiJIUzI1NiJ9..."}`) +body := bytes.NewBufferString(`{"grant": "eyJhbGciOiJFUzI1NktFWSJ9..."}`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential", body) + "https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -177,13 +168,13 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "grant": "eyJhbGciOiJIUzI1NiJ9..." + "grant": "eyJhbGciOiJFUzI1NktFWSJ9..." }' ``` @@ -192,7 +183,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCre ```json { "credential": "eyJhbGciOiJFUzI1NiJ9...", - "expiresAt": "2026-05-09T16:00:00Z" + "expiresAt": "2026-05-09T14:00:00Z" } ``` @@ -202,20 +193,19 @@ 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`) | +| `iss` | The space authority's DID (who signed it) | +| `sub` | The full `ats://` space URI | | `iat` | Issued at (Unix timestamp) | | `exp` | Expiry (Unix timestamp) | +| `jti` | Random nonce for replay protection | ## 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`). +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 (`atproto-space-credential+jwt`). ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -226,7 +216,7 @@ const data = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -237,7 +227,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getRecord") + .get("https://happyview.example.com/xrpc/com.atproto.space.getRecord") .query(&[("space", "..."), ("collection", "..."), ("rkey", "...")]) .header("Authorization", format!("Bearer {}", space_credential)) .send() @@ -246,28 +236,28 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", nil) req.Header.Set("Authorization", "Bearer "+spaceCredential) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...' \ +curl 'https://happyview.example.com/xrpc/com.atproto.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. +No DPoP auth or client key is needed when authenticating via space credential — the credential itself is sufficient. The `sub` claim identifies the space being accessed. -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. +HappyView verifies the credential by resolving the issuer's DID document, extracting the `#atproto_space` signing key, and validating the JWT signature and expiry. If valid, the request is granted read access to the space identified by `sub`. ## 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 +- **`open` (default)**: any app can get credentials +- **`allowList`**: only apps whose client metadata URL appears in the `allowed` array can get credentials -If no client key is present in the DPoP claims, the check is skipped (direct user access without an app intermediary). +For `open` spaces, requests without a client key are allowed. For `allowList` spaces, a client key is required — requests without one are rejected. ## External credential verification @@ -275,8 +265,8 @@ HappyView can also verify credentials issued by *other* HappyView instances or s 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 +3. Extracts the `#atproto_space` signing key from the DID doc 4. Verifies the JWT signature and expiry -5. Checks that the `space` claim matches the requested space +5. Checks that the `sub` 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/content/docs/experimental/spaces/index.md b/packages/docs/content/docs/experimental/spaces/index.md index 803c99b..8fed136 100644 --- a/packages/docs/content/docs/experimental/spaces/index.md +++ b/packages/docs/content/docs/experimental/spaces/index.md @@ -3,7 +3,7 @@ title: "Overview" --- -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. +Permissioned Spaces are experimental and the API will change. This implementation follows [AT Protocol Proposal 0016](https://github.com/bluesky-social/proposals) (Permissioned Data). HappyView uses the `com.atproto.space.*` and `com.atproto.simplespace.*` namespaces. The previous `dev.happyview.space.*` endpoints remain available as backward-compatible aliases until v3. 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. @@ -71,65 +71,100 @@ curl -X PUT http://127.0.0.1:3000/admin/settings/feature.spaces_enabled \ -d '{"value": "true"}' ``` -When disabled, all `/xrpc/dev.happyview.space.*` endpoints return `501 Not Implemented`. +When disabled, all space endpoints return a `404` error with `FeatureDisabled` as the error code. ## 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) | +Space endpoints are split across two namespaces: + +- **`com.atproto.space.*`** — protocol-level routes (queries, data, credentials) +- **`com.atproto.simplespace.*`** — management routes (create/update/delete spaces, membership) + +The previous `dev.happyview.space.*` endpoints remain as backward-compatible aliases until v3. All endpoints require [DPoP authentication](../../getting-started/authentication.md) or cookie-based session auth. + +| Endpoint | Method | Description | +| --------------------------------------------- | ------ | ----------------------------------------------- | +| `com.atproto.simplespace.createSpace` | POST | Create a space | +| `com.atproto.space.getSpace` | GET | Get a space by URI | +| `com.atproto.space.listSpaces` | GET | List spaces by membership | +| `com.atproto.simplespace.updateSpace` | POST | Update space metadata | +| `com.atproto.simplespace.deleteSpace` | POST | Delete a space | +| `com.atproto.simplespace.getConfig` | GET | Get space configuration | +| `com.atproto.simplespace.updateConfig` | POST | Update space configuration | +| `com.atproto.space.createRecord` | POST | Create a record (auto-generated rkey) | +| `com.atproto.space.putRecord` | POST | Write a record | +| `com.atproto.space.getRecord` | GET | Get a record | +| `com.atproto.space.listRecords` | GET | List records | +| `com.atproto.space.deleteRecord` | POST | Delete a record | +| `com.atproto.space.applyWrites` | POST | Batch write operations | +| `com.atproto.simplespace.addMember` | POST | Add a member | +| `com.atproto.simplespace.removeMember` | POST | Remove a member | +| `com.atproto.simplespace.listMembers` | GET | List resolved members | +| `com.atproto.space.getRepoState` | GET | Get per-user repo state (LtHash + commit) | +| `com.atproto.space.listRepoOps` | GET | List record operation log entries | +| `com.atproto.space.listRepos` | GET | List repos (authors) in a space | +| `com.atproto.space.getDelegationToken` | GET | Get a delegation token (step 1 of credentials) | +| `com.atproto.space.getSpaceCredential` | POST | Get a space credential (step 2) | +| `com.atproto.space.getBlob` | GET | Get a blob from a space | +| `com.atproto.space.registerNotify` | POST | Register for write notifications | +| `com.atproto.space.notifyWrite` | POST | Push a write notification | +| `com.atproto.space.notifySpaceDeleted` | POST | Push a space-deleted notification | +| `dev.happyview.space.createInvite` | POST | Create an invite (HappyView extension) | +| `dev.happyview.space.acceptInvite` | POST | Accept an invite (HappyView extension) | +| `dev.happyview.space.revokeInvite` | POST | Revoke an invite (HappyView extension) | +| `dev.happyview.space.listInvites` | GET | List invites (HappyView extension) | ## Access model -Spaces have an **access mode** that controls third-party app access: +Spaces use two independent controls for access: -- **`default_allow`** — any app can access (with optional denylist) -- **`default_deny`** — only explicitly allowed apps can access +**Mint policy** controls who can create permissioned repos in the space: -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. +- **`member-list`** (default) — only members can create repos +- **`public`** — anyone can create repos +- **`managing-app`** — only the managing app can create repos + +**App access** controls which third-party apps can interact with the space: + +- **`open`** (default) — any app can access +- **`allowList`** — only explicitly listed apps can access + +Individual users access spaces through **membership**. Members have one of three access levels: + +- **`write`** — can read and write data +- **`read`** — can read all data in the space +- **`read_self`** — can only read their own data within the space + +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 +## Alignment with Proposal 0016 -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 implements [AT Protocol Proposal 0016](https://github.com/bluesky-social/proposals) (Permissioned Data) with some HappyView-specific extensions. -### HappyView extensions (not in the reference branch) +### Protocol features implemented -- **`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 +- **Namespace split** — `com.atproto.space.*` for protocol routes, `com.atproto.simplespace.*` for management +- **Mint policy** — `member-list`, `public`, `managing-app` (replaces `accessMode`) +- **App access** — `open`, `allowList` (replaces `appAllowlist`/`appDenylist`) +- **Delegation tokens** — `getDelegationToken` (GET, 60-second TTL) replaces `getMemberGrant` +- **Space credentials** — `atproto-space-credential+jwt` typ, ES256, 2-hour TTL +- **Deniable commit signatures** — user signs context (space + rev + random IKM), not content hash +- **LtHash** — homomorphic set-hash (2048-byte state, 1024 uint16 lanes, BLAKE3 XOF) +- **Record operation log** — `listRepoOps` returns the oplog for sync +- **Repo state** — `getRepoState` returns LtHash state + signed commit +- **Write notifications** — `registerNotify`, `notifyWrite`, `notifySpaceDeleted` +- **Space-scoped blobs** — `getBlob` +- **Authority DID** — spaces use `authority_did` (not `owner_did`) with a separate `creator_did` -### Reference features not yet implemented +### HappyView extensions (not in the protocol spec) -- **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 +- **Invite system** — `createInvite`, `acceptInvite`, `revokeInvite`, `listInvites` (under `dev.happyview.space.*`) +- **`isDelegation` on members** — allows spaces to be members of other spaces +- **`displayName`, `description` on spaces** — human-readable metadata +- **`config` object** — `membershipPublic`, `recordsPublic`, plus arbitrary extra fields +- **`read_self` access level** — restricts reads to the member's own data ## Next steps @@ -138,3 +173,4 @@ HappyView mostly mirrors [Daniel Holmgren's `permissioned-data` branch](https:// - [Records](./records.md) — read and write permissioned data - [Credentials](./credentials.md) — cross-service authentication for spaces - [Invites](./invites.md) — invite-based membership +- [Changelog](./changelog.md) — version history diff --git a/packages/docs/content/docs/experimental/spaces/invites.md b/packages/docs/content/docs/experimental/spaces/invites.md index 6c48bbe..981a8f5 100644 --- a/packages/docs/content/docs/experimental/spaces/invites.md +++ b/packages/docs/content/docs/experimental/spaces/invites.md @@ -109,7 +109,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createInvit | 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`) | +| `access` | string | No | `read` | Access level granted on acceptance (`read`, `read_self`, or `write`) | | `maxUses` | integer | No | unlimited | Maximum number of times the invite can be redeemed | | `expiresAt` | string (datetime) | No | never | When the invite expires | @@ -129,12 +129,12 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createInvit The `token` is only returned once. It is stored as a SHA-256 hash — HappyView cannot recover the plaintext. -## Redeeming an invite +## Accepting an invite -Any authenticated user can redeem an invite token to join the space. +Any authenticated user can accept an invite token to join the space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite", { +const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -146,14 +146,14 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s token: "a1b2c3d4e5f6...", }), }); -interface RedeemInviteResponse { +interface AcceptInviteResponse { uri: string; access: string; } -const data: RedeemInviteResponse = await response.json(); +const data: AcceptInviteResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite", { +const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -169,7 +169,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite") + .post("https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -183,7 +183,7 @@ let data: serde_json::Value = response.json().await?; ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{"token": "a1b2c3d4e5f6..."}`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite", body) + "https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -191,7 +191,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite' \ +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -210,7 +210,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.redeemInvit } ``` -Redemption fails if: +Acceptance fails if: - The token is invalid (no matching hash found) - The invite has been revoked diff --git a/packages/docs/content/docs/experimental/spaces/managing-spaces.md b/packages/docs/content/docs/experimental/spaces/managing-spaces.md index 3287f3f..3601956 100644 --- a/packages/docs/content/docs/experimental/spaces/managing-spaces.md +++ b/packages/docs/content/docs/experimental/spaces/managing-spaces.md @@ -9,7 +9,7 @@ This API is experimental and will change. See the [Permissioned Spaces overview] ## Creating a space ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -22,7 +22,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s skey: "main", displayName: "My Forum", description: "A place for discussion", - accessMode: "default_allow", + mintPolicy: "member-list", }), }); interface CreateSpaceResponse { @@ -31,7 +31,7 @@ interface CreateSpaceResponse { const data: CreateSpaceResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -44,14 +44,14 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s skey: "main", displayName: "My Forum", description: "A place for discussion", - accessMode: "default_allow", + mintPolicy: "member-list", }), }); const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.createSpace") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -60,7 +60,7 @@ let response = client "skey": "main", "displayName": "My Forum", "description": "A place for discussion", - "accessMode": "default_allow" + "mintPolicy": "member-list" })) .send() .await?; @@ -72,10 +72,10 @@ body := bytes.NewBufferString(`{ "skey": "main", "displayName": "My Forum", "description": "A place for discussion", - "accessMode": "default_allow" + "mintPolicy": "member-list" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.createSpace", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -83,7 +83,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -93,7 +93,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace "skey": "main", "displayName": "My Forum", "description": "A place for discussion", - "accessMode": "default_allow" + "mintPolicy": "member-list" }' ``` @@ -105,7 +105,8 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace | `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` | +| `mintPolicy` | string | No | `member-list` (default), `public`, or `managing-app` | +| `appAccess` | object | No | `{"type": "open"}` (default) or `{"type": "allowList", "allowed": [...]}` | | `managingAppDid` | string | No | DID of the application that manages this space | | `config` | object | No | Space configuration (see below) | @@ -117,7 +118,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace } ``` -The creator is automatically added as a write member. Use [`dev.happyview.space.getSpace`](#getting-a-space) to retrieve the full space object. +The creator is automatically added as a write member. Use [`com.atproto.space.getSpace`](#getting-a-space) to retrieve the full space object. ### Space configuration @@ -134,7 +135,7 @@ Additional fields are preserved as-is. ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -151,7 +152,7 @@ const data: Space = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -164,7 +165,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getSpace") + .get("https://happyview.example.com/xrpc/com.atproto.space.getSpace") .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) @@ -175,7 +176,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -183,7 +184,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -197,7 +198,7 @@ Returns spaces where the authenticated user is a member. ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", { headers: { "X-Client-Key": CLIENT_KEY, @@ -218,7 +219,7 @@ const data: ListSpacesResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", { headers: { "X-Client-Key": CLIENT_KEY, @@ -231,7 +232,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.listSpaces") + .get("https://happyview.example.com/xrpc/com.atproto.space.listSpaces") .query(&[("limit", "20")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) @@ -242,7 +243,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -250,7 +251,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -258,10 +259,11 @@ curl 'https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20 **Parameters:** -| Field | Type | Required | Default | Description | -| -------- | ------- | -------- | ------- | ---------------------------- | -| `limit` | integer | No | 50 | Max spaces to return (1-100) | -| `cursor` | string | No | | Pagination cursor | +| Field | Type | Required | Default | Description | +| -------- | ------- | -------- | -------------- | ---------------------------- | +| `did` | string | No | authenticated user | Filter by DID | +| `limit` | integer | No | 50 | Max spaces to return (1-100) | +| `cursor` | string | No | | Pagination cursor | **Response:** @@ -279,10 +281,10 @@ curl 'https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20 ## Updating a space -Only the space owner or a HappView super admin can update a space. +Only the space authority or a HappyView super admin can update a space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.updateSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -293,13 +295,12 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s body: JSON.stringify({ space: "ats://did:plc:abc123/com.example.forum/main", displayName: "Updated Forum Name", - accessMode: "default_deny", - appAllowlist: ["did:web:myapp.example.com"], + mintPolicy: "public", }), }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.updateSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -310,22 +311,20 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s body: JSON.stringify({ space: "ats://did:plc:abc123/com.example.forum/main", displayName: "Updated Forum Name", - accessMode: "default_deny", - appAllowlist: ["did:web:myapp.example.com"], + mintPolicy: "public", }), }); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.updateSpace") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ "space": "ats://did:plc:abc123/com.example.forum/main", "displayName": "Updated Forum Name", - "accessMode": "default_deny", - "appAllowlist": ["did:web:myapp.example.com"] + "mintPolicy": "public" })) .send() .await?; @@ -334,11 +333,10 @@ let response = client body := bytes.NewBufferString(`{ "space": "ats://did:plc:abc123/com.example.forum/main", "displayName": "Updated Forum Name", - "accessMode": "default_deny", - "appAllowlist": ["did:web:myapp.example.com"] + "mintPolicy": "public" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.updateSpace", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -346,7 +344,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.updateSpace' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -354,8 +352,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.updateSpace -d '{ "space": "ats://did:plc:abc123/com.example.forum/main", "displayName": "Updated Forum Name", - "accessMode": "default_deny", - "appAllowlist": ["did:web:myapp.example.com"] + "mintPolicy": "public" }' ``` @@ -363,10 +360,10 @@ All fields except `space` are optional. Only provided fields are updated. To cle ## Deleting a space -Only the space owner or a HappyView super admin can delete a space. +Only the space authority or a HappyView super admin can delete a space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -380,7 +377,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -395,7 +392,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -408,7 +405,7 @@ let response = client ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{"space": "ats://did:plc:abc123/com.example.forum/main"}`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -416,7 +413,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -425,5 +422,5 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace ``` -Deleting a space does not currently cascade to records, members, or credentials. This behavior may change. +Deleting a space cascades to all associated records, members, repo state, oplog entries, notification registrations, and credentials. diff --git a/packages/docs/content/docs/experimental/spaces/members.md b/packages/docs/content/docs/experimental/spaces/members.md index 30bf6e6..b39ce4b 100644 --- a/packages/docs/content/docs/experimental/spaces/members.md +++ b/packages/docs/content/docs/experimental/spaces/members.md @@ -6,14 +6,14 @@ title: "Members" 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. +Membership determines who can read and write within a space. Members have one of three access levels — `write`, `read`, or `read_self`. Write implies read. `read_self` restricts the member to reading only their own records within the space. ## Adding a member -Only the space owner or a super admin can add members. +Only the space authority or a super admin can add members. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -40,7 +40,7 @@ interface Member { const data: { member: Member } = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -59,7 +59,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.addMember") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -81,7 +81,7 @@ body := bytes.NewBufferString(`{ "isDelegation": false }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.addMember", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -89,7 +89,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.addMember' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -108,7 +108,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' |---|---|---|---|---| | `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` | +| `access` | string | No | `read` | `read`, `read_self`, or `write` | | `isDelegation` | boolean | No | `false` | Whether this member is a delegated space | **Response (201):** @@ -130,7 +130,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' ## Removing a member ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.removeMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -145,7 +145,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.removeMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -161,7 +161,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.removeMember") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -178,7 +178,7 @@ body := bytes.NewBufferString(`{ "did": "did:plc:newmember" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.removeMember", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -186,7 +186,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.removeMember' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -201,7 +201,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.removeMembe ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -218,7 +218,7 @@ const data: { members: ResolvedMember[] } = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -231,7 +231,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.listMembers") + .get("https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers") .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) @@ -242,7 +242,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -250,7 +250,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -275,7 +275,7 @@ The response returns the **resolved** member list — delegation chains are trav 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. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -292,7 +292,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -310,7 +310,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.addMember") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -331,7 +331,7 @@ body := bytes.NewBufferString(`{ "isDelegation": true }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.addMember", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -339,7 +339,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.addMember' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ diff --git a/packages/docs/content/docs/experimental/spaces/records.md b/packages/docs/content/docs/experimental/spaces/records.md index efb8f69..ee6ae30 100644 --- a/packages/docs/content/docs/experimental/spaces/records.md +++ b/packages/docs/content/docs/experimental/spaces/records.md @@ -18,7 +18,7 @@ ats:// did:plc:abcdefghijklmnop1234567890 / com.example.forum / main / di Requires `write` membership in the space. The rkey is auto-generated using a TID. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.createRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -43,7 +43,7 @@ interface CreateRecordResponse { const data: CreateRecordResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.createRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -65,7 +65,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.createRecord") + .post("https://happyview.example.com/xrpc/com.atproto.space.createRecord") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -93,7 +93,7 @@ body := bytes.NewBufferString(`{ } }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.createRecord", body) + "https://happyview.example.com/xrpc/com.atproto.space.createRecord", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -101,7 +101,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createRecord' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.createRecord' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -141,7 +141,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createRecor Requires `write` membership in the space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.putRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.putRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -167,7 +167,7 @@ interface PutRecordResponse { const data: PutRecordResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.putRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.putRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -190,7 +190,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.putRecord") + .post("https://happyview.example.com/xrpc/com.atproto.space.putRecord") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -220,7 +220,7 @@ body := bytes.NewBufferString(`{ } }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.putRecord", body) + "https://happyview.example.com/xrpc/com.atproto.space.putRecord", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -228,7 +228,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.putRecord' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.putRecord' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -272,6 +272,8 @@ The author DID is taken from the authenticated user. You can only write records Requires `read` membership (or a valid [space credential](credentials.md)). +Members with `read_self` access can only retrieve their own records. Attempting to read another user's record returns `403 Forbidden`. + ```ts tab="TypeScript" tab-group="language" const params = new URLSearchParams({ space: "ats://did:plc:abc123/com.example.forum/main", @@ -279,7 +281,7 @@ const params = new URLSearchParams({ rkey: "3k2abc", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.getRecord?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.getRecord?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -302,7 +304,7 @@ const params = new URLSearchParams({ rkey: "3k2abc", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.getRecord?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.getRecord?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -315,7 +317,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getRecord") + .get("https://happyview.example.com/xrpc/com.atproto.space.getRecord") .query(&[ ("space", "ats://did:plc:abc123/com.example.forum/main"), ("collection", "com.example.forum.post"), @@ -330,7 +332,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "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", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -338,7 +340,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -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' \ +curl 'https://happyview.example.com/xrpc/com.atproto.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: ' @@ -375,7 +377,7 @@ const params = new URLSearchParams({ limit: "20", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.listRecords?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.listRecords?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -402,7 +404,7 @@ const params = new URLSearchParams({ limit: "20", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.listRecords?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.listRecords?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -415,7 +417,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.listRecords") + .get("https://happyview.example.com/xrpc/com.atproto.space.listRecords") .query(&[ ("space", "ats://did:plc:abc123/com.example.forum/main"), ("collection", "com.example.forum.post"), @@ -430,7 +432,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "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", + "https://happyview.example.com/xrpc/com.atproto.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -438,7 +440,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -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' \ +curl 'https://happyview.example.com/xrpc/com.atproto.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: ' @@ -475,7 +477,7 @@ curl 'https://happyview.example.com/xrpc/dev.happyview.space.listRecords?space=a You can only delete your own records. Requires `write` membership. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.deleteRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -491,7 +493,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.deleteRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -508,7 +510,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord") + .post("https://happyview.example.com/xrpc/com.atproto.space.deleteRecord") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -527,7 +529,7 @@ body := bytes.NewBufferString(`{ "rkey": "3k2abc" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord", body) + "https://happyview.example.com/xrpc/com.atproto.space.deleteRecord", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -535,7 +537,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.deleteRecord' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -563,7 +565,7 @@ Attempting to delete another user's record returns `403 Forbidden`. `applyWrites` performs multiple create, update, and delete operations in a single request. Requires `write` membership. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.applyWrites", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.applyWrites", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -601,7 +603,7 @@ interface ApplyWritesResult { const data: { results: ApplyWritesResult[] } = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.applyWrites", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.applyWrites", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -636,7 +638,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.applyWrites") + .post("https://happyview.example.com/xrpc/com.atproto.space.applyWrites") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -690,7 +692,7 @@ body := bytes.NewBufferString(`{ ] }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.applyWrites", body) + "https://happyview.example.com/xrpc/com.atproto.space.applyWrites", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -698,7 +700,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.applyWrites' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.applyWrites' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -779,7 +781,7 @@ Pass the `swapRecord` field on `putRecord`, `deleteRecord`, or individual operat 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`. +The space's current revision is available as `revision` in the space object returned by `com.atproto.space.getSpace`. ```json { @@ -795,7 +797,7 @@ Records can also be read using a [space credential](credentials.md) instead of d ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -806,7 +808,7 @@ const data = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -817,7 +819,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getRecord") + .get("https://happyview.example.com/xrpc/com.atproto.space.getRecord") .query(&[("space", "..."), ("collection", "..."), ("rkey", "...")]) .header("Authorization", format!("Bearer {}", space_credential)) .send() @@ -826,13 +828,13 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", nil) req.Header.Set("Authorization", "Bearer "+spaceCredential) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?...' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?...' \ -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' ``` diff --git a/packages/docs/content/docs/getting-started/authentication.md b/packages/docs/content/docs/getting-started/authentication.md index 3251c48..b26f8d7 100644 --- a/packages/docs/content/docs/getting-started/authentication.md +++ b/packages/docs/content/docs/getting-started/authentication.md @@ -130,7 +130,15 @@ curl 'https://happyview.example.com/xrpc/com.example.feed.getHot' \ Queries that don't care who is calling need nothing more than the client key. Procedures — and queries whose Lua scripts read the caller's DID — need a real atproto OAuth session. -XRPC routes only accept **DPoP auth** (`Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key`). Bearer tokens and service auth JWTs are not accepted on XRPC endpoints. +XRPC routes accept several auth methods, resolved in this order: + +1. **DPoP auth** (`Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key`) — used by third-party apps that went through the [DPoP key provisioning](#dpop-key-provisioning-for-third-party-apps) flow. +2. **Bearer space credential** (`Authorization: Bearer `) — a signed JWT granting access to a specific space; accepted on space routes. +3. **Bearer service auth JWT** (`Authorization: Bearer `) — a standard atproto inter-service JWT signed by a DID's atproto signing key; the caller is identified as the issuer DID. +4. **Cookie session** — when no `Authorization` header is present, HappyView falls back to the signed session cookie set after dashboard login. +5. **Anonymous** — if none of the above is present, the request proceeds with no identity. The endpoint's Lua script determines whether that is acceptable. + +Bearer API keys (`hv_*`) are **not** accepted on XRPC endpoints — those are for admin API access only. Third-party apps authenticate users through the [DPoP key provisioning](#dpop-key-provisioning-for-third-party-apps) flow: your app gets a DPoP keypair from HappyView, runs a standard OAuth flow with the user's PDS using that keypair, then registers the resulting tokens back with HappyView. diff --git a/packages/docs/content/docs/getting-started/dashboard.md b/packages/docs/content/docs/getting-started/dashboard.md index 1e55bb8..9b78664 100644 --- a/packages/docs/content/docs/getting-started/dashboard.md +++ b/packages/docs/content/docs/getting-started/dashboard.md @@ -97,6 +97,18 @@ View the current values of all environment variables that affect HappyView's beh View the audit log of admin actions. Events include user creation, lexicon uploads, permission changes, backfill starts, and more. Each entry shows the event type, severity, actor, subject, and timestamp. Events are retained for the number of days configured by `EVENT_LOG_RETENTION_DAYS` (default 30). +### Service Identity + +Configure the AT Protocol service identity for your HappyView instance — either a `did:web` derived from your public URL, a `did:plc` you control, or a linked atproto account. This determines the DID that signs service-level interactions on the network. + +### Experiments + +Toggle experimental feature flags for your instance. Flags like `feature.spaces_enabled` can be enabled here before they are promoted to stable configuration options. + +### Scripts + +Manage script variables that are injected into Lua scripts at runtime. Variables defined here are available to all scripts and can be used to store shared configuration without hardcoding values in individual scripts. + ## About The **About** page shows the current HappyView version and instance configuration: public URL, database backend, Jetstream URL, relay URL, and PLC directory URL. diff --git a/packages/docs/content/docs/guides/lua-scripting.md b/packages/docs/content/docs/guides/lua-scripting.md index eda6062..b411f57 100644 --- a/packages/docs/content/docs/guides/lua-scripting.md +++ b/packages/docs/content/docs/guides/lua-scripting.md @@ -65,6 +65,28 @@ These globals are set automatically before `handle()` is called. | `caller_did` | string? | DID of the authenticated user (nil if unauthenticated) | | `env` | table | Script variables configured in the dashboard | +### Space globals + +When a script handles a space-scoped request, the `space` global is set to a table with the space's metadata. For non-space requests, `space` is `nil`. + +| Field | Type | Description | +| ----------- | ------ | -------------------------------------------------------- | +| `space` | string | The full `ats://` space URI | +| `space_id` | string | Internal space identifier | +| `did` | string | The space's DID | +| `owner_did` | string | The space authority's DID | +| `type_nsid` | string | Space type NSID | +| `skey` | string | Space key | + +```lua +function handle() + if space then + log("handling request for space: " .. space.space) + log("space type: " .. space.type_nsid) + end +end +``` + ## Utility globals Available in both queries and procedures: diff --git a/packages/docs/content/docs/guides/permissions.md b/packages/docs/content/docs/guides/permissions.md index 20e6105..3722fce 100644 --- a/packages/docs/content/docs/guides/permissions.md +++ b/packages/docs/content/docs/guides/permissions.md @@ -6,57 +6,120 @@ HappyView uses a granular permission system to control access to the admin API. ## Permission list -HappyView defines 20 permissions organized by category: +HappyView defines 44 permissions organized by category: ### Lexicons -| Permission | Description | -| ----------------- | ---------------------------------------------- | -| `lexicons:create` | Upload and upsert lexicons (local and network) | -| `lexicons:read` | List and view lexicon details | -| `lexicons:delete` | Delete lexicons | +| Permission | Description | +| ----------------- | ------------------------------------ | +| `lexicons:create` | Upload and register new lexicon schemas | +| `lexicons:read` | View registered lexicon schemas | +| `lexicons:delete` | Remove lexicon schemas | ### Records | Permission | Description | | --------------------------- | --------------------------------------- | -| `records:read` | List and view indexed records | -| `records:delete` | Delete individual records | +| `records:read` | Browse indexed AT Protocol records | +| `records:delete` | Delete individual records from the index | | `records:delete-collection` | Bulk-delete all records in a collection | +### Scripts + +| Permission | Description | +| ---------------- | ------------------------------------------------- | +| `scripts:read` | View trigger-keyed scripts | +| `scripts:manage` | Create, update, and delete trigger-keyed scripts | + ### Script Variables -| Permission | Description | -| ------------------------- | ----------------------------------------- | -| `script-variables:create` | Create and update script variables | -| `script-variables:read` | List script variables (values are masked) | -| `script-variables:delete` | Delete script variables | +| Permission | Description | +| ------------------------- | -------------------------------------------------- | +| `script-variables:create` | Add or update environment variables for Lua scripts | +| `script-variables:read` | View script environment variable keys and values | +| `script-variables:delete` | Remove script environment variables | ### Users -| Permission | Description | -| -------------- | -------------------------- | -| `users:create` | Add new users | -| `users:read` | List and view user details | -| `users:update` | Modify user permissions | -| `users:delete` | Remove users | +| Permission | Description | +| -------------- | -------------------------------------- | +| `users:create` | Add new dashboard users | +| `users:read` | View the user list and their permissions | +| `users:update` | Modify user permissions | +| `users:delete` | Remove dashboard users | ### API Keys -| Permission | Description | -| ----------------- | ------------------- | -| `api-keys:create` | Create new API keys | -| `api-keys:read` | List API keys | -| `api-keys:delete` | Revoke API keys | +| Permission | Description | +| ----------------- | ---------------------------------------- | +| `api-keys:create` | Generate new API keys for admin access | +| `api-keys:read` | View existing API keys | +| `api-keys:delete` | Revoke existing API keys | + +### Backfill + +| Permission | Description | +| ----------------- | ----------------------------------------- | +| `backfill:create` | Trigger historical record backfill jobs | +| `backfill:read` | View backfill job status and progress | + +### Labelers + +| Permission | Description | +| ----------------- | ------------------------------------- | +| `labelers:create` | Subscribe to external labeler services | +| `labelers:read` | View subscribed labeler services | +| `labelers:delete` | Unsubscribe from labeler services | + +### Settings + +| Permission | Description | +| ----------------- | --------------------------------------------------- | +| `settings:manage` | Modify instance settings, logo, and configuration | + +### Plugins + +| Permission | Description | +| ----------------- | -------------------------------------------- | +| `plugins:read` | View installed plugins and their configuration | +| `plugins:create` | Install and configure new plugins | +| `plugins:delete` | Uninstall plugins | + +### API Clients + +| Permission | Description | +| -------------------- | ---------------------------------------- | +| `api-clients:view` | View registered OAuth API clients | +| `api-clients:create` | Register new OAuth API clients | +| `api-clients:edit` | Modify API client settings and credentials | +| `api-clients:delete` | Remove registered API clients | + +### Dead Letters + +| Permission | Description | +| --------------------- | -------------------------------------- | +| `dead-letters:read` | View failed hook executions | +| `dead-letters:manage` | Retry, re-index, or dismiss dead letters | + +### Spaces + +| Permission | Description | +| --------------------------- | ------------------------------------------ | +| `spaces:create` | Create new permissioned data spaces | +| `spaces:read` | View space details and metadata | +| `spaces:update` | Modify space settings | +| `spaces:delete` | Remove spaces and their data | +| `spaces:manage-members` | Add or remove space members and roles | +| `spaces:manage-invites` | Create and revoke space invitations | +| `spaces:manage-records` | Read and write records within spaces | +| `spaces:manage-credentials` | Issue and revoke space access credentials | -### Operations +### System -| Permission | Description | -| ----------------- | ------------------------ | -| `backfill:create` | Start backfill jobs | -| `backfill:read` | View backfill job status | -| `stats:read` | View record statistics | -| `events:read` | Query the event log | +| Permission | Description | +| ------------ | ---------------------------------------- | +| `stats:read` | View collection statistics and record counts | +| `events:read` | View the event log | ## Permission templates @@ -64,25 +127,25 @@ Templates are predefined sets of permissions that simplify user creation. Pass a ### Viewer -Read-only access. Can browse lexicons, records, stats, events, and user lists but cannot modify anything. +Read-only access. Can browse lexicons, records, scripts, stats, events, dead letters, and user lists but cannot modify anything. -Includes: `lexicons:read`, `records:read`, `script-variables:read`, `users:read`, `api-keys:read`, `backfill:read`, `stats:read`, `events:read` +Includes: `lexicons:read`, `records:read`, `scripts:read`, `script-variables:read`, `users:read`, `api-keys:read`, `backfill:read`, `stats:read`, `events:read`, `dead-letters:read` ### Operator -Everything in Viewer, plus the ability to run backfill jobs and manage API keys. +Everything in Viewer, plus the ability to run backfill jobs, manage API keys, and manage dead letters. -Adds: `backfill:create`, `api-keys:create`, `api-keys:delete` +Adds: `backfill:create`, `api-keys:create`, `api-keys:delete`, `dead-letters:manage` ### Manager -Everything in Operator, plus the ability to manage lexicons, records, and script variables. +Everything in Operator, plus the ability to manage lexicons, records, scripts, labelers, settings, plugins, API clients, and spaces. -Adds: `lexicons:create`, `lexicons:delete`, `script-variables:create`, `script-variables:delete`, `records:delete` +Adds: `lexicons:create`, `lexicons:delete`, `scripts:manage`, `script-variables:create`, `script-variables:delete`, `records:delete`, `labelers:create`, `labelers:read`, `labelers:delete`, `settings:manage`, `plugins:read`, `plugins:create`, `plugins:delete`, `api-clients:view`, `api-clients:create`, `api-clients:edit`, `api-clients:delete`, `spaces:create`, `spaces:read`, `spaces:update`, `spaces:delete`, `spaces:manage-members`, `spaces:manage-invites`, `spaces:manage-records`, `spaces:manage-credentials` ### Full Access -All 20 permissions. Equivalent to granting every permission individually (but still not a super user). +All 44 permissions. Equivalent to granting every permission individually (but still not a super user). ## Super user diff --git a/packages/docs/content/docs/index.md b/packages/docs/content/docs/index.md index 917ac20..e28ab6e 100644 --- a/packages/docs/content/docs/index.md +++ b/packages/docs/content/docs/index.md @@ -16,6 +16,8 @@ Building an AppView from scratch means wiring up real-time event streams, record - **Protocol-native:** Works with any PDS, resolves DIDs through the directory, and fetches [network lexicons](guides/lexicons.md#network-lexicons) via DNS authority resolution. +- **Permissioned Spaces:** Experimental support for [AT Protocol Proposal 0016](experimental/spaces/index.md) — membership-gated data containers with per-user repo state, cross-service credentials, and write notifications. + - **Full admin surface:** Built-in [dashboard](getting-started/dashboard.md) and [admin API](api-reference/admin/admin-api.md) for managing lexicons, users, API keys, API clients, backfill jobs, and plugins. ## Design Principles @@ -36,4 +38,5 @@ Building an AppView from scratch means wiring up real-time event streams, record - [Record & Label Scripts](guides/label-scripts): React to record changes and label events in real time - [Labelers](guides/labelers.md): Subscribe to external labelers and manage content labels - [Plugins](guides/plugins.md): Integrate with external platforms using WASM plugins +- [Permissioned Spaces](experimental/spaces/index.md): Create membership-gated data containers with the AT Protocol spaces API - [Event Logs](guides/event-logs.md): Monitor system activity, debug script errors, and audit admin actions diff --git a/packages/docs/content/docs/reference/architecture.md b/packages/docs/content/docs/reference/architecture.md index 0563cef..fc6bf1a 100644 --- a/packages/docs/content/docs/reference/architecture.md +++ b/packages/docs/content/docs/reference/architecture.md @@ -16,14 +16,16 @@ graph LR subgraph HappyView Query["Query Handler
Lua Script (Optional)"] Procedure["Procedure Handler
Lua Script (Optional)"] + Spaces["Spaces
Permissioned Data"] end Procedure --> DB Query --> DB + Spaces --> DB Procedure -->|proxy write| PDS["User PDS"] - DB[("SQLite / PostgreSQL
records · lexicons")] + DB[("SQLite / PostgreSQL
records · lexicons · spaces")] Jetstream["Jetstream
WebSocket"] -->|record events| DB Relay["Relay
listReposByCollection"] -->|repo discovery| Backfill @@ -33,7 +35,7 @@ graph LR Labeler["Labeler
WebSocket (out-of-band)"] -->|label events| DB ``` -Queries go through the query handler to the database (SQLite by default, or Postgres). Writes go through the procedure handler to the user's PDS, then HappyView indexes the record locally. Real-time record events stream in via [Jetstream](https://github.com/bluesky-social/jetstream); historical records are backfilled in-process by discovering repos via the relay's `listReposByCollection` and fetching records directly from each PDS. [Labelers](../guides/labelers.md) are external services that emit content labels over a direct WebSocket connection — they operate out-of-band, outside the relay/repo system. +Queries go through the query handler to the database (SQLite by default, or Postgres). Writes go through the procedure handler to the user's PDS, then HappyView indexes the record locally. Real-time record events stream in via [Jetstream](https://github.com/bluesky-social/jetstream); historical records are backfilled in-process by discovering repos via the relay's `listReposByCollection` and fetching records directly from each PDS. [Labelers](../guides/labelers.md) are external services that emit content labels over a direct WebSocket connection — they operate out-of-band, outside the relay/repo system. [Spaces](../experimental/spaces/index.md) provide permissioned data containers with membership-gated access, per-user repo state tracking (LtHash + signed commits), and cross-service credential-based authentication. ## Request flow @@ -287,6 +289,175 @@ sequenceDiagram | `created_at` | timestamptz | | | `updated_at` | timestamptz | | +### `spaces` + +| Column | Type | Description | +| ----------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | Internal space identifier | +| `did` | text | The space's own DID | +| `authority_did` | text | DID that controls the space | +| `creator_did` | text | DID of the user who created the space | +| `type_nsid` | text | Space type as an NSID | +| `skey` | text | Space key (differentiates spaces of the same type) | +| `display_name` | text | Human-readable name (optional) | +| `description` | text | Description (optional) | +| `mint_policy` | text | `member-list`, `public`, or `managing-app` | +| `app_access` | text (JSON) | `{"type":"open"}` or `{"type":"allowList","allowed":[...]}` | +| `managing_app_did`| text | DID of the managing app (optional) | +| `config` | text (JSON) | Space config (`membershipPublic`, `recordsPublic`, extras) | +| `revision` | text | Current revision TID | +| `created_at` | text | | +| `updated_at` | text | | + +### `space_members` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `did` | text | Member's DID (or space URI for delegation) | +| `access` | text | `read`, `read_self`, or `write` | +| `is_delegation`| boolean | Whether this member is a delegated space | +| `granted_by` | text | DID of who granted membership | +| `created_at` | text | | + +### `space_records` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `uri` | text (PK) | `ats://` URI of the record | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | DID of the record author | +| `collection` | text | Lexicon NSID | +| `rkey` | text | Record key | +| `record` | jsonb | Record value | +| `cid` | text | Content identifier | +| `indexed_at` | text | | + +### `space_repo_state` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | DID of the repo author | +| `lthash_state` | bytea | 2048-byte LtHash state | +| `rev` | text | Current revision | +| `hash` | bytea | Content hash | +| `ikm` | bytea | Input keying material for deniable signatures | +| `sig` | bytea | Signature | +| `mac` | bytea | Message authentication code | +| `updated_at` | text | | + +### `space_record_oplog` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | DID of the operation author | +| `rev` | text | Revision this operation belongs to | +| `idx` | integer | Index within the revision | +| `action` | text | `create`, `update`, or `delete` | +| `collection` | text | Lexicon NSID | +| `rkey` | text | Record key | +| `cid` | text | Content identifier (for create/update) | +| `prev` | text | Previous CID (for update/delete) | +| `created_at` | text | | + +### `space_notify_registrations` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | Filter by author DID (optional) | +| `endpoint` | text | Notification endpoint URL | +| `registered_by`| text | DID of who registered | +| `expires_at` | text | When the registration expires | +| `created_at` | text | | + +### `space_invites` + +| Column | Type | Description | +| ------------ | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `token_hash` | text | SHA-256 hash of the invite token | +| `created_by` | text | DID of the user who created the invite | +| `access` | text | Access level granted: `read`, `read_self`, `write` | +| `max_uses` | integer? | Maximum number of uses (null = unlimited) | +| `uses` | integer | Current use count | +| `expires_at` | text? | Expiry timestamp (null = never) | +| `revoked` | boolean | Whether the invite has been revoked | +| `created_at` | text | | + +### `space_credentials` + +| Column | Type | Description | +| ------------ | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `issued_to` | text | DID the credential was issued to | +| `token_hash` | text | Hash of the credential token | +| `expires_at` | text | When the credential expires | +| `created_at` | text | | + +### `space_dids` + +| Column | Type | Description | +| ------------------ | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `did` | text | The space's DID | +| `space_id` | text (FK) | References `spaces.id` | +| `signing_key_enc` | text | Encrypted signing key (AES-256-GCM) | +| `rotation_key_enc` | text | Encrypted rotation key (AES-256-GCM) | +| `created_by` | text | DID of who provisioned the key | +| `created_at` | text | | + +### `service_identity` + +| Column | Type | Description | +| --------------------- | ----------- | ------------------------------------------------ | +| `id` | integer (PK)| Always 1 (singleton) | +| `mode` | text | `did_web`, `did_plc`, or `linked_account` | +| `did` | text | The service's DID | +| `signing_key_enc` | text | Encrypted signing key | +| `rotation_key_enc` | text? | Encrypted rotation key (did:plc only) | +| `attached_account_did`| text? | Linked account DID (linked_account mode) | +| `setup_complete` | boolean | Whether setup has been finalized | +| `created_at` | text | | +| `updated_at` | text | | + +### `service_entries` + +| Column | Type | Description | +| ------------- | ----------- | ------------------------------------------------ | +| `id` | integer (PK)| | +| `fragment_id` | text | DID document fragment identifier | +| `service_type`| text | Service type (e.g. `AtprotoAppView`) | +| `access_mode` | text | `all` or scoped to specific XRPCs | +| `created_at` | text | | +| `updated_at` | text | | + +### `service_entry_xrpcs` + +| Column | Type | Description | +| ------------------ | ----------- | ------------------------------------------------ | +| `service_entry_id` | integer (FK)| References `service_entries.id` | +| `lexicon_id` | text | Lexicon NSID this entry handles | + +### `verification_methods` + +| Column | Type | Description | +| ----------------------- | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `fragment_id` | text | DID document fragment (e.g. `#atproto_space`) | +| `key_type` | text | Always `Multikey` | +| `public_key_multibase` | text | Public key in multibase encoding | +| `private_key_enc` | text | Encrypted private key (AES-256-GCM) | +| `created_at` | text | | + ### `backfill_jobs` | Column | Type | Description | diff --git a/packages/docs/content/docs/reference/glossary.md b/packages/docs/content/docs/reference/glossary.md index 85aff68..a25adab 100644 --- a/packages/docs/content/docs/reference/glossary.md +++ b/packages/docs/content/docs/reference/glossary.md @@ -36,13 +36,29 @@ Key terms used throughout the HappyView documentation. For a broader introductio ## HappyView-specific terms +**App Access** — Controls which third-party apps can interact with a space. Either `open` (any app) or `allowList` (only specified apps). Set via `com.atproto.simplespace.updateConfig`. + +**Authority DID** — The DID that controls a space. Distinct from the creator DID (who originally created it). Replaces the earlier `owner_did` concept. + **Backfill** — The process of bulk-indexing existing records from the network. HappyView discovers repos via the relay and fetches each repo's records directly from its PDS. Runs when a new record-type lexicon is uploaded or triggered manually. See [Backfill](../guides/backfill.md). +**Delegation Token** — A short-lived JWT (`typ: atproto-space-delegation+jwt`, ES256K, 60-second TTL) that proves a user is a member of a space. Used as step 1 of the credential issuance flow. Obtained via `com.atproto.space.getDelegationToken`. + +**LtHash** — A homomorphic set-hash used for per-user repo state in spaces. Uses a 2048-byte state with 1024 little-endian uint16 lanes and BLAKE3 XOF. Supports incremental insert/remove operations. + +**Mint Policy** — Controls who can create permissioned repos in a space: `member-list` (only members), `public` (anyone), or `managing-app` (only the managing app). + **Network lexicon** — A lexicon fetched directly from the atproto network via DNS authority resolution, rather than uploaded manually. See [Lexicons - Network lexicons](../guides/lexicons.md#network-lexicons). -**Permission** — A granular access control right that authorizes a specific action in the admin API. HappyView defines 20 permissions organized by category (e.g. `lexicons:create`, `users:read`). See [Permissions](../guides/permissions.md). +**Permission** — A granular access control right that authorizes a specific action in the admin API. HappyView defines 44 permissions organized by category (e.g. `lexicons:create`, `users:read`). See [Permissions](../guides/permissions.md). + +**Permissioned Data** — AT Protocol data that is gated by membership in a space, as opposed to public repo data. Defined by AT Protocol Proposal 0016. + +**Permission template** — A predefined set of permissions that can be applied when creating a user. Templates are: **Viewer** (read-only access), **Operator** (viewer + backfill and API key management), **Manager** (operator + lexicon, record, spaces, and plugin management), and **Full Access** (all 44 permissions). + +**Space** — A container for permissioned data in AT Protocol. Identified by a space DID, type NSID, and space key (skey), forming an `ats://` URI. -**Permission template** — A predefined set of permissions that can be applied when creating a user. Templates are: **Viewer** (read-only access), **Operator** (viewer + backfill and API key management), **Manager** (operator + lexicon and record management), and **Full Access** (all 20 permissions). +**Space Credential** — A short-lived JWT (`typ: atproto-space-credential+jwt`, ES256, 2-hour TTL) for cross-service read access to space data. Signed by the space's P-256 keypair. Obtained by exchanging a delegation token via `com.atproto.space.getSpaceCredential`. **Super user** — The bootstrapped user created on first login to a fresh HappyView instance. The super user has unrestricted access to all endpoints regardless of permissions, can transfer super status to another user, and cannot be deleted. -- 2.51.2