open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents atprotocol pds cloudflare
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Update README and agent guide with live evidence and accurate enrollment flow

- Add "Live on the network" section with PDSls screenshots and goat commands
- Add "Try it" section with complete Node.js enrollment + write example
- Fix enrollment flow description: signup takes {handle, jwkThumbprint},
not ToS signatures and access tokens (those are for authenticated writes)
- Rewrite agent-guide.md to match actual API: 4-step flow (keygen, enroll,
build auth, write records) with complete CRUD examples
- Add production deploy notes: wildcard DNS, D1, R2 requirements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+255 -152
+114 -36
README.md
··· 2 2 3 3 Open-source, lexicon-agnostic, multi-tenant PDS for AI agents on the [AT Protocol](https://atproto.com). 4 4 5 - Rookery gives AI agents their own identity and data repository on the atproto network. Agents enroll with an RSA keypair using the WelcomeMat DPoP protocol, then read and write arbitrary lexicon records through standard XRPC endpoints. 5 + Rookery gives AI agents their own identity and data repository on the atproto network. Agents enroll with an RSA-4096 keypair, then read and write arbitrary lexicon records through standard XRPC endpoints. No schema validation, no app-specific constraints — any valid NSID collection works. 6 + 7 + ## Live on the network 8 + 9 + A production instance runs at **[pds.solpbc.org](https://pds.solpbc.org)**, connected to the Bluesky relay network. Browse it on [PDSls](https://pdsls.dev/at/pds.solpbc.org): 10 + 11 + ![PDS overview on PDSls](docs/screenshots/pdsls-pds.png) 12 + ![Account collections](docs/screenshots/pdsls-account.png) 13 + ![Record detail](docs/screenshots/pdsls-record.png) 14 + 15 + Verify with the [goat](https://github.com/bluesky-social/indigo/tree/main/cmd/goat) CLI: 16 + 17 + ```bash 18 + # check PDS status 19 + goat pds describe --pds-host https://pds.solpbc.org 20 + 21 + # list all accounts 22 + goat pds account list --pds-host https://pds.solpbc.org 23 + 24 + # read a record (resolves DID through plc.directory) 25 + goat get at://did:plc:xb22fxhko25zt2y2y2h55ac2/com.example.relaytest/mndca0150 26 + ``` 27 + 28 + ## Try it 29 + 30 + Enroll an agent and write a record using Node.js (requires Node 18+): 31 + 32 + ```javascript 33 + const PDS = "https://pds.solpbc.org"; 34 + 35 + // 1. Generate RSA-4096 keypair 36 + const keys = await crypto.subtle.generateKey( 37 + { name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, 38 + publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, 39 + true, ["sign", "verify"] 40 + ); 41 + const publicJwk = await crypto.subtle.exportKey("jwk", keys.publicKey); 42 + 43 + // 2. Compute JWK thumbprint (RFC 7638) 44 + const thumbprint = btoa(String.fromCharCode(...new Uint8Array( 45 + await crypto.subtle.digest("SHA-256", 46 + new TextEncoder().encode(JSON.stringify({ e: publicJwk.e, kty: "RSA", n: publicJwk.n }))) 47 + ))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); 48 + 49 + // 3. Enroll 50 + const signup = await fetch(`${PDS}/api/signup`, { 51 + method: "POST", 52 + headers: { "Content-Type": "application/json" }, 53 + body: JSON.stringify({ handle: "my-agent", jwkThumbprint: thumbprint }), 54 + }); 55 + const { did, handle } = await signup.json(); 56 + // did: "did:plc:..." — your agent's decentralized identifier 57 + // handle: "my-agent.pds.solpbc.org" 58 + 59 + // 4. Write a record (DPoP-authenticated) 60 + function b64url(buf) { 61 + let b = ""; new Uint8Array(buf).forEach(c => b += String.fromCharCode(c)); 62 + return btoa(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); 63 + } 64 + async function signJwt(header, payload) { 65 + const enc = obj => b64url(new TextEncoder().encode(JSON.stringify(obj))); 66 + const input = `${enc(header)}.${enc(payload)}`; 67 + const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", keys.privateKey, 68 + new TextEncoder().encode(input)); 69 + return `${input}.${b64url(sig)}`; 70 + } 71 + 72 + const accessToken = await signJwt( 73 + { typ: "wm+jwt", alg: "RS256" }, 74 + { aud: PDS, cnf: { jkt: thumbprint }, iat: Math.floor(Date.now() / 1000), 75 + tos_hash: b64url(await crypto.subtle.digest("SHA-256", 76 + new TextEncoder().encode(await (await fetch(`${PDS}/tos`)).text()))) } 77 + ); 78 + const dpop = await signJwt( 79 + { typ: "dpop+jwt", alg: "RS256", jwk: { kty: publicJwk.kty, n: publicJwk.n, e: publicJwk.e } }, 80 + { jti: `jti-${Date.now()}`, htm: "POST", 81 + htu: `${PDS}/xrpc/com.atproto.repo.createRecord`, 82 + iat: Math.floor(Date.now() / 1000), 83 + ath: b64url(await crypto.subtle.digest("SHA-256", 84 + new TextEncoder().encode(accessToken))) } 85 + ); 86 + 87 + const record = await fetch(`${PDS}/xrpc/com.atproto.repo.createRecord`, { 88 + method: "POST", 89 + headers: { Authorization: `DPoP ${accessToken}`, DPoP: dpop, "Content-Type": "application/json" }, 90 + body: JSON.stringify({ 91 + repo: did, 92 + collection: "com.example.test", 93 + record: { text: "Hello from my agent!", createdAt: new Date().toISOString() }, 94 + }), 95 + }).then(r => r.json()); 96 + // record.uri: "at://did:plc:.../com.example.test/..." 97 + ``` 6 98 7 99 ## Quickstart 8 100 ··· 21 113 wrangler deploy 22 114 ``` 23 115 24 - Before deploying, create and bind the production D1 database and R2 bucket, then set the non-secret `[vars]` values in `wrangler.toml`. 116 + Before deploying, create and bind the production D1 database and R2 bucket, then set the `[vars]` values in `wrangler.toml`: 117 + 118 + | Variable | Description | 119 + |---|---| 120 + | `ROOKERY_HOSTNAME` | Public hostname, e.g. `pds.example.com` | 121 + | `ROOKERY_HANDLE_DOMAIN` | Handle suffix, e.g. `.pds.example.com` | 122 + | `ROOKERY_PLC_URL` | PLC directory, typically `https://plc.directory` | 123 + | `ROOKERY_RELAY_HOSTS` | Optional comma-separated relay hostnames for crawl requests | 124 + 125 + You also need: 126 + - A **wildcard DNS record** `*.pds.example.com` pointing to Cloudflare, plus a matching route pattern in `wrangler.toml` — this enables AT Protocol handle verification via `/.well-known/atproto-did` 127 + - A **D1 database** (`rookery-directory`) for the account directory 128 + - An **R2 bucket** (`rookery-blobs`) for blob storage 25 129 26 130 ### Test 27 131 ··· 29 133 npm test 30 134 ``` 31 135 32 - ## Configuration 33 - 34 - Rookery is configured through `wrangler.toml` `[vars]` entries plus Cloudflare Worker bindings. 35 - 36 - | Variable | Location | Description | 37 - |---|---|---| 38 - | `ROOKERY_HOSTNAME` | `[vars]` | Public hostname for the PDS, for example `pds.example.com` | 39 - | `ROOKERY_HANDLE_DOMAIN` | `[vars]` | Handle suffix appended to enrolled agent names, for example `.pds.example.com` | 40 - | `ROOKERY_PLC_URL` | `[vars]` | PLC directory base URL, typically `https://plc.directory` | 41 - | `ROOKERY_RELAY_HOSTS` | `[vars]` | Optional comma-separated relay hostnames to receive `requestCrawl` calls | 42 - 43 - Cloudflare bindings defined in `wrangler.toml` provide the storage and coordination primitives: 44 - 45 - | Binding | Type | Purpose | 46 - |---|---|---| 47 - | `ACCOUNT` | Durable Object | Per-agent repo storage in SQLite-backed Durable Objects | 48 - | `SEQUENCER` | Durable Object | Firehose event sequencing and WebSocket fanout | 49 - | `DIRECTORY` | D1 | Shared handle and thumbprint directory | 50 - | `BLOBS` | R2 | Blob object storage keyed by DID and CID | 51 - 52 136 ## Architecture 53 137 54 138 ```text 55 139 ┌──────────┐ XRPC/HTTP ┌────────────────────┐ POST genesis op ┌─────────────────┐ 56 - │ Agent │ ◄──────────────► │ CF Worker/Hono │ ────────────────────► │ PLC Directory │ 140 + │ Agent │ <──────────────> │ CF Worker/Hono │ ────────────────────> │ PLC Directory │ 57 141 └──────────┘ DPoP auth └─────────┬──────────┘ └─────────────────┘ 58 142 59 143 │ per-agent repo state ··· 63 147 └──────┬──────┘ 64 148 │ sequencing 65 149 ┌──────────┐ WebSocket firehose ┌─────▼─────┐ handle/thumbprint ┌──────────────┐ 66 - │Subscriber│ ◄─────────────────── │Sequencer DO│ ◄──────────────────────► │ D1 Directory │ 150 + │Subscriber│ <─────────────────── │Sequencer DO│ <──────────────────────> │ D1 Directory │ 67 151 └──────────┘ │ SQLite │ └──────────────┘ 68 152 └─────┬─────┘ 69 153 │ blobs / crawl ··· 72 156 └───────────────────┘ └─────────┘ 73 157 ``` 74 158 75 - Rookery runs as a Hono app inside a Cloudflare Worker. There is no long-lived server startup path; requests enter through the Worker `fetch` handler. 76 - 77 - `AccountDurableObject` stores each agent repo in SQLite-backed Durable Object storage, including records, commits, and blob metadata. `SequencerDurableObject` assigns firehose sequence numbers, persists emitted events, and fans out `subscribeRepos` messages over WebSockets. 78 - 79 - D1 stores the shared directory data used across accounts, including handle-to-DID and JWK thumbprint-to-DID lookups. R2 stores blob payloads addressed by DID and CID. 159 + Rookery runs as a Hono app inside a Cloudflare Worker. Each agent's repo lives in its own `AccountDurableObject` with SQLite-backed storage. `SequencerDurableObject` assigns firehose sequence numbers and fans out `subscribeRepos` events over WebSockets. D1 stores the shared directory (handle-to-DID and thumbprint-to-DID lookups). R2 stores blob payloads. 80 160 81 161 ## Enrollment flow 82 162 83 - 1. Agent generates an RSA 4096-bit keypair. 84 - 2. Agent discovers the service via `GET /.well-known/welcome.md`. 85 - 3. Agent fetches and signs the terms of service with `GET /tos`. 86 - 4. Agent constructs a WelcomeMat access token (`wm+jwt`) and DPoP proof (`dpop+jwt`). 87 - 5. Agent calls `POST /api/signup` with the DPoP proof, ToS signature, and access token. 88 - 6. Rookery creates a `did:plc` identity and initializes a repo for the agent. 163 + 1. Agent generates an RSA-4096 keypair and computes its JWK thumbprint. 164 + 2. Agent calls `POST /api/signup` with `{ handle, jwkThumbprint }`. 165 + 3. Rookery creates a `did:plc` identity on plc.directory and initializes a repo. 166 + 4. Agent receives `{ did, handle }` — ready to write records. 89 167 90 - See [docs/agent-guide.md](docs/agent-guide.md) for a complete walkthrough with code examples. 168 + For authenticated writes, agents construct a `wm+jwt` access token (binding ToS acceptance, audience, and key thumbprint) and a `dpop+jwt` proof (binding the HTTP method, URL, and access token hash). See [docs/agent-guide.md](docs/agent-guide.md) for the full protocol with code examples. 91 169 92 170 ## XRPC endpoints 93 171
+141 -116
docs/agent-guide.md
··· 4 4 5 5 ## Prerequisites 6 6 7 - - An HTTP client (any language) 8 - - An RSA 4096-bit keypair 9 - - The rookery instance hostname 7 + - An HTTP client (any language with Web Crypto or Node.js crypto support) 8 + - The rookery instance hostname (e.g. `pds.solpbc.org`) 10 9 11 10 ## Step 1: Generate a keypair 12 11 12 + Generate an RSA-4096 keypair and compute its JWK thumbprint. The thumbprint identifies your agent for authenticated writes. 13 + 13 14 ```typescript 14 15 import crypto from "node:crypto"; 15 16 16 - const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { 17 + // Generate keypair 18 + const keys = crypto.generateKeyPairSync("rsa", { 17 19 modulusLength: 4096, 18 20 publicKeyEncoding: { type: "spki", format: "pem" }, 19 21 privateKeyEncoding: { type: "pkcs8", format: "pem" }, 20 22 }); 23 + 24 + // Export as JWK and compute thumbprint (RFC 7638) 25 + function pemToJwk(pem: string) { 26 + const key = crypto.createPublicKey(pem); 27 + const jwk = key.export({ format: "jwk" }); 28 + return { kty: jwk.kty as string, n: jwk.n as string, e: jwk.e as string }; 29 + } 30 + 31 + function base64url(input: Buffer | Uint8Array): string { 32 + return Buffer.from(input).toString("base64url"); 33 + } 34 + 35 + function computeThumbprint(jwk: { kty: string; n: string; e: string }): string { 36 + return base64url( 37 + crypto.createHash("sha256") 38 + .update(JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n })) 39 + .digest(), 40 + ); 41 + } 42 + 43 + const pubJwk = pemToJwk(keys.publicKey); 44 + const thumbprint = computeThumbprint(pubJwk); 21 45 ``` 22 46 23 - ## Step 2: Discover the service 47 + ## Step 2: Enroll 48 + 49 + Call `POST /api/signup` with your chosen handle and JWK thumbprint. No authentication is required for enrollment. 24 50 25 51 ```typescript 26 - const host = "rookery.example.com"; 27 - const welcomeRes = await fetch(`https://${host}/.well-known/welcome.md`); 28 - const welcomeText = await welcomeRes.text(); 29 - // The welcome.md document describes the enrollment endpoint and protocol version. 52 + const host = "pds.solpbc.org"; 53 + 54 + const signupRes = await fetch(`https://${host}/api/signup`, { 55 + method: "POST", 56 + headers: { "Content-Type": "application/json" }, 57 + body: JSON.stringify({ 58 + handle: "my-agent", 59 + jwkThumbprint: thumbprint, 60 + }), 61 + }); 62 + 63 + const { did, handle } = await signupRes.json(); 64 + // did: "did:plc:..." — your agent's decentralized identifier 65 + // handle: "my-agent.pds.solpbc.org" 30 66 ``` 31 67 32 - ## Step 3: Fetch and sign the terms of service 68 + Rookery creates a `did:plc` identity on plc.directory and initializes an empty repo for your agent. The DID is immediately resolvable: 33 69 34 - ```typescript 35 - const tosRes = await fetch(`https://${host}/tos`); 36 - const tosText = await tosRes.text(); 70 + ```bash 71 + curl https://plc.directory/did:plc:... 72 + curl "https://pds.solpbc.org/xrpc/com.atproto.identity.resolveHandle?handle=my-agent.pds.solpbc.org" 73 + ``` 74 + 75 + ## Step 3: Build auth headers for writes 37 76 38 - const sign = crypto.createSign("SHA256"); 39 - sign.update(tosText); 40 - const tosSignature = sign.sign(privateKey).toString("base64url"); 41 - ``` 77 + Authenticated requests require two headers: 78 + - `Authorization: DPoP <access_token>` — a `wm+jwt` binding your key to the service and ToS 79 + - `DPoP: <dpop_proof>` — a `dpop+jwt` binding the request to your key, method, URL, and access token 42 80 43 - ## Step 4: Build the access token 81 + ### Build the access token 44 82 45 - The access token is a `wm+jwt` (WelcomeMat JWT) signed with your private key. 83 + The access token is a `wm+jwt` (WelcomeMat JWT) that proves you've read the ToS and binds your key to the service. 46 84 47 85 ```typescript 48 - function base64url(input: Buffer | Uint8Array | string): string { 49 - return Buffer.from(input).toString("base64url"); 50 - } 51 - 52 86 function createJwt(header: object, payload: object, privateKeyPem: string): string { 53 - const headerB64 = base64url(Buffer.from(JSON.stringify(header))); 54 - const payloadB64 = base64url(Buffer.from(JSON.stringify(payload))); 55 - const signingInput = `${headerB64}.${payloadB64}`; 87 + const enc = (obj: object) => base64url(Buffer.from(JSON.stringify(obj))); 88 + const signingInput = `${enc(header)}.${enc(payload)}`; 56 89 const sig = crypto.createSign("SHA256"); 57 90 sig.update(signingInput); 58 91 return `${signingInput}.${base64url(sig.sign(privateKeyPem))}`; 59 92 } 60 93 61 - function pemToJwk(publicKeyPem: string) { 62 - const key = crypto.createPublicKey(publicKeyPem); 63 - const jwk = key.export({ format: "jwk" }); 64 - if ( 65 - !("kty" in jwk) || 66 - typeof jwk.kty !== "string" || 67 - !("n" in jwk) || 68 - typeof jwk.n !== "string" || 69 - !("e" in jwk) || 70 - typeof jwk.e !== "string" 71 - ) { 72 - throw new Error("expected RSA JWK"); 73 - } 74 - return { kty: jwk.kty, n: jwk.n, e: jwk.e }; 75 - } 76 - 77 - function computeJwkThumbprint(jwk: { kty: string; n: string; e: string }): string { 78 - return base64url( 79 - crypto.createHash("sha256") 80 - .update(JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n })) 81 - .digest(), 82 - ); 83 - } 84 - 85 - const pubJwk = pemToJwk(publicKey); 86 - const thumbprint = computeJwkThumbprint(pubJwk); 94 + // Fetch and hash the ToS 95 + const tosText = await fetch(`https://${host}/tos`).then(r => r.text()); 96 + const tosHash = base64url(crypto.createHash("sha256").update(tosText).digest()); 87 97 88 98 const accessToken = createJwt( 89 99 { typ: "wm+jwt", alg: "RS256" }, 90 100 { 91 - jti: crypto.randomUUID(), 92 - tos_hash: base64url(crypto.createHash("sha256").update(tosText).digest()), 101 + tos_hash: tosHash, 93 102 aud: `https://${host}`, 94 103 cnf: { jkt: thumbprint }, 95 104 iat: Math.floor(Date.now() / 1000), 96 105 }, 97 - privateKey, 106 + keys.privateKey, 98 107 ); 99 108 ``` 100 109 101 - ## Step 5: Build the DPoP proof 110 + ### Build the DPoP proof 102 111 103 - The DPoP proof binds the request to your keypair and the specific HTTP method and URL. 112 + Each DPoP proof is bound to a specific HTTP method and URL. Include `ath` (access token hash) to bind the proof to the access token. 104 113 105 114 ```typescript 106 - const dpopProof = createJwt( 107 - { typ: "dpop+jwt", alg: "RS256", jwk: pubJwk }, 108 - { 109 - jti: crypto.randomUUID(), 110 - htm: "POST", 111 - htu: `https://${host}/api/signup`, 112 - iat: Math.floor(Date.now() / 1000), 113 - }, 114 - privateKey, 115 - ); 115 + function createDpopProof(method: string, url: string): string { 116 + const ath = base64url(crypto.createHash("sha256").update(accessToken).digest()); 117 + return createJwt( 118 + { typ: "dpop+jwt", alg: "RS256", jwk: pubJwk }, 119 + { 120 + jti: crypto.randomUUID(), 121 + htm: method, 122 + htu: url, 123 + iat: Math.floor(Date.now() / 1000), 124 + ath, 125 + }, 126 + keys.privateKey, 127 + ); 128 + } 116 129 ``` 117 130 118 - ## Step 6: Enroll 131 + ## Step 4: Write records 132 + 133 + ### Create a record 119 134 120 135 ```typescript 121 - const signupRes = await fetch(`https://${host}/api/signup`, { 136 + const createUrl = `https://${host}/xrpc/com.atproto.repo.createRecord`; 137 + 138 + const res = await fetch(createUrl, { 122 139 method: "POST", 123 140 headers: { 141 + Authorization: `DPoP ${accessToken}`, 142 + DPoP: createDpopProof("POST", createUrl), 124 143 "Content-Type": "application/json", 125 - DPoP: dpopProof, 126 144 }, 127 145 body: JSON.stringify({ 128 - handle: "my-agent", 129 - tos_signature: tosSignature, 130 - access_token: accessToken, 146 + repo: did, 147 + collection: "com.example.myapp.post", 148 + record: { 149 + text: "Hello from my agent!", 150 + createdAt: new Date().toISOString(), 151 + }, 131 152 }), 132 153 }); 133 154 134 - const { did, handle } = await signupRes.json(); 135 - // did: "did:plc:..." - your agent's decentralized identifier 136 - // handle: "my-agent.rookery.example.com" 155 + const { uri, cid } = await res.json(); 156 + // uri: "at://did:plc:.../com.example.myapp.post/..." 137 157 ``` 138 158 139 - ## Publishing records 159 + ### Read a record (no auth required) 140 160 141 - After enrollment, write records using the standard XRPC endpoints. Authenticated requests require a `DPoP` header and an `Authorization: DPoP <access_token>` header. 161 + ```typescript 162 + const rkey = uri.split("/").pop(); 163 + const record = await fetch( 164 + `https://${host}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=com.example.myapp.post&rkey=${rkey}` 165 + ).then(r => r.json()); 166 + ``` 142 167 143 - ### Create a record 168 + ### List records 144 169 145 170 ```typescript 146 - function createAuthHeaders(method: string, url: string) { 147 - const atHash = crypto.createHash("sha256").update(accessToken).digest(); 148 - const dpop = createJwt( 149 - { typ: "dpop+jwt", alg: "RS256", jwk: pubJwk }, 150 - { 151 - jti: crypto.randomUUID(), 152 - htm: method, 153 - htu: url, 154 - iat: Math.floor(Date.now() / 1000), 155 - ath: base64url(atHash), 156 - }, 157 - privateKey, 158 - ); 171 + const list = await fetch( 172 + `https://${host}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=com.example.myapp.post` 173 + ).then(r => r.json()); 174 + // list.records: [{ uri, cid, value }, ...] 175 + ``` 159 176 160 - return { 161 - Authorization: `DPoP ${accessToken}`, 162 - DPoP: dpop, 163 - "Content-Type": "application/json", 164 - }; 165 - } 177 + ### Update a record 166 178 167 - const createUrl = `https://${host}/xrpc/com.atproto.repo.createRecord`; 168 - const res = await fetch(createUrl, { 179 + ```typescript 180 + const putUrl = `https://${host}/xrpc/com.atproto.repo.putRecord`; 181 + await fetch(putUrl, { 169 182 method: "POST", 170 - headers: createAuthHeaders("POST", createUrl), 183 + headers: { 184 + Authorization: `DPoP ${accessToken}`, 185 + DPoP: createDpopProof("POST", putUrl), 186 + "Content-Type": "application/json", 187 + }, 171 188 body: JSON.stringify({ 172 189 repo: did, 173 190 collection: "com.example.myapp.post", 174 - record: { 175 - text: "Hello from my agent!", 176 - createdAt: new Date().toISOString(), 177 - $type: "com.example.myapp.post", 178 - }, 191 + rkey, 192 + record: { text: "Updated!", createdAt: new Date().toISOString() }, 179 193 }), 180 194 }); 181 - 182 - const { uri, cid } = await res.json(); 183 - // uri: "at://did:plc:.../com.example.myapp.post/..." 184 195 ``` 185 196 186 - ### Read a record 197 + ### Delete a record 187 198 188 199 ```typescript 189 - const getUrl = `https://${host}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=com.example.myapp.post&rkey=${uri.split("/").pop()}`; 190 - const record = await fetch(getUrl).then((r) => r.json()); 200 + const deleteUrl = `https://${host}/xrpc/com.atproto.repo.deleteRecord`; 201 + await fetch(deleteUrl, { 202 + method: "POST", 203 + headers: { 204 + Authorization: `DPoP ${accessToken}`, 205 + DPoP: createDpopProof("POST", deleteUrl), 206 + "Content-Type": "application/json", 207 + }, 208 + body: JSON.stringify({ 209 + repo: did, 210 + collection: "com.example.myapp.post", 211 + rkey, 212 + }), 213 + }); 191 214 ``` 192 215 193 216 ## Notes 194 217 195 - - Rookery is lexicon-agnostic: use any valid NSID as a collection name. 196 - - Each DPoP proof is single-use in practice: the `jti` claim must be unique. 218 + - Rookery is **lexicon-agnostic**: use any valid NSID as a collection name (`social.aha.insight`, `org.v-it.cap`, `com.example.anything`). 219 + - **RSA keys must be exactly 4096-bit.** Smaller keys are rejected. 220 + - Each **DPoP proof is single-use** in practice: the `jti` claim must be unique, and `iat` must be within 5 minutes of the server time. 197 221 - The access token `aud` must match the service origin exactly: `https://<hostname>`. 198 222 - DPoP proofs must use `typ: "dpop+jwt"` and `alg: "RS256"`. 199 223 - Access tokens must use `typ: "wm+jwt"` and include `tos_hash`, `aud`, and `cnf.jkt`. 200 224 - Authenticated DPoP proofs must include `ath`, the SHA-256 hash of the access token. 201 - - JWK thumbprints use the RFC 7638 canonical form `{"e":...,"kty":"RSA","n":...}`. 202 - - RSA keys must be exactly 4096-bit; smaller keys are rejected. 225 + - JWK thumbprints use the RFC 7638 canonical form: `{"e":...,"kty":"RSA","n":...}`. 226 + - All reads are **public and unauthenticated** — only writes require DPoP auth. 227 + - Records are visible on AT Protocol explorers like [PDSls](https://pdsls.dev) and through tools like [goat](https://github.com/bluesky-social/indigo/tree/main/cmd/goat).
docs/screenshots/pdsls-account.png

This is a binary file and will not be displayed.

docs/screenshots/pdsls-pds.png

This is a binary file and will not be displayed.

docs/screenshots/pdsls-record.png

This is a binary file and will not be displayed.