this repo has no description
1# OAuth Scope Validation Implementation Plan 2 3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 5**Goal:** Implement granular OAuth scope validation matching the official atproto PDS behavior for repo, blob, and transition scopes. 6 7**Architecture:** Add a `ScopePermissions` class that parses scope strings and provides `allowsRepo(collection, action)` and `allowsBlob(mime)` methods. Replace `hasRequiredScope()` calls with permission checks at each write endpoint. Support `atproto` and `transition:generic` as full-access scopes. 8 9**Tech Stack:** Pure JavaScript, no dependencies. Node.js test runner for TDD. 10 11--- 12 13## Task 1: Parse Repo Scopes 14 15**Files:** 16- Modify: `src/pds.js` (add after `hasRequiredScope` function ~line 4565) 17- Test: `test/pds.test.js` (add new describe block) 18 19**Step 1: Write the failing tests** 20 21Add to `test/pds.test.js`: 22 23```javascript 24import { 25 // ... existing imports ... 26 parseRepoScope, 27} from '../src/pds.js'; 28 29describe('Scope Parsing', () => { 30 describe('parseRepoScope', () => { 31 test('parses wildcard collection with single action', () => { 32 const result = parseRepoScope('repo:*:create'); 33 assert.deepStrictEqual(result, { 34 collections: ['*'], 35 actions: ['create'], 36 }); 37 }); 38 39 test('parses specific collection with single action', () => { 40 const result = parseRepoScope('repo:app.bsky.feed.post:create'); 41 assert.deepStrictEqual(result, { 42 collections: ['app.bsky.feed.post'], 43 actions: ['create'], 44 }); 45 }); 46 47 test('parses multiple actions', () => { 48 const result = parseRepoScope('repo:*:create,update,delete'); 49 assert.deepStrictEqual(result, { 50 collections: ['*'], 51 actions: ['create', 'update', 'delete'], 52 }); 53 }); 54 55 test('returns null for non-repo scope', () => { 56 assert.strictEqual(parseRepoScope('atproto'), null); 57 assert.strictEqual(parseRepoScope('blob:image/*'), null); 58 assert.strictEqual(parseRepoScope('transition:generic'), null); 59 }); 60 61 test('returns null for invalid repo scope', () => { 62 assert.strictEqual(parseRepoScope('repo:'), null); 63 assert.strictEqual(parseRepoScope('repo:foo'), null); 64 assert.strictEqual(parseRepoScope('repo::create'), null); 65 }); 66 }); 67}); 68``` 69 70**Step 2: Run tests to verify they fail** 71 72Run: `npm test` 73Expected: FAIL with "parseRepoScope is not exported" 74 75**Step 3: Write minimal implementation** 76 77Add to `src/pds.js` after the `hasRequiredScope` function (~line 4565): 78 79```javascript 80/** 81 * Parse a repo scope string into its components. 82 * Format: repo:<collection>:<action>[,<action>...] 83 * @param {string} scope - The scope string to parse 84 * @returns {{ collections: string[], actions: string[] } | null} Parsed scope or null if invalid 85 */ 86function parseRepoScope(scope) { 87 if (!scope.startsWith('repo:')) return null; 88 89 const rest = scope.slice(5); // Remove 'repo:' 90 const colonIdx = rest.lastIndexOf(':'); 91 if (colonIdx === -1 || colonIdx === 0 || colonIdx === rest.length - 1) { 92 return null; 93 } 94 95 const collection = rest.slice(0, colonIdx); 96 const actionsStr = rest.slice(colonIdx + 1); 97 98 if (!collection || !actionsStr) return null; 99 100 const actions = actionsStr.split(',').filter(a => a); 101 if (actions.length === 0) return null; 102 103 return { 104 collections: [collection], 105 actions, 106 }; 107} 108``` 109 110Add `parseRepoScope` to the exports at the end of the file. 111 112**Step 4: Run tests to verify they pass** 113 114Run: `npm test` 115Expected: PASS 116 117**Step 5: Commit** 118 119```bash 120git add src/pds.js test/pds.test.js 121git commit -m "feat(scope): add parseRepoScope function" 122``` 123 124--- 125 126## Task 2: Parse Blob Scopes with MIME Matching 127 128**Files:** 129- Modify: `src/pds.js` 130- Test: `test/pds.test.js` 131 132**Step 1: Write the failing tests** 133 134Add to test file: 135 136```javascript 137import { 138 // ... existing imports ... 139 parseBlobScope, 140 matchesMime, 141} from '../src/pds.js'; 142 143describe('parseBlobScope', () => { 144 test('parses wildcard MIME', () => { 145 const result = parseBlobScope('blob:*/*'); 146 assert.deepStrictEqual(result, { accept: ['*/*'] }); 147 }); 148 149 test('parses type wildcard', () => { 150 const result = parseBlobScope('blob:image/*'); 151 assert.deepStrictEqual(result, { accept: ['image/*'] }); 152 }); 153 154 test('parses specific MIME', () => { 155 const result = parseBlobScope('blob:image/png'); 156 assert.deepStrictEqual(result, { accept: ['image/png'] }); 157 }); 158 159 test('parses multiple MIMEs', () => { 160 const result = parseBlobScope('blob:image/png,image/jpeg'); 161 assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] }); 162 }); 163 164 test('returns null for non-blob scope', () => { 165 assert.strictEqual(parseBlobScope('atproto'), null); 166 assert.strictEqual(parseBlobScope('repo:*:create'), null); 167 }); 168}); 169 170describe('matchesMime', () => { 171 test('wildcard matches everything', () => { 172 assert.strictEqual(matchesMime('*/*', 'image/png'), true); 173 assert.strictEqual(matchesMime('*/*', 'video/mp4'), true); 174 }); 175 176 test('type wildcard matches same type', () => { 177 assert.strictEqual(matchesMime('image/*', 'image/png'), true); 178 assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true); 179 assert.strictEqual(matchesMime('image/*', 'video/mp4'), false); 180 }); 181 182 test('exact match', () => { 183 assert.strictEqual(matchesMime('image/png', 'image/png'), true); 184 assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false); 185 }); 186 187 test('case insensitive', () => { 188 assert.strictEqual(matchesMime('image/PNG', 'image/png'), true); 189 assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true); 190 }); 191}); 192``` 193 194**Step 2: Run tests to verify they fail** 195 196Run: `npm test` 197Expected: FAIL 198 199**Step 3: Write minimal implementation** 200 201```javascript 202/** 203 * Parse a blob scope string into its components. 204 * Format: blob:<mime>[,<mime>...] 205 * @param {string} scope - The scope string to parse 206 * @returns {{ accept: string[] } | null} Parsed scope or null if invalid 207 */ 208function parseBlobScope(scope) { 209 if (!scope.startsWith('blob:')) return null; 210 211 const mimeStr = scope.slice(5); // Remove 'blob:' 212 if (!mimeStr) return null; 213 214 const accept = mimeStr.split(',').filter(m => m); 215 if (accept.length === 0) return null; 216 217 return { accept }; 218} 219 220/** 221 * Check if a MIME pattern matches an actual MIME type. 222 * @param {string} pattern - MIME pattern (e.g., 'image/*', '*/*', 'image/png') 223 * @param {string} mime - Actual MIME type to check 224 * @returns {boolean} Whether the pattern matches 225 */ 226function matchesMime(pattern, mime) { 227 const p = pattern.toLowerCase(); 228 const m = mime.toLowerCase(); 229 230 if (p === '*/*') return true; 231 232 if (p.endsWith('/*')) { 233 const pType = p.slice(0, -2); 234 const mType = m.split('/')[0]; 235 return pType === mType; 236 } 237 238 return p === m; 239} 240``` 241 242Add exports. 243 244**Step 4: Run tests to verify they pass** 245 246Run: `npm test` 247Expected: PASS 248 249**Step 5: Commit** 250 251```bash 252git add src/pds.js test/pds.test.js 253git commit -m "feat(scope): add parseBlobScope and matchesMime functions" 254``` 255 256--- 257 258## Task 3: Create ScopePermissions Class 259 260**Files:** 261- Modify: `src/pds.js` 262- Test: `test/pds.test.js` 263 264**Step 1: Write the failing tests** 265 266```javascript 267import { 268 // ... existing imports ... 269 ScopePermissions, 270} from '../src/pds.js'; 271 272describe('ScopePermissions', () => { 273 describe('static scopes', () => { 274 test('atproto grants full access', () => { 275 const perms = new ScopePermissions('atproto'); 276 assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 277 assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); 278 assert.strictEqual(perms.allowsBlob('image/png'), true); 279 assert.strictEqual(perms.allowsBlob('video/mp4'), true); 280 }); 281 282 test('transition:generic grants full repo/blob access', () => { 283 const perms = new ScopePermissions('transition:generic'); 284 assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 285 assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); 286 assert.strictEqual(perms.allowsBlob('image/png'), true); 287 }); 288 }); 289 290 describe('repo scopes', () => { 291 test('wildcard collection allows any collection', () => { 292 const perms = new ScopePermissions('repo:*:create'); 293 assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 294 assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'create'), true); 295 assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false); 296 }); 297 298 test('specific collection restricts to that collection', () => { 299 const perms = new ScopePermissions('repo:app.bsky.feed.post:create'); 300 assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 301 assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'create'), false); 302 }); 303 304 test('multiple actions', () => { 305 const perms = new ScopePermissions('repo:*:create,update'); 306 assert.strictEqual(perms.allowsRepo('x', 'create'), true); 307 assert.strictEqual(perms.allowsRepo('x', 'update'), true); 308 assert.strictEqual(perms.allowsRepo('x', 'delete'), false); 309 }); 310 311 test('multiple scopes combine', () => { 312 const perms = new ScopePermissions('repo:app.bsky.feed.post:create repo:app.bsky.feed.like:delete'); 313 assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 314 assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'delete'), true); 315 assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false); 316 }); 317 }); 318 319 describe('blob scopes', () => { 320 test('wildcard allows any MIME', () => { 321 const perms = new ScopePermissions('blob:*/*'); 322 assert.strictEqual(perms.allowsBlob('image/png'), true); 323 assert.strictEqual(perms.allowsBlob('video/mp4'), true); 324 }); 325 326 test('type wildcard restricts to type', () => { 327 const perms = new ScopePermissions('blob:image/*'); 328 assert.strictEqual(perms.allowsBlob('image/png'), true); 329 assert.strictEqual(perms.allowsBlob('image/jpeg'), true); 330 assert.strictEqual(perms.allowsBlob('video/mp4'), false); 331 }); 332 333 test('specific MIME restricts exactly', () => { 334 const perms = new ScopePermissions('blob:image/png'); 335 assert.strictEqual(perms.allowsBlob('image/png'), true); 336 assert.strictEqual(perms.allowsBlob('image/jpeg'), false); 337 }); 338 }); 339 340 describe('empty/no scope', () => { 341 test('no scope denies everything', () => { 342 const perms = new ScopePermissions(''); 343 assert.strictEqual(perms.allowsRepo('x', 'create'), false); 344 assert.strictEqual(perms.allowsBlob('image/png'), false); 345 }); 346 347 test('undefined scope denies everything', () => { 348 const perms = new ScopePermissions(undefined); 349 assert.strictEqual(perms.allowsRepo('x', 'create'), false); 350 }); 351 }); 352 353 describe('assertRepo', () => { 354 test('throws ScopeMissingError when denied', () => { 355 const perms = new ScopePermissions('repo:app.bsky.feed.post:create'); 356 assert.throws( 357 () => perms.assertRepo('app.bsky.feed.like', 'create'), 358 { message: /Missing required scope/ } 359 ); 360 }); 361 362 test('does not throw when allowed', () => { 363 const perms = new ScopePermissions('repo:app.bsky.feed.post:create'); 364 assert.doesNotThrow(() => perms.assertRepo('app.bsky.feed.post', 'create')); 365 }); 366 }); 367 368 describe('assertBlob', () => { 369 test('throws ScopeMissingError when denied', () => { 370 const perms = new ScopePermissions('blob:image/*'); 371 assert.throws( 372 () => perms.assertBlob('video/mp4'), 373 { message: /Missing required scope/ } 374 ); 375 }); 376 377 test('does not throw when allowed', () => { 378 const perms = new ScopePermissions('blob:image/*'); 379 assert.doesNotThrow(() => perms.assertBlob('image/png')); 380 }); 381 }); 382}); 383``` 384 385**Step 2: Run tests to verify they fail** 386 387Run: `npm test` 388Expected: FAIL 389 390**Step 3: Write minimal implementation** 391 392```javascript 393/** 394 * Error thrown when a required scope is missing. 395 */ 396class ScopeMissingError extends Error { 397 /** 398 * @param {string} scope - The missing scope 399 */ 400 constructor(scope) { 401 super(`Missing required scope "${scope}"`); 402 this.name = 'ScopeMissingError'; 403 this.scope = scope; 404 this.status = 403; 405 } 406} 407 408/** 409 * Parses and checks OAuth scope permissions. 410 */ 411class ScopePermissions { 412 /** 413 * @param {string | undefined} scopeString - Space-separated scope string 414 */ 415 constructor(scopeString) { 416 /** @type {Set<string>} */ 417 this.scopes = new Set(scopeString ? scopeString.split(' ').filter(s => s) : []); 418 419 /** @type {Array<{ collections: string[], actions: string[] }>} */ 420 this.repoPermissions = []; 421 422 /** @type {Array<{ accept: string[] }>} */ 423 this.blobPermissions = []; 424 425 for (const scope of this.scopes) { 426 const repo = parseRepoScope(scope); 427 if (repo) this.repoPermissions.push(repo); 428 429 const blob = parseBlobScope(scope); 430 if (blob) this.blobPermissions.push(blob); 431 } 432 } 433 434 /** 435 * Check if full access is granted (atproto or transition:generic). 436 * @returns {boolean} 437 */ 438 hasFullAccess() { 439 return this.scopes.has('atproto') || this.scopes.has('transition:generic'); 440 } 441 442 /** 443 * Check if a repo operation is allowed. 444 * @param {string} collection - The collection NSID 445 * @param {string} action - The action (create, update, delete) 446 * @returns {boolean} 447 */ 448 allowsRepo(collection, action) { 449 if (this.hasFullAccess()) return true; 450 451 for (const perm of this.repoPermissions) { 452 const collectionMatch = perm.collections.includes('*') || perm.collections.includes(collection); 453 const actionMatch = perm.actions.includes(action); 454 if (collectionMatch && actionMatch) return true; 455 } 456 457 return false; 458 } 459 460 /** 461 * Assert that a repo operation is allowed, throwing if not. 462 * @param {string} collection - The collection NSID 463 * @param {string} action - The action (create, update, delete) 464 * @throws {ScopeMissingError} 465 */ 466 assertRepo(collection, action) { 467 if (!this.allowsRepo(collection, action)) { 468 throw new ScopeMissingError(`repo:${collection}:${action}`); 469 } 470 } 471 472 /** 473 * Check if a blob operation is allowed. 474 * @param {string} mime - The MIME type of the blob 475 * @returns {boolean} 476 */ 477 allowsBlob(mime) { 478 if (this.hasFullAccess()) return true; 479 480 for (const perm of this.blobPermissions) { 481 for (const pattern of perm.accept) { 482 if (matchesMime(pattern, mime)) return true; 483 } 484 } 485 486 return false; 487 } 488 489 /** 490 * Assert that a blob operation is allowed, throwing if not. 491 * @param {string} mime - The MIME type of the blob 492 * @throws {ScopeMissingError} 493 */ 494 assertBlob(mime) { 495 if (!this.allowsBlob(mime)) { 496 throw new ScopeMissingError(`blob:${mime}`); 497 } 498 } 499} 500``` 501 502Add exports. 503 504**Step 4: Run tests to verify they pass** 505 506Run: `npm test` 507Expected: PASS 508 509**Step 5: Commit** 510 511```bash 512git add src/pds.js test/pds.test.js 513git commit -m "feat(scope): add ScopePermissions class with repo/blob checking" 514``` 515 516--- 517 518## Task 4: Integrate Scope Checking into createRecord 519 520**Files:** 521- Modify: `src/pds.js` (handleRepoWrite function and createRecord handler) 522- Test: `test/e2e.test.js` (add scope enforcement tests) 523 524**Step 1: Understand the current flow** 525 526The `handleRepoWrite` function at line ~4597 currently does: 527```javascript 528if (!hasRequiredScope(auth.scope, 'atproto')) { 529 return errorResponse('Forbidden', 'Insufficient scope for repo write', 403); 530} 531``` 532 533This needs to be replaced with per-endpoint scope checking. The collection is in `body.collection`. 534 535**Step 2: Modify handleRepoWrite to accept collection and action** 536 537Update `handleRepoWrite` in `src/pds.js`: 538 539```javascript 540/** 541 * @param {Request} request 542 * @param {Env} env 543 * @param {string} collection - The collection being written to 544 * @param {string} action - The action being performed (create, update, delete) 545 */ 546async function handleRepoWrite(request, env, collection, action) { 547 const auth = await requireAuth(request, env); 548 if ('error' in auth) return auth.error; 549 550 // Validate scope for repo write using granular permissions 551 if (auth.scope !== undefined) { 552 const permissions = new ScopePermissions(auth.scope); 553 if (!permissions.allowsRepo(collection, action)) { 554 return errorResponse( 555 'Forbidden', 556 `Missing required scope "repo:${collection}:${action}"`, 557 403, 558 ); 559 } 560 } 561 // Legacy tokens without scope are trusted (backward compat) 562 563 // ... rest of function 564} 565``` 566 567**Step 3: Update createRecord to pass collection and action** 568 569Find the createRecord handler in the routes object and update it to extract collection before calling handleRepoWrite. 570 571Since createRecord is POST, the collection comes from the body. We need to restructure slightly: 572 573```javascript 574// In the route handler for com.atproto.repo.createRecord 575async (request, env) => { 576 const auth = await requireAuth(request, env); 577 if ('error' in auth) return auth.error; 578 579 const body = await request.json(); 580 const collection = body.collection; 581 582 if (!collection) { 583 return errorResponse('InvalidRequest', 'missing collection param', 400); 584 } 585 586 // Validate scope 587 if (auth.scope !== undefined) { 588 const permissions = new ScopePermissions(auth.scope); 589 if (!permissions.allowsRepo(collection, 'create')) { 590 return errorResponse( 591 'Forbidden', 592 `Missing required scope "repo:${collection}:create"`, 593 403, 594 ); 595 } 596 } 597 598 // Continue with existing logic... 599} 600``` 601 602**Step 4: Write E2E test for scope enforcement** 603 604Add to `test/e2e.test.js`: 605 606```javascript 607describe('Scope Enforcement', () => { 608 test('createRecord denied with insufficient scope', async () => { 609 // Create OAuth token with limited scope 610 const limitedToken = await getOAuthToken('repo:app.bsky.feed.like:create'); 611 612 const response = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.createRecord`, { 613 method: 'POST', 614 headers: { 615 'Content-Type': 'application/json', 616 'Authorization': `DPoP ${limitedToken}`, 617 'DPoP': dpopProof, 618 }, 619 body: JSON.stringify({ 620 repo: TEST_DID, 621 collection: 'app.bsky.feed.post', // Not allowed by scope 622 record: { text: 'test', createdAt: new Date().toISOString() }, 623 }), 624 }); 625 626 assert.strictEqual(response.status, 403); 627 const body = await response.json(); 628 assert.ok(body.message.includes('Missing required scope')); 629 }); 630 631 test('createRecord allowed with matching scope', async () => { 632 const validToken = await getOAuthToken('repo:app.bsky.feed.post:create'); 633 634 const response = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.createRecord`, { 635 method: 'POST', 636 headers: { 637 'Content-Type': 'application/json', 638 'Authorization': `DPoP ${validToken}`, 639 'DPoP': dpopProof, 640 }, 641 body: JSON.stringify({ 642 repo: TEST_DID, 643 collection: 'app.bsky.feed.post', 644 record: { text: 'test', createdAt: new Date().toISOString() }, 645 }), 646 }); 647 648 assert.strictEqual(response.status, 200); 649 }); 650}); 651``` 652 653**Step 5: Run E2E tests** 654 655Run: `npm run test:e2e` 656Expected: PASS 657 658**Step 6: Commit** 659 660```bash 661git add src/pds.js test/e2e.test.js 662git commit -m "feat(scope): enforce granular scopes on createRecord" 663``` 664 665--- 666 667## Task 5: Integrate Scope Checking into putRecord 668 669**Files:** 670- Modify: `src/pds.js` 671 672**Step 1: Update putRecord handler** 673 674putRecord requires BOTH create AND update permissions (since it can do either): 675 676```javascript 677// In putRecord handler 678if (auth.scope !== undefined) { 679 const permissions = new ScopePermissions(auth.scope); 680 if (!permissions.allowsRepo(collection, 'create') || !permissions.allowsRepo(collection, 'update')) { 681 const missing = !permissions.allowsRepo(collection, 'create') ? 'create' : 'update'; 682 return errorResponse( 683 'Forbidden', 684 `Missing required scope "repo:${collection}:${missing}"`, 685 403, 686 ); 687 } 688} 689``` 690 691**Step 2: Run tests** 692 693Run: `npm test && npm run test:e2e` 694Expected: PASS 695 696**Step 3: Commit** 697 698```bash 699git add src/pds.js 700git commit -m "feat(scope): enforce granular scopes on putRecord" 701``` 702 703--- 704 705## Task 6: Integrate Scope Checking into deleteRecord 706 707**Files:** 708- Modify: `src/pds.js` 709 710**Step 1: Update deleteRecord handler** 711 712```javascript 713// In deleteRecord handler 714if (auth.scope !== undefined) { 715 const permissions = new ScopePermissions(auth.scope); 716 if (!permissions.allowsRepo(collection, 'delete')) { 717 return errorResponse( 718 'Forbidden', 719 `Missing required scope "repo:${collection}:delete"`, 720 403, 721 ); 722 } 723} 724``` 725 726**Step 2: Run tests** 727 728Run: `npm test && npm run test:e2e` 729Expected: PASS 730 731**Step 3: Commit** 732 733```bash 734git add src/pds.js 735git commit -m "feat(scope): enforce granular scopes on deleteRecord" 736``` 737 738--- 739 740## Task 7: Integrate Scope Checking into applyWrites 741 742**Files:** 743- Modify: `src/pds.js` 744 745**Step 1: Update applyWrites handler** 746 747applyWrites must check each write operation individually: 748 749```javascript 750// In applyWrites handler 751if (auth.scope !== undefined) { 752 const permissions = new ScopePermissions(auth.scope); 753 754 for (const write of writes) { 755 const collection = write.collection; 756 let action; 757 758 if (write.$type === 'com.atproto.repo.applyWrites#create') { 759 action = 'create'; 760 } else if (write.$type === 'com.atproto.repo.applyWrites#update') { 761 action = 'update'; 762 } else if (write.$type === 'com.atproto.repo.applyWrites#delete') { 763 action = 'delete'; 764 } else { 765 continue; 766 } 767 768 if (!permissions.allowsRepo(collection, action)) { 769 return errorResponse( 770 'Forbidden', 771 `Missing required scope "repo:${collection}:${action}"`, 772 403, 773 ); 774 } 775 } 776} 777``` 778 779**Step 2: Run tests** 780 781Run: `npm test && npm run test:e2e` 782Expected: PASS 783 784**Step 3: Commit** 785 786```bash 787git add src/pds.js 788git commit -m "feat(scope): enforce granular scopes on applyWrites" 789``` 790 791--- 792 793## Task 8: Integrate Scope Checking into uploadBlob 794 795**Files:** 796- Modify: `src/pds.js` (handleBlobUpload function) 797 798**Step 1: Update handleBlobUpload** 799 800The MIME type comes from the Content-Type header: 801 802```javascript 803async function handleBlobUpload(request, env) { 804 const auth = await requireAuth(request, env); 805 if ('error' in auth) return auth.error; 806 807 const contentType = request.headers.get('content-type') || 'application/octet-stream'; 808 809 // Validate scope for blob upload 810 if (auth.scope !== undefined) { 811 const permissions = new ScopePermissions(auth.scope); 812 if (!permissions.allowsBlob(contentType)) { 813 return errorResponse( 814 'Forbidden', 815 `Missing required scope "blob:${contentType}"`, 816 403, 817 ); 818 } 819 } 820 821 // ... rest of function 822} 823``` 824 825**Step 2: Run tests** 826 827Run: `npm test && npm run test:e2e` 828Expected: PASS 829 830**Step 3: Commit** 831 832```bash 833git add src/pds.js 834git commit -m "feat(scope): enforce granular scopes on uploadBlob with MIME matching" 835``` 836 837--- 838 839## Task 9: Remove Old hasRequiredScope Calls 840 841**Files:** 842- Modify: `src/pds.js` 843 844**Step 1: Search and remove old calls** 845 846Find all remaining uses of `hasRequiredScope` and either: 847- Remove them (if replaced by ScopePermissions) 848- Keep for legacy non-OAuth paths if needed 849 850**Step 2: Run all tests** 851 852Run: `npm test && npm run test:e2e` 853Expected: PASS 854 855**Step 3: Commit** 856 857```bash 858git add src/pds.js 859git commit -m "refactor(scope): remove deprecated hasRequiredScope function" 860``` 861 862--- 863 864## Task 10: Update scope-comparison.md 865 866**Files:** 867- Modify: `docs/scope-comparison.md` 868 869**Step 1: Update status in comparison doc** 870 871Change the pds.js column entries to reflect new implementation: 872 873- `atproto`: "Full access" 874- `transition:generic`: "Full access" 875- `repo:<collection>:<action>`: "Full parsing + enforcement" 876- `blob:<mime>`: "Full parsing + enforcement" 877 878**Step 2: Commit** 879 880```bash 881git add docs/scope-comparison.md 882git commit -m "docs: update scope comparison with implementation status" 883``` 884 885--- 886 887## Summary 888 889| Task | Description | Est. Time | 890|------|-------------|-----------| 891| 1 | Parse repo scopes | 5 min | 892| 2 | Parse blob scopes + MIME matching | 5 min | 893| 3 | ScopePermissions class | 10 min | 894| 4 | Integrate into createRecord | 10 min | 895| 5 | Integrate into putRecord | 5 min | 896| 6 | Integrate into deleteRecord | 5 min | 897| 7 | Integrate into applyWrites | 10 min | 898| 8 | Integrate into uploadBlob | 5 min | 899| 9 | Remove old hasRequiredScope | 5 min | 900| 10 | Update docs | 5 min | 901 902**Total: ~65 minutes**