+43
CHANGELOG.md
+43
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
+
9
52
### Added
10
53
11
54
- **Granular OAuth scope enforcement** on repo and blob endpoints
+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:
+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
+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}`);
+370
-131
src/pds.js
+370
-131
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
/**
···
693
795
* @param {*} value
694
796
* @returns {Uint8Array}
695
797
*/
696
-
function cborEncodeDagCbor(value) {
798
+
export function cborEncodeDagCbor(value) {
697
799
/** @type {number[]} */
698
800
const parts = [];
699
801
···
2721
2823
*/
2722
2824
async handleAppViewProxy(request, userDid) {
2723
2825
const url = new URL(request.url);
2724
-
// Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences
2725
2826
const lxm = url.pathname.replace('/xrpc/', '');
2726
-
2727
-
// Create service auth JWT
2728
2827
const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm);
2729
-
2730
-
// Build AppView URL
2731
-
const appViewUrl = new URL(
2732
-
url.pathname + url.search,
2733
-
'https://api.bsky.app',
2734
-
);
2735
-
2736
-
// Forward request with service auth
2737
-
const headers = new Headers();
2738
-
headers.set('Authorization', `Bearer ${serviceJwt}`);
2739
-
headers.set(
2740
-
'Content-Type',
2741
-
request.headers.get('Content-Type') || 'application/json',
2742
-
);
2743
-
const acceptHeader = request.headers.get('Accept');
2744
-
if (acceptHeader) {
2745
-
headers.set('Accept', acceptHeader);
2746
-
}
2747
-
const acceptLangHeader = request.headers.get('Accept-Language');
2748
-
if (acceptLangHeader) {
2749
-
headers.set('Accept-Language', acceptLangHeader);
2750
-
}
2751
-
2752
-
const proxyReq = new Request(appViewUrl.toString(), {
2753
-
method: request.method,
2754
-
headers,
2755
-
body:
2756
-
request.method !== 'GET' && request.method !== 'HEAD'
2757
-
? request.body
2758
-
: undefined,
2759
-
});
2760
-
2761
-
try {
2762
-
const response = await fetch(proxyReq);
2763
-
// Return the response with CORS headers
2764
-
const responseHeaders = new Headers(response.headers);
2765
-
responseHeaders.set('Access-Control-Allow-Origin', '*');
2766
-
return new Response(response.body, {
2767
-
status: response.status,
2768
-
statusText: response.statusText,
2769
-
headers: responseHeaders,
2770
-
});
2771
-
} catch (err) {
2772
-
const message = err instanceof Error ? err.message : String(err);
2773
-
return errorResponse(
2774
-
'UpstreamFailure',
2775
-
`Failed to reach AppView: ${message}`,
2776
-
502,
2777
-
);
2778
-
}
2828
+
return proxyToService(request, BSKY_APPVIEW_URL, `Bearer ${serviceJwt}`);
2779
2829
}
2780
2830
2781
2831
async handleListRepos() {
···
3642
3692
code_challenge_methods_supported: ['S256'],
3643
3693
token_endpoint_auth_methods_supported: ['none'],
3644
3694
dpop_signing_alg_values_supported: ['ES256'],
3645
-
require_pushed_authorization_requests: true,
3695
+
require_pushed_authorization_requests: false,
3646
3696
authorization_response_iss_parameter_supported: true,
3647
3697
client_id_metadata_document_supported: true,
3648
3698
protected_resources: [issuer],
···
3678
3728
}
3679
3729
3680
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
+
/**
3681
3810
* Handle Pushed Authorization Request (PAR) endpoint.
3682
3811
* Validates DPoP proof, client metadata, PKCE parameters, and stores the authorization request.
3683
3812
* @param {Request} request - The incoming request
···
3717
3846
const codeChallengeMethod = data.code_challenge_method;
3718
3847
const loginHint = data.login_hint;
3719
3848
3720
-
if (!clientId)
3721
-
return errorResponse('invalid_request', 'client_id required', 400);
3722
-
if (!redirectUri)
3723
-
return errorResponse('invalid_request', 'redirect_uri required', 400);
3724
-
if (responseType !== 'code')
3725
-
return errorResponse(
3726
-
'unsupported_response_type',
3727
-
'response_type must be code',
3728
-
400,
3729
-
);
3730
-
if (!codeChallenge || codeChallengeMethod !== 'S256') {
3731
-
return errorResponse('invalid_request', 'PKCE with S256 required', 400);
3732
-
}
3733
-
3734
-
let clientMetadata;
3735
-
try {
3736
-
clientMetadata = await getClientMetadata(clientId);
3737
-
} catch (err) {
3738
-
return errorResponse('invalid_client', err.message, 400);
3739
-
}
3740
-
3741
-
// Validate redirect_uri against registered URIs
3742
-
// For loopback clients (RFC 8252), allow any path on the same origin
3743
-
const isLoopback =
3744
-
clientId.startsWith('http://localhost') ||
3745
-
clientId.startsWith('http://127.0.0.1');
3746
-
const redirectUriValid = clientMetadata.redirect_uris.some((uri) => {
3747
-
if (isLoopback) {
3748
-
// For loopback, check origin match (any path allowed)
3749
-
try {
3750
-
const registered = new URL(uri);
3751
-
const requested = new URL(redirectUri);
3752
-
return registered.origin === requested.origin;
3753
-
} catch {
3754
-
return false;
3755
-
}
3756
-
}
3757
-
return uri === redirectUri;
3849
+
// Use shared validation
3850
+
const validationResult = await this.validateAuthorizationParameters({
3851
+
clientId,
3852
+
redirectUri,
3853
+
responseType,
3854
+
codeChallenge,
3855
+
codeChallengeMethod,
3758
3856
});
3759
-
if (!redirectUriValid) {
3760
-
return errorResponse(
3761
-
'invalid_request',
3762
-
'redirect_uri not registered for this client',
3763
-
400,
3764
-
);
3765
-
}
3857
+
if ('error' in validationResult) return validationResult.error;
3858
+
const { clientMetadata } = validationResult;
3766
3859
3767
3860
const requestId = crypto.randomUUID();
3768
3861
const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
···
3812
3905
3813
3906
/**
3814
3907
* Handle GET /oauth/authorize - displays the consent UI.
3815
-
* Validates the request_uri from PAR and renders a login/consent form.
3908
+
* Supports both PAR (request_uri) and direct authorization parameters.
3816
3909
* @param {URL} url - Parsed request URL
3817
3910
* @returns {Promise<Response>} HTML consent page
3818
3911
*/
3819
3912
async handleOAuthAuthorizeGet(url) {
3913
+
// Opportunistically clean up expired authorization requests
3914
+
this.cleanupExpiredAuthorizationRequests();
3915
+
3820
3916
const requestUri = url.searchParams.get('request_uri');
3821
3917
const clientId = url.searchParams.get('client_id');
3822
3918
3823
-
if (!requestUri || !clientId) {
3824
-
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
+
);
3825
3966
}
3826
3967
3827
-
const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/);
3828
-
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
+
}
3829
3972
3830
-
const rows = this.sql
3831
-
.exec(
3832
-
`SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`,
3833
-
match[1],
3834
-
clientId,
3835
-
)
3836
-
.toArray();
3837
-
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');
3838
3981
3839
-
if (!authRequest) return new Response('Request not found', { status: 400 });
3840
-
if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date())
3841
-
return new Response('Request expired', { status: 400 });
3842
-
if (authRequest.code)
3843
-
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;
3844
3992
3845
-
const clientMetadata = JSON.parse(
3846
-
/** @type {string} */ (authRequest.client_metadata),
3847
-
);
3848
-
const parameters = JSON.parse(
3849
-
/** @type {string} */ (authRequest.parameters),
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();
3998
+
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(),
3850
4020
);
3851
4021
3852
4022
return new Response(
3853
4023
renderConsentPage({
3854
4024
clientName: clientMetadata.client_name || clientId,
3855
-
clientId: clientId || '',
3856
-
scope: parameters.scope || 'atproto',
3857
-
requestUri: requestUri || '',
4025
+
clientId: clientId,
4026
+
scope: scope || 'atproto',
4027
+
requestUri: newRequestUri,
4028
+
loginHint: loginHint || '',
3858
4029
}),
3859
4030
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
3860
4031
);
···
4044
4215
return errorResponse('invalid_grant', 'Invalid code', 400);
4045
4216
if (authRequest.client_id !== clientId)
4046
4217
return errorResponse('invalid_grant', 'Client mismatch', 400);
4047
-
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) {
4048
4221
return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400);
4222
+
}
4049
4223
4050
4224
const parameters = JSON.parse(
4051
4225
/** @type {string} */ (authRequest.parameters),
···
4833
5007
4834
5008
/**
4835
5009
* Render the OAuth consent page HTML.
4836
-
* @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params
5010
+
* @param {{ clientName: string, clientId: string, scope: string, requestUri: string, loginHint?: string, error?: string }} params
4837
5011
* @returns {string} HTML page content
4838
5012
*/
4839
5013
function renderConsentPage({
···
4841
5015
clientId,
4842
5016
scope,
4843
5017
requestUri,
5018
+
loginHint = '',
4844
5019
error = '',
4845
5020
}) {
4846
5021
const parsed = parseScopesForDisplay(scope);
···
4880
5055
.blob-list li{margin:4px 0}
4881
5056
.warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0}
4882
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}}
4883
5066
</style></head>
4884
-
<body><h2>Sign in to authorize</h2>
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>
4885
5078
<p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p>
4886
5079
${renderPermissionsHtml(parsed)}
4887
5080
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
···
4891
5084
<label>Password</label><input type="password" name="password" required autofocus>
4892
5085
<div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button>
4893
5086
<button type="submit" name="action" value="approve" class="approve">Authorize</button></div>
4894
-
</form></body></html>`;
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>`;
4895
5108
}
4896
5109
4897
5110
/**
···
5193
5406
if (!repo) {
5194
5407
return errorResponse('InvalidRequest', 'missing repo param', 400);
5195
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)
5196
5435
const id = env.PDS.idFromName(repo);
5197
5436
const pds = env.PDS.get(id);
5198
5437
return pds.fetch(request);
···
5234
5473
5235
5474
// Health check endpoint
5236
5475
if (url.pathname === '/xrpc/_health') {
5237
-
return Response.json({ version: '0.1.0' });
5476
+
return Response.json({ version: VERSION });
5238
5477
}
5239
5478
5240
5479
// Root path - ASCII art
+396
-24
test/e2e.test.js
+396
-24
test/e2e.test.js
···
40
40
}
41
41
42
42
/**
43
-
* Make JSON request helper
43
+
* Make JSON request helper (with retry for flaky wrangler dev 5xx errors)
44
44
*/
45
45
async function jsonPost(path, body, headers = {}) {
46
-
const res = await fetch(`${BASE}${path}`, {
47
-
method: 'POST',
48
-
headers: { 'Content-Type': 'application/json', ...headers },
49
-
body: JSON.stringify(body),
50
-
});
51
-
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
+
}
52
59
}
53
60
54
61
/**
55
-
* Make form-encoded POST
62
+
* Make form-encoded POST (with retry for flaky wrangler dev 5xx errors)
56
63
*/
57
64
async function formPost(path, params, headers = {}) {
58
-
const res = await fetch(`${BASE}${path}`, {
59
-
method: 'POST',
60
-
headers: {
61
-
'Content-Type': 'application/x-www-form-urlencoded',
62
-
...headers,
63
-
},
64
-
body: new URLSearchParams(params).toString(),
65
-
});
66
-
const text = await res.text();
67
-
let data = null;
68
-
try {
69
-
data = JSON.parse(text);
70
-
} catch {
71
-
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 };
72
87
}
73
-
return { status: res.status, data };
74
88
}
75
89
76
90
describe('E2E Tests', () => {
···
538
552
assert.strictEqual(data.jwks_uri, `${BASE}/oauth/jwks`);
539
553
assert.deepStrictEqual(data.scopes_supported, ['atproto']);
540
554
assert.deepStrictEqual(data.dpop_signing_alg_values_supported, ['ES256']);
541
-
assert.strictEqual(data.require_pushed_authorization_requests, true);
555
+
assert.strictEqual(data.require_pushed_authorization_requests, false);
542
556
assert.strictEqual(data.client_id_metadata_document_supported, true);
543
557
assert.deepStrictEqual(data.protected_resources, [BASE]);
544
558
});
···
1448
1462
assert.ok(
1449
1463
html.includes('Full repository access requested'),
1450
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}`,
1451
1823
);
1452
1824
});
1453
1825
});
+121
-49
test/helpers/oauth.js
+121
-49
test/helpers/oauth.js
···
8
8
const BASE = 'http://localhost:8787';
9
9
10
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
+
/**
11
43
* Get an OAuth token with a specific scope via full PAR -> authorize -> token flow
12
44
* @param {string} scope - The scope to request
13
45
* @param {string} did - The DID to authenticate as
···
25
57
);
26
58
const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');
27
59
28
-
// PAR request
29
-
const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`);
30
-
const parRes = await fetch(`${BASE}/oauth/par`, {
31
-
method: 'POST',
32
-
headers: {
33
-
'Content-Type': 'application/x-www-form-urlencoded',
34
-
DPoP: parProof,
35
-
},
36
-
body: new URLSearchParams({
37
-
client_id: clientId,
38
-
redirect_uri: redirectUri,
39
-
response_type: 'code',
40
-
scope: scope,
41
-
code_challenge: codeChallenge,
42
-
code_challenge_method: 'S256',
43
-
login_hint: did,
44
-
}).toString(),
45
-
});
46
-
const parData = await parRes.json();
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
+
}
47
94
48
-
// Authorize
49
-
const authRes = await fetch(`${BASE}/oauth/authorize`, {
50
-
method: 'POST',
51
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
52
-
body: new URLSearchParams({
53
-
request_uri: parData.request_uri,
54
-
client_id: clientId,
55
-
password: password,
56
-
}).toString(),
57
-
redirect: 'manual',
58
-
});
59
-
const location = authRes.headers.get('location');
60
-
const authCode = new URL(location).searchParams.get('code');
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
+
}
61
119
62
-
// Token exchange
63
-
const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`);
64
-
const tokenRes = await fetch(`${BASE}/oauth/token`, {
65
-
method: 'POST',
66
-
headers: {
67
-
'Content-Type': 'application/x-www-form-urlencoded',
68
-
DPoP: tokenProof,
69
-
},
70
-
body: new URLSearchParams({
71
-
grant_type: 'authorization_code',
72
-
code: authCode,
73
-
client_id: clientId,
74
-
redirect_uri: redirectUri,
75
-
code_verifier: codeVerifier,
76
-
}).toString(),
77
-
});
78
-
const tokenData = await tokenRes.json();
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
+
}
79
151
80
152
return {
81
153
accessToken: tokenData.access_token,
+78
test/pds.test.js
+78
test/pds.test.js
···
19
19
findBlobRefs,
20
20
generateKeyPair,
21
21
getKeyDepth,
22
+
getKnownServiceUrl,
22
23
getLoopbackClientMetadata,
23
24
hexToBytes,
24
25
importPrivateKey,
25
26
isLoopbackClient,
26
27
matchesMime,
28
+
parseAtprotoProxyHeader,
27
29
parseBlobScope,
28
30
parseRepoScope,
29
31
parseScopesForDisplay,
···
35
37
verifyAccessJwt,
36
38
verifyRefreshJwt,
37
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';
38
43
39
44
describe('CBOR Encoding', () => {
40
45
test('encodes simple map', () => {
···
830
835
validateClientMetadata(metadata, 'https://example.com/metadata.json'),
831
836
/client_id mismatch/,
832
837
);
838
+
});
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
+
});
833
911
});
834
912
});
835
913