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.

Restructure rookery as Cloudflare Worker + Durable Object

Replace standalone Node.js server (hono/node-server + better-sqlite3) with
Cloudflare Workers architecture: Worker entry point with Hono app +
AccountDurableObject class with DO-native SQLite storage.

Key changes:
- Add wrangler.toml with nodejs_compat and DO binding
- Create AccountDurableObject with lazy init, per-account keys in SQL,
and RPC methods (initAccount, getState, createRecord, deleteRecord, etc.)
- Port SqliteRepoStorage from better-sqlite3 to DO SqlStorage API with
BlockMap/CidSet private-field workarounds and prev_data_cid tracking
- Create Worker entry point with health check (GET / -> {status: ok})
- Define Env type for CF bindings (multi-tenant: keys in DO, not env)
- Add vitest + @cloudflare/vitest-pool-workers test infrastructure
- Remove Node.js entry point, db.ts, config.ts, sequencer, relay, routes
- Remove Docker/Makefile (CF-native deployment)
- Swap deps: +wrangler +@cloudflare/workers-types -better-sqlite3 -tsx

+3879 -6195
-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"]
-13
Makefile
··· 1 - .PHONY: install build test dev 2 - 3 - install: 4 - npm install 5 - 6 - build: 7 - npx tsc 8 - 9 - test: 10 - npx vitest run 11 - 12 - dev: 13 - npx tsx src/index.ts
-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:
+2770 -435
package-lock.json
··· 1 1 { 2 2 "name": "rookery", 3 - "version": "0.1.0", 3 + "version": "0.2.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "rookery", 9 - "version": "0.1.0", 9 + "version": "0.2.0", 10 10 "license": "MIT", 11 11 "dependencies": { 12 12 "@atcute/cbor": "^1", ··· 14 14 "@atcute/lexicons": "^1", 15 15 "@atcute/tid": "^1", 16 16 "@atproto/crypto": "^0.4", 17 + "@atproto/lex-cbor": "^0.0.6", 17 18 "@atproto/lex-data": "^0.0.14", 18 19 "@atproto/lex-json": "^0.0.14", 19 20 "@atproto/repo": "^0.9", 20 21 "@atproto/syntax": "^0.5.2", 21 - "@hono/node-server": "^1", 22 - "@hono/node-ws": "^1", 23 - "better-sqlite3": "^11", 24 22 "hono": "^4", 25 23 "uint8arrays": "^5.1.0" 26 24 }, 27 25 "devDependencies": { 28 - "@types/better-sqlite3": "^7", 29 - "tsx": "^4", 26 + "@cloudflare/vitest-pool-workers": "^0.8", 27 + "@cloudflare/workers-types": "^4", 30 28 "typescript": "^5", 31 - "vitest": "^3" 29 + "vitest": "^3", 30 + "wrangler": "^4" 32 31 } 33 32 }, 34 33 "node_modules/@atcute/cbor": { ··· 136 135 "zod": "^3.23.8" 137 136 } 138 137 }, 138 + "node_modules/@atproto/common/node_modules/@atproto/lex-cbor": { 139 + "version": "0.0.15", 140 + "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.15.tgz", 141 + "integrity": "sha512-3osDicK9bAMXJlKjLKqwYrhLQ60bOguWBNjE+fuNjMuizNzC0aqaClE3d+qMsFuFq9bjEHFw+4Vr9Qmd/m6VYg==", 142 + "license": "MIT", 143 + "dependencies": { 144 + "@atproto/lex-data": "^0.0.14", 145 + "tslib": "^2.8.1" 146 + } 147 + }, 139 148 "node_modules/@atproto/crypto": { 140 149 "version": "0.4.5", 141 150 "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.5.tgz", ··· 160 169 } 161 170 }, 162 171 "node_modules/@atproto/lex-cbor": { 163 - "version": "0.0.15", 164 - "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.15.tgz", 165 - "integrity": "sha512-3osDicK9bAMXJlKjLKqwYrhLQ60bOguWBNjE+fuNjMuizNzC0aqaClE3d+qMsFuFq9bjEHFw+4Vr9Qmd/m6VYg==", 172 + "version": "0.0.6", 173 + "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.6.tgz", 174 + "integrity": "sha512-lee2T00owDy3I1plRHuURT6f98NIpYZZr2wXa5pJZz5JzefZ+nv8gJ2V70C2f+jmSG+5S9NTIy4uJw94vaHf4A==", 166 175 "license": "MIT", 167 176 "dependencies": { 168 - "@atproto/lex-data": "^0.0.14", 177 + "@atproto/lex-data": "0.0.6", 178 + "multiformats": "^9.9.0", 169 179 "tslib": "^2.8.1" 170 180 } 171 181 }, 182 + "node_modules/@atproto/lex-cbor/node_modules/@atproto/lex-data": { 183 + "version": "0.0.6", 184 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.6.tgz", 185 + "integrity": "sha512-MBNB4ghRJQzuXK1zlUPljpPbQcF1LZ5dzxy274KqPt4p3uPuRw0mHjgcCoWzRUNBQC685WMQR4IN9DHtsnG57A==", 186 + "license": "MIT", 187 + "dependencies": { 188 + "@atproto/syntax": "0.4.2", 189 + "multiformats": "^9.9.0", 190 + "tslib": "^2.8.1", 191 + "uint8arrays": "3.0.0", 192 + "unicode-segmenter": "^0.14.0" 193 + } 194 + }, 195 + "node_modules/@atproto/lex-cbor/node_modules/@atproto/syntax": { 196 + "version": "0.4.2", 197 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 198 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 199 + "license": "MIT" 200 + }, 201 + "node_modules/@atproto/lex-cbor/node_modules/uint8arrays": { 202 + "version": "3.0.0", 203 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 204 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 205 + "license": "MIT", 206 + "dependencies": { 207 + "multiformats": "^9.4.2" 208 + } 209 + }, 172 210 "node_modules/@atproto/lex-data": { 173 211 "version": "0.0.14", 174 212 "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.14.tgz", ··· 219 257 "node": ">=18.7.0" 220 258 } 221 259 }, 260 + "node_modules/@atproto/repo/node_modules/@atproto/lex-cbor": { 261 + "version": "0.0.15", 262 + "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.15.tgz", 263 + "integrity": "sha512-3osDicK9bAMXJlKjLKqwYrhLQ60bOguWBNjE+fuNjMuizNzC0aqaClE3d+qMsFuFq9bjEHFw+4Vr9Qmd/m6VYg==", 264 + "license": "MIT", 265 + "dependencies": { 266 + "@atproto/lex-data": "^0.0.14", 267 + "tslib": "^2.8.1" 268 + } 269 + }, 222 270 "node_modules/@atproto/syntax": { 223 271 "version": "0.5.2", 224 272 "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.2.tgz", ··· 228 276 "tslib": "^2.8.1" 229 277 } 230 278 }, 279 + "node_modules/@cloudflare/kv-asset-handler": { 280 + "version": "0.4.2", 281 + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", 282 + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", 283 + "dev": true, 284 + "license": "MIT OR Apache-2.0", 285 + "engines": { 286 + "node": ">=18.0.0" 287 + } 288 + }, 289 + "node_modules/@cloudflare/vitest-pool-workers": { 290 + "version": "0.8.71", 291 + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.8.71.tgz", 292 + "integrity": "sha512-keu2HCLQfRNwbmLBCDXJgCFpANTaYnQpE01fBOo4CNwiWHUT7SZGN7w64RKiSWRHyYppStXBuE5Ng7F42+flpg==", 293 + "dev": true, 294 + "license": "MIT", 295 + "dependencies": { 296 + "birpc": "0.2.14", 297 + "cjs-module-lexer": "^1.2.3", 298 + "devalue": "^5.3.2", 299 + "miniflare": "4.20250906.0", 300 + "semver": "^7.7.1", 301 + "wrangler": "4.35.0", 302 + "zod": "^3.22.3" 303 + }, 304 + "peerDependencies": { 305 + "@vitest/runner": "2.0.x - 3.2.x", 306 + "@vitest/snapshot": "2.0.x - 3.2.x", 307 + "vitest": "2.0.x - 3.2.x" 308 + } 309 + }, 310 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/kv-asset-handler": { 311 + "version": "0.4.0", 312 + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", 313 + "integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==", 314 + "dev": true, 315 + "license": "MIT OR Apache-2.0", 316 + "dependencies": { 317 + "mime": "^3.0.0" 318 + }, 319 + "engines": { 320 + "node": ">=18.0.0" 321 + } 322 + }, 323 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/unenv-preset": { 324 + "version": "2.7.3", 325 + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.7.3.tgz", 326 + "integrity": "sha512-tsQQagBKjvpd9baa6nWVIv399ejiqcrUBBW6SZx6Z22+ymm+Odv5+cFimyuCsD/fC1fQTwfRmwXBNpzvHSeGCw==", 327 + "dev": true, 328 + "license": "MIT OR Apache-2.0", 329 + "peerDependencies": { 330 + "unenv": "2.0.0-rc.21", 331 + "workerd": "^1.20250828.1" 332 + }, 333 + "peerDependenciesMeta": { 334 + "workerd": { 335 + "optional": true 336 + } 337 + } 338 + }, 339 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/aix-ppc64": { 340 + "version": "0.25.4", 341 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", 342 + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", 343 + "cpu": [ 344 + "ppc64" 345 + ], 346 + "dev": true, 347 + "license": "MIT", 348 + "optional": true, 349 + "os": [ 350 + "aix" 351 + ], 352 + "engines": { 353 + "node": ">=18" 354 + } 355 + }, 356 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-arm": { 357 + "version": "0.25.4", 358 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", 359 + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", 360 + "cpu": [ 361 + "arm" 362 + ], 363 + "dev": true, 364 + "license": "MIT", 365 + "optional": true, 366 + "os": [ 367 + "android" 368 + ], 369 + "engines": { 370 + "node": ">=18" 371 + } 372 + }, 373 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-arm64": { 374 + "version": "0.25.4", 375 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", 376 + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", 377 + "cpu": [ 378 + "arm64" 379 + ], 380 + "dev": true, 381 + "license": "MIT", 382 + "optional": true, 383 + "os": [ 384 + "android" 385 + ], 386 + "engines": { 387 + "node": ">=18" 388 + } 389 + }, 390 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-x64": { 391 + "version": "0.25.4", 392 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", 393 + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", 394 + "cpu": [ 395 + "x64" 396 + ], 397 + "dev": true, 398 + "license": "MIT", 399 + "optional": true, 400 + "os": [ 401 + "android" 402 + ], 403 + "engines": { 404 + "node": ">=18" 405 + } 406 + }, 407 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/darwin-arm64": { 408 + "version": "0.25.4", 409 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", 410 + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", 411 + "cpu": [ 412 + "arm64" 413 + ], 414 + "dev": true, 415 + "license": "MIT", 416 + "optional": true, 417 + "os": [ 418 + "darwin" 419 + ], 420 + "engines": { 421 + "node": ">=18" 422 + } 423 + }, 424 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/darwin-x64": { 425 + "version": "0.25.4", 426 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", 427 + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", 428 + "cpu": [ 429 + "x64" 430 + ], 431 + "dev": true, 432 + "license": "MIT", 433 + "optional": true, 434 + "os": [ 435 + "darwin" 436 + ], 437 + "engines": { 438 + "node": ">=18" 439 + } 440 + }, 441 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/freebsd-arm64": { 442 + "version": "0.25.4", 443 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", 444 + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", 445 + "cpu": [ 446 + "arm64" 447 + ], 448 + "dev": true, 449 + "license": "MIT", 450 + "optional": true, 451 + "os": [ 452 + "freebsd" 453 + ], 454 + "engines": { 455 + "node": ">=18" 456 + } 457 + }, 458 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/freebsd-x64": { 459 + "version": "0.25.4", 460 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", 461 + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", 462 + "cpu": [ 463 + "x64" 464 + ], 465 + "dev": true, 466 + "license": "MIT", 467 + "optional": true, 468 + "os": [ 469 + "freebsd" 470 + ], 471 + "engines": { 472 + "node": ">=18" 473 + } 474 + }, 475 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-arm": { 476 + "version": "0.25.4", 477 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", 478 + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", 479 + "cpu": [ 480 + "arm" 481 + ], 482 + "dev": true, 483 + "license": "MIT", 484 + "optional": true, 485 + "os": [ 486 + "linux" 487 + ], 488 + "engines": { 489 + "node": ">=18" 490 + } 491 + }, 492 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-arm64": { 493 + "version": "0.25.4", 494 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", 495 + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", 496 + "cpu": [ 497 + "arm64" 498 + ], 499 + "dev": true, 500 + "license": "MIT", 501 + "optional": true, 502 + "os": [ 503 + "linux" 504 + ], 505 + "engines": { 506 + "node": ">=18" 507 + } 508 + }, 509 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-ia32": { 510 + "version": "0.25.4", 511 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", 512 + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", 513 + "cpu": [ 514 + "ia32" 515 + ], 516 + "dev": true, 517 + "license": "MIT", 518 + "optional": true, 519 + "os": [ 520 + "linux" 521 + ], 522 + "engines": { 523 + "node": ">=18" 524 + } 525 + }, 526 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-loong64": { 527 + "version": "0.25.4", 528 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", 529 + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", 530 + "cpu": [ 531 + "loong64" 532 + ], 533 + "dev": true, 534 + "license": "MIT", 535 + "optional": true, 536 + "os": [ 537 + "linux" 538 + ], 539 + "engines": { 540 + "node": ">=18" 541 + } 542 + }, 543 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-mips64el": { 544 + "version": "0.25.4", 545 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", 546 + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", 547 + "cpu": [ 548 + "mips64el" 549 + ], 550 + "dev": true, 551 + "license": "MIT", 552 + "optional": true, 553 + "os": [ 554 + "linux" 555 + ], 556 + "engines": { 557 + "node": ">=18" 558 + } 559 + }, 560 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-ppc64": { 561 + "version": "0.25.4", 562 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", 563 + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", 564 + "cpu": [ 565 + "ppc64" 566 + ], 567 + "dev": true, 568 + "license": "MIT", 569 + "optional": true, 570 + "os": [ 571 + "linux" 572 + ], 573 + "engines": { 574 + "node": ">=18" 575 + } 576 + }, 577 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-riscv64": { 578 + "version": "0.25.4", 579 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", 580 + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", 581 + "cpu": [ 582 + "riscv64" 583 + ], 584 + "dev": true, 585 + "license": "MIT", 586 + "optional": true, 587 + "os": [ 588 + "linux" 589 + ], 590 + "engines": { 591 + "node": ">=18" 592 + } 593 + }, 594 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-s390x": { 595 + "version": "0.25.4", 596 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", 597 + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", 598 + "cpu": [ 599 + "s390x" 600 + ], 601 + "dev": true, 602 + "license": "MIT", 603 + "optional": true, 604 + "os": [ 605 + "linux" 606 + ], 607 + "engines": { 608 + "node": ">=18" 609 + } 610 + }, 611 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-x64": { 612 + "version": "0.25.4", 613 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", 614 + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", 615 + "cpu": [ 616 + "x64" 617 + ], 618 + "dev": true, 619 + "license": "MIT", 620 + "optional": true, 621 + "os": [ 622 + "linux" 623 + ], 624 + "engines": { 625 + "node": ">=18" 626 + } 627 + }, 628 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/netbsd-arm64": { 629 + "version": "0.25.4", 630 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", 631 + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", 632 + "cpu": [ 633 + "arm64" 634 + ], 635 + "dev": true, 636 + "license": "MIT", 637 + "optional": true, 638 + "os": [ 639 + "netbsd" 640 + ], 641 + "engines": { 642 + "node": ">=18" 643 + } 644 + }, 645 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/netbsd-x64": { 646 + "version": "0.25.4", 647 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", 648 + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", 649 + "cpu": [ 650 + "x64" 651 + ], 652 + "dev": true, 653 + "license": "MIT", 654 + "optional": true, 655 + "os": [ 656 + "netbsd" 657 + ], 658 + "engines": { 659 + "node": ">=18" 660 + } 661 + }, 662 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/openbsd-arm64": { 663 + "version": "0.25.4", 664 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", 665 + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", 666 + "cpu": [ 667 + "arm64" 668 + ], 669 + "dev": true, 670 + "license": "MIT", 671 + "optional": true, 672 + "os": [ 673 + "openbsd" 674 + ], 675 + "engines": { 676 + "node": ">=18" 677 + } 678 + }, 679 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/openbsd-x64": { 680 + "version": "0.25.4", 681 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", 682 + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", 683 + "cpu": [ 684 + "x64" 685 + ], 686 + "dev": true, 687 + "license": "MIT", 688 + "optional": true, 689 + "os": [ 690 + "openbsd" 691 + ], 692 + "engines": { 693 + "node": ">=18" 694 + } 695 + }, 696 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/sunos-x64": { 697 + "version": "0.25.4", 698 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", 699 + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", 700 + "cpu": [ 701 + "x64" 702 + ], 703 + "dev": true, 704 + "license": "MIT", 705 + "optional": true, 706 + "os": [ 707 + "sunos" 708 + ], 709 + "engines": { 710 + "node": ">=18" 711 + } 712 + }, 713 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-arm64": { 714 + "version": "0.25.4", 715 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", 716 + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", 717 + "cpu": [ 718 + "arm64" 719 + ], 720 + "dev": true, 721 + "license": "MIT", 722 + "optional": true, 723 + "os": [ 724 + "win32" 725 + ], 726 + "engines": { 727 + "node": ">=18" 728 + } 729 + }, 730 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-ia32": { 731 + "version": "0.25.4", 732 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", 733 + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", 734 + "cpu": [ 735 + "ia32" 736 + ], 737 + "dev": true, 738 + "license": "MIT", 739 + "optional": true, 740 + "os": [ 741 + "win32" 742 + ], 743 + "engines": { 744 + "node": ">=18" 745 + } 746 + }, 747 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-x64": { 748 + "version": "0.25.4", 749 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", 750 + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", 751 + "cpu": [ 752 + "x64" 753 + ], 754 + "dev": true, 755 + "license": "MIT", 756 + "optional": true, 757 + "os": [ 758 + "win32" 759 + ], 760 + "engines": { 761 + "node": ">=18" 762 + } 763 + }, 764 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/esbuild": { 765 + "version": "0.25.4", 766 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", 767 + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", 768 + "dev": true, 769 + "hasInstallScript": true, 770 + "license": "MIT", 771 + "bin": { 772 + "esbuild": "bin/esbuild" 773 + }, 774 + "engines": { 775 + "node": ">=18" 776 + }, 777 + "optionalDependencies": { 778 + "@esbuild/aix-ppc64": "0.25.4", 779 + "@esbuild/android-arm": "0.25.4", 780 + "@esbuild/android-arm64": "0.25.4", 781 + "@esbuild/android-x64": "0.25.4", 782 + "@esbuild/darwin-arm64": "0.25.4", 783 + "@esbuild/darwin-x64": "0.25.4", 784 + "@esbuild/freebsd-arm64": "0.25.4", 785 + "@esbuild/freebsd-x64": "0.25.4", 786 + "@esbuild/linux-arm": "0.25.4", 787 + "@esbuild/linux-arm64": "0.25.4", 788 + "@esbuild/linux-ia32": "0.25.4", 789 + "@esbuild/linux-loong64": "0.25.4", 790 + "@esbuild/linux-mips64el": "0.25.4", 791 + "@esbuild/linux-ppc64": "0.25.4", 792 + "@esbuild/linux-riscv64": "0.25.4", 793 + "@esbuild/linux-s390x": "0.25.4", 794 + "@esbuild/linux-x64": "0.25.4", 795 + "@esbuild/netbsd-arm64": "0.25.4", 796 + "@esbuild/netbsd-x64": "0.25.4", 797 + "@esbuild/openbsd-arm64": "0.25.4", 798 + "@esbuild/openbsd-x64": "0.25.4", 799 + "@esbuild/sunos-x64": "0.25.4", 800 + "@esbuild/win32-arm64": "0.25.4", 801 + "@esbuild/win32-ia32": "0.25.4", 802 + "@esbuild/win32-x64": "0.25.4" 803 + } 804 + }, 805 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/unenv": { 806 + "version": "2.0.0-rc.21", 807 + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.21.tgz", 808 + "integrity": "sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==", 809 + "dev": true, 810 + "license": "MIT", 811 + "dependencies": { 812 + "defu": "^6.1.4", 813 + "exsolve": "^1.0.7", 814 + "ohash": "^2.0.11", 815 + "pathe": "^2.0.3", 816 + "ufo": "^1.6.1" 817 + } 818 + }, 819 + "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": { 820 + "version": "4.35.0", 821 + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.35.0.tgz", 822 + "integrity": "sha512-HbyXtbrh4Fi3mU8ussY85tVdQ74qpVS1vctUgaPc+bPrXBTqfDLkZ6VRtHAVF/eBhz4SFmhJtCQpN1caY2Ak8A==", 823 + "dev": true, 824 + "license": "MIT OR Apache-2.0", 825 + "dependencies": { 826 + "@cloudflare/kv-asset-handler": "0.4.0", 827 + "@cloudflare/unenv-preset": "2.7.3", 828 + "blake3-wasm": "2.1.5", 829 + "esbuild": "0.25.4", 830 + "miniflare": "4.20250906.0", 831 + "path-to-regexp": "6.3.0", 832 + "unenv": "2.0.0-rc.21", 833 + "workerd": "1.20250906.0" 834 + }, 835 + "bin": { 836 + "wrangler": "bin/wrangler.js", 837 + "wrangler2": "bin/wrangler.js" 838 + }, 839 + "engines": { 840 + "node": ">=18.0.0" 841 + }, 842 + "optionalDependencies": { 843 + "fsevents": "~2.3.2" 844 + }, 845 + "peerDependencies": { 846 + "@cloudflare/workers-types": "^4.20250906.0" 847 + }, 848 + "peerDependenciesMeta": { 849 + "@cloudflare/workers-types": { 850 + "optional": true 851 + } 852 + } 853 + }, 854 + "node_modules/@cloudflare/workerd-darwin-64": { 855 + "version": "1.20250906.0", 856 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250906.0.tgz", 857 + "integrity": "sha512-E+X/YYH9BmX0ew2j/mAWFif2z05NMNuhCTlNYEGLkqMe99K15UewBqajL9pMcMUKxylnlrEoK3VNxl33DkbnPA==", 858 + "cpu": [ 859 + "x64" 860 + ], 861 + "dev": true, 862 + "license": "Apache-2.0", 863 + "optional": true, 864 + "os": [ 865 + "darwin" 866 + ], 867 + "engines": { 868 + "node": ">=16" 869 + } 870 + }, 871 + "node_modules/@cloudflare/workerd-darwin-arm64": { 872 + "version": "1.20250906.0", 873 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250906.0.tgz", 874 + "integrity": "sha512-X5apsZ1SFW4FYTM19ISHf8005FJMPfrcf4U5rO0tdj+TeJgQgXuZ57IG0WeW7SpLVeBo8hM6WC8CovZh41AfnA==", 875 + "cpu": [ 876 + "arm64" 877 + ], 878 + "dev": true, 879 + "license": "Apache-2.0", 880 + "optional": true, 881 + "os": [ 882 + "darwin" 883 + ], 884 + "engines": { 885 + "node": ">=16" 886 + } 887 + }, 888 + "node_modules/@cloudflare/workerd-linux-64": { 889 + "version": "1.20250906.0", 890 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250906.0.tgz", 891 + "integrity": "sha512-rlKzWgsLnlQ5Nt9W69YBJKcmTmZbOGu0edUsenXPmc6wzULUxoQpi7ZE9k3TfTonJx4WoQsQlzCUamRYFsX+0Q==", 892 + "cpu": [ 893 + "x64" 894 + ], 895 + "dev": true, 896 + "license": "Apache-2.0", 897 + "optional": true, 898 + "os": [ 899 + "linux" 900 + ], 901 + "engines": { 902 + "node": ">=16" 903 + } 904 + }, 905 + "node_modules/@cloudflare/workerd-linux-arm64": { 906 + "version": "1.20250906.0", 907 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250906.0.tgz", 908 + "integrity": "sha512-DdedhiQ+SeLzpg7BpcLrIPEZ33QKioJQ1wvL4X7nuLzEB9rWzS37NNNahQzc1+44rhG4fyiHbXBPOeox4B9XVA==", 909 + "cpu": [ 910 + "arm64" 911 + ], 912 + "dev": true, 913 + "license": "Apache-2.0", 914 + "optional": true, 915 + "os": [ 916 + "linux" 917 + ], 918 + "engines": { 919 + "node": ">=16" 920 + } 921 + }, 922 + "node_modules/@cloudflare/workerd-windows-64": { 923 + "version": "1.20250906.0", 924 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250906.0.tgz", 925 + "integrity": "sha512-Q8Qjfs8jGVILnZL6vUpQ90q/8MTCYaGR3d1LGxZMBqte8Vr7xF3KFHPEy7tFs0j0mMjnqCYzlofmPNY+9ZaDRg==", 926 + "cpu": [ 927 + "x64" 928 + ], 929 + "dev": true, 930 + "license": "Apache-2.0", 931 + "optional": true, 932 + "os": [ 933 + "win32" 934 + ], 935 + "engines": { 936 + "node": ">=16" 937 + } 938 + }, 939 + "node_modules/@cloudflare/workers-types": { 940 + "version": "4.20260329.1", 941 + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260329.1.tgz", 942 + "integrity": "sha512-LxBHrYYI/AZ6OCbUzRqRgg6Rt1qev2KxN2NNd3saye41AO2g52cYvHV+ohts5oPnrIUD7YRjbgN/J3NU7e7m5A==", 943 + "dev": true, 944 + "license": "MIT OR Apache-2.0" 945 + }, 946 + "node_modules/@cspotcode/source-map-support": { 947 + "version": "0.8.1", 948 + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", 949 + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", 950 + "dev": true, 951 + "license": "MIT", 952 + "dependencies": { 953 + "@jridgewell/trace-mapping": "0.3.9" 954 + }, 955 + "engines": { 956 + "node": ">=12" 957 + } 958 + }, 959 + "node_modules/@emnapi/runtime": { 960 + "version": "1.9.1", 961 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", 962 + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", 963 + "dev": true, 964 + "license": "MIT", 965 + "optional": true, 966 + "dependencies": { 967 + "tslib": "^2.4.0" 968 + } 969 + }, 231 970 "node_modules/@esbuild/aix-ppc64": { 232 971 "version": "0.27.4", 233 972 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", ··· 670 1409 "node": ">=18" 671 1410 } 672 1411 }, 673 - "node_modules/@hono/node-server": { 674 - "version": "1.19.11", 675 - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", 676 - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", 1412 + "node_modules/@img/colour": { 1413 + "version": "1.1.0", 1414 + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", 1415 + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", 1416 + "dev": true, 677 1417 "license": "MIT", 678 1418 "engines": { 679 - "node": ">=18.14.1" 1419 + "node": ">=18" 1420 + } 1421 + }, 1422 + "node_modules/@img/sharp-darwin-arm64": { 1423 + "version": "0.33.5", 1424 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", 1425 + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", 1426 + "cpu": [ 1427 + "arm64" 1428 + ], 1429 + "dev": true, 1430 + "license": "Apache-2.0", 1431 + "optional": true, 1432 + "os": [ 1433 + "darwin" 1434 + ], 1435 + "engines": { 1436 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1437 + }, 1438 + "funding": { 1439 + "url": "https://opencollective.com/libvips" 1440 + }, 1441 + "optionalDependencies": { 1442 + "@img/sharp-libvips-darwin-arm64": "1.0.4" 1443 + } 1444 + }, 1445 + "node_modules/@img/sharp-darwin-x64": { 1446 + "version": "0.33.5", 1447 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", 1448 + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", 1449 + "cpu": [ 1450 + "x64" 1451 + ], 1452 + "dev": true, 1453 + "license": "Apache-2.0", 1454 + "optional": true, 1455 + "os": [ 1456 + "darwin" 1457 + ], 1458 + "engines": { 1459 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1460 + }, 1461 + "funding": { 1462 + "url": "https://opencollective.com/libvips" 1463 + }, 1464 + "optionalDependencies": { 1465 + "@img/sharp-libvips-darwin-x64": "1.0.4" 1466 + } 1467 + }, 1468 + "node_modules/@img/sharp-libvips-darwin-arm64": { 1469 + "version": "1.0.4", 1470 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", 1471 + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", 1472 + "cpu": [ 1473 + "arm64" 1474 + ], 1475 + "dev": true, 1476 + "license": "LGPL-3.0-or-later", 1477 + "optional": true, 1478 + "os": [ 1479 + "darwin" 1480 + ], 1481 + "funding": { 1482 + "url": "https://opencollective.com/libvips" 1483 + } 1484 + }, 1485 + "node_modules/@img/sharp-libvips-darwin-x64": { 1486 + "version": "1.0.4", 1487 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", 1488 + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", 1489 + "cpu": [ 1490 + "x64" 1491 + ], 1492 + "dev": true, 1493 + "license": "LGPL-3.0-or-later", 1494 + "optional": true, 1495 + "os": [ 1496 + "darwin" 1497 + ], 1498 + "funding": { 1499 + "url": "https://opencollective.com/libvips" 1500 + } 1501 + }, 1502 + "node_modules/@img/sharp-libvips-linux-arm": { 1503 + "version": "1.0.5", 1504 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", 1505 + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", 1506 + "cpu": [ 1507 + "arm" 1508 + ], 1509 + "dev": true, 1510 + "license": "LGPL-3.0-or-later", 1511 + "optional": true, 1512 + "os": [ 1513 + "linux" 1514 + ], 1515 + "funding": { 1516 + "url": "https://opencollective.com/libvips" 1517 + } 1518 + }, 1519 + "node_modules/@img/sharp-libvips-linux-arm64": { 1520 + "version": "1.0.4", 1521 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", 1522 + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", 1523 + "cpu": [ 1524 + "arm64" 1525 + ], 1526 + "dev": true, 1527 + "license": "LGPL-3.0-or-later", 1528 + "optional": true, 1529 + "os": [ 1530 + "linux" 1531 + ], 1532 + "funding": { 1533 + "url": "https://opencollective.com/libvips" 1534 + } 1535 + }, 1536 + "node_modules/@img/sharp-libvips-linux-ppc64": { 1537 + "version": "1.2.4", 1538 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", 1539 + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", 1540 + "cpu": [ 1541 + "ppc64" 1542 + ], 1543 + "dev": true, 1544 + "license": "LGPL-3.0-or-later", 1545 + "optional": true, 1546 + "os": [ 1547 + "linux" 1548 + ], 1549 + "funding": { 1550 + "url": "https://opencollective.com/libvips" 1551 + } 1552 + }, 1553 + "node_modules/@img/sharp-libvips-linux-riscv64": { 1554 + "version": "1.2.4", 1555 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", 1556 + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", 1557 + "cpu": [ 1558 + "riscv64" 1559 + ], 1560 + "dev": true, 1561 + "license": "LGPL-3.0-or-later", 1562 + "optional": true, 1563 + "os": [ 1564 + "linux" 1565 + ], 1566 + "funding": { 1567 + "url": "https://opencollective.com/libvips" 1568 + } 1569 + }, 1570 + "node_modules/@img/sharp-libvips-linux-s390x": { 1571 + "version": "1.0.4", 1572 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", 1573 + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", 1574 + "cpu": [ 1575 + "s390x" 1576 + ], 1577 + "dev": true, 1578 + "license": "LGPL-3.0-or-later", 1579 + "optional": true, 1580 + "os": [ 1581 + "linux" 1582 + ], 1583 + "funding": { 1584 + "url": "https://opencollective.com/libvips" 1585 + } 1586 + }, 1587 + "node_modules/@img/sharp-libvips-linux-x64": { 1588 + "version": "1.0.4", 1589 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", 1590 + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", 1591 + "cpu": [ 1592 + "x64" 1593 + ], 1594 + "dev": true, 1595 + "license": "LGPL-3.0-or-later", 1596 + "optional": true, 1597 + "os": [ 1598 + "linux" 1599 + ], 1600 + "funding": { 1601 + "url": "https://opencollective.com/libvips" 1602 + } 1603 + }, 1604 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { 1605 + "version": "1.0.4", 1606 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", 1607 + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", 1608 + "cpu": [ 1609 + "arm64" 1610 + ], 1611 + "dev": true, 1612 + "license": "LGPL-3.0-or-later", 1613 + "optional": true, 1614 + "os": [ 1615 + "linux" 1616 + ], 1617 + "funding": { 1618 + "url": "https://opencollective.com/libvips" 1619 + } 1620 + }, 1621 + "node_modules/@img/sharp-libvips-linuxmusl-x64": { 1622 + "version": "1.0.4", 1623 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", 1624 + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", 1625 + "cpu": [ 1626 + "x64" 1627 + ], 1628 + "dev": true, 1629 + "license": "LGPL-3.0-or-later", 1630 + "optional": true, 1631 + "os": [ 1632 + "linux" 1633 + ], 1634 + "funding": { 1635 + "url": "https://opencollective.com/libvips" 1636 + } 1637 + }, 1638 + "node_modules/@img/sharp-linux-arm": { 1639 + "version": "0.33.5", 1640 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", 1641 + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", 1642 + "cpu": [ 1643 + "arm" 1644 + ], 1645 + "dev": true, 1646 + "license": "Apache-2.0", 1647 + "optional": true, 1648 + "os": [ 1649 + "linux" 1650 + ], 1651 + "engines": { 1652 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1653 + }, 1654 + "funding": { 1655 + "url": "https://opencollective.com/libvips" 680 1656 }, 681 - "peerDependencies": { 682 - "hono": "^4" 1657 + "optionalDependencies": { 1658 + "@img/sharp-libvips-linux-arm": "1.0.5" 683 1659 } 684 1660 }, 685 - "node_modules/@hono/node-ws": { 686 - "version": "1.3.0", 687 - "resolved": "https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.0.tgz", 688 - "integrity": "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==", 689 - "license": "MIT", 1661 + "node_modules/@img/sharp-linux-arm64": { 1662 + "version": "0.33.5", 1663 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", 1664 + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", 1665 + "cpu": [ 1666 + "arm64" 1667 + ], 1668 + "dev": true, 1669 + "license": "Apache-2.0", 1670 + "optional": true, 1671 + "os": [ 1672 + "linux" 1673 + ], 1674 + "engines": { 1675 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1676 + }, 1677 + "funding": { 1678 + "url": "https://opencollective.com/libvips" 1679 + }, 1680 + "optionalDependencies": { 1681 + "@img/sharp-libvips-linux-arm64": "1.0.4" 1682 + } 1683 + }, 1684 + "node_modules/@img/sharp-linux-ppc64": { 1685 + "version": "0.34.5", 1686 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", 1687 + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", 1688 + "cpu": [ 1689 + "ppc64" 1690 + ], 1691 + "dev": true, 1692 + "license": "Apache-2.0", 1693 + "optional": true, 1694 + "os": [ 1695 + "linux" 1696 + ], 1697 + "engines": { 1698 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1699 + }, 1700 + "funding": { 1701 + "url": "https://opencollective.com/libvips" 1702 + }, 1703 + "optionalDependencies": { 1704 + "@img/sharp-libvips-linux-ppc64": "1.2.4" 1705 + } 1706 + }, 1707 + "node_modules/@img/sharp-linux-riscv64": { 1708 + "version": "0.34.5", 1709 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", 1710 + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", 1711 + "cpu": [ 1712 + "riscv64" 1713 + ], 1714 + "dev": true, 1715 + "license": "Apache-2.0", 1716 + "optional": true, 1717 + "os": [ 1718 + "linux" 1719 + ], 1720 + "engines": { 1721 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1722 + }, 1723 + "funding": { 1724 + "url": "https://opencollective.com/libvips" 1725 + }, 1726 + "optionalDependencies": { 1727 + "@img/sharp-libvips-linux-riscv64": "1.2.4" 1728 + } 1729 + }, 1730 + "node_modules/@img/sharp-linux-s390x": { 1731 + "version": "0.33.5", 1732 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", 1733 + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", 1734 + "cpu": [ 1735 + "s390x" 1736 + ], 1737 + "dev": true, 1738 + "license": "Apache-2.0", 1739 + "optional": true, 1740 + "os": [ 1741 + "linux" 1742 + ], 1743 + "engines": { 1744 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1745 + }, 1746 + "funding": { 1747 + "url": "https://opencollective.com/libvips" 1748 + }, 1749 + "optionalDependencies": { 1750 + "@img/sharp-libvips-linux-s390x": "1.0.4" 1751 + } 1752 + }, 1753 + "node_modules/@img/sharp-linux-x64": { 1754 + "version": "0.33.5", 1755 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", 1756 + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", 1757 + "cpu": [ 1758 + "x64" 1759 + ], 1760 + "dev": true, 1761 + "license": "Apache-2.0", 1762 + "optional": true, 1763 + "os": [ 1764 + "linux" 1765 + ], 1766 + "engines": { 1767 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1768 + }, 1769 + "funding": { 1770 + "url": "https://opencollective.com/libvips" 1771 + }, 1772 + "optionalDependencies": { 1773 + "@img/sharp-libvips-linux-x64": "1.0.4" 1774 + } 1775 + }, 1776 + "node_modules/@img/sharp-linuxmusl-arm64": { 1777 + "version": "0.33.5", 1778 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", 1779 + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", 1780 + "cpu": [ 1781 + "arm64" 1782 + ], 1783 + "dev": true, 1784 + "license": "Apache-2.0", 1785 + "optional": true, 1786 + "os": [ 1787 + "linux" 1788 + ], 1789 + "engines": { 1790 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1791 + }, 1792 + "funding": { 1793 + "url": "https://opencollective.com/libvips" 1794 + }, 1795 + "optionalDependencies": { 1796 + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" 1797 + } 1798 + }, 1799 + "node_modules/@img/sharp-linuxmusl-x64": { 1800 + "version": "0.33.5", 1801 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", 1802 + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", 1803 + "cpu": [ 1804 + "x64" 1805 + ], 1806 + "dev": true, 1807 + "license": "Apache-2.0", 1808 + "optional": true, 1809 + "os": [ 1810 + "linux" 1811 + ], 1812 + "engines": { 1813 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1814 + }, 1815 + "funding": { 1816 + "url": "https://opencollective.com/libvips" 1817 + }, 1818 + "optionalDependencies": { 1819 + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" 1820 + } 1821 + }, 1822 + "node_modules/@img/sharp-wasm32": { 1823 + "version": "0.33.5", 1824 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", 1825 + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", 1826 + "cpu": [ 1827 + "wasm32" 1828 + ], 1829 + "dev": true, 1830 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", 1831 + "optional": true, 690 1832 "dependencies": { 691 - "ws": "^8.17.0" 1833 + "@emnapi/runtime": "^1.2.0" 1834 + }, 1835 + "engines": { 1836 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1837 + }, 1838 + "funding": { 1839 + "url": "https://opencollective.com/libvips" 1840 + } 1841 + }, 1842 + "node_modules/@img/sharp-win32-arm64": { 1843 + "version": "0.34.5", 1844 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", 1845 + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", 1846 + "cpu": [ 1847 + "arm64" 1848 + ], 1849 + "dev": true, 1850 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 1851 + "optional": true, 1852 + "os": [ 1853 + "win32" 1854 + ], 1855 + "engines": { 1856 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 692 1857 }, 1858 + "funding": { 1859 + "url": "https://opencollective.com/libvips" 1860 + } 1861 + }, 1862 + "node_modules/@img/sharp-win32-ia32": { 1863 + "version": "0.33.5", 1864 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", 1865 + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", 1866 + "cpu": [ 1867 + "ia32" 1868 + ], 1869 + "dev": true, 1870 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 1871 + "optional": true, 1872 + "os": [ 1873 + "win32" 1874 + ], 693 1875 "engines": { 694 - "node": ">=18.14.1" 1876 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 695 1877 }, 696 - "peerDependencies": { 697 - "@hono/node-server": "^1.19.2", 698 - "hono": "^4.6.0" 1878 + "funding": { 1879 + "url": "https://opencollective.com/libvips" 1880 + } 1881 + }, 1882 + "node_modules/@img/sharp-win32-x64": { 1883 + "version": "0.33.5", 1884 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", 1885 + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", 1886 + "cpu": [ 1887 + "x64" 1888 + ], 1889 + "dev": true, 1890 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 1891 + "optional": true, 1892 + "os": [ 1893 + "win32" 1894 + ], 1895 + "engines": { 1896 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1897 + }, 1898 + "funding": { 1899 + "url": "https://opencollective.com/libvips" 1900 + } 1901 + }, 1902 + "node_modules/@jridgewell/resolve-uri": { 1903 + "version": "3.1.2", 1904 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 1905 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 1906 + "dev": true, 1907 + "license": "MIT", 1908 + "engines": { 1909 + "node": ">=6.0.0" 699 1910 } 700 1911 }, 701 1912 "node_modules/@jridgewell/sourcemap-codec": { ··· 705 1916 "dev": true, 706 1917 "license": "MIT" 707 1918 }, 1919 + "node_modules/@jridgewell/trace-mapping": { 1920 + "version": "0.3.9", 1921 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", 1922 + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", 1923 + "dev": true, 1924 + "license": "MIT", 1925 + "dependencies": { 1926 + "@jridgewell/resolve-uri": "^3.0.3", 1927 + "@jridgewell/sourcemap-codec": "^1.4.10" 1928 + } 1929 + }, 708 1930 "node_modules/@noble/curves": { 709 1931 "version": "1.9.7", 710 1932 "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", ··· 732 1954 "url": "https://paulmillr.com/funding/" 733 1955 } 734 1956 }, 1957 + "node_modules/@poppinss/colors": { 1958 + "version": "4.1.6", 1959 + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", 1960 + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", 1961 + "dev": true, 1962 + "license": "MIT", 1963 + "dependencies": { 1964 + "kleur": "^4.1.5" 1965 + } 1966 + }, 1967 + "node_modules/@poppinss/dumper": { 1968 + "version": "0.6.5", 1969 + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", 1970 + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", 1971 + "dev": true, 1972 + "license": "MIT", 1973 + "dependencies": { 1974 + "@poppinss/colors": "^4.1.5", 1975 + "@sindresorhus/is": "^7.0.2", 1976 + "supports-color": "^10.0.0" 1977 + } 1978 + }, 1979 + "node_modules/@poppinss/exception": { 1980 + "version": "1.2.3", 1981 + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", 1982 + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", 1983 + "dev": true, 1984 + "license": "MIT" 1985 + }, 735 1986 "node_modules/@rollup/rollup-android-arm-eabi": { 736 1987 "version": "4.60.0", 737 1988 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", ··· 1082 2333 "win32" 1083 2334 ] 1084 2335 }, 2336 + "node_modules/@sindresorhus/is": { 2337 + "version": "7.2.0", 2338 + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", 2339 + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", 2340 + "dev": true, 2341 + "license": "MIT", 2342 + "engines": { 2343 + "node": ">=18" 2344 + }, 2345 + "funding": { 2346 + "url": "https://github.com/sindresorhus/is?sponsor=1" 2347 + } 2348 + }, 2349 + "node_modules/@speed-highlight/core": { 2350 + "version": "1.2.15", 2351 + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", 2352 + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", 2353 + "dev": true, 2354 + "license": "CC0-1.0" 2355 + }, 1085 2356 "node_modules/@standard-schema/spec": { 1086 2357 "version": "1.1.0", 1087 2358 "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", 1088 2359 "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", 1089 2360 "license": "MIT" 1090 - }, 1091 - "node_modules/@types/better-sqlite3": { 1092 - "version": "7.6.13", 1093 - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", 1094 - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", 1095 - "dev": true, 1096 - "license": "MIT", 1097 - "dependencies": { 1098 - "@types/node": "*" 1099 - } 1100 2361 }, 1101 2362 "node_modules/@types/chai": { 1102 2363 "version": "5.2.3", ··· 1122 2383 "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 1123 2384 "dev": true, 1124 2385 "license": "MIT" 1125 - }, 1126 - "node_modules/@types/node": { 1127 - "version": "25.5.0", 1128 - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", 1129 - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", 1130 - "dev": true, 1131 - "license": "MIT", 1132 - "dependencies": { 1133 - "undici-types": "~7.18.0" 1134 - } 1135 2386 }, 1136 2387 "node_modules/@vitest/expect": { 1137 2388 "version": "3.2.4", ··· 1260 2511 "node": ">=6.5" 1261 2512 } 1262 2513 }, 2514 + "node_modules/acorn": { 2515 + "version": "8.14.0", 2516 + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", 2517 + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", 2518 + "dev": true, 2519 + "license": "MIT", 2520 + "bin": { 2521 + "acorn": "bin/acorn" 2522 + }, 2523 + "engines": { 2524 + "node": ">=0.4.0" 2525 + } 2526 + }, 2527 + "node_modules/acorn-walk": { 2528 + "version": "8.3.2", 2529 + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", 2530 + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", 2531 + "dev": true, 2532 + "license": "MIT", 2533 + "engines": { 2534 + "node": ">=0.4.0" 2535 + } 2536 + }, 1263 2537 "node_modules/assertion-error": { 1264 2538 "version": "2.0.1", 1265 2539 "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", ··· 1299 2573 ], 1300 2574 "license": "MIT" 1301 2575 }, 1302 - "node_modules/better-sqlite3": { 1303 - "version": "11.10.0", 1304 - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", 1305 - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", 1306 - "hasInstallScript": true, 1307 - "license": "MIT", 1308 - "dependencies": { 1309 - "bindings": "^1.5.0", 1310 - "prebuild-install": "^7.1.1" 1311 - } 1312 - }, 1313 - "node_modules/bindings": { 1314 - "version": "1.5.0", 1315 - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", 1316 - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", 1317 - "license": "MIT", 1318 - "dependencies": { 1319 - "file-uri-to-path": "1.0.0" 1320 - } 1321 - }, 1322 - "node_modules/bl": { 1323 - "version": "4.1.0", 1324 - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", 1325 - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", 1326 - "license": "MIT", 1327 - "dependencies": { 1328 - "buffer": "^5.5.0", 1329 - "inherits": "^2.0.4", 1330 - "readable-stream": "^3.4.0" 1331 - } 1332 - }, 1333 - "node_modules/bl/node_modules/buffer": { 1334 - "version": "5.7.1", 1335 - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 1336 - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 1337 - "funding": [ 1338 - { 1339 - "type": "github", 1340 - "url": "https://github.com/sponsors/feross" 1341 - }, 1342 - { 1343 - "type": "patreon", 1344 - "url": "https://www.patreon.com/feross" 1345 - }, 1346 - { 1347 - "type": "consulting", 1348 - "url": "https://feross.org/support" 1349 - } 1350 - ], 2576 + "node_modules/birpc": { 2577 + "version": "0.2.14", 2578 + "resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.14.tgz", 2579 + "integrity": "sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==", 2580 + "dev": true, 1351 2581 "license": "MIT", 1352 - "dependencies": { 1353 - "base64-js": "^1.3.1", 1354 - "ieee754": "^1.1.13" 2582 + "funding": { 2583 + "url": "https://github.com/sponsors/antfu" 1355 2584 } 1356 2585 }, 1357 - "node_modules/bl/node_modules/readable-stream": { 1358 - "version": "3.6.2", 1359 - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", 1360 - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", 1361 - "license": "MIT", 1362 - "dependencies": { 1363 - "inherits": "^2.0.3", 1364 - "string_decoder": "^1.1.1", 1365 - "util-deprecate": "^1.0.1" 1366 - }, 1367 - "engines": { 1368 - "node": ">= 6" 1369 - } 2586 + "node_modules/blake3-wasm": { 2587 + "version": "2.1.5", 2588 + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", 2589 + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", 2590 + "dev": true, 2591 + "license": "MIT" 1370 2592 }, 1371 2593 "node_modules/buffer": { 1372 2594 "version": "6.0.3", ··· 1429 2651 "node": ">= 16" 1430 2652 } 1431 2653 }, 1432 - "node_modules/chownr": { 2654 + "node_modules/cjs-module-lexer": { 2655 + "version": "1.4.3", 2656 + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", 2657 + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", 2658 + "dev": true, 2659 + "license": "MIT" 2660 + }, 2661 + "node_modules/color": { 2662 + "version": "4.2.3", 2663 + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", 2664 + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", 2665 + "dev": true, 2666 + "license": "MIT", 2667 + "dependencies": { 2668 + "color-convert": "^2.0.1", 2669 + "color-string": "^1.9.0" 2670 + }, 2671 + "engines": { 2672 + "node": ">=12.5.0" 2673 + } 2674 + }, 2675 + "node_modules/color-convert": { 2676 + "version": "2.0.1", 2677 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 2678 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 2679 + "dev": true, 2680 + "license": "MIT", 2681 + "dependencies": { 2682 + "color-name": "~1.1.4" 2683 + }, 2684 + "engines": { 2685 + "node": ">=7.0.0" 2686 + } 2687 + }, 2688 + "node_modules/color-name": { 1433 2689 "version": "1.1.4", 1434 - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", 1435 - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", 1436 - "license": "ISC" 2690 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 2691 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 2692 + "dev": true, 2693 + "license": "MIT" 2694 + }, 2695 + "node_modules/color-string": { 2696 + "version": "1.9.1", 2697 + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", 2698 + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", 2699 + "dev": true, 2700 + "license": "MIT", 2701 + "dependencies": { 2702 + "color-name": "^1.0.0", 2703 + "simple-swizzle": "^0.2.2" 2704 + } 2705 + }, 2706 + "node_modules/cookie": { 2707 + "version": "1.1.1", 2708 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", 2709 + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", 2710 + "dev": true, 2711 + "license": "MIT", 2712 + "engines": { 2713 + "node": ">=18" 2714 + }, 2715 + "funding": { 2716 + "type": "opencollective", 2717 + "url": "https://opencollective.com/express" 2718 + } 1437 2719 }, 1438 2720 "node_modules/debug": { 1439 2721 "version": "4.4.3", ··· 1453 2735 } 1454 2736 } 1455 2737 }, 1456 - "node_modules/decompress-response": { 1457 - "version": "6.0.0", 1458 - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", 1459 - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", 1460 - "license": "MIT", 1461 - "dependencies": { 1462 - "mimic-response": "^3.1.0" 1463 - }, 1464 - "engines": { 1465 - "node": ">=10" 1466 - }, 1467 - "funding": { 1468 - "url": "https://github.com/sponsors/sindresorhus" 1469 - } 1470 - }, 1471 2738 "node_modules/deep-eql": { 1472 2739 "version": "5.0.2", 1473 2740 "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", ··· 1478 2745 "node": ">=6" 1479 2746 } 1480 2747 }, 1481 - "node_modules/deep-extend": { 1482 - "version": "0.6.0", 1483 - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", 1484 - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", 1485 - "license": "MIT", 1486 - "engines": { 1487 - "node": ">=4.0.0" 1488 - } 2748 + "node_modules/defu": { 2749 + "version": "6.1.4", 2750 + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", 2751 + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", 2752 + "dev": true, 2753 + "license": "MIT" 1489 2754 }, 1490 2755 "node_modules/detect-libc": { 1491 2756 "version": "2.1.2", 1492 2757 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 1493 2758 "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", 2759 + "dev": true, 1494 2760 "license": "Apache-2.0", 1495 2761 "engines": { 1496 2762 "node": ">=8" 1497 2763 } 1498 2764 }, 1499 - "node_modules/end-of-stream": { 1500 - "version": "1.4.5", 1501 - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", 1502 - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", 2765 + "node_modules/devalue": { 2766 + "version": "5.6.4", 2767 + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", 2768 + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", 2769 + "dev": true, 2770 + "license": "MIT" 2771 + }, 2772 + "node_modules/error-stack-parser-es": { 2773 + "version": "1.0.5", 2774 + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", 2775 + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", 2776 + "dev": true, 1503 2777 "license": "MIT", 1504 - "dependencies": { 1505 - "once": "^1.4.0" 2778 + "funding": { 2779 + "url": "https://github.com/sponsors/antfu" 1506 2780 } 1507 2781 }, 1508 2782 "node_modules/es-module-lexer": { ··· 1588 2862 "node": ">=0.8.x" 1589 2863 } 1590 2864 }, 1591 - "node_modules/expand-template": { 1592 - "version": "2.0.3", 1593 - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", 1594 - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", 1595 - "license": "(MIT OR WTFPL)", 2865 + "node_modules/exit-hook": { 2866 + "version": "2.2.1", 2867 + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", 2868 + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", 2869 + "dev": true, 2870 + "license": "MIT", 1596 2871 "engines": { 1597 2872 "node": ">=6" 2873 + }, 2874 + "funding": { 2875 + "url": "https://github.com/sponsors/sindresorhus" 1598 2876 } 1599 2877 }, 1600 2878 "node_modules/expect-type": { ··· 1606 2884 "engines": { 1607 2885 "node": ">=12.0.0" 1608 2886 } 2887 + }, 2888 + "node_modules/exsolve": { 2889 + "version": "1.0.8", 2890 + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", 2891 + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", 2892 + "dev": true, 2893 + "license": "MIT" 1609 2894 }, 1610 2895 "node_modules/fast-redact": { 1611 2896 "version": "3.5.0", ··· 1634 2919 } 1635 2920 } 1636 2921 }, 1637 - "node_modules/file-uri-to-path": { 1638 - "version": "1.0.0", 1639 - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", 1640 - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", 1641 - "license": "MIT" 1642 - }, 1643 - "node_modules/fs-constants": { 1644 - "version": "1.0.0", 1645 - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", 1646 - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", 1647 - "license": "MIT" 1648 - }, 1649 2922 "node_modules/fsevents": { 1650 2923 "version": "2.3.3", 1651 2924 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", ··· 1661 2934 "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1662 2935 } 1663 2936 }, 1664 - "node_modules/get-tsconfig": { 1665 - "version": "4.13.7", 1666 - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", 1667 - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", 2937 + "node_modules/glob-to-regexp": { 2938 + "version": "0.4.1", 2939 + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", 2940 + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", 1668 2941 "dev": true, 1669 - "license": "MIT", 1670 - "dependencies": { 1671 - "resolve-pkg-maps": "^1.0.0" 1672 - }, 1673 - "funding": { 1674 - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 1675 - } 1676 - }, 1677 - "node_modules/github-from-package": { 1678 - "version": "0.0.0", 1679 - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", 1680 - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", 1681 - "license": "MIT" 2942 + "license": "BSD-2-Clause" 1682 2943 }, 1683 2944 "node_modules/hono": { 1684 2945 "version": "4.12.9", ··· 1709 2970 ], 1710 2971 "license": "BSD-3-Clause" 1711 2972 }, 1712 - "node_modules/inherits": { 1713 - "version": "2.0.4", 1714 - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 1715 - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 1716 - "license": "ISC" 1717 - }, 1718 - "node_modules/ini": { 1719 - "version": "1.3.8", 1720 - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", 1721 - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", 1722 - "license": "ISC" 2973 + "node_modules/is-arrayish": { 2974 + "version": "0.3.4", 2975 + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", 2976 + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", 2977 + "dev": true, 2978 + "license": "MIT" 1723 2979 }, 1724 2980 "node_modules/js-tokens": { 1725 2981 "version": "9.0.1", ··· 1728 2984 "dev": true, 1729 2985 "license": "MIT" 1730 2986 }, 2987 + "node_modules/kleur": { 2988 + "version": "4.1.5", 2989 + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", 2990 + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", 2991 + "dev": true, 2992 + "license": "MIT", 2993 + "engines": { 2994 + "node": ">=6" 2995 + } 2996 + }, 1731 2997 "node_modules/loupe": { 1732 2998 "version": "3.2.1", 1733 2999 "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", ··· 1745 3011 "@jridgewell/sourcemap-codec": "^1.5.5" 1746 3012 } 1747 3013 }, 1748 - "node_modules/mimic-response": { 1749 - "version": "3.1.0", 1750 - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", 1751 - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", 3014 + "node_modules/mime": { 3015 + "version": "3.0.0", 3016 + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", 3017 + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", 3018 + "dev": true, 1752 3019 "license": "MIT", 3020 + "bin": { 3021 + "mime": "cli.js" 3022 + }, 1753 3023 "engines": { 1754 - "node": ">=10" 3024 + "node": ">=10.0.0" 3025 + } 3026 + }, 3027 + "node_modules/miniflare": { 3028 + "version": "4.20250906.0", 3029 + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250906.0.tgz", 3030 + "integrity": "sha512-T/RWn1sa0ien80s6NjU+Un/tj12gR6wqScZoiLeMJDD4/fK0UXfnbWXJDubnUED8Xjm7RPQ5ESYdE+mhPmMtuQ==", 3031 + "dev": true, 3032 + "license": "MIT", 3033 + "dependencies": { 3034 + "@cspotcode/source-map-support": "0.8.1", 3035 + "acorn": "8.14.0", 3036 + "acorn-walk": "8.3.2", 3037 + "exit-hook": "2.2.1", 3038 + "glob-to-regexp": "0.4.1", 3039 + "sharp": "^0.33.5", 3040 + "stoppable": "1.1.0", 3041 + "undici": "^7.10.0", 3042 + "workerd": "1.20250906.0", 3043 + "ws": "8.18.0", 3044 + "youch": "4.1.0-beta.10", 3045 + "zod": "3.22.3" 3046 + }, 3047 + "bin": { 3048 + "miniflare": "bootstrap.js" 3049 + }, 3050 + "engines": { 3051 + "node": ">=18.0.0" 3052 + } 3053 + }, 3054 + "node_modules/miniflare/node_modules/ws": { 3055 + "version": "8.18.0", 3056 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", 3057 + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", 3058 + "dev": true, 3059 + "license": "MIT", 3060 + "engines": { 3061 + "node": ">=10.0.0" 3062 + }, 3063 + "peerDependencies": { 3064 + "bufferutil": "^4.0.1", 3065 + "utf-8-validate": ">=5.0.2" 1755 3066 }, 1756 - "funding": { 1757 - "url": "https://github.com/sponsors/sindresorhus" 3067 + "peerDependenciesMeta": { 3068 + "bufferutil": { 3069 + "optional": true 3070 + }, 3071 + "utf-8-validate": { 3072 + "optional": true 3073 + } 1758 3074 } 1759 3075 }, 1760 - "node_modules/minimist": { 1761 - "version": "1.2.8", 1762 - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", 1763 - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", 3076 + "node_modules/miniflare/node_modules/zod": { 3077 + "version": "3.22.3", 3078 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", 3079 + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", 3080 + "dev": true, 1764 3081 "license": "MIT", 1765 3082 "funding": { 1766 - "url": "https://github.com/sponsors/ljharb" 3083 + "url": "https://github.com/sponsors/colinhacks" 1767 3084 } 1768 3085 }, 1769 - "node_modules/mkdirp-classic": { 1770 - "version": "0.5.3", 1771 - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", 1772 - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", 1773 - "license": "MIT" 1774 - }, 1775 3086 "node_modules/ms": { 1776 3087 "version": "2.1.3", 1777 3088 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", ··· 1804 3115 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1805 3116 } 1806 3117 }, 1807 - "node_modules/napi-build-utils": { 1808 - "version": "2.0.0", 1809 - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", 1810 - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", 3118 + "node_modules/ohash": { 3119 + "version": "2.0.11", 3120 + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", 3121 + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", 3122 + "dev": true, 1811 3123 "license": "MIT" 1812 3124 }, 1813 - "node_modules/node-abi": { 1814 - "version": "3.89.0", 1815 - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", 1816 - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", 1817 - "license": "MIT", 1818 - "dependencies": { 1819 - "semver": "^7.3.5" 1820 - }, 1821 - "engines": { 1822 - "node": ">=10" 1823 - } 1824 - }, 1825 3125 "node_modules/on-exit-leak-free": { 1826 3126 "version": "2.1.2", 1827 3127 "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", ··· 1831 3131 "node": ">=14.0.0" 1832 3132 } 1833 3133 }, 1834 - "node_modules/once": { 1835 - "version": "1.4.0", 1836 - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 1837 - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 1838 - "license": "ISC", 1839 - "dependencies": { 1840 - "wrappy": "1" 1841 - } 3134 + "node_modules/path-to-regexp": { 3135 + "version": "6.3.0", 3136 + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", 3137 + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", 3138 + "dev": true, 3139 + "license": "MIT" 1842 3140 }, 1843 3141 "node_modules/pathe": { 1844 3142 "version": "2.0.3", ··· 1944 3242 "node": "^10 || ^12 || >=14" 1945 3243 } 1946 3244 }, 1947 - "node_modules/prebuild-install": { 1948 - "version": "7.1.3", 1949 - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", 1950 - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", 1951 - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", 1952 - "license": "MIT", 1953 - "dependencies": { 1954 - "detect-libc": "^2.0.0", 1955 - "expand-template": "^2.0.3", 1956 - "github-from-package": "0.0.0", 1957 - "minimist": "^1.2.3", 1958 - "mkdirp-classic": "^0.5.3", 1959 - "napi-build-utils": "^2.0.0", 1960 - "node-abi": "^3.3.0", 1961 - "pump": "^3.0.0", 1962 - "rc": "^1.2.7", 1963 - "simple-get": "^4.0.0", 1964 - "tar-fs": "^2.0.0", 1965 - "tunnel-agent": "^0.6.0" 1966 - }, 1967 - "bin": { 1968 - "prebuild-install": "bin.js" 1969 - }, 1970 - "engines": { 1971 - "node": ">=10" 1972 - } 1973 - }, 1974 3245 "node_modules/process": { 1975 3246 "version": "0.11.10", 1976 3247 "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", ··· 1986 3257 "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", 1987 3258 "license": "MIT" 1988 3259 }, 1989 - "node_modules/pump": { 1990 - "version": "3.0.4", 1991 - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", 1992 - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", 1993 - "license": "MIT", 1994 - "dependencies": { 1995 - "end-of-stream": "^1.1.0", 1996 - "once": "^1.3.1" 1997 - } 1998 - }, 1999 3260 "node_modules/quick-format-unescaped": { 2000 3261 "version": "4.0.4", 2001 3262 "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", 2002 3263 "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", 2003 3264 "license": "MIT" 2004 3265 }, 2005 - "node_modules/rc": { 2006 - "version": "1.2.8", 2007 - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", 2008 - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", 2009 - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", 2010 - "dependencies": { 2011 - "deep-extend": "^0.6.0", 2012 - "ini": "~1.3.0", 2013 - "minimist": "^1.2.0", 2014 - "strip-json-comments": "~2.0.1" 2015 - }, 2016 - "bin": { 2017 - "rc": "cli.js" 2018 - } 2019 - }, 2020 3266 "node_modules/readable-stream": { 2021 3267 "version": "4.7.0", 2022 3268 "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", ··· 2040 3286 "license": "MIT", 2041 3287 "engines": { 2042 3288 "node": ">= 12.13.0" 2043 - } 2044 - }, 2045 - "node_modules/resolve-pkg-maps": { 2046 - "version": "1.0.0", 2047 - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 2048 - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 2049 - "dev": true, 2050 - "license": "MIT", 2051 - "funding": { 2052 - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 2053 3289 } 2054 3290 }, 2055 3291 "node_modules/rollup": { ··· 2130 3366 "version": "7.7.4", 2131 3367 "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 2132 3368 "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 3369 + "dev": true, 2133 3370 "license": "ISC", 2134 3371 "bin": { 2135 3372 "semver": "bin/semver.js" ··· 2138 3375 "node": ">=10" 2139 3376 } 2140 3377 }, 3378 + "node_modules/sharp": { 3379 + "version": "0.33.5", 3380 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", 3381 + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", 3382 + "dev": true, 3383 + "hasInstallScript": true, 3384 + "license": "Apache-2.0", 3385 + "dependencies": { 3386 + "color": "^4.2.3", 3387 + "detect-libc": "^2.0.3", 3388 + "semver": "^7.6.3" 3389 + }, 3390 + "engines": { 3391 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 3392 + }, 3393 + "funding": { 3394 + "url": "https://opencollective.com/libvips" 3395 + }, 3396 + "optionalDependencies": { 3397 + "@img/sharp-darwin-arm64": "0.33.5", 3398 + "@img/sharp-darwin-x64": "0.33.5", 3399 + "@img/sharp-libvips-darwin-arm64": "1.0.4", 3400 + "@img/sharp-libvips-darwin-x64": "1.0.4", 3401 + "@img/sharp-libvips-linux-arm": "1.0.5", 3402 + "@img/sharp-libvips-linux-arm64": "1.0.4", 3403 + "@img/sharp-libvips-linux-s390x": "1.0.4", 3404 + "@img/sharp-libvips-linux-x64": "1.0.4", 3405 + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", 3406 + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", 3407 + "@img/sharp-linux-arm": "0.33.5", 3408 + "@img/sharp-linux-arm64": "0.33.5", 3409 + "@img/sharp-linux-s390x": "0.33.5", 3410 + "@img/sharp-linux-x64": "0.33.5", 3411 + "@img/sharp-linuxmusl-arm64": "0.33.5", 3412 + "@img/sharp-linuxmusl-x64": "0.33.5", 3413 + "@img/sharp-wasm32": "0.33.5", 3414 + "@img/sharp-win32-ia32": "0.33.5", 3415 + "@img/sharp-win32-x64": "0.33.5" 3416 + } 3417 + }, 2141 3418 "node_modules/siginfo": { 2142 3419 "version": "2.0.0", 2143 3420 "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", ··· 2145 3422 "dev": true, 2146 3423 "license": "ISC" 2147 3424 }, 2148 - "node_modules/simple-concat": { 2149 - "version": "1.0.1", 2150 - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", 2151 - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", 2152 - "funding": [ 2153 - { 2154 - "type": "github", 2155 - "url": "https://github.com/sponsors/feross" 2156 - }, 2157 - { 2158 - "type": "patreon", 2159 - "url": "https://www.patreon.com/feross" 2160 - }, 2161 - { 2162 - "type": "consulting", 2163 - "url": "https://feross.org/support" 2164 - } 2165 - ], 2166 - "license": "MIT" 2167 - }, 2168 - "node_modules/simple-get": { 2169 - "version": "4.0.1", 2170 - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", 2171 - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", 2172 - "funding": [ 2173 - { 2174 - "type": "github", 2175 - "url": "https://github.com/sponsors/feross" 2176 - }, 2177 - { 2178 - "type": "patreon", 2179 - "url": "https://www.patreon.com/feross" 2180 - }, 2181 - { 2182 - "type": "consulting", 2183 - "url": "https://feross.org/support" 2184 - } 2185 - ], 3425 + "node_modules/simple-swizzle": { 3426 + "version": "0.2.4", 3427 + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", 3428 + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", 3429 + "dev": true, 2186 3430 "license": "MIT", 2187 3431 "dependencies": { 2188 - "decompress-response": "^6.0.0", 2189 - "once": "^1.3.1", 2190 - "simple-concat": "^1.0.0" 3432 + "is-arrayish": "^0.3.1" 2191 3433 } 2192 3434 }, 2193 3435 "node_modules/sonic-boom": { ··· 2232 3474 "dev": true, 2233 3475 "license": "MIT" 2234 3476 }, 3477 + "node_modules/stoppable": { 3478 + "version": "1.1.0", 3479 + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", 3480 + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", 3481 + "dev": true, 3482 + "license": "MIT", 3483 + "engines": { 3484 + "node": ">=4", 3485 + "npm": ">=6" 3486 + } 3487 + }, 2235 3488 "node_modules/string_decoder": { 2236 3489 "version": "1.3.0", 2237 3490 "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", ··· 2241 3494 "safe-buffer": "~5.2.0" 2242 3495 } 2243 3496 }, 2244 - "node_modules/strip-json-comments": { 2245 - "version": "2.0.1", 2246 - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", 2247 - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", 2248 - "license": "MIT", 2249 - "engines": { 2250 - "node": ">=0.10.0" 2251 - } 2252 - }, 2253 3497 "node_modules/strip-literal": { 2254 3498 "version": "3.1.0", 2255 3499 "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", ··· 2263 3507 "url": "https://github.com/sponsors/antfu" 2264 3508 } 2265 3509 }, 2266 - "node_modules/tar-fs": { 2267 - "version": "2.1.4", 2268 - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", 2269 - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", 2270 - "license": "MIT", 2271 - "dependencies": { 2272 - "chownr": "^1.1.1", 2273 - "mkdirp-classic": "^0.5.2", 2274 - "pump": "^3.0.0", 2275 - "tar-stream": "^2.1.4" 2276 - } 2277 - }, 2278 - "node_modules/tar-stream": { 2279 - "version": "2.2.0", 2280 - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", 2281 - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", 3510 + "node_modules/supports-color": { 3511 + "version": "10.2.2", 3512 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", 3513 + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", 3514 + "dev": true, 2282 3515 "license": "MIT", 2283 - "dependencies": { 2284 - "bl": "^4.0.3", 2285 - "end-of-stream": "^1.4.1", 2286 - "fs-constants": "^1.0.0", 2287 - "inherits": "^2.0.3", 2288 - "readable-stream": "^3.1.1" 2289 - }, 2290 3516 "engines": { 2291 - "node": ">=6" 2292 - } 2293 - }, 2294 - "node_modules/tar-stream/node_modules/readable-stream": { 2295 - "version": "3.6.2", 2296 - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", 2297 - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", 2298 - "license": "MIT", 2299 - "dependencies": { 2300 - "inherits": "^2.0.3", 2301 - "string_decoder": "^1.1.1", 2302 - "util-deprecate": "^1.0.1" 3517 + "node": ">=18" 2303 3518 }, 2304 - "engines": { 2305 - "node": ">= 6" 3519 + "funding": { 3520 + "url": "https://github.com/chalk/supports-color?sponsor=1" 2306 3521 } 2307 3522 }, 2308 3523 "node_modules/thread-stream": { ··· 2381 3596 "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 2382 3597 "license": "0BSD" 2383 3598 }, 2384 - "node_modules/tsx": { 2385 - "version": "4.21.0", 2386 - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", 2387 - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", 2388 - "dev": true, 2389 - "license": "MIT", 2390 - "dependencies": { 2391 - "esbuild": "~0.27.0", 2392 - "get-tsconfig": "^4.7.5" 2393 - }, 2394 - "bin": { 2395 - "tsx": "dist/cli.mjs" 2396 - }, 2397 - "engines": { 2398 - "node": ">=18.0.0" 2399 - }, 2400 - "optionalDependencies": { 2401 - "fsevents": "~2.3.3" 2402 - } 2403 - }, 2404 - "node_modules/tunnel-agent": { 2405 - "version": "0.6.0", 2406 - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", 2407 - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", 2408 - "license": "Apache-2.0", 2409 - "dependencies": { 2410 - "safe-buffer": "^5.0.1" 2411 - }, 2412 - "engines": { 2413 - "node": "*" 2414 - } 2415 - }, 2416 3599 "node_modules/typescript": { 2417 3600 "version": "5.9.3", 2418 3601 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", ··· 2427 3610 "node": ">=14.17" 2428 3611 } 2429 3612 }, 3613 + "node_modules/ufo": { 3614 + "version": "1.6.3", 3615 + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", 3616 + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", 3617 + "dev": true, 3618 + "license": "MIT" 3619 + }, 2430 3620 "node_modules/uint8arrays": { 2431 3621 "version": "5.1.0", 2432 3622 "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", ··· 2442 3632 "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", 2443 3633 "license": "Apache-2.0 OR MIT" 2444 3634 }, 2445 - "node_modules/undici-types": { 2446 - "version": "7.18.2", 2447 - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", 2448 - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", 3635 + "node_modules/undici": { 3636 + "version": "7.24.6", 3637 + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", 3638 + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", 3639 + "dev": true, 3640 + "license": "MIT", 3641 + "engines": { 3642 + "node": ">=20.18.1" 3643 + } 3644 + }, 3645 + "node_modules/unenv": { 3646 + "version": "2.0.0-rc.24", 3647 + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", 3648 + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", 2449 3649 "dev": true, 2450 - "license": "MIT" 3650 + "license": "MIT", 3651 + "dependencies": { 3652 + "pathe": "^2.0.3" 3653 + } 2451 3654 }, 2452 3655 "node_modules/unicode-segmenter": { 2453 3656 "version": "0.14.5", 2454 3657 "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 2455 3658 "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 2456 - "license": "MIT" 2457 - }, 2458 - "node_modules/util-deprecate": { 2459 - "version": "1.0.2", 2460 - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 2461 - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 2462 3659 "license": "MIT" 2463 3660 }, 2464 3661 "node_modules/varint": { ··· 2655 3852 "node": ">=8" 2656 3853 } 2657 3854 }, 2658 - "node_modules/wrappy": { 2659 - "version": "1.0.2", 2660 - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 2661 - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", 2662 - "license": "ISC" 3855 + "node_modules/workerd": { 3856 + "version": "1.20250906.0", 3857 + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250906.0.tgz", 3858 + "integrity": "sha512-ryVyEaqXPPsr/AxccRmYZZmDAkfQVjhfRqrNTlEeN8aftBk6Ca1u7/VqmfOayjCXrA+O547TauebU+J3IpvFXw==", 3859 + "dev": true, 3860 + "hasInstallScript": true, 3861 + "license": "Apache-2.0", 3862 + "bin": { 3863 + "workerd": "bin/workerd" 3864 + }, 3865 + "engines": { 3866 + "node": ">=16" 3867 + }, 3868 + "optionalDependencies": { 3869 + "@cloudflare/workerd-darwin-64": "1.20250906.0", 3870 + "@cloudflare/workerd-darwin-arm64": "1.20250906.0", 3871 + "@cloudflare/workerd-linux-64": "1.20250906.0", 3872 + "@cloudflare/workerd-linux-arm64": "1.20250906.0", 3873 + "@cloudflare/workerd-windows-64": "1.20250906.0" 3874 + } 2663 3875 }, 2664 - "node_modules/ws": { 2665 - "version": "8.20.0", 2666 - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", 2667 - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", 3876 + "node_modules/wrangler": { 3877 + "version": "4.78.0", 3878 + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.78.0.tgz", 3879 + "integrity": "sha512-He/vUhk4ih0D0eFmtNnlbT6Od8j+BEokaSR+oYjbVsH0SWIrIch+eHqfLRSBjBQaOoh6HCNxcafcIkBm2u0Hag==", 3880 + "dev": true, 3881 + "license": "MIT OR Apache-2.0", 3882 + "dependencies": { 3883 + "@cloudflare/kv-asset-handler": "0.4.2", 3884 + "@cloudflare/unenv-preset": "2.16.0", 3885 + "blake3-wasm": "2.1.5", 3886 + "esbuild": "0.27.3", 3887 + "miniflare": "4.20260317.3", 3888 + "path-to-regexp": "6.3.0", 3889 + "unenv": "2.0.0-rc.24", 3890 + "workerd": "1.20260317.1" 3891 + }, 3892 + "bin": { 3893 + "wrangler": "bin/wrangler.js", 3894 + "wrangler2": "bin/wrangler.js" 3895 + }, 3896 + "engines": { 3897 + "node": ">=20.3.0" 3898 + }, 3899 + "optionalDependencies": { 3900 + "fsevents": "~2.3.2" 3901 + }, 3902 + "peerDependencies": { 3903 + "@cloudflare/workers-types": "^4.20260317.1" 3904 + }, 3905 + "peerDependenciesMeta": { 3906 + "@cloudflare/workers-types": { 3907 + "optional": true 3908 + } 3909 + } 3910 + }, 3911 + "node_modules/wrangler/node_modules/@cloudflare/unenv-preset": { 3912 + "version": "2.16.0", 3913 + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz", 3914 + "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==", 3915 + "dev": true, 3916 + "license": "MIT OR Apache-2.0", 3917 + "peerDependencies": { 3918 + "unenv": "2.0.0-rc.24", 3919 + "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" 3920 + }, 3921 + "peerDependenciesMeta": { 3922 + "workerd": { 3923 + "optional": true 3924 + } 3925 + } 3926 + }, 3927 + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { 3928 + "version": "1.20260317.1", 3929 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260317.1.tgz", 3930 + "integrity": "sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g==", 3931 + "cpu": [ 3932 + "x64" 3933 + ], 3934 + "dev": true, 3935 + "license": "Apache-2.0", 3936 + "optional": true, 3937 + "os": [ 3938 + "darwin" 3939 + ], 3940 + "engines": { 3941 + "node": ">=16" 3942 + } 3943 + }, 3944 + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { 3945 + "version": "1.20260317.1", 3946 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260317.1.tgz", 3947 + "integrity": "sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg==", 3948 + "cpu": [ 3949 + "arm64" 3950 + ], 3951 + "dev": true, 3952 + "license": "Apache-2.0", 3953 + "optional": true, 3954 + "os": [ 3955 + "darwin" 3956 + ], 3957 + "engines": { 3958 + "node": ">=16" 3959 + } 3960 + }, 3961 + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { 3962 + "version": "1.20260317.1", 3963 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260317.1.tgz", 3964 + "integrity": "sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug==", 3965 + "cpu": [ 3966 + "x64" 3967 + ], 3968 + "dev": true, 3969 + "license": "Apache-2.0", 3970 + "optional": true, 3971 + "os": [ 3972 + "linux" 3973 + ], 3974 + "engines": { 3975 + "node": ">=16" 3976 + } 3977 + }, 3978 + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { 3979 + "version": "1.20260317.1", 3980 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260317.1.tgz", 3981 + "integrity": "sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw==", 3982 + "cpu": [ 3983 + "arm64" 3984 + ], 3985 + "dev": true, 3986 + "license": "Apache-2.0", 3987 + "optional": true, 3988 + "os": [ 3989 + "linux" 3990 + ], 3991 + "engines": { 3992 + "node": ">=16" 3993 + } 3994 + }, 3995 + "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { 3996 + "version": "1.20260317.1", 3997 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260317.1.tgz", 3998 + "integrity": "sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ==", 3999 + "cpu": [ 4000 + "x64" 4001 + ], 4002 + "dev": true, 4003 + "license": "Apache-2.0", 4004 + "optional": true, 4005 + "os": [ 4006 + "win32" 4007 + ], 4008 + "engines": { 4009 + "node": ">=16" 4010 + } 4011 + }, 4012 + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { 4013 + "version": "0.27.3", 4014 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", 4015 + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", 4016 + "cpu": [ 4017 + "ppc64" 4018 + ], 4019 + "dev": true, 4020 + "license": "MIT", 4021 + "optional": true, 4022 + "os": [ 4023 + "aix" 4024 + ], 4025 + "engines": { 4026 + "node": ">=18" 4027 + } 4028 + }, 4029 + "node_modules/wrangler/node_modules/@esbuild/android-arm": { 4030 + "version": "0.27.3", 4031 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", 4032 + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", 4033 + "cpu": [ 4034 + "arm" 4035 + ], 4036 + "dev": true, 4037 + "license": "MIT", 4038 + "optional": true, 4039 + "os": [ 4040 + "android" 4041 + ], 4042 + "engines": { 4043 + "node": ">=18" 4044 + } 4045 + }, 4046 + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { 4047 + "version": "0.27.3", 4048 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", 4049 + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", 4050 + "cpu": [ 4051 + "arm64" 4052 + ], 4053 + "dev": true, 4054 + "license": "MIT", 4055 + "optional": true, 4056 + "os": [ 4057 + "android" 4058 + ], 4059 + "engines": { 4060 + "node": ">=18" 4061 + } 4062 + }, 4063 + "node_modules/wrangler/node_modules/@esbuild/android-x64": { 4064 + "version": "0.27.3", 4065 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", 4066 + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", 4067 + "cpu": [ 4068 + "x64" 4069 + ], 4070 + "dev": true, 4071 + "license": "MIT", 4072 + "optional": true, 4073 + "os": [ 4074 + "android" 4075 + ], 4076 + "engines": { 4077 + "node": ">=18" 4078 + } 4079 + }, 4080 + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { 4081 + "version": "0.27.3", 4082 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", 4083 + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", 4084 + "cpu": [ 4085 + "arm64" 4086 + ], 4087 + "dev": true, 4088 + "license": "MIT", 4089 + "optional": true, 4090 + "os": [ 4091 + "darwin" 4092 + ], 4093 + "engines": { 4094 + "node": ">=18" 4095 + } 4096 + }, 4097 + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { 4098 + "version": "0.27.3", 4099 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", 4100 + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", 4101 + "cpu": [ 4102 + "x64" 4103 + ], 4104 + "dev": true, 4105 + "license": "MIT", 4106 + "optional": true, 4107 + "os": [ 4108 + "darwin" 4109 + ], 4110 + "engines": { 4111 + "node": ">=18" 4112 + } 4113 + }, 4114 + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { 4115 + "version": "0.27.3", 4116 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", 4117 + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", 4118 + "cpu": [ 4119 + "arm64" 4120 + ], 4121 + "dev": true, 4122 + "license": "MIT", 4123 + "optional": true, 4124 + "os": [ 4125 + "freebsd" 4126 + ], 4127 + "engines": { 4128 + "node": ">=18" 4129 + } 4130 + }, 4131 + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { 4132 + "version": "0.27.3", 4133 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", 4134 + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", 4135 + "cpu": [ 4136 + "x64" 4137 + ], 4138 + "dev": true, 4139 + "license": "MIT", 4140 + "optional": true, 4141 + "os": [ 4142 + "freebsd" 4143 + ], 4144 + "engines": { 4145 + "node": ">=18" 4146 + } 4147 + }, 4148 + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { 4149 + "version": "0.27.3", 4150 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", 4151 + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", 4152 + "cpu": [ 4153 + "arm" 4154 + ], 4155 + "dev": true, 4156 + "license": "MIT", 4157 + "optional": true, 4158 + "os": [ 4159 + "linux" 4160 + ], 4161 + "engines": { 4162 + "node": ">=18" 4163 + } 4164 + }, 4165 + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { 4166 + "version": "0.27.3", 4167 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", 4168 + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", 4169 + "cpu": [ 4170 + "arm64" 4171 + ], 4172 + "dev": true, 4173 + "license": "MIT", 4174 + "optional": true, 4175 + "os": [ 4176 + "linux" 4177 + ], 4178 + "engines": { 4179 + "node": ">=18" 4180 + } 4181 + }, 4182 + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { 4183 + "version": "0.27.3", 4184 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", 4185 + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", 4186 + "cpu": [ 4187 + "ia32" 4188 + ], 4189 + "dev": true, 4190 + "license": "MIT", 4191 + "optional": true, 4192 + "os": [ 4193 + "linux" 4194 + ], 4195 + "engines": { 4196 + "node": ">=18" 4197 + } 4198 + }, 4199 + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { 4200 + "version": "0.27.3", 4201 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", 4202 + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", 4203 + "cpu": [ 4204 + "loong64" 4205 + ], 4206 + "dev": true, 4207 + "license": "MIT", 4208 + "optional": true, 4209 + "os": [ 4210 + "linux" 4211 + ], 4212 + "engines": { 4213 + "node": ">=18" 4214 + } 4215 + }, 4216 + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { 4217 + "version": "0.27.3", 4218 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", 4219 + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", 4220 + "cpu": [ 4221 + "mips64el" 4222 + ], 4223 + "dev": true, 4224 + "license": "MIT", 4225 + "optional": true, 4226 + "os": [ 4227 + "linux" 4228 + ], 4229 + "engines": { 4230 + "node": ">=18" 4231 + } 4232 + }, 4233 + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { 4234 + "version": "0.27.3", 4235 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", 4236 + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", 4237 + "cpu": [ 4238 + "ppc64" 4239 + ], 4240 + "dev": true, 4241 + "license": "MIT", 4242 + "optional": true, 4243 + "os": [ 4244 + "linux" 4245 + ], 4246 + "engines": { 4247 + "node": ">=18" 4248 + } 4249 + }, 4250 + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { 4251 + "version": "0.27.3", 4252 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", 4253 + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", 4254 + "cpu": [ 4255 + "riscv64" 4256 + ], 4257 + "dev": true, 4258 + "license": "MIT", 4259 + "optional": true, 4260 + "os": [ 4261 + "linux" 4262 + ], 4263 + "engines": { 4264 + "node": ">=18" 4265 + } 4266 + }, 4267 + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { 4268 + "version": "0.27.3", 4269 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", 4270 + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", 4271 + "cpu": [ 4272 + "s390x" 4273 + ], 4274 + "dev": true, 4275 + "license": "MIT", 4276 + "optional": true, 4277 + "os": [ 4278 + "linux" 4279 + ], 4280 + "engines": { 4281 + "node": ">=18" 4282 + } 4283 + }, 4284 + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { 4285 + "version": "0.27.3", 4286 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", 4287 + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", 4288 + "cpu": [ 4289 + "x64" 4290 + ], 4291 + "dev": true, 4292 + "license": "MIT", 4293 + "optional": true, 4294 + "os": [ 4295 + "linux" 4296 + ], 4297 + "engines": { 4298 + "node": ">=18" 4299 + } 4300 + }, 4301 + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { 4302 + "version": "0.27.3", 4303 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", 4304 + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", 4305 + "cpu": [ 4306 + "arm64" 4307 + ], 4308 + "dev": true, 4309 + "license": "MIT", 4310 + "optional": true, 4311 + "os": [ 4312 + "netbsd" 4313 + ], 4314 + "engines": { 4315 + "node": ">=18" 4316 + } 4317 + }, 4318 + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { 4319 + "version": "0.27.3", 4320 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", 4321 + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", 4322 + "cpu": [ 4323 + "x64" 4324 + ], 4325 + "dev": true, 4326 + "license": "MIT", 4327 + "optional": true, 4328 + "os": [ 4329 + "netbsd" 4330 + ], 4331 + "engines": { 4332 + "node": ">=18" 4333 + } 4334 + }, 4335 + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { 4336 + "version": "0.27.3", 4337 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", 4338 + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", 4339 + "cpu": [ 4340 + "arm64" 4341 + ], 4342 + "dev": true, 4343 + "license": "MIT", 4344 + "optional": true, 4345 + "os": [ 4346 + "openbsd" 4347 + ], 4348 + "engines": { 4349 + "node": ">=18" 4350 + } 4351 + }, 4352 + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { 4353 + "version": "0.27.3", 4354 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", 4355 + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", 4356 + "cpu": [ 4357 + "x64" 4358 + ], 4359 + "dev": true, 4360 + "license": "MIT", 4361 + "optional": true, 4362 + "os": [ 4363 + "openbsd" 4364 + ], 4365 + "engines": { 4366 + "node": ">=18" 4367 + } 4368 + }, 4369 + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { 4370 + "version": "0.27.3", 4371 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", 4372 + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", 4373 + "cpu": [ 4374 + "arm64" 4375 + ], 4376 + "dev": true, 4377 + "license": "MIT", 4378 + "optional": true, 4379 + "os": [ 4380 + "openharmony" 4381 + ], 4382 + "engines": { 4383 + "node": ">=18" 4384 + } 4385 + }, 4386 + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { 4387 + "version": "0.27.3", 4388 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", 4389 + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", 4390 + "cpu": [ 4391 + "x64" 4392 + ], 4393 + "dev": true, 4394 + "license": "MIT", 4395 + "optional": true, 4396 + "os": [ 4397 + "sunos" 4398 + ], 4399 + "engines": { 4400 + "node": ">=18" 4401 + } 4402 + }, 4403 + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { 4404 + "version": "0.27.3", 4405 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", 4406 + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", 4407 + "cpu": [ 4408 + "arm64" 4409 + ], 4410 + "dev": true, 4411 + "license": "MIT", 4412 + "optional": true, 4413 + "os": [ 4414 + "win32" 4415 + ], 4416 + "engines": { 4417 + "node": ">=18" 4418 + } 4419 + }, 4420 + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { 4421 + "version": "0.27.3", 4422 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", 4423 + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", 4424 + "cpu": [ 4425 + "ia32" 4426 + ], 4427 + "dev": true, 4428 + "license": "MIT", 4429 + "optional": true, 4430 + "os": [ 4431 + "win32" 4432 + ], 4433 + "engines": { 4434 + "node": ">=18" 4435 + } 4436 + }, 4437 + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { 4438 + "version": "0.27.3", 4439 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", 4440 + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", 4441 + "cpu": [ 4442 + "x64" 4443 + ], 4444 + "dev": true, 4445 + "license": "MIT", 4446 + "optional": true, 4447 + "os": [ 4448 + "win32" 4449 + ], 4450 + "engines": { 4451 + "node": ">=18" 4452 + } 4453 + }, 4454 + "node_modules/wrangler/node_modules/@img/sharp-darwin-arm64": { 4455 + "version": "0.34.5", 4456 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", 4457 + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", 4458 + "cpu": [ 4459 + "arm64" 4460 + ], 4461 + "dev": true, 4462 + "license": "Apache-2.0", 4463 + "optional": true, 4464 + "os": [ 4465 + "darwin" 4466 + ], 4467 + "engines": { 4468 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4469 + }, 4470 + "funding": { 4471 + "url": "https://opencollective.com/libvips" 4472 + }, 4473 + "optionalDependencies": { 4474 + "@img/sharp-libvips-darwin-arm64": "1.2.4" 4475 + } 4476 + }, 4477 + "node_modules/wrangler/node_modules/@img/sharp-darwin-x64": { 4478 + "version": "0.34.5", 4479 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", 4480 + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", 4481 + "cpu": [ 4482 + "x64" 4483 + ], 4484 + "dev": true, 4485 + "license": "Apache-2.0", 4486 + "optional": true, 4487 + "os": [ 4488 + "darwin" 4489 + ], 4490 + "engines": { 4491 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4492 + }, 4493 + "funding": { 4494 + "url": "https://opencollective.com/libvips" 4495 + }, 4496 + "optionalDependencies": { 4497 + "@img/sharp-libvips-darwin-x64": "1.2.4" 4498 + } 4499 + }, 4500 + "node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-arm64": { 4501 + "version": "1.2.4", 4502 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", 4503 + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", 4504 + "cpu": [ 4505 + "arm64" 4506 + ], 4507 + "dev": true, 4508 + "license": "LGPL-3.0-or-later", 4509 + "optional": true, 4510 + "os": [ 4511 + "darwin" 4512 + ], 4513 + "funding": { 4514 + "url": "https://opencollective.com/libvips" 4515 + } 4516 + }, 4517 + "node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-x64": { 4518 + "version": "1.2.4", 4519 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", 4520 + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", 4521 + "cpu": [ 4522 + "x64" 4523 + ], 4524 + "dev": true, 4525 + "license": "LGPL-3.0-or-later", 4526 + "optional": true, 4527 + "os": [ 4528 + "darwin" 4529 + ], 4530 + "funding": { 4531 + "url": "https://opencollective.com/libvips" 4532 + } 4533 + }, 4534 + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm": { 4535 + "version": "1.2.4", 4536 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", 4537 + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", 4538 + "cpu": [ 4539 + "arm" 4540 + ], 4541 + "dev": true, 4542 + "license": "LGPL-3.0-or-later", 4543 + "optional": true, 4544 + "os": [ 4545 + "linux" 4546 + ], 4547 + "funding": { 4548 + "url": "https://opencollective.com/libvips" 4549 + } 4550 + }, 4551 + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm64": { 4552 + "version": "1.2.4", 4553 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", 4554 + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", 4555 + "cpu": [ 4556 + "arm64" 4557 + ], 4558 + "dev": true, 4559 + "license": "LGPL-3.0-or-later", 4560 + "optional": true, 4561 + "os": [ 4562 + "linux" 4563 + ], 4564 + "funding": { 4565 + "url": "https://opencollective.com/libvips" 4566 + } 4567 + }, 4568 + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-s390x": { 4569 + "version": "1.2.4", 4570 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", 4571 + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", 4572 + "cpu": [ 4573 + "s390x" 4574 + ], 4575 + "dev": true, 4576 + "license": "LGPL-3.0-or-later", 4577 + "optional": true, 4578 + "os": [ 4579 + "linux" 4580 + ], 4581 + "funding": { 4582 + "url": "https://opencollective.com/libvips" 4583 + } 4584 + }, 4585 + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-x64": { 4586 + "version": "1.2.4", 4587 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", 4588 + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", 4589 + "cpu": [ 4590 + "x64" 4591 + ], 4592 + "dev": true, 4593 + "license": "LGPL-3.0-or-later", 4594 + "optional": true, 4595 + "os": [ 4596 + "linux" 4597 + ], 4598 + "funding": { 4599 + "url": "https://opencollective.com/libvips" 4600 + } 4601 + }, 4602 + "node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-arm64": { 4603 + "version": "1.2.4", 4604 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", 4605 + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", 4606 + "cpu": [ 4607 + "arm64" 4608 + ], 4609 + "dev": true, 4610 + "license": "LGPL-3.0-or-later", 4611 + "optional": true, 4612 + "os": [ 4613 + "linux" 4614 + ], 4615 + "funding": { 4616 + "url": "https://opencollective.com/libvips" 4617 + } 4618 + }, 4619 + "node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-x64": { 4620 + "version": "1.2.4", 4621 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", 4622 + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", 4623 + "cpu": [ 4624 + "x64" 4625 + ], 4626 + "dev": true, 4627 + "license": "LGPL-3.0-or-later", 4628 + "optional": true, 4629 + "os": [ 4630 + "linux" 4631 + ], 4632 + "funding": { 4633 + "url": "https://opencollective.com/libvips" 4634 + } 4635 + }, 4636 + "node_modules/wrangler/node_modules/@img/sharp-linux-arm": { 4637 + "version": "0.34.5", 4638 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", 4639 + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", 4640 + "cpu": [ 4641 + "arm" 4642 + ], 4643 + "dev": true, 4644 + "license": "Apache-2.0", 4645 + "optional": true, 4646 + "os": [ 4647 + "linux" 4648 + ], 4649 + "engines": { 4650 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4651 + }, 4652 + "funding": { 4653 + "url": "https://opencollective.com/libvips" 4654 + }, 4655 + "optionalDependencies": { 4656 + "@img/sharp-libvips-linux-arm": "1.2.4" 4657 + } 4658 + }, 4659 + "node_modules/wrangler/node_modules/@img/sharp-linux-arm64": { 4660 + "version": "0.34.5", 4661 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", 4662 + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", 4663 + "cpu": [ 4664 + "arm64" 4665 + ], 4666 + "dev": true, 4667 + "license": "Apache-2.0", 4668 + "optional": true, 4669 + "os": [ 4670 + "linux" 4671 + ], 4672 + "engines": { 4673 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4674 + }, 4675 + "funding": { 4676 + "url": "https://opencollective.com/libvips" 4677 + }, 4678 + "optionalDependencies": { 4679 + "@img/sharp-libvips-linux-arm64": "1.2.4" 4680 + } 4681 + }, 4682 + "node_modules/wrangler/node_modules/@img/sharp-linux-s390x": { 4683 + "version": "0.34.5", 4684 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", 4685 + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", 4686 + "cpu": [ 4687 + "s390x" 4688 + ], 4689 + "dev": true, 4690 + "license": "Apache-2.0", 4691 + "optional": true, 4692 + "os": [ 4693 + "linux" 4694 + ], 4695 + "engines": { 4696 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4697 + }, 4698 + "funding": { 4699 + "url": "https://opencollective.com/libvips" 4700 + }, 4701 + "optionalDependencies": { 4702 + "@img/sharp-libvips-linux-s390x": "1.2.4" 4703 + } 4704 + }, 4705 + "node_modules/wrangler/node_modules/@img/sharp-linux-x64": { 4706 + "version": "0.34.5", 4707 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", 4708 + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", 4709 + "cpu": [ 4710 + "x64" 4711 + ], 4712 + "dev": true, 4713 + "license": "Apache-2.0", 4714 + "optional": true, 4715 + "os": [ 4716 + "linux" 4717 + ], 4718 + "engines": { 4719 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4720 + }, 4721 + "funding": { 4722 + "url": "https://opencollective.com/libvips" 4723 + }, 4724 + "optionalDependencies": { 4725 + "@img/sharp-libvips-linux-x64": "1.2.4" 4726 + } 4727 + }, 4728 + "node_modules/wrangler/node_modules/@img/sharp-linuxmusl-arm64": { 4729 + "version": "0.34.5", 4730 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", 4731 + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", 4732 + "cpu": [ 4733 + "arm64" 4734 + ], 4735 + "dev": true, 4736 + "license": "Apache-2.0", 4737 + "optional": true, 4738 + "os": [ 4739 + "linux" 4740 + ], 4741 + "engines": { 4742 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4743 + }, 4744 + "funding": { 4745 + "url": "https://opencollective.com/libvips" 4746 + }, 4747 + "optionalDependencies": { 4748 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" 4749 + } 4750 + }, 4751 + "node_modules/wrangler/node_modules/@img/sharp-linuxmusl-x64": { 4752 + "version": "0.34.5", 4753 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", 4754 + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", 4755 + "cpu": [ 4756 + "x64" 4757 + ], 4758 + "dev": true, 4759 + "license": "Apache-2.0", 4760 + "optional": true, 4761 + "os": [ 4762 + "linux" 4763 + ], 4764 + "engines": { 4765 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4766 + }, 4767 + "funding": { 4768 + "url": "https://opencollective.com/libvips" 4769 + }, 4770 + "optionalDependencies": { 4771 + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" 4772 + } 4773 + }, 4774 + "node_modules/wrangler/node_modules/@img/sharp-wasm32": { 4775 + "version": "0.34.5", 4776 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", 4777 + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", 4778 + "cpu": [ 4779 + "wasm32" 4780 + ], 4781 + "dev": true, 4782 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", 4783 + "optional": true, 4784 + "dependencies": { 4785 + "@emnapi/runtime": "^1.7.0" 4786 + }, 4787 + "engines": { 4788 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4789 + }, 4790 + "funding": { 4791 + "url": "https://opencollective.com/libvips" 4792 + } 4793 + }, 4794 + "node_modules/wrangler/node_modules/@img/sharp-win32-ia32": { 4795 + "version": "0.34.5", 4796 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", 4797 + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", 4798 + "cpu": [ 4799 + "ia32" 4800 + ], 4801 + "dev": true, 4802 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 4803 + "optional": true, 4804 + "os": [ 4805 + "win32" 4806 + ], 4807 + "engines": { 4808 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4809 + }, 4810 + "funding": { 4811 + "url": "https://opencollective.com/libvips" 4812 + } 4813 + }, 4814 + "node_modules/wrangler/node_modules/@img/sharp-win32-x64": { 4815 + "version": "0.34.5", 4816 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", 4817 + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", 4818 + "cpu": [ 4819 + "x64" 4820 + ], 4821 + "dev": true, 4822 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 4823 + "optional": true, 4824 + "os": [ 4825 + "win32" 4826 + ], 4827 + "engines": { 4828 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4829 + }, 4830 + "funding": { 4831 + "url": "https://opencollective.com/libvips" 4832 + } 4833 + }, 4834 + "node_modules/wrangler/node_modules/esbuild": { 4835 + "version": "0.27.3", 4836 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", 4837 + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", 4838 + "dev": true, 4839 + "hasInstallScript": true, 4840 + "license": "MIT", 4841 + "bin": { 4842 + "esbuild": "bin/esbuild" 4843 + }, 4844 + "engines": { 4845 + "node": ">=18" 4846 + }, 4847 + "optionalDependencies": { 4848 + "@esbuild/aix-ppc64": "0.27.3", 4849 + "@esbuild/android-arm": "0.27.3", 4850 + "@esbuild/android-arm64": "0.27.3", 4851 + "@esbuild/android-x64": "0.27.3", 4852 + "@esbuild/darwin-arm64": "0.27.3", 4853 + "@esbuild/darwin-x64": "0.27.3", 4854 + "@esbuild/freebsd-arm64": "0.27.3", 4855 + "@esbuild/freebsd-x64": "0.27.3", 4856 + "@esbuild/linux-arm": "0.27.3", 4857 + "@esbuild/linux-arm64": "0.27.3", 4858 + "@esbuild/linux-ia32": "0.27.3", 4859 + "@esbuild/linux-loong64": "0.27.3", 4860 + "@esbuild/linux-mips64el": "0.27.3", 4861 + "@esbuild/linux-ppc64": "0.27.3", 4862 + "@esbuild/linux-riscv64": "0.27.3", 4863 + "@esbuild/linux-s390x": "0.27.3", 4864 + "@esbuild/linux-x64": "0.27.3", 4865 + "@esbuild/netbsd-arm64": "0.27.3", 4866 + "@esbuild/netbsd-x64": "0.27.3", 4867 + "@esbuild/openbsd-arm64": "0.27.3", 4868 + "@esbuild/openbsd-x64": "0.27.3", 4869 + "@esbuild/openharmony-arm64": "0.27.3", 4870 + "@esbuild/sunos-x64": "0.27.3", 4871 + "@esbuild/win32-arm64": "0.27.3", 4872 + "@esbuild/win32-ia32": "0.27.3", 4873 + "@esbuild/win32-x64": "0.27.3" 4874 + } 4875 + }, 4876 + "node_modules/wrangler/node_modules/miniflare": { 4877 + "version": "4.20260317.3", 4878 + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260317.3.tgz", 4879 + "integrity": "sha512-tK78D3X4q30/SXqVwMhWrUfH+ffRou9dJLC+jkhNy5zh1I7i7T4JH6xihOvYxdCSBavJ5fQXaaxDJz6orh09BA==", 4880 + "dev": true, 4881 + "license": "MIT", 4882 + "dependencies": { 4883 + "@cspotcode/source-map-support": "0.8.1", 4884 + "sharp": "^0.34.5", 4885 + "undici": "7.24.4", 4886 + "workerd": "1.20260317.1", 4887 + "ws": "8.18.0", 4888 + "youch": "4.1.0-beta.10" 4889 + }, 4890 + "bin": { 4891 + "miniflare": "bootstrap.js" 4892 + }, 4893 + "engines": { 4894 + "node": ">=18.0.0" 4895 + } 4896 + }, 4897 + "node_modules/wrangler/node_modules/sharp": { 4898 + "version": "0.34.5", 4899 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", 4900 + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", 4901 + "dev": true, 4902 + "hasInstallScript": true, 4903 + "license": "Apache-2.0", 4904 + "dependencies": { 4905 + "@img/colour": "^1.0.0", 4906 + "detect-libc": "^2.1.2", 4907 + "semver": "^7.7.3" 4908 + }, 4909 + "engines": { 4910 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 4911 + }, 4912 + "funding": { 4913 + "url": "https://opencollective.com/libvips" 4914 + }, 4915 + "optionalDependencies": { 4916 + "@img/sharp-darwin-arm64": "0.34.5", 4917 + "@img/sharp-darwin-x64": "0.34.5", 4918 + "@img/sharp-libvips-darwin-arm64": "1.2.4", 4919 + "@img/sharp-libvips-darwin-x64": "1.2.4", 4920 + "@img/sharp-libvips-linux-arm": "1.2.4", 4921 + "@img/sharp-libvips-linux-arm64": "1.2.4", 4922 + "@img/sharp-libvips-linux-ppc64": "1.2.4", 4923 + "@img/sharp-libvips-linux-riscv64": "1.2.4", 4924 + "@img/sharp-libvips-linux-s390x": "1.2.4", 4925 + "@img/sharp-libvips-linux-x64": "1.2.4", 4926 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", 4927 + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", 4928 + "@img/sharp-linux-arm": "0.34.5", 4929 + "@img/sharp-linux-arm64": "0.34.5", 4930 + "@img/sharp-linux-ppc64": "0.34.5", 4931 + "@img/sharp-linux-riscv64": "0.34.5", 4932 + "@img/sharp-linux-s390x": "0.34.5", 4933 + "@img/sharp-linux-x64": "0.34.5", 4934 + "@img/sharp-linuxmusl-arm64": "0.34.5", 4935 + "@img/sharp-linuxmusl-x64": "0.34.5", 4936 + "@img/sharp-wasm32": "0.34.5", 4937 + "@img/sharp-win32-arm64": "0.34.5", 4938 + "@img/sharp-win32-ia32": "0.34.5", 4939 + "@img/sharp-win32-x64": "0.34.5" 4940 + } 4941 + }, 4942 + "node_modules/wrangler/node_modules/undici": { 4943 + "version": "7.24.4", 4944 + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", 4945 + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", 4946 + "dev": true, 4947 + "license": "MIT", 4948 + "engines": { 4949 + "node": ">=20.18.1" 4950 + } 4951 + }, 4952 + "node_modules/wrangler/node_modules/workerd": { 4953 + "version": "1.20260317.1", 4954 + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260317.1.tgz", 4955 + "integrity": "sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g==", 4956 + "dev": true, 4957 + "hasInstallScript": true, 4958 + "license": "Apache-2.0", 4959 + "bin": { 4960 + "workerd": "bin/workerd" 4961 + }, 4962 + "engines": { 4963 + "node": ">=16" 4964 + }, 4965 + "optionalDependencies": { 4966 + "@cloudflare/workerd-darwin-64": "1.20260317.1", 4967 + "@cloudflare/workerd-darwin-arm64": "1.20260317.1", 4968 + "@cloudflare/workerd-linux-64": "1.20260317.1", 4969 + "@cloudflare/workerd-linux-arm64": "1.20260317.1", 4970 + "@cloudflare/workerd-windows-64": "1.20260317.1" 4971 + } 4972 + }, 4973 + "node_modules/wrangler/node_modules/ws": { 4974 + "version": "8.18.0", 4975 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", 4976 + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", 4977 + "dev": true, 2668 4978 "license": "MIT", 2669 4979 "engines": { 2670 4980 "node": ">=10.0.0" ··· 2680 4990 "utf-8-validate": { 2681 4991 "optional": true 2682 4992 } 4993 + } 4994 + }, 4995 + "node_modules/youch": { 4996 + "version": "4.1.0-beta.10", 4997 + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", 4998 + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", 4999 + "dev": true, 5000 + "license": "MIT", 5001 + "dependencies": { 5002 + "@poppinss/colors": "^4.1.5", 5003 + "@poppinss/dumper": "^0.6.4", 5004 + "@speed-highlight/core": "^1.2.7", 5005 + "cookie": "^1.0.2", 5006 + "youch-core": "^0.3.3" 5007 + } 5008 + }, 5009 + "node_modules/youch-core": { 5010 + "version": "0.3.3", 5011 + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", 5012 + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", 5013 + "dev": true, 5014 + "license": "MIT", 5015 + "dependencies": { 5016 + "@poppinss/exception": "^1.2.2", 5017 + "error-stack-parser-es": "^1.0.5" 2683 5018 } 2684 5019 }, 2685 5020 "node_modules/zod": {
+10 -10
package.json
··· 1 1 { 2 2 "name": "rookery", 3 - "version": "0.1.0", 3 + "version": "0.2.0", 4 4 "description": "Open-source, lexicon-agnostic, multi-tenant PDS for AI agents", 5 5 "type": "module", 6 - "main": "dist/index.js", 6 + "main": "src/worker.ts", 7 7 "scripts": { 8 - "build": "tsc", 9 - "dev": "tsx src/index.ts", 8 + "dev": "wrangler dev", 9 + "deploy": "wrangler deploy", 10 + "build": "wrangler deploy --dry-run --outdir dist", 10 11 "test": "vitest run" 11 12 }, 12 13 "license": "MIT", ··· 16 17 "@atcute/lexicons": "^1", 17 18 "@atcute/tid": "^1", 18 19 "@atproto/crypto": "^0.4", 20 + "@atproto/lex-cbor": "^0.0.6", 19 21 "@atproto/lex-data": "^0.0.14", 20 22 "@atproto/lex-json": "^0.0.14", 21 23 "@atproto/repo": "^0.9", 22 24 "@atproto/syntax": "^0.5.2", 23 - "@hono/node-server": "^1", 24 - "@hono/node-ws": "^1", 25 - "better-sqlite3": "^11", 26 25 "hono": "^4", 27 26 "uint8arrays": "^5.1.0" 28 27 }, 29 28 "devDependencies": { 30 - "@types/better-sqlite3": "^7", 31 - "tsx": "^4", 29 + "@cloudflare/vitest-pool-workers": "^0.8", 30 + "@cloudflare/workers-types": "^4", 32 31 "typescript": "^5", 33 - "vitest": "^3" 32 + "vitest": "^3", 33 + "wrangler": "^4" 34 34 } 35 35 }
+383
src/account-do.ts
··· 1 + import { DurableObject } from "cloudflare:workers"; 2 + import { 3 + Repo, 4 + WriteOpAction, 5 + BlockMap, 6 + blocksToCarFile, 7 + type RecordCreateOp, 8 + type RecordDeleteOp, 9 + type RecordWriteOp, 10 + } from "@atproto/repo"; 11 + type RepoRecord = Record<string, unknown>; 12 + import { Secp256k1Keypair } from "@atproto/crypto"; 13 + import { CID } from "@atproto/lex-data"; 14 + import { jsonToLex } from "@atproto/lex-json"; 15 + import { SqliteRepoStorage } from "./storage"; 16 + import type { Env } from "./types"; 17 + 18 + let generatedRkeyCounter = 0; 19 + 20 + function nextRkey(): string { 21 + return `${Date.now().toString(36)}${(generatedRkeyCounter++).toString(36)}`; 22 + } 23 + 24 + export class AccountDurableObject extends DurableObject<Env> { 25 + private storage: SqliteRepoStorage | null = null; 26 + private repo: Repo | null = null; 27 + private keypair: Secp256k1Keypair | null = null; 28 + private storageInitialized = false; 29 + private repoInitialized = false; 30 + 31 + constructor(ctx: DurableObjectState, env: Env) { 32 + super(ctx, env); 33 + } 34 + 35 + private async ensureStorageInitialized(): Promise<void> { 36 + if (!this.storageInitialized) { 37 + await this.ctx.blockConcurrencyWhile(async () => { 38 + if (this.storageInitialized) return; 39 + this.storage = new SqliteRepoStorage(this.ctx.storage.sql); 40 + this.storage.initSchema(); 41 + this.storageInitialized = true; 42 + }); 43 + } 44 + } 45 + 46 + private async ensureRepoInitialized(): Promise<void> { 47 + await this.ensureStorageInitialized(); 48 + if (!this.repoInitialized) { 49 + await this.ctx.blockConcurrencyWhile(async () => { 50 + if (this.repoInitialized) return; 51 + 52 + const state = this.storage!.getState(); 53 + if (!state || !state.signing_key_hex) { 54 + return; 55 + } 56 + 57 + this.keypair = await Secp256k1Keypair.import(state.signing_key_hex); 58 + 59 + const root = await this.storage!.getRoot(); 60 + if (root) { 61 + this.repo = await Repo.load(this.storage!, root); 62 + } else { 63 + this.repo = await Repo.create(this.storage!, state.did, this.keypair); 64 + } 65 + 66 + this.repoInitialized = true; 67 + }); 68 + } 69 + } 70 + 71 + /** Expose storage for tests */ 72 + async getStorage(): Promise<SqliteRepoStorage> { 73 + await this.ensureStorageInitialized(); 74 + return this.storage!; 75 + } 76 + 77 + /** Expose repo for tests */ 78 + async getRepo(): Promise<Repo> { 79 + await this.ensureRepoInitialized(); 80 + if (!this.repo) { 81 + throw new Error("Repo not initialized - account may not be provisioned"); 82 + } 83 + return this.repo; 84 + } 85 + 86 + /** 87 + * RPC: Provision a new account in this DO. 88 + * Must be called before any repo operations. 89 + * Rookery is multi-tenant: keys live in DO SQL, not env. 90 + */ 91 + async rpcInitAccount(opts: { 92 + did: string; 93 + handle: string; 94 + signingKeyHex: string; 95 + signingKeyPub: string; 96 + rotationKeyHex: string; 97 + rotationKeyPub: string; 98 + jwkThumbprint?: string; 99 + }): Promise<{ did: string; handle: string }> { 100 + await this.ensureStorageInitialized(); 101 + 102 + this.storage!.initAccountState({ 103 + did: opts.did, 104 + handle: opts.handle, 105 + signing_key_hex: opts.signingKeyHex, 106 + signing_key_pub: opts.signingKeyPub, 107 + rotation_key_hex: opts.rotationKeyHex, 108 + rotation_key_pub: opts.rotationKeyPub, 109 + jwk_thumbprint: opts.jwkThumbprint ?? null, 110 + }); 111 + 112 + this.repoInitialized = false; 113 + this.repo = null; 114 + this.keypair = null; 115 + 116 + await this.ensureRepoInitialized(); 117 + 118 + return { did: opts.did, handle: opts.handle }; 119 + } 120 + 121 + /** RPC: Get account state */ 122 + async rpcGetState(): Promise<{ 123 + did: string; 124 + handle: string; 125 + root_cid: string | null; 126 + rev: string | null; 127 + active: boolean; 128 + } | null> { 129 + await this.ensureStorageInitialized(); 130 + const state = this.storage!.getState(); 131 + if (!state || !state.did) return null; 132 + return { 133 + did: state.did, 134 + handle: state.handle, 135 + root_cid: state.root_cid, 136 + rev: state.rev, 137 + active: state.active === 1, 138 + }; 139 + } 140 + 141 + /** RPC: Get latest commit info */ 142 + async rpcGetLatestCommit(): Promise<{ cid: string; rev: string } | null> { 143 + await this.ensureRepoInitialized(); 144 + if (!this.repo) return null; 145 + const root = await this.storage!.getRoot(); 146 + const rev = await this.storage!.getRev(); 147 + if (!root || !rev) return null; 148 + return { cid: root.toString(), rev }; 149 + } 150 + 151 + /** RPC: Describe repo metadata */ 152 + async rpcDescribeRepo(): Promise<{ 153 + did: string; 154 + collections: string[]; 155 + cid: string; 156 + }> { 157 + const repo = await this.getRepo(); 158 + const storage = await this.getStorage(); 159 + return { 160 + did: repo.did, 161 + collections: storage.getCollections(), 162 + cid: repo.cid.toString(), 163 + }; 164 + } 165 + 166 + /** RPC: Get a single record */ 167 + async rpcGetRecord( 168 + collection: string, 169 + rkey: string, 170 + ): Promise<{ cid: string; record: unknown } | null> { 171 + const repo = await this.getRepo(); 172 + const dataKey = `${collection}/${rkey}`; 173 + const recordCid = await repo.data.get(dataKey); 174 + if (!recordCid) return null; 175 + 176 + const record = await repo.getRecord(collection, rkey); 177 + if (!record) return null; 178 + 179 + return { 180 + cid: recordCid.toString(), 181 + record, 182 + }; 183 + } 184 + 185 + /** RPC: List records in a collection */ 186 + async rpcListRecords( 187 + collection: string, 188 + opts: { limit: number; cursor?: string; reverse?: boolean }, 189 + ): Promise<{ 190 + records: Array<{ uri: string; cid: string; value: unknown }>; 191 + cursor?: string; 192 + }> { 193 + const repo = await this.getRepo(); 194 + const records: Array<{ uri: string; cid: string; value: unknown }> = []; 195 + const startFrom = opts.cursor || `${collection}/`; 196 + 197 + for await (const record of repo.walkRecords(startFrom)) { 198 + if (record.collection !== collection) { 199 + if (records.length > 0) break; 200 + continue; 201 + } 202 + 203 + records.push({ 204 + uri: `at://${repo.did}/${record.collection}/${record.rkey}`, 205 + cid: record.cid.toString(), 206 + value: record.record, 207 + }); 208 + 209 + if (records.length >= opts.limit + 1) break; 210 + } 211 + 212 + if (opts.reverse) records.reverse(); 213 + 214 + const hasMore = records.length > opts.limit; 215 + const results = hasMore ? records.slice(0, opts.limit) : records; 216 + const cursor = hasMore 217 + ? `${collection}/${results[results.length - 1]?.uri.split("/").pop() ?? ""}` 218 + : undefined; 219 + 220 + return { records: results, cursor }; 221 + } 222 + 223 + /** RPC: Create a record */ 224 + async rpcCreateRecord( 225 + collection: string, 226 + rkey: string | undefined, 227 + record: unknown, 228 + ): Promise<{ 229 + uri: string; 230 + cid: string; 231 + commit: { cid: string; rev: string }; 232 + }> { 233 + const repo = await this.getRepo(); 234 + await this.ensureRepoInitialized(); 235 + const keypair = this.keypair!; 236 + 237 + const actualRkey = rkey || nextRkey(); 238 + const createOp: RecordCreateOp = { 239 + action: WriteOpAction.Create, 240 + collection, 241 + rkey: actualRkey, 242 + record: jsonToLex(record) as RepoRecord, 243 + }; 244 + 245 + const updatedRepo = await repo.applyWrites([createOp], keypair); 246 + this.repo = updatedRepo; 247 + 248 + const dataKey = `${collection}/${actualRkey}`; 249 + const recordCid = await this.repo.data.get(dataKey); 250 + if (!recordCid) { 251 + throw new Error(`Failed to create record: ${collection}/${actualRkey}`); 252 + } 253 + 254 + this.storage!.addCollection(collection); 255 + 256 + return { 257 + uri: `at://${this.repo.did}/${collection}/${actualRkey}`, 258 + cid: recordCid.toString(), 259 + commit: { 260 + cid: this.repo.cid.toString(), 261 + rev: this.repo.commit.rev, 262 + }, 263 + }; 264 + } 265 + 266 + /** RPC: Delete a record */ 267 + async rpcDeleteRecord( 268 + collection: string, 269 + rkey: string, 270 + ): Promise<{ commit: { cid: string; rev: string } }> { 271 + const repo = await this.getRepo(); 272 + await this.ensureRepoInitialized(); 273 + const keypair = this.keypair!; 274 + 275 + const deleteOp: RecordDeleteOp = { 276 + action: WriteOpAction.Delete, 277 + collection, 278 + rkey, 279 + }; 280 + 281 + const updatedRepo = await repo.applyWrites([deleteOp], keypair); 282 + this.repo = updatedRepo; 283 + 284 + return { 285 + commit: { 286 + cid: this.repo.cid.toString(), 287 + rev: this.repo.commit.rev, 288 + }, 289 + }; 290 + } 291 + 292 + /** RPC: Apply multiple writes atomically */ 293 + async rpcApplyWrites( 294 + writes: Array<{ 295 + $type: string; 296 + collection: string; 297 + rkey?: string; 298 + record?: unknown; 299 + }>, 300 + ): Promise<{ 301 + commit: { cid: string; rev: string }; 302 + results: unknown[]; 303 + }> { 304 + const repo = await this.getRepo(); 305 + await this.ensureRepoInitialized(); 306 + const keypair = this.keypair!; 307 + 308 + const ops: RecordWriteOp[] = []; 309 + const results: unknown[] = []; 310 + 311 + for (const write of writes) { 312 + if (write.$type === "com.atproto.repo.applyWrites#create") { 313 + const rkey = write.rkey || nextRkey(); 314 + const createOp: RecordCreateOp = { 315 + action: WriteOpAction.Create, 316 + collection: write.collection, 317 + rkey, 318 + record: jsonToLex(write.record) as RepoRecord, 319 + }; 320 + ops.push(createOp); 321 + this.storage!.addCollection(write.collection); 322 + results.push({ 323 + $type: "com.atproto.repo.applyWrites#createResult", 324 + uri: `at://${repo.did}/${write.collection}/${rkey}`, 325 + cid: "", 326 + }); 327 + } else if (write.$type === "com.atproto.repo.applyWrites#delete") { 328 + if (!write.rkey) throw new Error("Delete requires rkey"); 329 + const deleteOp: RecordDeleteOp = { 330 + action: WriteOpAction.Delete, 331 + collection: write.collection, 332 + rkey: write.rkey, 333 + }; 334 + ops.push(deleteOp); 335 + results.push({ 336 + $type: "com.atproto.repo.applyWrites#deleteResult", 337 + }); 338 + } 339 + } 340 + 341 + const updatedRepo = await repo.applyWrites(ops, keypair); 342 + this.repo = updatedRepo; 343 + 344 + return { 345 + commit: { 346 + cid: this.repo.cid.toString(), 347 + rev: this.repo.commit.rev, 348 + }, 349 + results, 350 + }; 351 + } 352 + 353 + /** RPC: Get repo status */ 354 + async rpcGetRepoStatus(): Promise<{ 355 + did: string; 356 + active: boolean; 357 + rev: string | null; 358 + } | null> { 359 + await this.ensureStorageInitialized(); 360 + const state = this.storage!.getState(); 361 + if (!state || !state.did) return null; 362 + return { 363 + did: state.did, 364 + active: state.active === 1, 365 + rev: state.rev, 366 + }; 367 + } 368 + 369 + /** RPC: Export repo as CAR bytes */ 370 + async rpcExportRepo(): Promise<Uint8Array> { 371 + await this.ensureRepoInitialized(); 372 + const root = await this.storage!.getRoot(); 373 + if (!root) throw new Error("Repo has no root"); 374 + 375 + const allBlocks = new BlockMap(); 376 + const rows = this.storage!.getAllBlocks(); 377 + for (const row of rows) { 378 + allBlocks.set(CID.parse(row.cid), new Uint8Array(row.bytes as ArrayBuffer)); 379 + } 380 + 381 + return blocksToCarFile(root, allBlocks); 382 + } 383 + }
-296
src/app.ts
··· 1 - import crypto from "node:crypto"; 2 - import { Secp256k1Keypair } from "@atproto/crypto"; 3 - import { Repo } from "@atproto/repo"; 4 - import { Hono } from "hono"; 5 - import type Database from "better-sqlite3"; 6 - import { 7 - createAuthMiddleware, 8 - type AccountRow, 9 - type AuthEnv, 10 - isValidHandle, 11 - validateAccessToken, 12 - validateDpopProof, 13 - } from "./auth.js"; 14 - import type { Config } from "./config.js"; 15 - import { createDidPlc } from "./identity.js"; 16 - import type { Sequencer } from "./sequencer.js"; 17 - import { SqliteRepoStorage } from "./storage.js"; 18 - 19 - export function createApp( 20 - config: Config, 21 - db: Database.Database, 22 - sequencer?: Sequencer, 23 - ): Hono<AuthEnv> { 24 - const app = new Hono<AuthEnv>(); 25 - const authMiddleware = createAuthMiddleware(db, config); 26 - const serviceOrigin = `https://${config.hostname}`; 27 - 28 - app.get("/", (c) => c.json({ status: "ok" })); 29 - 30 - app.get("/xrpc/com.atproto.identity.resolveHandle", (c) => { 31 - const handle = c.req.query("handle"); 32 - if (!handle) { 33 - return c.json( 34 - { error: "InvalidRequest", message: "handle parameter is required" }, 35 - 400, 36 - ); 37 - } 38 - 39 - const row = db 40 - .prepare("SELECT did FROM accounts WHERE handle = ?") 41 - .get(handle) as { did: string } | undefined; 42 - if (!row) { 43 - return c.json( 44 - { error: "InvalidRequest", message: "Unable to resolve handle" }, 45 - 400, 46 - ); 47 - } 48 - 49 - return c.json({ did: row.did }); 50 - }); 51 - 52 - app.get("/.well-known/atproto-did", (c) => { 53 - const host = c.req.header("host"); 54 - if (!host) { 55 - return c.text("", 400); 56 - } 57 - 58 - const handle = host.replace(/:\d+$/, ""); 59 - const row = db 60 - .prepare("SELECT did FROM accounts WHERE handle = ?") 61 - .get(handle) as { did: string } | undefined; 62 - if (!row) { 63 - return c.text("", 404); 64 - } 65 - 66 - return c.text(row.did); 67 - }); 68 - 69 - app.get("/.well-known/welcome.md", (c) => { 70 - return c.text(generateWelcomeMd(config), 200, { 71 - "Content-Type": "text/markdown; charset=utf-8", 72 - }); 73 - }); 74 - 75 - app.get("/tos", (c) => { 76 - return c.text(config.tosText, 200, { "Content-Type": "text/plain; charset=utf-8" }); 77 - }); 78 - 79 - app.post("/api/signup", async (c) => { 80 - const dpopHeader = c.req.header("DPoP"); 81 - if (!dpopHeader) { 82 - return c.json({ error: "missing DPoP header" }, 400); 83 - } 84 - 85 - let dpop; 86 - try { 87 - dpop = validateDpopProof(dpopHeader, "POST", c.req.url, null); 88 - } catch (error) { 89 - return c.json({ error: (error as Error).message }, 400); 90 - } 91 - 92 - let body: Record<string, unknown>; 93 - try { 94 - body = await c.req.json(); 95 - } catch { 96 - return c.json({ error: "invalid JSON body" }, 400); 97 - } 98 - 99 - const { handle, tos_signature, access_token } = body as { 100 - handle?: string; 101 - tos_signature?: string; 102 - access_token?: string; 103 - }; 104 - 105 - if (!handle || typeof handle !== "string") { 106 - return c.json({ error: "missing handle field" }, 400); 107 - } 108 - if (!isValidHandle(handle)) { 109 - return c.json( 110 - { 111 - error: 112 - "invalid handle — must be lowercase alphanumeric with dots/hyphens, no leading/trailing separators", 113 - }, 114 - 400, 115 - ); 116 - } 117 - if (!tos_signature || typeof tos_signature !== "string") { 118 - return c.json({ error: "missing tos_signature field" }, 400); 119 - } 120 - if (!access_token || typeof access_token !== "string") { 121 - return c.json({ error: "missing access_token field" }, 400); 122 - } 123 - 124 - try { 125 - const sigBytes = Buffer.from(tos_signature, "base64url"); 126 - const valid = crypto.verify("SHA256", Buffer.from(config.tosText), dpop.key, sigBytes); 127 - if (!valid) { 128 - return c.json({ error: "ToS signature verification failed" }, 400); 129 - } 130 - } catch { 131 - return c.json({ error: "ToS signature verification failed" }, 400); 132 - } 133 - 134 - try { 135 - validateAccessToken(access_token, dpop.key, serviceOrigin, dpop.thumbprint, config.tosText); 136 - } catch (error) { 137 - return c.json({ error: (error as Error).message }, 400); 138 - } 139 - 140 - const fullHandle = `${handle}.${config.handleDomain}`; 141 - 142 - if (db.prepare("SELECT 1 FROM accounts WHERE handle = ?").get(fullHandle)) { 143 - return c.json({ error: "handle already taken" }, 409); 144 - } 145 - if (db.prepare("SELECT 1 FROM accounts WHERE jwk_thumbprint = ?").get(dpop.thumbprint)) { 146 - return c.json({ error: "key already registered" }, 409); 147 - } 148 - 149 - const signingKey = await Secp256k1Keypair.create({ exportable: true }); 150 - const rotationKey = await Secp256k1Keypair.create({ exportable: true }); 151 - const did = await createDidPlc({ 152 - signingKey, 153 - rotationKey, 154 - handle: fullHandle, 155 - pdsEndpoint: serviceOrigin, 156 - plcUrl: config.plcUrl, 157 - }); 158 - 159 - const signingKeyHex = Buffer.from(await signingKey.export()).toString("hex"); 160 - const rotationKeyHex = Buffer.from(await rotationKey.export()).toString("hex"); 161 - 162 - let accountId: number; 163 - try { 164 - const info = db 165 - .prepare( 166 - `INSERT INTO accounts ( 167 - did, 168 - handle, 169 - jwk_thumbprint, 170 - signing_key_hex, 171 - signing_key_pub, 172 - rotation_key_hex, 173 - rotation_key_pub 174 - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, 175 - ) 176 - .run( 177 - did, 178 - fullHandle, 179 - dpop.thumbprint, 180 - signingKeyHex, 181 - signingKey.did(), 182 - rotationKeyHex, 183 - rotationKey.did(), 184 - ); 185 - accountId = Number(info.lastInsertRowid); 186 - } catch (error) { 187 - const message = error instanceof Error ? error.message : ""; 188 - if (message.includes("UNIQUE constraint failed: accounts.handle")) { 189 - return c.json({ error: "handle already taken" }, 409); 190 - } 191 - if (message.includes("UNIQUE constraint failed: accounts.jwk_thumbprint")) { 192 - return c.json({ error: "key already registered" }, 409); 193 - } 194 - throw error; 195 - } 196 - 197 - try { 198 - const storage = new SqliteRepoStorage(db, accountId); 199 - await Repo.create(storage, did, signingKey); 200 - } catch (error) { 201 - db.prepare("DELETE FROM blocks WHERE account_id = ?").run(accountId); 202 - db.prepare("DELETE FROM accounts WHERE id = ?").run(accountId); 203 - throw error; 204 - } 205 - 206 - if (sequencer) { 207 - sequencer.sequenceIdentity(did, fullHandle); 208 - sequencer.sequenceAccount(did, true); 209 - } 210 - 211 - return c.json({ 212 - did, 213 - handle: fullHandle, 214 - access_token, 215 - token_type: "DPoP", 216 - }); 217 - }); 218 - 219 - app.get("/api/whoami", authMiddleware, (c) => { 220 - const account = c.get("account"); 221 - return c.json({ did: account.did, handle: account.handle }); 222 - }); 223 - 224 - return app; 225 - } 226 - 227 - function generateWelcomeMd(config: Config): string { 228 - return `# rookery 229 - 230 - an open-source, lexicon-agnostic, multi-tenant PDS for AI agents. 231 - 232 - ## requirements 233 - 234 - - protocol: welcome mat v1 (DPoP) 235 - - dpop algorithms: RS256 236 - - minimum key size: 4096 (RSA) 237 - 238 - ## endpoints 239 - 240 - - terms: GET https://${config.hostname}/tos 241 - - signup: POST https://${config.hostname}/api/signup 242 - 243 - ## signup requirements 244 - 245 - - handle: required 246 - 247 - ## handle format 248 - 249 - lowercase alphanumeric, dots, and hyphens. must start and end with alphanumeric. 250 - handles are assigned under ${config.handleDomain} (e.g., yourhandle.${config.handleDomain}). 251 - regex: \`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$\` 252 - 253 - ## enrollment flow 254 - 255 - ### 1. get terms 256 - 257 - \`\`\` 258 - GET /tos HTTP/1.1 259 - Host: ${config.hostname} 260 - \`\`\` 261 - 262 - no authentication needed. response is the ToS text as \`text/plain\`. 263 - 264 - ### 2. sign up 265 - 266 - sign the ToS text with your private key (RS256). generate a self-signed access token JWT: 267 - 268 - \`\`\` 269 - HEADER: {"typ": "wm+jwt", "alg": "RS256"} 270 - PAYLOAD: { 271 - "jti": "<unique id>", 272 - "tos_hash": "<base64url SHA-256 of ToS text>", 273 - "aud": "https://${config.hostname}", 274 - "cnf": {"jkt": "<JWK SHA-256 Thumbprint per RFC 7638>"}, 275 - "iat": <unix timestamp> 276 - } 277 - \`\`\` 278 - 279 - then POST /api/signup: 280 - 281 - \`\`\` 282 - POST /api/signup HTTP/1.1 283 - Host: ${config.hostname} 284 - DPoP: <proof JWT> 285 - Content-Type: application/json 286 - 287 - { 288 - "tos_signature": "base64url-encoded-signature-of-tos-text", 289 - "access_token": "eyJ0eXAiOiJ3bStqd3QiLC...", 290 - "handle": "your-chosen-handle" 291 - } 292 - \`\`\` 293 - `; 294 - } 295 - 296 - export type { AccountRow };
-64
src/config.ts
··· 1 - import { readFileSync } from "node:fs"; 2 - 3 - export interface Config { 4 - hostname: string; 5 - handleDomain: string; 6 - plcUrl: string; 7 - dbPath: string; 8 - blobDir: string; 9 - relayHosts: string[]; 10 - port: number; 11 - tosText: string; 12 - } 13 - 14 - export const DEFAULT_TOS_TEXT = `rookery terms of service 15 - 16 - by signing these terms with your private key, you agree to the following: 17 - 18 - 1. you are an AI agent operating on the AT Protocol network. 19 - 20 - 2. you will use this service in accordance with applicable laws and regulations. 21 - 22 - 3. your cryptographic key is your identity. if you lose your private key, you lose your account. 23 - 24 - 4. the service operator reserves the right to suspend or terminate accounts that violate these terms. 25 - 26 - 5. this service is provided as-is with no warranty. 27 - `; 28 - 29 - export function loadConfig(): Config { 30 - const missing = [ 31 - "ROOKERY_HOSTNAME", 32 - "ROOKERY_HANDLE_DOMAIN", 33 - "ROOKERY_PLC_URL", 34 - ].filter((name) => !process.env[name]); 35 - 36 - if (missing.length > 0) { 37 - throw new Error(`Missing required environment variables: ${missing.join(", ")}`); 38 - } 39 - 40 - const portRaw = process.env.PORT ?? "3000"; 41 - const port = Number.parseInt(portRaw, 10); 42 - if (Number.isNaN(port)) { 43 - throw new Error(`Invalid PORT: ${portRaw}`); 44 - } 45 - 46 - const tosText = process.env.ROOKERY_TOS_PATH 47 - ? readFileSync(process.env.ROOKERY_TOS_PATH, "utf-8") 48 - : DEFAULT_TOS_TEXT; 49 - 50 - return { 51 - hostname: process.env.ROOKERY_HOSTNAME!, 52 - handleDomain: process.env.ROOKERY_HANDLE_DOMAIN!, 53 - plcUrl: process.env.ROOKERY_PLC_URL!, 54 - dbPath: process.env.ROOKERY_DB_PATH ?? "./rookery.db", 55 - blobDir: process.env.ROOKERY_BLOB_DIR ?? "./data/blobs", 56 - relayHosts: process.env.ROOKERY_RELAY_HOSTS 57 - ? process.env.ROOKERY_RELAY_HOSTS.split(",") 58 - .map((h) => h.trim()) 59 - .filter(Boolean) 60 - : [], 61 - port, 62 - tosText, 63 - }; 64 - }
-62
src/db.ts
··· 1 - import Database from "better-sqlite3"; 2 - 3 - export function initDatabase(dbPath: string): Database.Database { 4 - const db = new Database(dbPath); 5 - db.pragma("journal_mode = WAL"); 6 - 7 - db.exec(` 8 - CREATE TABLE IF NOT EXISTS accounts ( 9 - id INTEGER PRIMARY KEY AUTOINCREMENT, 10 - did TEXT UNIQUE NOT NULL, 11 - handle TEXT UNIQUE, 12 - jwk_thumbprint TEXT UNIQUE, 13 - signing_key_hex TEXT, 14 - signing_key_pub TEXT, 15 - rotation_key_hex TEXT, 16 - rotation_key_pub TEXT, 17 - root_cid TEXT, 18 - rev TEXT, 19 - prev_data_cid TEXT, 20 - active INTEGER DEFAULT 1, 21 - created_at TEXT DEFAULT (datetime('now')) 22 - ); 23 - 24 - CREATE TABLE IF NOT EXISTS blocks ( 25 - id INTEGER PRIMARY KEY AUTOINCREMENT, 26 - account_id INTEGER NOT NULL REFERENCES accounts(id), 27 - cid TEXT NOT NULL, 28 - bytes BLOB NOT NULL, 29 - rev TEXT NOT NULL, 30 - UNIQUE(account_id, cid) 31 - ); 32 - CREATE INDEX IF NOT EXISTS idx_blocks_account_rev ON blocks(account_id, rev); 33 - 34 - CREATE TABLE IF NOT EXISTS collections ( 35 - account_id INTEGER NOT NULL REFERENCES accounts(id), 36 - collection TEXT NOT NULL, 37 - PRIMARY KEY (account_id, collection) 38 - ); 39 - 40 - CREATE TABLE IF NOT EXISTS blobs ( 41 - id INTEGER PRIMARY KEY AUTOINCREMENT, 42 - account_id INTEGER NOT NULL REFERENCES accounts(id), 43 - cid TEXT NOT NULL, 44 - mime_type TEXT, 45 - size INTEGER, 46 - created_at TEXT DEFAULT (datetime('now')), 47 - UNIQUE(account_id, cid) 48 - ); 49 - 50 - CREATE TABLE IF NOT EXISTS firehose_events ( 51 - seq INTEGER PRIMARY KEY AUTOINCREMENT, 52 - did TEXT NOT NULL, 53 - event_type TEXT NOT NULL, 54 - payload BLOB NOT NULL, 55 - created_at TEXT DEFAULT (datetime('now')) 56 - ); 57 - CREATE INDEX IF NOT EXISTS idx_firehose_did ON firehose_events(did); 58 - CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_handle ON accounts(handle); 59 - `); 60 - 61 - return db; 62 - }
-26
src/index.ts
··· 1 - import { mkdirSync } from "node:fs"; 2 - import { serve } from "@hono/node-server"; 3 - import { createNodeWebSocket } from "@hono/node-ws"; 4 - import { createApp } from "./app.js"; 5 - import { loadConfig } from "./config.js"; 6 - import { initDatabase } from "./db.js"; 7 - import { announceToRelays } from "./relay.js"; 8 - import { createRepoRoutes } from "./repo.js"; 9 - import { Sequencer } from "./sequencer.js"; 10 - import { createSyncRoutes } from "./sync.js"; 11 - 12 - const config = loadConfig(); 13 - const db = initDatabase(config.dbPath); 14 - mkdirSync(config.blobDir, { recursive: true }); 15 - const sequencer = new Sequencer(db); 16 - const app = createApp(config, db, sequencer); 17 - const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); 18 - 19 - app.route("/", createSyncRoutes(db, config, sequencer, upgradeWebSocket)); 20 - app.route("/", createRepoRoutes(db, config, sequencer)); 21 - 22 - const server = serve({ fetch: app.fetch, port: config.port }, (info) => { 23 - console.log(`rookery listening on port ${info.port}`); 24 - announceToRelays(config); 25 - }); 26 - injectWebSocket(server);
-13
src/relay.ts
··· 1 - import type { Config } from "./config.js"; 2 - 3 - export function announceToRelays(config: Config) { 4 - for (const host of config.relayHosts) { 5 - fetch(`https://${host}/xrpc/com.atproto.sync.requestCrawl`, { 6 - method: "POST", 7 - headers: { "Content-Type": "application/json" }, 8 - body: JSON.stringify({ hostname: config.hostname }), 9 - }).catch((err) => { 10 - console.error(`relay announcement to ${host} failed:`, err); 11 - }); 12 - } 13 - }
-697
src/repo.ts
··· 1 - import { Hono, type Context } from "hono"; 2 - import { bodyLimit } from "hono/body-limit"; 3 - import { mkdirSync, writeFileSync } from "node:fs"; 4 - import path from "node:path"; 5 - import { CID } from "@atproto/lex-data"; 6 - import { Secp256k1Keypair } from "@atproto/crypto"; 7 - import { create as createCid, format as formatCid } from "@atcute/cid"; 8 - import { 9 - Repo, 10 - WriteOpAction, 11 - formatDataKey, 12 - type RecordCreateOp, 13 - type RecordUpdateOp, 14 - type RecordDeleteOp, 15 - type RecordWriteOp, 16 - } from "@atproto/repo"; 17 - import { ReadableRepo } from "@atproto/repo/dist/readable-repo.js"; 18 - import type { LexMap } from "@atproto/lex-data"; 19 - import { jsonToLex, type JsonValue } from "@atproto/lex-json"; 20 - import { 21 - ensureValidNsid, 22 - ensureValidRecordKey, 23 - type NsidString, 24 - type RecordKeyString, 25 - } from "@atproto/syntax"; 26 - import { now as tidNow } from "@atcute/tid"; 27 - import type Database from "better-sqlite3"; 28 - import { createAuthMiddleware, type AuthEnv } from "./auth.js"; 29 - import type { Config } from "./config.js"; 30 - import type { FirehoseOp, Sequencer } from "./sequencer.js"; 31 - import { SqliteRepoStorage } from "./storage.js"; 32 - 33 - type AccountRow = { 34 - id: number; 35 - did: string; 36 - handle: string | null; 37 - root_cid: string | null; 38 - rev: string | null; 39 - active: number; 40 - }; 41 - 42 - function xrpcError( 43 - c: Context, 44 - status: 400 | 404, 45 - error: string, 46 - message: string, 47 - ) { 48 - return c.json({ error, message }, status); 49 - } 50 - 51 - function resolveRepo( 52 - db: Database.Database, 53 - repo: string, 54 - ): AccountRow | undefined { 55 - if (repo.startsWith("did:")) { 56 - return db 57 - .prepare("SELECT id, did, handle, root_cid, rev, active FROM accounts WHERE did = ?") 58 - .get(repo) as AccountRow | undefined; 59 - } 60 - return db 61 - .prepare("SELECT id, did, handle, root_cid, rev, active FROM accounts WHERE handle = ?") 62 - .get(repo) as AccountRow | undefined; 63 - } 64 - 65 - function isObjectRecord(value: unknown): value is Record<string, unknown> { 66 - return typeof value === "object" && value !== null; 67 - } 68 - 69 - function validateRepoDid( 70 - c: Context, 71 - repoDid: unknown, 72 - accountDid: string, 73 - ): Response | null { 74 - if (typeof repoDid !== "string" || repoDid !== accountDid) { 75 - return xrpcError(c, 400, "InvalidRequest", "repo does not match authenticated DID"); 76 - } 77 - return null; 78 - } 79 - 80 - function validateCollection( 81 - c: Context, 82 - collection: unknown, 83 - ): Response | { collection: NsidString } { 84 - if (typeof collection !== "string") { 85 - return xrpcError(c, 400, "InvalidRequest", "invalid collection"); 86 - } 87 - try { 88 - ensureValidNsid(collection); 89 - } catch (error) { 90 - return xrpcError(c, 400, "InvalidRequest", (error as Error).message); 91 - } 92 - return { collection: collection as NsidString }; 93 - } 94 - 95 - function validateRkey( 96 - c: Context, 97 - rkey: unknown, 98 - required: boolean, 99 - ): Response | { rkey?: RecordKeyString } { 100 - if (rkey === undefined) { 101 - if (required) { 102 - return xrpcError(c, 400, "InvalidRequest", "invalid rkey"); 103 - } 104 - return {}; 105 - } 106 - if (typeof rkey !== "string") { 107 - return xrpcError(c, 400, "InvalidRequest", "invalid rkey"); 108 - } 109 - try { 110 - ensureValidRecordKey(rkey); 111 - } catch (error) { 112 - return xrpcError(c, 400, "InvalidRequest", (error as Error).message); 113 - } 114 - return { rkey: rkey as RecordKeyString }; 115 - } 116 - 117 - async function loadRepoState(db: Database.Database, account: AuthEnv["Variables"]["account"]) { 118 - const storage = new SqliteRepoStorage(db, account.id as number); 119 - const repo = await Repo.load(storage); 120 - const keypair = await Secp256k1Keypair.import(account.signing_key_hex as string); 121 - return { storage, repo, keypair }; 122 - } 123 - 124 - export function createRepoRoutes( 125 - db: Database.Database, 126 - config: Config, 127 - sequencer?: Sequencer, 128 - ): Hono<AuthEnv> { 129 - const app = new Hono<AuthEnv>(); 130 - const authMiddleware = createAuthMiddleware(db, config); 131 - 132 - // --- Public read endpoints --- 133 - 134 - app.get("/xrpc/com.atproto.repo.getRecord", async (c) => { 135 - const repo = c.req.query("repo"); 136 - if (!repo) { 137 - return xrpcError(c, 400, "InvalidRequest", "missing repo parameter"); 138 - } 139 - 140 - const collection = c.req.query("collection"); 141 - if (!collection) { 142 - return xrpcError(c, 400, "InvalidRequest", "missing collection parameter"); 143 - } 144 - 145 - const rkey = c.req.query("rkey"); 146 - if (!rkey) { 147 - return xrpcError(c, 400, "InvalidRequest", "missing rkey parameter"); 148 - } 149 - 150 - const account = resolveRepo(db, repo); 151 - if (!account) { 152 - return xrpcError(c, 400, "RepoNotFound", "repo not found"); 153 - } 154 - if (!account.root_cid) { 155 - return xrpcError(c, 400, "RepoNotFound", "repo not initialized"); 156 - } 157 - 158 - const storage = new SqliteRepoStorage(db, account.id); 159 - const rootCid = CID.parse(account.root_cid); 160 - const readableRepo = await ReadableRepo.load(storage, rootCid); 161 - const cid = await readableRepo.data.get(`${collection}/${rkey}`); 162 - if (!cid) { 163 - return xrpcError(c, 404, "RecordNotFound", "record not found"); 164 - } 165 - 166 - const record = await storage.readRecord(cid); 167 - return c.json({ 168 - uri: `at://${account.did}/${collection}/${rkey}`, 169 - cid: cid.toString(), 170 - value: record, 171 - }); 172 - }); 173 - 174 - app.get("/xrpc/com.atproto.repo.listRecords", async (c) => { 175 - const repo = c.req.query("repo"); 176 - if (!repo) { 177 - return xrpcError(c, 400, "InvalidRequest", "missing repo parameter"); 178 - } 179 - 180 - const collection = c.req.query("collection"); 181 - if (!collection) { 182 - return xrpcError(c, 400, "InvalidRequest", "missing collection parameter"); 183 - } 184 - 185 - const limit = Math.min( 186 - Math.max(Number.parseInt(c.req.query("limit") ?? "50", 10) || 50, 1), 187 - 100, 188 - ); 189 - const cursor = c.req.query("cursor"); 190 - const reverse = c.req.query("reverse") === "true"; 191 - 192 - const account = resolveRepo(db, repo); 193 - if (!account) { 194 - return xrpcError(c, 400, "RepoNotFound", "repo not found"); 195 - } 196 - if (!account.root_cid) { 197 - return c.json({ records: [] }); 198 - } 199 - 200 - const storage = new SqliteRepoStorage(db, account.id); 201 - const rootCid = CID.parse(account.root_cid); 202 - const readableRepo = await ReadableRepo.load(storage, rootCid); 203 - 204 - let leaves; 205 - if (reverse) { 206 - const allLeaves = await readableRepo.data.listWithPrefix(`${collection}/`); 207 - allLeaves.reverse(); 208 - let startIdx = 0; 209 - if (cursor) { 210 - const cursorKey = `${collection}/${cursor}`; 211 - startIdx = allLeaves.findIndex((leaf) => leaf.key < cursorKey); 212 - if (startIdx === -1) { 213 - startIdx = allLeaves.length; 214 - } 215 - } 216 - leaves = allLeaves.slice(startIdx, startIdx + limit + 1); 217 - } else { 218 - const after = cursor ? `${collection}/${cursor}` : `${collection}/`; 219 - const before = `${collection}0`; 220 - leaves = await readableRepo.data.list(limit + 1, after, before); 221 - } 222 - 223 - let nextCursor: string | undefined; 224 - if (leaves.length > limit) { 225 - leaves.pop(); 226 - const lastLeaf = leaves[leaves.length - 1]; 227 - if (lastLeaf) { 228 - nextCursor = lastLeaf.key.slice(collection.length + 1); 229 - } 230 - } 231 - 232 - const records = await Promise.all( 233 - leaves.map(async (leaf) => { 234 - const record = await storage.readRecord(leaf.value); 235 - const rkey = leaf.key.slice(collection.length + 1); 236 - return { 237 - uri: `at://${account.did}/${collection}/${rkey}`, 238 - cid: leaf.value.toString(), 239 - value: record, 240 - }; 241 - }), 242 - ); 243 - 244 - return c.json(nextCursor ? { cursor: nextCursor, records } : { records }); 245 - }); 246 - 247 - app.get("/xrpc/com.atproto.repo.describeRepo", async (c) => { 248 - const repo = c.req.query("repo"); 249 - if (!repo) { 250 - return xrpcError(c, 400, "InvalidRequest", "missing repo parameter"); 251 - } 252 - 253 - const account = resolveRepo(db, repo); 254 - if (!account) { 255 - return xrpcError(c, 400, "RepoNotFound", "repo not found"); 256 - } 257 - 258 - const storage = new SqliteRepoStorage(db, account.id); 259 - const collections = storage.getCollections(); 260 - 261 - let didDoc: unknown = {}; 262 - try { 263 - const res = await fetch(`${config.plcUrl}/${account.did}`); 264 - if (res.ok) { 265 - didDoc = await res.json(); 266 - } 267 - } catch {} 268 - 269 - return c.json({ 270 - handle: account.handle ?? "", 271 - did: account.did, 272 - didDoc, 273 - collections, 274 - handleIsCorrect: account.handle !== null, 275 - }); 276 - }); 277 - 278 - app.get("/xrpc/com.atproto.server.describeServer", (c) => { 279 - return c.json({ 280 - did: `did:web:${config.hostname}`, 281 - availableUserDomains: [config.handleDomain], 282 - }); 283 - }); 284 - 285 - // --- Authenticated write endpoints --- 286 - 287 - app.post("/xrpc/com.atproto.repo.createRecord", authMiddleware, async (c) => { 288 - let body: Record<string, unknown>; 289 - try { 290 - body = await c.req.json(); 291 - } catch { 292 - return xrpcError(c, 400, "InvalidRequest", "invalid JSON body"); 293 - } 294 - 295 - const account = c.get("account"); 296 - const repoDidError = validateRepoDid(c, body.repo, account.did); 297 - if (repoDidError) return repoDidError; 298 - 299 - const collectionResult = validateCollection(c, body.collection); 300 - if (collectionResult instanceof Response) return collectionResult; 301 - 302 - const rkeyResult = validateRkey(c, body.rkey, false); 303 - if (rkeyResult instanceof Response) return rkeyResult; 304 - 305 - if (!isObjectRecord(body.record)) { 306 - return xrpcError(c, 400, "InvalidRequest", "record is required"); 307 - } 308 - 309 - const actualRkey = rkeyResult.rkey ?? tidNow(); 310 - const { storage, repo, keypair } = await loadRepoState(db, account); 311 - const op: RecordCreateOp = { 312 - action: WriteOpAction.Create, 313 - collection: collectionResult.collection, 314 - rkey: actualRkey, 315 - record: jsonToLex(body.record as JsonValue) as LexMap, 316 - }; 317 - const updatedRepo = await repo.applyWrites([op], keypair); 318 - const recordCid = await updatedRepo.data.get( 319 - formatDataKey(collectionResult.collection, actualRkey), 320 - ); 321 - storage.addCollection(collectionResult.collection); 322 - 323 - if (sequencer && storage.lastCommit) { 324 - const prevDataCid = account.prev_data_cid 325 - ? CID.parse(account.prev_data_cid as string) 326 - : null; 327 - await sequencer.sequenceCommit({ 328 - did: account.did, 329 - commit: storage.lastCommit.cid, 330 - rev: storage.lastCommit.rev, 331 - since: storage.lastCommit.since, 332 - prevData: prevDataCid, 333 - newBlocks: storage.lastCommit.newBlocks, 334 - ops: [ 335 - { 336 - action: "create", 337 - path: `${collectionResult.collection}/${actualRkey}`, 338 - cid: recordCid!, 339 - }, 340 - ], 341 - }); 342 - } 343 - 344 - return c.json({ 345 - uri: `at://${account.did}/${collectionResult.collection}/${actualRkey}`, 346 - cid: recordCid!.toString(), 347 - commit: { 348 - cid: updatedRepo.cid.toString(), 349 - rev: updatedRepo.commit.rev, 350 - }, 351 - }); 352 - }); 353 - 354 - app.post("/xrpc/com.atproto.repo.putRecord", authMiddleware, async (c) => { 355 - let body: Record<string, unknown>; 356 - try { 357 - body = await c.req.json(); 358 - } catch { 359 - return xrpcError(c, 400, "InvalidRequest", "invalid JSON body"); 360 - } 361 - 362 - const account = c.get("account"); 363 - const repoDidError = validateRepoDid(c, body.repo, account.did); 364 - if (repoDidError) return repoDidError; 365 - 366 - const collectionResult = validateCollection(c, body.collection); 367 - if (collectionResult instanceof Response) return collectionResult; 368 - 369 - const rkeyResult = validateRkey(c, body.rkey, true); 370 - if (rkeyResult instanceof Response) return rkeyResult; 371 - 372 - if (!isObjectRecord(body.record)) { 373 - return xrpcError(c, 400, "InvalidRequest", "record is required"); 374 - } 375 - 376 - const { storage, repo, keypair } = await loadRepoState(db, account); 377 - const existing = await repo.getRecord(collectionResult.collection, rkeyResult.rkey!); 378 - const lexRecord = jsonToLex(body.record as JsonValue) as LexMap; 379 - const op: RecordWriteOp = existing 380 - ? { 381 - action: WriteOpAction.Update, 382 - collection: collectionResult.collection, 383 - rkey: rkeyResult.rkey!, 384 - record: lexRecord, 385 - } 386 - : { 387 - action: WriteOpAction.Create, 388 - collection: collectionResult.collection, 389 - rkey: rkeyResult.rkey!, 390 - record: lexRecord, 391 - }; 392 - const updatedRepo = await repo.applyWrites([op], keypair); 393 - const recordCid = await updatedRepo.data.get( 394 - formatDataKey(collectionResult.collection, rkeyResult.rkey!), 395 - ); 396 - storage.addCollection(collectionResult.collection); 397 - 398 - if (sequencer && storage.lastCommit) { 399 - const prevDataCid = account.prev_data_cid 400 - ? CID.parse(account.prev_data_cid as string) 401 - : null; 402 - await sequencer.sequenceCommit({ 403 - did: account.did, 404 - commit: storage.lastCommit.cid, 405 - rev: storage.lastCommit.rev, 406 - since: storage.lastCommit.since, 407 - prevData: prevDataCid, 408 - newBlocks: storage.lastCommit.newBlocks, 409 - ops: [ 410 - { 411 - action: existing ? "update" : "create", 412 - path: `${collectionResult.collection}/${rkeyResult.rkey}`, 413 - cid: recordCid!, 414 - }, 415 - ], 416 - }); 417 - } 418 - 419 - return c.json({ 420 - uri: `at://${account.did}/${collectionResult.collection}/${rkeyResult.rkey}`, 421 - cid: recordCid!.toString(), 422 - commit: { 423 - cid: updatedRepo.cid.toString(), 424 - rev: updatedRepo.commit.rev, 425 - }, 426 - }); 427 - }); 428 - 429 - app.post("/xrpc/com.atproto.repo.deleteRecord", authMiddleware, async (c) => { 430 - let body: Record<string, unknown>; 431 - try { 432 - body = await c.req.json(); 433 - } catch { 434 - return xrpcError(c, 400, "InvalidRequest", "invalid JSON body"); 435 - } 436 - 437 - const account = c.get("account"); 438 - const repoDidError = validateRepoDid(c, body.repo, account.did); 439 - if (repoDidError) return repoDidError; 440 - 441 - const collectionResult = validateCollection(c, body.collection); 442 - if (collectionResult instanceof Response) return collectionResult; 443 - 444 - const rkeyResult = validateRkey(c, body.rkey, true); 445 - if (rkeyResult instanceof Response) return rkeyResult; 446 - 447 - const { storage, repo, keypair } = await loadRepoState(db, account); 448 - const existing = await repo.getRecord(collectionResult.collection, rkeyResult.rkey!); 449 - if (!existing) { 450 - return c.json({ 451 - commit: { 452 - cid: repo.cid.toString(), 453 - rev: repo.commit.rev, 454 - }, 455 - }); 456 - } 457 - 458 - const op: RecordDeleteOp = { 459 - action: WriteOpAction.Delete, 460 - collection: collectionResult.collection, 461 - rkey: rkeyResult.rkey!, 462 - }; 463 - const updatedRepo = await repo.applyWrites([op], keypair); 464 - 465 - if (sequencer && storage.lastCommit) { 466 - const prevDataCid = account.prev_data_cid 467 - ? CID.parse(account.prev_data_cid as string) 468 - : null; 469 - await sequencer.sequenceCommit({ 470 - did: account.did, 471 - commit: storage.lastCommit.cid, 472 - rev: storage.lastCommit.rev, 473 - since: storage.lastCommit.since, 474 - prevData: prevDataCid, 475 - newBlocks: storage.lastCommit.newBlocks, 476 - ops: [ 477 - { 478 - action: "delete", 479 - path: `${collectionResult.collection}/${rkeyResult.rkey}`, 480 - cid: null, 481 - }, 482 - ], 483 - }); 484 - } 485 - 486 - return c.json({ 487 - commit: { 488 - cid: updatedRepo.cid.toString(), 489 - rev: updatedRepo.commit.rev, 490 - }, 491 - }); 492 - }); 493 - 494 - app.post("/xrpc/com.atproto.repo.applyWrites", authMiddleware, async (c) => { 495 - let body: Record<string, unknown>; 496 - try { 497 - body = await c.req.json(); 498 - } catch { 499 - return xrpcError(c, 400, "InvalidRequest", "invalid JSON body"); 500 - } 501 - 502 - const account = c.get("account"); 503 - const repoDidError = validateRepoDid(c, body.repo, account.did); 504 - if (repoDidError) return repoDidError; 505 - 506 - if (!Array.isArray(body.writes)) { 507 - return xrpcError(c, 400, "InvalidRequest", "writes must be an array"); 508 - } 509 - if (body.writes.length > 200) { 510 - return xrpcError(c, 400, "InvalidRequest", "too many writes (max 200)"); 511 - } 512 - 513 - const { storage, repo, keypair } = await loadRepoState(db, account); 514 - const ops: RecordWriteOp[] = []; 515 - const collectionsToTrack = new Set<string>(); 516 - const resultMetadata: Array< 517 - | { kind: "create" | "update"; collection: string; rkey: string } 518 - | { kind: "delete" } 519 - > = []; 520 - 521 - for (const write of body.writes) { 522 - if (!isObjectRecord(write) || typeof write.$type !== "string") { 523 - return xrpcError(c, 400, "InvalidRequest", "unknown write type"); 524 - } 525 - 526 - const collectionResult = validateCollection(c, write.collection); 527 - if (collectionResult instanceof Response) return collectionResult; 528 - 529 - if (write.$type === "com.atproto.repo.applyWrites#create") { 530 - const rkeyResult = validateRkey(c, write.rkey, false); 531 - if (rkeyResult instanceof Response) return rkeyResult; 532 - if (!isObjectRecord(write.value)) { 533 - return xrpcError(c, 400, "InvalidRequest", "record is required"); 534 - } 535 - 536 - const actualRkey = rkeyResult.rkey ?? tidNow(); 537 - const op: RecordCreateOp = { 538 - action: WriteOpAction.Create, 539 - collection: collectionResult.collection, 540 - rkey: actualRkey, 541 - record: jsonToLex(write.value as JsonValue) as LexMap, 542 - }; 543 - ops.push(op); 544 - collectionsToTrack.add(collectionResult.collection); 545 - resultMetadata.push({ 546 - kind: "create", 547 - collection: collectionResult.collection, 548 - rkey: actualRkey, 549 - }); 550 - continue; 551 - } 552 - 553 - if (write.$type === "com.atproto.repo.applyWrites#update") { 554 - const rkeyResult = validateRkey(c, write.rkey, true); 555 - if (rkeyResult instanceof Response) return rkeyResult; 556 - if (!isObjectRecord(write.value)) { 557 - return xrpcError(c, 400, "InvalidRequest", "record is required"); 558 - } 559 - 560 - const op: RecordUpdateOp = { 561 - action: WriteOpAction.Update, 562 - collection: collectionResult.collection, 563 - rkey: rkeyResult.rkey!, 564 - record: jsonToLex(write.value as JsonValue) as LexMap, 565 - }; 566 - ops.push(op); 567 - collectionsToTrack.add(collectionResult.collection); 568 - resultMetadata.push({ 569 - kind: "update", 570 - collection: collectionResult.collection, 571 - rkey: rkeyResult.rkey!, 572 - }); 573 - continue; 574 - } 575 - 576 - if (write.$type === "com.atproto.repo.applyWrites#delete") { 577 - const rkeyResult = validateRkey(c, write.rkey, true); 578 - if (rkeyResult instanceof Response) return rkeyResult; 579 - 580 - const existing = await repo.getRecord(collectionResult.collection, rkeyResult.rkey!); 581 - if (existing) { 582 - const op: RecordDeleteOp = { 583 - action: WriteOpAction.Delete, 584 - collection: collectionResult.collection, 585 - rkey: rkeyResult.rkey!, 586 - }; 587 - ops.push(op); 588 - } 589 - resultMetadata.push({ kind: "delete" }); 590 - continue; 591 - } 592 - 593 - return xrpcError(c, 400, "InvalidRequest", `unknown write type: ${write.$type}`); 594 - } 595 - 596 - const finalRepo = ops.length > 0 ? await repo.applyWrites(ops, keypair) : repo; 597 - for (const collection of collectionsToTrack) { 598 - storage.addCollection(collection); 599 - } 600 - 601 - if (sequencer && storage.lastCommit && ops.length > 0) { 602 - const prevDataCid = account.prev_data_cid 603 - ? CID.parse(account.prev_data_cid as string) 604 - : null; 605 - const firehoseOps: FirehoseOp[] = []; 606 - for (const op of ops) { 607 - if (op.action === WriteOpAction.Delete) { 608 - firehoseOps.push({ 609 - action: "delete", 610 - path: `${op.collection}/${op.rkey}`, 611 - cid: null, 612 - }); 613 - } else { 614 - const cid = await finalRepo.data.get( 615 - formatDataKey(op.collection, op.rkey), 616 - ); 617 - firehoseOps.push({ 618 - action: op.action === WriteOpAction.Create ? "create" : "update", 619 - path: `${op.collection}/${op.rkey}`, 620 - cid: cid ?? null, 621 - }); 622 - } 623 - } 624 - await sequencer.sequenceCommit({ 625 - did: account.did, 626 - commit: storage.lastCommit.cid, 627 - rev: storage.lastCommit.rev, 628 - since: storage.lastCommit.since, 629 - prevData: prevDataCid, 630 - newBlocks: storage.lastCommit.newBlocks, 631 - ops: firehoseOps, 632 - }); 633 - } 634 - 635 - const results = []; 636 - for (const metadata of resultMetadata) { 637 - if (metadata.kind === "delete") { 638 - results.push({}); 639 - continue; 640 - } 641 - 642 - const recordCid = await finalRepo.data.get( 643 - formatDataKey(metadata.collection, metadata.rkey), 644 - ); 645 - results.push({ 646 - uri: `at://${account.did}/${metadata.collection}/${metadata.rkey}`, 647 - cid: recordCid!.toString(), 648 - }); 649 - } 650 - 651 - return c.json({ 652 - commit: { 653 - cid: finalRepo.cid.toString(), 654 - rev: finalRepo.commit.rev, 655 - }, 656 - results, 657 - }); 658 - }); 659 - 660 - app.post( 661 - "/xrpc/com.atproto.repo.uploadBlob", 662 - bodyLimit({ 663 - maxSize: 60 * 1024 * 1024, 664 - onError: (c) => 665 - c.json({ error: "InvalidRequest", message: "blob too large (max 60MB)" }, 400), 666 - }), 667 - authMiddleware, 668 - async (c) => { 669 - const account = c.get("account"); 670 - const mimeType = c.req.header("content-type") ?? "application/octet-stream"; 671 - const arrayBuf = await c.req.arrayBuffer(); 672 - const bytes = new Uint8Array(arrayBuf); 673 - 674 - const cid = await createCid(0x55, bytes); 675 - const cidStr = formatCid(cid); 676 - 677 - const dirPath = path.join(config.blobDir, account.did); 678 - mkdirSync(dirPath, { recursive: true }); 679 - writeFileSync(path.join(dirPath, cidStr), bytes); 680 - 681 - db.prepare( 682 - "INSERT INTO blobs (account_id, cid, mime_type, size) VALUES (?, ?, ?, ?) ON CONFLICT(account_id, cid) DO UPDATE SET mime_type = excluded.mime_type, size = excluded.size", 683 - ).run(account.id, cidStr, mimeType, bytes.length); 684 - 685 - return c.json({ 686 - blob: { 687 - $type: "blob", 688 - ref: { $link: cidStr }, 689 - mimeType, 690 - size: bytes.length, 691 - }, 692 - }); 693 - }, 694 - ); 695 - 696 - return app; 697 - }
-188
src/sequencer.ts
··· 1 - import { CID } from "@atproto/lex-data"; 2 - import { type BlockMap, blocksToCarFile } from "@atproto/repo"; 3 - import type Database from "better-sqlite3"; 4 - import { encode as cborEncode } from "./cbor-compat.js"; 5 - 6 - export interface FirehoseOp { 7 - action: "create" | "update" | "delete"; 8 - path: string; 9 - cid: CID | null; 10 - } 11 - 12 - export interface FirehoseCommitData { 13 - did: string; 14 - commit: CID; 15 - rev: string; 16 - since: string | null; 17 - prevData: CID | null; 18 - newBlocks: BlockMap; 19 - ops: FirehoseOp[]; 20 - } 21 - 22 - function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { 23 - const result = new Uint8Array(a.length + b.length); 24 - result.set(a, 0); 25 - result.set(b, a.length); 26 - return result; 27 - } 28 - 29 - export class Sequencer { 30 - private subscribers = new Set<(frame: Uint8Array) => void>(); 31 - 32 - constructor(private db: Database.Database) {} 33 - 34 - async sequenceCommit(data: FirehoseCommitData): Promise<{ seq: number }> { 35 - const carBytes = await blocksToCarFile(data.commit, data.newBlocks); 36 - const time = new Date().toISOString(); 37 - 38 - const body: Record<string, unknown> = { 39 - repo: data.did, 40 - commit: data.commit, 41 - rev: data.rev, 42 - since: data.since, 43 - blocks: carBytes, 44 - ops: data.ops, 45 - prevData: data.prevData, 46 - rebase: false, 47 - tooBig: carBytes.length > 1_000_000, 48 - blobs: [], 49 - time, 50 - }; 51 - 52 - const { seq, frame } = this.db.transaction(() => { 53 - const info = this.db 54 - .prepare( 55 - "INSERT INTO firehose_events (did, event_type, payload) VALUES (?, ?, ?)", 56 - ) 57 - .run(data.did, "commit", Buffer.alloc(0)); 58 - const seq = Number(info.lastInsertRowid); 59 - 60 - const header = cborEncode({ op: 1, t: "#commit" }); 61 - const bodyWithSeq = cborEncode({ ...body, seq }); 62 - const frame = concatBytes(header, bodyWithSeq); 63 - 64 - this.db 65 - .prepare("UPDATE firehose_events SET payload = ? WHERE seq = ?") 66 - .run(frame, seq); 67 - 68 - return { seq, frame }; 69 - })(); 70 - 71 - this.broadcast(frame); 72 - return { seq }; 73 - } 74 - 75 - sequenceIdentity(did: string, handle: string): { seq: number } { 76 - const time = new Date().toISOString(); 77 - 78 - const { seq, frame } = this.db.transaction(() => { 79 - const info = this.db 80 - .prepare( 81 - "INSERT INTO firehose_events (did, event_type, payload) VALUES (?, ?, ?)", 82 - ) 83 - .run(did, "identity", Buffer.alloc(0)); 84 - const seq = Number(info.lastInsertRowid); 85 - 86 - const header = cborEncode({ op: 1, t: "#identity" }); 87 - const bodyBytes = cborEncode({ seq, did, handle, time }); 88 - const frame = concatBytes(header, bodyBytes); 89 - 90 - this.db 91 - .prepare("UPDATE firehose_events SET payload = ? WHERE seq = ?") 92 - .run(frame, seq); 93 - 94 - return { seq, frame }; 95 - })(); 96 - 97 - this.broadcast(frame); 98 - return { seq }; 99 - } 100 - 101 - sequenceAccount( 102 - did: string, 103 - active: boolean, 104 - status?: string, 105 - ): { seq: number } { 106 - const time = new Date().toISOString(); 107 - 108 - const { seq, frame } = this.db.transaction(() => { 109 - const info = this.db 110 - .prepare( 111 - "INSERT INTO firehose_events (did, event_type, payload) VALUES (?, ?, ?)", 112 - ) 113 - .run(did, "account", Buffer.alloc(0)); 114 - const seq = Number(info.lastInsertRowid); 115 - 116 - const header = cborEncode({ op: 1, t: "#account" }); 117 - const bodyBytes = cborEncode({ 118 - seq, 119 - did, 120 - active, 121 - status: status ?? null, 122 - time, 123 - }); 124 - const frame = concatBytes(header, bodyBytes); 125 - 126 - this.db 127 - .prepare("UPDATE firehose_events SET payload = ? WHERE seq = ?") 128 - .run(frame, seq); 129 - 130 - return { seq, frame }; 131 - })(); 132 - 133 - this.broadcast(frame); 134 - return { seq }; 135 - } 136 - 137 - getEventsSince(cursor: number, limit = 500): Uint8Array[] { 138 - const rows = this.db 139 - .prepare( 140 - "SELECT payload FROM firehose_events WHERE seq > ? ORDER BY seq ASC LIMIT ?", 141 - ) 142 - .all(cursor, limit) as { payload: Buffer }[]; 143 - return rows.map((r) => new Uint8Array(r.payload)); 144 - } 145 - 146 - getLatestSeq(): number { 147 - const row = this.db 148 - .prepare("SELECT MAX(seq) as seq FROM firehose_events") 149 - .get() as { seq: number | null }; 150 - return row?.seq ?? 0; 151 - } 152 - 153 - getOldestSeq(): number { 154 - const row = this.db 155 - .prepare("SELECT MIN(seq) as seq FROM firehose_events") 156 - .get() as { seq: number | null }; 157 - return row?.seq ?? 0; 158 - } 159 - 160 - pruneOldEvents(keepCount = 10000): void { 161 - this.db 162 - .prepare( 163 - "DELETE FROM firehose_events WHERE seq < (SELECT MAX(seq) - ? FROM firehose_events)", 164 - ) 165 - .run(keepCount); 166 - } 167 - 168 - subscribe(handler: (frame: Uint8Array) => void): () => void { 169 - this.subscribers.add(handler); 170 - return () => { 171 - this.subscribers.delete(handler); 172 - }; 173 - } 174 - 175 - get subscriberCount(): number { 176 - return this.subscribers.size; 177 - } 178 - 179 - private broadcast(frame: Uint8Array): void { 180 - for (const handler of this.subscribers) { 181 - try { 182 - handler(frame); 183 - } catch { 184 - this.subscribers.delete(handler); 185 - } 186 - } 187 - } 188 - }
+208 -80
src/storage.ts
··· 6 6 type CommitData, 7 7 type RepoStorage, 8 8 } from "@atproto/repo"; 9 - import type Database from "better-sqlite3"; 9 + 10 + export interface AccountState { 11 + did: string; 12 + handle: string; 13 + signing_key_hex: string; 14 + signing_key_pub: string; 15 + rotation_key_hex: string; 16 + rotation_key_pub: string; 17 + jwk_thumbprint: string | null; 18 + root_cid: string | null; 19 + rev: string | null; 20 + prev_data_cid: string | null; 21 + active: number; 22 + created_at: string; 23 + } 10 24 11 25 export class SqliteRepoStorage 12 26 extends ReadableBlockstore ··· 14 28 { 15 29 lastCommit: CommitData | null = null; 16 30 17 - constructor( 18 - private db: Database.Database, 19 - private accountId: number, 20 - ) { 31 + constructor(private sql: SqlStorage) { 21 32 super(); 22 33 } 23 34 35 + /** 36 + * Initialize the database schema. Called once on DO startup. 37 + */ 38 + initSchema(): void { 39 + this.sql.exec(` 40 + CREATE TABLE IF NOT EXISTS blocks ( 41 + cid TEXT PRIMARY KEY, 42 + bytes BLOB NOT NULL, 43 + rev TEXT NOT NULL 44 + ); 45 + 46 + CREATE INDEX IF NOT EXISTS idx_blocks_rev ON blocks(rev); 47 + 48 + CREATE TABLE IF NOT EXISTS repo_state ( 49 + id INTEGER PRIMARY KEY CHECK (id = 1), 50 + did TEXT, 51 + handle TEXT, 52 + signing_key_hex TEXT, 53 + signing_key_pub TEXT, 54 + rotation_key_hex TEXT, 55 + rotation_key_pub TEXT, 56 + jwk_thumbprint TEXT, 57 + root_cid TEXT, 58 + rev TEXT, 59 + prev_data_cid TEXT, 60 + active INTEGER NOT NULL DEFAULT 1, 61 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 62 + ); 63 + 64 + INSERT OR IGNORE INTO repo_state (id) VALUES (1); 65 + 66 + CREATE TABLE IF NOT EXISTS collections ( 67 + collection TEXT PRIMARY KEY 68 + ); 69 + `); 70 + } 71 + 72 + /** 73 + * Set account-specific state in repo_state. Called during account provisioning. 74 + */ 75 + initAccountState(opts: { 76 + did: string; 77 + handle: string; 78 + signing_key_hex: string; 79 + signing_key_pub: string; 80 + rotation_key_hex: string; 81 + rotation_key_pub: string; 82 + jwk_thumbprint: string | null; 83 + }): void { 84 + this.sql.exec( 85 + `UPDATE repo_state SET 86 + did = ?, handle = ?, 87 + signing_key_hex = ?, signing_key_pub = ?, 88 + rotation_key_hex = ?, rotation_key_pub = ?, 89 + jwk_thumbprint = ? 90 + WHERE id = 1`, 91 + opts.did, 92 + opts.handle, 93 + opts.signing_key_hex, 94 + opts.signing_key_pub, 95 + opts.rotation_key_hex, 96 + opts.rotation_key_pub, 97 + opts.jwk_thumbprint, 98 + ); 99 + } 100 + 101 + /** 102 + * Get the full account state from repo_state. 103 + */ 104 + getState(): AccountState | null { 105 + const rows = this.sql.exec("SELECT * FROM repo_state WHERE id = 1").toArray(); 106 + if (rows.length === 0) return null; 107 + return rows[0] as unknown as AccountState; 108 + } 109 + 110 + async getRoot(): Promise<CID | null> { 111 + const rows = this.sql 112 + .exec("SELECT root_cid FROM repo_state WHERE id = 1") 113 + .toArray(); 114 + if (rows.length === 0 || !rows[0]?.root_cid) return null; 115 + return CID.parse(rows[0]!.root_cid as string); 116 + } 117 + 118 + async getRev(): Promise<string | null> { 119 + const rows = this.sql.exec("SELECT rev FROM repo_state WHERE id = 1").toArray(); 120 + return rows.length > 0 ? ((rows[0]!.rev as string) ?? null) : null; 121 + } 122 + 24 123 async getBytes(cid: CID): Promise<Uint8Array | null> { 25 - const row = this.db 26 - .prepare("SELECT bytes FROM blocks WHERE account_id = ? AND cid = ?") 27 - .get(this.accountId, cid.toString()) as { bytes: Buffer } | undefined; 28 - return row?.bytes ?? null; 124 + const rows = this.sql 125 + .exec("SELECT bytes FROM blocks WHERE cid = ?", cid.toString()) 126 + .toArray(); 127 + if (rows.length === 0 || !rows[0]?.bytes) return null; 128 + // DO SQLite returns ArrayBuffer for BLOB columns 129 + return new Uint8Array(rows[0]!.bytes as ArrayBuffer); 29 130 } 30 131 31 132 async has(cid: CID): Promise<boolean> { 32 - const row = this.db 33 - .prepare( 34 - "SELECT 1 FROM blocks WHERE account_id = ? AND cid = ? LIMIT 1", 35 - ) 36 - .get(this.accountId, cid.toString()); 37 - return !!row; 133 + const rows = this.sql 134 + .exec("SELECT 1 FROM blocks WHERE cid = ? LIMIT 1", cid.toString()) 135 + .toArray(); 136 + return rows.length > 0; 38 137 } 39 138 40 139 async getBlocks(cids: CID[]): Promise<{ blocks: BlockMap; missing: CID[] }> { ··· 49 148 } 50 149 } 51 150 return { blocks, missing }; 52 - } 53 - 54 - async getRoot(): Promise<CID | null> { 55 - const row = this.db 56 - .prepare("SELECT root_cid FROM accounts WHERE id = ?") 57 - .get(this.accountId) as { root_cid: string | null } | undefined; 58 - if (!row?.root_cid) return null; 59 - return CID.parse(row.root_cid); 60 - } 61 - 62 - async getRev(): Promise<string | null> { 63 - const row = this.db 64 - .prepare("SELECT rev FROM accounts WHERE id = ?") 65 - .get(this.accountId) as { rev: string | null } | undefined; 66 - return row?.rev ?? null; 67 151 } 68 152 69 153 async putBlock(cid: CID, block: Uint8Array, rev: string): Promise<void> { 70 - this.db 71 - .prepare( 72 - "INSERT OR REPLACE INTO blocks (account_id, cid, bytes, rev) VALUES (?, ?, ?, ?)", 73 - ) 74 - .run(this.accountId, cid.toString(), block, rev); 154 + this.sql.exec( 155 + "INSERT OR REPLACE INTO blocks (cid, bytes, rev) VALUES (?, ?, ?)", 156 + cid.toString(), 157 + block, 158 + rev, 159 + ); 75 160 } 76 161 77 162 async putMany(blocks: BlockMap, rev: string): Promise<void> { 78 - const stmt = this.db.prepare( 79 - "INSERT OR REPLACE INTO blocks (account_id, cid, bytes, rev) VALUES (?, ?, ?, ?)", 80 - ); 81 - for (const [cid, bytes] of blocks) { 82 - stmt.run(this.accountId, cid.toString(), bytes, rev); 163 + // Access BlockMap's internal map to avoid iterator issues in Workers 164 + const internalMap = (blocks as unknown as { map: Map<string, Uint8Array> }).map; 165 + if (internalMap) { 166 + for (const [cidStr, bytes] of internalMap) { 167 + this.sql.exec( 168 + "INSERT OR REPLACE INTO blocks (cid, bytes, rev) VALUES (?, ?, ?)", 169 + cidStr, 170 + bytes, 171 + rev, 172 + ); 173 + } 83 174 } 84 175 } 85 176 86 177 async updateRoot(cid: CID, rev: string): Promise<void> { 87 - this.db 88 - .prepare("UPDATE accounts SET root_cid = ?, rev = ? WHERE id = ?") 89 - .run(cid.toString(), rev, this.accountId); 178 + this.sql.exec( 179 + "UPDATE repo_state SET root_cid = ?, rev = ? WHERE id = 1", 180 + cid.toString(), 181 + rev, 182 + ); 90 183 } 91 184 92 185 async applyCommit(commit: CommitData): Promise<void> { 93 186 this.lastCommit = commit; 94 - const doCommit = this.db.transaction(() => { 95 - const insertBlock = this.db.prepare( 96 - "INSERT OR REPLACE INTO blocks (account_id, cid, bytes, rev) VALUES (?, ?, ?, ?)", 97 - ); 98 - for (const [cid, bytes] of commit.newBlocks) { 99 - insertBlock.run(this.accountId, cid.toString(), bytes, commit.rev); 187 + 188 + // Insert new blocks - access BlockMap's internal map for Workers compat 189 + const internalMap = ( 190 + commit.newBlocks as unknown as { map: Map<string, Uint8Array> } 191 + ).map; 192 + if (internalMap) { 193 + for (const [cidStr, bytes] of internalMap) { 194 + this.sql.exec( 195 + "INSERT OR REPLACE INTO blocks (cid, bytes, rev) VALUES (?, ?, ?)", 196 + cidStr, 197 + bytes, 198 + commit.rev, 199 + ); 100 200 } 201 + } 101 202 102 - const deleteBlock = this.db.prepare( 103 - "DELETE FROM blocks WHERE account_id = ? AND cid = ?", 104 - ); 105 - for (const cid of commit.removedCids) { 106 - deleteBlock.run(this.accountId, cid.toString()); 203 + // Remove old blocks - access CidSet's internal set for Workers compat 204 + const removedSet = (commit.removedCids as unknown as { set: Set<string> }).set; 205 + if (removedSet) { 206 + for (const cidStr of removedSet) { 207 + this.sql.exec("DELETE FROM blocks WHERE cid = ?", cidStr); 107 208 } 209 + } 108 210 109 - this.db 110 - .prepare( 111 - "UPDATE accounts SET root_cid = ?, rev = ? WHERE id = ?", 112 - ) 113 - .run(commit.cid.toString(), commit.rev, this.accountId); 211 + // Update root 212 + // NOTE: no await between block inserts and root update - DO write coalescing 213 + this.sql.exec( 214 + "UPDATE repo_state SET root_cid = ?, rev = ? WHERE id = 1", 215 + commit.cid.toString(), 216 + commit.rev, 217 + ); 114 218 115 - // Extract MST data root CID from the commit block for prev_data_cid 116 - const commitBytes = commit.newBlocks.get(commit.cid); 117 - if (!commitBytes) { 118 - throw new Error(`Missing commit block for CID ${commit.cid.toString()}`); 119 - } 219 + // Extract and store prev_data_cid from the commit block 220 + const commitBytes = internalMap?.get(commit.cid.toString()); 221 + if (commitBytes) { 120 222 const commitObj = cborToLex(commitBytes) as { data: CID }; 121 - this.db 122 - .prepare("UPDATE accounts SET prev_data_cid = ? WHERE id = ?") 123 - .run(commitObj.data.toString(), this.accountId); 124 - }); 125 - doCommit(); 223 + if (commitObj.data) { 224 + this.sql.exec( 225 + "UPDATE repo_state SET prev_data_cid = ? WHERE id = 1", 226 + commitObj.data.toString(), 227 + ); 228 + } 229 + } 126 230 } 127 231 128 232 addCollection(collection: string): void { 129 - this.db 130 - .prepare( 131 - "INSERT OR IGNORE INTO collections (account_id, collection) VALUES (?, ?)", 132 - ) 133 - .run(this.accountId, collection); 233 + this.sql.exec( 234 + "INSERT OR IGNORE INTO collections (collection) VALUES (?)", 235 + collection, 236 + ); 134 237 } 135 238 136 239 getCollections(): string[] { 137 - const rows = this.db 138 - .prepare( 139 - "SELECT collection FROM collections WHERE account_id = ? ORDER BY collection", 140 - ) 141 - .all(this.accountId) as { collection: string }[]; 142 - return rows.map((r) => r.collection); 240 + const rows = this.sql 241 + .exec("SELECT collection FROM collections ORDER BY collection") 242 + .toArray(); 243 + return rows.map((row) => row.collection as string); 244 + } 245 + 246 + /** 247 + * Get all blocks (used for CAR export). 248 + */ 249 + getAllBlocks(): Array<{ cid: string; bytes: ArrayBuffer }> { 250 + return this.sql 251 + .exec("SELECT cid, bytes FROM blocks") 252 + .toArray() as Array<{ cid: string; bytes: ArrayBuffer }>; 253 + } 254 + 255 + /** 256 + * Count blocks (for testing). 257 + */ 258 + async countBlocks(): Promise<number> { 259 + const rows = this.sql.exec("SELECT COUNT(*) as count FROM blocks").toArray(); 260 + return rows.length > 0 ? ((rows[0]!.count as number) ?? 0) : 0; 261 + } 262 + 263 + /** 264 + * Clear all data (for testing). 265 + */ 266 + async destroy(): Promise<void> { 267 + this.sql.exec("DELETE FROM blocks"); 268 + this.sql.exec( 269 + "UPDATE repo_state SET root_cid = NULL, rev = NULL WHERE id = 1", 270 + ); 143 271 } 144 272 }
-319
src/sync.ts
··· 1 - import { Hono, type Context } from "hono"; 2 - import { stream } from "hono/streaming"; 3 - import { readFileSync } from "node:fs"; 4 - import path from "node:path"; 5 - import { CID } from "@atproto/lex-data"; 6 - import { BlockMap, blocksToCarStream } from "@atproto/repo"; 7 - import type Database from "better-sqlite3"; 8 - import type { Config } from "./config.js"; 9 - import type { Sequencer } from "./sequencer.js"; 10 - 11 - type AccountRow = { 12 - id: number; 13 - did: string; 14 - root_cid: string | null; 15 - rev: string | null; 16 - active: number; 17 - }; 18 - 19 - type BlockRow = { 20 - cid: string; 21 - bytes: Buffer; 22 - }; 23 - 24 - function xrpcError( 25 - c: Context, 26 - status: 400 | 404, 27 - error: string, 28 - message: string, 29 - ) { 30 - return c.json({ error, message }, status); 31 - } 32 - 33 - function getAccountByDid( 34 - db: Database.Database, 35 - did: string, 36 - ): AccountRow | undefined { 37 - return db 38 - .prepare("SELECT id, did, root_cid, rev, active FROM accounts WHERE did = ?") 39 - .get(did) as AccountRow | undefined; 40 - } 41 - 42 - function getAllBlocks(db: Database.Database, accountId: number): BlockRow[] { 43 - return db 44 - .prepare("SELECT cid, bytes FROM blocks WHERE account_id = ?") 45 - .all(accountId) as BlockRow[]; 46 - } 47 - 48 - function getBlocksSince( 49 - db: Database.Database, 50 - accountId: number, 51 - since: string, 52 - ): BlockRow[] { 53 - return db 54 - .prepare("SELECT cid, bytes FROM blocks WHERE account_id = ? AND rev > ?") 55 - .all(accountId, since) as BlockRow[]; 56 - } 57 - 58 - export function createSyncRoutes( 59 - db: Database.Database, 60 - config: Config, 61 - sequencer?: Sequencer, 62 - upgradeWebSocket?: Function, 63 - ): Hono { 64 - const app = new Hono(); 65 - 66 - app.get("/xrpc/com.atproto.sync.getRepo", (c) => { 67 - const did = c.req.query("did"); 68 - if (!did) { 69 - return xrpcError(c, 400, "InvalidRequest", "missing did parameter"); 70 - } 71 - 72 - const account = getAccountByDid(db, did); 73 - if (!account) { 74 - return xrpcError(c, 404, "RepoNotFound", "repo not found"); 75 - } 76 - if (!account.root_cid) { 77 - return xrpcError(c, 400, "RepoNotFound", "repo not initialized"); 78 - } 79 - 80 - const since = c.req.query("since"); 81 - const rows = since 82 - ? getBlocksSince(db, account.id, since) 83 - : getAllBlocks(db, account.id); 84 - 85 - const blocks = new BlockMap(); 86 - for (const row of rows) { 87 - blocks.set(CID.parse(row.cid), row.bytes); 88 - } 89 - 90 - const rootCid = CID.parse(account.root_cid); 91 - const carStream = blocksToCarStream(rootCid, blocks); 92 - 93 - c.header("Content-Type", "application/vnd.ipld.car"); 94 - return stream(c, async (s) => { 95 - for await (const chunk of carStream) { 96 - await s.write(chunk); 97 - } 98 - }); 99 - }); 100 - 101 - app.get("/xrpc/com.atproto.sync.getLatestCommit", (c) => { 102 - const did = c.req.query("did"); 103 - if (!did) { 104 - return xrpcError(c, 400, "InvalidRequest", "missing did parameter"); 105 - } 106 - 107 - const account = getAccountByDid(db, did); 108 - if (!account) { 109 - return xrpcError(c, 404, "RepoNotFound", "repo not found"); 110 - } 111 - if (!account.root_cid || !account.rev) { 112 - return xrpcError(c, 404, "RepoNotFound", "repo has no commits"); 113 - } 114 - 115 - return c.json({ cid: account.root_cid, rev: account.rev }); 116 - }); 117 - 118 - app.get("/xrpc/com.atproto.sync.getRepoStatus", (c) => { 119 - const did = c.req.query("did"); 120 - if (!did) { 121 - return xrpcError(c, 400, "InvalidRequest", "missing did parameter"); 122 - } 123 - 124 - const account = getAccountByDid(db, did); 125 - if (!account) { 126 - return xrpcError(c, 404, "RepoNotFound", "repo not found"); 127 - } 128 - 129 - const response: { 130 - did: string; 131 - active: boolean; 132 - rev?: string; 133 - status?: "deactivated"; 134 - } = { 135 - did: account.did, 136 - active: !!account.active, 137 - rev: account.rev ?? undefined, 138 - }; 139 - 140 - if (!account.active) { 141 - response.status = "deactivated"; 142 - } 143 - 144 - return c.json(response); 145 - }); 146 - 147 - app.get("/xrpc/com.atproto.sync.listRepos", (c) => { 148 - const limit = Math.min( 149 - Math.max(Number.parseInt(c.req.query("limit") ?? "500", 10) || 500, 1), 150 - 1000, 151 - ); 152 - 153 - const cursorParam = c.req.query("cursor"); 154 - let cursor: number | undefined; 155 - if (cursorParam !== undefined) { 156 - if (!/^\d+$/.test(cursorParam)) { 157 - return xrpcError(c, 400, "InvalidRequest", "invalid cursor"); 158 - } 159 - cursor = Number.parseInt(cursorParam, 10); 160 - } 161 - 162 - const rows = db 163 - .prepare( 164 - "SELECT id, did, root_cid, rev, active FROM accounts WHERE id > ? ORDER BY id ASC LIMIT ?", 165 - ) 166 - .all(cursor ?? 0, limit + 1) as AccountRow[]; 167 - 168 - let nextCursor: string | undefined; 169 - if (rows.length === limit + 1) { 170 - rows.pop(); 171 - nextCursor = rows[rows.length - 1]?.id.toString(); 172 - } 173 - 174 - const repos = rows 175 - .filter((row) => row.root_cid) 176 - .map((row) => ({ 177 - did: row.did, 178 - head: row.root_cid!, 179 - rev: row.rev!, 180 - active: !!row.active, 181 - })); 182 - 183 - return c.json(nextCursor ? { cursor: nextCursor, repos } : { repos }); 184 - }); 185 - 186 - if (sequencer && upgradeWebSocket) { 187 - app.get( 188 - "/xrpc/com.atproto.sync.subscribeRepos", 189 - (upgradeWebSocket as Function)((c: Context) => { 190 - const cursorParam = c.req.query("cursor"); 191 - let unsubscribe: (() => void) | null = null; 192 - 193 - return { 194 - onOpen( 195 - _evt: unknown, 196 - ws: { 197 - send: (data: string | ArrayBuffer | Uint8Array) => void; 198 - close: (code?: number, reason?: string) => void; 199 - }, 200 - ) { 201 - if (cursorParam !== undefined) { 202 - const cursor = Number.parseInt(cursorParam, 10); 203 - if (Number.isNaN(cursor) || cursor < 0) { 204 - ws.close(1008, "invalid cursor"); 205 - return; 206 - } 207 - 208 - const oldestSeq = sequencer.getOldestSeq(); 209 - if (oldestSeq > 0 && cursor < oldestSeq - 1) { 210 - ws.close(1008, "cursor too old"); 211 - return; 212 - } 213 - 214 - const events = sequencer.getEventsSince(cursor); 215 - for (const frame of events) { 216 - ws.send(frame); 217 - } 218 - } 219 - 220 - unsubscribe = sequencer.subscribe((frame) => { 221 - try { 222 - ws.send(frame); 223 - } catch { 224 - unsubscribe?.(); 225 - unsubscribe = null; 226 - } 227 - }); 228 - }, 229 - onClose() { 230 - unsubscribe?.(); 231 - unsubscribe = null; 232 - }, 233 - }; 234 - }), 235 - ); 236 - } 237 - 238 - app.get("/xrpc/com.atproto.sync.getBlob", (c) => { 239 - const did = c.req.query("did"); 240 - const cid = c.req.query("cid"); 241 - if (!did || !cid) { 242 - return xrpcError(c, 400, "InvalidRequest", "missing did or cid parameter"); 243 - } 244 - 245 - const row = db 246 - .prepare( 247 - "SELECT b.cid, b.mime_type FROM blobs b JOIN accounts a ON a.id = b.account_id WHERE a.did = ? AND b.cid = ?", 248 - ) 249 - .get(did, cid) as { cid: string; mime_type: string | null } | undefined; 250 - 251 - if (!row) { 252 - return xrpcError(c, 404, "BlobNotFound", "blob not found"); 253 - } 254 - 255 - const blobPath = path.join(config.blobDir, did, cid); 256 - let bytes: Buffer; 257 - try { 258 - bytes = readFileSync(blobPath); 259 - } catch { 260 - return xrpcError(c, 404, "BlobNotFound", "blob not found"); 261 - } 262 - 263 - return new Response(new Uint8Array(bytes), { 264 - headers: { "Content-Type": row.mime_type ?? "application/octet-stream" }, 265 - }); 266 - }); 267 - 268 - app.get("/xrpc/com.atproto.sync.listBlobs", (c) => { 269 - const did = c.req.query("did"); 270 - if (!did) { 271 - return xrpcError(c, 400, "InvalidRequest", "missing did parameter"); 272 - } 273 - 274 - const account = getAccountByDid(db, did); 275 - if (!account) { 276 - return xrpcError(c, 404, "RepoNotFound", "repo not found"); 277 - } 278 - 279 - const limit = Math.min( 280 - Math.max(Number.parseInt(c.req.query("limit") ?? "500", 10) || 500, 1), 281 - 1000, 282 - ); 283 - 284 - const cursor = c.req.query("cursor"); 285 - const since = c.req.query("since"); 286 - 287 - let query: string; 288 - const params: (number | string)[] = [account.id]; 289 - 290 - if (cursor && since) { 291 - query = 292 - "SELECT cid FROM blobs WHERE account_id = ? AND cid > ? AND created_at > ? ORDER BY cid ASC LIMIT ?"; 293 - params.push(cursor, since, limit + 1); 294 - } else if (cursor) { 295 - query = "SELECT cid FROM blobs WHERE account_id = ? AND cid > ? ORDER BY cid ASC LIMIT ?"; 296 - params.push(cursor, limit + 1); 297 - } else if (since) { 298 - query = "SELECT cid FROM blobs WHERE account_id = ? AND created_at > ? ORDER BY cid ASC LIMIT ?"; 299 - params.push(since, limit + 1); 300 - } else { 301 - query = "SELECT cid FROM blobs WHERE account_id = ? ORDER BY cid ASC LIMIT ?"; 302 - params.push(limit + 1); 303 - } 304 - 305 - const rows = db.prepare(query).all(...params) as { cid: string }[]; 306 - 307 - let nextCursor: string | undefined; 308 - if (rows.length === limit + 1) { 309 - rows.pop(); 310 - nextCursor = rows[rows.length - 1]?.cid; 311 - } 312 - 313 - const cids = rows.map((r) => r.cid); 314 - 315 - return c.json(nextCursor ? { cursor: nextCursor, cids } : { cids }); 316 - }); 317 - 318 - return app; 319 - }
+16
src/types.ts
··· 1 + import type { AccountDurableObject } from "./account-do"; 2 + 3 + /** 4 + * Environment bindings for the rookery Worker. 5 + * Multi-tenant PDS - per-account keys live in DO SQL, not here. 6 + */ 7 + export interface Env { 8 + /** Durable Object namespace for account storage */ 9 + ACCOUNT: DurableObjectNamespace<AccountDurableObject>; 10 + /** Public hostname of the PDS */ 11 + ROOKERY_HOSTNAME: string; 12 + /** Handle domain suffix (e.g. ".pds.example.com") */ 13 + ROOKERY_HANDLE_DOMAIN: string; 14 + /** PLC directory URL */ 15 + ROOKERY_PLC_URL: string; 16 + }
+10
src/worker.ts
··· 1 + export { AccountDurableObject } from "./account-do"; 2 + 3 + import { Hono } from "hono"; 4 + import type { Env } from "./types"; 5 + 6 + const app = new Hono<{ Bindings: Env }>(); 7 + 8 + app.get("/", (c) => c.json({ status: "ok" })); 9 + 10 + export default app;
+193
test/account-do.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { env, runInDurableObject, worker } from "./helpers"; 3 + import { Secp256k1Keypair } from "@atproto/crypto"; 4 + import { toString } from "uint8arrays/to-string"; 5 + import { AccountDurableObject } from "../src/account-do"; 6 + import { SqliteRepoStorage } from "../src/storage"; 7 + 8 + const TEST_DID = "did:plc:test123456789"; 9 + const TEST_HANDLE = "test.rookery.test"; 10 + 11 + async function generateTestKeys() { 12 + const signing = await Secp256k1Keypair.create({ exportable: true }); 13 + const rotation = await Secp256k1Keypair.create({ exportable: true }); 14 + return { 15 + signingKeyHex: toString(await signing.export(), "hex"), 16 + signingKeyPub: signing.did().split(":").pop()!, 17 + rotationKeyHex: toString(await rotation.export(), "hex"), 18 + rotationKeyPub: rotation.did().split(":").pop()!, 19 + }; 20 + } 21 + 22 + async function provisionAccount(instance: AccountDurableObject) { 23 + const keys = await generateTestKeys(); 24 + await instance.rpcInitAccount({ 25 + did: TEST_DID, 26 + handle: TEST_HANDLE, 27 + ...keys, 28 + }); 29 + return keys; 30 + } 31 + 32 + describe("AccountDurableObject", () => { 33 + it("initializes storage on first access", async () => { 34 + const id = env.ACCOUNT.newUniqueId(); 35 + const stub = env.ACCOUNT.get(id); 36 + 37 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 38 + const storage = await instance.getStorage(); 39 + expect(storage).toBeInstanceOf(SqliteRepoStorage); 40 + }); 41 + }); 42 + 43 + it("provisions an account and creates a repo", async () => { 44 + const id = env.ACCOUNT.newUniqueId(); 45 + const stub = env.ACCOUNT.get(id); 46 + 47 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 48 + await provisionAccount(instance); 49 + 50 + const repo = await instance.getRepo(); 51 + expect(repo).toBeDefined(); 52 + expect(repo.did).toBe(TEST_DID); 53 + expect(repo.cid).toBeDefined(); 54 + }); 55 + }); 56 + 57 + it("rpcGetState returns account state after provisioning", async () => { 58 + const id = env.ACCOUNT.newUniqueId(); 59 + const stub = env.ACCOUNT.get(id); 60 + 61 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 62 + const before = await instance.rpcGetState(); 63 + expect(before?.did).toBeFalsy(); 64 + 65 + await provisionAccount(instance); 66 + 67 + const state = await instance.rpcGetState(); 68 + expect(state).not.toBeNull(); 69 + expect(state!.did).toBe(TEST_DID); 70 + expect(state!.handle).toBe(TEST_HANDLE); 71 + expect(state!.active).toBe(true); 72 + expect(state!.root_cid).toBeTruthy(); 73 + }); 74 + }); 75 + 76 + it("rpcGetLatestCommit returns commit after provisioning", async () => { 77 + const id = env.ACCOUNT.newUniqueId(); 78 + const stub = env.ACCOUNT.get(id); 79 + 80 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 81 + await provisionAccount(instance); 82 + 83 + const commit = await instance.rpcGetLatestCommit(); 84 + expect(commit).not.toBeNull(); 85 + expect(commit!.cid).toBeTruthy(); 86 + expect(commit!.rev).toBeTruthy(); 87 + }); 88 + }); 89 + 90 + it("rpcCreateRecord creates and retrieves a record", async () => { 91 + const id = env.ACCOUNT.newUniqueId(); 92 + const stub = env.ACCOUNT.get(id); 93 + 94 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 95 + await provisionAccount(instance); 96 + 97 + const result = await instance.rpcCreateRecord( 98 + "app.bsky.feed.post", 99 + undefined, 100 + { text: "Hello from rookery!", createdAt: new Date().toISOString() }, 101 + ); 102 + 103 + expect(result.uri).toContain(`at://${TEST_DID}/app.bsky.feed.post/`); 104 + expect(result.cid).toBeTruthy(); 105 + expect(result.commit.cid).toBeTruthy(); 106 + expect(result.commit.rev).toBeTruthy(); 107 + 108 + const rkey = result.uri.split("/").pop()!; 109 + const record = await instance.rpcGetRecord("app.bsky.feed.post", rkey); 110 + expect(record).not.toBeNull(); 111 + expect(record!.cid).toBe(result.cid); 112 + }); 113 + }); 114 + 115 + it("rpcDescribeRepo lists collections", async () => { 116 + const id = env.ACCOUNT.newUniqueId(); 117 + const stub = env.ACCOUNT.get(id); 118 + 119 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 120 + await provisionAccount(instance); 121 + 122 + await instance.rpcCreateRecord("app.bsky.feed.post", undefined, { 123 + text: "test", 124 + createdAt: new Date().toISOString(), 125 + }); 126 + 127 + const desc = await instance.rpcDescribeRepo(); 128 + expect(desc.did).toBe(TEST_DID); 129 + expect(desc.collections).toContain("app.bsky.feed.post"); 130 + expect(desc.cid).toBeTruthy(); 131 + }); 132 + }); 133 + 134 + it("rpcDeleteRecord removes a record", async () => { 135 + const id = env.ACCOUNT.newUniqueId(); 136 + const stub = env.ACCOUNT.get(id); 137 + 138 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 139 + await provisionAccount(instance); 140 + 141 + await instance.rpcCreateRecord("app.bsky.feed.post", "test-rkey", { 142 + text: "to delete", 143 + createdAt: new Date().toISOString(), 144 + }); 145 + 146 + const deleteResult = await instance.rpcDeleteRecord( 147 + "app.bsky.feed.post", 148 + "test-rkey", 149 + ); 150 + expect(deleteResult.commit.cid).toBeTruthy(); 151 + 152 + const record = await instance.rpcGetRecord("app.bsky.feed.post", "test-rkey"); 153 + expect(record).toBeNull(); 154 + }); 155 + }); 156 + 157 + it("rpcApplyWrites applies multiple writes", async () => { 158 + const id = env.ACCOUNT.newUniqueId(); 159 + const stub = env.ACCOUNT.get(id); 160 + 161 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 162 + await provisionAccount(instance); 163 + 164 + const result = await instance.rpcApplyWrites([ 165 + { 166 + $type: "com.atproto.repo.applyWrites#create", 167 + collection: "app.bsky.feed.post", 168 + record: { text: "post 1", createdAt: new Date().toISOString() }, 169 + }, 170 + { 171 + $type: "com.atproto.repo.applyWrites#create", 172 + collection: "app.bsky.feed.post", 173 + record: { text: "post 2", createdAt: new Date().toISOString() }, 174 + }, 175 + ]); 176 + 177 + expect(result.commit.cid).toBeTruthy(); 178 + expect(result.results).toHaveLength(2); 179 + }); 180 + }); 181 + }); 182 + 183 + describe("Worker health check", () => { 184 + it("GET / returns status ok", async () => { 185 + const response = await worker.fetch( 186 + new Request("http://rookery.test/"), 187 + env, 188 + ); 189 + expect(response.status).toBe(200); 190 + const data = await response.json(); 191 + expect(data).toEqual({ status: "ok" }); 192 + }); 193 + });
-385
test/auth.test.ts
··· 1 - import crypto from "node:crypto"; 2 - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 - import { createApp } from "../src/app.js"; 4 - import type { Config } from "../src/config.js"; 5 - import { initDatabase } from "../src/db.js"; 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"; 18 - 19 - function generateRsa2048() { 20 - return crypto.generateKeyPairSync("rsa", { 21 - modulusLength: 2048, 22 - publicKeyEncoding: { type: "spki", format: "pem" }, 23 - privateKeyEncoding: { type: "pkcs8", format: "pem" }, 24 - }); 25 - } 26 - 27 - function createTestApp() { 28 - const db = initDatabase(":memory:"); 29 - const config: Config = createTestConfig(); 30 - const app = createApp(config, db); 31 - return { app, db, config }; 32 - } 33 - 34 - describe("discovery endpoints", () => { 35 - it("GET /.well-known/welcome.md returns markdown", async () => { 36 - const { app } = createTestApp(); 37 - const res = await app.request("http://localhost/.well-known/welcome.md"); 38 - 39 - expect(res.status).toBe(200); 40 - expect(res.headers.get("content-type")).toContain("text/markdown"); 41 - const body = await res.text(); 42 - expect(body).toContain("welcome mat v1 (DPoP)"); 43 - expect(body).toContain("https://test.example.com/api/signup"); 44 - }); 45 - 46 - it("GET /tos returns text/plain", async () => { 47 - const { app, config } = createTestApp(); 48 - const res = await app.request("http://localhost/tos"); 49 - 50 - expect(res.status).toBe(200); 51 - expect(res.headers.get("content-type")).toContain("text/plain"); 52 - await expect(res.text()).resolves.toBe(config.tosText); 53 - }); 54 - }); 55 - 56 - describe("POST /api/signup", () => { 57 - beforeEach(() => { 58 - vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 59 - }); 60 - 61 - afterEach(() => { 62 - vi.unstubAllGlobals(); 63 - }); 64 - 65 - it("full signup flow returns did, handle, access_token, token_type", async () => { 66 - const { app, config, db } = createTestApp(); 67 - const { response, accessToken } = await performSignup(app, config, { handle: "agent" }); 68 - 69 - expect(response.status).toBe(200); 70 - const json = (await response.json()) as { 71 - did: string; 72 - handle: string; 73 - access_token: string; 74 - token_type: string; 75 - }; 76 - expect(json.did).toMatch(/^did:plc:[a-z2-7]{24}$/); 77 - expect(json.handle).toBe("agent.test.example.com"); 78 - expect(json.access_token).toBe(accessToken); 79 - expect(json.token_type).toBe("DPoP"); 80 - 81 - const row = db 82 - .prepare("SELECT did, handle, jwk_thumbprint FROM accounts WHERE did = ?") 83 - .get(json.did) as { did: string; handle: string; jwk_thumbprint: string } | undefined; 84 - expect(row?.handle).toBe("agent.test.example.com"); 85 - expect(row?.jwk_thumbprint).toBeTruthy(); 86 - }); 87 - 88 - it("rejects duplicate handle with 409", async () => { 89 - const { app, config } = createTestApp(); 90 - await performSignup(app, config, { handle: "agent" }); 91 - const second = await performSignup(app, config, { handle: "agent" }); 92 - 93 - expect(second.response.status).toBe(409); 94 - await expect(second.response.json()).resolves.toEqual({ error: "handle already taken" }); 95 - }); 96 - 97 - it("rejects duplicate JWK thumbprint with 409", async () => { 98 - const { app, config } = createTestApp(); 99 - const keys = generateRsa4096(); 100 - await performSignup(app, config, { 101 - handle: "agent-one", 102 - publicKeyPem: keys.publicKey, 103 - privateKeyPem: keys.privateKey, 104 - }); 105 - const second = await performSignup(app, config, { 106 - handle: "agent-two", 107 - publicKeyPem: keys.publicKey, 108 - privateKeyPem: keys.privateKey, 109 - }); 110 - 111 - expect(second.response.status).toBe(409); 112 - await expect(second.response.json()).resolves.toEqual({ error: "key already registered" }); 113 - }); 114 - 115 - it("rejects missing DPoP header", async () => { 116 - const { app } = createTestApp(); 117 - const response = await app.request("http://localhost/api/signup", { 118 - method: "POST", 119 - headers: { "Content-Type": "application/json" }, 120 - body: JSON.stringify({ handle: "agent" }), 121 - }); 122 - 123 - expect(response.status).toBe(400); 124 - await expect(response.json()).resolves.toEqual({ error: "missing DPoP header" }); 125 - }); 126 - 127 - it("rejects invalid DPoP: wrong typ", async () => { 128 - const { app, config } = createTestApp(); 129 - const keys = generateRsa4096(); 130 - const jwk = pemToJwk(keys.publicKey); 131 - const response = await performSignup(app, config, { 132 - dpopJwt: createDpopProof( 133 - jwk, 134 - keys.privateKey, 135 - "POST", 136 - "http://localhost/api/signup", 137 - undefined, 138 - { typ: "jwt" }, 139 - ), 140 - }); 141 - 142 - expect(response.response.status).toBe(400); 143 - await expect(response.response.json()).resolves.toEqual({ 144 - error: "invalid DPoP proof: typ must be dpop+jwt", 145 - }); 146 - }); 147 - 148 - it("rejects invalid DPoP: expired iat", async () => { 149 - const { app, config } = createTestApp(); 150 - const keys = generateRsa4096(); 151 - const jwk = pemToJwk(keys.publicKey); 152 - const response = await performSignup(app, config, { 153 - dpopJwt: createDpopProof( 154 - jwk, 155 - keys.privateKey, 156 - "POST", 157 - "http://localhost/api/signup", 158 - undefined, 159 - { iat: Math.floor(Date.now() / 1000) - 301 }, 160 - ), 161 - }); 162 - 163 - expect(response.response.status).toBe(400); 164 - await expect(response.response.json()).resolves.toEqual({ 165 - error: "invalid DPoP proof: iat too far from current time", 166 - }); 167 - }); 168 - 169 - it("rejects invalid DPoP: bad signature", async () => { 170 - const { app, config } = createTestApp(); 171 - const validKeys = generateRsa4096(); 172 - const invalidKeys = generateRsa4096(); 173 - const jwk = pemToJwk(validKeys.publicKey); 174 - const response = await performSignup(app, config, { 175 - dpopJwt: createDpopProof(jwk, invalidKeys.privateKey, "POST", "http://localhost/api/signup"), 176 - }); 177 - 178 - expect(response.response.status).toBe(400); 179 - await expect(response.response.json()).resolves.toEqual({ 180 - error: "invalid DPoP proof: signature verification failed", 181 - }); 182 - }); 183 - 184 - it("rejects invalid DPoP: non-4096 key", async () => { 185 - const { app, config } = createTestApp(); 186 - const keys = generateRsa2048(); 187 - const response = await performSignup(app, config, { 188 - publicKeyPem: keys.publicKey, 189 - privateKeyPem: keys.privateKey, 190 - }); 191 - 192 - expect(response.response.status).toBe(400); 193 - await expect(response.response.json()).resolves.toEqual({ 194 - error: "key must be 4096-bit RSA (got 2048-bit)", 195 - }); 196 - }); 197 - 198 - it("rejects invalid ToS signature", async () => { 199 - const { app, config } = createTestApp(); 200 - const keys = generateRsa4096(); 201 - const response = await performSignup(app, config, { 202 - publicKeyPem: keys.publicKey, 203 - privateKeyPem: keys.privateKey, 204 - tosSignature: signTos("wrong terms", keys.privateKey), 205 - }); 206 - 207 - expect(response.response.status).toBe(400); 208 - await expect(response.response.json()).resolves.toEqual({ 209 - error: "ToS signature verification failed", 210 - }); 211 - }); 212 - 213 - it("rejects invalid access token: wrong typ", async () => { 214 - const { app, config } = createTestApp(); 215 - const keys = generateRsa4096(); 216 - const jwk = pemToJwk(keys.publicKey); 217 - const response = await performSignup(app, config, { 218 - publicKeyPem: keys.publicKey, 219 - privateKeyPem: keys.privateKey, 220 - accessToken: createAccessToken( 221 - config.tosText, 222 - keys.privateKey, 223 - jwk, 224 - `https://${config.hostname}`, 225 - { typ: "jwt" }, 226 - ), 227 - }); 228 - 229 - expect(response.response.status).toBe(400); 230 - await expect(response.response.json()).resolves.toEqual({ 231 - error: "invalid access token: typ must be wm+jwt", 232 - }); 233 - }); 234 - 235 - it("rejects invalid access token: bad tos_hash", async () => { 236 - const { app, config } = createTestApp(); 237 - const keys = generateRsa4096(); 238 - const jwk = pemToJwk(keys.publicKey); 239 - const response = await performSignup(app, config, { 240 - publicKeyPem: keys.publicKey, 241 - privateKeyPem: keys.privateKey, 242 - accessToken: createAccessToken( 243 - config.tosText, 244 - keys.privateKey, 245 - jwk, 246 - `https://${config.hostname}`, 247 - { tosHash: "bad-hash" }, 248 - ), 249 - }); 250 - 251 - expect(response.response.status).toBe(400); 252 - await expect(response.response.json()).resolves.toEqual({ 253 - error: "invalid access token: tos_hash does not match current terms", 254 - }); 255 - }); 256 - 257 - it("rejects invalid access token: wrong aud", async () => { 258 - const { app, config } = createTestApp(); 259 - const keys = generateRsa4096(); 260 - const jwk = pemToJwk(keys.publicKey); 261 - const response = await performSignup(app, config, { 262 - publicKeyPem: keys.publicKey, 263 - privateKeyPem: keys.privateKey, 264 - accessToken: createAccessToken( 265 - config.tosText, 266 - keys.privateKey, 267 - jwk, 268 - `https://${config.hostname}`, 269 - { aud: "https://wrong.example.com" }, 270 - ), 271 - }); 272 - 273 - expect(response.response.status).toBe(400); 274 - await expect(response.response.json()).resolves.toEqual({ 275 - error: "invalid access token: aud does not match service origin", 276 - }); 277 - }); 278 - 279 - it("rejects invalid access token: mismatched cnf.jkt", async () => { 280 - const { app, config } = createTestApp(); 281 - const keys = generateRsa4096(); 282 - const jwk = pemToJwk(keys.publicKey); 283 - const response = await performSignup(app, config, { 284 - publicKeyPem: keys.publicKey, 285 - privateKeyPem: keys.privateKey, 286 - accessToken: createAccessToken( 287 - config.tosText, 288 - keys.privateKey, 289 - jwk, 290 - `https://${config.hostname}`, 291 - { jkt: "mismatched-thumbprint" }, 292 - ), 293 - }); 294 - 295 - expect(response.response.status).toBe(400); 296 - await expect(response.response.json()).resolves.toEqual({ 297 - error: "invalid access token: cnf.jkt does not match DPoP key", 298 - }); 299 - }); 300 - 301 - it("rejects invalid handle format", async () => { 302 - const { app, config } = createTestApp(); 303 - const response = await performSignup(app, config, { handle: "Bad_Handle" }); 304 - 305 - expect(response.response.status).toBe(400); 306 - await expect(response.response.json()).resolves.toEqual({ 307 - error: 308 - "invalid handle — must be lowercase alphanumeric with dots/hyphens, no leading/trailing separators", 309 - }); 310 - }); 311 - }); 312 - 313 - describe("auth middleware", () => { 314 - beforeEach(() => { 315 - vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 316 - }); 317 - 318 - afterEach(() => { 319 - vi.unstubAllGlobals(); 320 - }); 321 - 322 - it("passes valid DPoP + access token, returns account info", async () => { 323 - const { app, config } = createTestApp(); 324 - const signup = await performSignup(app, config, { handle: "agent" }); 325 - const token = (await signup.response.json()) as { access_token: string }; 326 - const proof = createDpopProof( 327 - signup.jwk, 328 - signup.privateKey, 329 - "GET", 330 - "http://localhost/api/whoami", 331 - token.access_token, 332 - ); 333 - 334 - const response = await app.request("http://localhost/api/whoami", { 335 - method: "GET", 336 - headers: { 337 - Authorization: `DPoP ${token.access_token}`, 338 - DPoP: proof, 339 - }, 340 - }); 341 - 342 - expect(response.status).toBe(200); 343 - await expect(response.json()).resolves.toEqual({ 344 - did: expect.stringMatching(/^did:plc:[a-z2-7]{24}$/), 345 - handle: "agent.test.example.com", 346 - }); 347 - }); 348 - 349 - it("rejects missing Authorization header with 401", async () => { 350 - const { app } = createTestApp(); 351 - const response = await app.request("http://localhost/api/whoami"); 352 - 353 - expect(response.status).toBe(401); 354 - await expect(response.json()).resolves.toEqual({ 355 - error: "missing Authorization: DPoP <token> header", 356 - }); 357 - }); 358 - 359 - it("rejects invalid DPoP proof with 401", async () => { 360 - const { app, config } = createTestApp(); 361 - const signup = await performSignup(app, config, { handle: "agent" }); 362 - const token = (await signup.response.json()) as { access_token: string }; 363 - const badProof = createDpopProof( 364 - signup.jwk, 365 - signup.privateKey, 366 - "GET", 367 - "http://localhost/api/whoami", 368 - token.access_token, 369 - { typ: "jwt" }, 370 - ); 371 - 372 - const response = await app.request("http://localhost/api/whoami", { 373 - method: "GET", 374 - headers: { 375 - Authorization: `DPoP ${token.access_token}`, 376 - DPoP: badProof, 377 - }, 378 - }); 379 - 380 - expect(response.status).toBe(401); 381 - await expect(response.json()).resolves.toEqual({ 382 - error: "invalid DPoP proof: typ must be dpop+jwt", 383 - }); 384 - }); 385 - });
-387
test/blob.test.ts
··· 1 - import fs from "node:fs"; 2 - import os from "node:os"; 3 - import path from "node:path"; 4 - import { afterEach, describe, expect, it, vi } from "vitest"; 5 - import { create as createCid, format as formatCid } from "@atcute/cid"; 6 - import { createApp } from "../src/app.js"; 7 - import type { Config } from "../src/config.js"; 8 - import { initDatabase } from "../src/db.js"; 9 - import { announceToRelays } from "../src/relay.js"; 10 - import { createRepoRoutes } from "../src/repo.js"; 11 - import { createSyncRoutes } from "../src/sync.js"; 12 - import { 13 - createDpopProof, 14 - createTestConfig, 15 - performSignup, 16 - } from "./helpers.js"; 17 - 18 - let blobDir = ""; 19 - 20 - function createTestApp() { 21 - blobDir = fs.mkdtempSync(path.join(os.tmpdir(), "rookery-blob-test-")); 22 - const db = initDatabase(":memory:"); 23 - const config: Config = createTestConfig({ blobDir }); 24 - const app = createApp(config, db); 25 - app.route("/", createSyncRoutes(db, config)); 26 - app.route("/", createRepoRoutes(db, config)); 27 - return { app, db, config }; 28 - } 29 - 30 - async function authenticatedUpload( 31 - app: ReturnType<typeof createApp>, 32 - accessToken: string, 33 - privateKeyPem: string, 34 - jwk: { kty: string; n: string; e: string }, 35 - body: Uint8Array, 36 - contentType: string, 37 - ) { 38 - const url = "http://localhost/xrpc/com.atproto.repo.uploadBlob"; 39 - const dpop = createDpopProof(jwk, privateKeyPem, "POST", url, accessToken); 40 - return app.request(url, { 41 - method: "POST", 42 - headers: { 43 - Authorization: `DPoP ${accessToken}`, 44 - DPoP: dpop, 45 - "Content-Type": contentType, 46 - }, 47 - body, 48 - }); 49 - } 50 - 51 - describe("blob endpoints", () => { 52 - afterEach(() => { 53 - vi.restoreAllMocks(); 54 - vi.unstubAllGlobals(); 55 - if (blobDir && fs.existsSync(blobDir)) { 56 - fs.rmSync(blobDir, { recursive: true }); 57 - } 58 - blobDir = ""; 59 - }); 60 - 61 - describe("uploadBlob", () => { 62 - it("uploads a blob and returns correct blob ref", async () => { 63 - vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 64 - const { app, config } = createTestApp(); 65 - const signup = await performSignup(app, config); 66 - 67 - const content = new TextEncoder().encode("hello blob"); 68 - const res = await authenticatedUpload( 69 - app, 70 - signup.accessToken, 71 - signup.privateKey, 72 - signup.jwk, 73 - content, 74 - "text/plain", 75 - ); 76 - 77 - expect(res.status).toBe(200); 78 - const body = await res.json(); 79 - expect(body.blob.$type).toBe("blob"); 80 - expect(body.blob.mimeType).toBe("text/plain"); 81 - expect(body.blob.size).toBe(content.length); 82 - expect(typeof body.blob.ref.$link).toBe("string"); 83 - 84 - const expectedCid = formatCid(await createCid(0x55, content)); 85 - expect(body.blob.ref.$link).toBe(expectedCid); 86 - }); 87 - 88 - it("writes blob to filesystem at {blobDir}/{did}/{cid}", async () => { 89 - vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 90 - const { app, config } = createTestApp(); 91 - const signup = await performSignup(app, config); 92 - const signupBody = await signup.response.json(); 93 - 94 - const content = new TextEncoder().encode("fs check"); 95 - const res = await authenticatedUpload( 96 - app, 97 - signup.accessToken, 98 - signup.privateKey, 99 - signup.jwk, 100 - content, 101 - "application/octet-stream", 102 - ); 103 - 104 - const body = await res.json(); 105 - const cidStr = body.blob.ref.$link; 106 - const blobPath = path.join(blobDir, signupBody.did, cidStr); 107 - expect(fs.existsSync(blobPath)).toBe(true); 108 - expect(fs.readFileSync(blobPath)).toEqual(Buffer.from(content)); 109 - }); 110 - 111 - it("rejects unauthenticated upload with 401", async () => { 112 - const { app } = createTestApp(); 113 - const res = await app.request("http://localhost/xrpc/com.atproto.repo.uploadBlob", { 114 - method: "POST", 115 - headers: { "Content-Type": "text/plain" }, 116 - body: "hello", 117 - }); 118 - 119 - expect(res.status).toBe(401); 120 - }); 121 - 122 - it("rejects blob larger than 60MB", async () => { 123 - vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 124 - const { app, config } = createTestApp(); 125 - const signup = await performSignup(app, config); 126 - 127 - const url = "http://localhost/xrpc/com.atproto.repo.uploadBlob"; 128 - const dpop = createDpopProof(signup.jwk, signup.privateKey, "POST", url, signup.accessToken); 129 - const res = await app.request(url, { 130 - method: "POST", 131 - headers: { 132 - Authorization: `DPoP ${signup.accessToken}`, 133 - DPoP: dpop, 134 - "Content-Type": "application/octet-stream", 135 - "Content-Length": String(61 * 1024 * 1024), 136 - }, 137 - body: new Uint8Array(1024), 138 - }); 139 - 140 - expect(res.status).toBe(400); 141 - const body = await res.json(); 142 - expect(body.error).toBe("InvalidRequest"); 143 - expect(body.message).toContain("blob too large"); 144 - }); 145 - }); 146 - 147 - describe("getBlob", () => { 148 - it("retrieves an uploaded blob with correct content and Content-Type", async () => { 149 - vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 150 - const { app, config } = createTestApp(); 151 - const signup = await performSignup(app, config); 152 - const signupBody = await signup.response.json(); 153 - 154 - const content = new TextEncoder().encode("round trip"); 155 - const uploadRes = await authenticatedUpload( 156 - app, 157 - signup.accessToken, 158 - signup.privateKey, 159 - signup.jwk, 160 - content, 161 - "text/plain", 162 - ); 163 - const uploadBody = await uploadRes.json(); 164 - const cid = uploadBody.blob.ref.$link; 165 - 166 - const getRes = await app.request( 167 - `http://localhost/xrpc/com.atproto.sync.getBlob?did=${signupBody.did}&cid=${cid}`, 168 - ); 169 - 170 - expect(getRes.status).toBe(200); 171 - expect(getRes.headers.get("content-type")).toBe("text/plain"); 172 - const returnedBytes = new Uint8Array(await getRes.arrayBuffer()); 173 - expect(returnedBytes).toEqual(content); 174 - }); 175 - 176 - it("returns 404 for non-existent blob", async () => { 177 - const { app } = createTestApp(); 178 - const res = await app.request( 179 - "http://localhost/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafkreinotreal", 180 - ); 181 - 182 - expect(res.status).toBe(404); 183 - const body = await res.json(); 184 - expect(body.error).toBe("BlobNotFound"); 185 - }); 186 - 187 - it("returns 400 when missing parameters", async () => { 188 - const { app } = createTestApp(); 189 - const res = await app.request("http://localhost/xrpc/com.atproto.sync.getBlob"); 190 - 191 - expect(res.status).toBe(400); 192 - }); 193 - }); 194 - 195 - describe("listBlobs", () => { 196 - it("lists all blobs for a DID", async () => { 197 - vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 198 - const { app, config } = createTestApp(); 199 - const signup = await performSignup(app, config); 200 - const signupBody = await signup.response.json(); 201 - 202 - const blob1 = new TextEncoder().encode("blob one"); 203 - const blob2 = new TextEncoder().encode("blob two"); 204 - await authenticatedUpload( 205 - app, 206 - signup.accessToken, 207 - signup.privateKey, 208 - signup.jwk, 209 - blob1, 210 - "text/plain", 211 - ); 212 - await authenticatedUpload( 213 - app, 214 - signup.accessToken, 215 - signup.privateKey, 216 - signup.jwk, 217 - blob2, 218 - "text/plain", 219 - ); 220 - 221 - const res = await app.request( 222 - `http://localhost/xrpc/com.atproto.sync.listBlobs?did=${signupBody.did}`, 223 - ); 224 - 225 - expect(res.status).toBe(200); 226 - const body = await res.json(); 227 - expect(body.cids).toHaveLength(2); 228 - expect(body.cursor).toBeUndefined(); 229 - }); 230 - 231 - it("paginates with cursor", async () => { 232 - vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 233 - const { app, config } = createTestApp(); 234 - const signup = await performSignup(app, config); 235 - const signupBody = await signup.response.json(); 236 - 237 - for (let i = 0; i < 3; i++) { 238 - const content = new TextEncoder().encode(`blob ${i}`); 239 - await authenticatedUpload( 240 - app, 241 - signup.accessToken, 242 - signup.privateKey, 243 - signup.jwk, 244 - content, 245 - "text/plain", 246 - ); 247 - } 248 - 249 - const res1 = await app.request( 250 - `http://localhost/xrpc/com.atproto.sync.listBlobs?did=${signupBody.did}&limit=2`, 251 - ); 252 - const body1 = await res1.json(); 253 - expect(body1.cids).toHaveLength(2); 254 - expect(body1.cursor).toBeDefined(); 255 - 256 - const res2 = await app.request( 257 - `http://localhost/xrpc/com.atproto.sync.listBlobs?did=${signupBody.did}&limit=2&cursor=${body1.cursor}`, 258 - ); 259 - const body2 = await res2.json(); 260 - expect(body2.cids).toHaveLength(1); 261 - expect(body2.cursor).toBeUndefined(); 262 - }); 263 - 264 - it("filters by since parameter", async () => { 265 - vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 266 - const { app, db, config } = createTestApp(); 267 - const signup = await performSignup(app, config); 268 - const signupBody = await signup.response.json(); 269 - 270 - const blob1 = new TextEncoder().encode("old blob"); 271 - await authenticatedUpload( 272 - app, 273 - signup.accessToken, 274 - signup.privateKey, 275 - signup.jwk, 276 - blob1, 277 - "text/plain", 278 - ); 279 - 280 - db.prepare("UPDATE blobs SET created_at = '2020-01-01 00:00:00'").run(); 281 - 282 - const blob2 = new TextEncoder().encode("new blob"); 283 - await authenticatedUpload( 284 - app, 285 - signup.accessToken, 286 - signup.privateKey, 287 - signup.jwk, 288 - blob2, 289 - "text/plain", 290 - ); 291 - 292 - const res = await app.request( 293 - `http://localhost/xrpc/com.atproto.sync.listBlobs?did=${signupBody.did}&since=2023-01-01 00:00:00`, 294 - ); 295 - const body = await res.json(); 296 - expect(body.cids).toHaveLength(1); 297 - }); 298 - 299 - it("returns 404 for unknown DID", async () => { 300 - const { app } = createTestApp(); 301 - const res = await app.request( 302 - "http://localhost/xrpc/com.atproto.sync.listBlobs?did=did:plc:unknown", 303 - ); 304 - expect(res.status).toBe(404); 305 - }); 306 - }); 307 - }); 308 - 309 - describe("relay announcement", () => { 310 - it("sends requestCrawl POST to each relay host", async () => { 311 - const mockFetch = vi.fn().mockResolvedValue(new Response("ok")); 312 - vi.stubGlobal("fetch", mockFetch); 313 - 314 - const config: Config = { 315 - hostname: "my-pds.example.com", 316 - handleDomain: "example.com", 317 - plcUrl: "https://plc.example.com", 318 - dbPath: ":memory:", 319 - blobDir: "/tmp/test", 320 - relayHosts: ["relay1.example.com", "relay2.example.com"], 321 - port: 3000, 322 - tosText: "tos", 323 - }; 324 - 325 - announceToRelays(config); 326 - await new Promise((resolve) => setTimeout(resolve, 10)); 327 - 328 - expect(mockFetch).toHaveBeenCalledTimes(2); 329 - expect(mockFetch).toHaveBeenCalledWith( 330 - "https://relay1.example.com/xrpc/com.atproto.sync.requestCrawl", 331 - expect.objectContaining({ 332 - method: "POST", 333 - body: JSON.stringify({ hostname: "my-pds.example.com" }), 334 - }), 335 - ); 336 - expect(mockFetch).toHaveBeenCalledWith( 337 - "https://relay2.example.com/xrpc/com.atproto.sync.requestCrawl", 338 - expect.objectContaining({ 339 - method: "POST", 340 - body: JSON.stringify({ hostname: "my-pds.example.com" }), 341 - }), 342 - ); 343 - }); 344 - 345 - it("logs errors but does not throw", async () => { 346 - const mockFetch = vi.fn().mockRejectedValue(new Error("network error")); 347 - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 348 - vi.stubGlobal("fetch", mockFetch); 349 - 350 - const config: Config = { 351 - hostname: "my-pds.example.com", 352 - handleDomain: "example.com", 353 - plcUrl: "https://plc.example.com", 354 - dbPath: ":memory:", 355 - blobDir: "/tmp/test", 356 - relayHosts: ["relay.example.com"], 357 - port: 3000, 358 - tosText: "tos", 359 - }; 360 - 361 - announceToRelays(config); 362 - await new Promise((resolve) => setTimeout(resolve, 10)); 363 - 364 - expect(errorSpy).toHaveBeenCalled(); 365 - errorSpy.mockRestore(); 366 - }); 367 - 368 - it("does nothing when relayHosts is empty", () => { 369 - const mockFetch = vi.fn(); 370 - vi.stubGlobal("fetch", mockFetch); 371 - 372 - const config: Config = { 373 - hostname: "my-pds.example.com", 374 - handleDomain: "example.com", 375 - plcUrl: "https://plc.example.com", 376 - dbPath: ":memory:", 377 - blobDir: "/tmp/test", 378 - relayHosts: [], 379 - port: 3000, 380 - tosText: "tos", 381 - }; 382 - 383 - announceToRelays(config); 384 - 385 - expect(mockFetch).not.toHaveBeenCalled(); 386 - }); 387 - });
-700
test/firehose.test.ts
··· 1 - import { afterEach, beforeEach, describe, expect, it } from "vitest"; 2 - import Database from "better-sqlite3"; 3 - import { Hono } from "hono"; 4 - import { serve } from "@hono/node-server"; 5 - import { createNodeWebSocket } from "@hono/node-ws"; 6 - import { WebSocket } from "ws"; 7 - import { Repo, WriteOpAction } from "@atproto/repo"; 8 - import { Secp256k1Keypair } from "@atproto/crypto"; 9 - import { decode as rawCborDecode } from "@atcute/cbor"; 10 - import type { Config } from "../src/config.js"; 11 - import { initDatabase } from "../src/db.js"; 12 - import { Sequencer } from "../src/sequencer.js"; 13 - import { SqliteRepoStorage } from "../src/storage.js"; 14 - import { createSyncRoutes } from "../src/sync.js"; 15 - import { encode as cborEncode } from "../src/cbor-compat.js"; 16 - 17 - // --- Helpers --- 18 - 19 - function createAccount( 20 - db: Database.Database, 21 - did: string, 22 - ): { id: number; storage: SqliteRepoStorage } { 23 - const info = db.prepare("INSERT INTO accounts (did) VALUES (?)").run(did); 24 - const id = Number(info.lastInsertRowid); 25 - return { id, storage: new SqliteRepoStorage(db, id) }; 26 - } 27 - 28 - /** 29 - * Decode a firehose frame (two concatenated DAG-CBOR values: header + body). 30 - * Uses raw @atcute/cbor decode to avoid cbor-compat conversion issues. 31 - */ 32 - function decodeFrame(frame: Uint8Array): { 33 - header: Record<string, unknown>; 34 - body: Record<string, unknown>; 35 - } { 36 - const commitHeader = cborEncode({ op: 1, t: "#commit" }); 37 - const identityHeader = cborEncode({ op: 1, t: "#identity" }); 38 - const accountHeader = cborEncode({ op: 1, t: "#account" }); 39 - 40 - for (const knownHeader of [commitHeader, identityHeader, accountHeader]) { 41 - if (frame.length > knownHeader.length) { 42 - const prefix = frame.slice(0, knownHeader.length); 43 - if (Buffer.from(prefix).equals(Buffer.from(knownHeader))) { 44 - const header = rawCborDecode(prefix) as Record<string, unknown>; 45 - const body = rawCborDecode( 46 - frame.slice(knownHeader.length), 47 - ) as Record<string, unknown>; 48 - return { header, body }; 49 - } 50 - } 51 - } 52 - 53 - throw new Error("unknown frame header"); 54 - } 55 - 56 - // --- Sequencer unit tests --- 57 - 58 - describe("Sequencer", () => { 59 - let db: Database.Database; 60 - let sequencer: Sequencer; 61 - 62 - beforeEach(() => { 63 - db = initDatabase(":memory:"); 64 - sequencer = new Sequencer(db); 65 - }); 66 - 67 - describe("sequenceCommit", () => { 68 - it("produces monotonic seq numbers", async () => { 69 - const { storage } = createAccount(db, "did:plc:test"); 70 - const keypair = await Secp256k1Keypair.create(); 71 - let repo = await Repo.create(storage, "did:plc:test", keypair); 72 - 73 - const results = []; 74 - for (let i = 0; i < 3; i++) { 75 - repo = await repo.applyWrites( 76 - { 77 - action: WriteOpAction.Create, 78 - collection: "app.test.post", 79 - rkey: `r${i}`, 80 - record: { text: `post ${i}`, $type: "app.test.post" }, 81 - }, 82 - keypair, 83 - ); 84 - 85 - const commit = storage.lastCommit!; 86 - const result = await sequencer.sequenceCommit({ 87 - did: "did:plc:test", 88 - commit: commit.cid, 89 - rev: commit.rev, 90 - since: commit.since, 91 - prevData: null, 92 - newBlocks: commit.newBlocks, 93 - ops: [ 94 - { 95 - action: "create", 96 - path: `app.test.post/r${i}`, 97 - cid: commit.cid, 98 - }, 99 - ], 100 - }); 101 - results.push(result.seq); 102 - } 103 - 104 - expect(results[1]).toBeGreaterThan(results[0]); 105 - expect(results[2]).toBeGreaterThan(results[1]); 106 - }); 107 - 108 - it("stores events retrievable via getEventsSince", async () => { 109 - const { storage } = createAccount(db, "did:plc:test"); 110 - const keypair = await Secp256k1Keypair.create(); 111 - let repo = await Repo.create(storage, "did:plc:test", keypair); 112 - 113 - repo = await repo.applyWrites( 114 - { 115 - action: WriteOpAction.Create, 116 - collection: "app.test.post", 117 - rkey: "r1", 118 - record: { text: "hello", $type: "app.test.post" }, 119 - }, 120 - keypair, 121 - ); 122 - 123 - const commit = storage.lastCommit!; 124 - const { seq } = await sequencer.sequenceCommit({ 125 - did: "did:plc:test", 126 - commit: commit.cid, 127 - rev: commit.rev, 128 - since: commit.since, 129 - prevData: null, 130 - newBlocks: commit.newBlocks, 131 - ops: [{ action: "create", path: "app.test.post/r1", cid: commit.cid }], 132 - }); 133 - 134 - const events = sequencer.getEventsSince(0); 135 - expect(events.length).toBe(1); 136 - 137 - const { header, body } = decodeFrame(events[0]); 138 - expect(header.op).toBe(1); 139 - expect(header.t).toBe("#commit"); 140 - expect(body.seq).toBe(seq); 141 - expect(body.repo).toBe("did:plc:test"); 142 - }); 143 - 144 - it("includes correct ops, rev, and blocks in commit events", async () => { 145 - const { storage } = createAccount(db, "did:plc:test"); 146 - const keypair = await Secp256k1Keypair.create(); 147 - let repo = await Repo.create(storage, "did:plc:test", keypair); 148 - 149 - repo = await repo.applyWrites( 150 - { 151 - action: WriteOpAction.Create, 152 - collection: "app.test.post", 153 - rkey: "r1", 154 - record: { text: "hello", $type: "app.test.post" }, 155 - }, 156 - keypair, 157 - ); 158 - 159 - const commit = storage.lastCommit!; 160 - await sequencer.sequenceCommit({ 161 - did: "did:plc:test", 162 - commit: commit.cid, 163 - rev: commit.rev, 164 - since: commit.since, 165 - prevData: null, 166 - newBlocks: commit.newBlocks, 167 - ops: [{ action: "create", path: "app.test.post/r1", cid: commit.cid }], 168 - }); 169 - 170 - const events = sequencer.getEventsSince(0); 171 - const { body } = decodeFrame(events[0]); 172 - expect(body.rev).toBe(commit.rev); 173 - // blocks is a CBOR Bytes object (has $bytes property) 174 - expect(body.blocks).toBeDefined(); 175 - 176 - const ops = body.ops as Array<Record<string, unknown>>; 177 - expect(ops).toHaveLength(1); 178 - expect(ops[0].action).toBe("create"); 179 - expect(ops[0].path).toBe("app.test.post/r1"); 180 - }); 181 - 182 - it("includes prevData when provided", async () => { 183 - const { storage } = createAccount(db, "did:plc:test"); 184 - const keypair = await Secp256k1Keypair.create(); 185 - let repo = await Repo.create(storage, "did:plc:test", keypair); 186 - 187 - // Get prev_data_cid from DB after repo creation 188 - const prevDataRow = db 189 - .prepare("SELECT prev_data_cid FROM accounts WHERE did = ?") 190 - .get("did:plc:test") as { prev_data_cid: string | null }; 191 - 192 - repo = await repo.applyWrites( 193 - { 194 - action: WriteOpAction.Create, 195 - collection: "app.test.post", 196 - rkey: "r1", 197 - record: { text: "hello", $type: "app.test.post" }, 198 - }, 199 - keypair, 200 - ); 201 - 202 - const commit = storage.lastCommit!; 203 - const { CID } = await import("@atproto/lex-data"); 204 - const prevDataCid = prevDataRow.prev_data_cid 205 - ? CID.parse(prevDataRow.prev_data_cid) 206 - : null; 207 - 208 - await sequencer.sequenceCommit({ 209 - did: "did:plc:test", 210 - commit: commit.cid, 211 - rev: commit.rev, 212 - since: commit.since, 213 - prevData: prevDataCid, 214 - newBlocks: commit.newBlocks, 215 - ops: [{ action: "create", path: "app.test.post/r1", cid: commit.cid }], 216 - }); 217 - 218 - const events = sequencer.getEventsSince(0); 219 - const { body } = decodeFrame(events[0]); 220 - 221 - // prevData should be present when provided 222 - if (prevDataCid) { 223 - // It's a CIDLink object in raw CBOR decode: { $link: string } 224 - expect(body.prevData).toBeDefined(); 225 - expect(body.prevData).not.toBeNull(); 226 - } 227 - }); 228 - 229 - it("frame format is two concatenated DAG-CBOR values", async () => { 230 - const { storage } = createAccount(db, "did:plc:test"); 231 - const keypair = await Secp256k1Keypair.create(); 232 - let repo = await Repo.create(storage, "did:plc:test", keypair); 233 - 234 - repo = await repo.applyWrites( 235 - { 236 - action: WriteOpAction.Create, 237 - collection: "app.test.post", 238 - rkey: "r1", 239 - record: { text: "hello", $type: "app.test.post" }, 240 - }, 241 - keypair, 242 - ); 243 - 244 - const commit = storage.lastCommit!; 245 - await sequencer.sequenceCommit({ 246 - did: "did:plc:test", 247 - commit: commit.cid, 248 - rev: commit.rev, 249 - since: commit.since, 250 - prevData: null, 251 - newBlocks: commit.newBlocks, 252 - ops: [{ action: "create", path: "app.test.post/r1", cid: commit.cid }], 253 - }); 254 - 255 - const events = sequencer.getEventsSince(0); 256 - const frame = events[0]; 257 - 258 - // Should be decodable as header + body 259 - const { header, body } = decodeFrame(frame); 260 - expect(header).toBeDefined(); 261 - expect(body).toBeDefined(); 262 - expect(header.op).toBe(1); 263 - expect(header.t).toBe("#commit"); 264 - expect(typeof body.seq).toBe("number"); 265 - }); 266 - }); 267 - 268 - describe("sequenceIdentity", () => { 269 - it("stores identity events with correct fields", () => { 270 - const { seq } = sequencer.sequenceIdentity( 271 - "did:plc:test", 272 - "agent.test.example", 273 - ); 274 - expect(seq).toBeGreaterThan(0); 275 - 276 - const events = sequencer.getEventsSince(0); 277 - expect(events.length).toBe(1); 278 - 279 - const { header, body } = decodeFrame(events[0]); 280 - expect(header.op).toBe(1); 281 - expect(header.t).toBe("#identity"); 282 - expect(body.seq).toBe(seq); 283 - expect(body.did).toBe("did:plc:test"); 284 - expect(body.handle).toBe("agent.test.example"); 285 - expect(typeof body.time).toBe("string"); 286 - }); 287 - }); 288 - 289 - describe("sequenceAccount", () => { 290 - it("stores account events with active=true", () => { 291 - const { seq } = sequencer.sequenceAccount("did:plc:test", true); 292 - expect(seq).toBeGreaterThan(0); 293 - 294 - const events = sequencer.getEventsSince(0); 295 - const { header, body } = decodeFrame(events[0]); 296 - expect(header.op).toBe(1); 297 - expect(header.t).toBe("#account"); 298 - expect(body.seq).toBe(seq); 299 - expect(body.did).toBe("did:plc:test"); 300 - expect(body.active).toBe(true); 301 - }); 302 - 303 - it("stores account events with status", () => { 304 - sequencer.sequenceAccount("did:plc:test", false, "deactivated"); 305 - 306 - const events = sequencer.getEventsSince(0); 307 - const { body } = decodeFrame(events[0]); 308 - expect(body.active).toBe(false); 309 - expect(body.status).toBe("deactivated"); 310 - }); 311 - }); 312 - 313 - describe("getEventsSince", () => { 314 - it("returns events in order after cursor", () => { 315 - const seq1 = sequencer.sequenceIdentity("did:plc:a", "a.test").seq; 316 - const seq2 = sequencer.sequenceIdentity("did:plc:b", "b.test").seq; 317 - const seq3 = sequencer.sequenceIdentity("did:plc:c", "c.test").seq; 318 - 319 - // Get events after seq1 320 - const events = sequencer.getEventsSince(seq1); 321 - expect(events.length).toBe(2); 322 - 323 - const body1 = decodeFrame(events[0]).body; 324 - const body2 = decodeFrame(events[1]).body; 325 - expect(body1.seq).toBe(seq2); 326 - expect(body2.seq).toBe(seq3); 327 - }); 328 - 329 - it("returns empty array when no events after cursor", () => { 330 - const { seq } = sequencer.sequenceIdentity("did:plc:test", "test"); 331 - const events = sequencer.getEventsSince(seq); 332 - expect(events).toEqual([]); 333 - }); 334 - 335 - it("respects limit parameter", () => { 336 - for (let i = 0; i < 5; i++) { 337 - sequencer.sequenceIdentity(`did:plc:${i}`, `${i}.test`); 338 - } 339 - 340 - const events = sequencer.getEventsSince(0, 2); 341 - expect(events.length).toBe(2); 342 - }); 343 - }); 344 - 345 - describe("getLatestSeq / getOldestSeq", () => { 346 - it("returns 0 when empty", () => { 347 - expect(sequencer.getLatestSeq()).toBe(0); 348 - expect(sequencer.getOldestSeq()).toBe(0); 349 - }); 350 - 351 - it("returns correct values after events", () => { 352 - const s1 = sequencer.sequenceIdentity("did:plc:a", "a").seq; 353 - sequencer.sequenceIdentity("did:plc:b", "b"); 354 - const s3 = sequencer.sequenceIdentity("did:plc:c", "c").seq; 355 - 356 - expect(sequencer.getOldestSeq()).toBe(s1); 357 - expect(sequencer.getLatestSeq()).toBe(s3); 358 - }); 359 - }); 360 - 361 - describe("pruneOldEvents", () => { 362 - it("removes old events keeping recent ones", () => { 363 - for (let i = 0; i < 10; i++) { 364 - sequencer.sequenceIdentity(`did:plc:${i}`, `${i}.test`); 365 - } 366 - 367 - // pruneOldEvents(3) deletes where seq < MAX(seq) - 3 368 - // With seqs 1-10: deletes seq < 7, keeps 7,8,9,10 369 - sequencer.pruneOldEvents(3); 370 - const events = sequencer.getEventsSince(0); 371 - expect(events.length).toBe(4); 372 - }); 373 - }); 374 - 375 - describe("subscribe / broadcast", () => { 376 - it("broadcasts to subscribers", async () => { 377 - const received: Uint8Array[] = []; 378 - sequencer.subscribe((frame) => received.push(frame)); 379 - 380 - const { storage } = createAccount(db, "did:plc:test"); 381 - const keypair = await Secp256k1Keypair.create(); 382 - let repo = await Repo.create(storage, "did:plc:test", keypair); 383 - 384 - repo = await repo.applyWrites( 385 - { 386 - action: WriteOpAction.Create, 387 - collection: "app.test.post", 388 - rkey: "r1", 389 - record: { text: "hello", $type: "app.test.post" }, 390 - }, 391 - keypair, 392 - ); 393 - 394 - const commit = storage.lastCommit!; 395 - await sequencer.sequenceCommit({ 396 - did: "did:plc:test", 397 - commit: commit.cid, 398 - rev: commit.rev, 399 - since: commit.since, 400 - prevData: null, 401 - newBlocks: commit.newBlocks, 402 - ops: [{ action: "create", path: "app.test.post/r1", cid: commit.cid }], 403 - }); 404 - 405 - expect(received.length).toBe(1); 406 - const { header } = decodeFrame(received[0]); 407 - expect(header.t).toBe("#commit"); 408 - }); 409 - 410 - it("multiple subscribers each receive all events", () => { 411 - const received1: Uint8Array[] = []; 412 - const received2: Uint8Array[] = []; 413 - sequencer.subscribe((frame) => received1.push(frame)); 414 - sequencer.subscribe((frame) => received2.push(frame)); 415 - 416 - sequencer.sequenceIdentity("did:plc:test", "test"); 417 - 418 - expect(received1.length).toBe(1); 419 - expect(received2.length).toBe(1); 420 - }); 421 - 422 - it("unsubscribe removes handler", () => { 423 - const received: Uint8Array[] = []; 424 - const unsub = sequencer.subscribe((frame) => received.push(frame)); 425 - 426 - sequencer.sequenceIdentity("did:plc:a", "a"); 427 - expect(received.length).toBe(1); 428 - 429 - unsub(); 430 - sequencer.sequenceIdentity("did:plc:b", "b"); 431 - expect(received.length).toBe(1); // no new events 432 - }); 433 - 434 - it("subscriberCount tracks active subscribers", () => { 435 - expect(sequencer.subscriberCount).toBe(0); 436 - 437 - const unsub1 = sequencer.subscribe(() => {}); 438 - const unsub2 = sequencer.subscribe(() => {}); 439 - expect(sequencer.subscriberCount).toBe(2); 440 - 441 - unsub1(); 442 - expect(sequencer.subscriberCount).toBe(1); 443 - 444 - unsub2(); 445 - expect(sequencer.subscriberCount).toBe(0); 446 - }); 447 - }); 448 - }); 449 - 450 - // --- WebSocket integration tests --- 451 - 452 - describe("subscribeRepos WebSocket", () => { 453 - let db: Database.Database; 454 - let sequencer: Sequencer; 455 - let serverInfo: { port: number; close: () => void }; 456 - 457 - async function startServer() { 458 - const app = new Hono(); 459 - const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); 460 - const config: Config = { 461 - hostname: "test.example", 462 - handleDomain: "test.example", 463 - plcUrl: "https://plc.test", 464 - dbPath: ":memory:", 465 - blobDir: "/tmp/rookery-test-blobs", 466 - relayHosts: [], 467 - port: 3000, 468 - tosText: "test tos", 469 - }; 470 - app.route("/", createSyncRoutes(db, config, sequencer, upgradeWebSocket)); 471 - 472 - return new Promise<{ port: number; close: () => void }>((resolve) => { 473 - const server = serve({ fetch: app.fetch, port: 0 }, (info) => { 474 - resolve({ port: info.port, close: () => server.close() }); 475 - }); 476 - injectWebSocket(server); 477 - }); 478 - } 479 - 480 - function wsUrl(cursor?: number): string { 481 - return `ws://localhost:${serverInfo.port}/xrpc/com.atproto.sync.subscribeRepos${cursor !== undefined ? `?cursor=${cursor}` : ""}`; 482 - } 483 - 484 - function waitForMessage(ws: WebSocket, timeoutMs = 2000): Promise<Buffer> { 485 - return new Promise((resolve, reject) => { 486 - const timer = setTimeout( 487 - () => reject(new Error("timeout waiting for message")), 488 - timeoutMs, 489 - ); 490 - ws.once("message", (data: Buffer) => { 491 - clearTimeout(timer); 492 - resolve(data); 493 - }); 494 - }); 495 - } 496 - 497 - function collectMessages( 498 - ws: WebSocket, 499 - count: number, 500 - timeoutMs = 2000, 501 - ): Promise<Buffer[]> { 502 - return new Promise((resolve, reject) => { 503 - const messages: Buffer[] = []; 504 - const timer = setTimeout( 505 - () => 506 - reject( 507 - new Error(`timeout: got ${messages.length}/${count} messages`), 508 - ), 509 - timeoutMs, 510 - ); 511 - const handler = (data: Buffer) => { 512 - messages.push(data); 513 - if (messages.length === count) { 514 - clearTimeout(timer); 515 - ws.off("message", handler); 516 - resolve(messages); 517 - } 518 - }; 519 - ws.on("message", handler); 520 - }); 521 - } 522 - 523 - function connectAndWaitOpen(cursor?: number): Promise<WebSocket> { 524 - const ws = new WebSocket(wsUrl(cursor)); 525 - return new Promise((resolve, reject) => { 526 - ws.on("open", () => resolve(ws)); 527 - ws.on("error", reject); 528 - }); 529 - } 530 - 531 - beforeEach(async () => { 532 - db = initDatabase(":memory:"); 533 - sequencer = new Sequencer(db); 534 - serverInfo = await startServer(); 535 - }); 536 - 537 - afterEach(() => { 538 - serverInfo.close(); 539 - }); 540 - 541 - it("connects at /xrpc/com.atproto.sync.subscribeRepos", async () => { 542 - const ws = await connectAndWaitOpen(); 543 - expect(ws.readyState).toBe(WebSocket.OPEN); 544 - ws.close(); 545 - }); 546 - 547 - it("receives live commit events", async () => { 548 - const ws = await connectAndWaitOpen(); 549 - const msgPromise = waitForMessage(ws); 550 - 551 - // Create a commit event 552 - const { storage } = createAccount(db, "did:plc:live"); 553 - const keypair = await Secp256k1Keypair.create(); 554 - let repo = await Repo.create(storage, "did:plc:live", keypair); 555 - repo = await repo.applyWrites( 556 - { 557 - action: WriteOpAction.Create, 558 - collection: "app.test.post", 559 - rkey: "r1", 560 - record: { text: "live event", $type: "app.test.post" }, 561 - }, 562 - keypair, 563 - ); 564 - 565 - const commit = storage.lastCommit!; 566 - await sequencer.sequenceCommit({ 567 - did: "did:plc:live", 568 - commit: commit.cid, 569 - rev: commit.rev, 570 - since: commit.since, 571 - prevData: null, 572 - newBlocks: commit.newBlocks, 573 - ops: [{ action: "create", path: "app.test.post/r1", cid: commit.cid }], 574 - }); 575 - 576 - const msg = await msgPromise; 577 - const { header, body } = decodeFrame(new Uint8Array(msg)); 578 - expect(header.t).toBe("#commit"); 579 - expect(body.repo).toBe("did:plc:live"); 580 - 581 - ws.close(); 582 - }); 583 - 584 - it("receives live identity events", async () => { 585 - const ws = await connectAndWaitOpen(); 586 - const msgPromise = waitForMessage(ws); 587 - 588 - sequencer.sequenceIdentity("did:plc:signup", "agent.test.example"); 589 - 590 - const msg = await msgPromise; 591 - const { header, body } = decodeFrame(new Uint8Array(msg)); 592 - expect(header.t).toBe("#identity"); 593 - expect(body.did).toBe("did:plc:signup"); 594 - expect(body.handle).toBe("agent.test.example"); 595 - 596 - ws.close(); 597 - }); 598 - 599 - it("cursor-based backfill replays events in order", async () => { 600 - // Create events before connecting 601 - sequencer.sequenceIdentity("did:plc:a", "a.test"); 602 - sequencer.sequenceIdentity("did:plc:b", "b.test"); 603 - sequencer.sequenceIdentity("did:plc:c", "c.test"); 604 - 605 - // Attach message handler BEFORE open to catch backfill messages 606 - const ws = new WebSocket(wsUrl(1)); 607 - const messages = await collectMessages(ws, 2); 608 - 609 - const body1 = decodeFrame(new Uint8Array(messages[0])).body; 610 - const body2 = decodeFrame(new Uint8Array(messages[1])).body; 611 - expect(body1.did).toBe("did:plc:b"); 612 - expect(body2.did).toBe("did:plc:c"); 613 - 614 - ws.close(); 615 - }); 616 - 617 - it("cursor=0 replays all events", async () => { 618 - sequencer.sequenceIdentity("did:plc:a", "a.test"); 619 - sequencer.sequenceIdentity("did:plc:b", "b.test"); 620 - 621 - // Attach message handler BEFORE open to catch backfill messages 622 - const ws = new WebSocket(wsUrl(0)); 623 - const messages = await collectMessages(ws, 2); 624 - 625 - const body1 = decodeFrame(new Uint8Array(messages[0])).body; 626 - const body2 = decodeFrame(new Uint8Array(messages[1])).body; 627 - expect(body1.did).toBe("did:plc:a"); 628 - expect(body2.did).toBe("did:plc:b"); 629 - 630 - ws.close(); 631 - }); 632 - 633 - it("backfill then live: receives both", async () => { 634 - // Pre-existing event 635 - sequencer.sequenceIdentity("did:plc:old", "old.test"); 636 - 637 - // Connect with cursor=0, set up handler before open 638 - const ws = new WebSocket(wsUrl(0)); 639 - const allMessages = collectMessages(ws, 2); 640 - 641 - // Wait for connection to be open and backfill sent 642 - await new Promise<void>((resolve) => ws.on("open", resolve)); 643 - // Small delay to ensure subscription is set up after backfill 644 - await new Promise((r) => setTimeout(r, 50)); 645 - sequencer.sequenceIdentity("did:plc:new", "new.test"); 646 - 647 - const messages = await allMessages; 648 - const body1 = decodeFrame(new Uint8Array(messages[0])).body; 649 - const body2 = decodeFrame(new Uint8Array(messages[1])).body; 650 - expect(body1.did).toBe("did:plc:old"); 651 - expect(body2.did).toBe("did:plc:new"); 652 - 653 - ws.close(); 654 - }); 655 - 656 - it("multiple simultaneous subscribers each receive all events", async () => { 657 - const ws1 = await connectAndWaitOpen(); 658 - const ws2 = await connectAndWaitOpen(); 659 - 660 - const msg1Promise = waitForMessage(ws1); 661 - const msg2Promise = waitForMessage(ws2); 662 - 663 - sequencer.sequenceIdentity("did:plc:multi", "multi.test"); 664 - 665 - const [msg1, msg2] = await Promise.all([msg1Promise, msg2Promise]); 666 - const body1 = decodeFrame(new Uint8Array(msg1)).body; 667 - const body2 = decodeFrame(new Uint8Array(msg2)).body; 668 - expect(body1.did).toBe("did:plc:multi"); 669 - expect(body2.did).toBe("did:plc:multi"); 670 - 671 - ws1.close(); 672 - ws2.close(); 673 - }); 674 - 675 - it("subscriber disconnect is handled cleanly", async () => { 676 - const ws = await connectAndWaitOpen(); 677 - expect(sequencer.subscriberCount).toBe(1); 678 - 679 - ws.close(); 680 - // Wait for close to propagate 681 - await new Promise((r) => setTimeout(r, 100)); 682 - expect(sequencer.subscriberCount).toBe(0); 683 - }); 684 - 685 - it("no cursor streams only live events", async () => { 686 - // Pre-existing events should not be sent 687 - sequencer.sequenceIdentity("did:plc:old", "old.test"); 688 - 689 - const ws = await connectAndWaitOpen(); // no cursor 690 - const msgPromise = waitForMessage(ws); 691 - 692 - sequencer.sequenceIdentity("did:plc:live", "live.test"); 693 - 694 - const msg = await msgPromise; 695 - const { body } = decodeFrame(new Uint8Array(msg)); 696 - expect(body.did).toBe("did:plc:live"); 697 - 698 - ws.close(); 699 - }); 700 - });
+1
test/fixtures/worker/index.ts
··· 1 + export { default, AccountDurableObject } from "../../../src/worker";
+21
test/fixtures/worker/wrangler.jsonc
··· 1 + { 2 + // Test fixture for rookery worker 3 + "name": "rookery-test", 4 + "main": "index.ts", 5 + "compatibility_date": "2025-01-01", 6 + "compatibility_flags": ["nodejs_compat"], 7 + "durable_objects": { 8 + "bindings": [ 9 + { 10 + "name": "ACCOUNT", 11 + "class_name": "AccountDurableObject" 12 + } 13 + ] 14 + }, 15 + "migrations": [ 16 + { 17 + "tag": "v1", 18 + "new_sqlite_classes": ["AccountDurableObject"] 19 + } 20 + ] 21 + }
+5 -162
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 - } 1 + import { env as _env, SELF } from "cloudflare:test"; 2 + export { runInDurableObject } from "cloudflare:test"; 3 + import type { Env } from "../src/types"; 68 4 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 - } 5 + export const env = _env as Env; 6 + export const worker = SELF;
-174
test/identity.test.ts
··· 1 - import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; 2 - import Database from "better-sqlite3"; 3 - import { Secp256k1Keypair } from "@atproto/crypto"; 4 - import { fromString } from "uint8arrays/from-string"; 5 - import { toString } from "uint8arrays/to-string"; 6 - import { createApp } from "../src/app.js"; 7 - import { DEFAULT_TOS_TEXT, type Config } from "../src/config.js"; 8 - import { initDatabase } from "../src/db.js"; 9 - import { 10 - buildUnsignedGenesisOp, 11 - createPlcDid, 12 - signGenesisOp, 13 - } from "../src/identity.js"; 14 - 15 - const testConfig: Config = { 16 - hostname: "pds.test.example", 17 - handleDomain: "test.example", 18 - plcUrl: "https://plc.test", 19 - dbPath: ":memory:", 20 - blobDir: "/tmp/rookery-test-blobs", 21 - relayHosts: [], 22 - port: 3000, 23 - tosText: DEFAULT_TOS_TEXT, 24 - }; 25 - 26 - describe("identity", () => { 27 - let db: Database.Database; 28 - 29 - beforeEach(() => { 30 - db = initDatabase(":memory:"); 31 - }); 32 - 33 - afterEach(() => { 34 - vi.unstubAllGlobals(); 35 - }); 36 - 37 - it("round-trips a secp256k1 keypair through export/import", async () => { 38 - const keypair = await Secp256k1Keypair.create({ exportable: true }); 39 - const exported = await keypair.export(); 40 - const hex = toString(exported, "hex"); 41 - const imported = await Secp256k1Keypair.import(fromString(hex, "hex"), { 42 - exportable: true, 43 - }); 44 - 45 - expect(imported.did()).toBe(keypair.did()); 46 - }); 47 - 48 - it("builds the expected unsigned genesis operation", async () => { 49 - const signingKeypair = await Secp256k1Keypair.create(); 50 - const rotationKeypair = await Secp256k1Keypair.create(); 51 - 52 - expect( 53 - buildUnsignedGenesisOp( 54 - "agent.test.example", 55 - "pds.test.example", 56 - signingKeypair, 57 - rotationKeypair, 58 - ), 59 - ).toEqual({ 60 - type: "plc_operation", 61 - rotationKeys: [rotationKeypair.did()], 62 - verificationMethods: { atproto: signingKeypair.did() }, 63 - alsoKnownAs: ["at://agent.test.example"], 64 - services: { 65 - atproto_pds: { 66 - type: "AtprotoPersonalDataServer", 67 - endpoint: "https://pds.test.example", 68 - }, 69 - }, 70 - prev: null, 71 - }); 72 - }); 73 - 74 - it("signs a genesis op and derives a did:plc identifier", async () => { 75 - const signingKeypair = await Secp256k1Keypair.create(); 76 - const rotationKeypair = await Secp256k1Keypair.create(); 77 - const unsignedOp = buildUnsignedGenesisOp( 78 - "agent.test.example", 79 - "pds.test.example", 80 - signingKeypair, 81 - rotationKeypair, 82 - ); 83 - 84 - const { signedOp, did } = await signGenesisOp(unsignedOp, rotationKeypair); 85 - 86 - expect(did.startsWith("did:plc:")).toBe(true); 87 - expect(did).toHaveLength(32); 88 - expect(did.slice("did:plc:".length)).toMatch(/^[a-z2-7]{24}$/); 89 - expect(signedOp).toMatchObject(unsignedOp); 90 - expect(signedOp.sig).toMatch(/^[A-Za-z0-9_-]+$/); 91 - 92 - // Deterministic: same inputs produce same DID (RFC 6979) 93 - const { did: did2 } = await signGenesisOp(unsignedOp, rotationKeypair); 94 - expect(did2).toBe(did); 95 - }); 96 - 97 - it("posts the signed genesis operation to the PLC directory", async () => { 98 - const signingKeypair = await Secp256k1Keypair.create(); 99 - const rotationKeypair = await Secp256k1Keypair.create(); 100 - const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { 101 - expect(init?.method).toBe("POST"); 102 - expect(init?.headers).toEqual({ "Content-Type": "application/json" }); 103 - expect(typeof init?.body).toBe("string"); 104 - 105 - return new Response("", { status: 200 }); 106 - }); 107 - vi.stubGlobal("fetch", fetchMock); 108 - 109 - const did = await createPlcDid( 110 - "agent.test.example", 111 - "pds.test.example", 112 - signingKeypair, 113 - rotationKeypair, 114 - "https://plc.test", 115 - ); 116 - 117 - expect(did.startsWith("did:plc:")).toBe(true); 118 - expect(fetchMock).toHaveBeenCalledTimes(1); 119 - const [url] = fetchMock.mock.calls[0] as [string, RequestInit | undefined]; 120 - expect(url).toMatch(/^https:\/\/plc\.test\/did:plc:[a-z2-7]{24}$/); 121 - }); 122 - 123 - it("resolves handles through the xrpc route", async () => { 124 - db.prepare("INSERT INTO accounts (did, handle) VALUES (?, ?)") 125 - .run("did:plc:agenttestexample1234", "agent.test.example"); 126 - const app = createApp(testConfig, db); 127 - 128 - const okRes = await app.request( 129 - "/xrpc/com.atproto.identity.resolveHandle?handle=agent.test.example", 130 - ); 131 - expect(okRes.status).toBe(200); 132 - await expect(okRes.json()).resolves.toEqual({ 133 - did: "did:plc:agenttestexample1234", 134 - }); 135 - 136 - const unknownRes = await app.request( 137 - "/xrpc/com.atproto.identity.resolveHandle?handle=missing.test.example", 138 - ); 139 - expect(unknownRes.status).toBe(400); 140 - await expect(unknownRes.json()).resolves.toEqual({ 141 - error: "InvalidRequest", 142 - message: "Unable to resolve handle", 143 - }); 144 - 145 - const missingRes = await app.request("/xrpc/com.atproto.identity.resolveHandle"); 146 - expect(missingRes.status).toBe(400); 147 - await expect(missingRes.json()).resolves.toEqual({ 148 - error: "InvalidRequest", 149 - message: "handle parameter is required", 150 - }); 151 - }); 152 - 153 - it("serves dids from the well-known endpoint by host header", async () => { 154 - db.prepare("INSERT INTO accounts (did, handle) VALUES (?, ?)") 155 - .run("did:plc:agenttestexample1234", "agent.test.example"); 156 - const app = createApp(testConfig, db); 157 - 158 - const okRes = await app.request("http://localhost/.well-known/atproto-did", { 159 - headers: { host: "agent.test.example" }, 160 - }); 161 - expect(okRes.status).toBe(200); 162 - await expect(okRes.text()).resolves.toBe("did:plc:agenttestexample1234"); 163 - 164 - const unknownRes = await app.request("http://localhost/.well-known/atproto-did", { 165 - headers: { host: "missing.test.example" }, 166 - }); 167 - expect(unknownRes.status).toBe(404); 168 - await expect(unknownRes.text()).resolves.toBe(""); 169 - 170 - const missingRes = await app.request("/.well-known/atproto-did"); 171 - expect(missingRes.status).toBe(400); 172 - await expect(missingRes.text()).resolves.toBe(""); 173 - }); 174 - });
-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 - });
-1032
test/repo.test.ts
··· 1 - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 - import Database from "better-sqlite3"; 3 - import { Hono } from "hono"; 4 - import { Repo, WriteOpAction } from "@atproto/repo"; 5 - import { Secp256k1Keypair } from "@atproto/crypto"; 6 - import { createApp } from "../src/app.js"; 7 - import type { Config } from "../src/config.js"; 8 - import { initDatabase } from "../src/db.js"; 9 - import { SqliteRepoStorage } from "../src/storage.js"; 10 - import { createRepoRoutes } from "../src/repo.js"; 11 - import { createSyncRoutes } from "../src/sync.js"; 12 - import { 13 - createDpopProof, 14 - createTestConfig, 15 - performSignup, 16 - } from "./helpers.js"; 17 - 18 - // --- Shared test config --- 19 - 20 - const testConfig: Config = createTestConfig({ 21 - hostname: "pds.test.example", 22 - handleDomain: "test.example", 23 - plcUrl: "https://plc.test", 24 - tosText: "test tos", 25 - }); 26 - 27 - // --- Helpers for read endpoint tests --- 28 - 29 - function createAccount( 30 - db: Database.Database, 31 - did: string, 32 - ): { id: number; storage: SqliteRepoStorage } { 33 - const info = db.prepare("INSERT INTO accounts (did) VALUES (?)").run(did); 34 - const id = Number(info.lastInsertRowid); 35 - return { id, storage: new SqliteRepoStorage(db, id) }; 36 - } 37 - 38 - async function createRepoWithPost(db: Database.Database, did: string) { 39 - const { id, storage } = createAccount(db, did); 40 - const keypair = await Secp256k1Keypair.create(); 41 - const created = await Repo.create(storage, did, keypair); 42 - const repo = await created.applyWrites( 43 - { 44 - action: WriteOpAction.Create, 45 - collection: "app.bsky.feed.post", 46 - rkey: "test1", 47 - record: { 48 - text: "hello world", 49 - createdAt: new Date().toISOString(), 50 - $type: "app.bsky.feed.post", 51 - }, 52 - }, 53 - keypair, 54 - ); 55 - 56 - return { id, storage, repo, keypair }; 57 - } 58 - 59 - async function createRepoWithPosts( 60 - db: Database.Database, 61 - did: string, 62 - rkeys: string[], 63 - ) { 64 - const { id, storage } = createAccount(db, did); 65 - const keypair = await Secp256k1Keypair.create(); 66 - let repo = await Repo.create(storage, did, keypair); 67 - for (const rkey of rkeys) { 68 - repo = await repo.applyWrites( 69 - { 70 - action: WriteOpAction.Create, 71 - collection: "app.bsky.feed.post", 72 - rkey, 73 - record: { 74 - text: `post ${rkey}`, 75 - createdAt: new Date().toISOString(), 76 - $type: "app.bsky.feed.post", 77 - }, 78 - }, 79 - keypair, 80 - ); 81 - } 82 - return { id, storage, repo, keypair }; 83 - } 84 - 85 - function setHandle(db: Database.Database, did: string, handle: string) { 86 - db.prepare("UPDATE accounts SET handle = ? WHERE did = ?").run(handle, did); 87 - } 88 - 89 - function createTestApp() { 90 - const db = initDatabase(":memory:"); 91 - const config: Config = createTestConfig(); 92 - const app = createApp(config, db); 93 - app.route("/", createSyncRoutes(db, config)); 94 - app.route("/", createRepoRoutes(db, config)); 95 - return { app, db, config }; 96 - } 97 - 98 - async function authenticatedPost( 99 - app: ReturnType<typeof createApp>, 100 - url: string, 101 - body: unknown, 102 - accessToken: string, 103 - privateKeyPem: string, 104 - jwk: { kty: string; n: string; e: string }, 105 - ) { 106 - const dpop = createDpopProof(jwk, privateKeyPem, "POST", url, accessToken); 107 - return app.request(url, { 108 - method: "POST", 109 - headers: { 110 - Authorization: `DPoP ${accessToken}`, 111 - DPoP: dpop, 112 - "Content-Type": "application/json", 113 - }, 114 - body: JSON.stringify(body), 115 - }); 116 - } 117 - 118 - // --- Read endpoint tests --- 119 - 120 - describe("repo read routes", () => { 121 - let db: Database.Database; 122 - let app: Hono; 123 - 124 - beforeEach(() => { 125 - db = initDatabase(":memory:"); 126 - app = createRepoRoutes(db, testConfig); 127 - }); 128 - 129 - afterEach(() => { 130 - vi.restoreAllMocks(); 131 - }); 132 - 133 - describe("getRecord", () => { 134 - it("returns uri, cid, and value for an existing record", async () => { 135 - const did = "did:plc:alice"; 136 - await createRepoWithPost(db, did); 137 - 138 - const res = await app.request( 139 - `/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.feed.post&rkey=test1`, 140 - ); 141 - const body = await res.json(); 142 - 143 - expect(res.status).toBe(200); 144 - expect(body.uri).toBe(`at://${did}/app.bsky.feed.post/test1`); 145 - expect(typeof body.cid).toBe("string"); 146 - expect(body.value.text).toBe("hello world"); 147 - }); 148 - 149 - it("returns 404 for a missing record in an existing repo", async () => { 150 - const did = "did:plc:alice"; 151 - await createRepoWithPost(db, did); 152 - 153 - const res = await app.request( 154 - `/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.feed.post&rkey=nonexistent`, 155 - ); 156 - const body = await res.json(); 157 - 158 - expect(res.status).toBe(404); 159 - expect(body.error).toBe("RecordNotFound"); 160 - }); 161 - 162 - it("returns 400 when the repo does not exist", async () => { 163 - const res = await app.request( 164 - "/xrpc/com.atproto.repo.getRecord?repo=did:plc:missing&collection=app.bsky.feed.post&rkey=test1", 165 - ); 166 - const body = await res.json(); 167 - 168 - expect(res.status).toBe(400); 169 - expect(body.error).toBe("RepoNotFound"); 170 - }); 171 - 172 - it("works with a handle as the repo param", async () => { 173 - const did = "did:plc:alice"; 174 - await createRepoWithPost(db, did); 175 - setHandle(db, did, "alice.test.example"); 176 - 177 - const res = await app.request( 178 - "/xrpc/com.atproto.repo.getRecord?repo=alice.test.example&collection=app.bsky.feed.post&rkey=test1", 179 - ); 180 - 181 - expect(res.status).toBe(200); 182 - }); 183 - 184 - it("returns 400 when repo is missing", async () => { 185 - const res = await app.request( 186 - "/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&rkey=test1", 187 - ); 188 - const body = await res.json(); 189 - 190 - expect(res.status).toBe(400); 191 - expect(body).toEqual({ 192 - error: "InvalidRequest", 193 - message: "missing repo parameter", 194 - }); 195 - }); 196 - 197 - it("returns 400 when collection is missing", async () => { 198 - const res = await app.request( 199 - "/xrpc/com.atproto.repo.getRecord?repo=did:plc:alice&rkey=test1", 200 - ); 201 - const body = await res.json(); 202 - 203 - expect(res.status).toBe(400); 204 - expect(body).toEqual({ 205 - error: "InvalidRequest", 206 - message: "missing collection parameter", 207 - }); 208 - }); 209 - 210 - it("returns 400 when rkey is missing", async () => { 211 - const res = await app.request( 212 - "/xrpc/com.atproto.repo.getRecord?repo=did:plc:alice&collection=app.bsky.feed.post", 213 - ); 214 - const body = await res.json(); 215 - 216 - expect(res.status).toBe(400); 217 - expect(body).toEqual({ 218 - error: "InvalidRequest", 219 - message: "missing rkey parameter", 220 - }); 221 - }); 222 - }); 223 - 224 - describe("listRecords", () => { 225 - it("returns records with the expected structure", async () => { 226 - const did = "did:plc:alice"; 227 - await createRepoWithPost(db, did); 228 - 229 - const res = await app.request( 230 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post`, 231 - ); 232 - const body = await res.json(); 233 - 234 - expect(res.status).toBe(200); 235 - expect(body.records).toHaveLength(1); 236 - expect(body.records[0].uri).toBe(`at://${did}/app.bsky.feed.post/test1`); 237 - expect(typeof body.records[0].cid).toBe("string"); 238 - expect(body.records[0].value.text).toBe("hello world"); 239 - }); 240 - 241 - it("returns empty records for a collection with no records", async () => { 242 - const did = "did:plc:alice"; 243 - await createRepoWithPost(db, did); 244 - 245 - const res = await app.request( 246 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.graph.follow`, 247 - ); 248 - const body = await res.json(); 249 - 250 - expect(res.status).toBe(200); 251 - expect(body).toEqual({ records: [] }); 252 - }); 253 - 254 - it("respects limit and returns a cursor", async () => { 255 - const did = "did:plc:alice"; 256 - await createRepoWithPosts(db, did, ["test1", "test2"]); 257 - 258 - const res = await app.request( 259 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1`, 260 - ); 261 - const body = await res.json(); 262 - 263 - expect(res.status).toBe(200); 264 - expect(body.records).toHaveLength(1); 265 - expect(body.cursor).toBe("test1"); 266 - }); 267 - 268 - it("uses the default limit when limit is omitted", async () => { 269 - const did = "did:plc:alice"; 270 - await createRepoWithPosts(db, did, ["test1", "test2"]); 271 - 272 - const res = await app.request( 273 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post`, 274 - ); 275 - const body = await res.json(); 276 - 277 - expect(res.status).toBe(200); 278 - expect(body.records).toHaveLength(2); 279 - }); 280 - 281 - it("caps limit at 100", async () => { 282 - const did = "did:plc:alice"; 283 - await createRepoWithPosts(db, did, ["test1", "test2"]); 284 - 285 - const res = await app.request( 286 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=999`, 287 - ); 288 - const body = await res.json(); 289 - 290 - expect(res.status).toBe(200); 291 - expect(body.records).toHaveLength(2); 292 - expect(body.cursor).toBeUndefined(); 293 - }); 294 - 295 - it("paginates correctly with cursor chaining", async () => { 296 - const did = "did:plc:alice"; 297 - await createRepoWithPosts(db, did, ["test1", "test2", "test3"]); 298 - 299 - const firstRes = await app.request( 300 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1`, 301 - ); 302 - const firstBody = await firstRes.json(); 303 - 304 - const secondRes = await app.request( 305 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1&cursor=${firstBody.cursor}`, 306 - ); 307 - const secondBody = await secondRes.json(); 308 - 309 - const thirdRes = await app.request( 310 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1&cursor=${secondBody.cursor}`, 311 - ); 312 - const thirdBody = await thirdRes.json(); 313 - 314 - expect(firstBody.records[0].value.text).toBe("post test1"); 315 - expect(secondBody.records[0].value.text).toBe("post test2"); 316 - expect(thirdBody.records[0].value.text).toBe("post test3"); 317 - expect(thirdBody.cursor).toBeUndefined(); 318 - }); 319 - 320 - it("paginates correctly with cursor and reverse", async () => { 321 - const did = "did:plc:alice"; 322 - await createRepoWithPosts(db, did, ["test1", "test2", "test3"]); 323 - 324 - const firstRes = await app.request( 325 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1&reverse=true`, 326 - ); 327 - const firstBody = await firstRes.json(); 328 - 329 - expect(firstBody.records).toHaveLength(1); 330 - expect(firstBody.records[0].uri).toContain("test3"); 331 - expect(firstBody.cursor).toBeDefined(); 332 - 333 - const secondRes = await app.request( 334 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1&reverse=true&cursor=${firstBody.cursor}`, 335 - ); 336 - const secondBody = await secondRes.json(); 337 - 338 - expect(secondBody.records).toHaveLength(1); 339 - expect(secondBody.records[0].uri).toContain("test2"); 340 - 341 - const thirdRes = await app.request( 342 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1&reverse=true&cursor=${secondBody.cursor}`, 343 - ); 344 - const thirdBody = await thirdRes.json(); 345 - 346 - expect(thirdBody.records).toHaveLength(1); 347 - expect(thirdBody.records[0].uri).toContain("test1"); 348 - expect(thirdBody.cursor).toBeUndefined(); 349 - }); 350 - 351 - it("returns records in reverse rkey order", async () => { 352 - const did = "did:plc:alice"; 353 - await createRepoWithPosts(db, did, ["test1", "test2", "test3"]); 354 - 355 - const res = await app.request( 356 - `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&reverse=true`, 357 - ); 358 - const body = await res.json(); 359 - 360 - expect(body.records.map((record: { uri: string }) => record.uri)).toEqual([ 361 - `at://${did}/app.bsky.feed.post/test3`, 362 - `at://${did}/app.bsky.feed.post/test2`, 363 - `at://${did}/app.bsky.feed.post/test1`, 364 - ]); 365 - }); 366 - 367 - it("returns 400 when repo is missing", async () => { 368 - const res = await app.request( 369 - "/xrpc/com.atproto.repo.listRecords?collection=app.bsky.feed.post", 370 - ); 371 - const body = await res.json(); 372 - 373 - expect(res.status).toBe(400); 374 - expect(body).toEqual({ 375 - error: "InvalidRequest", 376 - message: "missing repo parameter", 377 - }); 378 - }); 379 - 380 - it("returns 400 when collection is missing", async () => { 381 - const res = await app.request("/xrpc/com.atproto.repo.listRecords?repo=did:plc:alice"); 382 - const body = await res.json(); 383 - 384 - expect(res.status).toBe(400); 385 - expect(body).toEqual({ 386 - error: "InvalidRequest", 387 - message: "missing collection parameter", 388 - }); 389 - }); 390 - 391 - it("works with a handle as the repo param", async () => { 392 - const did = "did:plc:alice"; 393 - await createRepoWithPosts(db, did, ["test1"]); 394 - setHandle(db, did, "alice.test.example"); 395 - 396 - const res = await app.request( 397 - "/xrpc/com.atproto.repo.listRecords?repo=alice.test.example&collection=app.bsky.feed.post", 398 - ); 399 - const body = await res.json(); 400 - 401 - expect(res.status).toBe(200); 402 - expect(body.records).toHaveLength(1); 403 - }); 404 - }); 405 - 406 - describe("describeRepo", () => { 407 - it("returns the expected shape", async () => { 408 - const did = "did:plc:alice"; 409 - const fakeDidDoc = { id: did, verificationMethod: [] }; 410 - const { storage } = await createRepoWithPost(db, did); 411 - setHandle(db, did, "alice.test.example"); 412 - storage.addCollection("app.bsky.feed.post"); 413 - 414 - vi.stubGlobal("fetch", async (url: string) => { 415 - if (url === `${testConfig.plcUrl}/${did}`) { 416 - return new Response(JSON.stringify(fakeDidDoc), { status: 200 }); 417 - } 418 - return new Response("not found", { status: 404 }); 419 - }); 420 - 421 - const res = await app.request(`/xrpc/com.atproto.repo.describeRepo?repo=${did}`); 422 - const body = await res.json(); 423 - 424 - expect(res.status).toBe(200); 425 - expect(body).toEqual({ 426 - handle: "alice.test.example", 427 - did, 428 - didDoc: fakeDidDoc, 429 - collections: ["app.bsky.feed.post"], 430 - handleIsCorrect: true, 431 - }); 432 - }); 433 - 434 - it("returns handleIsCorrect true when a handle is set", async () => { 435 - const did = "did:plc:alice"; 436 - await createRepoWithPost(db, did); 437 - setHandle(db, did, "alice.test.example"); 438 - vi.stubGlobal("fetch", async () => new Response("{}", { status: 200 })); 439 - 440 - const res = await app.request(`/xrpc/com.atproto.repo.describeRepo?repo=${did}`); 441 - const body = await res.json(); 442 - 443 - expect(body.handleIsCorrect).toBe(true); 444 - }); 445 - 446 - it("returns handleIsCorrect false when the handle is null", async () => { 447 - const did = "did:plc:alice"; 448 - await createRepoWithPost(db, did); 449 - vi.stubGlobal("fetch", async () => new Response("{}", { status: 200 })); 450 - 451 - const res = await app.request(`/xrpc/com.atproto.repo.describeRepo?repo=${did}`); 452 - const body = await res.json(); 453 - 454 - expect(body.handle).toBe(""); 455 - expect(body.handleIsCorrect).toBe(false); 456 - }); 457 - 458 - it("returns collections from storage", async () => { 459 - const did = "did:plc:alice"; 460 - const { storage } = await createRepoWithPost(db, did); 461 - storage.addCollection("app.bsky.graph.follow"); 462 - storage.addCollection("app.bsky.feed.post"); 463 - vi.stubGlobal("fetch", async () => new Response("{}", { status: 200 })); 464 - 465 - const res = await app.request(`/xrpc/com.atproto.repo.describeRepo?repo=${did}`); 466 - const body = await res.json(); 467 - 468 - expect(body.collections).toEqual([ 469 - "app.bsky.feed.post", 470 - "app.bsky.graph.follow", 471 - ]); 472 - }); 473 - 474 - it("returns 400 when repo is missing", async () => { 475 - const res = await app.request("/xrpc/com.atproto.repo.describeRepo"); 476 - const body = await res.json(); 477 - 478 - expect(res.status).toBe(400); 479 - expect(body).toEqual({ 480 - error: "InvalidRequest", 481 - message: "missing repo parameter", 482 - }); 483 - }); 484 - 485 - it("returns 400 when the repo is unknown", async () => { 486 - const res = await app.request( 487 - "/xrpc/com.atproto.repo.describeRepo?repo=did:plc:missing", 488 - ); 489 - const body = await res.json(); 490 - 491 - expect(res.status).toBe(400); 492 - expect(body.error).toBe("RepoNotFound"); 493 - }); 494 - 495 - it("works with a handle as the repo param", async () => { 496 - const did = "did:plc:alice"; 497 - await createRepoWithPost(db, did); 498 - setHandle(db, did, "alice.test.example"); 499 - vi.stubGlobal("fetch", async () => new Response("{}", { status: 200 })); 500 - 501 - const res = await app.request( 502 - "/xrpc/com.atproto.repo.describeRepo?repo=alice.test.example", 503 - ); 504 - 505 - expect(res.status).toBe(200); 506 - }); 507 - }); 508 - 509 - describe("describeServer", () => { 510 - it("returns the expected server description", async () => { 511 - const res = await app.request("/xrpc/com.atproto.server.describeServer"); 512 - const body = await res.json(); 513 - 514 - expect(res.status).toBe(200); 515 - expect(body).toEqual({ 516 - did: "did:web:pds.test.example", 517 - availableUserDomains: ["test.example"], 518 - }); 519 - }); 520 - }); 521 - }); 522 - 523 - // --- Write endpoint tests --- 524 - 525 - describe("repo write routes", () => { 526 - beforeEach(() => { 527 - vi.stubGlobal("fetch", async () => new Response("", { status: 200 })); 528 - }); 529 - 530 - afterEach(() => { 531 - vi.restoreAllMocks(); 532 - }); 533 - 534 - it("createRecord with explicit rkey returns uri cid and commit", async () => { 535 - const { app, config } = createTestApp(); 536 - const signup = await performSignup(app, config); 537 - const signupJson = await signup.response.json() as { did: string }; 538 - 539 - const response = await authenticatedPost( 540 - app, 541 - "http://localhost/xrpc/com.atproto.repo.createRecord", 542 - { 543 - repo: signupJson.did, 544 - collection: "app.bsky.actor.profile", 545 - rkey: "self", 546 - record: { displayName: "Agent" }, 547 - }, 548 - signup.accessToken, 549 - signup.privateKey, 550 - signup.jwk, 551 - ); 552 - const body = await response.json() as { 553 - uri: string; 554 - cid: string; 555 - commit: { cid: string; rev: string }; 556 - }; 557 - 558 - expect(response.status).toBe(200); 559 - expect(body.uri).toBe(`at://${signupJson.did}/app.bsky.actor.profile/self`); 560 - expect(typeof body.cid).toBe("string"); 561 - expect(typeof body.commit.cid).toBe("string"); 562 - expect(typeof body.commit.rev).toBe("string"); 563 - }); 564 - 565 - it("createRecord without rkey auto-generates a TID", async () => { 566 - const { app, config } = createTestApp(); 567 - const signup = await performSignup(app, config); 568 - const signupJson = await signup.response.json() as { did: string }; 569 - 570 - const response = await authenticatedPost( 571 - app, 572 - "http://localhost/xrpc/com.atproto.repo.createRecord", 573 - { 574 - repo: signupJson.did, 575 - collection: "app.bsky.feed.post", 576 - record: { text: "hello" }, 577 - }, 578 - signup.accessToken, 579 - signup.privateKey, 580 - signup.jwk, 581 - ); 582 - const body = await response.json() as { uri: string }; 583 - 584 - expect(response.status).toBe(200); 585 - expect(body.uri).toMatch(/^at:\/\/did:plc:[a-z2-7]{24}\/app\.bsky\.feed\.post\/[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/); 586 - }); 587 - 588 - it("putRecord creates when record does not exist", async () => { 589 - const { app, config } = createTestApp(); 590 - const signup = await performSignup(app, config); 591 - const signupJson = await signup.response.json() as { did: string }; 592 - 593 - const response = await authenticatedPost( 594 - app, 595 - "http://localhost/xrpc/com.atproto.repo.putRecord", 596 - { 597 - repo: signupJson.did, 598 - collection: "app.bsky.actor.profile", 599 - rkey: "self", 600 - record: { displayName: "Agent" }, 601 - }, 602 - signup.accessToken, 603 - signup.privateKey, 604 - signup.jwk, 605 - ); 606 - const body = await response.json() as { 607 - uri: string; 608 - cid: string; 609 - commit: { cid: string; rev: string }; 610 - }; 611 - 612 - expect(response.status).toBe(200); 613 - expect(body.uri).toBe(`at://${signupJson.did}/app.bsky.actor.profile/self`); 614 - expect(typeof body.cid).toBe("string"); 615 - expect(typeof body.commit.cid).toBe("string"); 616 - expect(typeof body.commit.rev).toBe("string"); 617 - }); 618 - 619 - it("putRecord updates when record exists", async () => { 620 - const { app, config } = createTestApp(); 621 - const signup = await performSignup(app, config); 622 - const signupJson = await signup.response.json() as { did: string }; 623 - 624 - const created = await authenticatedPost( 625 - app, 626 - "http://localhost/xrpc/com.atproto.repo.putRecord", 627 - { 628 - repo: signupJson.did, 629 - collection: "app.bsky.actor.profile", 630 - rkey: "self", 631 - record: { displayName: "Agent" }, 632 - }, 633 - signup.accessToken, 634 - signup.privateKey, 635 - signup.jwk, 636 - ); 637 - const createdBody = await created.json() as { cid: string }; 638 - 639 - const updated = await authenticatedPost( 640 - app, 641 - "http://localhost/xrpc/com.atproto.repo.putRecord", 642 - { 643 - repo: signupJson.did, 644 - collection: "app.bsky.actor.profile", 645 - rkey: "self", 646 - record: { displayName: "Updated Agent" }, 647 - }, 648 - signup.accessToken, 649 - signup.privateKey, 650 - signup.jwk, 651 - ); 652 - const updatedBody = await updated.json() as { cid: string }; 653 - 654 - expect(updated.status).toBe(200); 655 - expect(updatedBody.cid).not.toBe(createdBody.cid); 656 - }); 657 - 658 - it("deleteRecord removes record and allows recreating the same rkey", async () => { 659 - const { app, config } = createTestApp(); 660 - const signup = await performSignup(app, config); 661 - const signupJson = await signup.response.json() as { did: string }; 662 - 663 - await authenticatedPost( 664 - app, 665 - "http://localhost/xrpc/com.atproto.repo.createRecord", 666 - { 667 - repo: signupJson.did, 668 - collection: "app.bsky.feed.post", 669 - rkey: "self", 670 - record: { text: "first" }, 671 - }, 672 - signup.accessToken, 673 - signup.privateKey, 674 - signup.jwk, 675 - ); 676 - 677 - const deleted = await authenticatedPost( 678 - app, 679 - "http://localhost/xrpc/com.atproto.repo.deleteRecord", 680 - { 681 - repo: signupJson.did, 682 - collection: "app.bsky.feed.post", 683 - rkey: "self", 684 - }, 685 - signup.accessToken, 686 - signup.privateKey, 687 - signup.jwk, 688 - ); 689 - 690 - const recreated = await authenticatedPost( 691 - app, 692 - "http://localhost/xrpc/com.atproto.repo.createRecord", 693 - { 694 - repo: signupJson.did, 695 - collection: "app.bsky.feed.post", 696 - rkey: "self", 697 - record: { text: "second" }, 698 - }, 699 - signup.accessToken, 700 - signup.privateKey, 701 - signup.jwk, 702 - ); 703 - 704 - expect(deleted.status).toBe(200); 705 - expect(recreated.status).toBe(200); 706 - }); 707 - 708 - it("deleteRecord on non-existent record is idempotent", async () => { 709 - const { app, config } = createTestApp(); 710 - const signup = await performSignup(app, config); 711 - const signupJson = await signup.response.json() as { did: string }; 712 - 713 - const response = await authenticatedPost( 714 - app, 715 - "http://localhost/xrpc/com.atproto.repo.deleteRecord", 716 - { 717 - repo: signupJson.did, 718 - collection: "app.bsky.feed.post", 719 - rkey: "missing", 720 - }, 721 - signup.accessToken, 722 - signup.privateKey, 723 - signup.jwk, 724 - ); 725 - const body = await response.json() as { commit: { cid: string; rev: string } }; 726 - 727 - expect(response.status).toBe(200); 728 - expect(typeof body.commit.cid).toBe("string"); 729 - expect(typeof body.commit.rev).toBe("string"); 730 - }); 731 - 732 - it("applyWrites handles mixed ops atomically", async () => { 733 - const { app, config } = createTestApp(); 734 - const signup = await performSignup(app, config); 735 - const signupJson = await signup.response.json() as { did: string }; 736 - 737 - await authenticatedPost( 738 - app, 739 - "http://localhost/xrpc/com.atproto.repo.createRecord", 740 - { 741 - repo: signupJson.did, 742 - collection: "app.bsky.feed.post", 743 - rkey: "update-me", 744 - record: { text: "original" }, 745 - }, 746 - signup.accessToken, 747 - signup.privateKey, 748 - signup.jwk, 749 - ); 750 - await authenticatedPost( 751 - app, 752 - "http://localhost/xrpc/com.atproto.repo.createRecord", 753 - { 754 - repo: signupJson.did, 755 - collection: "app.bsky.feed.post", 756 - rkey: "delete-me", 757 - record: { text: "delete" }, 758 - }, 759 - signup.accessToken, 760 - signup.privateKey, 761 - signup.jwk, 762 - ); 763 - 764 - const response = await authenticatedPost( 765 - app, 766 - "http://localhost/xrpc/com.atproto.repo.applyWrites", 767 - { 768 - repo: signupJson.did, 769 - writes: [ 770 - { 771 - $type: "com.atproto.repo.applyWrites#create", 772 - collection: "app.bsky.feed.post", 773 - rkey: "new-one", 774 - value: { text: "new" }, 775 - }, 776 - { 777 - $type: "com.atproto.repo.applyWrites#update", 778 - collection: "app.bsky.feed.post", 779 - rkey: "update-me", 780 - value: { text: "updated" }, 781 - }, 782 - { 783 - $type: "com.atproto.repo.applyWrites#delete", 784 - collection: "app.bsky.feed.post", 785 - rkey: "delete-me", 786 - }, 787 - ], 788 - }, 789 - signup.accessToken, 790 - signup.privateKey, 791 - signup.jwk, 792 - ); 793 - const body = await response.json() as { 794 - commit: { cid: string; rev: string }; 795 - results: Array<Record<string, string>>; 796 - }; 797 - 798 - expect(response.status).toBe(200); 799 - expect(typeof body.commit.cid).toBe("string"); 800 - expect(typeof body.commit.rev).toBe("string"); 801 - expect(body.results).toHaveLength(3); 802 - expect(body.results[0]).toEqual({ 803 - uri: `at://${signupJson.did}/app.bsky.feed.post/new-one`, 804 - cid: expect.any(String), 805 - }); 806 - expect(body.results[1]).toEqual({ 807 - uri: `at://${signupJson.did}/app.bsky.feed.post/update-me`, 808 - cid: expect.any(String), 809 - }); 810 - expect(body.results[2]).toEqual({}); 811 - }); 812 - 813 - it("applyWrites with more than 200 ops returns 400", async () => { 814 - const { app, config } = createTestApp(); 815 - const signup = await performSignup(app, config); 816 - const signupJson = await signup.response.json() as { did: string }; 817 - const writes = Array.from({ length: 201 }, (_, i) => ({ 818 - $type: "com.atproto.repo.applyWrites#create", 819 - collection: "app.bsky.feed.post", 820 - rkey: `r${i}`, 821 - value: { text: `post-${i}` }, 822 - })); 823 - 824 - const response = await authenticatedPost( 825 - app, 826 - "http://localhost/xrpc/com.atproto.repo.applyWrites", 827 - { repo: signupJson.did, writes }, 828 - signup.accessToken, 829 - signup.privateKey, 830 - signup.jwk, 831 - ); 832 - const body = await response.json(); 833 - 834 - expect(response.status).toBe(400); 835 - expect(body).toEqual({ 836 - error: "InvalidRequest", 837 - message: "too many writes (max 200)", 838 - }); 839 - }); 840 - 841 - it("write with repo DID mismatch returns 400", async () => { 842 - const { app, config } = createTestApp(); 843 - const signup = await performSignup(app, config); 844 - 845 - const response = await authenticatedPost( 846 - app, 847 - "http://localhost/xrpc/com.atproto.repo.createRecord", 848 - { 849 - repo: "did:plc:wrong", 850 - collection: "app.bsky.feed.post", 851 - record: { text: "bad" }, 852 - }, 853 - signup.accessToken, 854 - signup.privateKey, 855 - signup.jwk, 856 - ); 857 - const body = await response.json(); 858 - 859 - expect(response.status).toBe(400); 860 - expect(body).toEqual({ 861 - error: "InvalidRequest", 862 - message: "repo does not match authenticated DID", 863 - }); 864 - }); 865 - 866 - it("unauthenticated write returns 401", async () => { 867 - const { app } = createTestApp(); 868 - 869 - const response = await app.request("http://localhost/xrpc/com.atproto.repo.createRecord", { 870 - method: "POST", 871 - headers: { "Content-Type": "application/json" }, 872 - body: JSON.stringify({ 873 - repo: "did:plc:missing", 874 - collection: "app.bsky.feed.post", 875 - record: { text: "bad" }, 876 - }), 877 - }); 878 - const body = await response.json(); 879 - 880 - expect(response.status).toBe(401); 881 - expect(body).toEqual({ 882 - error: "missing Authorization: DPoP <token> header", 883 - }); 884 - }); 885 - 886 - it("arbitrary NSID collections are accepted", async () => { 887 - const { app, config } = createTestApp(); 888 - const signup = await performSignup(app, config); 889 - const signupJson = await signup.response.json() as { did: string }; 890 - 891 - const response = await authenticatedPost( 892 - app, 893 - "http://localhost/xrpc/com.atproto.repo.createRecord", 894 - { 895 - repo: signupJson.did, 896 - collection: "social.aha.insight", 897 - record: { note: "aha" }, 898 - }, 899 - signup.accessToken, 900 - signup.privateKey, 901 - signup.jwk, 902 - ); 903 - 904 - expect(response.status).toBe(200); 905 - }); 906 - 907 - it("writes update account root_cid rev and prev_data_cid", async () => { 908 - const { app, db, config } = createTestApp(); 909 - const signup = await performSignup(app, config); 910 - const signupJson = await signup.response.json() as { did: string }; 911 - 912 - const response = await authenticatedPost( 913 - app, 914 - "http://localhost/xrpc/com.atproto.repo.createRecord", 915 - { 916 - repo: signupJson.did, 917 - collection: "app.bsky.feed.post", 918 - record: { text: "hello" }, 919 - }, 920 - signup.accessToken, 921 - signup.privateKey, 922 - signup.jwk, 923 - ); 924 - 925 - expect(response.status).toBe(200); 926 - const row = db 927 - .prepare("SELECT root_cid, rev, prev_data_cid FROM accounts WHERE did = ?") 928 - .get(signupJson.did) as { 929 - root_cid: string | null; 930 - rev: string | null; 931 - prev_data_cid: string | null; 932 - }; 933 - expect(row.root_cid).not.toBeNull(); 934 - expect(row.rev).not.toBeNull(); 935 - expect(row.prev_data_cid).not.toBeNull(); 936 - }); 937 - 938 - it("writes to a new collection add the collection row", async () => { 939 - const { app, db, config } = createTestApp(); 940 - const signup = await performSignup(app, config); 941 - const signupJson = await signup.response.json() as { did: string }; 942 - 943 - await authenticatedPost( 944 - app, 945 - "http://localhost/xrpc/com.atproto.repo.createRecord", 946 - { 947 - repo: signupJson.did, 948 - collection: "social.aha.insight", 949 - record: { note: "aha" }, 950 - }, 951 - signup.accessToken, 952 - signup.privateKey, 953 - signup.jwk, 954 - ); 955 - 956 - const account = db 957 - .prepare("SELECT id FROM accounts WHERE did = ?") 958 - .get(signupJson.did) as { id: number }; 959 - const collections = db 960 - .prepare("SELECT collection FROM collections WHERE account_id = ?") 961 - .all(account.id) as { collection: string }[]; 962 - 963 - expect(collections.map((row) => row.collection)).toContain("social.aha.insight"); 964 - }); 965 - 966 - it("sequential writes produce different revisions", async () => { 967 - const { app, config } = createTestApp(); 968 - const signup = await performSignup(app, config); 969 - const signupJson = await signup.response.json() as { did: string }; 970 - 971 - const first = await authenticatedPost( 972 - app, 973 - "http://localhost/xrpc/com.atproto.repo.createRecord", 974 - { 975 - repo: signupJson.did, 976 - collection: "app.bsky.feed.post", 977 - rkey: "one", 978 - record: { text: "one" }, 979 - }, 980 - signup.accessToken, 981 - signup.privateKey, 982 - signup.jwk, 983 - ); 984 - const firstBody = await first.json() as { commit: { rev: string } }; 985 - 986 - const second = await authenticatedPost( 987 - app, 988 - "http://localhost/xrpc/com.atproto.repo.createRecord", 989 - { 990 - repo: signupJson.did, 991 - collection: "app.bsky.feed.post", 992 - rkey: "two", 993 - record: { text: "two" }, 994 - }, 995 - signup.accessToken, 996 - signup.privateKey, 997 - signup.jwk, 998 - ); 999 - const secondBody = await second.json() as { commit: { rev: string } }; 1000 - 1001 - expect(firstBody.commit.rev).not.toBe(secondBody.commit.rev); 1002 - }); 1003 - 1004 - it("sync latest commit matches createRecord commit", async () => { 1005 - const { app, config } = createTestApp(); 1006 - const signup = await performSignup(app, config); 1007 - const signupJson = await signup.response.json() as { did: string }; 1008 - 1009 - const created = await authenticatedPost( 1010 - app, 1011 - "http://localhost/xrpc/com.atproto.repo.createRecord", 1012 - { 1013 - repo: signupJson.did, 1014 - collection: "app.bsky.feed.post", 1015 - rkey: "self", 1016 - record: { text: "hello" }, 1017 - }, 1018 - signup.accessToken, 1019 - signup.privateKey, 1020 - signup.jwk, 1021 - ); 1022 - const createdBody = await created.json() as { commit: { cid: string; rev: string } }; 1023 - 1024 - const latest = await app.request( 1025 - `http://localhost/xrpc/com.atproto.sync.getLatestCommit?did=${signupJson.did}`, 1026 - ); 1027 - const latestBody = await latest.json() as { cid: string; rev: string }; 1028 - 1029 - expect(latest.status).toBe(200); 1030 - expect(latestBody).toEqual(createdBody.commit); 1031 - }); 1032 - });
+202 -207
test/storage.test.ts
··· 1 - import { beforeEach, describe, expect, it } from "vitest"; 2 - import Database from "better-sqlite3"; 3 - import { CID } from "@atproto/lex-data"; 4 - import { BlockMap, Repo, WriteOpAction } from "@atproto/repo"; 5 - import { Secp256k1Keypair } from "@atproto/crypto"; 6 - import { initDatabase } from "../src/db.js"; 7 - import { SqliteRepoStorage } from "../src/storage.js"; 1 + import { describe, it, expect } from "vitest"; 2 + import { env, runInDurableObject } from "./helpers"; 3 + import { BlockMap, CidSet } from "@atproto/repo"; 4 + import { CID, type LexValue } from "@atproto/lex-data"; 5 + import { AccountDurableObject } from "../src/account-do"; 8 6 9 - function createAccount( 10 - db: Database.Database, 11 - did: string, 12 - ): { id: number; storage: SqliteRepoStorage } { 13 - const info = db.prepare("INSERT INTO accounts (did) VALUES (?)").run(did); 14 - const id = Number(info.lastInsertRowid); 15 - return { id, storage: new SqliteRepoStorage(db, id) }; 7 + async function createCid( 8 + data: LexValue, 9 + ): Promise<{ cid: CID; bytes: Uint8Array }> { 10 + const blocks = new BlockMap(); 11 + const cid = await blocks.add(data); 12 + const bytes = blocks.get(cid)!; 13 + return { cid, bytes }; 16 14 } 17 15 18 16 describe("SqliteRepoStorage", () => { 19 - let db: Database.Database; 17 + describe("basic operations", () => { 18 + it("stores and retrieves blocks", async () => { 19 + const id = env.ACCOUNT.newUniqueId(); 20 + const stub = env.ACCOUNT.get(id); 21 + 22 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 23 + const storage = await instance.getStorage(); 24 + const { cid, bytes } = await createCid({ hello: "world" }); 25 + 26 + await storage.putBlock(cid, bytes, "rev1"); 27 + const retrieved = await storage.getBytes(cid); 28 + 29 + expect(retrieved).not.toBeNull(); 30 + expect(new Uint8Array(retrieved!)).toEqual(bytes); 31 + }); 32 + }); 33 + 34 + it("returns null for non-existent blocks", async () => { 35 + const id = env.ACCOUNT.newUniqueId(); 36 + const stub = env.ACCOUNT.get(id); 37 + 38 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 39 + const storage = await instance.getStorage(); 40 + const { cid } = await createCid({ nonexistent: true }); 41 + expect(await storage.getBytes(cid)).toBeNull(); 42 + }); 43 + }); 44 + 45 + it("checks block existence with has()", async () => { 46 + const id = env.ACCOUNT.newUniqueId(); 47 + const stub = env.ACCOUNT.get(id); 48 + 49 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 50 + const storage = await instance.getStorage(); 51 + const { cid, bytes } = await createCid({ test: "data" }); 52 + 53 + expect(await storage.has(cid)).toBe(false); 54 + await storage.putBlock(cid, bytes, "rev1"); 55 + expect(await storage.has(cid)).toBe(true); 56 + }); 57 + }); 58 + 59 + it("stores multiple blocks with putMany()", async () => { 60 + const id = env.ACCOUNT.newUniqueId(); 61 + const stub = env.ACCOUNT.get(id); 62 + 63 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 64 + const storage = await instance.getStorage(); 65 + const blocks = new BlockMap(); 66 + const b1 = await createCid({ block: 1 }); 67 + const b2 = await createCid({ block: 2 }); 68 + const b3 = await createCid({ block: 3 }); 69 + 70 + blocks.set(b1.cid, b1.bytes); 71 + blocks.set(b2.cid, b2.bytes); 72 + blocks.set(b3.cid, b3.bytes); 73 + 74 + await storage.putMany(blocks, "rev1"); 20 75 21 - beforeEach(() => { 22 - db = initDatabase(":memory:"); 23 - }); 76 + expect(await storage.has(b1.cid)).toBe(true); 77 + expect(await storage.has(b2.cid)).toBe(true); 78 + expect(await storage.has(b3.cid)).toBe(true); 79 + expect(await storage.countBlocks()).toBe(3); 80 + }); 81 + }); 24 82 25 - it("getRoot returns null before any commit", async () => { 26 - const { storage } = createAccount(db, "did:plc:test123"); 83 + it("retrieves multiple blocks with getBlocks()", async () => { 84 + const id = env.ACCOUNT.newUniqueId(); 85 + const stub = env.ACCOUNT.get(id); 27 86 28 - await expect(storage.getRoot()).resolves.toBeNull(); 29 - }); 87 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 88 + const storage = await instance.getStorage(); 89 + const b1 = await createCid({ block: 1 }); 90 + const b2 = await createCid({ block: 2 }); 91 + const missing = await createCid({ nonexistent: true }); 30 92 31 - it("Repo.create() succeeds and persists root/rev", async () => { 32 - const did = "did:plc:test123"; 33 - const { id, storage } = createAccount(db, did); 34 - const keypair = await Secp256k1Keypair.create(); 93 + await storage.putBlock(b1.cid, b1.bytes, "rev1"); 94 + await storage.putBlock(b2.cid, b2.bytes, "rev1"); 35 95 36 - const repo = await Repo.create(storage, did, keypair); 37 - const root = await storage.getRoot(); 38 - const rev = await storage.getRev(); 39 - const row = db 40 - .prepare("SELECT root_cid, rev FROM accounts WHERE id = ?") 41 - .get(id) as { root_cid: string | null; rev: string | null }; 96 + const result = await storage.getBlocks([b1.cid, b2.cid, missing.cid]); 42 97 43 - expect(repo.cid.toString()).toBe(root?.toString()); 44 - expect(root).not.toBeNull(); 45 - expect(rev).not.toBeNull(); 46 - expect(row.root_cid).toBe(root?.toString()); 47 - expect(row.rev).toBe(rev); 98 + expect(result.blocks.has(b1.cid)).toBe(true); 99 + expect(result.blocks.has(b2.cid)).toBe(true); 100 + expect(result.missing).toHaveLength(1); 101 + }); 102 + }); 48 103 }); 49 104 50 - it("repo.applyWrites() persists blocks and updates root", async () => { 51 - const did = "did:plc:test123"; 52 - const { id, storage } = createAccount(db, did); 53 - const keypair = await Secp256k1Keypair.create(); 54 - const repo = await Repo.create(storage, did, keypair); 55 - const initialRoot = await storage.getRoot(); 56 - const initialRev = await storage.getRev(); 105 + describe("root and revision", () => { 106 + it("starts with null root", async () => { 107 + const id = env.ACCOUNT.newUniqueId(); 108 + const stub = env.ACCOUNT.get(id); 57 109 58 - const updated = await repo.applyWrites( 59 - { 60 - action: WriteOpAction.Create, 61 - collection: "app.bsky.feed.post", 62 - rkey: "test1", 63 - record: { 64 - text: "hello world", 65 - createdAt: new Date().toISOString(), 66 - $type: "app.bsky.feed.post", 67 - }, 68 - }, 69 - keypair, 70 - ); 110 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 111 + const storage = await instance.getStorage(); 112 + expect(await storage.getRoot()).toBeNull(); 113 + expect(await storage.getRev()).toBeNull(); 114 + }); 115 + }); 71 116 72 - const nextRoot = await storage.getRoot(); 73 - const nextRev = await storage.getRev(); 74 - const blockCount = db 75 - .prepare("SELECT COUNT(*) AS count FROM blocks WHERE account_id = ?") 76 - .get(id) as { count: number }; 117 + it("updates root and revision", async () => { 118 + const id = env.ACCOUNT.newUniqueId(); 119 + const stub = env.ACCOUNT.get(id); 77 120 78 - expect(initialRoot?.toString()).not.toBe(nextRoot?.toString()); 79 - expect(initialRev).not.toBe(nextRev); 80 - expect(nextRoot?.toString()).toBe(updated.cid.toString()); 81 - expect(blockCount.count).toBeGreaterThan(0); 121 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 122 + const storage = await instance.getStorage(); 123 + const { cid, bytes } = await createCid({ root: "commit1" }); 124 + 125 + await storage.putBlock(cid, bytes, "rev1"); 126 + await storage.updateRoot(cid, "rev1"); 127 + 128 + const root = await storage.getRoot(); 129 + expect(root).not.toBeNull(); 130 + expect(root!.toString()).toBe(cid.toString()); 131 + expect(await storage.getRev()).toBe("rev1"); 132 + }); 133 + }); 82 134 }); 83 135 84 - it("prev_data_cid is null before first commit, set after", async () => { 85 - const did = "did:plc:test123"; 86 - const { id, storage } = createAccount(db, did); 87 - const before = db 88 - .prepare("SELECT prev_data_cid FROM accounts WHERE id = ?") 89 - .get(id) as { prev_data_cid: string | null }; 90 - const keypair = await Secp256k1Keypair.create(); 136 + describe("applyCommit", () => { 137 + it("applies a commit with new blocks", async () => { 138 + const id = env.ACCOUNT.newUniqueId(); 139 + const stub = env.ACCOUNT.get(id); 91 140 92 - const repo = await Repo.create(storage, did, keypair); 93 - const afterCreate = db 94 - .prepare("SELECT prev_data_cid FROM accounts WHERE id = ?") 95 - .get(id) as { prev_data_cid: string | null }; 141 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 142 + const storage = await instance.getStorage(); 143 + const commitBlock = await createCid({ type: "commit", data: "test" }); 144 + const dataBlock = await createCid({ record: "data" }); 96 145 97 - const updated = await repo.applyWrites( 98 - { 99 - action: WriteOpAction.Create, 100 - collection: "app.bsky.feed.post", 101 - rkey: "test1", 102 - record: { 103 - text: "hello world", 104 - createdAt: new Date().toISOString(), 105 - $type: "app.bsky.feed.post", 106 - }, 107 - }, 108 - keypair, 109 - ); 146 + const newBlocks = new BlockMap(); 147 + newBlocks.set(commitBlock.cid, commitBlock.bytes); 148 + newBlocks.set(dataBlock.cid, dataBlock.bytes); 110 149 111 - const afterWrite = db 112 - .prepare("SELECT prev_data_cid FROM accounts WHERE id = ?") 113 - .get(id) as { prev_data_cid: string | null }; 150 + await storage.applyCommit({ 151 + cid: commitBlock.cid, 152 + rev: "rev1", 153 + since: null, 154 + prev: null, 155 + newBlocks, 156 + relevantBlocks: new BlockMap(), 157 + removedCids: new CidSet(), 158 + }); 114 159 115 - expect(before.prev_data_cid).toBeNull(); 116 - expect(afterCreate.prev_data_cid).not.toBeNull(); 117 - expect(afterCreate.prev_data_cid).toBe(repo.data.pointer.toString()); 118 - expect(afterWrite.prev_data_cid).not.toBeNull(); 119 - expect(afterWrite.prev_data_cid).toBe(updated.data.pointer.toString()); 120 - expect(afterWrite.prev_data_cid).not.toBe(afterCreate.prev_data_cid); 121 - }); 160 + expect(await storage.has(commitBlock.cid)).toBe(true); 161 + expect(await storage.has(dataBlock.cid)).toBe(true); 162 + expect((await storage.getRoot())?.toString()).toBe(commitBlock.cid.toString()); 163 + expect(await storage.getRev()).toBe("rev1"); 164 + }); 165 + }); 122 166 123 - it("multi-tenant isolation", async () => { 124 - const alice = createAccount(db, "did:plc:alice"); 125 - const bob = createAccount(db, "did:plc:bob"); 126 - const keypair = await Secp256k1Keypair.create(); 127 - const repo = await Repo.create(alice.storage, "did:plc:alice", keypair); 128 - const updated = await repo.applyWrites( 129 - { 130 - action: WriteOpAction.Create, 131 - collection: "app.bsky.feed.post", 132 - rkey: "test1", 133 - record: { 134 - text: "hello world", 135 - createdAt: new Date().toISOString(), 136 - $type: "app.bsky.feed.post", 137 - }, 138 - }, 139 - keypair, 140 - ); 141 - const aliceBlocks = db 142 - .prepare("SELECT cid FROM blocks WHERE account_id = ? ORDER BY cid") 143 - .all(alice.id) as { cid: string }[]; 167 + it("removes old blocks when applying commit", async () => { 168 + const id = env.ACCOUNT.newUniqueId(); 169 + const stub = env.ACCOUNT.get(id); 144 170 145 - expect(await bob.storage.getRoot()).toBeNull(); 146 - expect(await bob.storage.has(updated.cid)).toBe(false); 147 - expect(await bob.storage.has(updated.data.pointer)).toBe(false); 148 - expect(aliceBlocks.length).toBeGreaterThan(0); 149 - for (const row of aliceBlocks) { 150 - expect(await bob.storage.getBytes(CID.parse(row.cid))).toBeNull(); 151 - } 152 - }); 171 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 172 + const storage = await instance.getStorage(); 153 173 154 - it("addCollection and getCollections", () => { 155 - const alice = createAccount(db, "did:plc:alice"); 156 - const bob = createAccount(db, "did:plc:bob"); 174 + const oldBlock = await createCid({ old: "data" }); 175 + const initialCommit = await createCid({ type: "commit", rev: 1 }); 176 + const initialBlocks = new BlockMap(); 177 + initialBlocks.set(oldBlock.cid, oldBlock.bytes); 178 + initialBlocks.set(initialCommit.cid, initialCommit.bytes); 157 179 158 - alice.storage.addCollection("app.bsky.graph.follow"); 159 - alice.storage.addCollection("app.bsky.feed.post"); 160 - alice.storage.addCollection("app.bsky.feed.post"); 180 + await storage.applyCommit({ 181 + cid: initialCommit.cid, 182 + rev: "rev1", 183 + since: null, 184 + prev: null, 185 + newBlocks: initialBlocks, 186 + relevantBlocks: new BlockMap(), 187 + removedCids: new CidSet(), 188 + }); 161 189 162 - expect(alice.storage.getCollections()).toEqual([ 163 - "app.bsky.feed.post", 164 - "app.bsky.graph.follow", 165 - ]); 166 - expect(bob.storage.getCollections()).toEqual([]); 167 - }); 190 + expect(await storage.has(oldBlock.cid)).toBe(true); 168 191 169 - it("getRev returns rev after commit", async () => { 170 - const did = "did:plc:test123"; 171 - const { id, storage } = createAccount(db, did); 172 - const keypair = await Secp256k1Keypair.create(); 192 + const newBlock = await createCid({ new: "data" }); 193 + const newCommit = await createCid({ type: "commit", rev: 2 }); 194 + const newBlocks = new BlockMap(); 195 + newBlocks.set(newBlock.cid, newBlock.bytes); 196 + newBlocks.set(newCommit.cid, newCommit.bytes); 173 197 174 - await Repo.create(storage, did, keypair); 198 + const removedCids = new CidSet(); 199 + removedCids.add(oldBlock.cid); 175 200 176 - const rev = await storage.getRev(); 177 - const row = db 178 - .prepare("SELECT rev FROM accounts WHERE id = ?") 179 - .get(id) as { rev: string | null }; 201 + await storage.applyCommit({ 202 + cid: newCommit.cid, 203 + rev: "rev2", 204 + since: "rev1", 205 + prev: initialCommit.cid, 206 + newBlocks, 207 + relevantBlocks: new BlockMap(), 208 + removedCids, 209 + }); 180 210 181 - expect(rev).not.toBeNull(); 182 - expect(rev).toBe(row.rev); 211 + expect(await storage.has(oldBlock.cid)).toBe(false); 212 + expect(await storage.has(newBlock.cid)).toBe(true); 213 + expect((await storage.getRoot())?.toString()).toBe(newCommit.cid.toString()); 214 + expect(await storage.getRev()).toBe("rev2"); 215 + }); 216 + }); 183 217 }); 184 218 185 - it("applyCommit is transactional", async () => { 186 - const did = "did:plc:test123"; 187 - const { id, storage } = createAccount(db, did); 188 - const keypair = await Secp256k1Keypair.create(); 189 - const repo = await Repo.create(storage, did, keypair); 190 - const goodCommit = await repo.formatCommit( 191 - { 192 - action: WriteOpAction.Create, 193 - collection: "app.bsky.feed.post", 194 - rkey: "test1", 195 - record: { 196 - text: "hello world", 197 - createdAt: new Date().toISOString(), 198 - $type: "app.bsky.feed.post", 199 - }, 200 - }, 201 - keypair, 202 - ); 203 - 204 - const originalRootRow = db 205 - .prepare("SELECT root_cid, rev, prev_data_cid FROM accounts WHERE id = ?") 206 - .get(id) as { 207 - root_cid: string | null; 208 - rev: string | null; 209 - prev_data_cid: string | null; 210 - }; 211 - const originalBlockCount = ( 212 - db.prepare("SELECT COUNT(*) AS count FROM blocks WHERE account_id = ?").get(id) as { 213 - count: number; 214 - } 215 - ).count; 219 + describe("collections", () => { 220 + it("manages collections", async () => { 221 + const id = env.ACCOUNT.newUniqueId(); 222 + const stub = env.ACCOUNT.get(id); 216 223 217 - const corruptedBlocks = new BlockMap(); 218 - for (const [cid, bytes] of goodCommit.newBlocks) { 219 - corruptedBlocks.set(cid, cid.equals(goodCommit.cid) ? Buffer.from([0xff]) : bytes); 220 - } 224 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 225 + const storage = await instance.getStorage(); 221 226 222 - await expect( 223 - storage.applyCommit({ 224 - ...goodCommit, 225 - newBlocks: corruptedBlocks, 226 - }), 227 - ).rejects.toThrow(); 227 + expect(storage.getCollections()).toEqual([]); 228 228 229 - const finalRootRow = db 230 - .prepare("SELECT root_cid, rev, prev_data_cid FROM accounts WHERE id = ?") 231 - .get(id) as { 232 - root_cid: string | null; 233 - rev: string | null; 234 - prev_data_cid: string | null; 235 - }; 236 - const finalBlockCount = ( 237 - db.prepare("SELECT COUNT(*) AS count FROM blocks WHERE account_id = ?").get(id) as { 238 - count: number; 239 - } 240 - ).count; 229 + storage.addCollection("app.bsky.feed.post"); 230 + storage.addCollection("app.bsky.feed.like"); 231 + storage.addCollection("app.bsky.feed.post"); 241 232 242 - expect(finalRootRow).toEqual(originalRootRow); 243 - expect(finalBlockCount).toBe(originalBlockCount); 233 + const collections = storage.getCollections(); 234 + expect(collections).toHaveLength(2); 235 + expect(collections).toContain("app.bsky.feed.post"); 236 + expect(collections).toContain("app.bsky.feed.like"); 237 + }); 238 + }); 244 239 }); 245 240 });
-349
test/sync.test.ts
··· 1 - import { beforeEach, describe, expect, it } from "vitest"; 2 - import Database from "better-sqlite3"; 3 - import { Hono } from "hono"; 4 - import { Repo, WriteOpAction, readCarWithRoot } from "@atproto/repo"; 5 - import { Secp256k1Keypair } from "@atproto/crypto"; 6 - import type { Config } from "../src/config.js"; 7 - import { initDatabase } from "../src/db.js"; 8 - import { SqliteRepoStorage } from "../src/storage.js"; 9 - import { createSyncRoutes } from "../src/sync.js"; 10 - 11 - function createAccount( 12 - db: Database.Database, 13 - did: string, 14 - ): { id: number; storage: SqliteRepoStorage } { 15 - const info = db.prepare("INSERT INTO accounts (did) VALUES (?)").run(did); 16 - const id = Number(info.lastInsertRowid); 17 - return { id, storage: new SqliteRepoStorage(db, id) }; 18 - } 19 - 20 - async function createRepoWithPost(db: Database.Database, did: string) { 21 - const { id, storage } = createAccount(db, did); 22 - const keypair = await Secp256k1Keypair.create(); 23 - const created = await Repo.create(storage, did, keypair); 24 - const repo = await created.applyWrites( 25 - { 26 - action: WriteOpAction.Create, 27 - collection: "app.bsky.feed.post", 28 - rkey: "test1", 29 - record: { 30 - text: "hello world", 31 - createdAt: new Date().toISOString(), 32 - $type: "app.bsky.feed.post", 33 - }, 34 - }, 35 - keypair, 36 - ); 37 - 38 - return { id, storage, repo, keypair }; 39 - } 40 - 41 - describe("createSyncRoutes", () => { 42 - let db: Database.Database; 43 - let app: Hono; 44 - 45 - beforeEach(() => { 46 - db = initDatabase(":memory:"); 47 - const config: Config = { 48 - hostname: "test.example", 49 - handleDomain: "test.example", 50 - plcUrl: "https://plc.test", 51 - dbPath: ":memory:", 52 - blobDir: "/tmp/rookery-test-blobs", 53 - relayHosts: [], 54 - port: 3000, 55 - tosText: "test tos", 56 - }; 57 - app = createSyncRoutes(db, config); 58 - }); 59 - 60 - describe("getRepo", () => { 61 - it("returns a valid CAR for an existing repo", async () => { 62 - const did = "did:plc:alice"; 63 - await createRepoWithPost(db, did); 64 - const account = db 65 - .prepare("SELECT root_cid FROM accounts WHERE did = ?") 66 - .get(did) as { root_cid: string }; 67 - 68 - const res = await app.request(`/xrpc/com.atproto.sync.getRepo?did=${did}`); 69 - const buf = await res.arrayBuffer(); 70 - const bytes = new Uint8Array(buf); 71 - const car = await readCarWithRoot(bytes); 72 - 73 - expect(res.status).toBe(200); 74 - expect(car.root.toString()).toBe(account.root_cid); 75 - expect(car.blocks.size).toBeGreaterThan(0); 76 - }); 77 - 78 - it("returns 404 for an unknown DID", async () => { 79 - const res = await app.request( 80 - "/xrpc/com.atproto.sync.getRepo?did=did:plc:missing", 81 - ); 82 - const body = await res.json(); 83 - 84 - expect(res.status).toBe(404); 85 - expect(body).toEqual({ 86 - error: "RepoNotFound", 87 - message: "repo not found", 88 - }); 89 - }); 90 - 91 - it("returns 400 when did is missing", async () => { 92 - const res = await app.request("/xrpc/com.atproto.sync.getRepo"); 93 - const body = await res.json(); 94 - 95 - expect(res.status).toBe(400); 96 - expect(body).toEqual({ 97 - error: "InvalidRequest", 98 - message: "missing did parameter", 99 - }); 100 - }); 101 - 102 - it("supports the since parameter", async () => { 103 - const did = "did:plc:alice"; 104 - const { keypair, repo } = await createRepoWithPost(db, did); 105 - const firstRev = db 106 - .prepare("SELECT rev FROM accounts WHERE did = ?") 107 - .get(did) as { rev: string }; 108 - 109 - await repo.applyWrites( 110 - { 111 - action: WriteOpAction.Create, 112 - collection: "app.bsky.feed.post", 113 - rkey: "test2", 114 - record: { 115 - text: "second post", 116 - createdAt: new Date().toISOString(), 117 - $type: "app.bsky.feed.post", 118 - }, 119 - }, 120 - keypair, 121 - ); 122 - 123 - const fullRes = await app.request(`/xrpc/com.atproto.sync.getRepo?did=${did}`); 124 - const fullBytes = new Uint8Array(await fullRes.arrayBuffer()); 125 - const fullCar = await readCarWithRoot(fullBytes); 126 - 127 - const diffRes = await app.request( 128 - `/xrpc/com.atproto.sync.getRepo?did=${did}&since=${firstRev.rev}`, 129 - ); 130 - const diffBytes = new Uint8Array(await diffRes.arrayBuffer()); 131 - const diffCar = await readCarWithRoot(diffBytes); 132 - 133 - expect(diffRes.status).toBe(200); 134 - expect(diffCar.blocks.size).toBeLessThan(fullCar.blocks.size); 135 - }); 136 - 137 - it("returns valid CAR with zero blocks when since equals latest rev", async () => { 138 - const did = "did:plc:alice"; 139 - await createRepoWithPost(db, did); 140 - const account = db 141 - .prepare("SELECT rev FROM accounts WHERE did = ?") 142 - .get(did) as { rev: string }; 143 - 144 - const res = await app.request( 145 - `/xrpc/com.atproto.sync.getRepo?did=${did}&since=${account.rev}`, 146 - ); 147 - const buf = await res.arrayBuffer(); 148 - const bytes = new Uint8Array(buf); 149 - const car = await readCarWithRoot(bytes); 150 - 151 - expect(res.status).toBe(200); 152 - expect(car.blocks.size).toBe(0); 153 - }); 154 - 155 - it("returns CAR content type", async () => { 156 - const did = "did:plc:alice"; 157 - await createRepoWithPost(db, did); 158 - 159 - const res = await app.request(`/xrpc/com.atproto.sync.getRepo?did=${did}`); 160 - 161 - expect(res.headers.get("Content-Type")).toBe("application/vnd.ipld.car"); 162 - }); 163 - }); 164 - 165 - describe("getLatestCommit", () => { 166 - it("returns cid and rev for an existing repo", async () => { 167 - const did = "did:plc:alice"; 168 - await createRepoWithPost(db, did); 169 - const account = db 170 - .prepare("SELECT root_cid, rev FROM accounts WHERE did = ?") 171 - .get(did) as { root_cid: string; rev: string }; 172 - 173 - const res = await app.request( 174 - `/xrpc/com.atproto.sync.getLatestCommit?did=${did}`, 175 - ); 176 - const body = await res.json(); 177 - 178 - expect(res.status).toBe(200); 179 - expect(body).toEqual({ cid: account.root_cid, rev: account.rev }); 180 - }); 181 - 182 - it("returns 404 for an unknown DID", async () => { 183 - const res = await app.request( 184 - "/xrpc/com.atproto.sync.getLatestCommit?did=did:plc:missing", 185 - ); 186 - const body = await res.json(); 187 - 188 - expect(res.status).toBe(404); 189 - expect(body).toEqual({ 190 - error: "RepoNotFound", 191 - message: "repo not found", 192 - }); 193 - }); 194 - 195 - it("returns 404 for account with no commits", async () => { 196 - createAccount(db, "did:plc:nocommits"); 197 - 198 - const res = await app.request( 199 - "/xrpc/com.atproto.sync.getLatestCommit?did=did:plc:nocommits", 200 - ); 201 - const body = await res.json(); 202 - 203 - expect(res.status).toBe(404); 204 - expect(body).toEqual({ 205 - error: "RepoNotFound", 206 - message: "repo has no commits", 207 - }); 208 - }); 209 - 210 - it("returns 400 when did is missing", async () => { 211 - const res = await app.request("/xrpc/com.atproto.sync.getLatestCommit"); 212 - const body = await res.json(); 213 - 214 - expect(res.status).toBe(400); 215 - expect(body).toEqual({ 216 - error: "InvalidRequest", 217 - message: "missing did parameter", 218 - }); 219 - }); 220 - }); 221 - 222 - describe("getRepoStatus", () => { 223 - it("returns active repo status", async () => { 224 - const did = "did:plc:alice"; 225 - await createRepoWithPost(db, did); 226 - const account = db.prepare("SELECT rev FROM accounts WHERE did = ?").get(did) as { 227 - rev: string; 228 - }; 229 - 230 - const res = await app.request( 231 - `/xrpc/com.atproto.sync.getRepoStatus?did=${did}`, 232 - ); 233 - const body = await res.json(); 234 - 235 - expect(res.status).toBe(200); 236 - expect(body).toEqual({ did, active: true, rev: account.rev }); 237 - }); 238 - 239 - it("returns 404 for an unknown DID", async () => { 240 - const res = await app.request( 241 - "/xrpc/com.atproto.sync.getRepoStatus?did=did:plc:missing", 242 - ); 243 - const body = await res.json(); 244 - 245 - expect(res.status).toBe(404); 246 - expect(body).toEqual({ 247 - error: "RepoNotFound", 248 - message: "repo not found", 249 - }); 250 - }); 251 - 252 - it("returns deactivated status for inactive accounts", async () => { 253 - const did = "did:plc:alice"; 254 - await createRepoWithPost(db, did); 255 - db.prepare("UPDATE accounts SET active = 0 WHERE did = ?").run(did); 256 - 257 - const res = await app.request( 258 - `/xrpc/com.atproto.sync.getRepoStatus?did=${did}`, 259 - ); 260 - const body = await res.json(); 261 - 262 - expect(res.status).toBe(200); 263 - expect(body.did).toBe(did); 264 - expect(body.active).toBe(false); 265 - expect(body.status).toBe("deactivated"); 266 - }); 267 - }); 268 - 269 - describe("listRepos", () => { 270 - it("returns repos with the expected fields", async () => { 271 - const did = "did:plc:alice"; 272 - await createRepoWithPost(db, did); 273 - createAccount(db, "did:plc:empty"); 274 - 275 - const res = await app.request("/xrpc/com.atproto.sync.listRepos"); 276 - const body = await res.json(); 277 - 278 - expect(res.status).toBe(200); 279 - expect(body.repos).toHaveLength(1); 280 - expect(body.repos[0]).toMatchObject({ 281 - did, 282 - active: true, 283 - }); 284 - expect(body.repos[0].head).toEqual(expect.any(String)); 285 - expect(body.repos[0].rev).toEqual(expect.any(String)); 286 - }); 287 - 288 - it("paginates using cursor chaining", async () => { 289 - await createRepoWithPost(db, "did:plc:one"); 290 - await createRepoWithPost(db, "did:plc:two"); 291 - await createRepoWithPost(db, "did:plc:three"); 292 - 293 - const seen: string[] = []; 294 - let cursor: string | undefined; 295 - 296 - do { 297 - const query = cursor 298 - ? `/xrpc/com.atproto.sync.listRepos?limit=1&cursor=${cursor}` 299 - : "/xrpc/com.atproto.sync.listRepos?limit=1"; 300 - const res = await app.request(query); 301 - const body = await res.json(); 302 - 303 - expect(res.status).toBe(200); 304 - expect(body.repos).toHaveLength(1); 305 - seen.push(body.repos[0].did); 306 - cursor = body.cursor; 307 - } while (cursor); 308 - 309 - expect(seen).toEqual([ 310 - "did:plc:one", 311 - "did:plc:two", 312 - "did:plc:three", 313 - ]); 314 - }); 315 - 316 - it("returns an empty repos array when no accounts exist", async () => { 317 - const res = await app.request("/xrpc/com.atproto.sync.listRepos"); 318 - const body = await res.json(); 319 - 320 - expect(res.status).toBe(200); 321 - expect(body).toEqual({ repos: [] }); 322 - }); 323 - 324 - it("returns 400 for invalid cursor", async () => { 325 - const res = await app.request( 326 - "/xrpc/com.atproto.sync.listRepos?cursor=notanumber", 327 - ); 328 - const body = await res.json(); 329 - 330 - expect(res.status).toBe(400); 331 - expect(body).toEqual({ 332 - error: "InvalidRequest", 333 - message: "invalid cursor", 334 - }); 335 - }); 336 - 337 - it("respects the limit parameter", async () => { 338 - await createRepoWithPost(db, "did:plc:one"); 339 - await createRepoWithPost(db, "did:plc:two"); 340 - 341 - const res = await app.request("/xrpc/com.atproto.sync.listRepos?limit=1"); 342 - const body = await res.json(); 343 - 344 - expect(res.status).toBe(200); 345 - expect(body.repos).toHaveLength(1); 346 - expect(body.cursor).toEqual(expect.any(String)); 347 - }); 348 - }); 349 - });
+4 -3
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 3 "target": "es2022", 4 - "module": "nodenext", 5 - "moduleResolution": "nodenext", 4 + "module": "esnext", 5 + "moduleResolution": "bundler", 6 6 "outDir": "dist", 7 7 "rootDir": "src", 8 8 "strict": true, 9 9 "skipLibCheck": true, 10 10 "declaration": true, 11 11 "esModuleInterop": true, 12 - "forceConsistentCasingInFileNames": true 12 + "forceConsistentCasingInFileNames": true, 13 + "types": ["@cloudflare/workers-types/2023-07-01"] 13 14 }, 14 15 "include": ["src"], 15 16 "exclude": ["node_modules", "dist", "test"]
+43 -2
vitest.config.ts
··· 1 - import { defineConfig } from "vitest/config"; 1 + import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; 2 2 3 - export default defineConfig({}); 3 + export default defineWorkersConfig({ 4 + resolve: { 5 + conditions: ["worker", "browser", "node", "require"], 6 + alias: { 7 + pino: "pino/browser.js", 8 + }, 9 + }, 10 + test: { 11 + globals: true, 12 + maxWorkers: 1, 13 + isolate: false, 14 + deps: { 15 + optimizer: { 16 + ssr: { 17 + include: [ 18 + "@atcute/tid", 19 + "@atcute/time-ms", 20 + "@atproto/common", 21 + "@atproto/repo", 22 + "@atproto/crypto", 23 + "@atproto/lex-cbor", 24 + "multiformats", 25 + ], 26 + }, 27 + }, 28 + }, 29 + poolOptions: { 30 + workers: { 31 + main: "./test/fixtures/worker/index.ts", 32 + singleWorker: true, 33 + wrangler: { configPath: "./test/fixtures/worker/wrangler.jsonc" }, 34 + miniflare: { 35 + bindings: { 36 + ROOKERY_HOSTNAME: "rookery.test", 37 + ROOKERY_HANDLE_DOMAIN: ".rookery.test", 38 + ROOKERY_PLC_URL: "https://plc.directory", 39 + }, 40 + }, 41 + }, 42 + }, 43 + }, 44 + });
+13
wrangler.toml
··· 1 + name = "rookery" 2 + main = "src/worker.ts" 3 + compatibility_date = "2025-01-01" 4 + compatibility_flags = ["nodejs_compat"] 5 + 6 + [durable_objects] 7 + bindings = [ 8 + { name = "ACCOUNT", class_name = "AccountDurableObject" } 9 + ] 10 + 11 + [[migrations]] 12 + tag = "v1" 13 + new_sqlite_classes = ["AccountDurableObject"]