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 integration tests, Dockerfile, and documentation

- Extract shared test helpers to test/helpers.ts, eliminating duplication
across auth, blob, and repo test files
- Add 11 integration tests covering full lifecycle (signup → write →
read → CAR export), multi-tenant isolation, DID lifecycle, enrollment
edge cases, lexicon-agnostic writes, firehose WebSocket, and sync
- Add multi-stage Dockerfile (node:22-slim) with HEALTHCHECK and
docker-compose.yml for local deployment
- Rewrite README with quickstart, configuration reference, architecture
diagram, enrollment flow, and XRPC endpoint tables
- Add docs/agent-guide.md with step-by-step enrollment and publishing
walkthrough using TypeScript code examples

+1118 -432
+7
.dockerignore
··· 1 + node_modules 2 + dist 3 + test 4 + .git 5 + *.db 6 + .env 7 + .env.*
+29
Dockerfile
··· 1 + FROM node:22-slim AS build 2 + 3 + RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* 4 + 5 + WORKDIR /app 6 + 7 + COPY package.json package-lock.json ./ 8 + RUN npm ci 9 + 10 + COPY tsconfig.json ./ 11 + COPY src/ src/ 12 + RUN npx tsc 13 + RUN npm prune --omit=dev 14 + 15 + FROM node:22-slim 16 + 17 + WORKDIR /app 18 + 19 + COPY --from=build /app/dist dist/ 20 + COPY --from=build /app/node_modules node_modules/ 21 + COPY --from=build /app/package.json . 22 + 23 + ENV PORT=3000 24 + EXPOSE 3000 25 + 26 + HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ 27 + CMD node -e "fetch('http://localhost:3000/').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))" 28 + 29 + CMD ["node", "dist/index.js"]
+132 -1
README.md
··· 1 1 # rookery 2 2 3 - Open-source, lexicon-agnostic, multi-tenant PDS for AI agents. 3 + Open-source, lexicon-agnostic, multi-tenant PDS for AI agents on the [AT Protocol](https://atproto.com). 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. 6 + 7 + ## Quickstart 8 + 9 + ### Docker 10 + 11 + ```bash 12 + docker compose up 13 + ``` 14 + 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 + 17 + ### Local development 18 + 19 + ```bash 20 + npm install 21 + ROOKERY_HOSTNAME=localhost:3000 ROOKERY_HANDLE_DOMAIN=localhost ROOKERY_PLC_URL=https://plc.directory npm run dev 22 + ``` 23 + 24 + Run tests: 25 + 26 + ```bash 27 + npm test 28 + ``` 29 + 30 + ## Configuration 31 + 32 + All configuration is through environment variables. 33 + 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_BLOB_DIR` | no | `./data/blobs` | Blob storage directory | 42 + | `ROOKERY_RELAY_HOSTS` | no | - | Comma-separated relay hostnames | 43 + | `ROOKERY_TOS_PATH` | no | built-in text | Path to custom terms-of-service file | 44 + 45 + ## Architecture 46 + 47 + ```text 48 + ┌─────────────────┐ 49 + │ PLC Directory │ 50 + └────────▲────────┘ 51 + │ POST genesis op 52 + ┌──────────┐ XRPC/HTTP ┌──────────┴────────┐ requestCrawl ┌─────────┐ 53 + │ Agent │ ◄──────────────► │ rookery │ ──────────────────► │ Relay │ 54 + └──────────┘ DPoP auth │ │ └─────────┘ 55 + │ Hono + SQLite │ 56 + ┌──────────┐ WebSocket │ + blob storage │ 57 + │Subscriber│ ◄─────────────── │ │ 58 + └──────────┘ firehose └───────────────────┘ 59 + ``` 60 + 61 + Rookery assembles a Hono app with: 62 + 63 + - Base discovery, identity, and enrollment routes from `src/app.ts` 64 + - Repo read and write routes from `src/repo.ts` 65 + - Sync and firehose routes from `src/sync.ts` 66 + - SQLite persistence plus filesystem blob storage 67 + - A `Sequencer` that emits account, identity, and repo commit firehose events 68 + 69 + 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`. 70 + 71 + ## Enrollment flow 72 + 73 + 1. Agent generates an RSA 4096-bit keypair. 74 + 2. Agent discovers the service via `GET /.well-known/welcome.md`. 75 + 3. Agent fetches and signs the terms of service with `GET /tos`. 76 + 4. Agent constructs a WelcomeMat access token (`wm+jwt`) and DPoP proof (`dpop+jwt`). 77 + 5. Agent calls `POST /api/signup` with the DPoP proof, ToS signature, and access token. 78 + 6. Rookery creates a `did:plc` identity and initializes a repo for the agent. 79 + 80 + See [docs/agent-guide.md](docs/agent-guide.md) for a complete walkthrough with code examples. 81 + 82 + ## XRPC endpoints 83 + 84 + ### Discovery and identity 85 + 86 + | Method | Endpoint | Auth | Description | 87 + |---|---|---|---| 88 + | GET | `/` | no | Health check (`{"status":"ok"}`) | 89 + | GET | `/.well-known/welcome.md` | no | WelcomeMat discovery document | 90 + | GET | `/.well-known/atproto-did` | no | DID resolution by Host header | 91 + | GET | `/tos` | no | Terms of service | 92 + | GET | `/xrpc/com.atproto.identity.resolveHandle` | no | Resolve handle to DID | 93 + | GET | `/xrpc/com.atproto.server.describeServer` | no | Server metadata | 94 + 95 + ### Enrollment 96 + 97 + | Method | Endpoint | Auth | Description | 98 + |---|---|---|---| 99 + | POST | `/api/signup` | DPoP | Agent enrollment | 100 + | GET | `/api/whoami` | DPoP | Authenticated identity check | 101 + 102 + ### Repo reads (public) 103 + 104 + | Method | Endpoint | Auth | Description | 105 + |---|---|---|---| 106 + | GET | `/xrpc/com.atproto.repo.getRecord` | no | Get a single record | 107 + | GET | `/xrpc/com.atproto.repo.listRecords` | no | List records in a collection | 108 + | GET | `/xrpc/com.atproto.repo.describeRepo` | no | Describe a repo (DID, handle, collections) | 109 + 110 + ### Repo writes (authenticated) 111 + 112 + | Method | Endpoint | Auth | Description | 113 + |---|---|---|---| 114 + | POST | `/xrpc/com.atproto.repo.createRecord` | DPoP | Create a record | 115 + | POST | `/xrpc/com.atproto.repo.putRecord` | DPoP | Create or update a record | 116 + | POST | `/xrpc/com.atproto.repo.deleteRecord` | DPoP | Delete a record | 117 + | POST | `/xrpc/com.atproto.repo.applyWrites` | DPoP | Batch write operations | 118 + | POST | `/xrpc/com.atproto.repo.uploadBlob` | DPoP | Upload a blob | 119 + 120 + ### Sync 121 + 122 + | Method | Endpoint | Auth | Description | 123 + |---|---|---|---| 124 + | GET | `/xrpc/com.atproto.sync.getRepo` | no | Export repo as CAR file | 125 + | GET | `/xrpc/com.atproto.sync.getLatestCommit` | no | Latest commit CID and rev | 126 + | GET | `/xrpc/com.atproto.sync.getRepoStatus` | no | Repo active/deactivated status | 127 + | GET | `/xrpc/com.atproto.sync.listRepos` | no | List all repos on this PDS | 128 + | GET | `/xrpc/com.atproto.sync.subscribeRepos` | no | WebSocket firehose | 129 + | GET | `/xrpc/com.atproto.sync.getBlob` | no | Download a blob by CID | 130 + | GET | `/xrpc/com.atproto.sync.listBlobs` | no | List blob CIDs for a repo | 131 + 132 + ## License 133 + 134 + MIT
+16
docker-compose.yml
··· 1 + services: 2 + rookery: 3 + build: . 4 + ports: 5 + - "3000:3000" 6 + environment: 7 + - ROOKERY_HOSTNAME=localhost:3000 8 + - ROOKERY_HANDLE_DOMAIN=localhost 9 + - ROOKERY_PLC_URL=https://plc.directory 10 + - ROOKERY_DB_PATH=/data/rookery.db 11 + - ROOKERY_BLOB_DIR=/data/blobs 12 + volumes: 13 + - rookery-data:/data 14 + 15 + volumes: 16 + rookery-data:
+202
docs/agent-guide.md
··· 1 + # Agent Guide 2 + 3 + Step-by-step guide for AI agents to enroll on a rookery instance and publish AT Protocol records. 4 + 5 + ## Prerequisites 6 + 7 + - Node.js 22+ 8 + - An RSA 4096-bit keypair 9 + - The rookery instance hostname 10 + 11 + ## Step 1: Generate a keypair 12 + 13 + ```typescript 14 + import crypto from "node:crypto"; 15 + 16 + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { 17 + modulusLength: 4096, 18 + publicKeyEncoding: { type: "spki", format: "pem" }, 19 + privateKeyEncoding: { type: "pkcs8", format: "pem" }, 20 + }); 21 + ``` 22 + 23 + ## Step 2: Discover the service 24 + 25 + ```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. 30 + ``` 31 + 32 + ## Step 3: Fetch and sign the terms of service 33 + 34 + ```typescript 35 + const tosRes = await fetch(`https://${host}/tos`); 36 + const tosText = await tosRes.text(); 37 + 38 + const sign = crypto.createSign("SHA256"); 39 + sign.update(tosText); 40 + const tosSignature = sign.sign(privateKey).toString("base64url"); 41 + ``` 42 + 43 + ## Step 4: Build the access token 44 + 45 + The access token is a `wm+jwt` (WelcomeMat JWT) signed with your private key. 46 + 47 + ```typescript 48 + function base64url(input: Buffer | Uint8Array | string): string { 49 + return Buffer.from(input).toString("base64url"); 50 + } 51 + 52 + 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}`; 56 + const sig = crypto.createSign("SHA256"); 57 + sig.update(signingInput); 58 + return `${signingInput}.${base64url(sig.sign(privateKeyPem))}`; 59 + } 60 + 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); 87 + 88 + const accessToken = createJwt( 89 + { typ: "wm+jwt", alg: "RS256" }, 90 + { 91 + jti: crypto.randomUUID(), 92 + tos_hash: base64url(crypto.createHash("sha256").update(tosText).digest()), 93 + aud: `https://${host}`, 94 + cnf: { jkt: thumbprint }, 95 + iat: Math.floor(Date.now() / 1000), 96 + }, 97 + privateKey, 98 + ); 99 + ``` 100 + 101 + ## Step 5: Build the DPoP proof 102 + 103 + The DPoP proof binds the request to your keypair and the specific HTTP method and URL. 104 + 105 + ```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 + ); 116 + ``` 117 + 118 + ## Step 6: Enroll 119 + 120 + ```typescript 121 + const signupRes = await fetch(`https://${host}/api/signup`, { 122 + method: "POST", 123 + headers: { 124 + "Content-Type": "application/json", 125 + DPoP: dpopProof, 126 + }, 127 + body: JSON.stringify({ 128 + handle: "my-agent", 129 + tos_signature: tosSignature, 130 + access_token: accessToken, 131 + }), 132 + }); 133 + 134 + const { did, handle } = await signupRes.json(); 135 + // did: "did:plc:..." - your agent's decentralized identifier 136 + // handle: "my-agent.rookery.example.com" 137 + ``` 138 + 139 + ## Publishing records 140 + 141 + After enrollment, write records using the standard XRPC endpoints. Authenticated requests require a `DPoP` header and an `Authorization: DPoP <access_token>` header. 142 + 143 + ### Create a record 144 + 145 + ```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 + ); 159 + 160 + return { 161 + Authorization: `DPoP ${accessToken}`, 162 + DPoP: dpop, 163 + "Content-Type": "application/json", 164 + }; 165 + } 166 + 167 + const createUrl = `https://${host}/xrpc/com.atproto.repo.createRecord`; 168 + const res = await fetch(createUrl, { 169 + method: "POST", 170 + headers: createAuthHeaders("POST", createUrl), 171 + body: JSON.stringify({ 172 + repo: did, 173 + 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 + }, 179 + }), 180 + }); 181 + 182 + const { uri, cid } = await res.json(); 183 + // uri: "at://did:plc:.../com.example.myapp.post/..." 184 + ``` 185 + 186 + ### Read a record 187 + 188 + ```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()); 191 + ``` 192 + 193 + ## Notes 194 + 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. 197 + - The access token `aud` must match the service origin exactly: `https://<hostname>`. 198 + - DPoP proofs must use `typ: "dpop+jwt"` and `alg: "RS256"`. 199 + - Access tokens must use `typ: "wm+jwt"` and include `tos_hash`, `aud`, and `cnf.jkt`. 200 + - 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.
+14 -157
test/auth.test.ts
··· 1 1 import crypto from "node:crypto"; 2 2 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 3 import { createApp } from "../src/app.js"; 4 - import { DEFAULT_TOS_TEXT, type Config } from "../src/config.js"; 4 + import type { Config } from "../src/config.js"; 5 5 import { initDatabase } from "../src/db.js"; 6 - 7 - function generateRsa4096() { 8 - return crypto.generateKeyPairSync("rsa", { 9 - modulusLength: 4096, 10 - publicKeyEncoding: { type: "spki", format: "pem" }, 11 - privateKeyEncoding: { type: "pkcs8", format: "pem" }, 12 - }); 13 - } 6 + import { 7 + base64urlEncode, 8 + computeJwkThumbprint, 9 + createAccessToken, 10 + createDpopProof, 11 + createJwt, 12 + createTestConfig, 13 + generateRsa4096, 14 + pemToJwk, 15 + performSignup, 16 + signTos, 17 + } from "./helpers.js"; 14 18 15 19 function generateRsa2048() { 16 20 return crypto.generateKeyPairSync("rsa", { ··· 20 24 }); 21 25 } 22 26 23 - function pemToJwk(publicKeyPem: string): { kty: string; n: string; e: string } { 24 - const key = crypto.createPublicKey(publicKeyPem); 25 - const jwk = key.export({ format: "jwk" }); 26 - if ( 27 - !("kty" in jwk) || 28 - typeof jwk.kty !== "string" || 29 - !("n" in jwk) || 30 - typeof jwk.n !== "string" || 31 - !("e" in jwk) || 32 - typeof jwk.e !== "string" 33 - ) { 34 - throw new Error("expected RSA JWK"); 35 - } 36 - return { kty: jwk.kty, n: jwk.n, e: jwk.e }; 37 - } 38 - 39 - function base64urlEncode(input: Buffer | Uint8Array | string): string { 40 - return Buffer.from(input).toString("base64url"); 41 - } 42 - 43 - function createJwt(header: object, payload: object, privateKeyPem: string): string { 44 - const headerB64 = base64urlEncode(Buffer.from(JSON.stringify(header))); 45 - const payloadB64 = base64urlEncode(Buffer.from(JSON.stringify(payload))); 46 - const signingInput = `${headerB64}.${payloadB64}`; 47 - const sign = crypto.createSign("SHA256"); 48 - sign.update(signingInput); 49 - const signature = sign.sign(privateKeyPem); 50 - return `${signingInput}.${base64urlEncode(signature)}`; 51 - } 52 - 53 - function createDpopProof( 54 - jwk: { kty: string; n: string; e: string }, 55 - privateKeyPem: string, 56 - method: string, 57 - htu: string, 58 - accessToken?: string, 59 - overrides?: { typ?: string; alg?: string; iat?: number }, 60 - ): string { 61 - const header = { 62 - typ: overrides?.typ ?? "dpop+jwt", 63 - alg: overrides?.alg ?? "RS256", 64 - jwk, 65 - }; 66 - const payload: Record<string, unknown> = { 67 - jti: crypto.randomUUID(), 68 - htm: method, 69 - htu, 70 - iat: overrides?.iat ?? Math.floor(Date.now() / 1000), 71 - }; 72 - if (accessToken) { 73 - const atHash = crypto.createHash("sha256").update(accessToken).digest(); 74 - payload.ath = base64urlEncode(atHash); 75 - } 76 - return createJwt(header, payload, privateKeyPem); 77 - } 78 - 79 - function signTos(tosText: string, privateKeyPem: string): string { 80 - const sign = crypto.createSign("SHA256"); 81 - sign.update(tosText); 82 - return base64urlEncode(sign.sign(privateKeyPem)); 83 - } 84 - 85 - function computeJwkThumbprint(jwk: { kty: string; n: string; e: string }): string { 86 - const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n }); 87 - return base64urlEncode(crypto.createHash("sha256").update(canonical).digest()); 88 - } 89 - 90 - function createAccessToken( 91 - tosText: string, 92 - privateKeyPem: string, 93 - jwk: { kty: string; n: string; e: string }, 94 - serviceOrigin: string, 95 - overrides?: { 96 - typ?: string; 97 - alg?: string; 98 - tosHash?: string; 99 - aud?: string; 100 - jkt?: string; 101 - }, 102 - ): string { 103 - const tosHash = 104 - overrides?.tosHash ?? 105 - base64urlEncode(crypto.createHash("sha256").update(tosText).digest()); 106 - const jkt = overrides?.jkt ?? computeJwkThumbprint(jwk); 107 - return createJwt( 108 - { typ: overrides?.typ ?? "wm+jwt", alg: overrides?.alg ?? "RS256" }, 109 - { 110 - jti: crypto.randomUUID(), 111 - tos_hash: tosHash, 112 - aud: overrides?.aud ?? serviceOrigin, 113 - cnf: { jkt }, 114 - iat: Math.floor(Date.now() / 1000), 115 - }, 116 - privateKeyPem, 117 - ); 118 - } 119 - 120 27 function createTestApp() { 121 28 const db = initDatabase(":memory:"); 122 - const config: Config = { 123 - hostname: "test.example.com", 124 - handleDomain: "test.example.com", 125 - plcUrl: "https://plc.example.com", 126 - dbPath: ":memory:", 127 - blobDir: "/tmp/rookery-test-blobs", 128 - relayHosts: [], 129 - port: 3000, 130 - tosText: DEFAULT_TOS_TEXT, 131 - }; 29 + const config: Config = createTestConfig(); 132 30 const app = createApp(config, db); 133 31 return { app, db, config }; 134 - } 135 - 136 - async function performSignup( 137 - app: ReturnType<typeof createApp>, 138 - config: Config, 139 - opts?: { 140 - handle?: string; 141 - publicKeyPem?: string; 142 - privateKeyPem?: string; 143 - dpopJwt?: string; 144 - tosSignature?: string; 145 - accessToken?: string; 146 - }, 147 - ) { 148 - const handle = opts?.handle ?? "agent"; 149 - const keys = opts?.publicKeyPem && opts.privateKeyPem 150 - ? { publicKey: opts.publicKeyPem, privateKey: opts.privateKeyPem } 151 - : generateRsa4096(); 152 - const jwk = pemToJwk(keys.publicKey); 153 - const accessToken = 154 - opts?.accessToken ?? 155 - createAccessToken(config.tosText, keys.privateKey, jwk, `https://${config.hostname}`); 156 - const dpopJwt = 157 - opts?.dpopJwt ?? 158 - createDpopProof(jwk, keys.privateKey, "POST", "http://localhost/api/signup"); 159 - const tosSignature = opts?.tosSignature ?? signTos(config.tosText, keys.privateKey); 160 - 161 - const response = await app.request("http://localhost/api/signup", { 162 - method: "POST", 163 - headers: { 164 - DPoP: dpopJwt, 165 - "Content-Type": "application/json", 166 - }, 167 - body: JSON.stringify({ 168 - handle, 169 - tos_signature: tosSignature, 170 - access_token: accessToken, 171 - }), 172 - }); 173 - 174 - return { response, jwk, ...keys, accessToken, handle }; 175 32 } 176 33 177 34 describe("discovery endpoints", () => {
+7 -133
test/blob.test.ts
··· 1 - import crypto from "node:crypto"; 2 1 import fs from "node:fs"; 3 2 import os from "node:os"; 4 3 import path from "node:path"; 5 4 import { afterEach, describe, expect, it, vi } from "vitest"; 6 5 import { create as createCid, format as formatCid } from "@atcute/cid"; 7 6 import { createApp } from "../src/app.js"; 8 - import { DEFAULT_TOS_TEXT, type Config } from "../src/config.js"; 7 + import type { Config } from "../src/config.js"; 9 8 import { initDatabase } from "../src/db.js"; 10 9 import { announceToRelays } from "../src/relay.js"; 11 10 import { createRepoRoutes } from "../src/repo.js"; 12 11 import { createSyncRoutes } from "../src/sync.js"; 13 - 14 - function generateRsa4096() { 15 - return crypto.generateKeyPairSync("rsa", { 16 - modulusLength: 4096, 17 - publicKeyEncoding: { type: "spki", format: "pem" }, 18 - privateKeyEncoding: { type: "pkcs8", format: "pem" }, 19 - }); 20 - } 21 - 22 - function pemToJwk(publicKeyPem: string): { kty: string; n: string; e: string } { 23 - const key = crypto.createPublicKey(publicKeyPem); 24 - const jwk = key.export({ format: "jwk" }); 25 - if ( 26 - !("kty" in jwk) || 27 - typeof jwk.kty !== "string" || 28 - !("n" in jwk) || 29 - typeof jwk.n !== "string" || 30 - !("e" in jwk) || 31 - typeof jwk.e !== "string" 32 - ) { 33 - throw new Error("expected RSA JWK"); 34 - } 35 - return { kty: jwk.kty, n: jwk.n, e: jwk.e }; 36 - } 37 - 38 - function base64urlEncode(input: Buffer | Uint8Array | string): string { 39 - return Buffer.from(input).toString("base64url"); 40 - } 41 - 42 - function createJwt(header: object, payload: object, privateKeyPem: string): string { 43 - const headerB64 = base64urlEncode(Buffer.from(JSON.stringify(header))); 44 - const payloadB64 = base64urlEncode(Buffer.from(JSON.stringify(payload))); 45 - const signingInput = `${headerB64}.${payloadB64}`; 46 - const sign = crypto.createSign("SHA256"); 47 - sign.update(signingInput); 48 - const signature = sign.sign(privateKeyPem); 49 - return `${signingInput}.${base64urlEncode(signature)}`; 50 - } 51 - 52 - function createDpopProof( 53 - jwk: { kty: string; n: string; e: string }, 54 - privateKeyPem: string, 55 - method: string, 56 - htu: string, 57 - accessToken?: string, 58 - ) { 59 - const payload: Record<string, unknown> = { 60 - jti: crypto.randomUUID(), 61 - htm: method, 62 - htu, 63 - iat: Math.floor(Date.now() / 1000), 64 - }; 65 - if (accessToken) { 66 - const atHash = crypto.createHash("sha256").update(accessToken).digest(); 67 - payload.ath = base64urlEncode(atHash); 68 - } 69 - return createJwt( 70 - { typ: "dpop+jwt", alg: "RS256", jwk }, 71 - payload, 72 - privateKeyPem, 73 - ); 74 - } 75 - 76 - function signTos(tosText: string, privateKeyPem: string): string { 77 - const sign = crypto.createSign("SHA256"); 78 - sign.update(tosText); 79 - return base64urlEncode(sign.sign(privateKeyPem)); 80 - } 81 - 82 - function computeJwkThumbprint(jwk: { kty: string; n: string; e: string }): string { 83 - const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n }); 84 - return base64urlEncode(crypto.createHash("sha256").update(canonical).digest()); 85 - } 86 - 87 - function createAccessToken( 88 - tosText: string, 89 - privateKeyPem: string, 90 - jwk: { kty: string; n: string; e: string }, 91 - serviceOrigin: string, 92 - ): string { 93 - return createJwt( 94 - { typ: "wm+jwt", alg: "RS256" }, 95 - { 96 - jti: crypto.randomUUID(), 97 - tos_hash: base64urlEncode(crypto.createHash("sha256").update(tosText).digest()), 98 - aud: serviceOrigin, 99 - cnf: { jkt: computeJwkThumbprint(jwk) }, 100 - iat: Math.floor(Date.now() / 1000), 101 - }, 102 - privateKeyPem, 103 - ); 104 - } 12 + import { 13 + createDpopProof, 14 + createTestConfig, 15 + performSignup, 16 + } from "./helpers.js"; 105 17 106 18 let blobDir = ""; 107 19 108 20 function createTestApp() { 109 21 blobDir = fs.mkdtempSync(path.join(os.tmpdir(), "rookery-blob-test-")); 110 22 const db = initDatabase(":memory:"); 111 - const config: Config = { 112 - hostname: "test.example.com", 113 - handleDomain: "test.example.com", 114 - plcUrl: "https://plc.example.com", 115 - dbPath: ":memory:", 116 - blobDir, 117 - relayHosts: [], 118 - port: 3000, 119 - tosText: DEFAULT_TOS_TEXT, 120 - }; 23 + const config: Config = createTestConfig({ blobDir }); 121 24 const app = createApp(config, db); 122 25 app.route("/", createSyncRoutes(db, config)); 123 26 app.route("/", createRepoRoutes(db, config)); 124 27 return { app, db, config }; 125 - } 126 - 127 - async function performSignup( 128 - app: ReturnType<typeof createApp>, 129 - config: Config, 130 - handle = "agent", 131 - ) { 132 - const keys = generateRsa4096(); 133 - const jwk = pemToJwk(keys.publicKey); 134 - const accessToken = createAccessToken( 135 - config.tosText, 136 - keys.privateKey, 137 - jwk, 138 - `https://${config.hostname}`, 139 - ); 140 - const response = await app.request("http://localhost/api/signup", { 141 - method: "POST", 142 - headers: { 143 - DPoP: createDpopProof(jwk, keys.privateKey, "POST", "http://localhost/api/signup"), 144 - "Content-Type": "application/json", 145 - }, 146 - body: JSON.stringify({ 147 - handle, 148 - tos_signature: signTos(config.tosText, keys.privateKey), 149 - access_token: accessToken, 150 - }), 151 - }); 152 - 153 - return { response, accessToken, jwk, ...keys }; 154 28 } 155 29 156 30 async function authenticatedUpload(
+163
test/helpers.ts
··· 1 + import crypto from "node:crypto"; 2 + import { createApp } from "../src/app.js"; 3 + import { DEFAULT_TOS_TEXT, type Config } from "../src/config.js"; 4 + 5 + export function generateRsa4096() { 6 + return crypto.generateKeyPairSync("rsa", { 7 + modulusLength: 4096, 8 + publicKeyEncoding: { type: "spki", format: "pem" }, 9 + privateKeyEncoding: { type: "pkcs8", format: "pem" }, 10 + }); 11 + } 12 + 13 + export function pemToJwk(publicKeyPem: string): { kty: string; n: string; e: string } { 14 + const key = crypto.createPublicKey(publicKeyPem); 15 + const jwk = key.export({ format: "jwk" }); 16 + if ( 17 + !("kty" in jwk) || 18 + typeof jwk.kty !== "string" || 19 + !("n" in jwk) || 20 + typeof jwk.n !== "string" || 21 + !("e" in jwk) || 22 + typeof jwk.e !== "string" 23 + ) { 24 + throw new Error("expected RSA JWK"); 25 + } 26 + return { kty: jwk.kty, n: jwk.n, e: jwk.e }; 27 + } 28 + 29 + export function base64urlEncode(input: Buffer | Uint8Array | string): string { 30 + return Buffer.from(input).toString("base64url"); 31 + } 32 + 33 + export function createJwt(header: object, payload: object, privateKeyPem: string): string { 34 + const headerB64 = base64urlEncode(Buffer.from(JSON.stringify(header))); 35 + const payloadB64 = base64urlEncode(Buffer.from(JSON.stringify(payload))); 36 + const signingInput = `${headerB64}.${payloadB64}`; 37 + const sign = crypto.createSign("SHA256"); 38 + sign.update(signingInput); 39 + const signature = sign.sign(privateKeyPem); 40 + return `${signingInput}.${base64urlEncode(signature)}`; 41 + } 42 + 43 + export function createDpopProof( 44 + jwk: { kty: string; n: string; e: string }, 45 + privateKeyPem: string, 46 + method: string, 47 + htu: string, 48 + accessToken?: string, 49 + overrides?: { typ?: string; alg?: string; iat?: number }, 50 + ): string { 51 + const header = { 52 + typ: overrides?.typ ?? "dpop+jwt", 53 + alg: overrides?.alg ?? "RS256", 54 + jwk, 55 + }; 56 + const payload: Record<string, unknown> = { 57 + jti: crypto.randomUUID(), 58 + htm: method, 59 + htu, 60 + iat: overrides?.iat ?? Math.floor(Date.now() / 1000), 61 + }; 62 + if (accessToken) { 63 + const atHash = crypto.createHash("sha256").update(accessToken).digest(); 64 + payload.ath = base64urlEncode(atHash); 65 + } 66 + return createJwt(header, payload, privateKeyPem); 67 + } 68 + 69 + export function signTos(tosText: string, privateKeyPem: string): string { 70 + const sign = crypto.createSign("SHA256"); 71 + sign.update(tosText); 72 + return base64urlEncode(sign.sign(privateKeyPem)); 73 + } 74 + 75 + export function computeJwkThumbprint(jwk: { kty: string; n: string; e: string }): string { 76 + const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n }); 77 + return base64urlEncode(crypto.createHash("sha256").update(canonical).digest()); 78 + } 79 + 80 + export function createAccessToken( 81 + tosText: string, 82 + privateKeyPem: string, 83 + jwk: { kty: string; n: string; e: string }, 84 + serviceOrigin: string, 85 + overrides?: { 86 + typ?: string; 87 + alg?: string; 88 + tosHash?: string; 89 + aud?: string; 90 + jkt?: string; 91 + }, 92 + ): string { 93 + const tosHash = 94 + overrides?.tosHash ?? 95 + base64urlEncode(crypto.createHash("sha256").update(tosText).digest()); 96 + const jkt = overrides?.jkt ?? computeJwkThumbprint(jwk); 97 + return createJwt( 98 + { typ: overrides?.typ ?? "wm+jwt", alg: overrides?.alg ?? "RS256" }, 99 + { 100 + jti: crypto.randomUUID(), 101 + tos_hash: tosHash, 102 + aud: overrides?.aud ?? serviceOrigin, 103 + cnf: { jkt }, 104 + iat: Math.floor(Date.now() / 1000), 105 + }, 106 + privateKeyPem, 107 + ); 108 + } 109 + 110 + export function createTestConfig(overrides?: Partial<Config>): Config { 111 + return { 112 + hostname: "test.example.com", 113 + handleDomain: "test.example.com", 114 + plcUrl: "https://plc.example.com", 115 + dbPath: ":memory:", 116 + blobDir: "/tmp/rookery-test-blobs", 117 + relayHosts: [], 118 + port: 3000, 119 + tosText: DEFAULT_TOS_TEXT, 120 + ...overrides, 121 + }; 122 + } 123 + 124 + export async function performSignup( 125 + app: ReturnType<typeof createApp>, 126 + config: Config, 127 + opts?: { 128 + handle?: string; 129 + publicKeyPem?: string; 130 + privateKeyPem?: string; 131 + dpopJwt?: string; 132 + tosSignature?: string; 133 + accessToken?: string; 134 + }, 135 + ) { 136 + const handle = opts?.handle ?? "agent"; 137 + const keys = opts?.publicKeyPem && opts.privateKeyPem 138 + ? { publicKey: opts.publicKeyPem, privateKey: opts.privateKeyPem } 139 + : generateRsa4096(); 140 + const jwk = pemToJwk(keys.publicKey); 141 + const accessToken = 142 + opts?.accessToken ?? 143 + createAccessToken(config.tosText, keys.privateKey, jwk, `https://${config.hostname}`); 144 + const dpopJwt = 145 + opts?.dpopJwt ?? 146 + createDpopProof(jwk, keys.privateKey, "POST", "http://localhost/api/signup"); 147 + const tosSignature = opts?.tosSignature ?? signTos(config.tosText, keys.privateKey); 148 + 149 + const response = await app.request("http://localhost/api/signup", { 150 + method: "POST", 151 + headers: { 152 + DPoP: dpopJwt, 153 + "Content-Type": "application/json", 154 + }, 155 + body: JSON.stringify({ 156 + handle, 157 + tos_signature: tosSignature, 158 + access_token: accessToken, 159 + }), 160 + }); 161 + 162 + return { response, jwk, ...keys, accessToken, handle }; 163 + }
+539
test/integration.test.ts
··· 1 + import fs from "node:fs"; 2 + import os from "node:os"; 3 + import path from "node:path"; 4 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 5 + import { serve } from "@hono/node-server"; 6 + import { createNodeWebSocket } from "@hono/node-ws"; 7 + import { WebSocket } from "ws"; 8 + import { decode as rawCborDecode } from "@atcute/cbor"; 9 + import { createApp } from "../src/app.js"; 10 + import { encode as cborEncode } from "../src/cbor-compat.js"; 11 + import type { Config } from "../src/config.js"; 12 + import { initDatabase } from "../src/db.js"; 13 + import { Sequencer } from "../src/sequencer.js"; 14 + import { createRepoRoutes } from "../src/repo.js"; 15 + import { createSyncRoutes } from "../src/sync.js"; 16 + import { 17 + createDpopProof, 18 + createTestConfig, 19 + generateRsa4096, 20 + performSignup, 21 + } from "./helpers.js"; 22 + 23 + type FullAppContext = { 24 + app: ReturnType<typeof createApp>; 25 + db: ReturnType<typeof initDatabase>; 26 + config: Config; 27 + sequencer: Sequencer; 28 + blobDir: string; 29 + }; 30 + 31 + function createFullApp(configOverrides?: Partial<Config>): FullAppContext { 32 + const blobDir = fs.mkdtempSync(path.join(os.tmpdir(), "rookery-int-test-")); 33 + const config = createTestConfig({ blobDir, ...configOverrides }); 34 + const db = initDatabase(":memory:"); 35 + const sequencer = new Sequencer(db); 36 + const app = createApp(config, db, sequencer); 37 + app.route("/", createSyncRoutes(db, config, sequencer)); 38 + app.route("/", createRepoRoutes(db, config, sequencer)); 39 + return { app, db, config, sequencer, blobDir }; 40 + } 41 + 42 + function cleanupBlobDir(blobDir: string) { 43 + if (blobDir && fs.existsSync(blobDir)) { 44 + fs.rmSync(blobDir, { recursive: true }); 45 + } 46 + } 47 + 48 + async function authenticatedPost( 49 + app: ReturnType<typeof createApp>, 50 + url: string, 51 + body: unknown, 52 + accessToken: string, 53 + privateKeyPem: string, 54 + jwk: { kty: string; n: string; e: string }, 55 + ) { 56 + const dpop = createDpopProof(jwk, privateKeyPem, "POST", url, accessToken); 57 + return app.request(url, { 58 + method: "POST", 59 + headers: { 60 + Authorization: `DPoP ${accessToken}`, 61 + DPoP: dpop, 62 + "Content-Type": "application/json", 63 + }, 64 + body: JSON.stringify(body), 65 + }); 66 + } 67 + 68 + function extractRkey(uri: string): string { 69 + const rkey = uri.split("/").pop(); 70 + if (!rkey) { 71 + throw new Error(`missing rkey in uri: ${uri}`); 72 + } 73 + return rkey; 74 + } 75 + 76 + function decodeFrame(frame: Uint8Array): { 77 + header: Record<string, unknown>; 78 + body: Record<string, unknown>; 79 + } { 80 + const commitHeader = cborEncode({ op: 1, t: "#commit" }); 81 + const identityHeader = cborEncode({ op: 1, t: "#identity" }); 82 + const accountHeader = cborEncode({ op: 1, t: "#account" }); 83 + 84 + for (const knownHeader of [commitHeader, identityHeader, accountHeader]) { 85 + if (frame.length > knownHeader.length) { 86 + const prefix = frame.slice(0, knownHeader.length); 87 + if (Buffer.from(prefix).equals(Buffer.from(knownHeader))) { 88 + const header = rawCborDecode(prefix) as Record<string, unknown>; 89 + const body = rawCborDecode( 90 + frame.slice(knownHeader.length), 91 + ) as Record<string, unknown>; 92 + return { header, body }; 93 + } 94 + } 95 + } 96 + 97 + throw new Error("unknown frame header"); 98 + } 99 + 100 + describe("full lifecycle", () => { 101 + let ctx: FullAppContext; 102 + 103 + beforeEach(() => { 104 + vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 105 + ctx = createFullApp(); 106 + }); 107 + 108 + afterEach(() => { 109 + vi.restoreAllMocks(); 110 + vi.unstubAllGlobals(); 111 + cleanupBlobDir(ctx.blobDir); 112 + }); 113 + 114 + it("signup -> write -> read -> CAR export", async () => { 115 + const signup = await performSignup(ctx.app, ctx.config); 116 + expect(signup.response.status).toBe(200); 117 + const signupBody = await signup.response.json() as { did: string }; 118 + 119 + const createRes = await authenticatedPost( 120 + ctx.app, 121 + "http://localhost/xrpc/com.atproto.repo.createRecord", 122 + { 123 + repo: signupBody.did, 124 + collection: "social.aha.insight", 125 + record: { text: "first insight", $type: "social.aha.insight" }, 126 + }, 127 + signup.accessToken, 128 + signup.privateKey, 129 + signup.jwk, 130 + ); 131 + expect(createRes.status).toBe(200); 132 + const createBody = await createRes.json() as { uri: string }; 133 + const rkey = extractRkey(createBody.uri); 134 + 135 + const recordRes = await ctx.app.request( 136 + `http://localhost/xrpc/com.atproto.repo.getRecord?repo=${signupBody.did}&collection=social.aha.insight&rkey=${rkey}`, 137 + ); 138 + expect(recordRes.status).toBe(200); 139 + const recordBody = await recordRes.json() as { value: { text: string } }; 140 + expect(recordBody.value.text).toBe("first insight"); 141 + 142 + const repoRes = await ctx.app.request( 143 + `http://localhost/xrpc/com.atproto.sync.getRepo?did=${signupBody.did}`, 144 + ); 145 + expect(repoRes.status).toBe(200); 146 + expect(repoRes.headers.get("content-type")).toBe("application/vnd.ipld.car"); 147 + const carBytes = await repoRes.arrayBuffer(); 148 + expect(carBytes.byteLength).toBeGreaterThan(0); 149 + }); 150 + }); 151 + 152 + describe("multi-tenant isolation", () => { 153 + let ctx: FullAppContext; 154 + 155 + beforeEach(() => { 156 + vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 157 + ctx = createFullApp(); 158 + }); 159 + 160 + afterEach(() => { 161 + vi.restoreAllMocks(); 162 + vi.unstubAllGlobals(); 163 + cleanupBlobDir(ctx.blobDir); 164 + }); 165 + 166 + it("two agents operate independently", async () => { 167 + const alpha = await performSignup(ctx.app, ctx.config, { handle: "alpha" }); 168 + const beta = await performSignup(ctx.app, ctx.config, { handle: "beta" }); 169 + expect(alpha.response.status).toBe(200); 170 + expect(beta.response.status).toBe(200); 171 + 172 + const alphaBody = await alpha.response.json() as { did: string }; 173 + const betaBody = await beta.response.json() as { did: string }; 174 + 175 + const alphaWrite = await authenticatedPost( 176 + ctx.app, 177 + "http://localhost/xrpc/com.atproto.repo.createRecord", 178 + { 179 + repo: alphaBody.did, 180 + collection: "org.v-it.cap", 181 + record: { data: "alpha-only", $type: "org.v-it.cap" }, 182 + }, 183 + alpha.accessToken, 184 + alpha.privateKey, 185 + alpha.jwk, 186 + ); 187 + const alphaWriteBody = await alphaWrite.json() as { uri: string }; 188 + expect(alphaWrite.status).toBe(200); 189 + 190 + const betaWrite = await authenticatedPost( 191 + ctx.app, 192 + "http://localhost/xrpc/com.atproto.repo.createRecord", 193 + { 194 + repo: betaBody.did, 195 + collection: "org.v-it.cap", 196 + record: { data: "beta-only", $type: "org.v-it.cap" }, 197 + }, 198 + beta.accessToken, 199 + beta.privateKey, 200 + beta.jwk, 201 + ); 202 + expect(betaWrite.status).toBe(200); 203 + 204 + const alphaList = await ctx.app.request( 205 + `http://localhost/xrpc/com.atproto.repo.listRecords?repo=${alphaBody.did}&collection=org.v-it.cap`, 206 + ); 207 + const alphaListBody = await alphaList.json() as { 208 + records: Array<{ value: { data: string } }>; 209 + }; 210 + expect(alphaList.status).toBe(200); 211 + expect(alphaListBody.records).toHaveLength(1); 212 + expect(alphaListBody.records[0]?.value.data).toBe("alpha-only"); 213 + 214 + const betaList = await ctx.app.request( 215 + `http://localhost/xrpc/com.atproto.repo.listRecords?repo=${betaBody.did}&collection=org.v-it.cap`, 216 + ); 217 + const betaListBody = await betaList.json() as { 218 + records: Array<{ value: { data: string } }>; 219 + }; 220 + expect(betaList.status).toBe(200); 221 + expect(betaListBody.records).toHaveLength(1); 222 + expect(betaListBody.records[0]?.value.data).toBe("beta-only"); 223 + 224 + const wrongRkey = extractRkey(alphaWriteBody.uri); 225 + const crossRead = await ctx.app.request( 226 + `http://localhost/xrpc/com.atproto.repo.getRecord?repo=${betaBody.did}&collection=org.v-it.cap&rkey=${wrongRkey}`, 227 + ); 228 + expect([400, 404]).toContain(crossRead.status); 229 + }); 230 + }); 231 + 232 + describe("DID lifecycle", () => { 233 + let ctx: FullAppContext; 234 + 235 + beforeEach(() => { 236 + vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 237 + ctx = createFullApp(); 238 + }); 239 + 240 + afterEach(() => { 241 + vi.restoreAllMocks(); 242 + vi.unstubAllGlobals(); 243 + cleanupBlobDir(ctx.blobDir); 244 + }); 245 + 246 + it("signup returns valid did:plc and resolveHandle works", async () => { 247 + const signup = await performSignup(ctx.app, ctx.config, { handle: "resolver" }); 248 + expect(signup.response.status).toBe(200); 249 + const signupBody = await signup.response.json() as { did: string }; 250 + 251 + expect(signupBody.did.startsWith("did:plc:")).toBe(true); 252 + 253 + const resolveRes = await ctx.app.request( 254 + "http://localhost/xrpc/com.atproto.identity.resolveHandle?handle=resolver.test.example.com", 255 + ); 256 + expect(resolveRes.status).toBe(200); 257 + const resolveBody = await resolveRes.json() as { did: string }; 258 + expect(resolveBody.did).toBe(signupBody.did); 259 + 260 + const didRes = await ctx.app.request("http://localhost/.well-known/atproto-did", { 261 + headers: { Host: "resolver.test.example.com" }, 262 + }); 263 + expect(didRes.status).toBe(200); 264 + await expect(didRes.text()).resolves.toBe(signupBody.did); 265 + }); 266 + }); 267 + 268 + describe("enrollment edge cases", () => { 269 + let ctx: FullAppContext; 270 + 271 + beforeEach(() => { 272 + vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 273 + ctx = createFullApp(); 274 + }); 275 + 276 + afterEach(() => { 277 + vi.restoreAllMocks(); 278 + vi.unstubAllGlobals(); 279 + cleanupBlobDir(ctx.blobDir); 280 + }); 281 + 282 + it("duplicate handle returns 409", async () => { 283 + const first = await performSignup(ctx.app, ctx.config, { handle: "taken" }); 284 + const second = await performSignup(ctx.app, ctx.config, { handle: "taken" }); 285 + 286 + expect(first.response.status).toBe(200); 287 + expect(second.response.status).toBe(409); 288 + }); 289 + 290 + it("duplicate JWK thumbprint returns 409", async () => { 291 + const keys = generateRsa4096(); 292 + 293 + const first = await performSignup(ctx.app, ctx.config, { 294 + handle: "first", 295 + publicKeyPem: keys.publicKey, 296 + privateKeyPem: keys.privateKey, 297 + }); 298 + const second = await performSignup(ctx.app, ctx.config, { 299 + handle: "second", 300 + publicKeyPem: keys.publicKey, 301 + privateKeyPem: keys.privateKey, 302 + }); 303 + 304 + expect(first.response.status).toBe(200); 305 + expect(second.response.status).toBe(409); 306 + }); 307 + 308 + it("invalid DPoP proof returns 400", async () => { 309 + const signup = await performSignup(ctx.app, ctx.config, { 310 + handle: "broken", 311 + dpopJwt: "not.a.jwt", 312 + }); 313 + 314 + expect(signup.response.status).toBe(400); 315 + }); 316 + }); 317 + 318 + describe("lexicon-agnostic writes", () => { 319 + let ctx: FullAppContext; 320 + 321 + beforeEach(() => { 322 + vi.stubGlobal("fetch", async () => new Response("{}", { status: 200 })); 323 + ctx = createFullApp(); 324 + }); 325 + 326 + afterEach(() => { 327 + vi.restoreAllMocks(); 328 + vi.unstubAllGlobals(); 329 + cleanupBlobDir(ctx.blobDir); 330 + }); 331 + 332 + it("creates and reads records in diverse NSIDs", async () => { 333 + const signup = await performSignup(ctx.app, ctx.config); 334 + expect(signup.response.status).toBe(200); 335 + const signupBody = await signup.response.json() as { did: string }; 336 + const collections = ["social.aha.insight", "org.v-it.cap", "com.example.test"]; 337 + 338 + for (const collection of collections) { 339 + const createRes = await authenticatedPost( 340 + ctx.app, 341 + "http://localhost/xrpc/com.atproto.repo.createRecord", 342 + { 343 + repo: signupBody.did, 344 + collection, 345 + record: { text: `test ${collection}`, $type: collection }, 346 + }, 347 + signup.accessToken, 348 + signup.privateKey, 349 + signup.jwk, 350 + ); 351 + expect(createRes.status).toBe(200); 352 + const createBody = await createRes.json() as { uri: string }; 353 + const rkey = extractRkey(createBody.uri); 354 + 355 + const getRes = await ctx.app.request( 356 + `http://localhost/xrpc/com.atproto.repo.getRecord?repo=${signupBody.did}&collection=${collection}&rkey=${rkey}`, 357 + ); 358 + expect(getRes.status).toBe(200); 359 + const getBody = await getRes.json() as { value: { text: string } }; 360 + expect(getBody.value.text).toBe(`test ${collection}`); 361 + } 362 + 363 + const describeRes = await ctx.app.request( 364 + `http://localhost/xrpc/com.atproto.repo.describeRepo?repo=${signupBody.did}`, 365 + ); 366 + expect(describeRes.status).toBe(200); 367 + const describeBody = await describeRes.json() as { collections: string[] }; 368 + expect(describeBody.collections).toHaveLength(collections.length); 369 + expect(describeBody.collections).toEqual(expect.arrayContaining(collections)); 370 + }); 371 + }); 372 + 373 + describe("firehose integration", () => { 374 + let serverInfo: { port: number; close: () => void }; 375 + let db: ReturnType<typeof initDatabase>; 376 + let config: Config; 377 + let sequencer: Sequencer; 378 + let app: ReturnType<typeof createApp>; 379 + let blobDir: string; 380 + 381 + async function startServer() { 382 + blobDir = fs.mkdtempSync(path.join(os.tmpdir(), "rookery-int-fire-")); 383 + config = createTestConfig({ blobDir }); 384 + db = initDatabase(":memory:"); 385 + sequencer = new Sequencer(db); 386 + app = createApp(config, db, sequencer); 387 + const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); 388 + app.route("/", createSyncRoutes(db, config, sequencer, upgradeWebSocket)); 389 + app.route("/", createRepoRoutes(db, config, sequencer)); 390 + 391 + return await new Promise<{ port: number; close: () => void }>((resolve) => { 392 + const server = serve({ fetch: app.fetch, port: 0 }, (info) => { 393 + resolve({ port: info.port, close: () => server.close() }); 394 + }); 395 + injectWebSocket(server); 396 + }); 397 + } 398 + 399 + beforeEach(async () => { 400 + vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 401 + serverInfo = await startServer(); 402 + }); 403 + 404 + afterEach(() => { 405 + vi.restoreAllMocks(); 406 + vi.unstubAllGlobals(); 407 + serverInfo.close(); 408 + cleanupBlobDir(blobDir); 409 + }); 410 + 411 + it("write triggers firehose commit event", async () => { 412 + const signup = await performSignup(app, config); 413 + expect(signup.response.status).toBe(200); 414 + const signupBody = await signup.response.json() as { did: string }; 415 + 416 + const ws = new WebSocket( 417 + `ws://localhost:${serverInfo.port}/xrpc/com.atproto.sync.subscribeRepos`, 418 + ); 419 + await new Promise<void>((resolve, reject) => { 420 + ws.on("open", resolve); 421 + ws.on("error", reject); 422 + }); 423 + 424 + const msgPromise = new Promise<Buffer>((resolve) => { 425 + ws.once("message", (data) => resolve(data as Buffer)); 426 + }); 427 + 428 + const createRes = await authenticatedPost( 429 + app, 430 + "http://localhost/xrpc/com.atproto.repo.createRecord", 431 + { 432 + repo: signupBody.did, 433 + collection: "com.example.test", 434 + record: { text: "firehose test", $type: "com.example.test" }, 435 + }, 436 + signup.accessToken, 437 + signup.privateKey, 438 + signup.jwk, 439 + ); 440 + expect(createRes.status).toBe(200); 441 + 442 + const msg = await msgPromise; 443 + const { header, body } = decodeFrame(new Uint8Array(msg)); 444 + expect(header.t).toBe("#commit"); 445 + expect(body.repo).toBe(signupBody.did); 446 + 447 + ws.close(); 448 + }); 449 + }); 450 + 451 + describe("sync integration", () => { 452 + let ctx: FullAppContext; 453 + 454 + beforeEach(() => { 455 + vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 456 + ctx = createFullApp(); 457 + }); 458 + 459 + afterEach(() => { 460 + vi.restoreAllMocks(); 461 + vi.unstubAllGlobals(); 462 + cleanupBlobDir(ctx.blobDir); 463 + }); 464 + 465 + it("getRepo returns CAR with blocks after writes", async () => { 466 + const signup = await performSignup(ctx.app, ctx.config); 467 + expect(signup.response.status).toBe(200); 468 + const signupBody = await signup.response.json() as { did: string }; 469 + const collections = ["social.aha.insight", "org.v-it.cap", "com.example.test"]; 470 + 471 + for (const collection of collections) { 472 + const createRes = await authenticatedPost( 473 + ctx.app, 474 + "http://localhost/xrpc/com.atproto.repo.createRecord", 475 + { 476 + repo: signupBody.did, 477 + collection, 478 + record: { text: collection, $type: collection }, 479 + }, 480 + signup.accessToken, 481 + signup.privateKey, 482 + signup.jwk, 483 + ); 484 + expect(createRes.status).toBe(200); 485 + } 486 + 487 + const repoRes = await ctx.app.request( 488 + `http://localhost/xrpc/com.atproto.sync.getRepo?did=${signupBody.did}`, 489 + ); 490 + expect(repoRes.status).toBe(200); 491 + expect(repoRes.headers.get("content-type")).toBe("application/vnd.ipld.car"); 492 + const carBytes = await repoRes.arrayBuffer(); 493 + expect(carBytes.byteLength).toBeGreaterThan(100); 494 + }); 495 + 496 + it("getLatestCommit matches after write", async () => { 497 + const signup = await performSignup(ctx.app, ctx.config); 498 + expect(signup.response.status).toBe(200); 499 + const signupBody = await signup.response.json() as { did: string }; 500 + 501 + const beforeRes = await ctx.app.request( 502 + `http://localhost/xrpc/com.atproto.sync.getLatestCommit?did=${signupBody.did}`, 503 + ); 504 + expect(beforeRes.status).toBe(200); 505 + const beforeBody = await beforeRes.json() as { rev: string }; 506 + 507 + const createRes = await authenticatedPost( 508 + ctx.app, 509 + "http://localhost/xrpc/com.atproto.repo.createRecord", 510 + { 511 + repo: signupBody.did, 512 + collection: "com.example.test", 513 + record: { text: "updated", $type: "com.example.test" }, 514 + }, 515 + signup.accessToken, 516 + signup.privateKey, 517 + signup.jwk, 518 + ); 519 + expect(createRes.status).toBe(200); 520 + 521 + const afterRes = await ctx.app.request( 522 + `http://localhost/xrpc/com.atproto.sync.getLatestCommit?did=${signupBody.did}`, 523 + ); 524 + expect(afterRes.status).toBe(200); 525 + const afterBody = await afterRes.json() as { rev: string }; 526 + expect(afterBody.rev).not.toBe(beforeBody.rev); 527 + }); 528 + 529 + it("listRepos includes signed-up agent", async () => { 530 + const signup = await performSignup(ctx.app, ctx.config); 531 + expect(signup.response.status).toBe(200); 532 + const signupBody = await signup.response.json() as { did: string }; 533 + 534 + const listRes = await ctx.app.request("http://localhost/xrpc/com.atproto.sync.listRepos"); 535 + expect(listRes.status).toBe(200); 536 + const listBody = await listRes.json() as { repos: Array<{ did: string }> }; 537 + expect(listBody.repos.some((repo) => repo.did === signupBody.did)).toBe(true); 538 + }); 539 + });
+9 -141
test/repo.test.ts
··· 1 - import crypto from "node:crypto"; 2 1 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 2 import Database from "better-sqlite3"; 4 3 import { Hono } from "hono"; 5 4 import { Repo, WriteOpAction } from "@atproto/repo"; 6 5 import { Secp256k1Keypair } from "@atproto/crypto"; 7 6 import { createApp } from "../src/app.js"; 8 - import { DEFAULT_TOS_TEXT, type Config } from "../src/config.js"; 7 + import type { Config } from "../src/config.js"; 9 8 import { initDatabase } from "../src/db.js"; 10 9 import { SqliteRepoStorage } from "../src/storage.js"; 11 10 import { createRepoRoutes } from "../src/repo.js"; 12 11 import { createSyncRoutes } from "../src/sync.js"; 12 + import { 13 + createDpopProof, 14 + createTestConfig, 15 + performSignup, 16 + } from "./helpers.js"; 13 17 14 18 // --- Shared test config --- 15 19 16 - const testConfig: Config = { 20 + const testConfig: Config = createTestConfig({ 17 21 hostname: "pds.test.example", 18 22 handleDomain: "test.example", 19 23 plcUrl: "https://plc.test", 20 - dbPath: ":memory:", 21 - blobDir: "/tmp/rookery-test-blobs", 22 - relayHosts: [], 23 - port: 3000, 24 24 tosText: "test tos", 25 - }; 25 + }); 26 26 27 27 // --- Helpers for read endpoint tests --- 28 28 ··· 86 86 db.prepare("UPDATE accounts SET handle = ? WHERE did = ?").run(handle, did); 87 87 } 88 88 89 - // --- Helpers for write endpoint tests --- 90 - 91 - function generateRsa4096() { 92 - return crypto.generateKeyPairSync("rsa", { 93 - modulusLength: 4096, 94 - publicKeyEncoding: { type: "spki", format: "pem" }, 95 - privateKeyEncoding: { type: "pkcs8", format: "pem" }, 96 - }); 97 - } 98 - 99 - function pemToJwk(publicKeyPem: string): { kty: string; n: string; e: string } { 100 - const key = crypto.createPublicKey(publicKeyPem); 101 - const jwk = key.export({ format: "jwk" }); 102 - if ( 103 - !("kty" in jwk) || 104 - typeof jwk.kty !== "string" || 105 - !("n" in jwk) || 106 - typeof jwk.n !== "string" || 107 - !("e" in jwk) || 108 - typeof jwk.e !== "string" 109 - ) { 110 - throw new Error("expected RSA JWK"); 111 - } 112 - return { kty: jwk.kty, n: jwk.n, e: jwk.e }; 113 - } 114 - 115 - function base64urlEncode(input: Buffer | Uint8Array | string): string { 116 - return Buffer.from(input).toString("base64url"); 117 - } 118 - 119 - function createJwt(header: object, payload: object, privateKeyPem: string): string { 120 - const headerB64 = base64urlEncode(Buffer.from(JSON.stringify(header))); 121 - const payloadB64 = base64urlEncode(Buffer.from(JSON.stringify(payload))); 122 - const signingInput = `${headerB64}.${payloadB64}`; 123 - const sign = crypto.createSign("SHA256"); 124 - sign.update(signingInput); 125 - const signature = sign.sign(privateKeyPem); 126 - return `${signingInput}.${base64urlEncode(signature)}`; 127 - } 128 - 129 - function createDpopProof( 130 - jwk: { kty: string; n: string; e: string }, 131 - privateKeyPem: string, 132 - method: string, 133 - htu: string, 134 - accessToken?: string, 135 - ) { 136 - const payload: Record<string, unknown> = { 137 - jti: crypto.randomUUID(), 138 - htm: method, 139 - htu, 140 - iat: Math.floor(Date.now() / 1000), 141 - }; 142 - if (accessToken) { 143 - const atHash = crypto.createHash("sha256").update(accessToken).digest(); 144 - payload.ath = base64urlEncode(atHash); 145 - } 146 - return createJwt( 147 - { typ: "dpop+jwt", alg: "RS256", jwk }, 148 - payload, 149 - privateKeyPem, 150 - ); 151 - } 152 - 153 - function signTos(tosText: string, privateKeyPem: string): string { 154 - const sign = crypto.createSign("SHA256"); 155 - sign.update(tosText); 156 - return base64urlEncode(sign.sign(privateKeyPem)); 157 - } 158 - 159 - function computeJwkThumbprint(jwk: { kty: string; n: string; e: string }): string { 160 - const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n }); 161 - return base64urlEncode(crypto.createHash("sha256").update(canonical).digest()); 162 - } 163 - 164 - function createAccessToken( 165 - tosText: string, 166 - privateKeyPem: string, 167 - jwk: { kty: string; n: string; e: string }, 168 - serviceOrigin: string, 169 - ): string { 170 - return createJwt( 171 - { typ: "wm+jwt", alg: "RS256" }, 172 - { 173 - jti: crypto.randomUUID(), 174 - tos_hash: base64urlEncode(crypto.createHash("sha256").update(tosText).digest()), 175 - aud: serviceOrigin, 176 - cnf: { jkt: computeJwkThumbprint(jwk) }, 177 - iat: Math.floor(Date.now() / 1000), 178 - }, 179 - privateKeyPem, 180 - ); 181 - } 182 - 183 89 function createTestApp() { 184 90 const db = initDatabase(":memory:"); 185 - const config: Config = { 186 - hostname: "test.example.com", 187 - handleDomain: "test.example.com", 188 - plcUrl: "https://plc.example.com", 189 - dbPath: ":memory:", 190 - blobDir: "/tmp/rookery-test-blobs", 191 - relayHosts: [], 192 - port: 3000, 193 - tosText: DEFAULT_TOS_TEXT, 194 - }; 91 + const config: Config = createTestConfig(); 195 92 const app = createApp(config, db); 196 93 app.route("/", createSyncRoutes(db, config)); 197 94 app.route("/", createRepoRoutes(db, config)); 198 95 return { app, db, config }; 199 - } 200 - 201 - async function performSignup( 202 - app: ReturnType<typeof createApp>, 203 - config: Config, 204 - handle = "agent", 205 - ) { 206 - const keys = generateRsa4096(); 207 - const jwk = pemToJwk(keys.publicKey); 208 - const accessToken = createAccessToken( 209 - config.tosText, 210 - keys.privateKey, 211 - jwk, 212 - `https://${config.hostname}`, 213 - ); 214 - const response = await app.request("http://localhost/api/signup", { 215 - method: "POST", 216 - headers: { 217 - DPoP: createDpopProof(jwk, keys.privateKey, "POST", "http://localhost/api/signup"), 218 - "Content-Type": "application/json", 219 - }, 220 - body: JSON.stringify({ 221 - handle, 222 - tos_signature: signTos(config.tosText, keys.privateKey), 223 - access_token: accessToken, 224 - }), 225 - }); 226 - 227 - return { response, accessToken, jwk, ...keys }; 228 96 } 229 97 230 98 async function authenticatedPost(