+57
CHANGELOG.md
+57
CHANGELOG.md
···
6
6
7
7
## [Unreleased]
8
8
9
+
## [0.6.0] - 2026-01-09
10
+
11
+
### Added
12
+
13
+
- **Profile card on OAuth consent page** showing authorizing user's identity
14
+
- Displays avatar, display name, and handle from Bluesky public API
15
+
- Fetches profile client-side using `login_hint` parameter
16
+
- Graceful degradation if fetch fails (shows handle only)
17
+
18
+
## [0.5.0] - 2026-01-08
19
+
20
+
### Added
21
+
22
+
- **Direct OAuth authorization** without requiring Pushed Authorization Requests (PAR)
23
+
- `/oauth/authorize` now accepts direct query parameters (client_id, redirect_uri, code_challenge, etc.)
24
+
- Creates authorization request record on-the-fly, same as PAR flow
25
+
- DPoP binding deferred to token exchange time for direct auth flows
26
+
- Matches official AT Protocol PDS behavior
27
+
28
+
### Changed
29
+
30
+
- AS metadata: `require_pushed_authorization_requests` now `false`
31
+
- Extracted `validateAuthorizationParameters()` helper shared between PAR and direct auth
32
+
33
+
## [0.4.0] - 2026-01-08
34
+
35
+
### Added
36
+
37
+
- **Foreign DID proxying** via `atproto-proxy` header
38
+
- `parseAtprotoProxyHeader()` parses `did:web:api.bsky.app#bsky_appview` format
39
+
- `getKnownServiceUrl()` maps known service DIDs to URLs
40
+
- `proxyToService()` generic proxy utility with header forwarding
41
+
- Repo endpoints (getRecord, listRecords, describeRepo) support explicit proxying
42
+
- Returns appropriate errors for malformed headers or unknown services
43
+
- Unit tests for proxy utilities
44
+
- E2E tests for foreign DID proxying behavior
45
+
46
+
### Changed
47
+
48
+
- Refactored `handleAppViewProxy` to use shared `proxyToService` utility
49
+
50
+
## [0.3.0] - 2026-01-08
51
+
52
+
### Added
53
+
54
+
- **Granular OAuth scope enforcement** on repo and blob endpoints
55
+
- `parseRepoScope()` parses `repo:collection?action=create&action=update` format
56
+
- `parseBlobScope()` parses `blob:image/*` format with MIME wildcards
57
+
- `ScopePermissions` class for checking repo/blob permissions
58
+
- Enforced on createRecord, putRecord, deleteRecord, applyWrites, uploadBlob
59
+
- **Consent page permissions table** displaying scopes in human-readable format
60
+
- Identity-only: "wants to uniquely identify you" message
61
+
- Granular scopes: Table with Collection + Create/Update/Delete columns
62
+
- Full access: Warning banner for `transition:generic`
63
+
- `parseScopesForDisplay()` helper for consent page rendering
64
+
- E2E tests for scope enforcement and consent page display
65
+
9
66
## [0.2.0] - 2026-01-07
10
67
11
68
### Added
+31
docker-compose.yml
+31
docker-compose.yml
···
1
+
services:
2
+
plc:
3
+
build:
4
+
context: https://github.com/did-method-plc/did-method-plc.git
5
+
dockerfile: packages/server/Dockerfile
6
+
ports:
7
+
- "2582:2582"
8
+
environment:
9
+
- DATABASE_URL=postgres://plc:plc@postgres:5432/plc
10
+
- PORT=2582
11
+
command: ["dumb-init", "node", "--enable-source-maps", "../dist/bin.js"]
12
+
depends_on:
13
+
postgres:
14
+
condition: service_healthy
15
+
16
+
postgres:
17
+
image: postgres:16-alpine
18
+
environment:
19
+
- POSTGRES_USER=plc
20
+
- POSTGRES_PASSWORD=plc
21
+
- POSTGRES_DB=plc
22
+
volumes:
23
+
- plc_data:/var/lib/postgresql/data
24
+
healthcheck:
25
+
test: ["CMD-SHELL", "pg_isready -U plc"]
26
+
interval: 2s
27
+
timeout: 5s
28
+
retries: 10
29
+
30
+
volumes:
31
+
plc_data:
+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**
+563
docs/plans/2026-01-08-consent-permissions-table.md
+563
docs/plans/2026-01-08-consent-permissions-table.md
···
1
+
# Consent Page Permissions Table Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Display OAuth scopes as a human-readable permissions table on the consent page, matching official atproto PDS behavior.
6
+
7
+
**Architecture:** Update `parseRepoScope()` to handle official query parameter format, add display helpers to parse scopes into a permissions map, render as HTML table with Create/Update/Delete columns. Three display modes: identity-only (no table), granular scopes (table), full access (warning banner).
8
+
9
+
**Tech Stack:** Vanilla JavaScript, HTML/CSS (inline in template string)
10
+
11
+
---
12
+
13
+
### Task 1: Update parseRepoScope to Handle Query Parameters
14
+
15
+
**Files:**
16
+
- Modify: `src/pds.js:4558-4580` (parseRepoScope function)
17
+
- Test: `test/pds.test.js` (parseRepoScope tests)
18
+
19
+
**Step 1: Write failing tests for new format**
20
+
21
+
Add to existing parseRepoScope test block in `test/pds.test.js`:
22
+
23
+
```javascript
24
+
test('parses repo scope with query parameter action', () => {
25
+
const result = parseRepoScope('repo:app.bsky.feed.post?action=create');
26
+
assert.deepStrictEqual(result, {
27
+
collection: 'app.bsky.feed.post',
28
+
actions: ['create'],
29
+
});
30
+
});
31
+
32
+
test('parses repo scope with multiple query parameter actions', () => {
33
+
const result = parseRepoScope('repo:app.bsky.feed.post?action=create&action=update');
34
+
assert.deepStrictEqual(result, {
35
+
collection: 'app.bsky.feed.post',
36
+
actions: ['create', 'update'],
37
+
});
38
+
});
39
+
40
+
test('parses repo scope without actions as all actions', () => {
41
+
const result = parseRepoScope('repo:app.bsky.feed.post');
42
+
assert.deepStrictEqual(result, {
43
+
collection: 'app.bsky.feed.post',
44
+
actions: ['create', 'update', 'delete'],
45
+
});
46
+
});
47
+
48
+
test('parses wildcard collection with action', () => {
49
+
const result = parseRepoScope('repo:*?action=create');
50
+
assert.deepStrictEqual(result, {
51
+
collection: '*',
52
+
actions: ['create'],
53
+
});
54
+
});
55
+
56
+
test('parses query-only format', () => {
57
+
const result = parseRepoScope('repo?collection=app.bsky.feed.post&action=create');
58
+
assert.deepStrictEqual(result, {
59
+
collection: 'app.bsky.feed.post',
60
+
actions: ['create'],
61
+
});
62
+
});
63
+
```
64
+
65
+
**Step 2: Run tests to verify they fail**
66
+
67
+
Run: `npm test 2>&1 | grep -A2 'parses repo scope with query'`
68
+
Expected: FAIL - current parser doesn't handle query params
69
+
70
+
**Step 3: Rewrite parseRepoScope implementation**
71
+
72
+
Replace the existing `parseRepoScope` function in `src/pds.js`:
73
+
74
+
```javascript
75
+
/**
76
+
* Parse a repo scope string into collection and actions.
77
+
* Official format: repo:collection?action=create&action=update
78
+
* Or: repo?collection=foo&action=create
79
+
* Without actions defaults to all: create, update, delete
80
+
* @param {string} scope - The scope string to parse
81
+
* @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid
82
+
*/
83
+
export function parseRepoScope(scope) {
84
+
if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null;
85
+
86
+
const ALL_ACTIONS = ['create', 'update', 'delete'];
87
+
let collection;
88
+
let actions;
89
+
90
+
const questionIdx = scope.indexOf('?');
91
+
if (questionIdx === -1) {
92
+
// repo:collection (no query params = all actions)
93
+
collection = scope.slice(5);
94
+
actions = ALL_ACTIONS;
95
+
} else {
96
+
// Parse query parameters
97
+
const queryString = scope.slice(questionIdx + 1);
98
+
const params = new URLSearchParams(queryString);
99
+
const pathPart = scope.startsWith('repo:') ? scope.slice(5, questionIdx) : '';
100
+
101
+
collection = pathPart || params.get('collection');
102
+
actions = params.getAll('action');
103
+
if (actions.length === 0) actions = ALL_ACTIONS;
104
+
}
105
+
106
+
if (!collection) return null;
107
+
108
+
// Validate actions
109
+
const validActions = actions.filter((a) => ALL_ACTIONS.includes(a));
110
+
if (validActions.length === 0) return null;
111
+
112
+
return { collection, actions: validActions };
113
+
}
114
+
```
115
+
116
+
**Step 4: Run tests to verify they pass**
117
+
118
+
Run: `npm test`
119
+
Expected: All parseRepoScope tests pass
120
+
121
+
**Step 5: Remove old format tests that no longer apply**
122
+
123
+
Remove tests for colon-delimited action format (e.g., `repo:collection:create,update`) from test file.
124
+
125
+
**Step 6: Run tests to verify still passing**
126
+
127
+
Run: `npm test`
128
+
Expected: PASS
129
+
130
+
**Step 7: Commit**
131
+
132
+
```bash
133
+
git add src/pds.js test/pds.test.js
134
+
git commit -m "refactor(scope): update parseRepoScope to official query param format"
135
+
```
136
+
137
+
---
138
+
139
+
### Task 2: Update ScopePermissions to Use New Parser
140
+
141
+
**Files:**
142
+
- Modify: `src/pds.js:4700-4710` (assertRepo method)
143
+
- Test: `test/pds.test.js` (ScopePermissions tests)
144
+
145
+
**Step 1: Update ScopePermissions.allowsRepo to handle new format**
146
+
147
+
The `allowsRepo` method should still work since it iterates `repoPermissions` which now have new structure. Verify with test.
148
+
149
+
**Step 2: Write test for new format compatibility**
150
+
151
+
```javascript
152
+
test('allowsRepo with query param format scopes', () => {
153
+
const perms = new ScopePermissions('atproto repo:app.bsky.feed.post?action=create');
154
+
assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true);
155
+
assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false);
156
+
});
157
+
```
158
+
159
+
**Step 3: Run test**
160
+
161
+
Run: `npm test`
162
+
Expected: PASS (existing logic should work)
163
+
164
+
**Step 4: Update assertRepo error message format**
165
+
166
+
In `assertRepo` method, update the error message to use official format:
167
+
168
+
```javascript
169
+
assertRepo(collection, action) {
170
+
if (!this.allowsRepo(collection, action)) {
171
+
throw new ScopeMissingError(`repo:${collection}?action=${action}`);
172
+
}
173
+
}
174
+
```
175
+
176
+
**Step 5: Run tests**
177
+
178
+
Run: `npm test`
179
+
Expected: PASS
180
+
181
+
**Step 6: Commit**
182
+
183
+
```bash
184
+
git add src/pds.js test/pds.test.js
185
+
git commit -m "refactor(scope): update ScopePermissions for query param format"
186
+
```
187
+
188
+
---
189
+
190
+
### Task 3: Add parseScopesForDisplay Helper
191
+
192
+
**Files:**
193
+
- Modify: `src/pds.js` (add new function near renderConsentPage)
194
+
- Test: `test/pds.test.js`
195
+
196
+
**Step 1: Write failing test**
197
+
198
+
```javascript
199
+
describe('parseScopesForDisplay', () => {
200
+
test('parses identity-only scope', () => {
201
+
const result = parseScopesForDisplay('atproto');
202
+
assert.strictEqual(result.hasAtproto, true);
203
+
assert.strictEqual(result.hasTransitionGeneric, false);
204
+
assert.strictEqual(result.repoPermissions.size, 0);
205
+
assert.deepStrictEqual(result.blobPermissions, []);
206
+
});
207
+
208
+
test('parses granular repo scopes', () => {
209
+
const result = parseScopesForDisplay('atproto repo:app.bsky.feed.post?action=create&action=update');
210
+
assert.strictEqual(result.repoPermissions.size, 1);
211
+
const postPerms = result.repoPermissions.get('app.bsky.feed.post');
212
+
assert.deepStrictEqual(postPerms, { create: true, update: true, delete: false });
213
+
});
214
+
215
+
test('merges multiple scopes for same collection', () => {
216
+
const result = parseScopesForDisplay('atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete');
217
+
const postPerms = result.repoPermissions.get('app.bsky.feed.post');
218
+
assert.deepStrictEqual(postPerms, { create: true, update: false, delete: true });
219
+
});
220
+
221
+
test('parses blob scopes', () => {
222
+
const result = parseScopesForDisplay('atproto blob:image/*');
223
+
assert.deepStrictEqual(result.blobPermissions, ['image/*']);
224
+
});
225
+
226
+
test('detects transition:generic', () => {
227
+
const result = parseScopesForDisplay('atproto transition:generic');
228
+
assert.strictEqual(result.hasTransitionGeneric, true);
229
+
});
230
+
});
231
+
```
232
+
233
+
**Step 2: Run tests to verify they fail**
234
+
235
+
Run: `npm test 2>&1 | grep -A2 'parseScopesForDisplay'`
236
+
Expected: FAIL - function doesn't exist
237
+
238
+
**Step 3: Add export to pds.js and implement**
239
+
240
+
```javascript
241
+
/**
242
+
* Parse scope string into display-friendly structure.
243
+
* @param {string} scope - Space-separated scope string
244
+
* @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }}
245
+
*/
246
+
export function parseScopesForDisplay(scope) {
247
+
const scopes = scope.split(' ').filter((s) => s);
248
+
249
+
const repoPermissions = new Map();
250
+
251
+
for (const s of scopes) {
252
+
const repo = parseRepoScope(s);
253
+
if (repo) {
254
+
const existing = repoPermissions.get(repo.collection) || {
255
+
create: false,
256
+
update: false,
257
+
delete: false,
258
+
};
259
+
for (const action of repo.actions) {
260
+
existing[action] = true;
261
+
}
262
+
repoPermissions.set(repo.collection, existing);
263
+
}
264
+
}
265
+
266
+
const blobPermissions = [];
267
+
for (const s of scopes) {
268
+
const blob = parseBlobScope(s);
269
+
if (blob) blobPermissions.push(...blob.accept);
270
+
}
271
+
272
+
return {
273
+
hasAtproto: scopes.includes('atproto'),
274
+
hasTransitionGeneric: scopes.includes('transition:generic'),
275
+
repoPermissions,
276
+
blobPermissions,
277
+
};
278
+
}
279
+
```
280
+
281
+
**Step 4: Run tests**
282
+
283
+
Run: `npm test`
284
+
Expected: PASS
285
+
286
+
**Step 5: Commit**
287
+
288
+
```bash
289
+
git add src/pds.js test/pds.test.js
290
+
git commit -m "feat(consent): add parseScopesForDisplay helper"
291
+
```
292
+
293
+
---
294
+
295
+
### Task 4: Add Permission Rendering Helpers
296
+
297
+
**Files:**
298
+
- Modify: `src/pds.js` (add functions near renderConsentPage)
299
+
300
+
**Step 1: Add renderRepoTable helper**
301
+
302
+
```javascript
303
+
/**
304
+
* Render repo permissions as HTML table.
305
+
* @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} repoPermissions
306
+
* @returns {string} HTML string
307
+
*/
308
+
function renderRepoTable(repoPermissions) {
309
+
if (repoPermissions.size === 0) return '';
310
+
311
+
let rows = '';
312
+
for (const [collection, actions] of repoPermissions) {
313
+
const displayCollection = collection === '*' ? '* (any)' : collection;
314
+
rows += `<tr>
315
+
<td>${escapeHtml(displayCollection)}</td>
316
+
<td class="check">${actions.create ? 'โ' : ''}</td>
317
+
<td class="check">${actions.update ? 'โ' : ''}</td>
318
+
<td class="check">${actions.delete ? 'โ' : ''}</td>
319
+
</tr>`;
320
+
}
321
+
322
+
return `<div class="permissions-section">
323
+
<div class="section-label">Repository permissions:</div>
324
+
<table class="permissions-table">
325
+
<thead><tr><th>Collection</th><th>C</th><th>U</th><th>D</th></tr></thead>
326
+
<tbody>${rows}</tbody>
327
+
</table>
328
+
</div>`;
329
+
}
330
+
```
331
+
332
+
**Step 2: Add renderBlobList helper**
333
+
334
+
```javascript
335
+
/**
336
+
* Render blob permissions as HTML list.
337
+
* @param {string[]} blobPermissions
338
+
* @returns {string} HTML string
339
+
*/
340
+
function renderBlobList(blobPermissions) {
341
+
if (blobPermissions.length === 0) return '';
342
+
343
+
const items = blobPermissions
344
+
.map((mime) => `<li>${escapeHtml(mime === '*/*' ? 'All file types' : mime)}</li>`)
345
+
.join('');
346
+
347
+
return `<div class="permissions-section">
348
+
<div class="section-label">Upload permissions:</div>
349
+
<ul class="blob-list">${items}</ul>
350
+
</div>`;
351
+
}
352
+
```
353
+
354
+
**Step 3: Add renderPermissionsHtml helper**
355
+
356
+
```javascript
357
+
/**
358
+
* Render full permissions display based on parsed scopes.
359
+
* @param {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map, blobPermissions: string[] }} parsed
360
+
* @returns {string} HTML string
361
+
*/
362
+
function renderPermissionsHtml(parsed) {
363
+
if (parsed.hasTransitionGeneric) {
364
+
return `<div class="warning">โ ๏ธ Full repository access requested<br>
365
+
<small>This app can create, update, and delete any data in your repository.</small></div>`;
366
+
}
367
+
368
+
if (parsed.repoPermissions.size === 0 && parsed.blobPermissions.length === 0) {
369
+
return '';
370
+
}
371
+
372
+
return renderRepoTable(parsed.repoPermissions) + renderBlobList(parsed.blobPermissions);
373
+
}
374
+
```
375
+
376
+
**Step 4: Add escapeHtml helper (if not exists)**
377
+
378
+
Check if `escHtml` exists in renderConsentPage - rename to `escapeHtml` and move outside function for reuse, or create new one:
379
+
380
+
```javascript
381
+
/**
382
+
* Escape HTML special characters.
383
+
* @param {string} s
384
+
* @returns {string}
385
+
*/
386
+
function escapeHtml(s) {
387
+
return s
388
+
.replace(/&/g, '&')
389
+
.replace(/</g, '<')
390
+
.replace(/>/g, '>')
391
+
.replace(/"/g, '"');
392
+
}
393
+
```
394
+
395
+
**Step 5: Run lint/format**
396
+
397
+
Run: `npm run format && npm run lint`
398
+
Expected: PASS
399
+
400
+
**Step 6: Commit**
401
+
402
+
```bash
403
+
git add src/pds.js
404
+
git commit -m "feat(consent): add permission rendering helpers"
405
+
```
406
+
407
+
---
408
+
409
+
### Task 5: Update renderConsentPage
410
+
411
+
**Files:**
412
+
- Modify: `src/pds.js:583-628` (renderConsentPage function)
413
+
414
+
**Step 1: Add new CSS to renderConsentPage**
415
+
416
+
Add to the `<style>` block:
417
+
418
+
```css
419
+
.permissions-section{margin:16px 0}
420
+
.section-label{color:#b0b0b0;font-size:13px;margin-bottom:8px}
421
+
.permissions-table{width:100%;border-collapse:collapse;font-size:13px}
422
+
.permissions-table th{color:#808080;font-weight:normal;text-align:left;padding:4px 8px;border-bottom:1px solid #333}
423
+
.permissions-table th:not(:first-child){text-align:center;width:32px}
424
+
.permissions-table td{padding:4px 8px;border-bottom:1px solid #2a2a2a}
425
+
.permissions-table td:not(:first-child){text-align:center}
426
+
.permissions-table .check{color:#4ade80}
427
+
.blob-list{margin:0;padding-left:20px;color:#e0e0e0;font-size:13px}
428
+
.blob-list li{margin:4px 0}
429
+
.warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0}
430
+
.warning small{color:#d4a000;display:block;margin-top:4px}
431
+
```
432
+
433
+
**Step 2: Update body content**
434
+
435
+
Replace the scope display line:
436
+
```javascript
437
+
// Old:
438
+
<p>Scope: ${escHtml(scope)}</p>
439
+
440
+
// New:
441
+
const parsed = parseScopesForDisplay(scope);
442
+
const isIdentityOnly = parsed.repoPermissions.size === 0 &&
443
+
parsed.blobPermissions.length === 0 &&
444
+
!parsed.hasTransitionGeneric;
445
+
446
+
// In template:
447
+
<p><b>${escHtml(clientName)}</b> ${isIdentityOnly ?
448
+
'wants to uniquely identify you through your account.' :
449
+
'wants to access your account.'}</p>
450
+
${renderPermissionsHtml(parsed)}
451
+
```
452
+
453
+
**Step 3: Run the app and test manually**
454
+
455
+
Run: `npm run dev`
456
+
Test: Navigate to OAuth flow with different scope combinations
457
+
458
+
**Step 4: Run all tests**
459
+
460
+
Run: `npm test`
461
+
Expected: PASS
462
+
463
+
**Step 5: Run format/lint/check**
464
+
465
+
Run: `npm run format && npm run lint && npm run check`
466
+
Expected: PASS
467
+
468
+
**Step 6: Commit**
469
+
470
+
```bash
471
+
git add src/pds.js
472
+
git commit -m "feat(consent): display scopes as permissions table"
473
+
```
474
+
475
+
---
476
+
477
+
### Task 6: Add E2E Test for Consent Page Display
478
+
479
+
**Files:**
480
+
- Modify: `test/e2e.test.js`
481
+
482
+
**Step 1: Add test for consent page content**
483
+
484
+
```javascript
485
+
it('consent page shows permissions table for granular scopes', async () => {
486
+
// Create PAR request with granular scopes
487
+
const codeVerifier = 'test-verifier-' + randomBytes(16).toString('hex');
488
+
const codeChallenge = createHash('sha256')
489
+
.update(codeVerifier)
490
+
.digest('base64url');
491
+
492
+
const parRes = await fetch(`${BASE}/oauth/par`, {
493
+
method: 'POST',
494
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
495
+
body: new URLSearchParams({
496
+
client_id: `http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1:3000/callback')}`,
497
+
redirect_uri: 'http://127.0.0.1:3000/callback',
498
+
response_type: 'code',
499
+
scope: 'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*',
500
+
code_challenge: codeChallenge,
501
+
code_challenge_method: 'S256',
502
+
state: 'test-state',
503
+
}),
504
+
});
505
+
506
+
const { request_uri } = await parRes.json();
507
+
508
+
// GET the authorize page
509
+
const authorizeRes = await fetch(
510
+
`${BASE}/oauth/authorize?client_id=${encodeURIComponent(`http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1:3000/callback')}`)}&request_uri=${encodeURIComponent(request_uri)}`,
511
+
);
512
+
513
+
const html = await authorizeRes.text();
514
+
515
+
// Verify permissions table is rendered
516
+
assert.ok(html.includes('Repository permissions:'), 'Should show repo permissions section');
517
+
assert.ok(html.includes('app.bsky.feed.post'), 'Should show collection name');
518
+
assert.ok(html.includes('Upload permissions:'), 'Should show upload permissions section');
519
+
assert.ok(html.includes('image/*'), 'Should show blob MIME type');
520
+
});
521
+
```
522
+
523
+
**Step 2: Run E2E tests**
524
+
525
+
Run: `npm run test:e2e`
526
+
Expected: PASS
527
+
528
+
**Step 3: Commit**
529
+
530
+
```bash
531
+
git add test/e2e.test.js
532
+
git commit -m "test(consent): add E2E test for permissions table display"
533
+
```
534
+
535
+
---
536
+
537
+
### Task 7: Final Verification and Cleanup
538
+
539
+
**Step 1: Run full test suite**
540
+
541
+
Run: `npm test && npm run test:e2e`
542
+
Expected: All tests pass
543
+
544
+
**Step 2: Run all quality checks**
545
+
546
+
Run: `npm run format && npm run lint && npm run check && npm run typecheck`
547
+
Expected: All pass
548
+
549
+
**Step 3: Manual verification**
550
+
551
+
1. Start dev server: `npm run dev`
552
+
2. Test consent page with various scopes:
553
+
- `atproto` only โ should show "uniquely identify you"
554
+
- `atproto repo:app.bsky.feed.post?action=create` โ should show table
555
+
- `atproto transition:generic` โ should show warning banner
556
+
- `atproto blob:image/*` โ should show upload permissions
557
+
558
+
**Step 4: Final commit if any fixes needed**
559
+
560
+
```bash
561
+
git add -A
562
+
git commit -m "chore: final cleanup for consent permissions table"
563
+
```
+480
docs/plans/2026-01-08-foreign-did-proxying.md
+480
docs/plans/2026-01-08-foreign-did-proxying.md
···
1
+
# Foreign DID Proxying Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Handle foreign DID requests by either (1) respecting `atproto-proxy` header, or (2) detecting foreign `repo` param and proxying to AppView.
6
+
7
+
**Architecture:** (matches official PDS)
8
+
1. Check if `repo` is a local DID โ handle locally (ignore atproto-proxy)
9
+
2. If foreign DID with `atproto-proxy` header โ proxy to specified service
10
+
3. If foreign DID without header โ proxy to AppView (default)
11
+
12
+
**Tech Stack:** Cloudflare Workers, Durable Objects, ATProto
13
+
14
+
---
15
+
16
+
## Background
17
+
18
+
When a client needs data from a foreign DID, it may:
19
+
1. Send `atproto-proxy: did:web:api.bsky.app#bsky_appview` header (explicit)
20
+
2. Just send `repo=did:plc:foreign...` without header (implicit)
21
+
22
+
Our PDS should handle both cases. Currently it ignores the header and always tries to find records locally.
23
+
24
+
---
25
+
26
+
### Task 1: Add parseAtprotoProxyHeader Utility
27
+
28
+
**Files:**
29
+
- Modify: `src/pds.js` (after errorResponse function, around line 178)
30
+
31
+
**Step 1: Add the utility function**
32
+
33
+
```javascript
34
+
/**
35
+
* Parse atproto-proxy header to get service DID and service ID
36
+
* Format: "did:web:api.bsky.app#bsky_appview"
37
+
* @param {string} header
38
+
* @returns {{ did: string, serviceId: string } | null}
39
+
*/
40
+
function parseAtprotoProxyHeader(header) {
41
+
if (!header) return null;
42
+
const hashIndex = header.indexOf('#');
43
+
if (hashIndex === -1 || hashIndex === 0 || hashIndex === header.length - 1) {
44
+
return null;
45
+
}
46
+
return {
47
+
did: header.slice(0, hashIndex),
48
+
serviceId: header.slice(hashIndex + 1),
49
+
};
50
+
}
51
+
```
52
+
53
+
**Step 2: Commit**
54
+
55
+
```bash
56
+
git add src/pds.js
57
+
git commit -m "feat: add parseAtprotoProxyHeader utility"
58
+
```
59
+
60
+
---
61
+
62
+
### Task 2: Add getKnownServiceUrl Utility
63
+
64
+
**Files:**
65
+
- Modify: `src/pds.js` (after parseAtprotoProxyHeader)
66
+
67
+
**Step 1: Add utility to resolve service URLs**
68
+
69
+
```javascript
70
+
/**
71
+
* Get URL for a known service DID
72
+
* @param {string} did - Service DID (e.g., "did:web:api.bsky.app")
73
+
* @param {string} serviceId - Service ID (e.g., "bsky_appview")
74
+
* @returns {string | null}
75
+
*/
76
+
function getKnownServiceUrl(did, serviceId) {
77
+
// Known Bluesky services
78
+
if (did === 'did:web:api.bsky.app' && serviceId === 'bsky_appview') {
79
+
return 'https://api.bsky.app';
80
+
}
81
+
// Add more known services as needed
82
+
return null;
83
+
}
84
+
```
85
+
86
+
**Step 2: Commit**
87
+
88
+
```bash
89
+
git add src/pds.js
90
+
git commit -m "feat: add getKnownServiceUrl utility"
91
+
```
92
+
93
+
---
94
+
95
+
### Task 3: Add proxyToService Utility
96
+
97
+
**Files:**
98
+
- Modify: `src/pds.js` (after getKnownServiceUrl)
99
+
100
+
**Step 1: Add the proxy utility function**
101
+
102
+
```javascript
103
+
/**
104
+
* Proxy a request to a service
105
+
* @param {Request} request - Original request
106
+
* @param {string} serviceUrl - Target service URL (e.g., "https://api.bsky.app")
107
+
* @param {string} [authHeader] - Optional Authorization header
108
+
* @returns {Promise<Response>}
109
+
*/
110
+
async function proxyToService(request, serviceUrl, authHeader) {
111
+
const url = new URL(request.url);
112
+
const targetUrl = new URL(url.pathname + url.search, serviceUrl);
113
+
114
+
const headers = new Headers();
115
+
if (authHeader) {
116
+
headers.set('Authorization', authHeader);
117
+
}
118
+
headers.set(
119
+
'Content-Type',
120
+
request.headers.get('Content-Type') || 'application/json',
121
+
);
122
+
const acceptHeader = request.headers.get('Accept');
123
+
if (acceptHeader) {
124
+
headers.set('Accept', acceptHeader);
125
+
}
126
+
const acceptLangHeader = request.headers.get('Accept-Language');
127
+
if (acceptLangHeader) {
128
+
headers.set('Accept-Language', acceptLangHeader);
129
+
}
130
+
// Forward atproto-specific headers
131
+
const labelersHeader = request.headers.get('atproto-accept-labelers');
132
+
if (labelersHeader) {
133
+
headers.set('atproto-accept-labelers', labelersHeader);
134
+
}
135
+
const topicsHeader = request.headers.get('x-bsky-topics');
136
+
if (topicsHeader) {
137
+
headers.set('x-bsky-topics', topicsHeader);
138
+
}
139
+
140
+
try {
141
+
const response = await fetch(targetUrl.toString(), {
142
+
method: request.method,
143
+
headers,
144
+
body:
145
+
request.method !== 'GET' && request.method !== 'HEAD'
146
+
? request.body
147
+
: undefined,
148
+
});
149
+
const responseHeaders = new Headers(response.headers);
150
+
responseHeaders.set('Access-Control-Allow-Origin', '*');
151
+
return new Response(response.body, {
152
+
status: response.status,
153
+
statusText: response.statusText,
154
+
headers: responseHeaders,
155
+
});
156
+
} catch (err) {
157
+
const message = err instanceof Error ? err.message : String(err);
158
+
return errorResponse('UpstreamFailure', `Failed to reach service: ${message}`, 502);
159
+
}
160
+
}
161
+
```
162
+
163
+
**Step 2: Commit**
164
+
165
+
```bash
166
+
git add src/pds.js
167
+
git commit -m "feat: add proxyToService utility"
168
+
```
169
+
170
+
---
171
+
172
+
### Task 4: Add isLocalDid Helper
173
+
174
+
**Files:**
175
+
- Modify: `src/pds.js` (after proxyToService)
176
+
177
+
**Step 1: Add helper to check if DID is registered locally**
178
+
179
+
```javascript
180
+
/**
181
+
* Check if a DID is registered on this PDS
182
+
* @param {Env} env
183
+
* @param {string} did
184
+
* @returns {Promise<boolean>}
185
+
*/
186
+
async function isLocalDid(env, did) {
187
+
const defaultPds = getDefaultPds(env);
188
+
const res = await defaultPds.fetch(
189
+
new Request('http://internal/get-registered-dids'),
190
+
);
191
+
if (!res.ok) return false;
192
+
const { dids } = await res.json();
193
+
return dids.includes(did);
194
+
}
195
+
```
196
+
197
+
**Step 2: Commit**
198
+
199
+
```bash
200
+
git add src/pds.js
201
+
git commit -m "feat: add isLocalDid helper"
202
+
```
203
+
204
+
---
205
+
206
+
### Task 5: Refactor handleAppViewProxy to Use proxyToService
207
+
208
+
**Files:**
209
+
- Modify: `src/pds.js:2725-2782` (handleAppViewProxy in PersonalDataServer class)
210
+
211
+
**Step 1: Refactor the method**
212
+
213
+
Replace with:
214
+
215
+
```javascript
216
+
/**
217
+
* @param {Request} request
218
+
* @param {string} userDid
219
+
*/
220
+
async handleAppViewProxy(request, userDid) {
221
+
const url = new URL(request.url);
222
+
const lxm = url.pathname.replace('/xrpc/', '');
223
+
const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm);
224
+
return proxyToService(request, 'https://api.bsky.app', `Bearer ${serviceJwt}`);
225
+
}
226
+
```
227
+
228
+
**Step 2: Run existing tests**
229
+
230
+
```bash
231
+
npm test
232
+
```
233
+
234
+
Expected: All tests pass
235
+
236
+
**Step 3: Commit**
237
+
238
+
```bash
239
+
git add src/pds.js
240
+
git commit -m "refactor: simplify handleAppViewProxy using proxyToService"
241
+
```
242
+
243
+
---
244
+
245
+
### Task 6: Handle Foreign Repo with atproto-proxy Support in Worker Routing
246
+
247
+
**Files:**
248
+
- Modify: `src/pds.js` in `handleRequest` function (around line 5199)
249
+
250
+
**Step 1: Update repo endpoints routing to match official PDS behavior**
251
+
252
+
Find the repo endpoints routing block and REPLACE the entire block.
253
+
254
+
Order of operations (matches official PDS):
255
+
1. Check if repo is local โ return local data
256
+
2. If foreign โ check atproto-proxy header for specific service
257
+
3. If no header โ default to AppView
258
+
259
+
```javascript
260
+
// Repo endpoints use ?repo= param instead of ?did=
261
+
if (
262
+
url.pathname === '/xrpc/com.atproto.repo.describeRepo' ||
263
+
url.pathname === '/xrpc/com.atproto.repo.listRecords' ||
264
+
url.pathname === '/xrpc/com.atproto.repo.getRecord'
265
+
) {
266
+
const repo = url.searchParams.get('repo');
267
+
if (!repo) {
268
+
return errorResponse('InvalidRequest', 'missing repo param', 400);
269
+
}
270
+
271
+
// Check if this is a local DID - if so, handle locally
272
+
const isLocal = await isLocalDid(env, repo);
273
+
if (isLocal) {
274
+
const id = env.PDS.idFromName(repo);
275
+
const pds = env.PDS.get(id);
276
+
return pds.fetch(request);
277
+
}
278
+
279
+
// Foreign DID - check for atproto-proxy header
280
+
const proxyHeader = request.headers.get('atproto-proxy');
281
+
if (proxyHeader) {
282
+
const parsed = parseAtprotoProxyHeader(proxyHeader);
283
+
if (parsed) {
284
+
const serviceUrl = getKnownServiceUrl(parsed.did, parsed.serviceId);
285
+
if (serviceUrl) {
286
+
return proxyToService(request, serviceUrl);
287
+
}
288
+
// Unknown service - could add DID resolution here in the future
289
+
return errorResponse('InvalidRequest', `Unknown proxy service: ${proxyHeader}`, 400);
290
+
}
291
+
}
292
+
293
+
// No header - default to AppView
294
+
return proxyToService(request, 'https://api.bsky.app');
295
+
}
296
+
```
297
+
298
+
**Step 2: Run existing tests**
299
+
300
+
```bash
301
+
npm test
302
+
```
303
+
304
+
Expected: All tests pass
305
+
306
+
**Step 3: Commit**
307
+
308
+
```bash
309
+
git add src/pds.js
310
+
git commit -m "feat: handle atproto-proxy header and foreign repo proxying"
311
+
```
312
+
313
+
---
314
+
315
+
### Task 7: Add E2E Tests
316
+
317
+
**Files:**
318
+
- Modify: `test/e2e.test.js`
319
+
320
+
**Step 1: Add tests for proxy functionality**
321
+
322
+
Add a new describe block:
323
+
324
+
```javascript
325
+
describe('Foreign DID proxying', () => {
326
+
it('proxies to AppView when atproto-proxy header present', async () => {
327
+
// Use a known public post from Bluesky (bsky.app official account)
328
+
const res = await fetch(
329
+
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`,
330
+
{
331
+
headers: {
332
+
'atproto-proxy': 'did:web:api.bsky.app#bsky_appview',
333
+
},
334
+
},
335
+
);
336
+
// Should get response from AppView, not local 404
337
+
assert.ok(
338
+
res.status === 200 || res.status === 400,
339
+
`Expected 200 or 400 from AppView, got ${res.status}`,
340
+
);
341
+
});
342
+
343
+
it('proxies to AppView for foreign repo without header', async () => {
344
+
// Foreign DID without atproto-proxy header - should still proxy
345
+
const res = await fetch(
346
+
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`,
347
+
);
348
+
// Should get response from AppView, not local 404
349
+
assert.ok(
350
+
res.status === 200 || res.status === 400,
351
+
`Expected 200 or 400 from AppView, got ${res.status}`,
352
+
);
353
+
});
354
+
355
+
it('returns error for unknown proxy service', async () => {
356
+
const res = await fetch(
357
+
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`,
358
+
{
359
+
headers: {
360
+
'atproto-proxy': 'did:web:unknown.service#unknown',
361
+
},
362
+
},
363
+
);
364
+
assert.strictEqual(res.status, 400);
365
+
const data = await res.json();
366
+
assert.ok(data.message.includes('Unknown proxy service'));
367
+
});
368
+
369
+
it('returns local record for local DID without proxy header', async () => {
370
+
// Create a record first
371
+
const { data: created } = await jsonPost(
372
+
'/xrpc/com.atproto.repo.createRecord',
373
+
{
374
+
repo: DID,
375
+
collection: 'app.bsky.feed.post',
376
+
record: {
377
+
$type: 'app.bsky.feed.post',
378
+
text: 'Test post for local DID test',
379
+
createdAt: new Date().toISOString(),
380
+
},
381
+
},
382
+
{ Authorization: `Bearer ${token}` },
383
+
);
384
+
385
+
// Fetch without proxy header - should get local record
386
+
const rkey = created.uri.split('/').pop();
387
+
const res = await fetch(
388
+
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`,
389
+
);
390
+
assert.strictEqual(res.status, 200);
391
+
const data = await res.json();
392
+
assert.ok(data.value.text.includes('Test post for local DID test'));
393
+
});
394
+
395
+
it('describeRepo proxies for foreign DID', async () => {
396
+
const res = await fetch(
397
+
`${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`,
398
+
);
399
+
// Should get response from AppView
400
+
assert.ok(res.status === 200 || res.status === 400);
401
+
});
402
+
403
+
it('listRecords proxies for foreign DID', async () => {
404
+
const res = await fetch(
405
+
`${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`,
406
+
);
407
+
// Should get response from AppView
408
+
assert.ok(res.status === 200 || res.status === 400);
409
+
});
410
+
});
411
+
```
412
+
413
+
**Step 2: Run the tests**
414
+
415
+
```bash
416
+
npm test
417
+
```
418
+
419
+
Expected: All tests pass
420
+
421
+
**Step 3: Commit**
422
+
423
+
```bash
424
+
git add test/e2e.test.js
425
+
git commit -m "test: add e2e tests for foreign DID proxying"
426
+
```
427
+
428
+
---
429
+
430
+
### Task 8: Manual Verification
431
+
432
+
**Step 1: Deploy to dev**
433
+
434
+
```bash
435
+
npx wrangler deploy
436
+
```
437
+
438
+
**Step 2: Test with the original failing curl (with header)**
439
+
440
+
```bash
441
+
curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&repo=did%3Aplc%3Abcgltzqazw5tb6k2g3ttenbj&rkey=3mbx6iyfqps2c' \
442
+
-H 'atproto-proxy: did:web:api.bsky.app#bsky_appview'
443
+
```
444
+
445
+
Expected: Returns post data from AppView
446
+
447
+
**Step 3: Test without header (foreign repo detection)**
448
+
449
+
```bash
450
+
curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&repo=did%3Aplc%3Abcgltzqazw5tb6k2g3ttenbj&rkey=3mbx6iyfqps2c'
451
+
```
452
+
453
+
Expected: Also returns post data from AppView (detected as foreign DID)
454
+
455
+
**Step 4: Test replying to a post in Bluesky client**
456
+
457
+
Verify the original issue is fixed.
458
+
459
+
---
460
+
461
+
## Future Enhancements
462
+
463
+
1. **Service auth for proxied requests** - Add service JWT when proxying authenticated requests
464
+
2. **DID resolution** - Resolve unknown DIDs to find their service endpoints dynamically
465
+
3. **Caching** - Cache registered DIDs list to avoid repeated lookups
466
+
467
+
---
468
+
469
+
## Summary
470
+
471
+
| Task | Description |
472
+
|------|-------------|
473
+
| 1 | Add `parseAtprotoProxyHeader` utility |
474
+
| 2 | Add `getKnownServiceUrl` utility |
475
+
| 3 | Add `proxyToService` utility |
476
+
| 4 | Add `isLocalDid` helper |
477
+
| 5 | Refactor `handleAppViewProxy` to use shared utility |
478
+
| 6 | Handle `atproto-proxy` header AND foreign `repo` param |
479
+
| 7 | Add e2e tests |
480
+
| 8 | Manual verification |
+255
docs/plans/2026-01-09-consent-profile-card.md
+255
docs/plans/2026-01-09-consent-profile-card.md
···
1
+
# Consent Page Profile Card Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Show the authorizing user's Bluesky profile (avatar, name, handle) on the OAuth consent page.
6
+
7
+
**Architecture:** Add inline HTML/CSS/JS to the consent page. Profile is fetched client-side from Bluesky's public API using the `login_hint` parameter. Graceful degradation if fetch fails.
8
+
9
+
**Tech Stack:** Vanilla JS, Bluesky public API (`app.bsky.actor.getProfile`)
10
+
11
+
---
12
+
13
+
### Task 1: Update renderConsentPage signature
14
+
15
+
**Files:**
16
+
- Modify: `src/pds.js:5008-5017` (function signature and JSDoc)
17
+
18
+
**Step 1: Add loginHint to JSDoc and parameters**
19
+
20
+
Change the function signature from:
21
+
```javascript
22
+
/**
23
+
* @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params
24
+
* @returns {string} HTML page content
25
+
*/
26
+
function renderConsentPage({
27
+
clientName,
28
+
clientId,
29
+
scope,
30
+
requestUri,
31
+
error = '',
32
+
}) {
33
+
```
34
+
35
+
To:
36
+
```javascript
37
+
/**
38
+
* @param {{ clientName: string, clientId: string, scope: string, requestUri: string, loginHint?: string, error?: string }} params
39
+
* @returns {string} HTML page content
40
+
*/
41
+
function renderConsentPage({
42
+
clientName,
43
+
clientId,
44
+
scope,
45
+
requestUri,
46
+
loginHint = '',
47
+
error = '',
48
+
}) {
49
+
```
50
+
51
+
**Step 2: Verify syntax is correct**
52
+
53
+
Run: `node --check src/pds.js`
54
+
Expected: No output (success)
55
+
56
+
---
57
+
58
+
### Task 2: Add profile card CSS
59
+
60
+
**Files:**
61
+
- Modify: `src/pds.js:5027-5055` (inside the `<style>` block)
62
+
63
+
**Step 1: Add profile card styles after existing styles**
64
+
65
+
Add before `</style></head>`:
66
+
```css
67
+
.profile-card{display:flex;align-items:center;gap:12px;padding:16px;background:#2a2a2a;border-radius:8px;margin-bottom:20px}
68
+
.profile-card.loading .avatar{background:#404040;animation:pulse 1.5s infinite}
69
+
.profile-card .avatar{width:48px;height:48px;border-radius:50%;background:#404040;flex-shrink:0}
70
+
.profile-card .avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover}
71
+
.profile-card .info{min-width:0}
72
+
.profile-card .name{color:#fff;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
73
+
.profile-card .handle{color:#808080;font-size:14px}
74
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}
75
+
```
76
+
77
+
**Step 2: Verify syntax is correct**
78
+
79
+
Run: `node --check src/pds.js`
80
+
Expected: No output (success)
81
+
82
+
---
83
+
84
+
### Task 3: Add profile card HTML
85
+
86
+
**Files:**
87
+
- Modify: `src/pds.js:5056-5057` (after `<body>` opening, before `<h2>`)
88
+
89
+
**Step 1: Add profile card HTML conditionally**
90
+
91
+
Replace:
92
+
```javascript
93
+
<body><h2>Sign in to authorize</h2>
94
+
```
95
+
96
+
With:
97
+
```javascript
98
+
<body>
99
+
${loginHint ? `<div class="profile-card loading" id="profile-card">
100
+
<div class="avatar" id="profile-avatar"></div>
101
+
<div class="info"><div class="name" id="profile-name">Loading...</div>
102
+
<div class="handle" id="profile-handle">${escapeHtml(loginHint.startsWith('did:') ? loginHint : '@' + loginHint)}</div></div>
103
+
</div>` : ''}
104
+
<h2>Sign in to authorize</h2>
105
+
```
106
+
107
+
**Step 2: Verify syntax is correct**
108
+
109
+
Run: `node --check src/pds.js`
110
+
Expected: No output (success)
111
+
112
+
---
113
+
114
+
### Task 4: Add profile fetch script
115
+
116
+
**Files:**
117
+
- Modify: `src/pds.js:5066` (before `</body></html>`)
118
+
119
+
**Step 1: Add inline script to fetch profile**
120
+
121
+
Replace:
122
+
```javascript
123
+
</form></body></html>`;
124
+
```
125
+
126
+
With:
127
+
```javascript
128
+
</form>
129
+
${loginHint ? `<script>
130
+
(async()=>{
131
+
const card=document.getElementById('profile-card');
132
+
if(!card)return;
133
+
try{
134
+
const r=await fetch('https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor='+encodeURIComponent('${escapeHtml(loginHint)}'));
135
+
if(!r.ok)throw new Error();
136
+
const p=await r.json();
137
+
document.getElementById('profile-avatar').innerHTML=p.avatar?'<img src="'+p.avatar+'" alt="">':'';
138
+
document.getElementById('profile-name').textContent=p.displayName||p.handle;
139
+
document.getElementById('profile-handle').textContent='@'+p.handle;
140
+
card.classList.remove('loading');
141
+
}catch(e){card.classList.remove('loading')}
142
+
})();
143
+
</script>` : ''}
144
+
</body></html>`;
145
+
```
146
+
147
+
**Step 2: Verify syntax is correct**
148
+
149
+
Run: `node --check src/pds.js`
150
+
Expected: No output (success)
151
+
152
+
---
153
+
154
+
### Task 5: Pass loginHint from PAR flow
155
+
156
+
**Files:**
157
+
- Modify: `src/pds.js:3954-3959` (PAR flow renderConsentPage call)
158
+
159
+
**Step 1: Add loginHint to renderConsentPage call**
160
+
161
+
Change:
162
+
```javascript
163
+
return new Response(
164
+
renderConsentPage({
165
+
clientName: clientMetadata.client_name || clientId,
166
+
clientId: clientId || '',
167
+
scope: parameters.scope || 'atproto',
168
+
requestUri: requestUri || '',
169
+
}),
170
+
```
171
+
172
+
To:
173
+
```javascript
174
+
return new Response(
175
+
renderConsentPage({
176
+
clientName: clientMetadata.client_name || clientId,
177
+
clientId: clientId || '',
178
+
scope: parameters.scope || 'atproto',
179
+
requestUri: requestUri || '',
180
+
loginHint: parameters.login_hint || '',
181
+
}),
182
+
```
183
+
184
+
**Step 2: Verify syntax is correct**
185
+
186
+
Run: `node --check src/pds.js`
187
+
Expected: No output (success)
188
+
189
+
---
190
+
191
+
### Task 6: Pass loginHint from direct flow
192
+
193
+
**Files:**
194
+
- Modify: `src/pds.js:4022-4027` (direct flow renderConsentPage call)
195
+
196
+
**Step 1: Add loginHint to renderConsentPage call**
197
+
198
+
Change:
199
+
```javascript
200
+
return new Response(
201
+
renderConsentPage({
202
+
clientName: clientMetadata.client_name || clientId,
203
+
clientId: clientId,
204
+
scope: scope || 'atproto',
205
+
requestUri: newRequestUri,
206
+
}),
207
+
```
208
+
209
+
To:
210
+
```javascript
211
+
return new Response(
212
+
renderConsentPage({
213
+
clientName: clientMetadata.client_name || clientId,
214
+
clientId: clientId,
215
+
scope: scope || 'atproto',
216
+
requestUri: newRequestUri,
217
+
loginHint: loginHint || '',
218
+
}),
219
+
```
220
+
221
+
**Step 2: Verify syntax is correct**
222
+
223
+
Run: `node --check src/pds.js`
224
+
Expected: No output (success)
225
+
226
+
---
227
+
228
+
### Task 7: Run tests and commit
229
+
230
+
**Step 1: Run full test suite**
231
+
232
+
Run: `npm test`
233
+
Expected: All 126 tests pass
234
+
235
+
**Step 2: Commit changes**
236
+
237
+
```bash
238
+
git add src/pds.js docs/plans/2025-01-09-consent-profile-card.md
239
+
git commit -m "feat: add profile card to OAuth consent page
240
+
241
+
Shows the authorizing user's avatar, display name, and handle
242
+
on the consent page. Fetches from Bluesky public API using
243
+
the login_hint parameter. Degrades gracefully if fetch fails."
244
+
```
245
+
246
+
---
247
+
248
+
## Manual Testing
249
+
250
+
After implementation, test by:
251
+
252
+
1. Start local PDS: `npx wrangler dev`
253
+
2. Trigger OAuth flow with login_hint parameter
254
+
3. Verify profile card shows on consent page
255
+
4. Verify it degrades gracefully with invalid login_hint
+46
-51
docs/scope-comparison.md
+46
-51
docs/scope-comparison.md
···
8
8
9
9
| Scope Type | Format | pds.js | atproto PDS |
10
10
|------------|--------|--------|-------------|
11
-
| `atproto` | Static | Checked (loose) | Required for all OAuth |
12
-
| `transition:generic` | Static | Not recognized | Full repo/blob bypass |
11
+
| `atproto` | Static | Full access | Required for all OAuth |
12
+
| `transition:generic` | Static | Full access | Full repo/blob bypass |
13
13
| `transition:email` | Static | N/A | Read account email |
14
14
| `transition:chat.bsky` | Static | N/A | Chat RPC access |
15
-
| `repo:<collection>:<action>` | Granular | Not parsed | Full parsing + enforcement |
16
-
| `blob:<mime>` | Granular | Not parsed | Full parsing + enforcement |
17
-
| `rpc:<aud>:<lxm>` | Granular | Not parsed | Full parsing + enforcement |
15
+
| `repo:<collection>?action=<action>` | Granular | Full parsing + enforcement | Full parsing + enforcement |
16
+
| `blob:<mime>` | Granular | Full parsing + enforcement | Full parsing + enforcement |
17
+
| `rpc:<aud>:<lxm>` | Granular | Not implemented | Full parsing + enforcement |
18
18
19
19
---
20
20
···
24
24
25
25
| Aspect | pds.js | atproto PDS |
26
26
|--------|--------|-------------|
27
-
| Scope check | `hasRequiredScope(scope, 'atproto')` | `permissions.assertRepo({ action: 'create', collection })` |
28
-
| Required scope | `atproto` anywhere in scope string | `repo:<collection>:create` or `transition:generic` or `atproto` |
29
-
| OAuth-only check | No (checks all tokens) | Yes (legacy Bearer bypasses) |
30
-
| Error response | 403 "Insufficient scope for repo write" | 403 "Missing required scope \"repo:...\"" |
27
+
| Scope check | `ScopePermissions.allowsRepo(collection, 'create')` | `permissions.assertRepo({ action: 'create', collection })` |
28
+
| Required scope | `repo:<collection>?action=create` or `transition:generic` or `atproto` | `repo:<collection>?action=create` or `transition:generic` or `atproto` |
29
+
| OAuth-only check | Yes (legacy tokens without scope bypass) | Yes (legacy Bearer bypasses) |
30
+
| Error response | 403 "Missing required scope \"repo:...?action=...\"" | 403 "Missing required scope \"repo:...?action=...\"" |
31
31
32
32
### com.atproto.repo.putRecord
33
33
34
34
| Aspect | pds.js | atproto PDS |
35
35
|--------|--------|-------------|
36
-
| Scope check | `hasRequiredScope(scope, 'atproto')` | `assertRepo({ action: 'create' })` AND `assertRepo({ action: 'update' })` |
37
-
| Required scope | `atproto` | Both `repo:<collection>:create` AND `repo:<collection>:update` |
38
-
| Notes | Single check | Requires both since putRecord can create or update |
36
+
| Scope check | `allowsRepo(collection, 'create')` AND `allowsRepo(collection, 'update')` | `assertRepo({ action: 'create' })` AND `assertRepo({ action: 'update' })` |
37
+
| Required scope | `repo:<collection>?action=create&action=update` | `repo:<collection>?action=create&action=update` |
38
+
| Notes | Requires both since putRecord can create or update | Requires both since putRecord can create or update |
39
39
40
40
### com.atproto.repo.deleteRecord
41
41
42
42
| Aspect | pds.js | atproto PDS |
43
43
|--------|--------|-------------|
44
-
| Scope check | `hasRequiredScope(scope, 'atproto')` | `permissions.assertRepo({ action: 'delete', collection })` |
45
-
| Required scope | `atproto` | `repo:<collection>:delete` |
44
+
| Scope check | `ScopePermissions.allowsRepo(collection, 'delete')` | `permissions.assertRepo({ action: 'delete', collection })` |
45
+
| Required scope | `repo:<collection>?action=delete` | `repo:<collection>?action=delete` |
46
46
47
47
### com.atproto.repo.applyWrites
48
48
49
49
| Aspect | pds.js | atproto PDS |
50
50
|--------|--------|-------------|
51
-
| Scope check | `hasRequiredScope(scope, 'atproto')` | Iterates all writes, asserts each unique action/collection pair |
52
-
| Required scope | `atproto` | All `repo:<collection>:<action>` for each write |
53
-
| Per-write validation | No | Yes |
51
+
| Scope check | Iterates all writes, checks each unique action/collection pair | Iterates all writes, asserts each unique action/collection pair |
52
+
| Required scope | All `repo:<collection>?action=<action>` for each write | All `repo:<collection>?action=<action>` for each write |
53
+
| Per-write validation | Yes | Yes |
54
54
55
55
### com.atproto.repo.uploadBlob
56
56
57
57
| Aspect | pds.js | atproto PDS |
58
58
|--------|--------|-------------|
59
-
| Scope check | `hasRequiredScope(scope, 'atproto')` | `permissions.assertBlob({ mime: encoding })` |
60
-
| Required scope | `atproto` | `blob:<mime-type>` (e.g., `blob:image/*`) |
61
-
| MIME type awareness | No | Yes (validates against Content-Type) |
59
+
| Scope check | `ScopePermissions.allowsBlob(contentType)` | `permissions.assertBlob({ mime: encoding })` |
60
+
| Required scope | `blob:<mime-type>` (e.g., `blob:image/*`) | `blob:<mime-type>` (e.g., `blob:image/*`) |
61
+
| MIME type awareness | Yes (validates against Content-Type) | Yes (validates against Content-Type) |
62
62
63
63
### app.bsky.actor.getPreferences
64
64
···
81
81
| Feature | pds.js | atproto PDS |
82
82
|---------|--------|-------------|
83
83
| Scope string splitting | `scope.split(' ')` | `ScopesSet` class |
84
-
| Repo scope parsing | None | `RepoPermission.fromString()` |
85
-
| Blob scope parsing | None | `BlobPermission.fromString()` |
84
+
| Repo scope parsing | `parseRepoScope()` | `RepoPermission.fromString()` |
85
+
| Repo scope format | `repo:collection?action=create&action=update` | `repo:collection?action=create&action=update` |
86
+
| Blob scope parsing | `parseBlobScope()` | `BlobPermission.fromString()` |
86
87
| RPC scope parsing | None | `RpcPermission.fromString()` |
87
-
| Scope validation | None (accepts any string) | Validates syntax, ignores invalid |
88
-
| Scope normalization | None | Sorts, dedupes, simplifies wildcards |
88
+
| Scope validation | Returns null for invalid | Validates syntax, ignores invalid |
89
+
| Action deduplication | Yes (via Set) | Yes |
90
+
| Default actions | All (create, update, delete) when no `?action=` | All (create, update, delete) when no `?action=` |
89
91
90
92
---
91
93
···
93
95
94
96
| Feature | pds.js | atproto PDS |
95
97
|---------|--------|-------------|
96
-
| Permission class | None | `ScopePermissions` / `ScopePermissionsTransition` |
97
-
| `allowsRepo(collection, action)` | N/A | Yes |
98
-
| `allowsBlob(mime)` | N/A | Yes (with MIME wildcard matching) |
98
+
| Permission class | `ScopePermissions` | `ScopePermissions` / `ScopePermissionsTransition` |
99
+
| `allowsRepo(collection, action)` | Yes | Yes |
100
+
| `allowsBlob(mime)` | Yes (with MIME wildcard matching) | Yes (with MIME wildcard matching) |
99
101
| `allowsRpc(aud, lxm)` | N/A | Yes |
100
-
| Transition scope handling | None | `transition:generic` bypasses repo/blob checks |
101
-
| Error messages | Generic | Specific missing scope in error |
102
+
| Transition scope handling | `transition:generic` bypasses repo/blob checks | `transition:generic` bypasses repo/blob checks |
103
+
| Error messages | Specific missing scope in error | Specific missing scope in error |
102
104
103
105
---
104
106
···
114
116
115
117
---
116
118
117
-
## Transition Scope Behavior (atproto PDS)
118
-
119
-
| Scope | Effect |
120
-
|-------|--------|
121
-
| `transition:generic` | Bypasses ALL repo permission checks |
122
-
| `transition:generic` | Bypasses ALL blob permission checks |
123
-
| `transition:generic` | Allows all RPC except `chat.bsky.*` |
124
-
| `transition:chat.bsky` | Allows `chat.bsky.*` RPC methods |
125
-
| `transition:email` | Allows `account:email:read` |
119
+
## Transition Scope Behavior
126
120
127
-
pds.js does not recognize any transition scopes.
121
+
| Scope | pds.js | atproto PDS |
122
+
|-------|--------|-------------|
123
+
| `transition:generic` | Bypasses all repo/blob permission checks | Bypasses ALL repo/blob permission checks |
124
+
| `transition:chat.bsky` | Not implemented | Allows `chat.bsky.*` RPC methods |
125
+
| `transition:email` | Not implemented | Allows `account:email:read` |
128
126
129
127
---
130
128
···
132
130
133
131
| Category | pds.js | atproto PDS |
134
132
|----------|--------|-------------|
135
-
| Scope parsing | String contains check | Full parser per scope type |
136
-
| Enforcement granularity | Binary (has atproto or not) | Per-collection, per-action |
137
-
| Transition scope support | None | Full |
138
-
| MIME-aware blob scopes | No | Yes |
133
+
| Scope parsing | Full parser for repo/blob | Full parser per scope type |
134
+
| Enforcement granularity | Per-collection, per-action | Per-collection, per-action |
135
+
| Transition scope support | `transition:generic` only | Full |
136
+
| MIME-aware blob scopes | Yes | Yes |
139
137
| RPC scopes | No | Yes |
140
-
| Error specificity | Generic 403 | Names missing scope |
138
+
| Error specificity | Names missing scope | Names missing scope |
141
139
142
140
---
143
141
144
-
## Gaps to Address
142
+
## Remaining Gaps
145
143
146
-
1. **Scope parsing** โ Need to parse `repo:*:create` and `blob:image/*` syntax
147
-
2. **Permission class** โ Need `allowsRepo(collection, action)` and `allowsBlob(mime)` methods
148
-
3. **Transition scopes** โ Need `transition:generic` to bypass checks
149
-
4. **Per-endpoint enforcement** โ Check specific scope at each write endpoint
150
-
5. **MIME matching** โ `blob:image/*` should match `image/png`, `image/jpeg`, etc.
151
-
6. **Error messages** โ Return which scope is missing, not generic error
144
+
1. **RPC scopes** โ `rpc:<aud>:<lxm>` parsing and enforcement not implemented
145
+
2. **Additional transition scopes** โ `transition:chat.bsky` and `transition:email` not implemented
146
+
3. **Scope validation at PAR** โ Could validate scope syntax during authorization request
+1
-1
package.json
+1
-1
package.json
+19
-215
scripts/setup.js
+19
-215
scripts/setup.js
···
4
4
* PDS Setup Script
5
5
*
6
6
* Registers a did:plc, initializes the PDS, and notifies the relay.
7
-
* Zero dependencies - uses Node.js built-ins only.
8
7
*
9
8
* Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev
10
9
*/
11
10
12
-
import { webcrypto } from 'node:crypto';
13
11
import { writeFileSync } from 'node:fs';
12
+
import {
13
+
base32Encode,
14
+
base64UrlEncode,
15
+
bytesToHex,
16
+
cborEncodeDagCbor,
17
+
generateKeyPair,
18
+
importPrivateKey,
19
+
sign,
20
+
} from '../src/pds.js';
14
21
15
22
// === ARGUMENT PARSING ===
16
23
···
57
64
return opts;
58
65
}
59
66
60
-
// === KEY GENERATION ===
61
-
62
-
async function generateP256Keypair() {
63
-
const keyPair = await webcrypto.subtle.generateKey(
64
-
{ name: 'ECDSA', namedCurve: 'P-256' },
65
-
true,
66
-
['sign', 'verify'],
67
-
);
68
-
69
-
// Export private key as raw 32 bytes
70
-
const privateJwk = await webcrypto.subtle.exportKey(
71
-
'jwk',
72
-
keyPair.privateKey,
73
-
);
74
-
const privateBytes = base64UrlDecode(privateJwk.d);
75
-
76
-
// Export public key as uncompressed point (65 bytes)
77
-
const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey);
78
-
const publicBytes = new Uint8Array(publicRaw);
79
-
80
-
// Compress public key to 33 bytes
81
-
const compressedPublic = compressPublicKey(publicBytes);
82
-
83
-
return {
84
-
privateKey: privateBytes,
85
-
publicKey: compressedPublic,
86
-
cryptoKey: keyPair.privateKey,
87
-
};
88
-
}
89
-
90
-
function compressPublicKey(uncompressed) {
91
-
// uncompressed is 65 bytes: 0x04 + x(32) + y(32)
92
-
const x = uncompressed.slice(1, 33);
93
-
const y = uncompressed.slice(33, 65);
94
-
const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03;
95
-
const compressed = new Uint8Array(33);
96
-
compressed[0] = prefix;
97
-
compressed.set(x, 1);
98
-
return compressed;
99
-
}
100
-
101
-
function base64UrlDecode(str) {
102
-
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
103
-
const binary = atob(base64);
104
-
const bytes = new Uint8Array(binary.length);
105
-
for (let i = 0; i < binary.length; i++) {
106
-
bytes[i] = binary.charCodeAt(i);
107
-
}
108
-
return bytes;
109
-
}
110
-
111
-
function bytesToHex(bytes) {
112
-
return Array.from(bytes)
113
-
.map((b) => b.toString(16).padStart(2, '0'))
114
-
.join('');
115
-
}
116
-
117
67
// === DID:KEY ENCODING ===
118
68
119
69
// Multicodec prefix for P-256 public key (0x1200)
···
164
114
return result;
165
115
}
166
116
167
-
// === CBOR ENCODING (dag-cbor compliant for PLC operations) ===
168
-
169
-
function cborEncodeKey(key) {
170
-
// Encode a string key to CBOR bytes (for sorting)
171
-
const bytes = new TextEncoder().encode(key);
172
-
const parts = [];
173
-
const mt = 3 << 5; // major type 3 = text string
174
-
if (bytes.length < 24) {
175
-
parts.push(mt | bytes.length);
176
-
} else if (bytes.length < 256) {
177
-
parts.push(mt | 24, bytes.length);
178
-
} else if (bytes.length < 65536) {
179
-
parts.push(mt | 25, bytes.length >> 8, bytes.length & 0xff);
180
-
}
181
-
parts.push(...bytes);
182
-
return new Uint8Array(parts);
183
-
}
184
-
185
-
function compareBytes(a, b) {
186
-
// dag-cbor: bytewise lexicographic order of encoded keys
187
-
const minLen = Math.min(a.length, b.length);
188
-
for (let i = 0; i < minLen; i++) {
189
-
if (a[i] !== b[i]) return a[i] - b[i];
190
-
}
191
-
return a.length - b.length;
192
-
}
193
-
194
-
function cborEncode(value) {
195
-
const parts = [];
196
-
197
-
function encode(val) {
198
-
if (val === null) {
199
-
parts.push(0xf6);
200
-
} else if (typeof val === 'string') {
201
-
const bytes = new TextEncoder().encode(val);
202
-
encodeHead(3, bytes.length);
203
-
parts.push(...bytes);
204
-
} else if (typeof val === 'number') {
205
-
if (Number.isInteger(val) && val >= 0) {
206
-
encodeHead(0, val);
207
-
}
208
-
} else if (val instanceof Uint8Array) {
209
-
encodeHead(2, val.length);
210
-
parts.push(...val);
211
-
} else if (Array.isArray(val)) {
212
-
encodeHead(4, val.length);
213
-
for (const item of val) encode(item);
214
-
} else if (typeof val === 'object') {
215
-
// dag-cbor: sort keys by their CBOR-encoded bytes (length first, then lexicographic)
216
-
const keys = Object.keys(val);
217
-
const keysSorted = keys.sort((a, b) =>
218
-
compareBytes(cborEncodeKey(a), cborEncodeKey(b)),
219
-
);
220
-
encodeHead(5, keysSorted.length);
221
-
for (const key of keysSorted) {
222
-
encode(key);
223
-
encode(val[key]);
224
-
}
225
-
}
226
-
}
227
-
228
-
function encodeHead(majorType, length) {
229
-
const mt = majorType << 5;
230
-
if (length < 24) {
231
-
parts.push(mt | length);
232
-
} else if (length < 256) {
233
-
parts.push(mt | 24, length);
234
-
} else if (length < 65536) {
235
-
parts.push(mt | 25, length >> 8, length & 0xff);
236
-
}
237
-
}
238
-
239
-
encode(value);
240
-
return new Uint8Array(parts);
241
-
}
242
-
243
117
// === HASHING ===
244
118
245
119
async function sha256(data) {
246
-
const hash = await webcrypto.subtle.digest('SHA-256', data);
120
+
const hash = await crypto.subtle.digest('SHA-256', data);
247
121
return new Uint8Array(hash);
248
122
}
249
123
250
124
// === PLC OPERATIONS ===
251
125
252
-
async function signPlcOperation(operation, privateKey) {
126
+
async function signPlcOperation(operation, cryptoKey) {
253
127
// Encode operation without sig field
254
128
const { sig, ...opWithoutSig } = operation;
255
-
const encoded = cborEncode(opWithoutSig);
129
+
const encoded = cborEncodeDagCbor(opWithoutSig);
256
130
257
-
// Sign with P-256
258
-
const signature = await webcrypto.subtle.sign(
259
-
{ name: 'ECDSA', hash: 'SHA-256' },
260
-
privateKey,
261
-
encoded,
262
-
);
263
-
264
-
// Convert to low-S form and base64url encode
265
-
const sigBytes = ensureLowS(new Uint8Array(signature));
266
-
return base64UrlEncode(sigBytes);
267
-
}
268
-
269
-
function ensureLowS(sig) {
270
-
// P-256 order N
271
-
const N = BigInt(
272
-
'0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551',
273
-
);
274
-
const halfN = N / 2n;
275
-
276
-
const r = sig.slice(0, 32);
277
-
const s = sig.slice(32, 64);
278
-
279
-
// Convert s to BigInt
280
-
let sInt = BigInt(`0x${bytesToHex(s)}`);
281
-
282
-
// If s > N/2, replace with N - s
283
-
if (sInt > halfN) {
284
-
sInt = N - sInt;
285
-
const newS = hexToBytes(sInt.toString(16).padStart(64, '0'));
286
-
const result = new Uint8Array(64);
287
-
result.set(r);
288
-
result.set(newS, 32);
289
-
return result;
290
-
}
291
-
292
-
return sig;
293
-
}
294
-
295
-
function hexToBytes(hex) {
296
-
const bytes = new Uint8Array(hex.length / 2);
297
-
for (let i = 0; i < hex.length; i += 2) {
298
-
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
299
-
}
300
-
return bytes;
301
-
}
302
-
303
-
function base64UrlEncode(bytes) {
304
-
const binary = String.fromCharCode(...bytes);
305
-
return btoa(binary)
306
-
.replace(/\+/g, '-')
307
-
.replace(/\//g, '_')
308
-
.replace(/=+$/, '');
131
+
// Sign with P-256 (sign() handles low-S normalization)
132
+
const signature = await sign(cryptoKey, encoded);
133
+
return base64UrlEncode(signature);
309
134
}
310
135
311
136
async function createGenesisOperation(opts) {
···
339
164
340
165
async function deriveDidFromOperation(operation) {
341
166
// DID is computed from the FULL operation INCLUDING the signature
342
-
const encoded = cborEncode(operation);
167
+
const encoded = cborEncodeDagCbor(operation);
343
168
const hash = await sha256(encoded);
344
169
// DID is base32 of first 15 bytes of hash (= 24 base32 chars)
345
170
return `did:plc:${base32Encode(hash.slice(0, 15))}`;
346
-
}
347
-
348
-
function base32Encode(bytes) {
349
-
const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
350
-
let result = '';
351
-
let bits = 0;
352
-
let value = 0;
353
-
354
-
for (const byte of bytes) {
355
-
value = (value << 8) | byte;
356
-
bits += 8;
357
-
while (bits >= 5) {
358
-
bits -= 5;
359
-
result += alphabet[(value >> bits) & 31];
360
-
}
361
-
}
362
-
363
-
if (bits > 0) {
364
-
result += alphabet[(value << (5 - bits)) & 31];
365
-
}
366
-
367
-
return result;
368
171
}
369
172
370
173
// === PLC DIRECTORY REGISTRATION ===
···
479
282
480
283
// Step 1: Generate keypair
481
284
console.log('Generating P-256 keypair...');
482
-
const keyPair = await generateP256Keypair();
285
+
const keyPair = await generateKeyPair();
286
+
const cryptoKey = await importPrivateKey(keyPair.privateKey);
483
287
const didKey = publicKeyToDidKey(keyPair.publicKey);
484
288
console.log(` did:key: ${didKey}`);
485
289
console.log('');
···
490
294
didKey,
491
295
handle: opts.handle,
492
296
pdsUrl: opts.pds,
493
-
cryptoKey: keyPair.cryptoKey,
297
+
cryptoKey,
494
298
});
495
299
const did = await deriveDidFromOperation(operation);
496
300
console.log(` DID: ${did}`);
+883
-225
src/pds.js
+883
-225
src/pds.js
···
31
31
// โ Environment bindings, SQL row types, protocol constants โ
32
32
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
33
33
34
+
// PDS version (keep in sync with package.json)
35
+
const VERSION = '0.5.0';
36
+
34
37
// CBOR primitive markers (RFC 8949)
35
38
const CBOR_FALSE = 0xf4;
36
39
const CBOR_TRUE = 0xf5;
···
57
60
// Crawler notification throttle
58
61
const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000; // 20 minutes (matches official PDS)
59
62
let lastCrawlNotify = 0;
63
+
64
+
// Default Bluesky AppView URL
65
+
const BSKY_APPVIEW_URL = 'https://api.bsky.app';
60
66
61
67
/**
62
68
* Cloudflare Workers environment bindings
···
172
178
*/
173
179
function errorResponse(error, message, status) {
174
180
return Response.json({ error, message }, { status });
181
+
}
182
+
183
+
/**
184
+
* Parse atproto-proxy header to get service DID and service ID
185
+
* Format: "did:web:api.bsky.app#bsky_appview"
186
+
* @param {string} header
187
+
* @returns {{ did: string, serviceId: string } | null}
188
+
*/
189
+
export function parseAtprotoProxyHeader(header) {
190
+
if (!header) return null;
191
+
const hashIndex = header.indexOf('#');
192
+
if (hashIndex === -1 || hashIndex === 0 || hashIndex === header.length - 1) {
193
+
return null;
194
+
}
195
+
return {
196
+
did: header.slice(0, hashIndex),
197
+
serviceId: header.slice(hashIndex + 1),
198
+
};
199
+
}
200
+
201
+
/**
202
+
* Get URL for a known service DID
203
+
* @param {string} did - Service DID (e.g., "did:web:api.bsky.app")
204
+
* @param {string} serviceId - Service ID (e.g., "bsky_appview")
205
+
* @returns {string | null}
206
+
*/
207
+
export function getKnownServiceUrl(did, serviceId) {
208
+
// Known Bluesky services
209
+
if (did === 'did:web:api.bsky.app' && serviceId === 'bsky_appview') {
210
+
return BSKY_APPVIEW_URL;
211
+
}
212
+
// Add more known services as needed
213
+
return null;
214
+
}
215
+
216
+
/**
217
+
* Proxy a request to a service
218
+
* @param {Request} request - Original request
219
+
* @param {string} serviceUrl - Target service URL (e.g., "https://api.bsky.app")
220
+
* @param {string} [authHeader] - Optional Authorization header
221
+
* @returns {Promise<Response>}
222
+
*/
223
+
async function proxyToService(request, serviceUrl, authHeader) {
224
+
const url = new URL(request.url);
225
+
const targetUrl = new URL(url.pathname + url.search, serviceUrl);
226
+
227
+
const headers = new Headers();
228
+
if (authHeader) {
229
+
headers.set('Authorization', authHeader);
230
+
}
231
+
headers.set(
232
+
'Content-Type',
233
+
request.headers.get('Content-Type') || 'application/json',
234
+
);
235
+
const acceptHeader = request.headers.get('Accept');
236
+
if (acceptHeader) {
237
+
headers.set('Accept', acceptHeader);
238
+
}
239
+
const acceptLangHeader = request.headers.get('Accept-Language');
240
+
if (acceptLangHeader) {
241
+
headers.set('Accept-Language', acceptLangHeader);
242
+
}
243
+
// Forward atproto-specific headers
244
+
const labelersHeader = request.headers.get('atproto-accept-labelers');
245
+
if (labelersHeader) {
246
+
headers.set('atproto-accept-labelers', labelersHeader);
247
+
}
248
+
const topicsHeader = request.headers.get('x-bsky-topics');
249
+
if (topicsHeader) {
250
+
headers.set('x-bsky-topics', topicsHeader);
251
+
}
252
+
253
+
try {
254
+
const response = await fetch(targetUrl.toString(), {
255
+
method: request.method,
256
+
headers,
257
+
body:
258
+
request.method !== 'GET' && request.method !== 'HEAD'
259
+
? request.body
260
+
: undefined,
261
+
});
262
+
const responseHeaders = new Headers(response.headers);
263
+
responseHeaders.set('Access-Control-Allow-Origin', '*');
264
+
return new Response(response.body, {
265
+
status: response.status,
266
+
statusText: response.statusText,
267
+
headers: responseHeaders,
268
+
});
269
+
} catch (err) {
270
+
const message = err instanceof Error ? err.message : String(err);
271
+
return errorResponse(
272
+
'UpstreamFailure',
273
+
`Failed to reach service: ${message}`,
274
+
502,
275
+
);
276
+
}
175
277
}
176
278
177
279
/**
···
575
677
576
678
return { jkt, jti: payload.jti, iat: payload.iat, jwk: header.jwk };
577
679
}
578
-
/**
579
-
* Render the OAuth consent page HTML.
580
-
* @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params
581
-
* @returns {string} HTML page content
582
-
*/
583
-
function renderConsentPage({
584
-
clientName,
585
-
clientId,
586
-
scope,
587
-
requestUri,
588
-
error = '',
589
-
}) {
590
-
/** @param {string} s */
591
-
const escHtml = (s) =>
592
-
s
593
-
.replace(/&/g, '&')
594
-
.replace(/</g, '<')
595
-
.replace(/>/g, '>')
596
-
.replace(/"/g, '"');
597
-
return `<!DOCTYPE html>
598
-
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
599
-
<title>Authorize</title>
600
-
<style>
601
-
*{box-sizing:border-box}
602
-
body{font-family:system-ui,sans-serif;max-width:400px;margin:40px auto;padding:20px;background:#1a1a1a;color:#e0e0e0}
603
-
h2{color:#fff;margin-bottom:24px}
604
-
p{color:#b0b0b0;line-height:1.5}
605
-
b{color:#fff}
606
-
.error{color:#ff6b6b;background:#2d1f1f;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #4a2020}
607
-
label{display:block;margin:16px 0 6px;color:#b0b0b0;font-size:14px}
608
-
input[type="password"]{width:100%;padding:12px;background:#2a2a2a;border:1px solid #404040;border-radius:6px;color:#fff;font-size:16px}
609
-
input[type="password"]:focus{outline:none;border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,0.2)}
610
-
.actions{display:flex;gap:12px;margin-top:24px}
611
-
button{flex:1;padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s}
612
-
.deny{background:#2a2a2a;color:#e0e0e0;border:1px solid #404040}
613
-
.deny:hover{background:#333}
614
-
.approve{background:#2563eb;color:#fff;border:none}
615
-
.approve:hover{background:#1d4ed8}
616
-
</style></head>
617
-
<body><h2>Sign in to authorize</h2>
618
-
<p><b>${escHtml(clientName)}</b> wants to access your account.</p>
619
-
<p>Scope: ${escHtml(scope)}</p>
620
-
${error ? `<p class="error">${escHtml(error)}</p>` : ''}
621
-
<form method="POST" action="/oauth/authorize">
622
-
<input type="hidden" name="request_uri" value="${escHtml(requestUri)}">
623
-
<input type="hidden" name="client_id" value="${escHtml(clientId)}">
624
-
<label>Password</label><input type="password" name="password" required autofocus>
625
-
<div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button>
626
-
<button type="submit" name="action" value="approve" class="approve">Authorize</button></div>
627
-
</form></body></html>`;
628
-
}
629
680
630
681
/**
631
682
* Encode integer as unsigned varint
···
744
795
* @param {*} value
745
796
* @returns {Uint8Array}
746
797
*/
747
-
function cborEncodeDagCbor(value) {
798
+
export function cborEncodeDagCbor(value) {
748
799
/** @type {number[]} */
749
800
const parts = [];
750
801
···
2772
2823
*/
2773
2824
async handleAppViewProxy(request, userDid) {
2774
2825
const url = new URL(request.url);
2775
-
// Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences
2776
2826
const lxm = url.pathname.replace('/xrpc/', '');
2777
-
2778
-
// Create service auth JWT
2779
2827
const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm);
2780
-
2781
-
// Build AppView URL
2782
-
const appViewUrl = new URL(
2783
-
url.pathname + url.search,
2784
-
'https://api.bsky.app',
2785
-
);
2786
-
2787
-
// Forward request with service auth
2788
-
const headers = new Headers();
2789
-
headers.set('Authorization', `Bearer ${serviceJwt}`);
2790
-
headers.set(
2791
-
'Content-Type',
2792
-
request.headers.get('Content-Type') || 'application/json',
2793
-
);
2794
-
const acceptHeader = request.headers.get('Accept');
2795
-
if (acceptHeader) {
2796
-
headers.set('Accept', acceptHeader);
2797
-
}
2798
-
const acceptLangHeader = request.headers.get('Accept-Language');
2799
-
if (acceptLangHeader) {
2800
-
headers.set('Accept-Language', acceptLangHeader);
2801
-
}
2802
-
2803
-
const proxyReq = new Request(appViewUrl.toString(), {
2804
-
method: request.method,
2805
-
headers,
2806
-
body:
2807
-
request.method !== 'GET' && request.method !== 'HEAD'
2808
-
? request.body
2809
-
: undefined,
2810
-
});
2811
-
2812
-
try {
2813
-
const response = await fetch(proxyReq);
2814
-
// Return the response with CORS headers
2815
-
const responseHeaders = new Headers(response.headers);
2816
-
responseHeaders.set('Access-Control-Allow-Origin', '*');
2817
-
return new Response(response.body, {
2818
-
status: response.status,
2819
-
statusText: response.statusText,
2820
-
headers: responseHeaders,
2821
-
});
2822
-
} catch (err) {
2823
-
const message = err instanceof Error ? err.message : String(err);
2824
-
return errorResponse(
2825
-
'UpstreamFailure',
2826
-
`Failed to reach AppView: ${message}`,
2827
-
502,
2828
-
);
2829
-
}
2828
+
return proxyToService(request, BSKY_APPVIEW_URL, `Bearer ${serviceJwt}`);
2830
2829
}
2831
2830
2832
2831
async handleListRepos() {
···
3241
3240
3242
3241
/** @param {Request} request */
3243
3242
async handleUploadBlob(request) {
3244
-
// Require auth
3245
-
const authHeader = request.headers.get('Authorization');
3246
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
3247
-
return errorResponse(
3248
-
'AuthRequired',
3249
-
'Missing or invalid authorization header',
3250
-
401,
3251
-
);
3252
-
}
3243
+
// Check if auth was already done by outer handler (OAuth/DPoP flow)
3244
+
const authedDid = request.headers.get('x-authed-did');
3245
+
if (!authedDid) {
3246
+
// Fallback to legacy Bearer token auth
3247
+
const authHeader = request.headers.get('Authorization');
3248
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
3249
+
return errorResponse(
3250
+
'AuthRequired',
3251
+
'Missing or invalid authorization header',
3252
+
401,
3253
+
);
3254
+
}
3253
3255
3254
-
const token = authHeader.slice(7);
3255
-
const jwtSecret = this.env?.JWT_SECRET;
3256
-
if (!jwtSecret) {
3257
-
return errorResponse(
3258
-
'InternalServerError',
3259
-
'Server not configured for authentication',
3260
-
500,
3261
-
);
3262
-
}
3256
+
const token = authHeader.slice(7);
3257
+
const jwtSecret = this.env?.JWT_SECRET;
3258
+
if (!jwtSecret) {
3259
+
return errorResponse(
3260
+
'InternalServerError',
3261
+
'Server not configured for authentication',
3262
+
500,
3263
+
);
3264
+
}
3263
3265
3264
-
try {
3265
-
await verifyAccessJwt(token, jwtSecret);
3266
-
} catch (err) {
3267
-
const message = err instanceof Error ? err.message : String(err);
3268
-
return errorResponse('InvalidToken', message, 401);
3266
+
try {
3267
+
await verifyAccessJwt(token, jwtSecret);
3268
+
} catch (err) {
3269
+
const message = err instanceof Error ? err.message : String(err);
3270
+
return errorResponse('InvalidToken', message, 401);
3271
+
}
3269
3272
}
3270
3273
3271
3274
const did = await this.getDid();
···
3689
3692
code_challenge_methods_supported: ['S256'],
3690
3693
token_endpoint_auth_methods_supported: ['none'],
3691
3694
dpop_signing_alg_values_supported: ['ES256'],
3692
-
require_pushed_authorization_requests: true,
3695
+
require_pushed_authorization_requests: false,
3693
3696
authorization_response_iss_parameter_supported: true,
3694
3697
client_id_metadata_document_supported: true,
3695
3698
protected_resources: [issuer],
···
3725
3728
}
3726
3729
3727
3730
/**
3731
+
* Validate OAuth authorization request parameters.
3732
+
* Shared between PAR and direct authorization flows.
3733
+
* @param {Object} params - The authorization parameters
3734
+
* @param {string | undefined | null} params.clientId - The client_id
3735
+
* @param {string | undefined | null} params.redirectUri - The redirect_uri
3736
+
* @param {string | undefined | null} params.responseType - The response_type
3737
+
* @param {string | undefined | null} params.codeChallenge - The code_challenge
3738
+
* @param {string | undefined | null} params.codeChallengeMethod - The code_challenge_method
3739
+
* @returns {Promise<{error: Response} | {clientMetadata: ClientMetadata}>}
3740
+
*/
3741
+
async validateAuthorizationParameters({
3742
+
clientId,
3743
+
redirectUri,
3744
+
responseType,
3745
+
codeChallenge,
3746
+
codeChallengeMethod,
3747
+
}) {
3748
+
if (!clientId) {
3749
+
return {
3750
+
error: errorResponse('invalid_request', 'client_id required', 400),
3751
+
};
3752
+
}
3753
+
if (!redirectUri) {
3754
+
return {
3755
+
error: errorResponse('invalid_request', 'redirect_uri required', 400),
3756
+
};
3757
+
}
3758
+
if (responseType !== 'code') {
3759
+
return {
3760
+
error: errorResponse(
3761
+
'unsupported_response_type',
3762
+
'response_type must be code',
3763
+
400,
3764
+
),
3765
+
};
3766
+
}
3767
+
if (!codeChallenge || codeChallengeMethod !== 'S256') {
3768
+
return {
3769
+
error: errorResponse('invalid_request', 'PKCE with S256 required', 400),
3770
+
};
3771
+
}
3772
+
3773
+
let clientMetadata;
3774
+
try {
3775
+
clientMetadata = await getClientMetadata(clientId);
3776
+
} catch (err) {
3777
+
return { error: errorResponse('invalid_client', err.message, 400) };
3778
+
}
3779
+
3780
+
// Validate redirect_uri against registered URIs
3781
+
const isLoopback =
3782
+
clientId.startsWith('http://localhost') ||
3783
+
clientId.startsWith('http://127.0.0.1');
3784
+
const redirectUriValid = clientMetadata.redirect_uris.some((uri) => {
3785
+
if (isLoopback) {
3786
+
try {
3787
+
const registered = new URL(uri);
3788
+
const requested = new URL(redirectUri);
3789
+
return registered.origin === requested.origin;
3790
+
} catch {
3791
+
return false;
3792
+
}
3793
+
}
3794
+
return uri === redirectUri;
3795
+
});
3796
+
if (!redirectUriValid) {
3797
+
return {
3798
+
error: errorResponse(
3799
+
'invalid_request',
3800
+
'redirect_uri not registered for this client',
3801
+
400,
3802
+
),
3803
+
};
3804
+
}
3805
+
3806
+
return { clientMetadata };
3807
+
}
3808
+
3809
+
/**
3728
3810
* Handle Pushed Authorization Request (PAR) endpoint.
3729
3811
* Validates DPoP proof, client metadata, PKCE parameters, and stores the authorization request.
3730
3812
* @param {Request} request - The incoming request
···
3764
3846
const codeChallengeMethod = data.code_challenge_method;
3765
3847
const loginHint = data.login_hint;
3766
3848
3767
-
if (!clientId)
3768
-
return errorResponse('invalid_request', 'client_id required', 400);
3769
-
if (!redirectUri)
3770
-
return errorResponse('invalid_request', 'redirect_uri required', 400);
3771
-
if (responseType !== 'code')
3772
-
return errorResponse(
3773
-
'unsupported_response_type',
3774
-
'response_type must be code',
3775
-
400,
3776
-
);
3777
-
if (!codeChallenge || codeChallengeMethod !== 'S256') {
3778
-
return errorResponse('invalid_request', 'PKCE with S256 required', 400);
3779
-
}
3780
-
3781
-
let clientMetadata;
3782
-
try {
3783
-
clientMetadata = await getClientMetadata(clientId);
3784
-
} catch (err) {
3785
-
return errorResponse('invalid_client', err.message, 400);
3786
-
}
3787
-
3788
-
// Validate redirect_uri against registered URIs
3789
-
// For loopback clients (RFC 8252), allow any path on the same origin
3790
-
const isLoopback =
3791
-
clientId.startsWith('http://localhost') ||
3792
-
clientId.startsWith('http://127.0.0.1');
3793
-
const redirectUriValid = clientMetadata.redirect_uris.some((uri) => {
3794
-
if (isLoopback) {
3795
-
// For loopback, check origin match (any path allowed)
3796
-
try {
3797
-
const registered = new URL(uri);
3798
-
const requested = new URL(redirectUri);
3799
-
return registered.origin === requested.origin;
3800
-
} catch {
3801
-
return false;
3802
-
}
3803
-
}
3804
-
return uri === redirectUri;
3849
+
// Use shared validation
3850
+
const validationResult = await this.validateAuthorizationParameters({
3851
+
clientId,
3852
+
redirectUri,
3853
+
responseType,
3854
+
codeChallenge,
3855
+
codeChallengeMethod,
3805
3856
});
3806
-
if (!redirectUriValid) {
3807
-
return errorResponse(
3808
-
'invalid_request',
3809
-
'redirect_uri not registered for this client',
3810
-
400,
3811
-
);
3812
-
}
3857
+
if ('error' in validationResult) return validationResult.error;
3858
+
const { clientMetadata } = validationResult;
3813
3859
3814
3860
const requestId = crypto.randomUUID();
3815
3861
const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
···
3859
3905
3860
3906
/**
3861
3907
* Handle GET /oauth/authorize - displays the consent UI.
3862
-
* Validates the request_uri from PAR and renders a login/consent form.
3908
+
* Supports both PAR (request_uri) and direct authorization parameters.
3863
3909
* @param {URL} url - Parsed request URL
3864
3910
* @returns {Promise<Response>} HTML consent page
3865
3911
*/
3866
3912
async handleOAuthAuthorizeGet(url) {
3913
+
// Opportunistically clean up expired authorization requests
3914
+
this.cleanupExpiredAuthorizationRequests();
3915
+
3867
3916
const requestUri = url.searchParams.get('request_uri');
3868
3917
const clientId = url.searchParams.get('client_id');
3869
3918
3870
-
if (!requestUri || !clientId) {
3871
-
return new Response('Missing parameters', { status: 400 });
3919
+
// If request_uri is present, use PAR flow
3920
+
if (requestUri) {
3921
+
if (!clientId) {
3922
+
return new Response('Missing client_id parameter', { status: 400 });
3923
+
}
3924
+
3925
+
const match = requestUri.match(
3926
+
/^urn:ietf:params:oauth:request_uri:(.+)$/,
3927
+
);
3928
+
if (!match) return new Response('Invalid request_uri', { status: 400 });
3929
+
3930
+
const rows = this.sql
3931
+
.exec(
3932
+
`SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`,
3933
+
match[1],
3934
+
clientId,
3935
+
)
3936
+
.toArray();
3937
+
const authRequest = rows[0];
3938
+
3939
+
if (!authRequest)
3940
+
return new Response('Request not found', { status: 400 });
3941
+
if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date())
3942
+
return new Response('Request expired', { status: 400 });
3943
+
if (authRequest.code)
3944
+
return new Response('Request already used', { status: 400 });
3945
+
3946
+
const clientMetadata = JSON.parse(
3947
+
/** @type {string} */ (authRequest.client_metadata),
3948
+
);
3949
+
const parameters = JSON.parse(
3950
+
/** @type {string} */ (authRequest.parameters),
3951
+
);
3952
+
3953
+
return new Response(
3954
+
renderConsentPage({
3955
+
clientName: clientMetadata.client_name || clientId,
3956
+
clientId: clientId || '',
3957
+
scope: parameters.scope || 'atproto',
3958
+
requestUri: requestUri || '',
3959
+
loginHint: parameters.login_hint || '',
3960
+
}),
3961
+
{
3962
+
status: 200,
3963
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
3964
+
},
3965
+
);
3872
3966
}
3873
3967
3874
-
const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/);
3875
-
if (!match) return new Response('Invalid request_uri', { status: 400 });
3968
+
// Direct authorization flow - create request on-the-fly
3969
+
if (!clientId) {
3970
+
return new Response('Missing client_id parameter', { status: 400 });
3971
+
}
3876
3972
3877
-
const rows = this.sql
3878
-
.exec(
3879
-
`SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`,
3880
-
match[1],
3881
-
clientId,
3882
-
)
3883
-
.toArray();
3884
-
const authRequest = rows[0];
3973
+
const redirectUri = url.searchParams.get('redirect_uri');
3974
+
const responseType = url.searchParams.get('response_type');
3975
+
const responseMode = url.searchParams.get('response_mode');
3976
+
const scope = url.searchParams.get('scope');
3977
+
const state = url.searchParams.get('state');
3978
+
const codeChallenge = url.searchParams.get('code_challenge');
3979
+
const codeChallengeMethod = url.searchParams.get('code_challenge_method');
3980
+
const loginHint = url.searchParams.get('login_hint');
3885
3981
3886
-
if (!authRequest) return new Response('Request not found', { status: 400 });
3887
-
if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date())
3888
-
return new Response('Request expired', { status: 400 });
3889
-
if (authRequest.code)
3890
-
return new Response('Request already used', { status: 400 });
3982
+
// Validate parameters using shared helper
3983
+
const validationResult = await this.validateAuthorizationParameters({
3984
+
clientId,
3985
+
redirectUri,
3986
+
responseType,
3987
+
codeChallenge,
3988
+
codeChallengeMethod,
3989
+
});
3990
+
if ('error' in validationResult) return validationResult.error;
3991
+
const { clientMetadata } = validationResult;
3992
+
3993
+
// Create authorization request record (same as PAR but without DPoP)
3994
+
const requestId = crypto.randomUUID();
3995
+
const newRequestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
3996
+
const expiresIn = 600;
3997
+
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
3891
3998
3892
-
const clientMetadata = JSON.parse(
3893
-
/** @type {string} */ (authRequest.client_metadata),
3894
-
);
3895
-
const parameters = JSON.parse(
3896
-
/** @type {string} */ (authRequest.parameters),
3999
+
this.sql.exec(
4000
+
`INSERT INTO authorization_requests (
4001
+
id, client_id, client_metadata, parameters,
4002
+
code_challenge, code_challenge_method, dpop_jkt,
4003
+
expires_at, created_at
4004
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
4005
+
requestId,
4006
+
clientId,
4007
+
JSON.stringify(clientMetadata),
4008
+
JSON.stringify({
4009
+
redirect_uri: redirectUri,
4010
+
scope,
4011
+
state,
4012
+
response_mode: responseMode,
4013
+
login_hint: loginHint,
4014
+
}),
4015
+
codeChallenge,
4016
+
codeChallengeMethod,
4017
+
null, // No DPoP for direct authorization - will be bound at token exchange
4018
+
expiresAt,
4019
+
new Date().toISOString(),
3897
4020
);
3898
4021
3899
4022
return new Response(
3900
4023
renderConsentPage({
3901
4024
clientName: clientMetadata.client_name || clientId,
3902
-
clientId: clientId || '',
3903
-
scope: parameters.scope || 'atproto',
3904
-
requestUri: requestUri || '',
4025
+
clientId: clientId,
4026
+
scope: scope || 'atproto',
4027
+
requestUri: newRequestUri,
4028
+
loginHint: loginHint || '',
3905
4029
}),
3906
4030
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
3907
4031
);
···
4091
4215
return errorResponse('invalid_grant', 'Invalid code', 400);
4092
4216
if (authRequest.client_id !== clientId)
4093
4217
return errorResponse('invalid_grant', 'Client mismatch', 400);
4094
-
if (authRequest.dpop_jkt !== dpop.jkt)
4218
+
// For PAR flow, dpop_jkt is set at PAR time and must match
4219
+
// For direct authorization, dpop_jkt is null and we bind to the token request's DPoP
4220
+
if (authRequest.dpop_jkt !== null && authRequest.dpop_jkt !== dpop.jkt) {
4095
4221
return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400);
4222
+
}
4096
4223
4097
4224
const parameters = JSON.parse(
4098
4225
/** @type {string} */ (authRequest.parameters),
···
4549
4676
return { did: payload.sub, scope: payload.scope };
4550
4677
}
4551
4678
4679
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
4680
+
// โ SCOPES โ
4681
+
// โ OAuth scope parsing and permission checking โ
4682
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
4683
+
4552
4684
/**
4553
-
* Check if the token scope allows the requested operation.
4554
-
* Legacy tokens (no scope) are always allowed; OAuth tokens must have 'atproto' scope.
4555
-
* @param {string | undefined} scope - The token scope
4556
-
* @param {string} requiredScope - The required scope (e.g., 'atproto')
4557
-
* @returns {boolean} Whether the scope is sufficient
4685
+
* Parse a repo scope string into collection and actions.
4686
+
* Official format: repo:collection?action=create&action=update
4687
+
* Or: repo?collection=foo&action=create
4688
+
* Without actions defaults to all: create, update, delete
4689
+
* @param {string} scope - The scope string to parse
4690
+
* @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid
4558
4691
*/
4559
-
function hasRequiredScope(scope, requiredScope) {
4560
-
// Legacy tokens without scope are trusted for all operations
4561
-
if (!scope) return true;
4562
-
// Check if the scope includes the required scope
4563
-
const scopes = scope.split(' ');
4564
-
return scopes.includes(requiredScope);
4692
+
export function parseRepoScope(scope) {
4693
+
if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null;
4694
+
4695
+
const ALL_ACTIONS = ['create', 'update', 'delete'];
4696
+
let collection;
4697
+
let actions;
4698
+
4699
+
const questionIdx = scope.indexOf('?');
4700
+
if (questionIdx === -1) {
4701
+
// repo:collection (no query params = all actions)
4702
+
collection = scope.slice(5);
4703
+
actions = ALL_ACTIONS;
4704
+
} else {
4705
+
// Parse query parameters
4706
+
const queryString = scope.slice(questionIdx + 1);
4707
+
const params = new URLSearchParams(queryString);
4708
+
const pathPart = scope.startsWith('repo:')
4709
+
? scope.slice(5, questionIdx)
4710
+
: '';
4711
+
4712
+
collection = pathPart || params.get('collection');
4713
+
actions = params.getAll('action');
4714
+
if (actions.length === 0) actions = ALL_ACTIONS;
4715
+
}
4716
+
4717
+
if (!collection) return null;
4718
+
4719
+
// Validate actions
4720
+
const validActions = [
4721
+
...new Set(actions.filter((a) => ALL_ACTIONS.includes(a))),
4722
+
];
4723
+
if (validActions.length === 0) return null;
4724
+
4725
+
return { collection, actions: validActions };
4726
+
}
4727
+
4728
+
/**
4729
+
* Parse a blob scope string into its components.
4730
+
* Format: blob:<mime>[,<mime>...]
4731
+
* @param {string} scope - The scope string to parse
4732
+
* @returns {{ accept: string[] } | null} Parsed scope or null if invalid
4733
+
*/
4734
+
export function parseBlobScope(scope) {
4735
+
if (!scope.startsWith('blob:')) return null;
4736
+
4737
+
const mimeStr = scope.slice(5); // Remove 'blob:'
4738
+
if (!mimeStr) return null;
4739
+
4740
+
const accept = mimeStr.split(',').filter((m) => m);
4741
+
if (accept.length === 0) return null;
4742
+
4743
+
return { accept };
4744
+
}
4745
+
4746
+
/**
4747
+
* Check if a MIME pattern matches an actual MIME type.
4748
+
* @param {string} pattern - MIME pattern (e.g., 'image/\*', '\*\/\*', 'image/png')
4749
+
* @param {string} mime - Actual MIME type to check
4750
+
* @returns {boolean} Whether the pattern matches
4751
+
*/
4752
+
export function matchesMime(pattern, mime) {
4753
+
const p = pattern.toLowerCase();
4754
+
const m = mime.toLowerCase();
4755
+
4756
+
if (p === '*/*') return true;
4757
+
4758
+
if (p.endsWith('/*')) {
4759
+
const pType = p.slice(0, -2);
4760
+
const mType = m.split('/')[0];
4761
+
return pType === mType;
4762
+
}
4763
+
4764
+
return p === m;
4765
+
}
4766
+
4767
+
/**
4768
+
* Error thrown when a required scope is missing.
4769
+
*/
4770
+
class ScopeMissingError extends Error {
4771
+
/**
4772
+
* @param {string} scope - The missing scope
4773
+
*/
4774
+
constructor(scope) {
4775
+
super(`Missing required scope "${scope}"`);
4776
+
this.name = 'ScopeMissingError';
4777
+
this.scope = scope;
4778
+
this.status = 403;
4779
+
}
4780
+
}
4781
+
4782
+
/**
4783
+
* Parses and checks OAuth scope permissions.
4784
+
*/
4785
+
export class ScopePermissions {
4786
+
/**
4787
+
* @param {string | undefined} scopeString - Space-separated scope string
4788
+
*/
4789
+
constructor(scopeString) {
4790
+
/** @type {Set<string>} */
4791
+
this.scopes = new Set(
4792
+
scopeString ? scopeString.split(' ').filter((s) => s) : [],
4793
+
);
4794
+
4795
+
/** @type {Array<{ collection: string, actions: string[] }>} */
4796
+
this.repoPermissions = [];
4797
+
4798
+
/** @type {Array<{ accept: string[] }>} */
4799
+
this.blobPermissions = [];
4800
+
4801
+
for (const scope of this.scopes) {
4802
+
const repo = parseRepoScope(scope);
4803
+
if (repo) this.repoPermissions.push(repo);
4804
+
4805
+
const blob = parseBlobScope(scope);
4806
+
if (blob) this.blobPermissions.push(blob);
4807
+
}
4808
+
}
4809
+
4810
+
/**
4811
+
* Check if full access is granted (atproto or transition:generic).
4812
+
* @returns {boolean}
4813
+
*/
4814
+
hasFullAccess() {
4815
+
return this.scopes.has('atproto') || this.scopes.has('transition:generic');
4816
+
}
4817
+
4818
+
/**
4819
+
* Check if a repo operation is allowed.
4820
+
* @param {string} collection - The collection NSID
4821
+
* @param {string} action - The action (create, update, delete)
4822
+
* @returns {boolean}
4823
+
*/
4824
+
allowsRepo(collection, action) {
4825
+
if (this.hasFullAccess()) return true;
4826
+
4827
+
for (const perm of this.repoPermissions) {
4828
+
const collectionMatch =
4829
+
perm.collection === '*' || perm.collection === collection;
4830
+
const actionMatch = perm.actions.includes(action);
4831
+
if (collectionMatch && actionMatch) return true;
4832
+
}
4833
+
4834
+
return false;
4835
+
}
4836
+
4837
+
/**
4838
+
* Assert that a repo operation is allowed, throwing if not.
4839
+
* @param {string} collection - The collection NSID
4840
+
* @param {string} action - The action (create, update, delete)
4841
+
* @throws {ScopeMissingError}
4842
+
*/
4843
+
assertRepo(collection, action) {
4844
+
if (!this.allowsRepo(collection, action)) {
4845
+
throw new ScopeMissingError(`repo:${collection}?action=${action}`);
4846
+
}
4847
+
}
4848
+
4849
+
/**
4850
+
* Check if a blob operation is allowed.
4851
+
* @param {string} mime - The MIME type of the blob
4852
+
* @returns {boolean}
4853
+
*/
4854
+
allowsBlob(mime) {
4855
+
if (this.hasFullAccess()) return true;
4856
+
4857
+
for (const perm of this.blobPermissions) {
4858
+
for (const pattern of perm.accept) {
4859
+
if (matchesMime(pattern, mime)) return true;
4860
+
}
4861
+
}
4862
+
4863
+
return false;
4864
+
}
4865
+
4866
+
/**
4867
+
* Assert that a blob operation is allowed, throwing if not.
4868
+
* @param {string} mime - The MIME type of the blob
4869
+
* @throws {ScopeMissingError}
4870
+
*/
4871
+
assertBlob(mime) {
4872
+
if (!this.allowsBlob(mime)) {
4873
+
throw new ScopeMissingError(`blob:${mime}`);
4874
+
}
4875
+
}
4876
+
}
4877
+
4878
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
4879
+
// โ CONSENT PAGE DISPLAY โ
4880
+
// โ OAuth consent page rendering with scope visualization โ
4881
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
4882
+
4883
+
/**
4884
+
* Parse scope string into display-friendly structure.
4885
+
* @param {string} scope - Space-separated scope string
4886
+
* @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }}
4887
+
*/
4888
+
export function parseScopesForDisplay(scope) {
4889
+
const scopes = scope.split(' ').filter((s) => s);
4890
+
4891
+
const repoPermissions = new Map();
4892
+
4893
+
for (const s of scopes) {
4894
+
const repo = parseRepoScope(s);
4895
+
if (repo) {
4896
+
const existing = repoPermissions.get(repo.collection) || {
4897
+
create: false,
4898
+
update: false,
4899
+
delete: false,
4900
+
};
4901
+
for (const action of repo.actions) {
4902
+
existing[action] = true;
4903
+
}
4904
+
repoPermissions.set(repo.collection, existing);
4905
+
}
4906
+
}
4907
+
4908
+
const blobPermissions = [];
4909
+
for (const s of scopes) {
4910
+
const blob = parseBlobScope(s);
4911
+
if (blob) blobPermissions.push(...blob.accept);
4912
+
}
4913
+
4914
+
return {
4915
+
hasAtproto: scopes.includes('atproto'),
4916
+
hasTransitionGeneric: scopes.includes('transition:generic'),
4917
+
repoPermissions,
4918
+
blobPermissions,
4919
+
};
4920
+
}
4921
+
4922
+
/**
4923
+
* Escape HTML special characters.
4924
+
* @param {string} s
4925
+
* @returns {string}
4926
+
*/
4927
+
function escapeHtml(s) {
4928
+
return s
4929
+
.replace(/&/g, '&')
4930
+
.replace(/</g, '<')
4931
+
.replace(/>/g, '>')
4932
+
.replace(/"/g, '"');
4933
+
}
4934
+
4935
+
/**
4936
+
* Render repo permissions as HTML table.
4937
+
* @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} repoPermissions
4938
+
* @returns {string} HTML string
4939
+
*/
4940
+
function renderRepoTable(repoPermissions) {
4941
+
if (repoPermissions.size === 0) return '';
4942
+
4943
+
let rows = '';
4944
+
for (const [collection, actions] of repoPermissions) {
4945
+
const displayCollection = collection === '*' ? '* (any)' : collection;
4946
+
rows += `<tr>
4947
+
<td>${escapeHtml(displayCollection)}</td>
4948
+
<td class="check">${actions.create ? 'โ' : ''}</td>
4949
+
<td class="check">${actions.update ? 'โ' : ''}</td>
4950
+
<td class="check">${actions.delete ? 'โ' : ''}</td>
4951
+
</tr>`;
4952
+
}
4953
+
4954
+
return `<div class="permissions-section">
4955
+
<div class="section-label">Repository permissions:</div>
4956
+
<table class="permissions-table">
4957
+
<thead><tr><th>Collection</th><th title="Create">C</th><th title="Update">U</th><th title="Delete">D</th></tr></thead>
4958
+
<tbody>${rows}</tbody>
4959
+
</table>
4960
+
</div>`;
4961
+
}
4962
+
4963
+
/**
4964
+
* Render blob permissions as HTML list.
4965
+
* @param {string[]} blobPermissions
4966
+
* @returns {string} HTML string
4967
+
*/
4968
+
function renderBlobList(blobPermissions) {
4969
+
if (blobPermissions.length === 0) return '';
4970
+
4971
+
const items = blobPermissions
4972
+
.map(
4973
+
(mime) =>
4974
+
`<li>${escapeHtml(mime === '*/*' ? 'All file types' : mime)}</li>`,
4975
+
)
4976
+
.join('');
4977
+
4978
+
return `<div class="permissions-section">
4979
+
<div class="section-label">Upload permissions:</div>
4980
+
<ul class="blob-list">${items}</ul>
4981
+
</div>`;
4982
+
}
4983
+
4984
+
/**
4985
+
* Render full permissions display based on parsed scopes.
4986
+
* @param {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} parsed
4987
+
* @returns {string} HTML string
4988
+
*/
4989
+
function renderPermissionsHtml(parsed) {
4990
+
if (parsed.hasTransitionGeneric) {
4991
+
return `<div class="warning">โ ๏ธ Full repository access requested<br>
4992
+
<small>This app can create, update, and delete any data in your repository.</small></div>`;
4993
+
}
4994
+
4995
+
if (
4996
+
parsed.repoPermissions.size === 0 &&
4997
+
parsed.blobPermissions.length === 0
4998
+
) {
4999
+
return '';
5000
+
}
5001
+
5002
+
return (
5003
+
renderRepoTable(parsed.repoPermissions) +
5004
+
renderBlobList(parsed.blobPermissions)
5005
+
);
5006
+
}
5007
+
5008
+
/**
5009
+
* Render the OAuth consent page HTML.
5010
+
* @param {{ clientName: string, clientId: string, scope: string, requestUri: string, loginHint?: string, error?: string }} params
5011
+
* @returns {string} HTML page content
5012
+
*/
5013
+
function renderConsentPage({
5014
+
clientName,
5015
+
clientId,
5016
+
scope,
5017
+
requestUri,
5018
+
loginHint = '',
5019
+
error = '',
5020
+
}) {
5021
+
const parsed = parseScopesForDisplay(scope);
5022
+
const isIdentityOnly =
5023
+
parsed.repoPermissions.size === 0 &&
5024
+
parsed.blobPermissions.length === 0 &&
5025
+
!parsed.hasTransitionGeneric;
5026
+
5027
+
return `<!DOCTYPE html>
5028
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5029
+
<title>Authorize</title>
5030
+
<style>
5031
+
*{box-sizing:border-box}
5032
+
body{font-family:system-ui,sans-serif;max-width:400px;margin:40px auto;padding:20px;background:#1a1a1a;color:#e0e0e0}
5033
+
h2{color:#fff;margin-bottom:24px}
5034
+
p{color:#b0b0b0;line-height:1.5}
5035
+
b{color:#fff}
5036
+
.error{color:#ff6b6b;background:#2d1f1f;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #4a2020}
5037
+
label{display:block;margin:16px 0 6px;color:#b0b0b0;font-size:14px}
5038
+
input[type="password"]{width:100%;padding:12px;background:#2a2a2a;border:1px solid #404040;border-radius:6px;color:#fff;font-size:16px}
5039
+
input[type="password"]:focus{outline:none;border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,0.2)}
5040
+
.actions{display:flex;gap:12px;margin-top:24px}
5041
+
button{flex:1;padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s}
5042
+
.deny{background:#2a2a2a;color:#e0e0e0;border:1px solid #404040}
5043
+
.deny:hover{background:#333}
5044
+
.approve{background:#2563eb;color:#fff;border:none}
5045
+
.approve:hover{background:#1d4ed8}
5046
+
.permissions-section{margin:16px 0}
5047
+
.section-label{color:#b0b0b0;font-size:13px;margin-bottom:8px}
5048
+
.permissions-table{width:100%;border-collapse:collapse;font-size:13px}
5049
+
.permissions-table th{color:#808080;font-weight:normal;text-align:left;padding:4px 8px;border-bottom:1px solid #333}
5050
+
.permissions-table th:not(:first-child){text-align:center;width:32px}
5051
+
.permissions-table td{padding:4px 8px;border-bottom:1px solid #2a2a2a}
5052
+
.permissions-table td:not(:first-child){text-align:center}
5053
+
.permissions-table .check{color:#4ade80}
5054
+
.blob-list{margin:0;padding-left:20px;color:#e0e0e0;font-size:13px}
5055
+
.blob-list li{margin:4px 0}
5056
+
.warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0}
5057
+
.warning small{color:#d4a000;display:block;margin-top:4px}
5058
+
.profile-card{display:flex;align-items:center;gap:12px;padding:16px;background:#2a2a2a;border-radius:8px;margin-bottom:20px}
5059
+
.profile-card.loading .avatar{background:#404040;animation:pulse 1.5s infinite}
5060
+
.profile-card .avatar{width:48px;height:48px;border-radius:50%;background:#404040;flex-shrink:0}
5061
+
.profile-card .avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover}
5062
+
.profile-card .info{min-width:0}
5063
+
.profile-card .name{color:#fff;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
5064
+
.profile-card .handle{color:#808080;font-size:14px}
5065
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}
5066
+
</style></head>
5067
+
<body>
5068
+
${
5069
+
loginHint
5070
+
? `<div class="profile-card loading" id="profile-card">
5071
+
<div class="avatar" id="profile-avatar"></div>
5072
+
<div class="info"><div class="name" id="profile-name">Loading...</div>
5073
+
<div class="handle" id="profile-handle">${escapeHtml(loginHint.startsWith('did:') ? loginHint : `@${loginHint}`)}</div></div>
5074
+
</div>`
5075
+
: ''
5076
+
}
5077
+
<h2>Sign in to authorize</h2>
5078
+
<p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p>
5079
+
${renderPermissionsHtml(parsed)}
5080
+
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
5081
+
<form method="POST" action="/oauth/authorize">
5082
+
<input type="hidden" name="request_uri" value="${escapeHtml(requestUri)}">
5083
+
<input type="hidden" name="client_id" value="${escapeHtml(clientId)}">
5084
+
<label>Password</label><input type="password" name="password" required autofocus>
5085
+
<div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button>
5086
+
<button type="submit" name="action" value="approve" class="approve">Authorize</button></div>
5087
+
</form>
5088
+
${
5089
+
loginHint
5090
+
? `<script>
5091
+
(async()=>{
5092
+
const card=document.getElementById('profile-card');
5093
+
if(!card)return;
5094
+
try{
5095
+
const r=await fetch('https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor='+encodeURIComponent(${JSON.stringify(loginHint)}));
5096
+
if(!r.ok)throw new Error();
5097
+
const p=await r.json();
5098
+
document.getElementById('profile-avatar').innerHTML=p.avatar?'<img src="'+p.avatar+'" alt="">':'';
5099
+
document.getElementById('profile-name').textContent=p.displayName||p.handle;
5100
+
document.getElementById('profile-handle').textContent='@'+p.handle;
5101
+
card.classList.remove('loading');
5102
+
}catch(e){card.classList.remove('loading')}
5103
+
})();
5104
+
</script>`
5105
+
: ''
5106
+
}
5107
+
</body></html>`;
4565
5108
}
4566
5109
4567
5110
/**
···
4575
5118
if ('error' in auth) return auth.error;
4576
5119
4577
5120
// Validate scope for blob upload
4578
-
if (!hasRequiredScope(auth.scope, 'atproto')) {
4579
-
return errorResponse(
4580
-
'Forbidden',
4581
-
'Insufficient scope for blob upload',
4582
-
403,
4583
-
);
5121
+
if (auth.scope !== undefined) {
5122
+
const contentType =
5123
+
request.headers.get('content-type') || 'application/octet-stream';
5124
+
const permissions = new ScopePermissions(auth.scope);
5125
+
if (!permissions.allowsBlob(contentType)) {
5126
+
return errorResponse(
5127
+
'Forbidden',
5128
+
`Missing required scope "blob:${contentType}"`,
5129
+
403,
5130
+
);
5131
+
}
4584
5132
}
5133
+
// Legacy tokens without scope are trusted (backward compat)
4585
5134
4586
5135
// Route to the user's DO based on their DID from the token
4587
5136
const id = env.PDS.idFromName(auth.did);
4588
5137
const pds = env.PDS.get(id);
4589
-
return pds.fetch(request);
5138
+
// Pass x-authed-did so DO knows auth was already done (avoids DPoP replay detection)
5139
+
return pds.fetch(
5140
+
new Request(request.url, {
5141
+
method: request.method,
5142
+
headers: {
5143
+
...Object.fromEntries(request.headers),
5144
+
'x-authed-did': auth.did,
5145
+
},
5146
+
body: request.body,
5147
+
}),
5148
+
);
4590
5149
}
4591
5150
4592
5151
/**
···
4599
5158
const auth = await requireAuth(request, env, defaultPds);
4600
5159
if ('error' in auth) return auth.error;
4601
5160
4602
-
// Validate scope for repo write
4603
-
if (!hasRequiredScope(auth.scope, 'atproto')) {
4604
-
return errorResponse('Forbidden', 'Insufficient scope for repo write', 403);
4605
-
}
4606
-
4607
5161
const body = await request.json();
4608
5162
const repo = body.repo;
4609
5163
if (!repo) {
···
4614
5168
return errorResponse('Forbidden', "Cannot modify another user's repo", 403);
4615
5169
}
4616
5170
5171
+
// Granular scope validation for OAuth tokens
5172
+
if (auth.scope !== undefined) {
5173
+
const permissions = new ScopePermissions(auth.scope);
5174
+
const url = new URL(request.url);
5175
+
const endpoint = url.pathname;
5176
+
5177
+
if (endpoint === '/xrpc/com.atproto.repo.createRecord') {
5178
+
const collection = body.collection;
5179
+
if (!collection) {
5180
+
return errorResponse('InvalidRequest', 'missing collection param', 400);
5181
+
}
5182
+
if (!permissions.allowsRepo(collection, 'create')) {
5183
+
return errorResponse(
5184
+
'Forbidden',
5185
+
`Missing required scope "repo:${collection}:create"`,
5186
+
403,
5187
+
);
5188
+
}
5189
+
} else if (endpoint === '/xrpc/com.atproto.repo.putRecord') {
5190
+
const collection = body.collection;
5191
+
if (!collection) {
5192
+
return errorResponse('InvalidRequest', 'missing collection param', 400);
5193
+
}
5194
+
// putRecord requires both create and update permissions
5195
+
if (
5196
+
!permissions.allowsRepo(collection, 'create') ||
5197
+
!permissions.allowsRepo(collection, 'update')
5198
+
) {
5199
+
const missing = !permissions.allowsRepo(collection, 'create')
5200
+
? 'create'
5201
+
: 'update';
5202
+
return errorResponse(
5203
+
'Forbidden',
5204
+
`Missing required scope "repo:${collection}:${missing}"`,
5205
+
403,
5206
+
);
5207
+
}
5208
+
} else if (endpoint === '/xrpc/com.atproto.repo.deleteRecord') {
5209
+
const collection = body.collection;
5210
+
if (!collection) {
5211
+
return errorResponse('InvalidRequest', 'missing collection param', 400);
5212
+
}
5213
+
if (!permissions.allowsRepo(collection, 'delete')) {
5214
+
return errorResponse(
5215
+
'Forbidden',
5216
+
`Missing required scope "repo:${collection}:delete"`,
5217
+
403,
5218
+
);
5219
+
}
5220
+
} else if (endpoint === '/xrpc/com.atproto.repo.applyWrites') {
5221
+
const writes = body.writes || [];
5222
+
for (const write of writes) {
5223
+
const collection = write.collection;
5224
+
if (!collection) continue;
5225
+
5226
+
let action;
5227
+
if (write.$type === 'com.atproto.repo.applyWrites#create') {
5228
+
action = 'create';
5229
+
} else if (write.$type === 'com.atproto.repo.applyWrites#update') {
5230
+
action = 'update';
5231
+
} else if (write.$type === 'com.atproto.repo.applyWrites#delete') {
5232
+
action = 'delete';
5233
+
} else {
5234
+
continue;
5235
+
}
5236
+
5237
+
if (!permissions.allowsRepo(collection, action)) {
5238
+
return errorResponse(
5239
+
'Forbidden',
5240
+
`Missing required scope "repo:${collection}:${action}"`,
5241
+
403,
5242
+
);
5243
+
}
5244
+
}
5245
+
}
5246
+
}
5247
+
// Legacy tokens without scope are trusted (backward compat)
5248
+
4617
5249
const id = env.PDS.idFromName(repo);
4618
5250
const pds = env.PDS.get(id);
4619
5251
const response = await pds.fetch(
···
4774
5406
if (!repo) {
4775
5407
return errorResponse('InvalidRequest', 'missing repo param', 400);
4776
5408
}
5409
+
5410
+
// Check for atproto-proxy header - if present, proxy to specified service
5411
+
const proxyHeader = request.headers.get('atproto-proxy');
5412
+
if (proxyHeader) {
5413
+
const parsed = parseAtprotoProxyHeader(proxyHeader);
5414
+
if (!parsed) {
5415
+
// Header present but malformed
5416
+
return errorResponse(
5417
+
'InvalidRequest',
5418
+
`Malformed atproto-proxy header: ${proxyHeader}`,
5419
+
400,
5420
+
);
5421
+
}
5422
+
const serviceUrl = getKnownServiceUrl(parsed.did, parsed.serviceId);
5423
+
if (serviceUrl) {
5424
+
return proxyToService(request, serviceUrl);
5425
+
}
5426
+
// Unknown service - could add DID resolution here in the future
5427
+
return errorResponse(
5428
+
'InvalidRequest',
5429
+
`Unknown proxy service: ${proxyHeader}`,
5430
+
400,
5431
+
);
5432
+
}
5433
+
5434
+
// No proxy header - handle locally (returns appropriate error if DID not found)
4777
5435
const id = env.PDS.idFromName(repo);
4778
5436
const pds = env.PDS.get(id);
4779
5437
return pds.fetch(request);
···
4815
5473
4816
5474
// Health check endpoint
4817
5475
if (url.pathname === '/xrpc/_health') {
4818
-
return Response.json({ version: '0.1.0' });
5476
+
return Response.json({ version: VERSION });
4819
5477
}
4820
5478
4821
5479
// Root path - ASCII art
+824
-25
test/e2e.test.js
+824
-25
test/e2e.test.js
···
3
3
* Uses Node's built-in test runner and fetch
4
4
*/
5
5
6
-
import { describe, it, before, after } from 'node:test';
7
6
import assert from 'node:assert';
8
7
import { spawn } from 'node:child_process';
9
8
import { randomBytes } from 'node:crypto';
9
+
import { after, before, describe, it } from 'node:test';
10
10
import { DpopClient } from './helpers/dpop.js';
11
+
import { getOAuthTokenWithScope } from './helpers/oauth.js';
11
12
12
13
const BASE = 'http://localhost:8787';
13
14
const DID = `did:plc:test${randomBytes(8).toString('hex')}`;
···
39
40
}
40
41
41
42
/**
42
-
* Make JSON request helper
43
+
* Make JSON request helper (with retry for flaky wrangler dev 5xx errors)
43
44
*/
44
45
async function jsonPost(path, body, headers = {}) {
45
-
const res = await fetch(`${BASE}${path}`, {
46
-
method: 'POST',
47
-
headers: { 'Content-Type': 'application/json', ...headers },
48
-
body: JSON.stringify(body),
49
-
});
50
-
return { status: res.status, data: res.ok ? await res.json() : null };
46
+
for (let attempt = 0; attempt < 3; attempt++) {
47
+
const res = await fetch(`${BASE}${path}`, {
48
+
method: 'POST',
49
+
headers: { 'Content-Type': 'application/json', ...headers },
50
+
body: JSON.stringify(body),
51
+
});
52
+
// Retry on 5xx errors (wrangler dev flakiness)
53
+
if (res.status >= 500 && attempt < 2) {
54
+
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
55
+
continue;
56
+
}
57
+
return { status: res.status, data: res.ok ? await res.json() : null };
58
+
}
51
59
}
52
60
53
61
/**
54
-
* Make form-encoded POST
62
+
* Make form-encoded POST (with retry for flaky wrangler dev 5xx errors)
55
63
*/
56
64
async function formPost(path, params, headers = {}) {
57
-
const res = await fetch(`${BASE}${path}`, {
58
-
method: 'POST',
59
-
headers: {
60
-
'Content-Type': 'application/x-www-form-urlencoded',
61
-
...headers,
62
-
},
63
-
body: new URLSearchParams(params).toString(),
64
-
});
65
-
const text = await res.text();
66
-
let data = null;
67
-
try {
68
-
data = JSON.parse(text);
69
-
} catch {
70
-
data = text;
65
+
for (let attempt = 0; attempt < 3; attempt++) {
66
+
const res = await fetch(`${BASE}${path}`, {
67
+
method: 'POST',
68
+
headers: {
69
+
'Content-Type': 'application/x-www-form-urlencoded',
70
+
...headers,
71
+
},
72
+
body: new URLSearchParams(params).toString(),
73
+
});
74
+
// Retry on 5xx errors (wrangler dev flakiness)
75
+
if (res.status >= 500 && attempt < 2) {
76
+
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
77
+
continue;
78
+
}
79
+
const text = await res.text();
80
+
let data = null;
81
+
try {
82
+
data = JSON.parse(text);
83
+
} catch {
84
+
data = text;
85
+
}
86
+
return { status: res.status, data };
71
87
}
72
-
return { status: res.status, data };
73
88
}
74
89
75
90
describe('E2E Tests', () => {
···
537
552
assert.strictEqual(data.jwks_uri, `${BASE}/oauth/jwks`);
538
553
assert.deepStrictEqual(data.scopes_supported, ['atproto']);
539
554
assert.deepStrictEqual(data.dpop_signing_alg_values_supported, ['ES256']);
540
-
assert.strictEqual(data.require_pushed_authorization_requests, true);
555
+
assert.strictEqual(data.require_pushed_authorization_requests, false);
541
556
assert.strictEqual(data.client_id_metadata_document_supported, true);
542
557
assert.deepStrictEqual(data.protected_resources, [BASE]);
543
558
});
···
1022
1037
const data = await parRes2.json();
1023
1038
assert.strictEqual(data.error, 'invalid_dpop_proof');
1024
1039
assert.ok(data.message?.includes('replay'));
1040
+
});
1041
+
});
1042
+
1043
+
describe('Scope Enforcement', () => {
1044
+
it('createRecord denied with insufficient scope', async () => {
1045
+
// Get token that only allows creating likes, not posts
1046
+
const { accessToken, dpop } = await getOAuthTokenWithScope(
1047
+
'repo:app.bsky.feed.like?action=create',
1048
+
DID,
1049
+
PASSWORD,
1050
+
);
1051
+
1052
+
const proof = await dpop.createProof(
1053
+
'POST',
1054
+
`${BASE}/xrpc/com.atproto.repo.createRecord`,
1055
+
accessToken,
1056
+
);
1057
+
1058
+
const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, {
1059
+
method: 'POST',
1060
+
headers: {
1061
+
'Content-Type': 'application/json',
1062
+
Authorization: `DPoP ${accessToken}`,
1063
+
DPoP: proof,
1064
+
},
1065
+
body: JSON.stringify({
1066
+
repo: DID,
1067
+
collection: 'app.bsky.feed.post', // Not allowed by scope
1068
+
record: { text: 'test', createdAt: new Date().toISOString() },
1069
+
}),
1070
+
});
1071
+
1072
+
assert.strictEqual(res.status, 403, 'Should reject with 403');
1073
+
const body = await res.json();
1074
+
assert.ok(
1075
+
body.message?.includes('Missing required scope'),
1076
+
'Error should mention missing scope',
1077
+
);
1078
+
});
1079
+
1080
+
it('createRecord allowed with matching scope', async () => {
1081
+
// Get token that allows creating posts
1082
+
const { accessToken, dpop } = await getOAuthTokenWithScope(
1083
+
'repo:app.bsky.feed.post?action=create',
1084
+
DID,
1085
+
PASSWORD,
1086
+
);
1087
+
1088
+
const proof = await dpop.createProof(
1089
+
'POST',
1090
+
`${BASE}/xrpc/com.atproto.repo.createRecord`,
1091
+
accessToken,
1092
+
);
1093
+
1094
+
const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, {
1095
+
method: 'POST',
1096
+
headers: {
1097
+
'Content-Type': 'application/json',
1098
+
Authorization: `DPoP ${accessToken}`,
1099
+
DPoP: proof,
1100
+
},
1101
+
body: JSON.stringify({
1102
+
repo: DID,
1103
+
collection: 'app.bsky.feed.post',
1104
+
record: { text: 'scope test', createdAt: new Date().toISOString() },
1105
+
}),
1106
+
});
1107
+
1108
+
assert.strictEqual(res.status, 200, 'Should allow with correct scope');
1109
+
const body = await res.json();
1110
+
assert.ok(body.uri, 'Should return uri');
1111
+
1112
+
// Note: We don't clean up here because our token only has create scope
1113
+
// The record will be cleaned up by subsequent tests with full-access tokens
1114
+
});
1115
+
1116
+
it('createRecord allowed with wildcard collection scope', async () => {
1117
+
// Get token that allows creating any record type
1118
+
const { accessToken, dpop } = await getOAuthTokenWithScope(
1119
+
'repo:*?action=create',
1120
+
DID,
1121
+
PASSWORD,
1122
+
);
1123
+
1124
+
const proof = await dpop.createProof(
1125
+
'POST',
1126
+
`${BASE}/xrpc/com.atproto.repo.createRecord`,
1127
+
accessToken,
1128
+
);
1129
+
1130
+
const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, {
1131
+
method: 'POST',
1132
+
headers: {
1133
+
'Content-Type': 'application/json',
1134
+
Authorization: `DPoP ${accessToken}`,
1135
+
DPoP: proof,
1136
+
},
1137
+
body: JSON.stringify({
1138
+
repo: DID,
1139
+
collection: 'app.bsky.feed.post',
1140
+
record: {
1141
+
text: 'wildcard scope test',
1142
+
createdAt: new Date().toISOString(),
1143
+
},
1144
+
}),
1145
+
});
1146
+
1147
+
assert.strictEqual(
1148
+
res.status,
1149
+
200,
1150
+
'Wildcard scope should allow any collection',
1151
+
);
1152
+
});
1153
+
1154
+
it('deleteRecord denied without delete scope', async () => {
1155
+
// Get token that only has create scope
1156
+
const { accessToken, dpop } = await getOAuthTokenWithScope(
1157
+
'repo:app.bsky.feed.post?action=create',
1158
+
DID,
1159
+
PASSWORD,
1160
+
);
1161
+
1162
+
const proof = await dpop.createProof(
1163
+
'POST',
1164
+
`${BASE}/xrpc/com.atproto.repo.deleteRecord`,
1165
+
accessToken,
1166
+
);
1167
+
1168
+
const res = await fetch(`${BASE}/xrpc/com.atproto.repo.deleteRecord`, {
1169
+
method: 'POST',
1170
+
headers: {
1171
+
'Content-Type': 'application/json',
1172
+
Authorization: `DPoP ${accessToken}`,
1173
+
DPoP: proof,
1174
+
},
1175
+
body: JSON.stringify({
1176
+
repo: DID,
1177
+
collection: 'app.bsky.feed.post',
1178
+
rkey: 'nonexistent', // Doesn't matter, should fail on scope first
1179
+
}),
1180
+
});
1181
+
1182
+
assert.strictEqual(
1183
+
res.status,
1184
+
403,
1185
+
'Should reject delete without delete scope',
1186
+
);
1187
+
});
1188
+
1189
+
it('uploadBlob denied with mismatched MIME scope', async () => {
1190
+
// Get token that only allows image uploads
1191
+
const { accessToken, dpop } = await getOAuthTokenWithScope(
1192
+
'blob:image/*',
1193
+
DID,
1194
+
PASSWORD,
1195
+
);
1196
+
1197
+
const proof = await dpop.createProof(
1198
+
'POST',
1199
+
`${BASE}/xrpc/com.atproto.repo.uploadBlob`,
1200
+
accessToken,
1201
+
);
1202
+
1203
+
// Try to upload a video (not allowed by scope)
1204
+
const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, {
1205
+
method: 'POST',
1206
+
headers: {
1207
+
'Content-Type': 'video/mp4',
1208
+
Authorization: `DPoP ${accessToken}`,
1209
+
DPoP: proof,
1210
+
},
1211
+
body: new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), // Fake MP4 header
1212
+
});
1213
+
1214
+
assert.strictEqual(
1215
+
res.status,
1216
+
403,
1217
+
'Should reject video upload with image-only scope',
1218
+
);
1219
+
const body = await res.json();
1220
+
assert.ok(
1221
+
body.message?.includes('Missing required scope'),
1222
+
'Error should mention missing scope',
1223
+
);
1224
+
});
1225
+
1226
+
it('uploadBlob allowed with matching MIME scope', async () => {
1227
+
// Get token that allows image uploads
1228
+
const { accessToken, dpop } = await getOAuthTokenWithScope(
1229
+
'blob:image/*',
1230
+
DID,
1231
+
PASSWORD,
1232
+
);
1233
+
1234
+
const proof = await dpop.createProof(
1235
+
'POST',
1236
+
`${BASE}/xrpc/com.atproto.repo.uploadBlob`,
1237
+
accessToken,
1238
+
);
1239
+
1240
+
// Minimal PNG
1241
+
const pngBytes = new Uint8Array([
1242
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
1243
+
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
1244
+
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
1245
+
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
1246
+
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
1247
+
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
1248
+
]);
1249
+
1250
+
const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, {
1251
+
method: 'POST',
1252
+
headers: {
1253
+
'Content-Type': 'image/png',
1254
+
Authorization: `DPoP ${accessToken}`,
1255
+
DPoP: proof,
1256
+
},
1257
+
body: pngBytes,
1258
+
});
1259
+
1260
+
assert.strictEqual(
1261
+
res.status,
1262
+
200,
1263
+
'Should allow image upload with image scope',
1264
+
);
1265
+
});
1266
+
1267
+
it('transition:generic grants full access', async () => {
1268
+
// Get token with transition:generic scope (full access)
1269
+
const { accessToken, dpop } = await getOAuthTokenWithScope(
1270
+
'transition:generic',
1271
+
DID,
1272
+
PASSWORD,
1273
+
);
1274
+
1275
+
const proof = await dpop.createProof(
1276
+
'POST',
1277
+
`${BASE}/xrpc/com.atproto.repo.createRecord`,
1278
+
accessToken,
1279
+
);
1280
+
1281
+
const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, {
1282
+
method: 'POST',
1283
+
headers: {
1284
+
'Content-Type': 'application/json',
1285
+
Authorization: `DPoP ${accessToken}`,
1286
+
DPoP: proof,
1287
+
},
1288
+
body: JSON.stringify({
1289
+
repo: DID,
1290
+
collection: 'app.bsky.feed.post',
1291
+
record: {
1292
+
text: 'transition scope test',
1293
+
createdAt: new Date().toISOString(),
1294
+
},
1295
+
}),
1296
+
});
1297
+
1298
+
assert.strictEqual(
1299
+
res.status,
1300
+
200,
1301
+
'transition:generic should grant full access',
1302
+
);
1303
+
});
1304
+
});
1305
+
1306
+
describe('Consent page display', () => {
1307
+
it('consent page shows permissions table for granular scopes', async () => {
1308
+
const dpop = await DpopClient.create();
1309
+
const clientId = 'http://localhost:3000';
1310
+
const redirectUri = 'http://localhost:3000/callback';
1311
+
const codeVerifier = randomBytes(32).toString('base64url');
1312
+
1313
+
const challengeBuffer = await crypto.subtle.digest(
1314
+
'SHA-256',
1315
+
new TextEncoder().encode(codeVerifier),
1316
+
);
1317
+
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
1318
+
1319
+
// PAR request with granular scopes
1320
+
const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`);
1321
+
const parRes = await fetch(`${BASE}/oauth/par`, {
1322
+
method: 'POST',
1323
+
headers: {
1324
+
'Content-Type': 'application/x-www-form-urlencoded',
1325
+
DPoP: parProof,
1326
+
},
1327
+
body: new URLSearchParams({
1328
+
client_id: clientId,
1329
+
redirect_uri: redirectUri,
1330
+
response_type: 'code',
1331
+
scope:
1332
+
'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*',
1333
+
code_challenge: codeChallenge,
1334
+
code_challenge_method: 'S256',
1335
+
state: 'test-state',
1336
+
login_hint: DID,
1337
+
}).toString(),
1338
+
});
1339
+
1340
+
assert.strictEqual(parRes.status, 200, 'PAR should succeed');
1341
+
const { request_uri } = await parRes.json();
1342
+
1343
+
// GET the authorize page
1344
+
const authorizeRes = await fetch(
1345
+
`${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`,
1346
+
);
1347
+
1348
+
const html = await authorizeRes.text();
1349
+
1350
+
// Verify permissions table is rendered
1351
+
assert.ok(
1352
+
html.includes('Repository permissions:'),
1353
+
'Should show repo permissions section',
1354
+
);
1355
+
assert.ok(
1356
+
html.includes('app.bsky.feed.post'),
1357
+
'Should show collection name',
1358
+
);
1359
+
assert.ok(
1360
+
html.includes('Upload permissions:'),
1361
+
'Should show upload permissions section',
1362
+
);
1363
+
assert.ok(html.includes('image/*'), 'Should show blob MIME type');
1364
+
});
1365
+
1366
+
it('consent page shows identity message for atproto-only scope', async () => {
1367
+
const dpop = await DpopClient.create();
1368
+
const clientId = 'http://localhost:3000';
1369
+
const redirectUri = 'http://localhost:3000/callback';
1370
+
const codeVerifier = randomBytes(32).toString('base64url');
1371
+
1372
+
const challengeBuffer = await crypto.subtle.digest(
1373
+
'SHA-256',
1374
+
new TextEncoder().encode(codeVerifier),
1375
+
);
1376
+
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
1377
+
1378
+
// PAR request with atproto only (identity-only)
1379
+
const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`);
1380
+
const parRes = await fetch(`${BASE}/oauth/par`, {
1381
+
method: 'POST',
1382
+
headers: {
1383
+
'Content-Type': 'application/x-www-form-urlencoded',
1384
+
DPoP: parProof,
1385
+
},
1386
+
body: new URLSearchParams({
1387
+
client_id: clientId,
1388
+
redirect_uri: redirectUri,
1389
+
response_type: 'code',
1390
+
scope: 'atproto',
1391
+
code_challenge: codeChallenge,
1392
+
code_challenge_method: 'S256',
1393
+
state: 'test-state',
1394
+
login_hint: DID,
1395
+
}).toString(),
1396
+
});
1397
+
1398
+
assert.strictEqual(parRes.status, 200, 'PAR should succeed');
1399
+
const { request_uri } = await parRes.json();
1400
+
1401
+
// GET the authorize page
1402
+
const authorizeRes = await fetch(
1403
+
`${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`,
1404
+
);
1405
+
1406
+
const html = await authorizeRes.text();
1407
+
1408
+
// Verify identity-only message
1409
+
assert.ok(
1410
+
html.includes('wants to uniquely identify you'),
1411
+
'Should show identity-only message',
1412
+
);
1413
+
assert.ok(
1414
+
!html.includes('Repository permissions:'),
1415
+
'Should NOT show permissions table',
1416
+
);
1417
+
});
1418
+
1419
+
it('consent page shows warning for transition:generic scope', async () => {
1420
+
const dpop = await DpopClient.create();
1421
+
const clientId = 'http://localhost:3000';
1422
+
const redirectUri = 'http://localhost:3000/callback';
1423
+
const codeVerifier = randomBytes(32).toString('base64url');
1424
+
1425
+
const challengeBuffer = await crypto.subtle.digest(
1426
+
'SHA-256',
1427
+
new TextEncoder().encode(codeVerifier),
1428
+
);
1429
+
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
1430
+
1431
+
// PAR request with transition:generic (full access)
1432
+
const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`);
1433
+
const parRes = await fetch(`${BASE}/oauth/par`, {
1434
+
method: 'POST',
1435
+
headers: {
1436
+
'Content-Type': 'application/x-www-form-urlencoded',
1437
+
DPoP: parProof,
1438
+
},
1439
+
body: new URLSearchParams({
1440
+
client_id: clientId,
1441
+
redirect_uri: redirectUri,
1442
+
response_type: 'code',
1443
+
scope: 'atproto transition:generic',
1444
+
code_challenge: codeChallenge,
1445
+
code_challenge_method: 'S256',
1446
+
state: 'test-state',
1447
+
login_hint: DID,
1448
+
}).toString(),
1449
+
});
1450
+
1451
+
assert.strictEqual(parRes.status, 200, 'PAR should succeed');
1452
+
const { request_uri } = await parRes.json();
1453
+
1454
+
// GET the authorize page
1455
+
const authorizeRes = await fetch(
1456
+
`${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`,
1457
+
);
1458
+
1459
+
const html = await authorizeRes.text();
1460
+
1461
+
// Verify warning banner
1462
+
assert.ok(
1463
+
html.includes('Full repository access requested'),
1464
+
'Should show full access warning',
1465
+
);
1466
+
});
1467
+
1468
+
it('supports direct authorization without PAR', async () => {
1469
+
const clientId = 'http://localhost:3000';
1470
+
const redirectUri = 'http://localhost:3000/callback';
1471
+
const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!';
1472
+
const challengeBuffer = await crypto.subtle.digest(
1473
+
'SHA-256',
1474
+
new TextEncoder().encode(codeVerifier),
1475
+
);
1476
+
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
1477
+
const state = 'test-direct-auth-state';
1478
+
1479
+
// Step 1: GET authorize with direct parameters (no PAR)
1480
+
const authorizeUrl = new URL(`${BASE}/oauth/authorize`);
1481
+
authorizeUrl.searchParams.set('client_id', clientId);
1482
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
1483
+
authorizeUrl.searchParams.set('response_type', 'code');
1484
+
authorizeUrl.searchParams.set('scope', 'atproto');
1485
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
1486
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
1487
+
authorizeUrl.searchParams.set('state', state);
1488
+
authorizeUrl.searchParams.set('login_hint', DID);
1489
+
1490
+
const getRes = await fetch(authorizeUrl.toString());
1491
+
assert.strictEqual(
1492
+
getRes.status,
1493
+
200,
1494
+
'Direct authorize GET should succeed',
1495
+
);
1496
+
1497
+
const html = await getRes.text();
1498
+
assert.ok(html.includes('Authorize'), 'Should show consent page');
1499
+
assert.ok(
1500
+
html.includes('request_uri'),
1501
+
'Should include request_uri in form',
1502
+
);
1503
+
});
1504
+
1505
+
it('completes full direct authorization flow', async () => {
1506
+
const clientId = 'http://localhost:3000';
1507
+
const redirectUri = 'http://localhost:3000/callback';
1508
+
const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!';
1509
+
const challengeBuffer = await crypto.subtle.digest(
1510
+
'SHA-256',
1511
+
new TextEncoder().encode(codeVerifier),
1512
+
);
1513
+
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
1514
+
const state = 'test-direct-auth-state';
1515
+
1516
+
// Step 1: GET authorize with direct parameters
1517
+
const authorizeUrl = new URL(`${BASE}/oauth/authorize`);
1518
+
authorizeUrl.searchParams.set('client_id', clientId);
1519
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
1520
+
authorizeUrl.searchParams.set('response_type', 'code');
1521
+
authorizeUrl.searchParams.set('scope', 'atproto');
1522
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
1523
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
1524
+
authorizeUrl.searchParams.set('state', state);
1525
+
authorizeUrl.searchParams.set('login_hint', DID);
1526
+
1527
+
const getRes = await fetch(authorizeUrl.toString());
1528
+
assert.strictEqual(getRes.status, 200);
1529
+
const html = await getRes.text();
1530
+
1531
+
// Extract request_uri from the form
1532
+
const requestUriMatch = html.match(/name="request_uri" value="([^"]+)"/);
1533
+
assert.ok(requestUriMatch, 'Should have request_uri in form');
1534
+
const requestUri = requestUriMatch[1];
1535
+
1536
+
// Step 2: POST to authorize (user approval)
1537
+
const authRes = await fetch(`${BASE}/oauth/authorize`, {
1538
+
method: 'POST',
1539
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1540
+
body: new URLSearchParams({
1541
+
request_uri: requestUri,
1542
+
client_id: clientId,
1543
+
password: PASSWORD,
1544
+
}).toString(),
1545
+
redirect: 'manual',
1546
+
});
1547
+
1548
+
assert.strictEqual(authRes.status, 302, 'Should redirect after approval');
1549
+
const location = authRes.headers.get('location');
1550
+
assert.ok(location, 'Should have Location header');
1551
+
const locationUrl = new URL(location);
1552
+
const code = locationUrl.searchParams.get('code');
1553
+
assert.ok(code, 'Should have authorization code');
1554
+
assert.strictEqual(locationUrl.searchParams.get('state'), state);
1555
+
1556
+
// Step 3: Exchange code for tokens
1557
+
const dpop = await DpopClient.create();
1558
+
const dpopProof = await dpop.createProof('POST', `${BASE}/oauth/token`);
1559
+
1560
+
const tokenRes = await fetch(`${BASE}/oauth/token`, {
1561
+
method: 'POST',
1562
+
headers: {
1563
+
'Content-Type': 'application/x-www-form-urlencoded',
1564
+
DPoP: dpopProof,
1565
+
},
1566
+
body: new URLSearchParams({
1567
+
grant_type: 'authorization_code',
1568
+
code,
1569
+
redirect_uri: redirectUri,
1570
+
client_id: clientId,
1571
+
code_verifier: codeVerifier,
1572
+
}).toString(),
1573
+
});
1574
+
1575
+
assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed');
1576
+
const tokenData = await tokenRes.json();
1577
+
assert.ok(tokenData.access_token, 'Should have access_token');
1578
+
assert.strictEqual(tokenData.token_type, 'DPoP');
1579
+
});
1580
+
1581
+
it('consent page shows profile card when login_hint is provided', async () => {
1582
+
const clientId = 'http://localhost:3000';
1583
+
const redirectUri = 'http://localhost:3000/callback';
1584
+
const codeVerifier = 'test-verifier-for-profile-card-test-min-43-chars!!';
1585
+
const challengeBuffer = await crypto.subtle.digest(
1586
+
'SHA-256',
1587
+
new TextEncoder().encode(codeVerifier),
1588
+
);
1589
+
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
1590
+
1591
+
const authorizeUrl = new URL(`${BASE}/oauth/authorize`);
1592
+
authorizeUrl.searchParams.set('client_id', clientId);
1593
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
1594
+
authorizeUrl.searchParams.set('response_type', 'code');
1595
+
authorizeUrl.searchParams.set('scope', 'atproto');
1596
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
1597
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
1598
+
authorizeUrl.searchParams.set('state', 'test-state');
1599
+
authorizeUrl.searchParams.set('login_hint', 'test.handle.example');
1600
+
1601
+
const res = await fetch(authorizeUrl.toString());
1602
+
const html = await res.text();
1603
+
1604
+
assert.ok(
1605
+
html.includes('profile-card'),
1606
+
'Should include profile card element',
1607
+
);
1608
+
assert.ok(
1609
+
html.includes('@test.handle.example'),
1610
+
'Should show handle with @ prefix',
1611
+
);
1612
+
assert.ok(
1613
+
html.includes('app.bsky.actor.getProfile'),
1614
+
'Should include profile fetch script',
1615
+
);
1616
+
});
1617
+
1618
+
it('consent page does not show profile card when login_hint is omitted', async () => {
1619
+
const clientId = 'http://localhost:3000';
1620
+
const redirectUri = 'http://localhost:3000/callback';
1621
+
const codeVerifier = 'test-verifier-for-no-profile-test-min-43-chars!!';
1622
+
const challengeBuffer = await crypto.subtle.digest(
1623
+
'SHA-256',
1624
+
new TextEncoder().encode(codeVerifier),
1625
+
);
1626
+
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
1627
+
1628
+
const authorizeUrl = new URL(`${BASE}/oauth/authorize`);
1629
+
authorizeUrl.searchParams.set('client_id', clientId);
1630
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
1631
+
authorizeUrl.searchParams.set('response_type', 'code');
1632
+
authorizeUrl.searchParams.set('scope', 'atproto');
1633
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
1634
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
1635
+
authorizeUrl.searchParams.set('state', 'test-state');
1636
+
// No login_hint parameter
1637
+
1638
+
const res = await fetch(authorizeUrl.toString());
1639
+
const html = await res.text();
1640
+
1641
+
// Check for the actual element (id="profile-card"), not the CSS class selector
1642
+
assert.ok(
1643
+
!html.includes('id="profile-card"'),
1644
+
'Should NOT include profile card element',
1645
+
);
1646
+
assert.ok(
1647
+
!html.includes('app.bsky.actor.getProfile'),
1648
+
'Should NOT include profile fetch script',
1649
+
);
1650
+
});
1651
+
1652
+
it('consent page escapes dangerous characters in login_hint', async () => {
1653
+
const clientId = 'http://localhost:3000';
1654
+
const redirectUri = 'http://localhost:3000/callback';
1655
+
const codeVerifier = 'test-verifier-for-xss-test-minimum-43-chars!!!!!';
1656
+
const challengeBuffer = await crypto.subtle.digest(
1657
+
'SHA-256',
1658
+
new TextEncoder().encode(codeVerifier),
1659
+
);
1660
+
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
1661
+
1662
+
// Attempt XSS via login_hint with double quotes to break out of JSON.stringify
1663
+
const maliciousHint = 'user");alert("xss';
1664
+
1665
+
const authorizeUrl = new URL(`${BASE}/oauth/authorize`);
1666
+
authorizeUrl.searchParams.set('client_id', clientId);
1667
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
1668
+
authorizeUrl.searchParams.set('response_type', 'code');
1669
+
authorizeUrl.searchParams.set('scope', 'atproto');
1670
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
1671
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
1672
+
authorizeUrl.searchParams.set('state', 'test-state');
1673
+
authorizeUrl.searchParams.set('login_hint', maliciousHint);
1674
+
1675
+
const res = await fetch(authorizeUrl.toString());
1676
+
const html = await res.text();
1677
+
1678
+
// JSON.stringify escapes double quotes, so the payload should be escaped
1679
+
// The raw ");alert(" should NOT appear - it should be escaped as \");alert(\"
1680
+
assert.ok(
1681
+
!html.includes('");alert("'),
1682
+
'Should escape double quotes to prevent XSS breakout',
1683
+
);
1684
+
// Verify the escaped version is present (backslash before the quote)
1685
+
assert.ok(
1686
+
html.includes('\\"'),
1687
+
'Should contain escaped characters from JSON.stringify',
1688
+
);
1689
+
});
1690
+
});
1691
+
1692
+
describe('Foreign DID proxying', () => {
1693
+
it('proxies to AppView when atproto-proxy header present', async () => {
1694
+
// Use a known public DID (bsky.app official account)
1695
+
// We expect 200 (record exists) or 400 (record deleted/not found) from AppView
1696
+
// A 502 would indicate proxy failure, 404 would indicate local handling
1697
+
const res = await fetch(
1698
+
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`,
1699
+
{
1700
+
headers: {
1701
+
'atproto-proxy': 'did:web:api.bsky.app#bsky_appview',
1702
+
},
1703
+
},
1704
+
);
1705
+
// AppView returns 200 (found) or 400 (RecordNotFound), not 404 or 502
1706
+
assert.ok(
1707
+
res.status === 200 || res.status === 400,
1708
+
`Expected 200 or 400 from AppView, got ${res.status}`,
1709
+
);
1710
+
// Verify we got a JSON response (not an error page)
1711
+
const contentType = res.headers.get('content-type');
1712
+
assert.ok(
1713
+
contentType?.includes('application/json'),
1714
+
'Should return JSON',
1715
+
);
1716
+
});
1717
+
1718
+
it('handles foreign repo locally without header (returns not found)', async () => {
1719
+
// Foreign DID without atproto-proxy header is handled locally
1720
+
// This returns an error since the foreign DID doesn't exist on this PDS
1721
+
const res = await fetch(
1722
+
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`,
1723
+
);
1724
+
// Local PDS returns 404 for non-existent record/DID
1725
+
assert.strictEqual(res.status, 404);
1726
+
});
1727
+
1728
+
it('returns error for unknown proxy service', async () => {
1729
+
const res = await fetch(
1730
+
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`,
1731
+
{
1732
+
headers: {
1733
+
'atproto-proxy': 'did:web:unknown.service#unknown',
1734
+
},
1735
+
},
1736
+
);
1737
+
assert.strictEqual(res.status, 400);
1738
+
const data = await res.json();
1739
+
assert.ok(data.message.includes('Unknown proxy service'));
1740
+
});
1741
+
1742
+
it('returns error for malformed atproto-proxy header', async () => {
1743
+
// Header without fragment separator
1744
+
const res1 = await fetch(
1745
+
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`,
1746
+
{
1747
+
headers: {
1748
+
'atproto-proxy': 'did:web:api.bsky.app', // missing #serviceId
1749
+
},
1750
+
},
1751
+
);
1752
+
assert.strictEqual(res1.status, 400);
1753
+
const data1 = await res1.json();
1754
+
assert.ok(data1.message.includes('Malformed atproto-proxy header'));
1755
+
1756
+
// Header with only fragment
1757
+
const res2 = await fetch(
1758
+
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`,
1759
+
{
1760
+
headers: {
1761
+
'atproto-proxy': '#bsky_appview', // missing DID
1762
+
},
1763
+
},
1764
+
);
1765
+
assert.strictEqual(res2.status, 400);
1766
+
const data2 = await res2.json();
1767
+
assert.ok(data2.message.includes('Malformed atproto-proxy header'));
1768
+
});
1769
+
1770
+
it('returns local record for local DID without proxy header', async () => {
1771
+
// Create a record first
1772
+
const { data: created } = await jsonPost(
1773
+
'/xrpc/com.atproto.repo.createRecord',
1774
+
{
1775
+
repo: DID,
1776
+
collection: 'app.bsky.feed.post',
1777
+
record: {
1778
+
$type: 'app.bsky.feed.post',
1779
+
text: 'Test post for local DID test',
1780
+
createdAt: new Date().toISOString(),
1781
+
},
1782
+
},
1783
+
{ Authorization: `Bearer ${token}` },
1784
+
);
1785
+
1786
+
// Fetch without proxy header - should get local record
1787
+
const rkey = created.uri.split('/').pop();
1788
+
const res = await fetch(
1789
+
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`,
1790
+
);
1791
+
assert.strictEqual(res.status, 200);
1792
+
const data = await res.json();
1793
+
assert.ok(data.value.text.includes('Test post for local DID test'));
1794
+
1795
+
// Cleanup - verify success to ensure test isolation
1796
+
const { status: cleanupStatus } = await jsonPost(
1797
+
'/xrpc/com.atproto.repo.deleteRecord',
1798
+
{ repo: DID, collection: 'app.bsky.feed.post', rkey },
1799
+
{ Authorization: `Bearer ${token}` },
1800
+
);
1801
+
assert.strictEqual(cleanupStatus, 200, 'Cleanup should succeed');
1802
+
});
1803
+
1804
+
it('describeRepo handles foreign DID locally', async () => {
1805
+
// Without proxy header, foreign DID is handled locally (returns error)
1806
+
const res = await fetch(
1807
+
`${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`,
1808
+
);
1809
+
// Local PDS returns 404 for non-existent DID
1810
+
assert.strictEqual(res.status, 404);
1811
+
});
1812
+
1813
+
it('listRecords handles foreign DID locally', async () => {
1814
+
// Without proxy header, foreign DID is handled locally
1815
+
// listRecords returns 200 with empty records for non-existent collection
1816
+
const res = await fetch(
1817
+
`${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`,
1818
+
);
1819
+
// Local PDS returns 200 with empty records (or 404 for completely unknown DID)
1820
+
assert.ok(
1821
+
res.status === 200 || res.status === 404,
1822
+
`Expected 200 or 404, got ${res.status}`,
1823
+
);
1025
1824
});
1026
1825
});
1027
1826
+157
test/helpers/oauth.js
+157
test/helpers/oauth.js
···
1
+
/**
2
+
* OAuth flow helpers for e2e tests
3
+
*/
4
+
5
+
import { randomBytes } from 'node:crypto';
6
+
import { DpopClient } from './dpop.js';
7
+
8
+
const BASE = 'http://localhost:8787';
9
+
10
+
/**
11
+
* Fetch with retry for flaky wrangler dev
12
+
* @param {string} url
13
+
* @param {RequestInit} options
14
+
* @param {number} maxAttempts
15
+
* @returns {Promise<Response>}
16
+
*/
17
+
async function fetchWithRetry(url, options, maxAttempts = 3) {
18
+
let lastError;
19
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
20
+
try {
21
+
const res = await fetch(url, options);
22
+
// Check if we got an HTML error page instead of expected response
23
+
const contentType = res.headers.get('content-type') || '';
24
+
if (!res.ok && contentType.includes('text/html')) {
25
+
// Wrangler dev error page - retry
26
+
if (attempt < maxAttempts - 1) {
27
+
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
28
+
continue;
29
+
}
30
+
}
31
+
return res;
32
+
} catch (err) {
33
+
lastError = err;
34
+
if (attempt < maxAttempts - 1) {
35
+
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
36
+
}
37
+
}
38
+
}
39
+
throw lastError || new Error('Fetch failed after retries');
40
+
}
41
+
42
+
/**
43
+
* Get an OAuth token with a specific scope via full PAR -> authorize -> token flow
44
+
* @param {string} scope - The scope to request
45
+
* @param {string} did - The DID to authenticate as
46
+
* @param {string} password - The password for authentication
47
+
* @returns {Promise<{accessToken: string, refreshToken: string, dpop: DpopClient}>}
48
+
*/
49
+
export async function getOAuthTokenWithScope(scope, did, password) {
50
+
const dpop = await DpopClient.create();
51
+
const clientId = 'http://localhost:3000';
52
+
const redirectUri = 'http://localhost:3000/callback';
53
+
const codeVerifier = randomBytes(32).toString('base64url');
54
+
const challengeBuffer = await crypto.subtle.digest(
55
+
'SHA-256',
56
+
new TextEncoder().encode(codeVerifier),
57
+
);
58
+
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
59
+
60
+
// PAR request (with retry for flaky wrangler dev)
61
+
let parData;
62
+
for (let attempt = 0; attempt < 3; attempt++) {
63
+
// Generate fresh DPoP proof for each attempt
64
+
const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`);
65
+
const parRes = await fetchWithRetry(`${BASE}/oauth/par`, {
66
+
method: 'POST',
67
+
headers: {
68
+
'Content-Type': 'application/x-www-form-urlencoded',
69
+
DPoP: parProof,
70
+
},
71
+
body: new URLSearchParams({
72
+
client_id: clientId,
73
+
redirect_uri: redirectUri,
74
+
response_type: 'code',
75
+
scope: scope,
76
+
code_challenge: codeChallenge,
77
+
code_challenge_method: 'S256',
78
+
login_hint: did,
79
+
}).toString(),
80
+
});
81
+
if (parRes.ok) {
82
+
parData = await parRes.json();
83
+
break;
84
+
}
85
+
if (attempt < 2) {
86
+
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
87
+
} else {
88
+
const text = await parRes.text();
89
+
throw new Error(
90
+
`PAR request failed: ${parRes.status} - ${text.slice(0, 100)}`,
91
+
);
92
+
}
93
+
}
94
+
95
+
// Authorize (with retry)
96
+
let authCode;
97
+
for (let attempt = 0; attempt < 3; attempt++) {
98
+
const authRes = await fetchWithRetry(`${BASE}/oauth/authorize`, {
99
+
method: 'POST',
100
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
101
+
body: new URLSearchParams({
102
+
request_uri: parData.request_uri,
103
+
client_id: clientId,
104
+
password: password,
105
+
}).toString(),
106
+
redirect: 'manual',
107
+
});
108
+
const location = authRes.headers.get('location');
109
+
if (location) {
110
+
authCode = new URL(location).searchParams.get('code');
111
+
if (authCode) break;
112
+
}
113
+
if (attempt < 2) {
114
+
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
115
+
} else {
116
+
throw new Error('Authorize request failed to return code');
117
+
}
118
+
}
119
+
120
+
// Token exchange (with retry and fresh DPoP proof)
121
+
let tokenData;
122
+
for (let attempt = 0; attempt < 3; attempt++) {
123
+
const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`);
124
+
const tokenRes = await fetchWithRetry(`${BASE}/oauth/token`, {
125
+
method: 'POST',
126
+
headers: {
127
+
'Content-Type': 'application/x-www-form-urlencoded',
128
+
DPoP: tokenProof,
129
+
},
130
+
body: new URLSearchParams({
131
+
grant_type: 'authorization_code',
132
+
code: authCode,
133
+
client_id: clientId,
134
+
redirect_uri: redirectUri,
135
+
code_verifier: codeVerifier,
136
+
}).toString(),
137
+
});
138
+
if (tokenRes.ok) {
139
+
tokenData = await tokenRes.json();
140
+
break;
141
+
}
142
+
if (attempt < 2) {
143
+
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
144
+
} else {
145
+
const text = await tokenRes.text();
146
+
throw new Error(
147
+
`Token request failed: ${tokenRes.status} - ${text.slice(0, 100)}`,
148
+
);
149
+
}
150
+
}
151
+
152
+
return {
153
+
accessToken: tokenData.access_token,
154
+
refreshToken: tokenData.refresh_token,
155
+
dpop,
156
+
};
157
+
}
+420
-1
test/pds.test.js
+420
-1
test/pds.test.js
···
12
12
cidToString,
13
13
computeJwkThumbprint,
14
14
createAccessJwt,
15
-
createCid,
16
15
createBlobCid,
16
+
createCid,
17
17
createRefreshJwt,
18
18
createTid,
19
19
findBlobRefs,
20
20
generateKeyPair,
21
21
getKeyDepth,
22
+
getKnownServiceUrl,
22
23
getLoopbackClientMetadata,
23
24
hexToBytes,
24
25
importPrivateKey,
25
26
isLoopbackClient,
27
+
matchesMime,
28
+
parseAtprotoProxyHeader,
29
+
parseBlobScope,
30
+
parseRepoScope,
31
+
parseScopesForDisplay,
32
+
ScopePermissions,
26
33
sign,
27
34
sniffMimeType,
28
35
validateClientMetadata,
···
30
37
verifyAccessJwt,
31
38
verifyRefreshJwt,
32
39
} from '../src/pds.js';
40
+
41
+
// Internal constant - not exported from pds.js due to Cloudflare Workers limitation
42
+
const BSKY_APPVIEW_URL = 'https://api.bsky.app';
33
43
34
44
describe('CBOR Encoding', () => {
35
45
test('encodes simple map', () => {
···
827
837
);
828
838
});
829
839
});
840
+
841
+
describe('Proxy Utilities', () => {
842
+
describe('parseAtprotoProxyHeader', () => {
843
+
test('parses valid header', () => {
844
+
const result = parseAtprotoProxyHeader(
845
+
'did:web:api.bsky.app#bsky_appview',
846
+
);
847
+
assert.deepStrictEqual(result, {
848
+
did: 'did:web:api.bsky.app',
849
+
serviceId: 'bsky_appview',
850
+
});
851
+
});
852
+
853
+
test('parses header with did:plc', () => {
854
+
const result = parseAtprotoProxyHeader(
855
+
'did:plc:z72i7hdynmk6r22z27h6tvur#atproto_labeler',
856
+
);
857
+
assert.deepStrictEqual(result, {
858
+
did: 'did:plc:z72i7hdynmk6r22z27h6tvur',
859
+
serviceId: 'atproto_labeler',
860
+
});
861
+
});
862
+
863
+
test('returns null for null/undefined', () => {
864
+
assert.strictEqual(parseAtprotoProxyHeader(null), null);
865
+
assert.strictEqual(parseAtprotoProxyHeader(undefined), null);
866
+
assert.strictEqual(parseAtprotoProxyHeader(''), null);
867
+
});
868
+
869
+
test('returns null for header without fragment', () => {
870
+
assert.strictEqual(parseAtprotoProxyHeader('did:web:api.bsky.app'), null);
871
+
});
872
+
873
+
test('returns null for header with only fragment', () => {
874
+
assert.strictEqual(parseAtprotoProxyHeader('#bsky_appview'), null);
875
+
});
876
+
877
+
test('returns null for header with trailing fragment', () => {
878
+
assert.strictEqual(
879
+
parseAtprotoProxyHeader('did:web:api.bsky.app#'),
880
+
null,
881
+
);
882
+
});
883
+
});
884
+
885
+
describe('getKnownServiceUrl', () => {
886
+
test('returns URL for known Bluesky AppView', () => {
887
+
const result = getKnownServiceUrl('did:web:api.bsky.app', 'bsky_appview');
888
+
assert.strictEqual(result, BSKY_APPVIEW_URL);
889
+
});
890
+
891
+
test('returns null for unknown service DID', () => {
892
+
const result = getKnownServiceUrl(
893
+
'did:web:unknown.service',
894
+
'bsky_appview',
895
+
);
896
+
assert.strictEqual(result, null);
897
+
});
898
+
899
+
test('returns null for unknown service ID', () => {
900
+
const result = getKnownServiceUrl(
901
+
'did:web:api.bsky.app',
902
+
'unknown_service',
903
+
);
904
+
assert.strictEqual(result, null);
905
+
});
906
+
907
+
test('returns null for both unknown', () => {
908
+
const result = getKnownServiceUrl('did:web:unknown', 'unknown');
909
+
assert.strictEqual(result, null);
910
+
});
911
+
});
912
+
});
913
+
914
+
describe('Scope Parsing', () => {
915
+
describe('parseRepoScope', () => {
916
+
test('parses repo scope with query parameter action', () => {
917
+
const result = parseRepoScope('repo:app.bsky.feed.post?action=create');
918
+
assert.deepStrictEqual(result, {
919
+
collection: 'app.bsky.feed.post',
920
+
actions: ['create'],
921
+
});
922
+
});
923
+
924
+
test('parses repo scope with multiple query parameter actions', () => {
925
+
const result = parseRepoScope(
926
+
'repo:app.bsky.feed.post?action=create&action=update',
927
+
);
928
+
assert.deepStrictEqual(result, {
929
+
collection: 'app.bsky.feed.post',
930
+
actions: ['create', 'update'],
931
+
});
932
+
});
933
+
934
+
test('parses repo scope without actions as all actions', () => {
935
+
const result = parseRepoScope('repo:app.bsky.feed.post');
936
+
assert.deepStrictEqual(result, {
937
+
collection: 'app.bsky.feed.post',
938
+
actions: ['create', 'update', 'delete'],
939
+
});
940
+
});
941
+
942
+
test('parses wildcard collection with action', () => {
943
+
const result = parseRepoScope('repo:*?action=create');
944
+
assert.deepStrictEqual(result, {
945
+
collection: '*',
946
+
actions: ['create'],
947
+
});
948
+
});
949
+
950
+
test('parses query-only format', () => {
951
+
const result = parseRepoScope(
952
+
'repo?collection=app.bsky.feed.post&action=create',
953
+
);
954
+
assert.deepStrictEqual(result, {
955
+
collection: 'app.bsky.feed.post',
956
+
actions: ['create'],
957
+
});
958
+
});
959
+
960
+
test('deduplicates repeated actions', () => {
961
+
const result = parseRepoScope(
962
+
'repo:app.bsky.feed.post?action=create&action=create&action=update',
963
+
);
964
+
assert.deepStrictEqual(result, {
965
+
collection: 'app.bsky.feed.post',
966
+
actions: ['create', 'update'],
967
+
});
968
+
});
969
+
970
+
test('returns null for non-repo scope', () => {
971
+
assert.strictEqual(parseRepoScope('atproto'), null);
972
+
assert.strictEqual(parseRepoScope('blob:image/*'), null);
973
+
assert.strictEqual(parseRepoScope('transition:generic'), null);
974
+
});
975
+
976
+
test('returns null for invalid repo scope', () => {
977
+
assert.strictEqual(parseRepoScope('repo:'), null);
978
+
assert.strictEqual(parseRepoScope('repo?'), null);
979
+
});
980
+
});
981
+
982
+
describe('parseBlobScope', () => {
983
+
test('parses wildcard MIME', () => {
984
+
const result = parseBlobScope('blob:*/*');
985
+
assert.deepStrictEqual(result, { accept: ['*/*'] });
986
+
});
987
+
988
+
test('parses type wildcard', () => {
989
+
const result = parseBlobScope('blob:image/*');
990
+
assert.deepStrictEqual(result, { accept: ['image/*'] });
991
+
});
992
+
993
+
test('parses specific MIME', () => {
994
+
const result = parseBlobScope('blob:image/png');
995
+
assert.deepStrictEqual(result, { accept: ['image/png'] });
996
+
});
997
+
998
+
test('parses multiple MIMEs', () => {
999
+
const result = parseBlobScope('blob:image/png,image/jpeg');
1000
+
assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] });
1001
+
});
1002
+
1003
+
test('returns null for non-blob scope', () => {
1004
+
assert.strictEqual(parseBlobScope('atproto'), null);
1005
+
assert.strictEqual(parseBlobScope('repo:*:create'), null);
1006
+
});
1007
+
});
1008
+
1009
+
describe('matchesMime', () => {
1010
+
test('wildcard matches everything', () => {
1011
+
assert.strictEqual(matchesMime('*/*', 'image/png'), true);
1012
+
assert.strictEqual(matchesMime('*/*', 'video/mp4'), true);
1013
+
});
1014
+
1015
+
test('type wildcard matches same type', () => {
1016
+
assert.strictEqual(matchesMime('image/*', 'image/png'), true);
1017
+
assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true);
1018
+
assert.strictEqual(matchesMime('image/*', 'video/mp4'), false);
1019
+
});
1020
+
1021
+
test('exact match', () => {
1022
+
assert.strictEqual(matchesMime('image/png', 'image/png'), true);
1023
+
assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false);
1024
+
});
1025
+
1026
+
test('case insensitive', () => {
1027
+
assert.strictEqual(matchesMime('image/PNG', 'image/png'), true);
1028
+
assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true);
1029
+
});
1030
+
});
1031
+
});
1032
+
1033
+
describe('ScopePermissions', () => {
1034
+
describe('static scopes', () => {
1035
+
test('atproto grants full access', () => {
1036
+
const perms = new ScopePermissions('atproto');
1037
+
assert.strictEqual(
1038
+
perms.allowsRepo('app.bsky.feed.post', 'create'),
1039
+
true,
1040
+
);
1041
+
assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true);
1042
+
assert.strictEqual(perms.allowsBlob('image/png'), true);
1043
+
assert.strictEqual(perms.allowsBlob('video/mp4'), true);
1044
+
});
1045
+
1046
+
test('transition:generic grants full repo/blob access', () => {
1047
+
const perms = new ScopePermissions('transition:generic');
1048
+
assert.strictEqual(
1049
+
perms.allowsRepo('app.bsky.feed.post', 'create'),
1050
+
true,
1051
+
);
1052
+
assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true);
1053
+
assert.strictEqual(perms.allowsBlob('image/png'), true);
1054
+
});
1055
+
});
1056
+
1057
+
describe('repo scopes', () => {
1058
+
test('wildcard collection allows any collection', () => {
1059
+
const perms = new ScopePermissions('repo:*?action=create');
1060
+
assert.strictEqual(
1061
+
perms.allowsRepo('app.bsky.feed.post', 'create'),
1062
+
true,
1063
+
);
1064
+
assert.strictEqual(
1065
+
perms.allowsRepo('app.bsky.feed.like', 'create'),
1066
+
true,
1067
+
);
1068
+
assert.strictEqual(
1069
+
perms.allowsRepo('app.bsky.feed.post', 'delete'),
1070
+
false,
1071
+
);
1072
+
});
1073
+
1074
+
test('specific collection restricts to that collection', () => {
1075
+
const perms = new ScopePermissions(
1076
+
'repo:app.bsky.feed.post?action=create',
1077
+
);
1078
+
assert.strictEqual(
1079
+
perms.allowsRepo('app.bsky.feed.post', 'create'),
1080
+
true,
1081
+
);
1082
+
assert.strictEqual(
1083
+
perms.allowsRepo('app.bsky.feed.like', 'create'),
1084
+
false,
1085
+
);
1086
+
});
1087
+
1088
+
test('multiple actions', () => {
1089
+
const perms = new ScopePermissions('repo:*?action=create&action=update');
1090
+
assert.strictEqual(perms.allowsRepo('x', 'create'), true);
1091
+
assert.strictEqual(perms.allowsRepo('x', 'update'), true);
1092
+
assert.strictEqual(perms.allowsRepo('x', 'delete'), false);
1093
+
});
1094
+
1095
+
test('multiple scopes combine', () => {
1096
+
const perms = new ScopePermissions(
1097
+
'repo:app.bsky.feed.post?action=create repo:app.bsky.feed.like?action=delete',
1098
+
);
1099
+
assert.strictEqual(
1100
+
perms.allowsRepo('app.bsky.feed.post', 'create'),
1101
+
true,
1102
+
);
1103
+
assert.strictEqual(
1104
+
perms.allowsRepo('app.bsky.feed.like', 'delete'),
1105
+
true,
1106
+
);
1107
+
assert.strictEqual(
1108
+
perms.allowsRepo('app.bsky.feed.post', 'delete'),
1109
+
false,
1110
+
);
1111
+
});
1112
+
1113
+
test('allowsRepo with query param format scopes', () => {
1114
+
const perms = new ScopePermissions(
1115
+
'atproto repo:app.bsky.feed.post?action=create',
1116
+
);
1117
+
assert.strictEqual(
1118
+
perms.allowsRepo('app.bsky.feed.post', 'create'),
1119
+
true,
1120
+
);
1121
+
assert.strictEqual(
1122
+
perms.allowsRepo('app.bsky.feed.post', 'delete'),
1123
+
true,
1124
+
); // atproto grants full access
1125
+
});
1126
+
});
1127
+
1128
+
describe('blob scopes', () => {
1129
+
test('wildcard allows any MIME', () => {
1130
+
const perms = new ScopePermissions('blob:*/*');
1131
+
assert.strictEqual(perms.allowsBlob('image/png'), true);
1132
+
assert.strictEqual(perms.allowsBlob('video/mp4'), true);
1133
+
});
1134
+
1135
+
test('type wildcard restricts to type', () => {
1136
+
const perms = new ScopePermissions('blob:image/*');
1137
+
assert.strictEqual(perms.allowsBlob('image/png'), true);
1138
+
assert.strictEqual(perms.allowsBlob('image/jpeg'), true);
1139
+
assert.strictEqual(perms.allowsBlob('video/mp4'), false);
1140
+
});
1141
+
1142
+
test('specific MIME restricts exactly', () => {
1143
+
const perms = new ScopePermissions('blob:image/png');
1144
+
assert.strictEqual(perms.allowsBlob('image/png'), true);
1145
+
assert.strictEqual(perms.allowsBlob('image/jpeg'), false);
1146
+
});
1147
+
});
1148
+
1149
+
describe('empty/no scope', () => {
1150
+
test('no scope denies everything', () => {
1151
+
const perms = new ScopePermissions('');
1152
+
assert.strictEqual(perms.allowsRepo('x', 'create'), false);
1153
+
assert.strictEqual(perms.allowsBlob('image/png'), false);
1154
+
});
1155
+
1156
+
test('undefined scope denies everything', () => {
1157
+
const perms = new ScopePermissions(undefined);
1158
+
assert.strictEqual(perms.allowsRepo('x', 'create'), false);
1159
+
});
1160
+
});
1161
+
1162
+
describe('assertRepo', () => {
1163
+
test('throws ScopeMissingError when denied', () => {
1164
+
const perms = new ScopePermissions(
1165
+
'repo:app.bsky.feed.post?action=create',
1166
+
);
1167
+
assert.throws(() => perms.assertRepo('app.bsky.feed.like', 'create'), {
1168
+
message: /Missing required scope/,
1169
+
});
1170
+
});
1171
+
1172
+
test('does not throw when allowed', () => {
1173
+
const perms = new ScopePermissions(
1174
+
'repo:app.bsky.feed.post?action=create',
1175
+
);
1176
+
assert.doesNotThrow(() =>
1177
+
perms.assertRepo('app.bsky.feed.post', 'create'),
1178
+
);
1179
+
});
1180
+
});
1181
+
1182
+
describe('assertBlob', () => {
1183
+
test('throws ScopeMissingError when denied', () => {
1184
+
const perms = new ScopePermissions('blob:image/*');
1185
+
assert.throws(() => perms.assertBlob('video/mp4'), {
1186
+
message: /Missing required scope/,
1187
+
});
1188
+
});
1189
+
1190
+
test('does not throw when allowed', () => {
1191
+
const perms = new ScopePermissions('blob:image/*');
1192
+
assert.doesNotThrow(() => perms.assertBlob('image/png'));
1193
+
});
1194
+
});
1195
+
});
1196
+
1197
+
describe('parseScopesForDisplay', () => {
1198
+
test('parses identity-only scope', () => {
1199
+
const result = parseScopesForDisplay('atproto');
1200
+
assert.strictEqual(result.hasAtproto, true);
1201
+
assert.strictEqual(result.hasTransitionGeneric, false);
1202
+
assert.strictEqual(result.repoPermissions.size, 0);
1203
+
assert.deepStrictEqual(result.blobPermissions, []);
1204
+
});
1205
+
1206
+
test('parses granular repo scopes', () => {
1207
+
const result = parseScopesForDisplay(
1208
+
'atproto repo:app.bsky.feed.post?action=create&action=update',
1209
+
);
1210
+
assert.strictEqual(result.repoPermissions.size, 1);
1211
+
const postPerms = result.repoPermissions.get('app.bsky.feed.post');
1212
+
assert.deepStrictEqual(postPerms, {
1213
+
create: true,
1214
+
update: true,
1215
+
delete: false,
1216
+
});
1217
+
});
1218
+
1219
+
test('merges multiple scopes for same collection', () => {
1220
+
const result = parseScopesForDisplay(
1221
+
'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete',
1222
+
);
1223
+
const postPerms = result.repoPermissions.get('app.bsky.feed.post');
1224
+
assert.deepStrictEqual(postPerms, {
1225
+
create: true,
1226
+
update: false,
1227
+
delete: true,
1228
+
});
1229
+
});
1230
+
1231
+
test('parses blob scopes', () => {
1232
+
const result = parseScopesForDisplay('atproto blob:image/*');
1233
+
assert.deepStrictEqual(result.blobPermissions, ['image/*']);
1234
+
});
1235
+
1236
+
test('detects transition:generic', () => {
1237
+
const result = parseScopesForDisplay('atproto transition:generic');
1238
+
assert.strictEqual(result.hasTransitionGeneric, true);
1239
+
});
1240
+
1241
+
test('handles empty scope string', () => {
1242
+
const result = parseScopesForDisplay('');
1243
+
assert.strictEqual(result.hasAtproto, false);
1244
+
assert.strictEqual(result.hasTransitionGeneric, false);
1245
+
assert.strictEqual(result.repoPermissions.size, 0);
1246
+
assert.deepStrictEqual(result.blobPermissions, []);
1247
+
});
1248
+
});