+902
docs/plans/2026-01-07-scope-validation.md
+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**