A minimal AT Protocol Personal Data Server written in JavaScript.

refactor: split pds.test.js into module-specific test files

Split the monolithic test/pds.test.js (126 tests) into 7 separate
test files in packages/core/test/, each corresponding to its source module:

- crypto.test.js (13 tests): Base32, P-256, JWT Base64URL, JWK Thumbprint
- repo.test.js (44 tests): CBOR, CID, TID, CAR, MIME, Blob Ref
- auth.test.js (12 tests): JWT Creation, JWT Verification
- mst.test.js (5 tests): MST Key Depth
- scope.test.js (39 tests): Scope Parsing, ScopePermissions
- oauth.test.js (3 tests): Client Metadata
- pds.test.js (10 tests): Proxy Utilities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+2699 -511
+698
docs/plans/2026-01-13-package-restructure.md
··· 1 + # Package Restructure Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Reorganize packages to use `src/` and `test/` folders for better discoverability and encapsulation. 6 + 7 + **Architecture:** Move source files into `src/` subdirectories, update package.json exports, create `test/` folders for unit tests. E2E tests remain centralized. No build step required. 8 + 9 + **Tech Stack:** pnpm workspaces, Vitest, TypeScript declarations. 10 + 11 + --- 12 + 13 + ## Task 1: Restructure blobs-fs Package 14 + 15 + **Files:** 16 + - Create: `packages/blobs-fs/src/` 17 + - Move: `packages/blobs-fs/index.js` → `packages/blobs-fs/src/index.js` 18 + - Modify: `packages/blobs-fs/package.json` 19 + 20 + **Step 1: Create src directory and move source file** 21 + 22 + ```bash 23 + mkdir -p packages/blobs-fs/src 24 + mv packages/blobs-fs/index.js packages/blobs-fs/src/ 25 + ``` 26 + 27 + **Step 2: Update package.json** 28 + 29 + Replace `packages/blobs-fs/package.json` with: 30 + 31 + ```json 32 + { 33 + "name": "@pds/blobs-fs", 34 + "version": "0.1.0", 35 + "type": "module", 36 + "main": "./src/index.js", 37 + "types": "./src/index.d.ts", 38 + "exports": { 39 + ".": "./src/index.js" 40 + } 41 + } 42 + ``` 43 + 44 + **Step 3: Create empty test directory** 45 + 46 + ```bash 47 + mkdir -p packages/blobs-fs/test 48 + ``` 49 + 50 + **Step 4: Verify package resolves** 51 + 52 + ```bash 53 + node -e "import('@pds/blobs-fs').then(m => console.log('OK:', Object.keys(m)))" 54 + ``` 55 + 56 + Expected: `OK: [ 'createFsBlobs' ]` (or similar export names) 57 + 58 + **Step 5: Commit** 59 + 60 + ```bash 61 + git add packages/blobs-fs/ 62 + git commit -m "refactor(blobs-fs): move source to src/ folder" 63 + ``` 64 + 65 + --- 66 + 67 + ## Task 2: Restructure blobs-s3 Package 68 + 69 + **Files:** 70 + - Create: `packages/blobs-s3/src/` 71 + - Move: `packages/blobs-s3/index.js` → `packages/blobs-s3/src/index.js` 72 + - Modify: `packages/blobs-s3/package.json` 73 + 74 + **Step 1: Create src directory and move source file** 75 + 76 + ```bash 77 + mkdir -p packages/blobs-s3/src 78 + mv packages/blobs-s3/index.js packages/blobs-s3/src/ 79 + ``` 80 + 81 + **Step 2: Update package.json** 82 + 83 + Replace `packages/blobs-s3/package.json` with: 84 + 85 + ```json 86 + { 87 + "name": "@pds/blobs-s3", 88 + "version": "0.1.0", 89 + "type": "module", 90 + "main": "./src/index.js", 91 + "types": "./src/index.d.ts", 92 + "exports": { 93 + ".": "./src/index.js" 94 + } 95 + } 96 + ``` 97 + 98 + **Step 3: Create empty test directory** 99 + 100 + ```bash 101 + mkdir -p packages/blobs-s3/test 102 + ``` 103 + 104 + **Step 4: Verify package resolves** 105 + 106 + ```bash 107 + node -e "import('@pds/blobs-s3').then(m => console.log('OK:', Object.keys(m)))" 108 + ``` 109 + 110 + Expected: `OK: [ 'createS3Blobs' ]` (or similar) 111 + 112 + **Step 5: Commit** 113 + 114 + ```bash 115 + git add packages/blobs-s3/ 116 + git commit -m "refactor(blobs-s3): move source to src/ folder" 117 + ``` 118 + 119 + --- 120 + 121 + ## Task 3: Restructure blobs-deno Package 122 + 123 + **Files:** 124 + - Create: `packages/blobs-deno/src/` 125 + - Move: `packages/blobs-deno/index.js` → `packages/blobs-deno/src/index.js` 126 + - Modify: `packages/blobs-deno/package.json` 127 + - Modify: `packages/blobs-deno/deno.json` 128 + 129 + **Step 1: Create src directory and move source file** 130 + 131 + ```bash 132 + mkdir -p packages/blobs-deno/src 133 + mv packages/blobs-deno/index.js packages/blobs-deno/src/ 134 + ``` 135 + 136 + **Step 2: Update package.json** 137 + 138 + Replace `packages/blobs-deno/package.json` with: 139 + 140 + ```json 141 + { 142 + "name": "@pds/blobs-deno", 143 + "version": "0.1.0", 144 + "type": "module", 145 + "main": "./src/index.js", 146 + "types": "./src/index.d.ts", 147 + "exports": { 148 + ".": "./src/index.js" 149 + } 150 + } 151 + ``` 152 + 153 + **Step 3: Update deno.json exports** 154 + 155 + Update `packages/blobs-deno/deno.json` to point to src: 156 + 157 + ```json 158 + { 159 + "name": "@pds/blobs-deno", 160 + "version": "0.1.0", 161 + "exports": "./src/index.js" 162 + } 163 + ``` 164 + 165 + **Step 4: Create empty test directory** 166 + 167 + ```bash 168 + mkdir -p packages/blobs-deno/test 169 + ``` 170 + 171 + **Step 5: Commit** 172 + 173 + ```bash 174 + git add packages/blobs-deno/ 175 + git commit -m "refactor(blobs-deno): move source to src/ folder" 176 + ``` 177 + 178 + --- 179 + 180 + ## Task 4: Restructure storage-sqlite Package 181 + 182 + **Files:** 183 + - Create: `packages/storage-sqlite/src/` 184 + - Move: `packages/storage-sqlite/index.js` → `packages/storage-sqlite/src/index.js` 185 + - Move: `packages/storage-sqlite/driver.js` → `packages/storage-sqlite/src/driver.js` 186 + - Modify: `packages/storage-sqlite/package.json` 187 + 188 + **Step 1: Create src directory and move source files** 189 + 190 + ```bash 191 + mkdir -p packages/storage-sqlite/src 192 + mv packages/storage-sqlite/index.js packages/storage-sqlite/src/ 193 + mv packages/storage-sqlite/driver.js packages/storage-sqlite/src/ 194 + ``` 195 + 196 + **Step 2: Update package.json** 197 + 198 + Replace `packages/storage-sqlite/package.json` with: 199 + 200 + ```json 201 + { 202 + "name": "@pds/storage-sqlite", 203 + "version": "0.1.0", 204 + "type": "module", 205 + "main": "./src/index.js", 206 + "types": "./src/index.d.ts", 207 + "exports": { 208 + ".": "./src/index.js", 209 + "./driver": "./src/driver.js" 210 + }, 211 + "peerDependencies": { 212 + "better-sqlite3": ">=9.0.0" 213 + } 214 + } 215 + ``` 216 + 217 + **Step 3: Create empty test directory** 218 + 219 + ```bash 220 + mkdir -p packages/storage-sqlite/test 221 + ``` 222 + 223 + **Step 4: Verify package resolves** 224 + 225 + ```bash 226 + node -e "import('@pds/storage-sqlite').then(m => console.log('OK:', Object.keys(m)))" 227 + ``` 228 + 229 + Expected: `OK: [ 'createBetterSqliteStorage' ]` (or similar) 230 + 231 + **Step 5: Commit** 232 + 233 + ```bash 234 + git add packages/storage-sqlite/ 235 + git commit -m "refactor(storage-sqlite): move source to src/ folder" 236 + ``` 237 + 238 + --- 239 + 240 + ## Task 5: Restructure core Package 241 + 242 + **Files:** 243 + - Create: `packages/core/src/` 244 + - Move: All `.js` files to `packages/core/src/` 245 + - Modify: `packages/core/package.json` 246 + 247 + **Step 1: Create src directory and move all source files** 248 + 249 + ```bash 250 + mkdir -p packages/core/src 251 + mv packages/core/*.js packages/core/src/ 252 + ``` 253 + 254 + **Step 2: Update package.json** 255 + 256 + Replace `packages/core/package.json` with: 257 + 258 + ```json 259 + { 260 + "name": "@pds/core", 261 + "version": "0.1.0", 262 + "type": "module", 263 + "main": "./src/index.js", 264 + "types": "./src/index.d.ts", 265 + "exports": { 266 + ".": "./src/index.js", 267 + "./repo": "./src/repo.js", 268 + "./auth": "./src/auth.js", 269 + "./crypto": "./src/crypto.js", 270 + "./ports": "./src/ports.js", 271 + "./pds": "./src/pds.js", 272 + "./scope": "./src/scope.js", 273 + "./oauth": "./src/oauth.js" 274 + } 275 + } 276 + ``` 277 + 278 + **Step 3: Create empty test directory** 279 + 280 + ```bash 281 + mkdir -p packages/core/test 282 + ``` 283 + 284 + **Step 4: Verify package resolves** 285 + 286 + ```bash 287 + node -e "import('@pds/core').then(m => console.log('OK:', Object.keys(m)))" 288 + node -e "import('@pds/core/pds').then(m => console.log('OK pds:', Object.keys(m)))" 289 + ``` 290 + 291 + Expected: Lists of exports for each 292 + 293 + **Step 5: Commit** 294 + 295 + ```bash 296 + git add packages/core/ 297 + git commit -m "refactor(core): move source to src/ folder" 298 + ``` 299 + 300 + --- 301 + 302 + ## Task 6: Restructure node Package 303 + 304 + **Files:** 305 + - Create: `packages/node/src/` 306 + - Move: `packages/node/index.js` → `packages/node/src/index.js` 307 + - Modify: `packages/node/package.json` 308 + 309 + **Step 1: Create src directory and move source file** 310 + 311 + ```bash 312 + mkdir -p packages/node/src 313 + mv packages/node/index.js packages/node/src/ 314 + ``` 315 + 316 + **Step 2: Update package.json** 317 + 318 + Replace `packages/node/package.json` with: 319 + 320 + ```json 321 + { 322 + "name": "@pds/node", 323 + "version": "0.1.0", 324 + "type": "module", 325 + "main": "./src/index.js", 326 + "types": "./src/index.d.ts", 327 + "exports": { 328 + ".": "./src/index.js" 329 + }, 330 + "dependencies": { 331 + "@pds/blobs-fs": "workspace:*", 332 + "@pds/core": "workspace:*", 333 + "@pds/storage-sqlite": "workspace:*", 334 + "ws": "^8.19.0" 335 + }, 336 + "peerDependencies": { 337 + "better-sqlite3": ">=9.0.0" 338 + } 339 + } 340 + ``` 341 + 342 + **Step 3: Create empty test directory** 343 + 344 + ```bash 345 + mkdir -p packages/node/test 346 + ``` 347 + 348 + **Step 4: Verify package resolves** 349 + 350 + ```bash 351 + node -e "import('@pds/node').then(m => console.log('OK:', Object.keys(m)))" 352 + ``` 353 + 354 + Expected: `OK: [ 'createServer' ]` (or similar) 355 + 356 + **Step 5: Commit** 357 + 358 + ```bash 359 + git add packages/node/ 360 + git commit -m "refactor(node): move source to src/ folder" 361 + ``` 362 + 363 + --- 364 + 365 + ## Task 7: Restructure deno Package 366 + 367 + **Files:** 368 + - Create: `packages/deno/src/` 369 + - Move: `packages/deno/index.js` → `packages/deno/src/index.js` 370 + - Modify: `packages/deno/package.json` 371 + - Modify: `packages/deno/deno.json` 372 + 373 + **Step 1: Create src directory and move source file** 374 + 375 + ```bash 376 + mkdir -p packages/deno/src 377 + mv packages/deno/index.js packages/deno/src/ 378 + ``` 379 + 380 + **Step 2: Update package.json** 381 + 382 + Replace `packages/deno/package.json` with: 383 + 384 + ```json 385 + { 386 + "name": "@pds/deno", 387 + "version": "0.1.0", 388 + "type": "module", 389 + "main": "./src/index.js", 390 + "types": "./src/index.d.ts", 391 + "exports": { 392 + ".": "./src/index.js" 393 + } 394 + } 395 + ``` 396 + 397 + **Step 3: Update deno.json exports** 398 + 399 + Update `packages/deno/deno.json` to point to src: 400 + 401 + ```json 402 + { 403 + "name": "@pds/deno", 404 + "version": "0.1.0", 405 + "exports": "./src/index.js", 406 + "imports": { 407 + "@pds/core": "../core/src/index.js", 408 + "@pds/core/pds": "../core/src/pds.js", 409 + "@pds/storage-sqlite": "../storage-sqlite/src/index.js", 410 + "@pds/storage-sqlite/driver": "../storage-sqlite/src/driver.js", 411 + "@pds/blobs-deno": "../blobs-deno/src/index.js" 412 + } 413 + } 414 + ``` 415 + 416 + **Step 4: Create empty test directory** 417 + 418 + ```bash 419 + mkdir -p packages/deno/test 420 + ``` 421 + 422 + **Step 5: Commit** 423 + 424 + ```bash 425 + git add packages/deno/ 426 + git commit -m "refactor(deno): move source to src/ folder" 427 + ``` 428 + 429 + --- 430 + 431 + ## Task 8: Restructure cloudflare Package 432 + 433 + **Files:** 434 + - Create: `packages/cloudflare/src/` 435 + - Move: `packages/cloudflare/index.js` → `packages/cloudflare/src/index.js` 436 + - Modify: `packages/cloudflare/package.json` 437 + 438 + **Step 1: Create src directory and move source file** 439 + 440 + ```bash 441 + mkdir -p packages/cloudflare/src 442 + mv packages/cloudflare/index.js packages/cloudflare/src/ 443 + ``` 444 + 445 + **Step 2: Update package.json** 446 + 447 + Replace `packages/cloudflare/package.json` with: 448 + 449 + ```json 450 + { 451 + "name": "@pds/cloudflare", 452 + "version": "0.1.0", 453 + "type": "module", 454 + "main": "./src/index.js", 455 + "types": "./src/index.d.ts", 456 + "exports": { 457 + ".": "./src/index.js" 458 + }, 459 + "dependencies": { 460 + "@pds/core": "workspace:*" 461 + } 462 + } 463 + ``` 464 + 465 + **Step 3: Create empty test directory** 466 + 467 + ```bash 468 + mkdir -p packages/cloudflare/test 469 + ``` 470 + 471 + **Step 4: Verify package resolves** 472 + 473 + ```bash 474 + node -e "import('@pds/cloudflare').then(m => console.log('OK:', Object.keys(m)))" 475 + ``` 476 + 477 + Expected: `OK: [ 'createCloudflareHandler', 'PDSDurableObject' ]` (or similar) 478 + 479 + **Step 5: Commit** 480 + 481 + ```bash 482 + git add packages/cloudflare/ 483 + git commit -m "refactor(cloudflare): move source to src/ folder" 484 + ``` 485 + 486 + --- 487 + 488 + ## Task 9: Update Vitest Configuration 489 + 490 + **Files:** 491 + - Modify: `vitest.config.js` 492 + 493 + **Step 1: Update vitest.config.js** 494 + 495 + Replace `vitest.config.js` with: 496 + 497 + ```javascript 498 + import { defineConfig } from 'vitest/config'; 499 + 500 + export default defineConfig({ 501 + test: { 502 + include: [ 503 + 'packages/*/test/**/*.test.js', 504 + 'test/**/*.test.js' 505 + ], 506 + testTimeout: 30000, 507 + hookTimeout: 60000, 508 + coverage: { 509 + provider: 'v8', 510 + reporter: ['text', 'html'], 511 + include: ['packages/*/src/**/*.js'], 512 + }, 513 + }, 514 + }); 515 + ``` 516 + 517 + **Step 2: Verify test discovery still works** 518 + 519 + ```bash 520 + npm test 521 + ``` 522 + 523 + Expected: Existing tests in `test/` still run and pass. 524 + 525 + **Step 3: Commit** 526 + 527 + ```bash 528 + git add vitest.config.js 529 + git commit -m "build: update vitest config for new package structure" 530 + ``` 531 + 532 + --- 533 + 534 + ## Task 10: Update TypeScript Build Configuration 535 + 536 + **Files:** 537 + - Modify: `tsconfig.build.json` 538 + 539 + **Step 1: Check current tsconfig.build.json** 540 + 541 + Read the file to understand current include patterns. 542 + 543 + **Step 2: Update include patterns for src/ folders** 544 + 545 + Update `tsconfig.build.json` to generate `.d.ts` files in `src/` directories: 546 + 547 + ```json 548 + { 549 + "extends": "./tsconfig.json", 550 + "compilerOptions": { 551 + "declaration": true, 552 + "emitDeclarationOnly": true 553 + }, 554 + "include": ["packages/*/src/**/*.js"] 555 + } 556 + ``` 557 + 558 + **Step 3: Regenerate type declarations** 559 + 560 + ```bash 561 + rm -f packages/*/*.d.ts packages/*/src/*.d.ts 562 + npm run typecheck 563 + ``` 564 + 565 + Expected: New `.d.ts` files appear in `packages/*/src/` 566 + 567 + **Step 4: Commit** 568 + 569 + ```bash 570 + git add tsconfig.build.json packages/*/src/*.d.ts 571 + git commit -m "build: update tsconfig for src/ structure, regenerate types" 572 + ``` 573 + 574 + --- 575 + 576 + ## Task 11: Update Examples to Use New Paths 577 + 578 + **Files:** 579 + - Check: `examples/node/` 580 + - Check: `examples/deno/` 581 + - Check: `examples/cloudflare/` 582 + 583 + **Step 1: Verify examples still work** 584 + 585 + Since examples import via package names (`@pds/node`), they should work without changes. Verify: 586 + 587 + ```bash 588 + cd examples/node && node -e "import('@pds/node').then(m => console.log('OK'))" 589 + ``` 590 + 591 + Expected: `OK` 592 + 593 + **Step 2: Run E2E tests to verify everything works** 594 + 595 + ```bash 596 + npm run test:e2e:node 597 + ``` 598 + 599 + Expected: All E2E tests pass. 600 + 601 + **Step 3: Commit any fixes if needed** 602 + 603 + ```bash 604 + git add -A 605 + git commit -m "fix: update examples for new package structure" --allow-empty 606 + ``` 607 + 608 + --- 609 + 610 + ## Task 12: Clean Up Old Declaration Files 611 + 612 + **Files:** 613 + - Remove: `packages/*/*.d.ts` (old location) 614 + 615 + **Step 1: Remove old .d.ts files from package roots** 616 + 617 + ```bash 618 + rm -f packages/blobs-fs/*.d.ts 619 + rm -f packages/blobs-s3/*.d.ts 620 + rm -f packages/blobs-deno/*.d.ts 621 + rm -f packages/storage-sqlite/*.d.ts 622 + rm -f packages/core/*.d.ts 623 + rm -f packages/node/*.d.ts 624 + rm -f packages/deno/*.d.ts 625 + rm -f packages/cloudflare/*.d.ts 626 + ``` 627 + 628 + **Step 2: Verify no stray files remain** 629 + 630 + ```bash 631 + ls packages/*/*.d.ts 2>/dev/null || echo "Clean - no old .d.ts files" 632 + ``` 633 + 634 + Expected: "Clean - no old .d.ts files" 635 + 636 + **Step 3: Commit cleanup** 637 + 638 + ```bash 639 + git add -A 640 + git commit -m "chore: remove old .d.ts files from package roots" 641 + ``` 642 + 643 + --- 644 + 645 + ## Task 13: Final Verification 646 + 647 + **Step 1: Run full test suite** 648 + 649 + ```bash 650 + npm test 651 + npm run test:e2e:node 652 + ``` 653 + 654 + Expected: All tests pass. 655 + 656 + **Step 2: Run type check** 657 + 658 + ```bash 659 + npm run typecheck 660 + ``` 661 + 662 + Expected: No errors. 663 + 664 + **Step 3: Run lint** 665 + 666 + ```bash 667 + npm run check 668 + ``` 669 + 670 + Expected: No errors. 671 + 672 + **Step 4: Final commit if any stragglers** 673 + 674 + ```bash 675 + git status 676 + git add -A 677 + git commit -m "refactor: complete package restructure to src/test layout" --allow-empty 678 + ``` 679 + 680 + --- 681 + 682 + ## Summary 683 + 684 + | Task | Description | Commit Message | 685 + |------|-------------|----------------| 686 + | 1 | Restructure blobs-fs | `refactor(blobs-fs): move source to src/ folder` | 687 + | 2 | Restructure blobs-s3 | `refactor(blobs-s3): move source to src/ folder` | 688 + | 3 | Restructure blobs-deno | `refactor(blobs-deno): move source to src/ folder` | 689 + | 4 | Restructure storage-sqlite | `refactor(storage-sqlite): move source to src/ folder` | 690 + | 5 | Restructure core | `refactor(core): move source to src/ folder` | 691 + | 6 | Restructure node | `refactor(node): move source to src/ folder` | 692 + | 7 | Restructure deno | `refactor(deno): move source to src/ folder` | 693 + | 8 | Restructure cloudflare | `refactor(cloudflare): move source to src/ folder` | 694 + | 9 | Update vitest config | `build: update vitest config for new package structure` | 695 + | 10 | Update tsconfig | `build: update tsconfig for src/ structure, regenerate types` | 696 + | 11 | Verify examples | `fix: update examples for new package structure` | 697 + | 12 | Clean up old .d.ts | `chore: remove old .d.ts files from package roots` | 698 + | 13 | Final verification | `refactor: complete package restructure to src/test layout` |
+1 -1
examples/cloudflare/wrangler.toml
··· 1 1 name = "atproto-pds" 2 - main = "node_modules/@pds/cloudflare/index.js" 2 + main = "node_modules/@pds/cloudflare/src/index.js" 3 3 compatibility_date = "2024-01-01" 4 4 5 5 [[durable_objects.bindings]]
+1 -1
packages/blobs-deno/deno.json
··· 1 1 { 2 2 "name": "@pds/blobs-deno", 3 3 "version": "0.7.0", 4 - "exports": "./index.js" 4 + "exports": "./src/index.js" 5 5 }
packages/blobs-deno/index.js packages/blobs-deno/src/index.js
+3 -1
packages/blobs-deno/package.json
··· 2 2 "name": "@pds/blobs-deno", 3 3 "version": "0.7.0", 4 4 "type": "module", 5 + "main": "./src/index.js", 6 + "types": "./src/index.d.ts", 5 7 "exports": { 6 - ".": "./index.js" 8 + ".": "./src/index.js" 7 9 }, 8 10 "devDependencies": { 9 11 "@types/deno": "^2.5.0"
packages/blobs-fs/index.js packages/blobs-fs/src/index.js
+5 -2
packages/blobs-fs/package.json
··· 2 2 "name": "@pds/blobs-fs", 3 3 "version": "0.1.0", 4 4 "type": "module", 5 - "main": "index.js", 6 - "types": "index.d.ts" 5 + "main": "./src/index.js", 6 + "types": "./src/index.d.ts", 7 + "exports": { 8 + ".": "./src/index.js" 9 + } 7 10 }
packages/blobs-s3/index.js packages/blobs-s3/src/index.js
+5 -2
packages/blobs-s3/package.json
··· 2 2 "name": "@pds/blobs-s3", 3 3 "version": "0.1.0", 4 4 "type": "module", 5 - "main": "index.js", 6 - "types": "index.d.ts" 5 + "main": "./src/index.js", 6 + "types": "./src/index.d.ts", 7 + "exports": { 8 + ".": "./src/index.js" 9 + } 7 10 }
+1 -1
packages/cloudflare/index.js packages/cloudflare/src/index.js
··· 455 455 /** 456 456 * Create WebSocket port for Cloudflare Workers with Durable Object hibernation support 457 457 * @param {DurableObjectState} state - Durable Object state for hibernation API 458 - * @returns {import('@pds/core/ports.js').WebSocketPort} 458 + * @returns {import('@pds/core/ports').WebSocketPort} 459 459 */ 460 460 function createWebSocket(state) { 461 461 return {
+5 -2
packages/cloudflare/package.json
··· 2 2 "name": "@pds/cloudflare", 3 3 "version": "0.1.0", 4 4 "type": "module", 5 - "main": "index.js", 6 - "types": "index.d.ts", 5 + "main": "./src/index.js", 6 + "types": "./src/index.d.ts", 7 + "exports": { 8 + ".": "./src/index.js" 9 + }, 7 10 "dependencies": { 8 11 "@pds/core": "workspace:*" 9 12 }
packages/core/auth.js packages/core/src/auth.js
packages/core/crypto.js packages/core/src/crypto.js
packages/core/index.js packages/core/src/index.js
packages/core/mst.js packages/core/src/mst.js
packages/core/oauth.js packages/core/src/oauth.js
+10 -10
packages/core/package.json
··· 2 2 "name": "@pds/core", 3 3 "version": "0.1.0", 4 4 "type": "module", 5 - "main": "index.js", 6 - "types": "index.d.ts", 5 + "main": "./src/index.js", 6 + "types": "./src/index.d.ts", 7 7 "exports": { 8 - ".": "./index.js", 9 - "./repo": "./repo.js", 10 - "./auth": "./auth.js", 11 - "./crypto": "./crypto.js", 12 - "./ports": "./ports.js", 13 - "./pds": "./pds.js", 14 - "./scope": "./scope.js", 15 - "./oauth": "./oauth.js" 8 + ".": "./src/index.js", 9 + "./repo": "./src/repo.js", 10 + "./auth": "./src/auth.js", 11 + "./crypto": "./src/crypto.js", 12 + "./ports": "./src/ports.js", 13 + "./pds": "./src/pds.js", 14 + "./scope": "./src/scope.js", 15 + "./oauth": "./src/oauth.js" 16 16 } 17 17 }
packages/core/pds.js packages/core/src/pds.js
packages/core/ports.js packages/core/src/ports.js
packages/core/repo.js packages/core/src/repo.js
packages/core/scope.js packages/core/src/scope.js
+168
packages/core/test/auth.test.js
··· 1 + import { describe, expect, test } from 'vitest'; 2 + import { 3 + createAccessJwt, 4 + createRefreshJwt, 5 + verifyAccessJwt, 6 + verifyRefreshJwt, 7 + } from '@pds/core/auth'; 8 + import { base64UrlDecode } from '@pds/core/crypto'; 9 + 10 + describe('JWT Creation', () => { 11 + test('createAccessJwt creates valid JWT structure', async () => { 12 + const did = 'did:web:test.example'; 13 + const secret = 'test-secret-key'; 14 + const jwt = await createAccessJwt(did, secret); 15 + 16 + const parts = jwt.split('.'); 17 + expect(parts.length).toBe(3); 18 + 19 + // Decode header 20 + const header = JSON.parse( 21 + new TextDecoder().decode(base64UrlDecode(parts[0])), 22 + ); 23 + expect(header.typ).toBe('at+jwt'); 24 + expect(header.alg).toBe('HS256'); 25 + 26 + // Decode payload 27 + const payload = JSON.parse( 28 + new TextDecoder().decode(base64UrlDecode(parts[1])), 29 + ); 30 + expect(payload.scope).toBe('com.atproto.access'); 31 + expect(payload.sub).toBe(did); 32 + expect(payload.aud).toBe(did); 33 + expect(payload.iat > 0).toBe(true); 34 + expect(payload.exp > payload.iat).toBe(true); 35 + }); 36 + 37 + test('createRefreshJwt creates valid JWT with jti', async () => { 38 + const did = 'did:web:test.example'; 39 + const secret = 'test-secret-key'; 40 + const jwt = await createRefreshJwt(did, secret); 41 + 42 + const parts = jwt.split('.'); 43 + const header = JSON.parse( 44 + new TextDecoder().decode(base64UrlDecode(parts[0])), 45 + ); 46 + expect(header.typ).toBe('refresh+jwt'); 47 + 48 + const payload = JSON.parse( 49 + new TextDecoder().decode(base64UrlDecode(parts[1])), 50 + ); 51 + expect(payload.scope).toBe('com.atproto.refresh'); 52 + expect(payload.jti).toBeTruthy(); // has unique token ID 53 + }); 54 + }); 55 + 56 + describe('JWT Verification', () => { 57 + test('verifyAccessJwt returns payload for valid token', async () => { 58 + const did = 'did:web:test.example'; 59 + const secret = 'test-secret-key'; 60 + const jwt = await createAccessJwt(did, secret); 61 + 62 + const payload = await verifyAccessJwt(jwt, secret); 63 + expect(payload.sub).toBe(did); 64 + expect(payload.scope).toBe('com.atproto.access'); 65 + }); 66 + 67 + test('verifyAccessJwt throws for wrong secret', async () => { 68 + const did = 'did:web:test.example'; 69 + const jwt = await createAccessJwt(did, 'correct-secret'); 70 + 71 + await expect(() => verifyAccessJwt(jwt, 'wrong-secret')).rejects.toThrow( 72 + /invalid signature/i, 73 + ); 74 + }); 75 + 76 + test('verifyAccessJwt throws for expired token', async () => { 77 + const did = 'did:web:test.example'; 78 + const secret = 'test-secret-key'; 79 + // Create token that expired 1 second ago 80 + const jwt = await createAccessJwt(did, secret, -1); 81 + 82 + await expect(() => verifyAccessJwt(jwt, secret)).rejects.toThrow( 83 + /expired/i, 84 + ); 85 + }); 86 + 87 + test('verifyAccessJwt throws for refresh token', async () => { 88 + const did = 'did:web:test.example'; 89 + const secret = 'test-secret-key'; 90 + const jwt = await createRefreshJwt(did, secret); 91 + 92 + await expect(() => verifyAccessJwt(jwt, secret)).rejects.toThrow( 93 + /invalid token type/i, 94 + ); 95 + }); 96 + 97 + test('verifyRefreshJwt returns payload for valid token', async () => { 98 + const did = 'did:web:test.example'; 99 + const secret = 'test-secret-key'; 100 + const jwt = await createRefreshJwt(did, secret); 101 + 102 + const payload = await verifyRefreshJwt(jwt, secret); 103 + expect(payload.sub).toBe(did); 104 + expect(payload.scope).toBe('com.atproto.refresh'); 105 + expect(payload.jti).toBeTruthy(); // has token ID 106 + }); 107 + 108 + test('verifyRefreshJwt throws for wrong secret', async () => { 109 + const did = 'did:web:test.example'; 110 + const jwt = await createRefreshJwt(did, 'correct-secret'); 111 + 112 + await expect(() => verifyRefreshJwt(jwt, 'wrong-secret')).rejects.toThrow( 113 + /invalid signature/i, 114 + ); 115 + }); 116 + 117 + test('verifyRefreshJwt throws for expired token', async () => { 118 + const did = 'did:web:test.example'; 119 + const secret = 'test-secret-key'; 120 + // Create token that expired 1 second ago 121 + const jwt = await createRefreshJwt(did, secret, -1); 122 + 123 + await expect(() => verifyRefreshJwt(jwt, secret)).rejects.toThrow( 124 + /expired/i, 125 + ); 126 + }); 127 + 128 + test('verifyRefreshJwt throws for access token', async () => { 129 + const did = 'did:web:test.example'; 130 + const secret = 'test-secret-key'; 131 + const jwt = await createAccessJwt(did, secret); 132 + 133 + await expect(() => verifyRefreshJwt(jwt, secret)).rejects.toThrow( 134 + /invalid token type/i, 135 + ); 136 + }); 137 + 138 + test('verifyAccessJwt throws for malformed JWT', async () => { 139 + const secret = 'test-secret-key'; 140 + 141 + // Not a JWT at all 142 + await expect(() => verifyAccessJwt('not-a-jwt', secret)).rejects.toThrow( 143 + /Invalid JWT format/i, 144 + ); 145 + 146 + // Only two parts 147 + await expect(() => verifyAccessJwt('two.parts', secret)).rejects.toThrow( 148 + /Invalid JWT format/i, 149 + ); 150 + 151 + // Four parts 152 + await expect(() => 153 + verifyAccessJwt('one.two.three.four', secret), 154 + ).rejects.toThrow(/Invalid JWT format/i); 155 + }); 156 + 157 + test('verifyRefreshJwt throws for malformed JWT', async () => { 158 + const secret = 'test-secret-key'; 159 + 160 + await expect(() => verifyRefreshJwt('not-a-jwt', secret)).rejects.toThrow( 161 + /Invalid JWT format/i, 162 + ); 163 + 164 + await expect(() => verifyRefreshJwt('two.parts', secret)).rejects.toThrow( 165 + /Invalid JWT format/i, 166 + ); 167 + }); 168 + });
+170
packages/core/test/crypto.test.js
··· 1 + import { describe, expect, test } from 'vitest'; 2 + import { 3 + base32Decode, 4 + base32Encode, 5 + base64UrlDecode, 6 + base64UrlEncode, 7 + bytesToHex, 8 + computeJwkThumbprint, 9 + generateKeyPair, 10 + hexToBytes, 11 + importPrivateKey, 12 + sign, 13 + } from '@pds/core/crypto'; 14 + 15 + describe('Base32 Encoding', () => { 16 + test('encodes bytes to base32lower', () => { 17 + const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]); 18 + const encoded = base32Encode(bytes); 19 + expect(typeof encoded).toBe('string'); 20 + expect(encoded).toMatch(/^[a-z2-7]+$/); 21 + }); 22 + 23 + test('base32 encode/decode roundtrip', () => { 24 + const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]); 25 + const encoded = base32Encode(original); 26 + const decoded = base32Decode(encoded); 27 + expect(decoded).toEqual(original); 28 + }); 29 + }); 30 + 31 + describe('P-256 Signing', () => { 32 + test('generates key pair with correct sizes', async () => { 33 + const kp = await generateKeyPair(); 34 + 35 + expect(kp.privateKey.length).toBe(32); 36 + expect(kp.publicKey.length).toBe(33); // compressed 37 + expect(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03).toBe(true); 38 + }); 39 + 40 + test('can sign data with generated key', async () => { 41 + const kp = await generateKeyPair(); 42 + const key = await importPrivateKey(kp.privateKey); 43 + const data = new TextEncoder().encode('test message'); 44 + const sig = await sign(key, data); 45 + 46 + expect(sig.length).toBe(64); // r (32) + s (32) 47 + }); 48 + 49 + test('different messages produce different signatures', async () => { 50 + const kp = await generateKeyPair(); 51 + const key = await importPrivateKey(kp.privateKey); 52 + 53 + const sig1 = await sign(key, new TextEncoder().encode('message 1')); 54 + const sig2 = await sign(key, new TextEncoder().encode('message 2')); 55 + 56 + expect(sig1).not.toEqual(sig2); 57 + }); 58 + 59 + test('bytesToHex and hexToBytes roundtrip', () => { 60 + const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]); 61 + const hex = bytesToHex(original); 62 + const back = hexToBytes(hex); 63 + 64 + expect(hex).toBe('000ff0ffabcd'); 65 + expect(back).toEqual(original); 66 + }); 67 + 68 + test('importPrivateKey rejects invalid key lengths', async () => { 69 + // Too short 70 + await expect(() => importPrivateKey(new Uint8Array(31))).rejects.toThrow( 71 + /expected 32 bytes, got 31/, 72 + ); 73 + 74 + // Too long 75 + await expect(() => importPrivateKey(new Uint8Array(33))).rejects.toThrow( 76 + /expected 32 bytes, got 33/, 77 + ); 78 + 79 + // Empty 80 + await expect(() => importPrivateKey(new Uint8Array(0))).rejects.toThrow( 81 + /expected 32 bytes, got 0/, 82 + ); 83 + }); 84 + 85 + test('importPrivateKey rejects non-Uint8Array input', async () => { 86 + // Arrays have .length but aren't Uint8Array 87 + // @ts-expect-error - Testing runtime validation with invalid type 88 + await expect(() => importPrivateKey([1, 2, 3])).rejects.toThrow( 89 + /Invalid private key/, 90 + ); 91 + 92 + // Strings don't work either 93 + // @ts-expect-error - Testing runtime validation with invalid type 94 + await expect(() => importPrivateKey('not bytes')).rejects.toThrow( 95 + /Invalid private key/, 96 + ); 97 + 98 + // null/undefined 99 + // @ts-expect-error - Testing runtime validation with invalid type 100 + await expect(() => importPrivateKey(null)).rejects.toThrow( 101 + /Invalid private key/, 102 + ); 103 + }); 104 + }); 105 + 106 + describe('JWT Base64URL', () => { 107 + test('base64UrlEncode encodes bytes correctly', () => { 108 + const input = new TextEncoder().encode('hello world'); 109 + const encoded = base64UrlEncode(input); 110 + expect(encoded).toBe('aGVsbG8gd29ybGQ'); 111 + expect(encoded.includes('+')).toBe(false); 112 + expect(encoded.includes('/')).toBe(false); 113 + expect(encoded.includes('=')).toBe(false); 114 + }); 115 + 116 + test('base64UrlDecode decodes string correctly', () => { 117 + const decoded = base64UrlDecode('aGVsbG8gd29ybGQ'); 118 + const str = new TextDecoder().decode(decoded); 119 + expect(str).toBe('hello world'); 120 + }); 121 + 122 + test('base64url roundtrip', () => { 123 + const original = new Uint8Array([0, 1, 2, 255, 254, 253]); 124 + const encoded = base64UrlEncode(original); 125 + const decoded = base64UrlDecode(encoded); 126 + expect(decoded).toEqual(original); 127 + }); 128 + }); 129 + 130 + describe('JWK Thumbprint', () => { 131 + test('computes deterministic thumbprint for EC key', async () => { 132 + // Test vector: known JWK and its expected thumbprint 133 + const jwk = { 134 + kty: 'EC', 135 + crv: 'P-256', 136 + x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', 137 + y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ', 138 + }; 139 + 140 + const jkt1 = await computeJwkThumbprint(jwk); 141 + const jkt2 = await computeJwkThumbprint(jwk); 142 + 143 + // Thumbprint must be deterministic 144 + expect(jkt1).toBe(jkt2); 145 + // Must be base64url-encoded SHA-256 (43 chars) 146 + expect(jkt1.length).toBe(43); 147 + // Must only contain base64url characters 148 + expect(jkt1).toMatch(/^[A-Za-z0-9_-]+$/); 149 + }); 150 + 151 + test('produces different thumbprints for different keys', async () => { 152 + const jwk1 = { 153 + kty: 'EC', 154 + crv: 'P-256', 155 + x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', 156 + y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ', 157 + }; 158 + const jwk2 = { 159 + kty: 'EC', 160 + crv: 'P-256', 161 + x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', 162 + y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', 163 + }; 164 + 165 + const jkt1 = await computeJwkThumbprint(jwk1); 166 + const jkt2 = await computeJwkThumbprint(jwk2); 167 + 168 + expect(jkt1).not.toBe(jkt2); 169 + }); 170 + });
+39
packages/core/test/mst.test.js
··· 1 + import { describe, expect, test } from 'vitest'; 2 + import { getKeyDepth } from '@pds/core/repo'; 3 + 4 + describe('MST Key Depth', () => { 5 + test('returns a non-negative integer', async () => { 6 + const depth = await getKeyDepth('app.bsky.feed.post/abc123'); 7 + expect(typeof depth).toBe('number'); 8 + expect(depth >= 0).toBe(true); 9 + }); 10 + 11 + test('is deterministic for same key', async () => { 12 + const key = 'app.bsky.feed.post/test123'; 13 + const depth1 = await getKeyDepth(key); 14 + const depth2 = await getKeyDepth(key); 15 + expect(depth1).toBe(depth2); 16 + }); 17 + 18 + test('different keys can have different depths', async () => { 19 + // Generate many keys and check we get some variation 20 + const depths = new Set(); 21 + for (let i = 0; i < 100; i++) { 22 + depths.add(await getKeyDepth(`collection/key${i}`)); 23 + } 24 + // Should have at least 1 unique depth (realistically more) 25 + expect(depths.size >= 1).toBe(true); 26 + }); 27 + 28 + test('handles empty string', async () => { 29 + const depth = await getKeyDepth(''); 30 + expect(typeof depth).toBe('number'); 31 + expect(depth >= 0).toBe(true); 32 + }); 33 + 34 + test('handles unicode strings', async () => { 35 + const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉'); 36 + expect(typeof depth).toBe('number'); 37 + expect(depth >= 0).toBe(true); 38 + }); 39 + });
+33
packages/core/test/oauth.test.js
··· 1 + import { describe, expect, test } from 'vitest'; 2 + import { 3 + getLoopbackClientMetadata, 4 + isLoopbackClient, 5 + validateClientMetadata, 6 + } from '@pds/core/oauth'; 7 + 8 + describe('Client Metadata', () => { 9 + test('isLoopbackClient detects localhost', () => { 10 + expect(isLoopbackClient('http://localhost:8080')).toBe(true); 11 + expect(isLoopbackClient('http://127.0.0.1:3000')).toBe(true); 12 + expect(isLoopbackClient('https://example.com')).toBe(false); 13 + }); 14 + 15 + test('getLoopbackClientMetadata returns permissive defaults', () => { 16 + const metadata = getLoopbackClientMetadata('http://localhost:8080'); 17 + expect(metadata.client_id).toBe('http://localhost:8080'); 18 + expect(metadata.grant_types.includes('authorization_code')).toBe(true); 19 + expect(metadata.dpop_bound_access_tokens).toBe(true); 20 + }); 21 + 22 + test('validateClientMetadata rejects mismatched client_id', () => { 23 + const metadata = { 24 + client_id: 'https://other.com/metadata.json', 25 + redirect_uris: ['https://example.com/callback'], 26 + grant_types: ['authorization_code'], 27 + response_types: ['code'], 28 + }; 29 + expect(() => 30 + validateClientMetadata(metadata, 'https://example.com/metadata.json'), 31 + ).toThrow(/client_id mismatch/); 32 + }); 33 + });
+80
packages/core/test/pds.test.js
··· 1 + import { describe, expect, test } from 'vitest'; 2 + import { 3 + getKnownServiceUrl, 4 + parseAtprotoProxyHeader, 5 + } from '@pds/core/pds'; 6 + 7 + // Internal constant - not exported from pds.js due to Cloudflare Workers limitation 8 + const BSKY_APPVIEW_URL = 'https://api.bsky.app'; 9 + 10 + describe('Proxy Utilities', () => { 11 + describe('parseAtprotoProxyHeader', () => { 12 + test('parses valid header', () => { 13 + const result = parseAtprotoProxyHeader( 14 + 'did:web:api.bsky.app#bsky_appview', 15 + ); 16 + expect(result).toEqual({ 17 + did: 'did:web:api.bsky.app', 18 + serviceId: 'bsky_appview', 19 + }); 20 + }); 21 + 22 + test('parses header with did:plc', () => { 23 + const result = parseAtprotoProxyHeader( 24 + 'did:plc:z72i7hdynmk6r22z27h6tvur#atproto_labeler', 25 + ); 26 + expect(result).toEqual({ 27 + did: 'did:plc:z72i7hdynmk6r22z27h6tvur', 28 + serviceId: 'atproto_labeler', 29 + }); 30 + }); 31 + 32 + test('returns null for null/undefined', () => { 33 + // @ts-expect-error - Testing runtime handling of null 34 + expect(parseAtprotoProxyHeader(null)).toBe(null); 35 + // @ts-expect-error - Testing runtime handling of undefined 36 + expect(parseAtprotoProxyHeader(undefined)).toBe(null); 37 + expect(parseAtprotoProxyHeader('')).toBe(null); 38 + }); 39 + 40 + test('returns null for header without fragment', () => { 41 + expect(parseAtprotoProxyHeader('did:web:api.bsky.app')).toBe(null); 42 + }); 43 + 44 + test('returns null for header with only fragment', () => { 45 + expect(parseAtprotoProxyHeader('#bsky_appview')).toBe(null); 46 + }); 47 + 48 + test('returns null for header with trailing fragment', () => { 49 + expect(parseAtprotoProxyHeader('did:web:api.bsky.app#')).toBe(null); 50 + }); 51 + }); 52 + 53 + describe('getKnownServiceUrl', () => { 54 + test('returns URL for known Bluesky AppView', () => { 55 + const result = getKnownServiceUrl('did:web:api.bsky.app', 'bsky_appview'); 56 + expect(result).toBe(BSKY_APPVIEW_URL); 57 + }); 58 + 59 + test('returns null for unknown service DID', () => { 60 + const result = getKnownServiceUrl( 61 + 'did:web:unknown.service', 62 + 'bsky_appview', 63 + ); 64 + expect(result).toBe(null); 65 + }); 66 + 67 + test('returns null for unknown service ID', () => { 68 + const result = getKnownServiceUrl( 69 + 'did:web:api.bsky.app', 70 + 'unknown_service', 71 + ); 72 + expect(result).toBe(null); 73 + }); 74 + 75 + test('returns null for both unknown', () => { 76 + const result = getKnownServiceUrl('did:web:unknown', 'unknown'); 77 + expect(result).toBe(null); 78 + }); 79 + }); 80 + });
+421
packages/core/test/repo.test.js
··· 1 + import { describe, expect, test } from 'vitest'; 2 + import { 3 + buildCarFile, 4 + cborDecode, 5 + cborEncode, 6 + cidToString, 7 + createBlobCid, 8 + createCid, 9 + createTid, 10 + findBlobRefs, 11 + sniffMimeType, 12 + varint, 13 + } from '@pds/core/repo'; 14 + 15 + describe('CBOR Encoding', () => { 16 + test('encodes simple map', () => { 17 + const encoded = cborEncode({ hello: 'world', num: 42 }); 18 + // Expected: a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a 19 + const expected = new Uint8Array([ 20 + 0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c, 21 + 0x64, 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a, 22 + ]); 23 + expect(encoded).toEqual(expected); 24 + }); 25 + 26 + test('encodes null', () => { 27 + const encoded = cborEncode(null); 28 + expect(encoded).toEqual(new Uint8Array([0xf6])); 29 + }); 30 + 31 + test('encodes booleans', () => { 32 + expect(cborEncode(true)).toEqual(new Uint8Array([0xf5])); 33 + expect(cborEncode(false)).toEqual(new Uint8Array([0xf4])); 34 + }); 35 + 36 + test('encodes small integers', () => { 37 + expect(cborEncode(0)).toEqual(new Uint8Array([0x00])); 38 + expect(cborEncode(1)).toEqual(new Uint8Array([0x01])); 39 + expect(cborEncode(23)).toEqual(new Uint8Array([0x17])); 40 + }); 41 + 42 + test('encodes integers >= 24', () => { 43 + expect(cborEncode(24)).toEqual(new Uint8Array([0x18, 0x18])); 44 + expect(cborEncode(255)).toEqual(new Uint8Array([0x18, 0xff])); 45 + }); 46 + 47 + test('encodes negative integers', () => { 48 + expect(cborEncode(-1)).toEqual(new Uint8Array([0x20])); 49 + expect(cborEncode(-10)).toEqual(new Uint8Array([0x29])); 50 + }); 51 + 52 + test('encodes strings', () => { 53 + const encoded = cborEncode('hello'); 54 + // 0x65 = text string of length 5 55 + expect(encoded).toEqual( 56 + new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f]), 57 + ); 58 + }); 59 + 60 + test('encodes byte strings', () => { 61 + const bytes = new Uint8Array([1, 2, 3]); 62 + const encoded = cborEncode(bytes); 63 + // 0x43 = byte string of length 3 64 + expect(encoded).toEqual(new Uint8Array([0x43, 1, 2, 3])); 65 + }); 66 + 67 + test('encodes arrays', () => { 68 + const encoded = cborEncode([1, 2, 3]); 69 + // 0x83 = array of length 3 70 + expect(encoded).toEqual(new Uint8Array([0x83, 0x01, 0x02, 0x03])); 71 + }); 72 + 73 + test('sorts map keys deterministically', () => { 74 + const encoded1 = cborEncode({ z: 1, a: 2 }); 75 + const encoded2 = cborEncode({ a: 2, z: 1 }); 76 + expect(encoded1).toEqual(encoded2); 77 + // First key should be 'a' (0x61) 78 + expect(encoded1[1]).toBe(0x61); 79 + }); 80 + 81 + test('encodes large integers >= 2^31 without overflow', () => { 82 + // 2^31 would overflow with bitshift operators (treated as signed 32-bit) 83 + const twoTo31 = 2147483648; 84 + const encoded = cborEncode(twoTo31); 85 + const decoded = cborDecode(encoded); 86 + expect(decoded).toBe(twoTo31); 87 + 88 + // 2^32 - 1 (max unsigned 32-bit) 89 + const maxU32 = 4294967295; 90 + const encoded2 = cborEncode(maxU32); 91 + const decoded2 = cborDecode(encoded2); 92 + expect(decoded2).toBe(maxU32); 93 + }); 94 + 95 + test('encodes 2^31 with correct byte format', () => { 96 + // 2147483648 = 0x80000000 97 + // CBOR: major type 0 (unsigned int), additional info 26 (4-byte follows) 98 + const encoded = cborEncode(2147483648); 99 + expect(encoded[0]).toBe(0x1a); // type 0 | info 26 100 + expect(encoded[1]).toBe(0x80); 101 + expect(encoded[2]).toBe(0x00); 102 + expect(encoded[3]).toBe(0x00); 103 + expect(encoded[4]).toBe(0x00); 104 + }); 105 + }); 106 + 107 + describe('CBOR Decoding', () => { 108 + test('decodes what encode produces (roundtrip)', () => { 109 + const original = { hello: 'world', num: 42 }; 110 + const encoded = cborEncode(original); 111 + const decoded = cborDecode(encoded); 112 + expect(decoded).toEqual(original); 113 + }); 114 + 115 + test('decodes null', () => { 116 + const encoded = cborEncode(null); 117 + const decoded = cborDecode(encoded); 118 + expect(decoded).toBe(null); 119 + }); 120 + 121 + test('decodes booleans', () => { 122 + expect(cborDecode(cborEncode(true))).toBe(true); 123 + expect(cborDecode(cborEncode(false))).toBe(false); 124 + }); 125 + 126 + test('decodes integers', () => { 127 + expect(cborDecode(cborEncode(0))).toBe(0); 128 + expect(cborDecode(cborEncode(42))).toBe(42); 129 + expect(cborDecode(cborEncode(255))).toBe(255); 130 + expect(cborDecode(cborEncode(-1))).toBe(-1); 131 + expect(cborDecode(cborEncode(-10))).toBe(-10); 132 + }); 133 + 134 + test('decodes strings', () => { 135 + expect(cborDecode(cborEncode('hello'))).toBe('hello'); 136 + expect(cborDecode(cborEncode(''))).toBe(''); 137 + }); 138 + 139 + test('decodes arrays', () => { 140 + expect(cborDecode(cborEncode([1, 2, 3]))).toEqual([1, 2, 3]); 141 + expect(cborDecode(cborEncode([]))).toEqual([]); 142 + }); 143 + 144 + test('decodes nested structures', () => { 145 + const original = { arr: [1, { nested: true }], str: 'test' }; 146 + const decoded = cborDecode(cborEncode(original)); 147 + expect(decoded).toEqual(original); 148 + }); 149 + }); 150 + 151 + describe('CID Generation', () => { 152 + test('createCid uses dag-cbor codec', async () => { 153 + const data = cborEncode({ test: 'data' }); 154 + const cid = await createCid(data); 155 + 156 + expect(cid.length).toBe(36); // 2 prefix + 2 multihash header + 32 hash 157 + expect(cid[0]).toBe(0x01); // CIDv1 158 + expect(cid[1]).toBe(0x71); // dag-cbor 159 + expect(cid[2]).toBe(0x12); // sha-256 160 + expect(cid[3]).toBe(0x20); // 32 bytes 161 + }); 162 + 163 + test('createBlobCid uses raw codec', async () => { 164 + const data = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); // JPEG magic bytes 165 + const cid = await createBlobCid(data); 166 + 167 + expect(cid.length).toBe(36); 168 + expect(cid[0]).toBe(0x01); // CIDv1 169 + expect(cid[1]).toBe(0x55); // raw codec 170 + expect(cid[2]).toBe(0x12); // sha-256 171 + expect(cid[3]).toBe(0x20); // 32 bytes 172 + }); 173 + 174 + test('same bytes produce different CIDs with different codecs', async () => { 175 + const data = new Uint8Array([1, 2, 3, 4]); 176 + const dagCborCid = cidToString(await createCid(data)); 177 + const rawCid = cidToString(await createBlobCid(data)); 178 + 179 + expect(dagCborCid).not.toBe(rawCid); 180 + }); 181 + 182 + test('cidToString returns base32lower with b prefix', async () => { 183 + const data = cborEncode({ test: 'data' }); 184 + const cid = await createCid(data); 185 + const cidStr = cidToString(cid); 186 + 187 + expect(cidStr[0]).toBe('b'); 188 + expect(cidStr).toMatch(/^b[a-z2-7]+$/); 189 + }); 190 + 191 + test('same input produces same CID', async () => { 192 + const data1 = cborEncode({ test: 'data' }); 193 + const data2 = cborEncode({ test: 'data' }); 194 + const cid1 = cidToString(await createCid(data1)); 195 + const cid2 = cidToString(await createCid(data2)); 196 + 197 + expect(cid1).toBe(cid2); 198 + }); 199 + 200 + test('different input produces different CID', async () => { 201 + const cid1 = cidToString(await createCid(cborEncode({ a: 1 }))); 202 + const cid2 = cidToString(await createCid(cborEncode({ a: 2 }))); 203 + 204 + expect(cid1).not.toBe(cid2); 205 + }); 206 + }); 207 + 208 + describe('TID Generation', () => { 209 + test('creates 13-character TIDs', () => { 210 + const tid = createTid(); 211 + expect(tid.length).toBe(13); 212 + }); 213 + 214 + test('uses valid base32-sort characters', () => { 215 + const tid = createTid(); 216 + expect(tid).toMatch(/^[234567abcdefghijklmnopqrstuvwxyz]+$/); 217 + }); 218 + 219 + test('generates monotonically increasing TIDs', () => { 220 + const tid1 = createTid(); 221 + const tid2 = createTid(); 222 + const tid3 = createTid(); 223 + 224 + expect(tid1 < tid2).toBe(true); 225 + expect(tid2 < tid3).toBe(true); 226 + }); 227 + 228 + test('generates unique TIDs', () => { 229 + const tids = new Set(); 230 + for (let i = 0; i < 100; i++) { 231 + tids.add(createTid()); 232 + } 233 + expect(tids.size).toBe(100); 234 + }); 235 + }); 236 + 237 + describe('CAR File Builder', () => { 238 + test('varint encodes small numbers', () => { 239 + expect(varint(0)).toEqual(new Uint8Array([0])); 240 + expect(varint(1)).toEqual(new Uint8Array([1])); 241 + expect(varint(127)).toEqual(new Uint8Array([127])); 242 + }); 243 + 244 + test('varint encodes multi-byte numbers', () => { 245 + // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01] 246 + expect(varint(128)).toEqual(new Uint8Array([0x80, 0x01])); 247 + // 300 = 0x12c -> [0xac, 0x02] 248 + expect(varint(300)).toEqual(new Uint8Array([0xac, 0x02])); 249 + }); 250 + 251 + test('buildCarFile produces valid structure', async () => { 252 + const data = cborEncode({ test: 'data' }); 253 + const cid = await createCid(data); 254 + const cidStr = cidToString(cid); 255 + 256 + const car = buildCarFile(cidStr, [{ cid: cidStr, data }]); 257 + 258 + expect(car instanceof Uint8Array).toBe(true); 259 + expect(car.length > 0).toBe(true); 260 + // First byte should be varint of header length 261 + expect(car[0] > 0).toBe(true); 262 + }); 263 + }); 264 + 265 + describe('MIME Type Sniffing', () => { 266 + test('detects JPEG', () => { 267 + const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); 268 + expect(sniffMimeType(bytes)).toBe('image/jpeg'); 269 + }); 270 + 271 + test('detects PNG', () => { 272 + const bytes = new Uint8Array([ 273 + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 274 + ]); 275 + expect(sniffMimeType(bytes)).toBe('image/png'); 276 + }); 277 + 278 + test('detects GIF', () => { 279 + const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); 280 + expect(sniffMimeType(bytes)).toBe('image/gif'); 281 + }); 282 + 283 + test('detects WebP', () => { 284 + const bytes = new Uint8Array([ 285 + 0x52, 286 + 0x49, 287 + 0x46, 288 + 0x46, // RIFF 289 + 0x00, 290 + 0x00, 291 + 0x00, 292 + 0x00, // size (ignored) 293 + 0x57, 294 + 0x45, 295 + 0x42, 296 + 0x50, // WEBP 297 + ]); 298 + expect(sniffMimeType(bytes)).toBe('image/webp'); 299 + }); 300 + 301 + test('detects MP4', () => { 302 + const bytes = new Uint8Array([ 303 + 0x00, 304 + 0x00, 305 + 0x00, 306 + 0x18, // size 307 + 0x66, 308 + 0x74, 309 + 0x79, 310 + 0x70, // ftyp 311 + 0x69, 312 + 0x73, 313 + 0x6f, 314 + 0x6d, // isom brand 315 + ]); 316 + expect(sniffMimeType(bytes)).toBe('video/mp4'); 317 + }); 318 + 319 + test('detects AVIF', () => { 320 + const bytes = new Uint8Array([ 321 + 0x00, 322 + 0x00, 323 + 0x00, 324 + 0x1c, // size 325 + 0x66, 326 + 0x74, 327 + 0x79, 328 + 0x70, // ftyp 329 + 0x61, 330 + 0x76, 331 + 0x69, 332 + 0x66, // avif brand 333 + ]); 334 + expect(sniffMimeType(bytes)).toBe('image/avif'); 335 + }); 336 + 337 + test('detects HEIC', () => { 338 + const bytes = new Uint8Array([ 339 + 0x00, 340 + 0x00, 341 + 0x00, 342 + 0x18, // size 343 + 0x66, 344 + 0x74, 345 + 0x79, 346 + 0x70, // ftyp 347 + 0x68, 348 + 0x65, 349 + 0x69, 350 + 0x63, // heic brand 351 + ]); 352 + expect(sniffMimeType(bytes)).toBe('image/heic'); 353 + }); 354 + 355 + test('returns null for unknown', () => { 356 + const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]); 357 + expect(sniffMimeType(bytes)).toBe(null); 358 + }); 359 + }); 360 + 361 + describe('Blob Ref Detection', () => { 362 + test('finds blob ref in simple object', () => { 363 + const record = { 364 + $type: 'app.bsky.feed.post', 365 + text: 'Hello', 366 + embed: { 367 + $type: 'app.bsky.embed.images', 368 + images: [ 369 + { 370 + image: { 371 + $type: 'blob', 372 + ref: { $link: 'bafkreiabc123' }, 373 + mimeType: 'image/jpeg', 374 + size: 1234, 375 + }, 376 + alt: 'test image', 377 + }, 378 + ], 379 + }, 380 + }; 381 + const refs = findBlobRefs(record); 382 + expect(refs).toEqual(['bafkreiabc123']); 383 + }); 384 + 385 + test('finds multiple blob refs', () => { 386 + const record = { 387 + images: [ 388 + { 389 + image: { 390 + $type: 'blob', 391 + ref: { $link: 'cid1' }, 392 + mimeType: 'image/png', 393 + size: 100, 394 + }, 395 + }, 396 + { 397 + image: { 398 + $type: 'blob', 399 + ref: { $link: 'cid2' }, 400 + mimeType: 'image/png', 401 + size: 200, 402 + }, 403 + }, 404 + ], 405 + }; 406 + const refs = findBlobRefs(record); 407 + expect(refs).toEqual(['cid1', 'cid2']); 408 + }); 409 + 410 + test('returns empty array when no blobs', () => { 411 + const record = { text: 'Hello world', count: 42 }; 412 + const refs = findBlobRefs(record); 413 + expect(refs).toEqual([]); 414 + }); 415 + 416 + test('handles null and primitives', () => { 417 + expect(findBlobRefs(null)).toEqual([]); 418 + expect(findBlobRefs('string')).toEqual([]); 419 + expect(findBlobRefs(42)).toEqual([]); 420 + }); 421 + });
+308
packages/core/test/scope.test.js
··· 1 + import { describe, expect, test } from 'vitest'; 2 + import { 3 + matchesMime, 4 + parseBlobScope, 5 + parseRepoScope, 6 + parseScopesForDisplay, 7 + ScopePermissions, 8 + } from '@pds/core/scope'; 9 + 10 + describe('Scope Parsing', () => { 11 + describe('parseRepoScope', () => { 12 + test('parses repo scope with query parameter action', () => { 13 + const result = parseRepoScope('repo:app.bsky.feed.post?action=create'); 14 + expect(result).toEqual({ 15 + collection: 'app.bsky.feed.post', 16 + actions: ['create'], 17 + }); 18 + }); 19 + 20 + test('parses repo scope with multiple query parameter actions', () => { 21 + const result = parseRepoScope( 22 + 'repo:app.bsky.feed.post?action=create&action=update', 23 + ); 24 + expect(result).toEqual({ 25 + collection: 'app.bsky.feed.post', 26 + actions: ['create', 'update'], 27 + }); 28 + }); 29 + 30 + test('parses repo scope without actions as all actions', () => { 31 + const result = parseRepoScope('repo:app.bsky.feed.post'); 32 + expect(result).toEqual({ 33 + collection: 'app.bsky.feed.post', 34 + actions: ['create', 'update', 'delete'], 35 + }); 36 + }); 37 + 38 + test('parses wildcard collection with action', () => { 39 + const result = parseRepoScope('repo:*?action=create'); 40 + expect(result).toEqual({ 41 + collection: '*', 42 + actions: ['create'], 43 + }); 44 + }); 45 + 46 + test('parses query-only format', () => { 47 + const result = parseRepoScope( 48 + 'repo?collection=app.bsky.feed.post&action=create', 49 + ); 50 + expect(result).toEqual({ 51 + collection: 'app.bsky.feed.post', 52 + actions: ['create'], 53 + }); 54 + }); 55 + 56 + test('deduplicates repeated actions', () => { 57 + const result = parseRepoScope( 58 + 'repo:app.bsky.feed.post?action=create&action=create&action=update', 59 + ); 60 + expect(result).toEqual({ 61 + collection: 'app.bsky.feed.post', 62 + actions: ['create', 'update'], 63 + }); 64 + }); 65 + 66 + test('returns null for non-repo scope', () => { 67 + expect(parseRepoScope('atproto')).toBe(null); 68 + expect(parseRepoScope('blob:image/*')).toBe(null); 69 + expect(parseRepoScope('transition:generic')).toBe(null); 70 + }); 71 + 72 + test('returns null for invalid repo scope', () => { 73 + expect(parseRepoScope('repo:')).toBe(null); 74 + expect(parseRepoScope('repo?')).toBe(null); 75 + }); 76 + }); 77 + 78 + describe('parseBlobScope', () => { 79 + test('parses wildcard MIME', () => { 80 + const result = parseBlobScope('blob:*/*'); 81 + expect(result).toEqual({ accept: ['*/*'] }); 82 + }); 83 + 84 + test('parses type wildcard', () => { 85 + const result = parseBlobScope('blob:image/*'); 86 + expect(result).toEqual({ accept: ['image/*'] }); 87 + }); 88 + 89 + test('parses specific MIME', () => { 90 + const result = parseBlobScope('blob:image/png'); 91 + expect(result).toEqual({ accept: ['image/png'] }); 92 + }); 93 + 94 + test('parses multiple MIMEs', () => { 95 + const result = parseBlobScope('blob:image/png,image/jpeg'); 96 + expect(result).toEqual({ accept: ['image/png', 'image/jpeg'] }); 97 + }); 98 + 99 + test('returns null for non-blob scope', () => { 100 + expect(parseBlobScope('atproto')).toBe(null); 101 + expect(parseBlobScope('repo:*:create')).toBe(null); 102 + }); 103 + }); 104 + 105 + describe('matchesMime', () => { 106 + test('wildcard matches everything', () => { 107 + expect(matchesMime('*/*', 'image/png')).toBe(true); 108 + expect(matchesMime('*/*', 'video/mp4')).toBe(true); 109 + }); 110 + 111 + test('type wildcard matches same type', () => { 112 + expect(matchesMime('image/*', 'image/png')).toBe(true); 113 + expect(matchesMime('image/*', 'image/jpeg')).toBe(true); 114 + expect(matchesMime('image/*', 'video/mp4')).toBe(false); 115 + }); 116 + 117 + test('exact match', () => { 118 + expect(matchesMime('image/png', 'image/png')).toBe(true); 119 + expect(matchesMime('image/png', 'image/jpeg')).toBe(false); 120 + }); 121 + 122 + test('case insensitive', () => { 123 + expect(matchesMime('image/PNG', 'image/png')).toBe(true); 124 + expect(matchesMime('IMAGE/*', 'image/png')).toBe(true); 125 + }); 126 + }); 127 + }); 128 + 129 + describe('ScopePermissions', () => { 130 + describe('static scopes', () => { 131 + test('atproto grants full access', () => { 132 + const perms = new ScopePermissions('atproto'); 133 + expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true); 134 + expect(perms.allowsRepo('any.collection', 'delete')).toBe(true); 135 + expect(perms.allowsBlob('image/png')).toBe(true); 136 + expect(perms.allowsBlob('video/mp4')).toBe(true); 137 + }); 138 + 139 + test('transition:generic grants full repo/blob access', () => { 140 + const perms = new ScopePermissions('transition:generic'); 141 + expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true); 142 + expect(perms.allowsRepo('any.collection', 'delete')).toBe(true); 143 + expect(perms.allowsBlob('image/png')).toBe(true); 144 + }); 145 + }); 146 + 147 + describe('repo scopes', () => { 148 + test('wildcard collection allows any collection', () => { 149 + const perms = new ScopePermissions('repo:*?action=create'); 150 + expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true); 151 + expect(perms.allowsRepo('app.bsky.feed.like', 'create')).toBe(true); 152 + expect(perms.allowsRepo('app.bsky.feed.post', 'delete')).toBe(false); 153 + }); 154 + 155 + test('specific collection restricts to that collection', () => { 156 + const perms = new ScopePermissions( 157 + 'repo:app.bsky.feed.post?action=create', 158 + ); 159 + expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true); 160 + expect(perms.allowsRepo('app.bsky.feed.like', 'create')).toBe(false); 161 + }); 162 + 163 + test('multiple actions', () => { 164 + const perms = new ScopePermissions('repo:*?action=create&action=update'); 165 + expect(perms.allowsRepo('x', 'create')).toBe(true); 166 + expect(perms.allowsRepo('x', 'update')).toBe(true); 167 + expect(perms.allowsRepo('x', 'delete')).toBe(false); 168 + }); 169 + 170 + test('multiple scopes combine', () => { 171 + const perms = new ScopePermissions( 172 + 'repo:app.bsky.feed.post?action=create repo:app.bsky.feed.like?action=delete', 173 + ); 174 + expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true); 175 + expect(perms.allowsRepo('app.bsky.feed.like', 'delete')).toBe(true); 176 + expect(perms.allowsRepo('app.bsky.feed.post', 'delete')).toBe(false); 177 + }); 178 + 179 + test('allowsRepo with query param format scopes', () => { 180 + const perms = new ScopePermissions( 181 + 'atproto repo:app.bsky.feed.post?action=create', 182 + ); 183 + expect(perms.allowsRepo('app.bsky.feed.post', 'create')).toBe(true); 184 + expect(perms.allowsRepo('app.bsky.feed.post', 'delete')).toBe(true); // atproto grants full access 185 + }); 186 + }); 187 + 188 + describe('blob scopes', () => { 189 + test('wildcard allows any MIME', () => { 190 + const perms = new ScopePermissions('blob:*/*'); 191 + expect(perms.allowsBlob('image/png')).toBe(true); 192 + expect(perms.allowsBlob('video/mp4')).toBe(true); 193 + }); 194 + 195 + test('type wildcard restricts to type', () => { 196 + const perms = new ScopePermissions('blob:image/*'); 197 + expect(perms.allowsBlob('image/png')).toBe(true); 198 + expect(perms.allowsBlob('image/jpeg')).toBe(true); 199 + expect(perms.allowsBlob('video/mp4')).toBe(false); 200 + }); 201 + 202 + test('specific MIME restricts exactly', () => { 203 + const perms = new ScopePermissions('blob:image/png'); 204 + expect(perms.allowsBlob('image/png')).toBe(true); 205 + expect(perms.allowsBlob('image/jpeg')).toBe(false); 206 + }); 207 + }); 208 + 209 + describe('empty/no scope', () => { 210 + test('no scope denies everything', () => { 211 + const perms = new ScopePermissions(''); 212 + expect(perms.allowsRepo('x', 'create')).toBe(false); 213 + expect(perms.allowsBlob('image/png')).toBe(false); 214 + }); 215 + 216 + test('undefined scope denies everything', () => { 217 + const perms = new ScopePermissions(undefined); 218 + expect(perms.allowsRepo('x', 'create')).toBe(false); 219 + }); 220 + }); 221 + 222 + describe('assertRepo', () => { 223 + test('throws ScopeMissingError when denied', () => { 224 + const perms = new ScopePermissions( 225 + 'repo:app.bsky.feed.post?action=create', 226 + ); 227 + expect(() => perms.assertRepo('app.bsky.feed.like', 'create')).toThrow( 228 + /Missing required scope/, 229 + ); 230 + }); 231 + 232 + test('does not throw when allowed', () => { 233 + const perms = new ScopePermissions( 234 + 'repo:app.bsky.feed.post?action=create', 235 + ); 236 + expect(() => 237 + perms.assertRepo('app.bsky.feed.post', 'create'), 238 + ).not.toThrow(); 239 + }); 240 + }); 241 + 242 + describe('assertBlob', () => { 243 + test('throws ScopeMissingError when denied', () => { 244 + const perms = new ScopePermissions('blob:image/*'); 245 + expect(() => perms.assertBlob('video/mp4')).toThrow( 246 + /Missing required scope/, 247 + ); 248 + }); 249 + 250 + test('does not throw when allowed', () => { 251 + const perms = new ScopePermissions('blob:image/*'); 252 + expect(() => perms.assertBlob('image/png')).not.toThrow(); 253 + }); 254 + }); 255 + }); 256 + 257 + describe('parseScopesForDisplay', () => { 258 + test('parses identity-only scope', () => { 259 + const result = parseScopesForDisplay('atproto'); 260 + expect(result.hasAtproto).toBe(true); 261 + expect(result.hasTransitionGeneric).toBe(false); 262 + expect(result.repoPermissions.size).toBe(0); 263 + expect(result.blobPermissions).toEqual([]); 264 + }); 265 + 266 + test('parses granular repo scopes', () => { 267 + const result = parseScopesForDisplay( 268 + 'atproto repo:app.bsky.feed.post?action=create&action=update', 269 + ); 270 + expect(result.repoPermissions.size).toBe(1); 271 + const postPerms = result.repoPermissions.get('app.bsky.feed.post'); 272 + expect(postPerms).toEqual({ 273 + create: true, 274 + update: true, 275 + delete: false, 276 + }); 277 + }); 278 + 279 + test('merges multiple scopes for same collection', () => { 280 + const result = parseScopesForDisplay( 281 + 'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete', 282 + ); 283 + const postPerms = result.repoPermissions.get('app.bsky.feed.post'); 284 + expect(postPerms).toEqual({ 285 + create: true, 286 + update: false, 287 + delete: true, 288 + }); 289 + }); 290 + 291 + test('parses blob scopes', () => { 292 + const result = parseScopesForDisplay('atproto blob:image/*'); 293 + expect(result.blobPermissions).toEqual(['image/*']); 294 + }); 295 + 296 + test('detects transition:generic', () => { 297 + const result = parseScopesForDisplay('atproto transition:generic'); 298 + expect(result.hasTransitionGeneric).toBe(true); 299 + }); 300 + 301 + test('handles empty scope string', () => { 302 + const result = parseScopesForDisplay(''); 303 + expect(result.hasAtproto).toBe(false); 304 + expect(result.hasTransitionGeneric).toBe(false); 305 + expect(result.repoPermissions.size).toBe(0); 306 + expect(result.blobPermissions).toEqual([]); 307 + }); 308 + });
packages/core/validation.js packages/core/src/validation.js
+5 -5
packages/deno/deno.json
··· 1 1 { 2 2 "name": "@pds/deno", 3 3 "version": "0.7.0", 4 - "exports": "./index.js", 4 + "exports": "./src/index.js", 5 5 "imports": { 6 - "@pds/core": "../core/index.js", 7 - "@pds/core/": "../core/", 8 - "@pds/storage-sqlite": "../storage-sqlite/index.js", 9 - "@pds/blobs-deno": "../blobs-deno/index.js" 6 + "@pds/core": "../core/src/index.js", 7 + "@pds/core/": "../core/src/", 8 + "@pds/storage-sqlite": "../storage-sqlite/src/index.js", 9 + "@pds/blobs-deno": "../blobs-deno/src/index.js" 10 10 } 11 11 }
packages/deno/index.js packages/deno/src/index.js
+3 -1
packages/deno/package.json
··· 2 2 "name": "@pds/deno", 3 3 "version": "0.7.0", 4 4 "type": "module", 5 + "main": "./src/index.js", 6 + "types": "./src/index.d.ts", 5 7 "exports": { 6 - ".": "./index.js" 8 + ".": "./src/index.js" 7 9 }, 8 10 "dependencies": { 9 11 "@pds/core": "workspace:*"
+1 -1
packages/node/index.js packages/node/src/index.js
··· 19 19 * Create WebSocket port for Node.js 20 20 * @param {WeakMap<Request, import('ws').WebSocket>} upgradeMap 21 21 * @param {import('ws').WebSocketServer} wss - WebSocket server for broadcast 22 - * @returns {import('@pds/core/ports.js').WebSocketPort} 22 + * @returns {import('@pds/core/ports').WebSocketPort} 23 23 */ 24 24 function createWebSocket(upgradeMap, wss) { 25 25 return {
+5 -2
packages/node/package.json
··· 2 2 "name": "@pds/node", 3 3 "version": "0.1.0", 4 4 "type": "module", 5 - "main": "index.js", 6 - "types": "index.d.ts", 5 + "main": "./src/index.js", 6 + "types": "./src/index.d.ts", 7 + "exports": { 8 + ".": "./src/index.js" 9 + }, 7 10 "dependencies": { 8 11 "@pds/blobs-fs": "workspace:*", 9 12 "@pds/core": "workspace:*",
packages/storage-sqlite/driver.js packages/storage-sqlite/src/driver.js
packages/storage-sqlite/index.js packages/storage-sqlite/src/index.js
+4 -4
packages/storage-sqlite/package.json
··· 2 2 "name": "@pds/storage-sqlite", 3 3 "version": "0.1.0", 4 4 "type": "module", 5 - "main": "index.js", 6 - "types": "index.d.ts", 5 + "main": "./src/index.js", 6 + "types": "./src/index.d.ts", 7 7 "exports": { 8 - ".": "./index.js", 9 - "./driver": "./driver.js" 8 + ".": "./src/index.js", 9 + "./driver": "./src/driver.js" 10 10 }, 11 11 "peerDependencies": { 12 12 "better-sqlite3": ">=9.0.0"
+726 -474
test/pds.test.js docs/plans/2026-01-13-split-pds-tests.md
··· 1 + # Split pds.test.js Into Module-Specific Test Files 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Split the monolithic `test/pds.test.js` into 7 separate test files in `packages/core/test/`, each corresponding to its source module. 6 + 7 + **Architecture:** Extract describe blocks from the single test file into module-specific test files. Each new file imports only from its corresponding source module. No source file changes. 8 + 9 + **Tech Stack:** Vitest, ES modules 10 + 11 + --- 12 + 13 + ## File Mapping Reference 14 + 15 + | New Test File | Describe Blocks | 16 + |---------------|-----------------| 17 + | `packages/core/test/crypto.test.js` | Base32 Encoding, P-256 Signing, JWT Base64URL, JWK Thumbprint | 18 + | `packages/core/test/repo.test.js` | CBOR Encoding, CBOR Decoding, CID Generation, TID Generation, CAR File Builder, MIME Type Sniffing, Blob Ref Detection | 19 + | `packages/core/test/auth.test.js` | JWT Creation, JWT Verification | 20 + | `packages/core/test/scope.test.js` | Scope Parsing, ScopePermissions, parseScopesForDisplay | 21 + | `packages/core/test/oauth.test.js` | Client Metadata | 22 + | `packages/core/test/pds.test.js` | Proxy Utilities | 23 + | `packages/core/test/mst.test.js` | MST Key Depth | 24 + 25 + --- 26 + 27 + ### Task 1: Create test directory and crypto.test.js 28 + 29 + **Files:** 30 + - Create: `packages/core/test/crypto.test.js` 31 + 32 + **Step 1: Create test directory** 33 + 34 + Run: `mkdir -p packages/core/test` 35 + 36 + **Step 2: Create crypto.test.js** 37 + 38 + ```javascript 1 39 import { describe, expect, test } from 'vitest'; 2 40 import { 3 41 base32Decode, ··· 11 49 importPrivateKey, 12 50 sign, 13 51 } from '@pds/core/crypto'; 52 + 53 + describe('Base32 Encoding', () => { 54 + test('encodes bytes to base32lower', () => { 55 + const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]); 56 + const encoded = base32Encode(bytes); 57 + expect(typeof encoded).toBe('string'); 58 + expect(encoded).toMatch(/^[a-z2-7]+$/); 59 + }); 60 + 61 + test('base32 encode/decode roundtrip', () => { 62 + const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]); 63 + const encoded = base32Encode(original); 64 + const decoded = base32Decode(encoded); 65 + expect(decoded).toEqual(original); 66 + }); 67 + }); 68 + 69 + describe('P-256 Signing', () => { 70 + test('generates key pair with correct sizes', async () => { 71 + const kp = await generateKeyPair(); 72 + 73 + expect(kp.privateKey.length).toBe(32); 74 + expect(kp.publicKey.length).toBe(33); // compressed 75 + expect(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03).toBe(true); 76 + }); 77 + 78 + test('can sign data with generated key', async () => { 79 + const kp = await generateKeyPair(); 80 + const key = await importPrivateKey(kp.privateKey); 81 + const data = new TextEncoder().encode('test message'); 82 + const sig = await sign(key, data); 83 + 84 + expect(sig.length).toBe(64); // r (32) + s (32) 85 + }); 86 + 87 + test('different messages produce different signatures', async () => { 88 + const kp = await generateKeyPair(); 89 + const key = await importPrivateKey(kp.privateKey); 90 + 91 + const sig1 = await sign(key, new TextEncoder().encode('message 1')); 92 + const sig2 = await sign(key, new TextEncoder().encode('message 2')); 93 + 94 + expect(sig1).not.toEqual(sig2); 95 + }); 96 + 97 + test('bytesToHex and hexToBytes roundtrip', () => { 98 + const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]); 99 + const hex = bytesToHex(original); 100 + const back = hexToBytes(hex); 101 + 102 + expect(hex).toBe('000ff0ffabcd'); 103 + expect(back).toEqual(original); 104 + }); 105 + 106 + test('importPrivateKey rejects invalid key lengths', async () => { 107 + // Too short 108 + await expect(() => importPrivateKey(new Uint8Array(31))).rejects.toThrow( 109 + /expected 32 bytes, got 31/, 110 + ); 111 + 112 + // Too long 113 + await expect(() => importPrivateKey(new Uint8Array(33))).rejects.toThrow( 114 + /expected 32 bytes, got 33/, 115 + ); 116 + 117 + // Empty 118 + await expect(() => importPrivateKey(new Uint8Array(0))).rejects.toThrow( 119 + /expected 32 bytes, got 0/, 120 + ); 121 + }); 122 + 123 + test('importPrivateKey rejects non-Uint8Array input', async () => { 124 + // Arrays have .length but aren't Uint8Array 125 + // @ts-expect-error - Testing runtime validation with invalid type 126 + await expect(() => importPrivateKey([1, 2, 3])).rejects.toThrow( 127 + /Invalid private key/, 128 + ); 129 + 130 + // Strings don't work either 131 + // @ts-expect-error - Testing runtime validation with invalid type 132 + await expect(() => importPrivateKey('not bytes')).rejects.toThrow( 133 + /Invalid private key/, 134 + ); 135 + 136 + // null/undefined 137 + // @ts-expect-error - Testing runtime validation with invalid type 138 + await expect(() => importPrivateKey(null)).rejects.toThrow( 139 + /Invalid private key/, 140 + ); 141 + }); 142 + }); 143 + 144 + describe('JWT Base64URL', () => { 145 + test('base64UrlEncode encodes bytes correctly', () => { 146 + const input = new TextEncoder().encode('hello world'); 147 + const encoded = base64UrlEncode(input); 148 + expect(encoded).toBe('aGVsbG8gd29ybGQ'); 149 + expect(encoded.includes('+')).toBe(false); 150 + expect(encoded.includes('/')).toBe(false); 151 + expect(encoded.includes('=')).toBe(false); 152 + }); 153 + 154 + test('base64UrlDecode decodes string correctly', () => { 155 + const decoded = base64UrlDecode('aGVsbG8gd29ybGQ'); 156 + const str = new TextDecoder().decode(decoded); 157 + expect(str).toBe('hello world'); 158 + }); 159 + 160 + test('base64url roundtrip', () => { 161 + const original = new Uint8Array([0, 1, 2, 255, 254, 253]); 162 + const encoded = base64UrlEncode(original); 163 + const decoded = base64UrlDecode(encoded); 164 + expect(decoded).toEqual(original); 165 + }); 166 + }); 167 + 168 + describe('JWK Thumbprint', () => { 169 + test('computes deterministic thumbprint for EC key', async () => { 170 + // Test vector: known JWK and its expected thumbprint 171 + const jwk = { 172 + kty: 'EC', 173 + crv: 'P-256', 174 + x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', 175 + y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ', 176 + }; 177 + 178 + const jkt1 = await computeJwkThumbprint(jwk); 179 + const jkt2 = await computeJwkThumbprint(jwk); 180 + 181 + // Thumbprint must be deterministic 182 + expect(jkt1).toBe(jkt2); 183 + // Must be base64url-encoded SHA-256 (43 chars) 184 + expect(jkt1.length).toBe(43); 185 + // Must only contain base64url characters 186 + expect(jkt1).toMatch(/^[A-Za-z0-9_-]+$/); 187 + }); 188 + 189 + test('produces different thumbprints for different keys', async () => { 190 + const jwk1 = { 191 + kty: 'EC', 192 + crv: 'P-256', 193 + x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', 194 + y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ', 195 + }; 196 + const jwk2 = { 197 + kty: 'EC', 198 + crv: 'P-256', 199 + x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', 200 + y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', 201 + }; 202 + 203 + const jkt1 = await computeJwkThumbprint(jwk1); 204 + const jkt2 = await computeJwkThumbprint(jwk2); 205 + 206 + expect(jkt1).not.toBe(jkt2); 207 + }); 208 + }); 209 + ``` 210 + 211 + **Step 3: Run tests to verify** 212 + 213 + Run: `pnpm test packages/core/test/crypto.test.js` 214 + Expected: All tests PASS 215 + 216 + **Step 4: Commit** 217 + 218 + ```bash 219 + git add packages/core/test/crypto.test.js 220 + git commit -m "test: extract crypto tests from pds.test.js" 221 + ``` 222 + 223 + --- 224 + 225 + ### Task 2: Create repo.test.js 226 + 227 + **Files:** 228 + - Create: `packages/core/test/repo.test.js` 229 + 230 + **Step 1: Create repo.test.js** 231 + 232 + ```javascript 233 + import { describe, expect, test } from 'vitest'; 14 234 import { 15 235 buildCarFile, 16 236 cborDecode, ··· 20 240 createCid, 21 241 createTid, 22 242 findBlobRefs, 23 - getKeyDepth, 24 243 sniffMimeType, 25 244 varint, 26 245 } from '@pds/core/repo'; 27 - import { 28 - createAccessJwt, 29 - createRefreshJwt, 30 - verifyAccessJwt, 31 - verifyRefreshJwt, 32 - } from '@pds/core/auth'; 33 - import { 34 - matchesMime, 35 - parseBlobScope, 36 - parseRepoScope, 37 - parseScopesForDisplay, 38 - ScopePermissions, 39 - } from '@pds/core/scope'; 40 - import { 41 - getLoopbackClientMetadata, 42 - isLoopbackClient, 43 - validateClientMetadata, 44 - } from '@pds/core/oauth'; 45 - import { 46 - getKnownServiceUrl, 47 - parseAtprotoProxyHeader, 48 - } from '@pds/core/pds'; 49 - 50 - // Internal constant - not exported from pds.js due to Cloudflare Workers limitation 51 - const BSKY_APPVIEW_URL = 'https://api.bsky.app'; 246 + import { base32Decode, base32Encode } from '@pds/core/crypto'; 52 247 53 248 describe('CBOR Encoding', () => { 54 249 test('encodes simple map', () => { ··· 142 337 }); 143 338 }); 144 339 145 - describe('Base32 Encoding', () => { 146 - test('encodes bytes to base32lower', () => { 147 - const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]); 148 - const encoded = base32Encode(bytes); 149 - expect(typeof encoded).toBe('string'); 150 - expect(encoded).toMatch(/^[a-z2-7]+$/); 340 + describe('CBOR Decoding', () => { 341 + test('decodes what encode produces (roundtrip)', () => { 342 + const original = { hello: 'world', num: 42 }; 343 + const encoded = cborEncode(original); 344 + const decoded = cborDecode(encoded); 345 + expect(decoded).toEqual(original); 346 + }); 347 + 348 + test('decodes null', () => { 349 + const encoded = cborEncode(null); 350 + const decoded = cborDecode(encoded); 351 + expect(decoded).toBe(null); 352 + }); 353 + 354 + test('decodes booleans', () => { 355 + expect(cborDecode(cborEncode(true))).toBe(true); 356 + expect(cborDecode(cborEncode(false))).toBe(false); 357 + }); 358 + 359 + test('decodes integers', () => { 360 + expect(cborDecode(cborEncode(0))).toBe(0); 361 + expect(cborDecode(cborEncode(42))).toBe(42); 362 + expect(cborDecode(cborEncode(255))).toBe(255); 363 + expect(cborDecode(cborEncode(-1))).toBe(-1); 364 + expect(cborDecode(cborEncode(-10))).toBe(-10); 365 + }); 366 + 367 + test('decodes strings', () => { 368 + expect(cborDecode(cborEncode('hello'))).toBe('hello'); 369 + expect(cborDecode(cborEncode(''))).toBe(''); 370 + }); 371 + 372 + test('decodes arrays', () => { 373 + expect(cborDecode(cborEncode([1, 2, 3]))).toEqual([1, 2, 3]); 374 + expect(cborDecode(cborEncode([]))).toEqual([]); 375 + }); 376 + 377 + test('decodes nested structures', () => { 378 + const original = { arr: [1, { nested: true }], str: 'test' }; 379 + const decoded = cborDecode(cborEncode(original)); 380 + expect(decoded).toEqual(original); 151 381 }); 152 382 }); 153 383 ··· 237 467 }); 238 468 }); 239 469 240 - describe('P-256 Signing', () => { 241 - test('generates key pair with correct sizes', async () => { 242 - const kp = await generateKeyPair(); 243 - 244 - expect(kp.privateKey.length).toBe(32); 245 - expect(kp.publicKey.length).toBe(33); // compressed 246 - expect(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03).toBe(true); 470 + describe('CAR File Builder', () => { 471 + test('varint encodes small numbers', () => { 472 + expect(varint(0)).toEqual(new Uint8Array([0])); 473 + expect(varint(1)).toEqual(new Uint8Array([1])); 474 + expect(varint(127)).toEqual(new Uint8Array([127])); 247 475 }); 248 476 249 - test('can sign data with generated key', async () => { 250 - const kp = await generateKeyPair(); 251 - const key = await importPrivateKey(kp.privateKey); 252 - const data = new TextEncoder().encode('test message'); 253 - const sig = await sign(key, data); 254 - 255 - expect(sig.length).toBe(64); // r (32) + s (32) 477 + test('varint encodes multi-byte numbers', () => { 478 + // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01] 479 + expect(varint(128)).toEqual(new Uint8Array([0x80, 0x01])); 480 + // 300 = 0x12c -> [0xac, 0x02] 481 + expect(varint(300)).toEqual(new Uint8Array([0xac, 0x02])); 256 482 }); 257 483 258 - test('different messages produce different signatures', async () => { 259 - const kp = await generateKeyPair(); 260 - const key = await importPrivateKey(kp.privateKey); 484 + test('buildCarFile produces valid structure', async () => { 485 + const data = cborEncode({ test: 'data' }); 486 + const cid = await createCid(data); 487 + const cidStr = cidToString(cid); 261 488 262 - const sig1 = await sign(key, new TextEncoder().encode('message 1')); 263 - const sig2 = await sign(key, new TextEncoder().encode('message 2')); 489 + const car = buildCarFile(cidStr, [{ cid: cidStr, data }]); 264 490 265 - expect(sig1).not.toEqual(sig2); 266 - }); 267 - 268 - test('bytesToHex and hexToBytes roundtrip', () => { 269 - const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]); 270 - const hex = bytesToHex(original); 271 - const back = hexToBytes(hex); 272 - 273 - expect(hex).toBe('000ff0ffabcd'); 274 - expect(back).toEqual(original); 275 - }); 276 - 277 - test('importPrivateKey rejects invalid key lengths', async () => { 278 - // Too short 279 - await expect(() => importPrivateKey(new Uint8Array(31))).rejects.toThrow( 280 - /expected 32 bytes, got 31/, 281 - ); 282 - 283 - // Too long 284 - await expect(() => importPrivateKey(new Uint8Array(33))).rejects.toThrow( 285 - /expected 32 bytes, got 33/, 286 - ); 287 - 288 - // Empty 289 - await expect(() => importPrivateKey(new Uint8Array(0))).rejects.toThrow( 290 - /expected 32 bytes, got 0/, 291 - ); 292 - }); 293 - 294 - test('importPrivateKey rejects non-Uint8Array input', async () => { 295 - // Arrays have .length but aren't Uint8Array 296 - // @ts-expect-error - Testing runtime validation with invalid type 297 - await expect(() => importPrivateKey([1, 2, 3])).rejects.toThrow( 298 - /Invalid private key/, 299 - ); 300 - 301 - // Strings don't work either 302 - // @ts-expect-error - Testing runtime validation with invalid type 303 - await expect(() => importPrivateKey('not bytes')).rejects.toThrow( 304 - /Invalid private key/, 305 - ); 306 - 307 - // null/undefined 308 - // @ts-expect-error - Testing runtime validation with invalid type 309 - await expect(() => importPrivateKey(null)).rejects.toThrow( 310 - /Invalid private key/, 311 - ); 491 + expect(car instanceof Uint8Array).toBe(true); 492 + expect(car.length > 0).toBe(true); 493 + // First byte should be varint of header length 494 + expect(car[0] > 0).toBe(true); 312 495 }); 313 496 }); 314 497 315 - describe('MST Key Depth', () => { 316 - test('returns a non-negative integer', async () => { 317 - const depth = await getKeyDepth('app.bsky.feed.post/abc123'); 318 - expect(typeof depth).toBe('number'); 319 - expect(depth >= 0).toBe(true); 498 + describe('MIME Type Sniffing', () => { 499 + test('detects JPEG', () => { 500 + const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); 501 + expect(sniffMimeType(bytes)).toBe('image/jpeg'); 320 502 }); 321 503 322 - test('is deterministic for same key', async () => { 323 - const key = 'app.bsky.feed.post/test123'; 324 - const depth1 = await getKeyDepth(key); 325 - const depth2 = await getKeyDepth(key); 326 - expect(depth1).toBe(depth2); 504 + test('detects PNG', () => { 505 + const bytes = new Uint8Array([ 506 + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 507 + ]); 508 + expect(sniffMimeType(bytes)).toBe('image/png'); 327 509 }); 328 510 329 - test('different keys can have different depths', async () => { 330 - // Generate many keys and check we get some variation 331 - const depths = new Set(); 332 - for (let i = 0; i < 100; i++) { 333 - depths.add(await getKeyDepth(`collection/key${i}`)); 334 - } 335 - // Should have at least 1 unique depth (realistically more) 336 - expect(depths.size >= 1).toBe(true); 511 + test('detects GIF', () => { 512 + const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); 513 + expect(sniffMimeType(bytes)).toBe('image/gif'); 337 514 }); 338 515 339 - test('handles empty string', async () => { 340 - const depth = await getKeyDepth(''); 341 - expect(typeof depth).toBe('number'); 342 - expect(depth >= 0).toBe(true); 516 + test('detects WebP', () => { 517 + const bytes = new Uint8Array([ 518 + 0x52, 519 + 0x49, 520 + 0x46, 521 + 0x46, // RIFF 522 + 0x00, 523 + 0x00, 524 + 0x00, 525 + 0x00, // size (ignored) 526 + 0x57, 527 + 0x45, 528 + 0x42, 529 + 0x50, // WEBP 530 + ]); 531 + expect(sniffMimeType(bytes)).toBe('image/webp'); 343 532 }); 344 533 345 - test('handles unicode strings', async () => { 346 - const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉'); 347 - expect(typeof depth).toBe('number'); 348 - expect(depth >= 0).toBe(true); 534 + test('detects MP4', () => { 535 + const bytes = new Uint8Array([ 536 + 0x00, 537 + 0x00, 538 + 0x00, 539 + 0x18, // size 540 + 0x66, 541 + 0x74, 542 + 0x79, 543 + 0x70, // ftyp 544 + 0x69, 545 + 0x73, 546 + 0x6f, 547 + 0x6d, // isom brand 548 + ]); 549 + expect(sniffMimeType(bytes)).toBe('video/mp4'); 349 550 }); 350 - }); 351 551 352 - describe('CBOR Decoding', () => { 353 - test('decodes what encode produces (roundtrip)', () => { 354 - const original = { hello: 'world', num: 42 }; 355 - const encoded = cborEncode(original); 356 - const decoded = cborDecode(encoded); 357 - expect(decoded).toEqual(original); 552 + test('detects AVIF', () => { 553 + const bytes = new Uint8Array([ 554 + 0x00, 555 + 0x00, 556 + 0x00, 557 + 0x1c, // size 558 + 0x66, 559 + 0x74, 560 + 0x79, 561 + 0x70, // ftyp 562 + 0x61, 563 + 0x76, 564 + 0x69, 565 + 0x66, // avif brand 566 + ]); 567 + expect(sniffMimeType(bytes)).toBe('image/avif'); 358 568 }); 359 569 360 - test('decodes null', () => { 361 - const encoded = cborEncode(null); 362 - const decoded = cborDecode(encoded); 363 - expect(decoded).toBe(null); 570 + test('detects HEIC', () => { 571 + const bytes = new Uint8Array([ 572 + 0x00, 573 + 0x00, 574 + 0x00, 575 + 0x18, // size 576 + 0x66, 577 + 0x74, 578 + 0x79, 579 + 0x70, // ftyp 580 + 0x68, 581 + 0x65, 582 + 0x69, 583 + 0x63, // heic brand 584 + ]); 585 + expect(sniffMimeType(bytes)).toBe('image/heic'); 364 586 }); 365 587 366 - test('decodes booleans', () => { 367 - expect(cborDecode(cborEncode(true))).toBe(true); 368 - expect(cborDecode(cborEncode(false))).toBe(false); 588 + test('returns null for unknown', () => { 589 + const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]); 590 + expect(sniffMimeType(bytes)).toBe(null); 369 591 }); 592 + }); 370 593 371 - test('decodes integers', () => { 372 - expect(cborDecode(cborEncode(0))).toBe(0); 373 - expect(cborDecode(cborEncode(42))).toBe(42); 374 - expect(cborDecode(cborEncode(255))).toBe(255); 375 - expect(cborDecode(cborEncode(-1))).toBe(-1); 376 - expect(cborDecode(cborEncode(-10))).toBe(-10); 594 + describe('Blob Ref Detection', () => { 595 + test('finds blob ref in simple object', () => { 596 + const record = { 597 + $type: 'app.bsky.feed.post', 598 + text: 'Hello', 599 + embed: { 600 + $type: 'app.bsky.embed.images', 601 + images: [ 602 + { 603 + image: { 604 + $type: 'blob', 605 + ref: { $link: 'bafkreiabc123' }, 606 + mimeType: 'image/jpeg', 607 + size: 1234, 608 + }, 609 + alt: 'test image', 610 + }, 611 + ], 612 + }, 613 + }; 614 + const refs = findBlobRefs(record); 615 + expect(refs).toEqual(['bafkreiabc123']); 377 616 }); 378 617 379 - test('decodes strings', () => { 380 - expect(cborDecode(cborEncode('hello'))).toBe('hello'); 381 - expect(cborDecode(cborEncode(''))).toBe(''); 618 + test('finds multiple blob refs', () => { 619 + const record = { 620 + images: [ 621 + { 622 + image: { 623 + $type: 'blob', 624 + ref: { $link: 'cid1' }, 625 + mimeType: 'image/png', 626 + size: 100, 627 + }, 628 + }, 629 + { 630 + image: { 631 + $type: 'blob', 632 + ref: { $link: 'cid2' }, 633 + mimeType: 'image/png', 634 + size: 200, 635 + }, 636 + }, 637 + ], 638 + }; 639 + const refs = findBlobRefs(record); 640 + expect(refs).toEqual(['cid1', 'cid2']); 382 641 }); 383 642 384 - test('decodes arrays', () => { 385 - expect(cborDecode(cborEncode([1, 2, 3]))).toEqual([1, 2, 3]); 386 - expect(cborDecode(cborEncode([]))).toEqual([]); 643 + test('returns empty array when no blobs', () => { 644 + const record = { text: 'Hello world', count: 42 }; 645 + const refs = findBlobRefs(record); 646 + expect(refs).toEqual([]); 387 647 }); 388 648 389 - test('decodes nested structures', () => { 390 - const original = { arr: [1, { nested: true }], str: 'test' }; 391 - const decoded = cborDecode(cborEncode(original)); 392 - expect(decoded).toEqual(original); 649 + test('handles null and primitives', () => { 650 + expect(findBlobRefs(null)).toEqual([]); 651 + expect(findBlobRefs('string')).toEqual([]); 652 + expect(findBlobRefs(42)).toEqual([]); 393 653 }); 394 654 }); 655 + ``` 395 656 396 - describe('CAR File Builder', () => { 397 - test('varint encodes small numbers', () => { 398 - expect(varint(0)).toEqual(new Uint8Array([0])); 399 - expect(varint(1)).toEqual(new Uint8Array([1])); 400 - expect(varint(127)).toEqual(new Uint8Array([127])); 401 - }); 657 + **Step 2: Run tests to verify** 402 658 403 - test('varint encodes multi-byte numbers', () => { 404 - // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01] 405 - expect(varint(128)).toEqual(new Uint8Array([0x80, 0x01])); 406 - // 300 = 0x12c -> [0xac, 0x02] 407 - expect(varint(300)).toEqual(new Uint8Array([0xac, 0x02])); 408 - }); 659 + Run: `pnpm test packages/core/test/repo.test.js` 660 + Expected: All tests PASS 409 661 410 - test('base32 encode/decode roundtrip', () => { 411 - const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]); 412 - const encoded = base32Encode(original); 413 - const decoded = base32Decode(encoded); 414 - expect(decoded).toEqual(original); 415 - }); 662 + **Step 3: Commit** 416 663 417 - test('buildCarFile produces valid structure', async () => { 418 - const data = cborEncode({ test: 'data' }); 419 - const cid = await createCid(data); 420 - const cidStr = cidToString(cid); 664 + ```bash 665 + git add packages/core/test/repo.test.js 666 + git commit -m "test: extract repo tests from pds.test.js" 667 + ``` 421 668 422 - const car = buildCarFile(cidStr, [{ cid: cidStr, data }]); 669 + --- 423 670 424 - expect(car instanceof Uint8Array).toBe(true); 425 - expect(car.length > 0).toBe(true); 426 - // First byte should be varint of header length 427 - expect(car[0] > 0).toBe(true); 428 - }); 429 - }); 671 + ### Task 3: Create auth.test.js 430 672 431 - describe('JWT Base64URL', () => { 432 - test('base64UrlEncode encodes bytes correctly', () => { 433 - const input = new TextEncoder().encode('hello world'); 434 - const encoded = base64UrlEncode(input); 435 - expect(encoded).toBe('aGVsbG8gd29ybGQ'); 436 - expect(encoded.includes('+')).toBe(false); 437 - expect(encoded.includes('/')).toBe(false); 438 - expect(encoded.includes('=')).toBe(false); 439 - }); 673 + **Files:** 674 + - Create: `packages/core/test/auth.test.js` 440 675 441 - test('base64UrlDecode decodes string correctly', () => { 442 - const decoded = base64UrlDecode('aGVsbG8gd29ybGQ'); 443 - const str = new TextDecoder().decode(decoded); 444 - expect(str).toBe('hello world'); 445 - }); 676 + **Step 1: Create auth.test.js** 446 677 447 - test('base64url roundtrip', () => { 448 - const original = new Uint8Array([0, 1, 2, 255, 254, 253]); 449 - const encoded = base64UrlEncode(original); 450 - const decoded = base64UrlDecode(encoded); 451 - expect(decoded).toEqual(original); 452 - }); 453 - }); 678 + ```javascript 679 + import { describe, expect, test } from 'vitest'; 680 + import { 681 + createAccessJwt, 682 + createRefreshJwt, 683 + verifyAccessJwt, 684 + verifyRefreshJwt, 685 + } from '@pds/core/auth'; 686 + import { base64UrlDecode } from '@pds/core/crypto'; 454 687 455 688 describe('JWT Creation', () => { 456 689 test('createAccessJwt creates valid JWT structure', async () => { ··· 611 844 ); 612 845 }); 613 846 }); 847 + ``` 614 848 615 - describe('MIME Type Sniffing', () => { 616 - test('detects JPEG', () => { 617 - const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); 618 - expect(sniffMimeType(bytes)).toBe('image/jpeg'); 619 - }); 849 + **Step 2: Run tests to verify** 620 850 621 - test('detects PNG', () => { 622 - const bytes = new Uint8Array([ 623 - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 624 - ]); 625 - expect(sniffMimeType(bytes)).toBe('image/png'); 626 - }); 851 + Run: `pnpm test packages/core/test/auth.test.js` 852 + Expected: All tests PASS 627 853 628 - test('detects GIF', () => { 629 - const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); 630 - expect(sniffMimeType(bytes)).toBe('image/gif'); 631 - }); 854 + **Step 3: Commit** 632 855 633 - test('detects WebP', () => { 634 - const bytes = new Uint8Array([ 635 - 0x52, 636 - 0x49, 637 - 0x46, 638 - 0x46, // RIFF 639 - 0x00, 640 - 0x00, 641 - 0x00, 642 - 0x00, // size (ignored) 643 - 0x57, 644 - 0x45, 645 - 0x42, 646 - 0x50, // WEBP 647 - ]); 648 - expect(sniffMimeType(bytes)).toBe('image/webp'); 649 - }); 650 - 651 - test('detects MP4', () => { 652 - const bytes = new Uint8Array([ 653 - 0x00, 654 - 0x00, 655 - 0x00, 656 - 0x18, // size 657 - 0x66, 658 - 0x74, 659 - 0x79, 660 - 0x70, // ftyp 661 - 0x69, 662 - 0x73, 663 - 0x6f, 664 - 0x6d, // isom brand 665 - ]); 666 - expect(sniffMimeType(bytes)).toBe('video/mp4'); 667 - }); 668 - 669 - test('detects AVIF', () => { 670 - const bytes = new Uint8Array([ 671 - 0x00, 672 - 0x00, 673 - 0x00, 674 - 0x1c, // size 675 - 0x66, 676 - 0x74, 677 - 0x79, 678 - 0x70, // ftyp 679 - 0x61, 680 - 0x76, 681 - 0x69, 682 - 0x66, // avif brand 683 - ]); 684 - expect(sniffMimeType(bytes)).toBe('image/avif'); 685 - }); 686 - 687 - test('detects HEIC', () => { 688 - const bytes = new Uint8Array([ 689 - 0x00, 690 - 0x00, 691 - 0x00, 692 - 0x18, // size 693 - 0x66, 694 - 0x74, 695 - 0x79, 696 - 0x70, // ftyp 697 - 0x68, 698 - 0x65, 699 - 0x69, 700 - 0x63, // heic brand 701 - ]); 702 - expect(sniffMimeType(bytes)).toBe('image/heic'); 703 - }); 704 - 705 - test('returns null for unknown', () => { 706 - const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]); 707 - expect(sniffMimeType(bytes)).toBe(null); 708 - }); 709 - }); 710 - 711 - describe('Blob Ref Detection', () => { 712 - test('finds blob ref in simple object', () => { 713 - const record = { 714 - $type: 'app.bsky.feed.post', 715 - text: 'Hello', 716 - embed: { 717 - $type: 'app.bsky.embed.images', 718 - images: [ 719 - { 720 - image: { 721 - $type: 'blob', 722 - ref: { $link: 'bafkreiabc123' }, 723 - mimeType: 'image/jpeg', 724 - size: 1234, 725 - }, 726 - alt: 'test image', 727 - }, 728 - ], 729 - }, 730 - }; 731 - const refs = findBlobRefs(record); 732 - expect(refs).toEqual(['bafkreiabc123']); 733 - }); 856 + ```bash 857 + git add packages/core/test/auth.test.js 858 + git commit -m "test: extract auth tests from pds.test.js" 859 + ``` 734 860 735 - test('finds multiple blob refs', () => { 736 - const record = { 737 - images: [ 738 - { 739 - image: { 740 - $type: 'blob', 741 - ref: { $link: 'cid1' }, 742 - mimeType: 'image/png', 743 - size: 100, 744 - }, 745 - }, 746 - { 747 - image: { 748 - $type: 'blob', 749 - ref: { $link: 'cid2' }, 750 - mimeType: 'image/png', 751 - size: 200, 752 - }, 753 - }, 754 - ], 755 - }; 756 - const refs = findBlobRefs(record); 757 - expect(refs).toEqual(['cid1', 'cid2']); 758 - }); 861 + --- 759 862 760 - test('returns empty array when no blobs', () => { 761 - const record = { text: 'Hello world', count: 42 }; 762 - const refs = findBlobRefs(record); 763 - expect(refs).toEqual([]); 764 - }); 863 + ### Task 4: Create mst.test.js 765 864 766 - test('handles null and primitives', () => { 767 - expect(findBlobRefs(null)).toEqual([]); 768 - expect(findBlobRefs('string')).toEqual([]); 769 - expect(findBlobRefs(42)).toEqual([]); 770 - }); 771 - }); 865 + **Files:** 866 + - Create: `packages/core/test/mst.test.js` 772 867 773 - describe('JWK Thumbprint', () => { 774 - test('computes deterministic thumbprint for EC key', async () => { 775 - // Test vector: known JWK and its expected thumbprint 776 - const jwk = { 777 - kty: 'EC', 778 - crv: 'P-256', 779 - x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', 780 - y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ', 781 - }; 868 + **Step 1: Create mst.test.js** 782 869 783 - const jkt1 = await computeJwkThumbprint(jwk); 784 - const jkt2 = await computeJwkThumbprint(jwk); 870 + ```javascript 871 + import { describe, expect, test } from 'vitest'; 872 + import { getKeyDepth } from '@pds/core/repo'; 785 873 786 - // Thumbprint must be deterministic 787 - expect(jkt1).toBe(jkt2); 788 - // Must be base64url-encoded SHA-256 (43 chars) 789 - expect(jkt1.length).toBe(43); 790 - // Must only contain base64url characters 791 - expect(jkt1).toMatch(/^[A-Za-z0-9_-]+$/); 874 + describe('MST Key Depth', () => { 875 + test('returns a non-negative integer', async () => { 876 + const depth = await getKeyDepth('app.bsky.feed.post/abc123'); 877 + expect(typeof depth).toBe('number'); 878 + expect(depth >= 0).toBe(true); 792 879 }); 793 880 794 - test('produces different thumbprints for different keys', async () => { 795 - const jwk1 = { 796 - kty: 'EC', 797 - crv: 'P-256', 798 - x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', 799 - y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ', 800 - }; 801 - const jwk2 = { 802 - kty: 'EC', 803 - crv: 'P-256', 804 - x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', 805 - y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', 806 - }; 807 - 808 - const jkt1 = await computeJwkThumbprint(jwk1); 809 - const jkt2 = await computeJwkThumbprint(jwk2); 810 - 811 - expect(jkt1).not.toBe(jkt2); 881 + test('is deterministic for same key', async () => { 882 + const key = 'app.bsky.feed.post/test123'; 883 + const depth1 = await getKeyDepth(key); 884 + const depth2 = await getKeyDepth(key); 885 + expect(depth1).toBe(depth2); 812 886 }); 813 - }); 814 887 815 - describe('Client Metadata', () => { 816 - test('isLoopbackClient detects localhost', () => { 817 - expect(isLoopbackClient('http://localhost:8080')).toBe(true); 818 - expect(isLoopbackClient('http://127.0.0.1:3000')).toBe(true); 819 - expect(isLoopbackClient('https://example.com')).toBe(false); 888 + test('different keys can have different depths', async () => { 889 + // Generate many keys and check we get some variation 890 + const depths = new Set(); 891 + for (let i = 0; i < 100; i++) { 892 + depths.add(await getKeyDepth(`collection/key${i}`)); 893 + } 894 + // Should have at least 1 unique depth (realistically more) 895 + expect(depths.size >= 1).toBe(true); 820 896 }); 821 897 822 - test('getLoopbackClientMetadata returns permissive defaults', () => { 823 - const metadata = getLoopbackClientMetadata('http://localhost:8080'); 824 - expect(metadata.client_id).toBe('http://localhost:8080'); 825 - expect(metadata.grant_types.includes('authorization_code')).toBe(true); 826 - expect(metadata.dpop_bound_access_tokens).toBe(true); 898 + test('handles empty string', async () => { 899 + const depth = await getKeyDepth(''); 900 + expect(typeof depth).toBe('number'); 901 + expect(depth >= 0).toBe(true); 827 902 }); 828 903 829 - test('validateClientMetadata rejects mismatched client_id', () => { 830 - const metadata = { 831 - client_id: 'https://other.com/metadata.json', 832 - redirect_uris: ['https://example.com/callback'], 833 - grant_types: ['authorization_code'], 834 - response_types: ['code'], 835 - }; 836 - expect(() => 837 - validateClientMetadata(metadata, 'https://example.com/metadata.json'), 838 - ).toThrow(/client_id mismatch/); 904 + test('handles unicode strings', async () => { 905 + const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉'); 906 + expect(typeof depth).toBe('number'); 907 + expect(depth >= 0).toBe(true); 839 908 }); 840 909 }); 841 - 842 - describe('Proxy Utilities', () => { 843 - describe('parseAtprotoProxyHeader', () => { 844 - test('parses valid header', () => { 845 - const result = parseAtprotoProxyHeader( 846 - 'did:web:api.bsky.app#bsky_appview', 847 - ); 848 - expect(result).toEqual({ 849 - did: 'did:web:api.bsky.app', 850 - serviceId: 'bsky_appview', 851 - }); 852 - }); 910 + ``` 853 911 854 - test('parses header with did:plc', () => { 855 - const result = parseAtprotoProxyHeader( 856 - 'did:plc:z72i7hdynmk6r22z27h6tvur#atproto_labeler', 857 - ); 858 - expect(result).toEqual({ 859 - did: 'did:plc:z72i7hdynmk6r22z27h6tvur', 860 - serviceId: 'atproto_labeler', 861 - }); 862 - }); 912 + **Step 2: Run tests to verify** 863 913 864 - test('returns null for null/undefined', () => { 865 - // @ts-expect-error - Testing runtime handling of null 866 - expect(parseAtprotoProxyHeader(null)).toBe(null); 867 - // @ts-expect-error - Testing runtime handling of undefined 868 - expect(parseAtprotoProxyHeader(undefined)).toBe(null); 869 - expect(parseAtprotoProxyHeader('')).toBe(null); 870 - }); 914 + Run: `pnpm test packages/core/test/mst.test.js` 915 + Expected: All tests PASS 871 916 872 - test('returns null for header without fragment', () => { 873 - expect(parseAtprotoProxyHeader('did:web:api.bsky.app')).toBe(null); 874 - }); 917 + **Step 3: Commit** 875 918 876 - test('returns null for header with only fragment', () => { 877 - expect(parseAtprotoProxyHeader('#bsky_appview')).toBe(null); 878 - }); 919 + ```bash 920 + git add packages/core/test/mst.test.js 921 + git commit -m "test: extract mst tests from pds.test.js" 922 + ``` 879 923 880 - test('returns null for header with trailing fragment', () => { 881 - expect(parseAtprotoProxyHeader('did:web:api.bsky.app#')).toBe(null); 882 - }); 883 - }); 924 + --- 884 925 885 - describe('getKnownServiceUrl', () => { 886 - test('returns URL for known Bluesky AppView', () => { 887 - const result = getKnownServiceUrl('did:web:api.bsky.app', 'bsky_appview'); 888 - expect(result).toBe(BSKY_APPVIEW_URL); 889 - }); 926 + ### Task 5: Create scope.test.js 890 927 891 - test('returns null for unknown service DID', () => { 892 - const result = getKnownServiceUrl( 893 - 'did:web:unknown.service', 894 - 'bsky_appview', 895 - ); 896 - expect(result).toBe(null); 897 - }); 928 + **Files:** 929 + - Create: `packages/core/test/scope.test.js` 898 930 899 - test('returns null for unknown service ID', () => { 900 - const result = getKnownServiceUrl( 901 - 'did:web:api.bsky.app', 902 - 'unknown_service', 903 - ); 904 - expect(result).toBe(null); 905 - }); 931 + **Step 1: Create scope.test.js** 906 932 907 - test('returns null for both unknown', () => { 908 - const result = getKnownServiceUrl('did:web:unknown', 'unknown'); 909 - expect(result).toBe(null); 910 - }); 911 - }); 912 - }); 933 + ```javascript 934 + import { describe, expect, test } from 'vitest'; 935 + import { 936 + matchesMime, 937 + parseBlobScope, 938 + parseRepoScope, 939 + parseScopesForDisplay, 940 + ScopePermissions, 941 + } from '@pds/core/scope'; 913 942 914 943 describe('Scope Parsing', () => { 915 944 describe('parseRepoScope', () => { ··· 1210 1239 expect(result.blobPermissions).toEqual([]); 1211 1240 }); 1212 1241 }); 1242 + ``` 1243 + 1244 + **Step 2: Run tests to verify** 1245 + 1246 + Run: `pnpm test packages/core/test/scope.test.js` 1247 + Expected: All tests PASS 1248 + 1249 + **Step 3: Commit** 1250 + 1251 + ```bash 1252 + git add packages/core/test/scope.test.js 1253 + git commit -m "test: extract scope tests from pds.test.js" 1254 + ``` 1255 + 1256 + --- 1257 + 1258 + ### Task 6: Create oauth.test.js 1259 + 1260 + **Files:** 1261 + - Create: `packages/core/test/oauth.test.js` 1262 + 1263 + **Step 1: Create oauth.test.js** 1264 + 1265 + ```javascript 1266 + import { describe, expect, test } from 'vitest'; 1267 + import { 1268 + getLoopbackClientMetadata, 1269 + isLoopbackClient, 1270 + validateClientMetadata, 1271 + } from '@pds/core/oauth'; 1272 + 1273 + describe('Client Metadata', () => { 1274 + test('isLoopbackClient detects localhost', () => { 1275 + expect(isLoopbackClient('http://localhost:8080')).toBe(true); 1276 + expect(isLoopbackClient('http://127.0.0.1:3000')).toBe(true); 1277 + expect(isLoopbackClient('https://example.com')).toBe(false); 1278 + }); 1279 + 1280 + test('getLoopbackClientMetadata returns permissive defaults', () => { 1281 + const metadata = getLoopbackClientMetadata('http://localhost:8080'); 1282 + expect(metadata.client_id).toBe('http://localhost:8080'); 1283 + expect(metadata.grant_types.includes('authorization_code')).toBe(true); 1284 + expect(metadata.dpop_bound_access_tokens).toBe(true); 1285 + }); 1286 + 1287 + test('validateClientMetadata rejects mismatched client_id', () => { 1288 + const metadata = { 1289 + client_id: 'https://other.com/metadata.json', 1290 + redirect_uris: ['https://example.com/callback'], 1291 + grant_types: ['authorization_code'], 1292 + response_types: ['code'], 1293 + }; 1294 + expect(() => 1295 + validateClientMetadata(metadata, 'https://example.com/metadata.json'), 1296 + ).toThrow(/client_id mismatch/); 1297 + }); 1298 + }); 1299 + ``` 1300 + 1301 + **Step 2: Run tests to verify** 1302 + 1303 + Run: `pnpm test packages/core/test/oauth.test.js` 1304 + Expected: All tests PASS 1305 + 1306 + **Step 3: Commit** 1307 + 1308 + ```bash 1309 + git add packages/core/test/oauth.test.js 1310 + git commit -m "test: extract oauth tests from pds.test.js" 1311 + ``` 1312 + 1313 + --- 1314 + 1315 + ### Task 7: Create pds.test.js 1316 + 1317 + **Files:** 1318 + - Create: `packages/core/test/pds.test.js` 1319 + 1320 + **Step 1: Create pds.test.js** 1321 + 1322 + ```javascript 1323 + import { describe, expect, test } from 'vitest'; 1324 + import { 1325 + getKnownServiceUrl, 1326 + parseAtprotoProxyHeader, 1327 + } from '@pds/core/pds'; 1328 + 1329 + // Internal constant - not exported from pds.js due to Cloudflare Workers limitation 1330 + const BSKY_APPVIEW_URL = 'https://api.bsky.app'; 1331 + 1332 + describe('Proxy Utilities', () => { 1333 + describe('parseAtprotoProxyHeader', () => { 1334 + test('parses valid header', () => { 1335 + const result = parseAtprotoProxyHeader( 1336 + 'did:web:api.bsky.app#bsky_appview', 1337 + ); 1338 + expect(result).toEqual({ 1339 + did: 'did:web:api.bsky.app', 1340 + serviceId: 'bsky_appview', 1341 + }); 1342 + }); 1343 + 1344 + test('parses header with did:plc', () => { 1345 + const result = parseAtprotoProxyHeader( 1346 + 'did:plc:z72i7hdynmk6r22z27h6tvur#atproto_labeler', 1347 + ); 1348 + expect(result).toEqual({ 1349 + did: 'did:plc:z72i7hdynmk6r22z27h6tvur', 1350 + serviceId: 'atproto_labeler', 1351 + }); 1352 + }); 1353 + 1354 + test('returns null for null/undefined', () => { 1355 + // @ts-expect-error - Testing runtime handling of null 1356 + expect(parseAtprotoProxyHeader(null)).toBe(null); 1357 + // @ts-expect-error - Testing runtime handling of undefined 1358 + expect(parseAtprotoProxyHeader(undefined)).toBe(null); 1359 + expect(parseAtprotoProxyHeader('')).toBe(null); 1360 + }); 1361 + 1362 + test('returns null for header without fragment', () => { 1363 + expect(parseAtprotoProxyHeader('did:web:api.bsky.app')).toBe(null); 1364 + }); 1365 + 1366 + test('returns null for header with only fragment', () => { 1367 + expect(parseAtprotoProxyHeader('#bsky_appview')).toBe(null); 1368 + }); 1369 + 1370 + test('returns null for header with trailing fragment', () => { 1371 + expect(parseAtprotoProxyHeader('did:web:api.bsky.app#')).toBe(null); 1372 + }); 1373 + }); 1374 + 1375 + describe('getKnownServiceUrl', () => { 1376 + test('returns URL for known Bluesky AppView', () => { 1377 + const result = getKnownServiceUrl('did:web:api.bsky.app', 'bsky_appview'); 1378 + expect(result).toBe(BSKY_APPVIEW_URL); 1379 + }); 1380 + 1381 + test('returns null for unknown service DID', () => { 1382 + const result = getKnownServiceUrl( 1383 + 'did:web:unknown.service', 1384 + 'bsky_appview', 1385 + ); 1386 + expect(result).toBe(null); 1387 + }); 1388 + 1389 + test('returns null for unknown service ID', () => { 1390 + const result = getKnownServiceUrl( 1391 + 'did:web:api.bsky.app', 1392 + 'unknown_service', 1393 + ); 1394 + expect(result).toBe(null); 1395 + }); 1396 + 1397 + test('returns null for both unknown', () => { 1398 + const result = getKnownServiceUrl('did:web:unknown', 'unknown'); 1399 + expect(result).toBe(null); 1400 + }); 1401 + }); 1402 + }); 1403 + ``` 1404 + 1405 + **Step 2: Run tests to verify** 1406 + 1407 + Run: `pnpm test packages/core/test/pds.test.js` 1408 + Expected: All tests PASS 1409 + 1410 + **Step 3: Commit** 1411 + 1412 + ```bash 1413 + git add packages/core/test/pds.test.js 1414 + git commit -m "test: extract pds tests from pds.test.js" 1415 + ``` 1416 + 1417 + --- 1418 + 1419 + ### Task 8: Run all new tests together 1420 + 1421 + **Step 1: Run all core tests** 1422 + 1423 + Run: `pnpm test packages/core/test/` 1424 + Expected: All tests PASS (same count as original file) 1425 + 1426 + **Step 2: Verify test count matches original** 1427 + 1428 + The original `test/pds.test.js` had tests across all describe blocks. Verify the total test count is the same. 1429 + 1430 + --- 1431 + 1432 + ### Task 9: Delete original test file and verify 1433 + 1434 + **Files:** 1435 + - Delete: `test/pds.test.js` 1436 + 1437 + **Step 1: Delete original file** 1438 + 1439 + Run: `rm test/pds.test.js` 1440 + 1441 + **Step 2: Run full test suite** 1442 + 1443 + Run: `pnpm test` 1444 + Expected: All tests PASS (e2e.test.js + all new core tests) 1445 + 1446 + **Step 3: Commit** 1447 + 1448 + ```bash 1449 + git add -A 1450 + git commit -m "refactor: remove original pds.test.js after splitting into modules" 1451 + ``` 1452 + 1453 + --- 1454 + 1455 + ### Task 10: Final verification 1456 + 1457 + **Step 1: Run full test suite one more time** 1458 + 1459 + Run: `pnpm test` 1460 + Expected: All tests PASS 1461 + 1462 + **Step 2: Verify no regressions** 1463 + 1464 + Check that test count matches expectations and no tests were lost in the split.
+1 -1
tsconfig.build.json
··· 5 5 "declaration": true, 6 6 "emitDeclarationOnly": true 7 7 }, 8 - "include": ["packages/**/*.js"], 8 + "include": ["packages/*/src/**/*.js"], 9 9 "exclude": ["node_modules", "test", "examples", "packages/deno", "packages/blobs-deno"] 10 10 }
+5 -2
vitest.config.js
··· 2 2 3 3 export default defineConfig({ 4 4 test: { 5 - include: ['test/**/*.test.js'], 5 + include: [ 6 + 'packages/*/test/**/*.test.js', 7 + 'test/**/*.test.js' 8 + ], 6 9 testTimeout: 30000, 7 10 hookTimeout: 60000, 8 11 coverage: { 9 12 provider: 'v8', 10 13 reporter: ['text', 'html'], 11 - include: ['src/**/*.js'], 14 + include: ['packages/*/src/**/*.js'], 12 15 }, 13 16 }, 14 17 });
+1 -1
wrangler.toml
··· 1 1 name = "atproto-pds" 2 - main = "packages/cloudflare/index.js" 2 + main = "packages/cloudflare/src/index.js" 3 3 compatibility_date = "2024-01-01" 4 4 5 5 [[durable_objects.bindings]]