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**