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.

Add L16 integration tests, update docs and wrangler.toml for CF deployment

New test/integration.test.ts with 6 end-to-end tests:
- Full lifecycle: signup → write → read → CAR export → firehose events
- Multi-agent isolation: two agents on separate Account DOs
- DID lifecycle: signup creates did:plc, resolveHandle returns it
- Enrollment edge cases: duplicate handle, duplicate thumbprint, auth failures
- Blob round-trip: uploadBlob → getBlob via HTTP routes
- Lexicon-agnostic: various collection NSIDs accepted

Updated README.md: CF Worker architecture, wrangler quickstart, binding tables.
Updated docs/agent-guide.md: removed Node.js prerequisite.
Updated wrangler.toml: added [vars] for pds.solpbc.org.
Deleted schema/directory.sql (initDirectory() is authoritative).

+499 -50
+47 -37
README.md
··· 6 6 7 7 ## Quickstart 8 8 9 - ### Docker 9 + ### Local development 10 10 11 11 ```bash 12 - docker compose up 12 + npm install 13 + wrangler dev 13 14 ``` 14 15 15 - The provided compose file points `ROOKERY_PLC_URL` at `https://plc.directory`, so agent signup requires outbound access to the public PLC directory. 16 + Miniflare provides the Worker bindings during local development, so no extra environment variables are needed. 16 17 17 - ### Local development 18 + ### Production deploy 18 19 19 20 ```bash 20 - npm install 21 - ROOKERY_HOSTNAME=localhost:3000 ROOKERY_HANDLE_DOMAIN=localhost ROOKERY_PLC_URL=https://plc.directory npm run dev 21 + wrangler deploy 22 22 ``` 23 23 24 - Run tests: 24 + Before deploying, create and bind the production D1 database and R2 bucket, then set the non-secret `[vars]` values in `wrangler.toml`. 25 + 26 + ### Test 25 27 26 28 ```bash 27 29 npm test ··· 29 31 30 32 ## Configuration 31 33 32 - All configuration is through environment variables. 34 + Rookery is configured through `wrangler.toml` `[vars]` entries plus Cloudflare Worker bindings. 33 35 34 - | Variable | Required | Default | Description | 35 - |---|---|---|---| 36 - | `ROOKERY_HOSTNAME` | yes | - | Public hostname (e.g. `rookery.example.com`) | 37 - | `ROOKERY_HANDLE_DOMAIN` | yes | - | Domain suffix for agent handles | 38 - | `ROOKERY_PLC_URL` | yes | - | PLC directory URL (e.g. `https://plc.directory`) | 39 - | `PORT` | no | `3000` | HTTP listen port | 40 - | `ROOKERY_DB_PATH` | no | `./rookery.db` | SQLite database file path | 41 - | `ROOKERY_RELAY_HOSTS` | no | - | Comma-separated relay hostnames | 42 - | `ROOKERY_TOS_PATH` | no | built-in text | Path to custom terms-of-service file | 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 | 43 51 44 52 ## Architecture 45 53 46 54 ```text 47 - ┌─────────────────┐ 48 - │ PLC Directory │ 49 - └────────▲────────┘ 50 - │ POST genesis op 51 - ┌──────────┐ XRPC/HTTP ┌──────────┴────────┐ requestCrawl ┌─────────┐ 52 - │ Agent │ ◄──────────────► │ rookery │ ──────────────────► │ Relay │ 53 - └──────────┘ DPoP auth │ │ └─────────┘ 54 - │ Hono + SQLite │ 55 - ┌──────────┐ WebSocket │ + blob storage │ 56 - │Subscriber│ ◄─────────────── │ │ 57 - └──────────┘ firehose └───────────────────┘ 55 + ┌──────────┐ XRPC/HTTP ┌────────────────────┐ POST genesis op ┌─────────────────┐ 56 + │ Agent │ ◄──────────────► │ CF Worker/Hono │ ────────────────────► │ PLC Directory │ 57 + └──────────┘ DPoP auth └─────────┬──────────┘ └─────────────────┘ 58 + 59 + │ per-agent repo state 60 + ┌──────▼──────┐ 61 + │ Account DO │ 62 + │ SQLite │ 63 + └──────┬──────┘ 64 + │ sequencing 65 + ┌──────────┐ WebSocket firehose ┌─────▼─────┐ handle/thumbprint ┌──────────────┐ 66 + │Subscriber│ ◄─────────────────── │Sequencer DO│ ◄──────────────────────► │ D1 Directory │ 67 + └──────────┘ │ SQLite │ └──────────────┘ 68 + └─────┬─────┘ 69 + │ blobs / crawl 70 + ┌─────────▼─────────┐ ┌─────────┐ 71 + │ R2 Blobs │ │ Relay │ 72 + └───────────────────┘ └─────────┘ 58 73 ``` 59 74 60 - Rookery assembles a Hono app with: 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. 61 76 62 - - Base discovery, identity, and enrollment routes from `src/app.ts` 63 - - Repo read and write routes from `src/repo.ts` 64 - - Sync and firehose routes from `src/sync.ts` 65 - - SQLite persistence plus filesystem blob storage 66 - - A `Sequencer` that emits account, identity, and repo commit firehose events 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. 67 78 68 - At startup, `src/index.ts` loads env config, opens SQLite, creates the blob directory, initializes the sequencer, mounts sync and repo routes, starts the HTTP server, and injects WebSocket upgrade handling for `subscribeRepos`. 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. 69 80 70 81 ## Enrollment flow 71 82 ··· 95 106 96 107 | Method | Endpoint | Auth | Description | 97 108 |---|---|---|---| 98 - | POST | `/api/signup` | DPoP | Agent enrollment | 99 - | GET | `/api/whoami` | DPoP | Authenticated identity check | 109 + | POST | `/api/signup` | no | Agent enrollment | 100 110 101 111 ### Repo reads (public) 102 112
+1 -1
docs/agent-guide.md
··· 4 4 5 5 ## Prerequisites 6 6 7 - - Node.js 22+ 7 + - An HTTP client (any language) 8 8 - An RSA 4096-bit keypair 9 9 - The rookery instance hostname 10 10
-12
schema/directory.sql
··· 1 - -- D1 account directory schema for rookery 2 - -- Apply with: wrangler d1 execute rookery-directory --file schema/directory.sql 3 - 4 - CREATE TABLE IF NOT EXISTS accounts ( 5 - did TEXT PRIMARY KEY, 6 - handle TEXT NOT NULL UNIQUE, 7 - do_id TEXT NOT NULL, 8 - active INTEGER NOT NULL DEFAULT 1, 9 - created_at TEXT NOT NULL DEFAULT (datetime('now')) 10 - ); 11 - 12 - CREATE INDEX IF NOT EXISTS idx_accounts_handle ON accounts(handle);
+445
test/integration.test.ts
··· 1 + import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { env, worker, runInDurableObject } from "./helpers"; 3 + import { initDirectory } from "../src/directory"; 4 + import { base64urlEncode, jwkThumbprint, sha256Base64url } from "../src/auth"; 5 + import { SequencerDurableObject } from "../src/sequencer-do"; 6 + 7 + async function signJwt( 8 + header: Record<string, unknown>, 9 + payload: Record<string, unknown>, 10 + privateKey: CryptoKey, 11 + ): Promise<string> { 12 + const encode = (obj: Record<string, unknown>) => 13 + base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); 14 + const headerStr = encode(header); 15 + const payloadStr = encode(payload); 16 + const signingInput = `${headerStr}.${payloadStr}`; 17 + const signature = await crypto.subtle.sign( 18 + "RSASSA-PKCS1-v1_5", 19 + privateKey, 20 + new TextEncoder().encode(signingInput), 21 + ); 22 + return `${signingInput}.${base64urlEncode(signature)}`; 23 + } 24 + 25 + function getSequencerStub() { 26 + const id = env.SEQUENCER.idFromName("sequencer"); 27 + return env.SEQUENCER.get(id); 28 + } 29 + 30 + async function resetSequencer(): Promise<void> { 31 + const stub = getSequencerStub(); 32 + await runInDurableObject(stub, async (instance: SequencerDurableObject) => { 33 + await instance.sequenceIdentity("did:plc:reset", "reset.rookery.test"); 34 + const ctx = (instance as unknown as { ctx: DurableObjectState }).ctx; 35 + ctx.storage.sql.exec("DELETE FROM firehose_events"); 36 + ctx.storage.sql.exec( 37 + "DELETE FROM sqlite_sequence WHERE name = 'firehose_events'", 38 + ); 39 + }); 40 + } 41 + 42 + async function getFirehoseRows(): Promise< 43 + Array<{ seq: number; did: string; event_type: string; payload: ArrayBuffer }> 44 + > { 45 + const stub = getSequencerStub(); 46 + return runInDurableObject(stub, async (instance: SequencerDurableObject) => { 47 + const ctx = (instance as unknown as { ctx: DurableObjectState }).ctx; 48 + return ctx.storage.sql 49 + .exec("SELECT seq, did, event_type, payload FROM firehose_events ORDER BY seq ASC") 50 + .toArray() as Array<{ 51 + seq: number; 52 + did: string; 53 + event_type: string; 54 + payload: ArrayBuffer; 55 + }>; 56 + }); 57 + } 58 + 59 + async function createAccountViaSignup( 60 + handle: string, 61 + thumbprint?: string, 62 + ): Promise<{ did: string; handle: string }> { 63 + const originalFetch = globalThis.fetch.bind(globalThis); 64 + const fetchSpy = vi 65 + .spyOn(globalThis, "fetch") 66 + .mockImplementation(async (input, init) => { 67 + const url = 68 + typeof input === "string" ? input : input instanceof Request ? input.url : input.url; 69 + if (url.startsWith("https://plc.directory/")) { 70 + return new Response(null, { status: 200 }); 71 + } 72 + return originalFetch(input as RequestInfo | URL, init); 73 + }); 74 + 75 + try { 76 + const response = await worker.fetch( 77 + new Request("http://localhost/api/signup", { 78 + method: "POST", 79 + headers: { "Content-Type": "application/json" }, 80 + body: JSON.stringify({ handle, jwkThumbprint: thumbprint }), 81 + }), 82 + ); 83 + expect(response.status).toBe(200); 84 + return response.json<{ did: string; handle: string }>(); 85 + } finally { 86 + fetchSpy.mockRestore(); 87 + } 88 + } 89 + 90 + async function generateAuthKeys(): Promise<{ 91 + authKeys: CryptoKeyPair; 92 + publicJwk: JsonWebKey; 93 + thumbprint: string; 94 + }> { 95 + const authKeys = await crypto.subtle.generateKey( 96 + { 97 + name: "RSASSA-PKCS1-v1_5", 98 + modulusLength: 4096, 99 + publicExponent: new Uint8Array([1, 0, 1]), 100 + hash: "SHA-256", 101 + }, 102 + true, 103 + ["sign", "verify"], 104 + ); 105 + const publicJwk = await crypto.subtle.exportKey("jwk", authKeys.publicKey); 106 + const thumbprint = await jwkThumbprint(publicJwk as { kty: string; n: string; e: string }); 107 + return { authKeys, publicJwk, thumbprint }; 108 + } 109 + 110 + async function createDpopJwt( 111 + authKeys: CryptoKeyPair, 112 + publicJwk: JsonWebKey, 113 + htu: string, 114 + accessToken: string, 115 + ): Promise<string> { 116 + return signJwt( 117 + { 118 + typ: "dpop+jwt", 119 + alg: "RS256", 120 + jwk: publicJwk, 121 + }, 122 + { 123 + jti: `jti-${Date.now().toString(36)}`, 124 + htm: "POST", 125 + htu, 126 + iat: Math.floor(Date.now() / 1000), 127 + ath: await sha256Base64url(accessToken), 128 + }, 129 + authKeys.privateKey, 130 + ); 131 + } 132 + 133 + describe("Integration", () => { 134 + beforeAll(async () => { 135 + await initDirectory(env.DIRECTORY); 136 + }); 137 + 138 + beforeEach(async () => { 139 + await resetSequencer(); 140 + }); 141 + 142 + it("runs the full lifecycle from signup through firehose", async () => { 143 + const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 144 + const { did } = await createAccountViaSignup( 145 + `lifecycle-${Date.now().toString(36)}`, 146 + thumbprint, 147 + ); 148 + expect(did.startsWith("did:plc:")).toBe(true); 149 + 150 + const accessToken = "lifecycle-token"; 151 + const createUrl = "http://localhost/xrpc/com.atproto.repo.createRecord"; 152 + const createResponse = await worker.fetch( 153 + new Request(createUrl, { 154 + method: "POST", 155 + headers: { 156 + authorization: `DPoP ${accessToken}`, 157 + dpop: await createDpopJwt(authKeys, publicJwk, createUrl, accessToken), 158 + "content-type": "application/json", 159 + }, 160 + body: JSON.stringify({ 161 + repo: did, 162 + collection: "com.example.test", 163 + rkey: "post1", 164 + record: { text: "hello", createdAt: new Date().toISOString() }, 165 + }), 166 + }), 167 + ); 168 + expect(createResponse.status).toBe(200); 169 + 170 + const getResponse = await worker.fetch( 171 + `http://localhost/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=com.example.test&rkey=post1`, 172 + ); 173 + expect(getResponse.status).toBe(200); 174 + expect(await getResponse.json()).toMatchObject({ 175 + value: { text: "hello" }, 176 + }); 177 + 178 + const repoExport = await worker.fetch( 179 + `http://localhost/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`, 180 + ); 181 + expect(repoExport.status).toBe(200); 182 + expect(repoExport.headers.get("content-type")).toBe("application/vnd.ipld.car"); 183 + expect((await repoExport.arrayBuffer()).byteLength).toBeGreaterThan(0); 184 + 185 + const rows = await getFirehoseRows(); 186 + expect(rows.map((row) => row.event_type)).toEqual(["identity", "account", "commit"]); 187 + }); 188 + 189 + it("keeps multi-agent writes and reads isolated", async () => { 190 + const agentAKeys = await generateAuthKeys(); 191 + const agentBKeys = await generateAuthKeys(); 192 + const agentA = await createAccountViaSignup( 193 + `agent-a-${Date.now().toString(36)}`, 194 + agentAKeys.thumbprint, 195 + ); 196 + const agentB = await createAccountViaSignup( 197 + `agent-b-${Date.now().toString(36)}`, 198 + agentBKeys.thumbprint, 199 + ); 200 + 201 + const createUrl = "http://localhost/xrpc/com.atproto.repo.createRecord"; 202 + 203 + const agentAWrite = await worker.fetch( 204 + new Request(createUrl, { 205 + method: "POST", 206 + headers: { 207 + authorization: "DPoP agent-a-token", 208 + dpop: await createDpopJwt( 209 + agentAKeys.authKeys, 210 + agentAKeys.publicJwk, 211 + createUrl, 212 + "agent-a-token", 213 + ), 214 + "content-type": "application/json", 215 + }, 216 + body: JSON.stringify({ 217 + repo: agentA.did, 218 + collection: "com.example.test", 219 + rkey: "a-post", 220 + record: { text: "from agent a", createdAt: new Date().toISOString() }, 221 + }), 222 + }), 223 + ); 224 + expect(agentAWrite.status).toBe(200); 225 + 226 + const agentBWrite = await worker.fetch( 227 + new Request(createUrl, { 228 + method: "POST", 229 + headers: { 230 + authorization: "DPoP agent-b-token", 231 + dpop: await createDpopJwt( 232 + agentBKeys.authKeys, 233 + agentBKeys.publicJwk, 234 + createUrl, 235 + "agent-b-token", 236 + ), 237 + "content-type": "application/json", 238 + }, 239 + body: JSON.stringify({ 240 + repo: agentB.did, 241 + collection: "com.example.test", 242 + rkey: "b-post", 243 + record: { text: "from agent b", createdAt: new Date().toISOString() }, 244 + }), 245 + }), 246 + ); 247 + expect(agentBWrite.status).toBe(200); 248 + 249 + const agentARecord = await worker.fetch( 250 + `http://localhost/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(agentA.did)}&collection=com.example.test&rkey=a-post`, 251 + ); 252 + expect(agentARecord.status).toBe(200); 253 + expect(await agentARecord.json()).toMatchObject({ 254 + value: { text: "from agent a" }, 255 + }); 256 + 257 + const agentBRecord = await worker.fetch( 258 + `http://localhost/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(agentB.did)}&collection=com.example.test&rkey=b-post`, 259 + ); 260 + expect(agentBRecord.status).toBe(200); 261 + expect(await agentBRecord.json()).toMatchObject({ 262 + value: { text: "from agent b" }, 263 + }); 264 + 265 + const agentAList = await worker.fetch( 266 + `http://localhost/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(agentA.did)}&collection=com.example.test`, 267 + ); 268 + expect(agentAList.status).toBe(200); 269 + const agentAListBody = await agentAList.json() as { 270 + records: Array<{ uri: string; value: { text: string } }>; 271 + }; 272 + expect(agentAListBody.records).toHaveLength(1); 273 + expect(agentAListBody.records[0]).toMatchObject({ 274 + uri: `at://${agentA.did}/com.example.test/a-post`, 275 + value: { text: "from agent a" }, 276 + }); 277 + 278 + const agentBList = await worker.fetch( 279 + `http://localhost/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(agentB.did)}&collection=com.example.test`, 280 + ); 281 + expect(agentBList.status).toBe(200); 282 + const agentBListBody = await agentBList.json() as { 283 + records: Array<{ uri: string; value: { text: string } }>; 284 + }; 285 + expect(agentBListBody.records).toHaveLength(1); 286 + expect(agentBListBody.records[0]).toMatchObject({ 287 + uri: `at://${agentB.did}/com.example.test/b-post`, 288 + value: { text: "from agent b" }, 289 + }); 290 + }); 291 + 292 + it("resolves the signed-up handle back to the same DID", async () => { 293 + const account = await createAccountViaSignup(`did-test-${Date.now().toString(36)}`); 294 + expect(account.did.startsWith("did:plc:")).toBe(true); 295 + 296 + const response = await worker.fetch( 297 + `http://localhost/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(account.handle)}`, 298 + ); 299 + expect(response.status).toBe(200); 300 + expect(await response.json()).toEqual({ did: account.did }); 301 + }); 302 + 303 + it("covers enrollment edge cases", async () => { 304 + const duplicateHandle = `duphandle-${Date.now().toString(36)}`; 305 + await createAccountViaSignup(duplicateHandle); 306 + 307 + const originalFetch = globalThis.fetch.bind(globalThis); 308 + const fetchSpy = vi 309 + .spyOn(globalThis, "fetch") 310 + .mockImplementation(async (input, init) => { 311 + const url = 312 + typeof input === "string" ? input : input instanceof Request ? input.url : input.url; 313 + if (url.startsWith("https://plc.directory/")) { 314 + return new Response(null, { status: 200 }); 315 + } 316 + return originalFetch(input as RequestInfo | URL, init); 317 + }); 318 + 319 + try { 320 + const duplicateHandleResponse = await worker.fetch( 321 + new Request("http://localhost/api/signup", { 322 + method: "POST", 323 + headers: { "Content-Type": "application/json" }, 324 + body: JSON.stringify({ handle: duplicateHandle }), 325 + }), 326 + ); 327 + expect(duplicateHandleResponse.status).not.toBe(200); 328 + } finally { 329 + fetchSpy.mockRestore(); 330 + } 331 + 332 + const duplicateThumbprint = await generateAuthKeys(); 333 + await createAccountViaSignup( 334 + `dup-thumb-a-${Date.now().toString(36)}`, 335 + duplicateThumbprint.thumbprint, 336 + ); 337 + const dupThumbAccount = await createAccountViaSignup( 338 + `dup-thumb-b-${Date.now().toString(36)}`, 339 + duplicateThumbprint.thumbprint, 340 + ); 341 + expect(dupThumbAccount.did.startsWith("did:plc:")).toBe(true); 342 + 343 + const missingAuthResponse = await worker.fetch( 344 + new Request("http://localhost/xrpc/com.atproto.repo.createRecord", { 345 + method: "POST", 346 + headers: { "content-type": "application/json" }, 347 + body: JSON.stringify({ 348 + repo: "did:plc:missing-auth", 349 + collection: "com.example.test", 350 + rkey: "missing-auth", 351 + record: { text: "missing auth", createdAt: new Date().toISOString() }, 352 + }), 353 + }), 354 + ); 355 + expect(missingAuthResponse.status).toBe(401); 356 + 357 + const invalidDpopResponse = await worker.fetch( 358 + new Request("http://localhost/xrpc/com.atproto.repo.createRecord", { 359 + method: "POST", 360 + headers: { 361 + authorization: "DPoP fake-token", 362 + dpop: "not-a-jwt", 363 + "content-type": "application/json", 364 + }, 365 + body: JSON.stringify({ 366 + repo: "did:plc:invalid-dpop", 367 + collection: "com.example.test", 368 + rkey: "invalid-dpop", 369 + record: { text: "invalid", createdAt: new Date().toISOString() }, 370 + }), 371 + }), 372 + ); 373 + expect(invalidDpopResponse.status).toBe(401); 374 + }); 375 + 376 + it("round-trips blobs through uploadBlob and getBlob", async () => { 377 + const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 378 + const { did } = await createAccountViaSignup( 379 + `blob-${Date.now().toString(36)}`, 380 + thumbprint, 381 + ); 382 + 383 + const accessToken = "blob-token"; 384 + const uploadUrl = "http://localhost/xrpc/com.atproto.repo.uploadBlob"; 385 + const bytes = new TextEncoder().encode("test blob data"); 386 + const uploadResponse = await worker.fetch( 387 + new Request(uploadUrl, { 388 + method: "POST", 389 + headers: { 390 + authorization: `DPoP ${accessToken}`, 391 + dpop: await createDpopJwt(authKeys, publicJwk, uploadUrl, accessToken), 392 + "content-type": "application/octet-stream", 393 + "content-length": String(bytes.byteLength), 394 + }, 395 + body: bytes, 396 + }), 397 + ); 398 + expect(uploadResponse.status).toBe(200); 399 + const uploadBody = await uploadResponse.json() as { 400 + blob: { ref: { $link: string } }; 401 + }; 402 + const cid = uploadBody.blob.ref.$link; 403 + 404 + const downloadResponse = await worker.fetch( 405 + `http://localhost/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`, 406 + ); 407 + expect(downloadResponse.status).toBe(200); 408 + expect(new Uint8Array(await downloadResponse.arrayBuffer())).toEqual(bytes); 409 + }); 410 + 411 + it("accepts lexicon-agnostic collection NSIDs", async () => { 412 + const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 413 + const { did } = await createAccountViaSignup( 414 + `lexicon-${Date.now().toString(36)}`, 415 + thumbprint, 416 + ); 417 + const accessToken = "lexicon-token"; 418 + const createUrl = "http://localhost/xrpc/com.atproto.repo.createRecord"; 419 + 420 + for (const collection of [ 421 + "com.example.foo.bar", 422 + "app.bsky.feed.post", 423 + "xyz.custom.thing", 424 + "io.github.myapp.status", 425 + ]) { 426 + const response = await worker.fetch( 427 + new Request(createUrl, { 428 + method: "POST", 429 + headers: { 430 + authorization: `DPoP ${accessToken}`, 431 + dpop: await createDpopJwt(authKeys, publicJwk, createUrl, accessToken), 432 + "content-type": "application/json", 433 + }, 434 + body: JSON.stringify({ 435 + repo: did, 436 + collection, 437 + rkey: `${collection.split(".").pop()}-${Date.now().toString(36)}`, 438 + record: { text: collection, createdAt: new Date().toISOString() }, 439 + }), 440 + }), 441 + ); 442 + expect(response.status).toBe(200); 443 + } 444 + }); 445 + });
+6
wrangler.toml
··· 20 20 [[d1_databases]] 21 21 binding = "DIRECTORY" 22 22 database_name = "rookery-directory" 23 + # Placeholder until the real production D1 database is created and wired up. 23 24 database_id = "placeholder-create-with-wrangler-d1-create" 24 25 25 26 [[r2_buckets]] 26 27 binding = "BLOBS" 27 28 bucket_name = "rookery-blobs" 29 + 30 + [vars] 31 + ROOKERY_HOSTNAME = "pds.solpbc.org" 32 + ROOKERY_HANDLE_DOMAIN = ".pds.solpbc.org" 33 + ROOKERY_PLC_URL = "https://plc.directory"