A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

docs: add scope validation implementation plan

10-task TDD plan for implementing OAuth scope validation:
- Repo scope parsing and enforcement
- Blob scope parsing with MIME wildcard matching
- ScopePermissions class
- Per-endpoint integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Changed files
+902
docs
+902
docs/plans/2026-01-07-scope-validation.md
··· 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 + 21 + Add to `test/pds.test.js`: 22 + 23 + ```javascript 24 + import { 25 + // ... existing imports ... 26 + parseRepoScope, 27 + } from '../src/pds.js'; 28 + 29 + describe('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 + 72 + Run: `npm test` 73 + Expected: FAIL with "parseRepoScope is not exported" 74 + 75 + **Step 3: Write minimal implementation** 76 + 77 + Add 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 + */ 86 + function 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 + 110 + Add `parseRepoScope` to the exports at the end of the file. 111 + 112 + **Step 4: Run tests to verify they pass** 113 + 114 + Run: `npm test` 115 + Expected: PASS 116 + 117 + **Step 5: Commit** 118 + 119 + ```bash 120 + git add src/pds.js test/pds.test.js 121 + git 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 + 134 + Add to test file: 135 + 136 + ```javascript 137 + import { 138 + // ... existing imports ... 139 + parseBlobScope, 140 + matchesMime, 141 + } from '../src/pds.js'; 142 + 143 + describe('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 + 170 + describe('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 + 196 + Run: `npm test` 197 + Expected: 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 + */ 208 + function 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 + */ 226 + function 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 + 242 + Add exports. 243 + 244 + **Step 4: Run tests to verify they pass** 245 + 246 + Run: `npm test` 247 + Expected: PASS 248 + 249 + **Step 5: Commit** 250 + 251 + ```bash 252 + git add src/pds.js test/pds.test.js 253 + git 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 267 + import { 268 + // ... existing imports ... 269 + ScopePermissions, 270 + } from '../src/pds.js'; 271 + 272 + describe('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 + 387 + Run: `npm test` 388 + Expected: FAIL 389 + 390 + **Step 3: Write minimal implementation** 391 + 392 + ```javascript 393 + /** 394 + * Error thrown when a required scope is missing. 395 + */ 396 + class 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 + */ 411 + class 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 + 502 + Add exports. 503 + 504 + **Step 4: Run tests to verify they pass** 505 + 506 + Run: `npm test` 507 + Expected: PASS 508 + 509 + **Step 5: Commit** 510 + 511 + ```bash 512 + git add src/pds.js test/pds.test.js 513 + git 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 + 526 + The `handleRepoWrite` function at line ~4597 currently does: 527 + ```javascript 528 + if (!hasRequiredScope(auth.scope, 'atproto')) { 529 + return errorResponse('Forbidden', 'Insufficient scope for repo write', 403); 530 + } 531 + ``` 532 + 533 + This 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 + 537 + Update `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 + */ 546 + async 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 + 569 + Find the createRecord handler in the routes object and update it to extract collection before calling handleRepoWrite. 570 + 571 + Since 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 575 + async (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 + 604 + Add to `test/e2e.test.js`: 605 + 606 + ```javascript 607 + describe('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 + 655 + Run: `npm run test:e2e` 656 + Expected: PASS 657 + 658 + **Step 6: Commit** 659 + 660 + ```bash 661 + git add src/pds.js test/e2e.test.js 662 + git 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 + 674 + putRecord requires BOTH create AND update permissions (since it can do either): 675 + 676 + ```javascript 677 + // In putRecord handler 678 + if (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 + 693 + Run: `npm test && npm run test:e2e` 694 + Expected: PASS 695 + 696 + **Step 3: Commit** 697 + 698 + ```bash 699 + git add src/pds.js 700 + git 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 714 + if (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 + 728 + Run: `npm test && npm run test:e2e` 729 + Expected: PASS 730 + 731 + **Step 3: Commit** 732 + 733 + ```bash 734 + git add src/pds.js 735 + git 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 + 747 + applyWrites must check each write operation individually: 748 + 749 + ```javascript 750 + // In applyWrites handler 751 + if (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 + 781 + Run: `npm test && npm run test:e2e` 782 + Expected: PASS 783 + 784 + **Step 3: Commit** 785 + 786 + ```bash 787 + git add src/pds.js 788 + git 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 + 800 + The MIME type comes from the Content-Type header: 801 + 802 + ```javascript 803 + async 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 + 827 + Run: `npm test && npm run test:e2e` 828 + Expected: PASS 829 + 830 + **Step 3: Commit** 831 + 832 + ```bash 833 + git add src/pds.js 834 + git 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 + 846 + Find 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 + 852 + Run: `npm test && npm run test:e2e` 853 + Expected: PASS 854 + 855 + **Step 3: Commit** 856 + 857 + ```bash 858 + git add src/pds.js 859 + git 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 + 871 + Change 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 881 + git add docs/scope-comparison.md 882 + git 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**