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.

Merge branch 'hopper-74sjx2ef-l4-repo-writes'

# Conflicts:
# src/repo.ts
# test/repo.test.ts

+1046 -7
+2
package-lock.json
··· 15 15 "@atcute/tid": "^1", 16 16 "@atproto/crypto": "^0.4", 17 17 "@atproto/lex-data": "^0.0.14", 18 + "@atproto/lex-json": "^0.0.14", 18 19 "@atproto/repo": "^0.9", 20 + "@atproto/syntax": "^0.5.2", 19 21 "@hono/node-server": "^1", 20 22 "@hono/node-ws": "^1", 21 23 "better-sqlite3": "^11",
+2
package.json
··· 17 17 "@atcute/tid": "^1", 18 18 "@atproto/crypto": "^0.4", 19 19 "@atproto/lex-data": "^0.0.14", 20 + "@atproto/lex-json": "^0.0.14", 20 21 "@atproto/repo": "^0.9", 22 + "@atproto/syntax": "^0.5.2", 21 23 "@hono/node-server": "^1", 22 24 "@hono/node-ws": "^1", 23 25 "better-sqlite3": "^11",
+361 -5
src/repo.ts
··· 1 1 import { Hono, type Context } from "hono"; 2 2 import { CID } from "@atproto/lex-data"; 3 + import { Secp256k1Keypair } from "@atproto/crypto"; 4 + import { 5 + Repo, 6 + WriteOpAction, 7 + formatDataKey, 8 + type RecordCreateOp, 9 + type RecordUpdateOp, 10 + type RecordDeleteOp, 11 + type RecordWriteOp, 12 + } from "@atproto/repo"; 3 13 import { ReadableRepo } from "@atproto/repo/dist/readable-repo.js"; 14 + import type { LexMap } from "@atproto/lex-data"; 15 + import { jsonToLex, type JsonValue } from "@atproto/lex-json"; 16 + import { 17 + ensureValidNsid, 18 + ensureValidRecordKey, 19 + type NsidString, 20 + type RecordKeyString, 21 + } from "@atproto/syntax"; 22 + import { now as tidNow } from "@atcute/tid"; 4 23 import type Database from "better-sqlite3"; 24 + import { createAuthMiddleware, type AuthEnv } from "./auth.js"; 5 25 import type { Config } from "./config.js"; 6 26 import { SqliteRepoStorage } from "./storage.js"; 7 27 ··· 37 57 .get(repo) as AccountRow | undefined; 38 58 } 39 59 40 - export function createRepoRoutes( 41 - db: Database.Database, 42 - config: Config, 43 - ): Hono { 44 - const app = new Hono(); 60 + function isObjectRecord(value: unknown): value is Record<string, unknown> { 61 + return typeof value === "object" && value !== null; 62 + } 63 + 64 + function validateRepoDid( 65 + c: Context, 66 + repoDid: unknown, 67 + accountDid: string, 68 + ): Response | null { 69 + if (typeof repoDid !== "string" || repoDid !== accountDid) { 70 + return xrpcError(c, 400, "InvalidRequest", "repo does not match authenticated DID"); 71 + } 72 + return null; 73 + } 74 + 75 + function validateCollection( 76 + c: Context, 77 + collection: unknown, 78 + ): Response | { collection: NsidString } { 79 + if (typeof collection !== "string") { 80 + return xrpcError(c, 400, "InvalidRequest", "invalid collection"); 81 + } 82 + try { 83 + ensureValidNsid(collection); 84 + } catch (error) { 85 + return xrpcError(c, 400, "InvalidRequest", (error as Error).message); 86 + } 87 + return { collection: collection as NsidString }; 88 + } 89 + 90 + function validateRkey( 91 + c: Context, 92 + rkey: unknown, 93 + required: boolean, 94 + ): Response | { rkey?: RecordKeyString } { 95 + if (rkey === undefined) { 96 + if (required) { 97 + return xrpcError(c, 400, "InvalidRequest", "invalid rkey"); 98 + } 99 + return {}; 100 + } 101 + if (typeof rkey !== "string") { 102 + return xrpcError(c, 400, "InvalidRequest", "invalid rkey"); 103 + } 104 + try { 105 + ensureValidRecordKey(rkey); 106 + } catch (error) { 107 + return xrpcError(c, 400, "InvalidRequest", (error as Error).message); 108 + } 109 + return { rkey: rkey as RecordKeyString }; 110 + } 111 + 112 + async function loadRepoState(db: Database.Database, account: AuthEnv["Variables"]["account"]) { 113 + const storage = new SqliteRepoStorage(db, account.id as number); 114 + const repo = await Repo.load(storage); 115 + const keypair = await Secp256k1Keypair.import(account.signing_key_hex as string); 116 + return { storage, repo, keypair }; 117 + } 118 + 119 + export function createRepoRoutes(db: Database.Database, config: Config): Hono<AuthEnv> { 120 + const app = new Hono<AuthEnv>(); 121 + const authMiddleware = createAuthMiddleware(db, config); 122 + 123 + // --- Public read endpoints --- 45 124 46 125 app.get("/xrpc/com.atproto.repo.getRecord", async (c) => { 47 126 const repo = c.req.query("repo"); ··· 191 270 return c.json({ 192 271 did: `did:web:${config.hostname}`, 193 272 availableUserDomains: [config.handleDomain], 273 + }); 274 + }); 275 + 276 + // --- Authenticated write endpoints --- 277 + 278 + app.post("/xrpc/com.atproto.repo.createRecord", authMiddleware, async (c) => { 279 + let body: Record<string, unknown>; 280 + try { 281 + body = await c.req.json(); 282 + } catch { 283 + return xrpcError(c, 400, "InvalidRequest", "invalid JSON body"); 284 + } 285 + 286 + const account = c.get("account"); 287 + const repoDidError = validateRepoDid(c, body.repo, account.did); 288 + if (repoDidError) return repoDidError; 289 + 290 + const collectionResult = validateCollection(c, body.collection); 291 + if (collectionResult instanceof Response) return collectionResult; 292 + 293 + const rkeyResult = validateRkey(c, body.rkey, false); 294 + if (rkeyResult instanceof Response) return rkeyResult; 295 + 296 + if (!isObjectRecord(body.record)) { 297 + return xrpcError(c, 400, "InvalidRequest", "record is required"); 298 + } 299 + 300 + const actualRkey = rkeyResult.rkey ?? tidNow(); 301 + const { storage, repo, keypair } = await loadRepoState(db, account); 302 + const op: RecordCreateOp = { 303 + action: WriteOpAction.Create, 304 + collection: collectionResult.collection, 305 + rkey: actualRkey, 306 + record: jsonToLex(body.record as JsonValue) as LexMap, 307 + }; 308 + const updatedRepo = await repo.applyWrites([op], keypair); 309 + const recordCid = await updatedRepo.data.get( 310 + formatDataKey(collectionResult.collection, actualRkey), 311 + ); 312 + storage.addCollection(collectionResult.collection); 313 + 314 + return c.json({ 315 + uri: `at://${account.did}/${collectionResult.collection}/${actualRkey}`, 316 + cid: recordCid!.toString(), 317 + commit: { 318 + cid: updatedRepo.cid.toString(), 319 + rev: updatedRepo.commit.rev, 320 + }, 321 + }); 322 + }); 323 + 324 + app.post("/xrpc/com.atproto.repo.putRecord", authMiddleware, async (c) => { 325 + let body: Record<string, unknown>; 326 + try { 327 + body = await c.req.json(); 328 + } catch { 329 + return xrpcError(c, 400, "InvalidRequest", "invalid JSON body"); 330 + } 331 + 332 + const account = c.get("account"); 333 + const repoDidError = validateRepoDid(c, body.repo, account.did); 334 + if (repoDidError) return repoDidError; 335 + 336 + const collectionResult = validateCollection(c, body.collection); 337 + if (collectionResult instanceof Response) return collectionResult; 338 + 339 + const rkeyResult = validateRkey(c, body.rkey, true); 340 + if (rkeyResult instanceof Response) return rkeyResult; 341 + 342 + if (!isObjectRecord(body.record)) { 343 + return xrpcError(c, 400, "InvalidRequest", "record is required"); 344 + } 345 + 346 + const { storage, repo, keypair } = await loadRepoState(db, account); 347 + const existing = await repo.getRecord(collectionResult.collection, rkeyResult.rkey!); 348 + const lexRecord = jsonToLex(body.record as JsonValue) as LexMap; 349 + const op: RecordWriteOp = existing 350 + ? { 351 + action: WriteOpAction.Update, 352 + collection: collectionResult.collection, 353 + rkey: rkeyResult.rkey!, 354 + record: lexRecord, 355 + } 356 + : { 357 + action: WriteOpAction.Create, 358 + collection: collectionResult.collection, 359 + rkey: rkeyResult.rkey!, 360 + record: lexRecord, 361 + }; 362 + const updatedRepo = await repo.applyWrites([op], keypair); 363 + const recordCid = await updatedRepo.data.get( 364 + formatDataKey(collectionResult.collection, rkeyResult.rkey!), 365 + ); 366 + storage.addCollection(collectionResult.collection); 367 + 368 + return c.json({ 369 + uri: `at://${account.did}/${collectionResult.collection}/${rkeyResult.rkey}`, 370 + cid: recordCid!.toString(), 371 + commit: { 372 + cid: updatedRepo.cid.toString(), 373 + rev: updatedRepo.commit.rev, 374 + }, 375 + }); 376 + }); 377 + 378 + app.post("/xrpc/com.atproto.repo.deleteRecord", authMiddleware, async (c) => { 379 + let body: Record<string, unknown>; 380 + try { 381 + body = await c.req.json(); 382 + } catch { 383 + return xrpcError(c, 400, "InvalidRequest", "invalid JSON body"); 384 + } 385 + 386 + const account = c.get("account"); 387 + const repoDidError = validateRepoDid(c, body.repo, account.did); 388 + if (repoDidError) return repoDidError; 389 + 390 + const collectionResult = validateCollection(c, body.collection); 391 + if (collectionResult instanceof Response) return collectionResult; 392 + 393 + const rkeyResult = validateRkey(c, body.rkey, true); 394 + if (rkeyResult instanceof Response) return rkeyResult; 395 + 396 + const { repo, keypair } = await loadRepoState(db, account); 397 + const existing = await repo.getRecord(collectionResult.collection, rkeyResult.rkey!); 398 + if (!existing) { 399 + return c.json({ 400 + commit: { 401 + cid: repo.cid.toString(), 402 + rev: repo.commit.rev, 403 + }, 404 + }); 405 + } 406 + 407 + const op: RecordDeleteOp = { 408 + action: WriteOpAction.Delete, 409 + collection: collectionResult.collection, 410 + rkey: rkeyResult.rkey!, 411 + }; 412 + const updatedRepo = await repo.applyWrites([op], keypair); 413 + return c.json({ 414 + commit: { 415 + cid: updatedRepo.cid.toString(), 416 + rev: updatedRepo.commit.rev, 417 + }, 418 + }); 419 + }); 420 + 421 + app.post("/xrpc/com.atproto.repo.applyWrites", authMiddleware, async (c) => { 422 + let body: Record<string, unknown>; 423 + try { 424 + body = await c.req.json(); 425 + } catch { 426 + return xrpcError(c, 400, "InvalidRequest", "invalid JSON body"); 427 + } 428 + 429 + const account = c.get("account"); 430 + const repoDidError = validateRepoDid(c, body.repo, account.did); 431 + if (repoDidError) return repoDidError; 432 + 433 + if (!Array.isArray(body.writes)) { 434 + return xrpcError(c, 400, "InvalidRequest", "writes must be an array"); 435 + } 436 + if (body.writes.length > 200) { 437 + return xrpcError(c, 400, "InvalidRequest", "too many writes (max 200)"); 438 + } 439 + 440 + const { storage, repo, keypair } = await loadRepoState(db, account); 441 + const ops: RecordWriteOp[] = []; 442 + const collectionsToTrack = new Set<string>(); 443 + const resultMetadata: Array< 444 + | { kind: "create" | "update"; collection: string; rkey: string } 445 + | { kind: "delete" } 446 + > = []; 447 + 448 + for (const write of body.writes) { 449 + if (!isObjectRecord(write) || typeof write.$type !== "string") { 450 + return xrpcError(c, 400, "InvalidRequest", "unknown write type"); 451 + } 452 + 453 + const collectionResult = validateCollection(c, write.collection); 454 + if (collectionResult instanceof Response) return collectionResult; 455 + 456 + if (write.$type === "com.atproto.repo.applyWrites#create") { 457 + const rkeyResult = validateRkey(c, write.rkey, false); 458 + if (rkeyResult instanceof Response) return rkeyResult; 459 + if (!isObjectRecord(write.value)) { 460 + return xrpcError(c, 400, "InvalidRequest", "record is required"); 461 + } 462 + 463 + const actualRkey = rkeyResult.rkey ?? tidNow(); 464 + const op: RecordCreateOp = { 465 + action: WriteOpAction.Create, 466 + collection: collectionResult.collection, 467 + rkey: actualRkey, 468 + record: jsonToLex(write.value as JsonValue) as LexMap, 469 + }; 470 + ops.push(op); 471 + collectionsToTrack.add(collectionResult.collection); 472 + resultMetadata.push({ 473 + kind: "create", 474 + collection: collectionResult.collection, 475 + rkey: actualRkey, 476 + }); 477 + continue; 478 + } 479 + 480 + if (write.$type === "com.atproto.repo.applyWrites#update") { 481 + const rkeyResult = validateRkey(c, write.rkey, true); 482 + if (rkeyResult instanceof Response) return rkeyResult; 483 + if (!isObjectRecord(write.value)) { 484 + return xrpcError(c, 400, "InvalidRequest", "record is required"); 485 + } 486 + 487 + const op: RecordUpdateOp = { 488 + action: WriteOpAction.Update, 489 + collection: collectionResult.collection, 490 + rkey: rkeyResult.rkey!, 491 + record: jsonToLex(write.value as JsonValue) as LexMap, 492 + }; 493 + ops.push(op); 494 + collectionsToTrack.add(collectionResult.collection); 495 + resultMetadata.push({ 496 + kind: "update", 497 + collection: collectionResult.collection, 498 + rkey: rkeyResult.rkey!, 499 + }); 500 + continue; 501 + } 502 + 503 + if (write.$type === "com.atproto.repo.applyWrites#delete") { 504 + const rkeyResult = validateRkey(c, write.rkey, true); 505 + if (rkeyResult instanceof Response) return rkeyResult; 506 + 507 + const existing = await repo.getRecord(collectionResult.collection, rkeyResult.rkey!); 508 + if (existing) { 509 + const op: RecordDeleteOp = { 510 + action: WriteOpAction.Delete, 511 + collection: collectionResult.collection, 512 + rkey: rkeyResult.rkey!, 513 + }; 514 + ops.push(op); 515 + } 516 + resultMetadata.push({ kind: "delete" }); 517 + continue; 518 + } 519 + 520 + return xrpcError(c, 400, "InvalidRequest", `unknown write type: ${write.$type}`); 521 + } 522 + 523 + const finalRepo = ops.length > 0 ? await repo.applyWrites(ops, keypair) : repo; 524 + for (const collection of collectionsToTrack) { 525 + storage.addCollection(collection); 526 + } 527 + 528 + const results = []; 529 + for (const metadata of resultMetadata) { 530 + if (metadata.kind === "delete") { 531 + results.push({}); 532 + continue; 533 + } 534 + 535 + const recordCid = await finalRepo.data.get( 536 + formatDataKey(metadata.collection, metadata.rkey), 537 + ); 538 + results.push({ 539 + uri: `at://${account.did}/${metadata.collection}/${metadata.rkey}`, 540 + cid: recordCid!.toString(), 541 + }); 542 + } 543 + 544 + return c.json({ 545 + commit: { 546 + cid: finalRepo.cid.toString(), 547 + rev: finalRepo.commit.rev, 548 + }, 549 + results, 194 550 }); 195 551 }); 196 552
+681 -2
test/repo.test.ts
··· 1 + import crypto from "node:crypto"; 1 2 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 3 import Database from "better-sqlite3"; 3 4 import { Hono } from "hono"; 4 5 import { Repo, WriteOpAction } from "@atproto/repo"; 5 6 import { Secp256k1Keypair } from "@atproto/crypto"; 7 + import { createApp } from "../src/app.js"; 8 + import { DEFAULT_TOS_TEXT, type Config } from "../src/config.js"; 6 9 import { initDatabase } from "../src/db.js"; 7 10 import { SqliteRepoStorage } from "../src/storage.js"; 8 11 import { createRepoRoutes } from "../src/repo.js"; 9 - import type { Config } from "../src/config.js"; 12 + import { createSyncRoutes } from "../src/sync.js"; 13 + 14 + // --- Shared test config --- 10 15 11 16 const testConfig: Config = { 12 17 hostname: "pds.test.example", ··· 16 21 port: 3000, 17 22 tosText: "test tos", 18 23 }; 24 + 25 + // --- Helpers for read endpoint tests --- 19 26 20 27 function createAccount( 21 28 db: Database.Database, ··· 77 84 db.prepare("UPDATE accounts SET handle = ? WHERE did = ?").run(handle, did); 78 85 } 79 86 80 - describe("createRepoRoutes", () => { 87 + // --- Helpers for write endpoint tests --- 88 + 89 + function generateRsa4096() { 90 + return crypto.generateKeyPairSync("rsa", { 91 + modulusLength: 4096, 92 + publicKeyEncoding: { type: "spki", format: "pem" }, 93 + privateKeyEncoding: { type: "pkcs8", format: "pem" }, 94 + }); 95 + } 96 + 97 + function pemToJwk(publicKeyPem: string): { kty: string; n: string; e: string } { 98 + const key = crypto.createPublicKey(publicKeyPem); 99 + const jwk = key.export({ format: "jwk" }); 100 + if ( 101 + !("kty" in jwk) || 102 + typeof jwk.kty !== "string" || 103 + !("n" in jwk) || 104 + typeof jwk.n !== "string" || 105 + !("e" in jwk) || 106 + typeof jwk.e !== "string" 107 + ) { 108 + throw new Error("expected RSA JWK"); 109 + } 110 + return { kty: jwk.kty, n: jwk.n, e: jwk.e }; 111 + } 112 + 113 + function base64urlEncode(input: Buffer | Uint8Array | string): string { 114 + return Buffer.from(input).toString("base64url"); 115 + } 116 + 117 + function createJwt(header: object, payload: object, privateKeyPem: string): string { 118 + const headerB64 = base64urlEncode(Buffer.from(JSON.stringify(header))); 119 + const payloadB64 = base64urlEncode(Buffer.from(JSON.stringify(payload))); 120 + const signingInput = `${headerB64}.${payloadB64}`; 121 + const sign = crypto.createSign("SHA256"); 122 + sign.update(signingInput); 123 + const signature = sign.sign(privateKeyPem); 124 + return `${signingInput}.${base64urlEncode(signature)}`; 125 + } 126 + 127 + function createDpopProof( 128 + jwk: { kty: string; n: string; e: string }, 129 + privateKeyPem: string, 130 + method: string, 131 + htu: string, 132 + accessToken?: string, 133 + ) { 134 + const payload: Record<string, unknown> = { 135 + jti: crypto.randomUUID(), 136 + htm: method, 137 + htu, 138 + iat: Math.floor(Date.now() / 1000), 139 + }; 140 + if (accessToken) { 141 + const atHash = crypto.createHash("sha256").update(accessToken).digest(); 142 + payload.ath = base64urlEncode(atHash); 143 + } 144 + return createJwt( 145 + { typ: "dpop+jwt", alg: "RS256", jwk }, 146 + payload, 147 + privateKeyPem, 148 + ); 149 + } 150 + 151 + function signTos(tosText: string, privateKeyPem: string): string { 152 + const sign = crypto.createSign("SHA256"); 153 + sign.update(tosText); 154 + return base64urlEncode(sign.sign(privateKeyPem)); 155 + } 156 + 157 + function computeJwkThumbprint(jwk: { kty: string; n: string; e: string }): string { 158 + const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n }); 159 + return base64urlEncode(crypto.createHash("sha256").update(canonical).digest()); 160 + } 161 + 162 + function createAccessToken( 163 + tosText: string, 164 + privateKeyPem: string, 165 + jwk: { kty: string; n: string; e: string }, 166 + serviceOrigin: string, 167 + ): string { 168 + return createJwt( 169 + { typ: "wm+jwt", alg: "RS256" }, 170 + { 171 + jti: crypto.randomUUID(), 172 + tos_hash: base64urlEncode(crypto.createHash("sha256").update(tosText).digest()), 173 + aud: serviceOrigin, 174 + cnf: { jkt: computeJwkThumbprint(jwk) }, 175 + iat: Math.floor(Date.now() / 1000), 176 + }, 177 + privateKeyPem, 178 + ); 179 + } 180 + 181 + function createTestApp() { 182 + const db = initDatabase(":memory:"); 183 + const config: Config = { 184 + hostname: "test.example.com", 185 + handleDomain: "test.example.com", 186 + plcUrl: "https://plc.example.com", 187 + dbPath: ":memory:", 188 + port: 3000, 189 + tosText: DEFAULT_TOS_TEXT, 190 + }; 191 + const app = createApp(config, db); 192 + app.route("/", createSyncRoutes(db)); 193 + app.route("/", createRepoRoutes(db, config)); 194 + return { app, db, config }; 195 + } 196 + 197 + async function performSignup( 198 + app: ReturnType<typeof createApp>, 199 + config: Config, 200 + handle = "agent", 201 + ) { 202 + const keys = generateRsa4096(); 203 + const jwk = pemToJwk(keys.publicKey); 204 + const accessToken = createAccessToken( 205 + config.tosText, 206 + keys.privateKey, 207 + jwk, 208 + `https://${config.hostname}`, 209 + ); 210 + const response = await app.request("http://localhost/api/signup", { 211 + method: "POST", 212 + headers: { 213 + DPoP: createDpopProof(jwk, keys.privateKey, "POST", "http://localhost/api/signup"), 214 + "Content-Type": "application/json", 215 + }, 216 + body: JSON.stringify({ 217 + handle, 218 + tos_signature: signTos(config.tosText, keys.privateKey), 219 + access_token: accessToken, 220 + }), 221 + }); 222 + 223 + return { response, accessToken, jwk, ...keys }; 224 + } 225 + 226 + async function authenticatedPost( 227 + app: ReturnType<typeof createApp>, 228 + url: string, 229 + body: unknown, 230 + accessToken: string, 231 + privateKeyPem: string, 232 + jwk: { kty: string; n: string; e: string }, 233 + ) { 234 + const dpop = createDpopProof(jwk, privateKeyPem, "POST", url, accessToken); 235 + return app.request(url, { 236 + method: "POST", 237 + headers: { 238 + Authorization: `DPoP ${accessToken}`, 239 + DPoP: dpop, 240 + "Content-Type": "application/json", 241 + }, 242 + body: JSON.stringify(body), 243 + }); 244 + } 245 + 246 + // --- Read endpoint tests --- 247 + 248 + describe("repo read routes", () => { 81 249 let db: Database.Database; 82 250 let app: Hono; 83 251 ··· 479 647 }); 480 648 }); 481 649 }); 650 + 651 + // --- Write endpoint tests --- 652 + 653 + describe("repo write routes", () => { 654 + beforeEach(() => { 655 + vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 656 + }); 657 + 658 + afterEach(() => { 659 + vi.restoreAllMocks(); 660 + }); 661 + 662 + it("createRecord with explicit rkey returns uri cid and commit", async () => { 663 + const { app, config } = createTestApp(); 664 + const signup = await performSignup(app, config); 665 + const signupJson = await signup.response.json() as { did: string }; 666 + 667 + const response = await authenticatedPost( 668 + app, 669 + "http://localhost/xrpc/com.atproto.repo.createRecord", 670 + { 671 + repo: signupJson.did, 672 + collection: "app.bsky.actor.profile", 673 + rkey: "self", 674 + record: { displayName: "Agent" }, 675 + }, 676 + signup.accessToken, 677 + signup.privateKey, 678 + signup.jwk, 679 + ); 680 + const body = await response.json() as { 681 + uri: string; 682 + cid: string; 683 + commit: { cid: string; rev: string }; 684 + }; 685 + 686 + expect(response.status).toBe(200); 687 + expect(body.uri).toBe(`at://${signupJson.did}/app.bsky.actor.profile/self`); 688 + expect(typeof body.cid).toBe("string"); 689 + expect(typeof body.commit.cid).toBe("string"); 690 + expect(typeof body.commit.rev).toBe("string"); 691 + }); 692 + 693 + it("createRecord without rkey auto-generates a TID", async () => { 694 + const { app, config } = createTestApp(); 695 + const signup = await performSignup(app, config); 696 + const signupJson = await signup.response.json() as { did: string }; 697 + 698 + const response = await authenticatedPost( 699 + app, 700 + "http://localhost/xrpc/com.atproto.repo.createRecord", 701 + { 702 + repo: signupJson.did, 703 + collection: "app.bsky.feed.post", 704 + record: { text: "hello" }, 705 + }, 706 + signup.accessToken, 707 + signup.privateKey, 708 + signup.jwk, 709 + ); 710 + const body = await response.json() as { uri: string }; 711 + 712 + expect(response.status).toBe(200); 713 + expect(body.uri).toMatch(/^at:\/\/did:plc:[a-z2-7]{24}\/app\.bsky\.feed\.post\/[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/); 714 + }); 715 + 716 + it("putRecord creates when record does not exist", async () => { 717 + const { app, config } = createTestApp(); 718 + const signup = await performSignup(app, config); 719 + const signupJson = await signup.response.json() as { did: string }; 720 + 721 + const response = await authenticatedPost( 722 + app, 723 + "http://localhost/xrpc/com.atproto.repo.putRecord", 724 + { 725 + repo: signupJson.did, 726 + collection: "app.bsky.actor.profile", 727 + rkey: "self", 728 + record: { displayName: "Agent" }, 729 + }, 730 + signup.accessToken, 731 + signup.privateKey, 732 + signup.jwk, 733 + ); 734 + const body = await response.json() as { 735 + uri: string; 736 + cid: string; 737 + commit: { cid: string; rev: string }; 738 + }; 739 + 740 + expect(response.status).toBe(200); 741 + expect(body.uri).toBe(`at://${signupJson.did}/app.bsky.actor.profile/self`); 742 + expect(typeof body.cid).toBe("string"); 743 + expect(typeof body.commit.cid).toBe("string"); 744 + expect(typeof body.commit.rev).toBe("string"); 745 + }); 746 + 747 + it("putRecord updates when record exists", async () => { 748 + const { app, config } = createTestApp(); 749 + const signup = await performSignup(app, config); 750 + const signupJson = await signup.response.json() as { did: string }; 751 + 752 + const created = await authenticatedPost( 753 + app, 754 + "http://localhost/xrpc/com.atproto.repo.putRecord", 755 + { 756 + repo: signupJson.did, 757 + collection: "app.bsky.actor.profile", 758 + rkey: "self", 759 + record: { displayName: "Agent" }, 760 + }, 761 + signup.accessToken, 762 + signup.privateKey, 763 + signup.jwk, 764 + ); 765 + const createdBody = await created.json() as { cid: string }; 766 + 767 + const updated = await authenticatedPost( 768 + app, 769 + "http://localhost/xrpc/com.atproto.repo.putRecord", 770 + { 771 + repo: signupJson.did, 772 + collection: "app.bsky.actor.profile", 773 + rkey: "self", 774 + record: { displayName: "Updated Agent" }, 775 + }, 776 + signup.accessToken, 777 + signup.privateKey, 778 + signup.jwk, 779 + ); 780 + const updatedBody = await updated.json() as { cid: string }; 781 + 782 + expect(updated.status).toBe(200); 783 + expect(updatedBody.cid).not.toBe(createdBody.cid); 784 + }); 785 + 786 + it("deleteRecord removes record and allows recreating the same rkey", async () => { 787 + const { app, config } = createTestApp(); 788 + const signup = await performSignup(app, config); 789 + const signupJson = await signup.response.json() as { did: string }; 790 + 791 + await authenticatedPost( 792 + app, 793 + "http://localhost/xrpc/com.atproto.repo.createRecord", 794 + { 795 + repo: signupJson.did, 796 + collection: "app.bsky.feed.post", 797 + rkey: "self", 798 + record: { text: "first" }, 799 + }, 800 + signup.accessToken, 801 + signup.privateKey, 802 + signup.jwk, 803 + ); 804 + 805 + const deleted = await authenticatedPost( 806 + app, 807 + "http://localhost/xrpc/com.atproto.repo.deleteRecord", 808 + { 809 + repo: signupJson.did, 810 + collection: "app.bsky.feed.post", 811 + rkey: "self", 812 + }, 813 + signup.accessToken, 814 + signup.privateKey, 815 + signup.jwk, 816 + ); 817 + 818 + const recreated = await authenticatedPost( 819 + app, 820 + "http://localhost/xrpc/com.atproto.repo.createRecord", 821 + { 822 + repo: signupJson.did, 823 + collection: "app.bsky.feed.post", 824 + rkey: "self", 825 + record: { text: "second" }, 826 + }, 827 + signup.accessToken, 828 + signup.privateKey, 829 + signup.jwk, 830 + ); 831 + 832 + expect(deleted.status).toBe(200); 833 + expect(recreated.status).toBe(200); 834 + }); 835 + 836 + it("deleteRecord on non-existent record is idempotent", async () => { 837 + const { app, config } = createTestApp(); 838 + const signup = await performSignup(app, config); 839 + const signupJson = await signup.response.json() as { did: string }; 840 + 841 + const response = await authenticatedPost( 842 + app, 843 + "http://localhost/xrpc/com.atproto.repo.deleteRecord", 844 + { 845 + repo: signupJson.did, 846 + collection: "app.bsky.feed.post", 847 + rkey: "missing", 848 + }, 849 + signup.accessToken, 850 + signup.privateKey, 851 + signup.jwk, 852 + ); 853 + const body = await response.json() as { commit: { cid: string; rev: string } }; 854 + 855 + expect(response.status).toBe(200); 856 + expect(typeof body.commit.cid).toBe("string"); 857 + expect(typeof body.commit.rev).toBe("string"); 858 + }); 859 + 860 + it("applyWrites handles mixed ops atomically", async () => { 861 + const { app, config } = createTestApp(); 862 + const signup = await performSignup(app, config); 863 + const signupJson = await signup.response.json() as { did: string }; 864 + 865 + await authenticatedPost( 866 + app, 867 + "http://localhost/xrpc/com.atproto.repo.createRecord", 868 + { 869 + repo: signupJson.did, 870 + collection: "app.bsky.feed.post", 871 + rkey: "update-me", 872 + record: { text: "original" }, 873 + }, 874 + signup.accessToken, 875 + signup.privateKey, 876 + signup.jwk, 877 + ); 878 + await authenticatedPost( 879 + app, 880 + "http://localhost/xrpc/com.atproto.repo.createRecord", 881 + { 882 + repo: signupJson.did, 883 + collection: "app.bsky.feed.post", 884 + rkey: "delete-me", 885 + record: { text: "delete" }, 886 + }, 887 + signup.accessToken, 888 + signup.privateKey, 889 + signup.jwk, 890 + ); 891 + 892 + const response = await authenticatedPost( 893 + app, 894 + "http://localhost/xrpc/com.atproto.repo.applyWrites", 895 + { 896 + repo: signupJson.did, 897 + writes: [ 898 + { 899 + $type: "com.atproto.repo.applyWrites#create", 900 + collection: "app.bsky.feed.post", 901 + rkey: "new-one", 902 + value: { text: "new" }, 903 + }, 904 + { 905 + $type: "com.atproto.repo.applyWrites#update", 906 + collection: "app.bsky.feed.post", 907 + rkey: "update-me", 908 + value: { text: "updated" }, 909 + }, 910 + { 911 + $type: "com.atproto.repo.applyWrites#delete", 912 + collection: "app.bsky.feed.post", 913 + rkey: "delete-me", 914 + }, 915 + ], 916 + }, 917 + signup.accessToken, 918 + signup.privateKey, 919 + signup.jwk, 920 + ); 921 + const body = await response.json() as { 922 + commit: { cid: string; rev: string }; 923 + results: Array<Record<string, string>>; 924 + }; 925 + 926 + expect(response.status).toBe(200); 927 + expect(typeof body.commit.cid).toBe("string"); 928 + expect(typeof body.commit.rev).toBe("string"); 929 + expect(body.results).toHaveLength(3); 930 + expect(body.results[0]).toEqual({ 931 + uri: `at://${signupJson.did}/app.bsky.feed.post/new-one`, 932 + cid: expect.any(String), 933 + }); 934 + expect(body.results[1]).toEqual({ 935 + uri: `at://${signupJson.did}/app.bsky.feed.post/update-me`, 936 + cid: expect.any(String), 937 + }); 938 + expect(body.results[2]).toEqual({}); 939 + }); 940 + 941 + it("applyWrites with more than 200 ops returns 400", async () => { 942 + const { app, config } = createTestApp(); 943 + const signup = await performSignup(app, config); 944 + const signupJson = await signup.response.json() as { did: string }; 945 + const writes = Array.from({ length: 201 }, (_, i) => ({ 946 + $type: "com.atproto.repo.applyWrites#create", 947 + collection: "app.bsky.feed.post", 948 + rkey: `r${i}`, 949 + value: { text: `post-${i}` }, 950 + })); 951 + 952 + const response = await authenticatedPost( 953 + app, 954 + "http://localhost/xrpc/com.atproto.repo.applyWrites", 955 + { repo: signupJson.did, writes }, 956 + signup.accessToken, 957 + signup.privateKey, 958 + signup.jwk, 959 + ); 960 + const body = await response.json(); 961 + 962 + expect(response.status).toBe(400); 963 + expect(body).toEqual({ 964 + error: "InvalidRequest", 965 + message: "too many writes (max 200)", 966 + }); 967 + }); 968 + 969 + it("write with repo DID mismatch returns 400", async () => { 970 + const { app, config } = createTestApp(); 971 + const signup = await performSignup(app, config); 972 + 973 + const response = await authenticatedPost( 974 + app, 975 + "http://localhost/xrpc/com.atproto.repo.createRecord", 976 + { 977 + repo: "did:plc:wrong", 978 + collection: "app.bsky.feed.post", 979 + record: { text: "bad" }, 980 + }, 981 + signup.accessToken, 982 + signup.privateKey, 983 + signup.jwk, 984 + ); 985 + const body = await response.json(); 986 + 987 + expect(response.status).toBe(400); 988 + expect(body).toEqual({ 989 + error: "InvalidRequest", 990 + message: "repo does not match authenticated DID", 991 + }); 992 + }); 993 + 994 + it("unauthenticated write returns 401", async () => { 995 + const { app } = createTestApp(); 996 + 997 + const response = await app.request("http://localhost/xrpc/com.atproto.repo.createRecord", { 998 + method: "POST", 999 + headers: { "Content-Type": "application/json" }, 1000 + body: JSON.stringify({ 1001 + repo: "did:plc:missing", 1002 + collection: "app.bsky.feed.post", 1003 + record: { text: "bad" }, 1004 + }), 1005 + }); 1006 + const body = await response.json(); 1007 + 1008 + expect(response.status).toBe(401); 1009 + expect(body).toEqual({ 1010 + error: "missing Authorization: DPoP <token> header", 1011 + }); 1012 + }); 1013 + 1014 + it("arbitrary NSID collections are accepted", async () => { 1015 + const { app, config } = createTestApp(); 1016 + const signup = await performSignup(app, config); 1017 + const signupJson = await signup.response.json() as { did: string }; 1018 + 1019 + const response = await authenticatedPost( 1020 + app, 1021 + "http://localhost/xrpc/com.atproto.repo.createRecord", 1022 + { 1023 + repo: signupJson.did, 1024 + collection: "social.aha.insight", 1025 + record: { note: "aha" }, 1026 + }, 1027 + signup.accessToken, 1028 + signup.privateKey, 1029 + signup.jwk, 1030 + ); 1031 + 1032 + expect(response.status).toBe(200); 1033 + }); 1034 + 1035 + it("writes update account root_cid rev and prev_data_cid", async () => { 1036 + const { app, db, config } = createTestApp(); 1037 + const signup = await performSignup(app, config); 1038 + const signupJson = await signup.response.json() as { did: string }; 1039 + 1040 + const response = await authenticatedPost( 1041 + app, 1042 + "http://localhost/xrpc/com.atproto.repo.createRecord", 1043 + { 1044 + repo: signupJson.did, 1045 + collection: "app.bsky.feed.post", 1046 + record: { text: "hello" }, 1047 + }, 1048 + signup.accessToken, 1049 + signup.privateKey, 1050 + signup.jwk, 1051 + ); 1052 + 1053 + expect(response.status).toBe(200); 1054 + const row = db 1055 + .prepare("SELECT root_cid, rev, prev_data_cid FROM accounts WHERE did = ?") 1056 + .get(signupJson.did) as { 1057 + root_cid: string | null; 1058 + rev: string | null; 1059 + prev_data_cid: string | null; 1060 + }; 1061 + expect(row.root_cid).not.toBeNull(); 1062 + expect(row.rev).not.toBeNull(); 1063 + expect(row.prev_data_cid).not.toBeNull(); 1064 + }); 1065 + 1066 + it("writes to a new collection add the collection row", async () => { 1067 + const { app, db, config } = createTestApp(); 1068 + const signup = await performSignup(app, config); 1069 + const signupJson = await signup.response.json() as { did: string }; 1070 + 1071 + await authenticatedPost( 1072 + app, 1073 + "http://localhost/xrpc/com.atproto.repo.createRecord", 1074 + { 1075 + repo: signupJson.did, 1076 + collection: "social.aha.insight", 1077 + record: { note: "aha" }, 1078 + }, 1079 + signup.accessToken, 1080 + signup.privateKey, 1081 + signup.jwk, 1082 + ); 1083 + 1084 + const account = db 1085 + .prepare("SELECT id FROM accounts WHERE did = ?") 1086 + .get(signupJson.did) as { id: number }; 1087 + const collections = db 1088 + .prepare("SELECT collection FROM collections WHERE account_id = ?") 1089 + .all(account.id) as { collection: string }[]; 1090 + 1091 + expect(collections.map((row) => row.collection)).toContain("social.aha.insight"); 1092 + }); 1093 + 1094 + it("sequential writes produce different revisions", async () => { 1095 + const { app, config } = createTestApp(); 1096 + const signup = await performSignup(app, config); 1097 + const signupJson = await signup.response.json() as { did: string }; 1098 + 1099 + const first = await authenticatedPost( 1100 + app, 1101 + "http://localhost/xrpc/com.atproto.repo.createRecord", 1102 + { 1103 + repo: signupJson.did, 1104 + collection: "app.bsky.feed.post", 1105 + rkey: "one", 1106 + record: { text: "one" }, 1107 + }, 1108 + signup.accessToken, 1109 + signup.privateKey, 1110 + signup.jwk, 1111 + ); 1112 + const firstBody = await first.json() as { commit: { rev: string } }; 1113 + 1114 + const second = await authenticatedPost( 1115 + app, 1116 + "http://localhost/xrpc/com.atproto.repo.createRecord", 1117 + { 1118 + repo: signupJson.did, 1119 + collection: "app.bsky.feed.post", 1120 + rkey: "two", 1121 + record: { text: "two" }, 1122 + }, 1123 + signup.accessToken, 1124 + signup.privateKey, 1125 + signup.jwk, 1126 + ); 1127 + const secondBody = await second.json() as { commit: { rev: string } }; 1128 + 1129 + expect(firstBody.commit.rev).not.toBe(secondBody.commit.rev); 1130 + }); 1131 + 1132 + it("sync latest commit matches createRecord commit", async () => { 1133 + const { app, config } = createTestApp(); 1134 + const signup = await performSignup(app, config); 1135 + const signupJson = await signup.response.json() as { did: string }; 1136 + 1137 + const created = await authenticatedPost( 1138 + app, 1139 + "http://localhost/xrpc/com.atproto.repo.createRecord", 1140 + { 1141 + repo: signupJson.did, 1142 + collection: "app.bsky.feed.post", 1143 + rkey: "self", 1144 + record: { text: "hello" }, 1145 + }, 1146 + signup.accessToken, 1147 + signup.privateKey, 1148 + signup.jwk, 1149 + ); 1150 + const createdBody = await created.json() as { commit: { cid: string; rev: string } }; 1151 + 1152 + const latest = await app.request( 1153 + `http://localhost/xrpc/com.atproto.sync.getLatestCommit?did=${signupJson.did}`, 1154 + ); 1155 + const latestBody = await latest.json() as { cid: string; rev: string }; 1156 + 1157 + expect(latest.status).toBe(200); 1158 + expect(latestBody).toEqual(createdBody.commit); 1159 + }); 1160 + });