diff --git a/packages/docs/content/docs/experimental/spaces/invites.md b/packages/docs/content/docs/experimental/spaces/invites.md index 981a8f5..b96736b 100644 --- a/packages/docs/content/docs/experimental/spaces/invites.md +++ b/packages/docs/content/docs/experimental/spaces/invites.md @@ -6,7 +6,7 @@ title: "Invites" This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. -Invites let space owners distribute membership tokens without knowing recipients' DIDs in advance. +Invites let space authorities distribute membership tokens without knowing recipients' DIDs in advance. Invites are a HappyView-specific feature, not part of the AT Protocol spaces spec. They may be replaced by a different mechanism in the future. @@ -14,7 +14,7 @@ Invites are a HappyView-specific feature, not part of the AT Protocol spaces spe ## Creating an invite -Only the space owner or a super admin can create invites. +Only the space authority or a super admin can create invites. ```ts tab="TypeScript" tab-group="language" const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createInvite", { @@ -292,7 +292,7 @@ Revoking an invite prevents future redemptions but does not remove members who a ## Listing invites -Only the space owner or a super admin can list invites. +Only the space authority or a super admin can list invites. ```ts tab="TypeScript" tab-group="language" const response = await fetch( diff --git a/packages/docs/content/docs/experimental/spaces/managing-spaces.md b/packages/docs/content/docs/experimental/spaces/managing-spaces.md index 3601956..0d99f1d 100644 --- a/packages/docs/content/docs/experimental/spaces/managing-spaces.md +++ b/packages/docs/content/docs/experimental/spaces/managing-spaces.md @@ -144,11 +144,12 @@ const response = await fetch( }, }, ); -interface Space { +interface GetSpaceResponse { uri: string; - isOwner: boolean; + space: Space; + config: SpaceConfig; } -const data: Space = await response.json(); +const data: GetSpaceResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( @@ -190,7 +191,7 @@ curl 'https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats:// -H 'DPoP: ' ``` -If `membershipPublic` is `false`, the caller must be authenticated and be a member (or the owner) to see the space. Non-members receive a `404 Not Found`. +If `membershipPublic` is `false`, the caller must be authenticated and be a member (or the authority) to see the space. Non-members receive a `404 Not Found`. ## Listing spaces @@ -424,3 +425,172 @@ curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.deleteS Deleting a space cascades to all associated records, members, repo state, oplog entries, notification registrations, and credentials. + +## Getting configuration + +Returns the simplespace configuration for a space. Requires admin access (space authority or super admin). + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +interface SpaceConfig { + $type: "com.atproto.simplespace.defs#spaceConfig"; + mintPolicy: string; + appAccess: object; + managingApp: string | null; +} +const data: SpaceConfig = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig") + .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) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Response:** + +```json +{ + "$type": "com.atproto.simplespace.defs#spaceConfig", + "mintPolicy": "member-list", + "appAccess": { "type": "open" }, + "managingApp": null +} +``` + +| Field | Type | Description | +| ------------- | ------ | ------------------------------------------------------------------------ | +| `mintPolicy` | string | `member-list`, `public`, or `managing-app` | +| `appAccess` | object | `{"type": "open"}` or `{"type": "allowList", "allowed": ["did:...", ...]}` | +| `managingApp` | string \| null | DID of the application that manages this space | + +## Updating configuration + +Updates the simplespace configuration for a space. Requires admin access (space authority or super admin). + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateConfig", { + method: "POST", + 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", + mintPolicy: "public", + appAccess: { type: "allowList", allowed: ["did:web:myapp.example.com"] }, + }), +}); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateConfig", { + method: "POST", + 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", + mintPolicy: "public", + appAccess: { type: "allowList", allowed: ["did:web:myapp.example.com"] }, + }), +}); +``` +```rust tab="Rust" tab-group="language" +let response = client + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.updateConfig") + .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", + "mintPolicy": "public", + "appAccess": { "type": "allowList", "allowed": ["did:web:myapp.example.com"] } + })) + .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", + "mintPolicy": "public", + "appAccess": {"type": "allowList", "allowed": ["did:web:myapp.example.com"]} +}`) +req, _ := http.NewRequest("POST", + "https://happyview.example.com/xrpc/com.atproto.simplespace.updateConfig", body) +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/com.atproto.simplespace.updateConfig' \ + -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", + "mintPolicy": "public", + "appAccess": {"type": "allowList", "allowed": ["did:web:myapp.example.com"]} + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| -------------- | -------------- | -------- | ------------------------------------------------------------------------ | +| `space` | string | Yes | Space URI | +| `mintPolicy` | string | No | `member-list`, `public`, or `managing-app` | +| `appAccess` | object | No | `{"type": "open"}` or `{"type": "allowList", "allowed": ["did:...", ...]}` | +| `managingApp` | string \| null | No | DID of the managing app, or `null` to clear | + +All fields except `space` are optional. Only provided fields are updated. The response returns the updated configuration in the same format as `getConfig`. diff --git a/packages/docs/content/docs/experimental/spaces/meta.json b/packages/docs/content/docs/experimental/spaces/meta.json index cdfbf93..5693c82 100644 --- a/packages/docs/content/docs/experimental/spaces/meta.json +++ b/packages/docs/content/docs/experimental/spaces/meta.json @@ -6,6 +6,7 @@ "members", "records", "credentials", + "notifications", "invites", "changelog" ] diff --git a/packages/docs/content/docs/experimental/spaces/notifications.md b/packages/docs/content/docs/experimental/spaces/notifications.md new file mode 100644 index 0000000..ce4c93e --- /dev/null +++ b/packages/docs/content/docs/experimental/spaces/notifications.md @@ -0,0 +1,300 @@ +--- +title: "Write Notifications" +--- + + +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. + + +Write notifications let external services receive webhooks when records change in a space. A service registers an endpoint, and HappyView pushes notifications to it when records are created, updated, or deleted — or when the space itself is deleted. + +Registrations expire after 24 hours and must be renewed. + +## Registering for notifications + +Requires DPoP auth or a space credential. The caller provides the DID of the service that will receive notifications and the HTTPS endpoint to deliver them to. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.registerNotify", { + method: "POST", + 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", + serviceDid: "did:web:feed.example.com", + endpoint: "https://feed.example.com/webhooks/space-writes", + }), +}); +interface RegisterNotifyResponse { + id: string; +} +const data: RegisterNotifyResponse = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.registerNotify", { + method: "POST", + 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", + serviceDid: "did:web:feed.example.com", + endpoint: "https://feed.example.com/webhooks/space-writes", + }), +}); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .post("https://happyview.example.com/xrpc/com.atproto.space.registerNotify") + .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", + "serviceDid": "did:web:feed.example.com", + "endpoint": "https://feed.example.com/webhooks/space-writes" + })) + .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", + "serviceDid": "did:web:feed.example.com", + "endpoint": "https://feed.example.com/webhooks/space-writes" +}`) +req, _ := http.NewRequest("POST", + "https://happyview.example.com/xrpc/com.atproto.space.registerNotify", body) +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/com.atproto.space.registerNotify' \ + -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", + "serviceDid": "did:web:feed.example.com", + "endpoint": "https://feed.example.com/webhooks/space-writes" + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ------------------------------------------------ | +| `space` | string | Yes | Space URI (`ats://...`) | +| `serviceDid` | string | Yes | DID of the service receiving notifications | +| `endpoint` | string | Yes | HTTPS endpoint to deliver notifications to | + +**Response (200):** + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +## Write notification payload + +When a record is created, updated, or deleted in a space, HappyView POSTs a JSON payload to each registered endpoint: + +```json +{ + "space": "space-id", + "did": "did:plc:author", + "collection": "com.example.forum.post", + "rkey": "3jwq5dya2gy2z", + "cid": "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454" +} +``` + +| Field | Type | Description | +| ------------ | ------------- | ------------------------------------------------ | +| `space` | string | Internal space ID | +| `did` | string | DID of the author who made the change | +| `collection` | string (NSID) | Collection the record belongs to | +| `rkey` | string | Record key | +| `cid` | string? | CID of the new record value (null for deletes) | + +Notifications are delivered to both per-author registrations (matching `serviceDid`) and space-wide registrations (no author filter). Delivery is best-effort — if the endpoint is unreachable, the notification is dropped. + +## Pushing a write notification + +Server-to-server endpoint. Triggers write notifications to all registered endpoints for a space. This is used internally by HappyView when records change, but can also be called externally. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.notifyWrite", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + did: "did:plc:author456", + collection: "com.example.forum.post", + rkey: "3jwq5dya2gy2z", + cid: "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454", + }), +}); +const data = await response.json(); +// { "success": true } +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.notifyWrite", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + did: "did:plc:author456", + collection: "com.example.forum.post", + rkey: "3jwq5dya2gy2z", + cid: "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454", + }), +}); +const data = await response.json(); +// { "success": true } +``` +```rust tab="Rust" tab-group="language" +let response = client + .post("https://happyview.example.com/xrpc/com.atproto.space.notifyWrite") + .json(&serde_json::json!({ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "did:plc:author456", + "collection": "com.example.forum.post", + "rkey": "3jwq5dya2gy2z", + "cid": "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454" + })) + .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", + "did": "did:plc:author456", + "collection": "com.example.forum.post", + "rkey": "3jwq5dya2gy2z", + "cid": "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454" +}`) +req, _ := http.NewRequest("POST", + "https://happyview.example.com/xrpc/com.atproto.space.notifyWrite", body) +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/com.atproto.space.notifyWrite' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "did:plc:author456", + "collection": "com.example.forum.post", + "rkey": "3jwq5dya2gy2z", + "cid": "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454" + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------------- | -------- | ------------------------------------------------ | +| `space` | string | Yes | Space URI (`ats://...`) | +| `did` | string | Yes | DID of the author who made the change | +| `collection` | string (NSID) | Yes | Collection the record belongs to | +| `rkey` | string | Yes | Record key | +| `cid` | string | No | CID of the record (omit for deletes) | + +**Response (200):** + +```json +{ + "success": true +} +``` + +## Notifying space deletion + +Server-to-server endpoint. Notifies all registered endpoints that a space has been deleted. Registered endpoints receive `{ "space": "" }`. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + }), +}); +const data = await response.json(); +// { "success": true } +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + }), +}); +const data = await response.json(); +// { "success": true } +``` +```rust tab="Rust" tab-group="language" +let response = client + .post("https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted") + .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/com.atproto.space.notifySpaceDeleted", body) +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/com.atproto.space.notifySpaceDeleted' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main" + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------- | ------ | -------- | ----------------------- | +| `space` | string | Yes | Space URI (`ats://...`) | + +**Response (200):** + +```json +{ + "success": true +} +``` diff --git a/packages/docs/content/docs/experimental/spaces/records.md b/packages/docs/content/docs/experimental/spaces/records.md index ee6ae30..4e7425a 100644 --- a/packages/docs/content/docs/experimental/spaces/records.md +++ b/packages/docs/content/docs/experimental/spaces/records.md @@ -791,6 +791,330 @@ The space's current revision is available as `revision` in the space object retu } ``` +## Repo state + +Returns the per-user repo state for a space, including the current revision and deniable commit data. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +interface RepoStateResponse { + rev: string | null; + commit: { + hash: string; + ikm: string; + sig: string; + mac: string; + rev: string; + } | null; +} +const data: RepoStateResponse = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.space.getRepoState") + .query(&[ + ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("did", "did:plc:author"), + ]) + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Parameters:** + +| Field | Type | Required | Description | +| ------- | ------ | -------- | ------------------------------------ | +| `space` | string | Yes | The space URI | +| `did` | string | Yes | The DID of the user to get state for | + +**Response:** + +| Field | Type | Description | +| -------- | ------------ | -------------------------------------------------------------- | +| `rev` | string/null | Current revision for this user's repo in the space | +| `commit` | object/null | Deniable commit data (base64url-encoded `hash`, `ikm`, `sig`, `mac`, and `rev`) | + +## Record operation log + +Returns the operation log for a user in a space. Each write (create, update, delete) is recorded as an oplog entry. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const data = await response.json(); +// data.ops — array of oplog entries +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.space.listRepoOps") + .query(&[ + ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("did", "did:plc:author"), + ]) + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Parameters:** + +| Field | Type | Required | Description | +| -------- | ------- | -------- | ---------------------------------------------- | +| `space` | string | Yes | The space URI | +| `did` | string | Yes | The DID of the user whose ops to list | +| `limit` | integer | No | Max number of entries to return (default 100, max 1000) | +| `cursor` | string | No | Revision to start after (for pagination) | + +**Response:** + +```json +{ + "ops": [ + { + "id": "...", + "rev": "3l2tkbx7225co", + "idx": 0, + "action": "create", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "cid": "bafyrei...", + "prev": null, + "createdAt": "2026-05-09T12:00:00Z" + } + ] +} +``` + +Each entry records a single write operation. The `action` is one of `create`, `update`, or `delete`. The `prev` field contains the CID of the record before the operation (for updates and deletes). + +## Listing repos + +Returns the list of users who have records in a space, along with their current revision. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +interface Repo { + did: string; + rev: string | null; +} +const data: { repos: Repo[] } = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.space.listRepos") + .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) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Parameters:** + +| Field | Type | Required | Description | +| ------- | ------ | -------- | ------------- | +| `space` | string | Yes | The space URI | + +**Response:** + +```json +{ + "repos": [ + { "did": "did:plc:author1", "rev": "3l2tkbx7225co" }, + { "did": "did:plc:author2", "rev": null } + ] +} +``` + +## Getting a blob + +Retrieves a blob from a space. The blob is fetched from the author's PDS and proxied through HappyView with access control. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const blob = await response.blob(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const blob = await response.blob(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.space.getBlob") + .query(&[ + ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("cid", "bafyrei..."), + ]) + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .send() + .await?; +let bytes = response.bytes().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + --output image.jpg +``` + +**Parameters:** + +| Field | Type | Required | Description | +| ------- | ------ | -------- | ------------------------ | +| `space` | string | Yes | The space URI | +| `cid` | string | Yes | The CID of the blob | + +The response body is the raw blob data with the original `Content-Type` header preserved. + ## Cross-service access Records can also be read using a [space credential](credentials.md) instead of direct membership. Pass the credential as a Bearer token: @@ -838,4 +1162,4 @@ curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?...' \ -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' ``` -A feed generator or other service that isn't a direct member can use a credential issued by the space owner to read data without joining the space. No DPoP auth is needed — the credential itself authenticates the request. +A feed generator or other service that isn't a direct member can use a credential issued by the space authority to read data without joining the space. No DPoP auth is needed — the credential itself authenticates the request.