A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto
pds
1import assert from 'node:assert';
2import { describe, test } from 'node:test';
3import {
4 base32Decode,
5 base32Encode,
6 base64UrlDecode,
7 base64UrlEncode,
8 buildCarFile,
9 bytesToHex,
10 cborDecode,
11 cborEncode,
12 cidToString,
13 computeJwkThumbprint,
14 createAccessJwt,
15 createBlobCid,
16 createCid,
17 createRefreshJwt,
18 createTid,
19 findBlobRefs,
20 generateKeyPair,
21 getKeyDepth,
22 getKnownServiceUrl,
23 getLoopbackClientMetadata,
24 hexToBytes,
25 importPrivateKey,
26 isLoopbackClient,
27 matchesMime,
28 parseAtprotoProxyHeader,
29 parseBlobScope,
30 parseRepoScope,
31 parseScopesForDisplay,
32 ScopePermissions,
33 sign,
34 sniffMimeType,
35 validateClientMetadata,
36 varint,
37 verifyAccessJwt,
38 verifyRefreshJwt,
39} from '../src/pds.js';
40
41// Internal constant - not exported from pds.js due to Cloudflare Workers limitation
42const BSKY_APPVIEW_URL = 'https://api.bsky.app';
43
44describe('CBOR Encoding', () => {
45 test('encodes simple map', () => {
46 const encoded = cborEncode({ hello: 'world', num: 42 });
47 // Expected: a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a
48 const expected = new Uint8Array([
49 0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c,
50 0x64, 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a,
51 ]);
52 assert.deepStrictEqual(encoded, expected);
53 });
54
55 test('encodes null', () => {
56 const encoded = cborEncode(null);
57 assert.deepStrictEqual(encoded, new Uint8Array([0xf6]));
58 });
59
60 test('encodes booleans', () => {
61 assert.deepStrictEqual(cborEncode(true), new Uint8Array([0xf5]));
62 assert.deepStrictEqual(cborEncode(false), new Uint8Array([0xf4]));
63 });
64
65 test('encodes small integers', () => {
66 assert.deepStrictEqual(cborEncode(0), new Uint8Array([0x00]));
67 assert.deepStrictEqual(cborEncode(1), new Uint8Array([0x01]));
68 assert.deepStrictEqual(cborEncode(23), new Uint8Array([0x17]));
69 });
70
71 test('encodes integers >= 24', () => {
72 assert.deepStrictEqual(cborEncode(24), new Uint8Array([0x18, 0x18]));
73 assert.deepStrictEqual(cborEncode(255), new Uint8Array([0x18, 0xff]));
74 });
75
76 test('encodes negative integers', () => {
77 assert.deepStrictEqual(cborEncode(-1), new Uint8Array([0x20]));
78 assert.deepStrictEqual(cborEncode(-10), new Uint8Array([0x29]));
79 });
80
81 test('encodes strings', () => {
82 const encoded = cborEncode('hello');
83 // 0x65 = text string of length 5
84 assert.deepStrictEqual(
85 encoded,
86 new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f]),
87 );
88 });
89
90 test('encodes byte strings', () => {
91 const bytes = new Uint8Array([1, 2, 3]);
92 const encoded = cborEncode(bytes);
93 // 0x43 = byte string of length 3
94 assert.deepStrictEqual(encoded, new Uint8Array([0x43, 1, 2, 3]));
95 });
96
97 test('encodes arrays', () => {
98 const encoded = cborEncode([1, 2, 3]);
99 // 0x83 = array of length 3
100 assert.deepStrictEqual(encoded, new Uint8Array([0x83, 0x01, 0x02, 0x03]));
101 });
102
103 test('sorts map keys deterministically', () => {
104 const encoded1 = cborEncode({ z: 1, a: 2 });
105 const encoded2 = cborEncode({ a: 2, z: 1 });
106 assert.deepStrictEqual(encoded1, encoded2);
107 // First key should be 'a' (0x61)
108 assert.strictEqual(encoded1[1], 0x61);
109 });
110
111 test('encodes large integers >= 2^31 without overflow', () => {
112 // 2^31 would overflow with bitshift operators (treated as signed 32-bit)
113 const twoTo31 = 2147483648;
114 const encoded = cborEncode(twoTo31);
115 const decoded = cborDecode(encoded);
116 assert.strictEqual(decoded, twoTo31);
117
118 // 2^32 - 1 (max unsigned 32-bit)
119 const maxU32 = 4294967295;
120 const encoded2 = cborEncode(maxU32);
121 const decoded2 = cborDecode(encoded2);
122 assert.strictEqual(decoded2, maxU32);
123 });
124
125 test('encodes 2^31 with correct byte format', () => {
126 // 2147483648 = 0x80000000
127 // CBOR: major type 0 (unsigned int), additional info 26 (4-byte follows)
128 const encoded = cborEncode(2147483648);
129 assert.strictEqual(encoded[0], 0x1a); // type 0 | info 26
130 assert.strictEqual(encoded[1], 0x80);
131 assert.strictEqual(encoded[2], 0x00);
132 assert.strictEqual(encoded[3], 0x00);
133 assert.strictEqual(encoded[4], 0x00);
134 });
135});
136
137describe('Base32 Encoding', () => {
138 test('encodes bytes to base32lower', () => {
139 const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]);
140 const encoded = base32Encode(bytes);
141 assert.strictEqual(typeof encoded, 'string');
142 assert.match(encoded, /^[a-z2-7]+$/);
143 });
144});
145
146describe('CID Generation', () => {
147 test('createCid uses dag-cbor codec', async () => {
148 const data = cborEncode({ test: 'data' });
149 const cid = await createCid(data);
150
151 assert.strictEqual(cid.length, 36); // 2 prefix + 2 multihash header + 32 hash
152 assert.strictEqual(cid[0], 0x01); // CIDv1
153 assert.strictEqual(cid[1], 0x71); // dag-cbor
154 assert.strictEqual(cid[2], 0x12); // sha-256
155 assert.strictEqual(cid[3], 0x20); // 32 bytes
156 });
157
158 test('createBlobCid uses raw codec', async () => {
159 const data = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); // JPEG magic bytes
160 const cid = await createBlobCid(data);
161
162 assert.strictEqual(cid.length, 36);
163 assert.strictEqual(cid[0], 0x01); // CIDv1
164 assert.strictEqual(cid[1], 0x55); // raw codec
165 assert.strictEqual(cid[2], 0x12); // sha-256
166 assert.strictEqual(cid[3], 0x20); // 32 bytes
167 });
168
169 test('same bytes produce different CIDs with different codecs', async () => {
170 const data = new Uint8Array([1, 2, 3, 4]);
171 const dagCborCid = cidToString(await createCid(data));
172 const rawCid = cidToString(await createBlobCid(data));
173
174 assert.notStrictEqual(dagCborCid, rawCid);
175 });
176
177 test('cidToString returns base32lower with b prefix', async () => {
178 const data = cborEncode({ test: 'data' });
179 const cid = await createCid(data);
180 const cidStr = cidToString(cid);
181
182 assert.strictEqual(cidStr[0], 'b');
183 assert.match(cidStr, /^b[a-z2-7]+$/);
184 });
185
186 test('same input produces same CID', async () => {
187 const data1 = cborEncode({ test: 'data' });
188 const data2 = cborEncode({ test: 'data' });
189 const cid1 = cidToString(await createCid(data1));
190 const cid2 = cidToString(await createCid(data2));
191
192 assert.strictEqual(cid1, cid2);
193 });
194
195 test('different input produces different CID', async () => {
196 const cid1 = cidToString(await createCid(cborEncode({ a: 1 })));
197 const cid2 = cidToString(await createCid(cborEncode({ a: 2 })));
198
199 assert.notStrictEqual(cid1, cid2);
200 });
201});
202
203describe('TID Generation', () => {
204 test('creates 13-character TIDs', () => {
205 const tid = createTid();
206 assert.strictEqual(tid.length, 13);
207 });
208
209 test('uses valid base32-sort characters', () => {
210 const tid = createTid();
211 assert.match(tid, /^[234567abcdefghijklmnopqrstuvwxyz]+$/);
212 });
213
214 test('generates monotonically increasing TIDs', () => {
215 const tid1 = createTid();
216 const tid2 = createTid();
217 const tid3 = createTid();
218
219 assert.ok(tid1 < tid2, `${tid1} should be less than ${tid2}`);
220 assert.ok(tid2 < tid3, `${tid2} should be less than ${tid3}`);
221 });
222
223 test('generates unique TIDs', () => {
224 const tids = new Set();
225 for (let i = 0; i < 100; i++) {
226 tids.add(createTid());
227 }
228 assert.strictEqual(tids.size, 100);
229 });
230});
231
232describe('P-256 Signing', () => {
233 test('generates key pair with correct sizes', async () => {
234 const kp = await generateKeyPair();
235
236 assert.strictEqual(kp.privateKey.length, 32);
237 assert.strictEqual(kp.publicKey.length, 33); // compressed
238 assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03);
239 });
240
241 test('can sign data with generated key', async () => {
242 const kp = await generateKeyPair();
243 const key = await importPrivateKey(kp.privateKey);
244 const data = new TextEncoder().encode('test message');
245 const sig = await sign(key, data);
246
247 assert.strictEqual(sig.length, 64); // r (32) + s (32)
248 });
249
250 test('different messages produce different signatures', async () => {
251 const kp = await generateKeyPair();
252 const key = await importPrivateKey(kp.privateKey);
253
254 const sig1 = await sign(key, new TextEncoder().encode('message 1'));
255 const sig2 = await sign(key, new TextEncoder().encode('message 2'));
256
257 assert.notDeepStrictEqual(sig1, sig2);
258 });
259
260 test('bytesToHex and hexToBytes roundtrip', () => {
261 const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]);
262 const hex = bytesToHex(original);
263 const back = hexToBytes(hex);
264
265 assert.strictEqual(hex, '000ff0ffabcd');
266 assert.deepStrictEqual(back, original);
267 });
268
269 test('importPrivateKey rejects invalid key lengths', async () => {
270 // Too short
271 await assert.rejects(
272 () => importPrivateKey(new Uint8Array(31)),
273 /expected 32 bytes, got 31/,
274 );
275
276 // Too long
277 await assert.rejects(
278 () => importPrivateKey(new Uint8Array(33)),
279 /expected 32 bytes, got 33/,
280 );
281
282 // Empty
283 await assert.rejects(
284 () => importPrivateKey(new Uint8Array(0)),
285 /expected 32 bytes, got 0/,
286 );
287 });
288
289 test('importPrivateKey rejects non-Uint8Array input', async () => {
290 // Arrays have .length but aren't Uint8Array
291 await assert.rejects(
292 () => importPrivateKey([1, 2, 3]),
293 /Invalid private key/,
294 );
295
296 // Strings don't work either
297 await assert.rejects(
298 () => importPrivateKey('not bytes'),
299 /Invalid private key/,
300 );
301
302 // null/undefined
303 await assert.rejects(() => importPrivateKey(null), /Invalid private key/);
304 });
305});
306
307describe('MST Key Depth', () => {
308 test('returns a non-negative integer', async () => {
309 const depth = await getKeyDepth('app.bsky.feed.post/abc123');
310 assert.strictEqual(typeof depth, 'number');
311 assert.ok(depth >= 0);
312 });
313
314 test('is deterministic for same key', async () => {
315 const key = 'app.bsky.feed.post/test123';
316 const depth1 = await getKeyDepth(key);
317 const depth2 = await getKeyDepth(key);
318 assert.strictEqual(depth1, depth2);
319 });
320
321 test('different keys can have different depths', async () => {
322 // Generate many keys and check we get some variation
323 const depths = new Set();
324 for (let i = 0; i < 100; i++) {
325 depths.add(await getKeyDepth(`collection/key${i}`));
326 }
327 // Should have at least 1 unique depth (realistically more)
328 assert.ok(depths.size >= 1);
329 });
330
331 test('handles empty string', async () => {
332 const depth = await getKeyDepth('');
333 assert.strictEqual(typeof depth, 'number');
334 assert.ok(depth >= 0);
335 });
336
337 test('handles unicode strings', async () => {
338 const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉');
339 assert.strictEqual(typeof depth, 'number');
340 assert.ok(depth >= 0);
341 });
342});
343
344describe('CBOR Decoding', () => {
345 test('decodes what encode produces (roundtrip)', () => {
346 const original = { hello: 'world', num: 42 };
347 const encoded = cborEncode(original);
348 const decoded = cborDecode(encoded);
349 assert.deepStrictEqual(decoded, original);
350 });
351
352 test('decodes null', () => {
353 const encoded = cborEncode(null);
354 const decoded = cborDecode(encoded);
355 assert.strictEqual(decoded, null);
356 });
357
358 test('decodes booleans', () => {
359 assert.strictEqual(cborDecode(cborEncode(true)), true);
360 assert.strictEqual(cborDecode(cborEncode(false)), false);
361 });
362
363 test('decodes integers', () => {
364 assert.strictEqual(cborDecode(cborEncode(0)), 0);
365 assert.strictEqual(cborDecode(cborEncode(42)), 42);
366 assert.strictEqual(cborDecode(cborEncode(255)), 255);
367 assert.strictEqual(cborDecode(cborEncode(-1)), -1);
368 assert.strictEqual(cborDecode(cborEncode(-10)), -10);
369 });
370
371 test('decodes strings', () => {
372 assert.strictEqual(cborDecode(cborEncode('hello')), 'hello');
373 assert.strictEqual(cborDecode(cborEncode('')), '');
374 });
375
376 test('decodes arrays', () => {
377 assert.deepStrictEqual(cborDecode(cborEncode([1, 2, 3])), [1, 2, 3]);
378 assert.deepStrictEqual(cborDecode(cborEncode([])), []);
379 });
380
381 test('decodes nested structures', () => {
382 const original = { arr: [1, { nested: true }], str: 'test' };
383 const decoded = cborDecode(cborEncode(original));
384 assert.deepStrictEqual(decoded, original);
385 });
386});
387
388describe('CAR File Builder', () => {
389 test('varint encodes small numbers', () => {
390 assert.deepStrictEqual(varint(0), new Uint8Array([0]));
391 assert.deepStrictEqual(varint(1), new Uint8Array([1]));
392 assert.deepStrictEqual(varint(127), new Uint8Array([127]));
393 });
394
395 test('varint encodes multi-byte numbers', () => {
396 // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01]
397 assert.deepStrictEqual(varint(128), new Uint8Array([0x80, 0x01]));
398 // 300 = 0x12c -> [0xac, 0x02]
399 assert.deepStrictEqual(varint(300), new Uint8Array([0xac, 0x02]));
400 });
401
402 test('base32 encode/decode roundtrip', () => {
403 const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]);
404 const encoded = base32Encode(original);
405 const decoded = base32Decode(encoded);
406 assert.deepStrictEqual(decoded, original);
407 });
408
409 test('buildCarFile produces valid structure', async () => {
410 const data = cborEncode({ test: 'data' });
411 const cid = await createCid(data);
412 const cidStr = cidToString(cid);
413
414 const car = buildCarFile(cidStr, [{ cid: cidStr, data }]);
415
416 assert.ok(car instanceof Uint8Array);
417 assert.ok(car.length > 0);
418 // First byte should be varint of header length
419 assert.ok(car[0] > 0);
420 });
421});
422
423describe('JWT Base64URL', () => {
424 test('base64UrlEncode encodes bytes correctly', () => {
425 const input = new TextEncoder().encode('hello world');
426 const encoded = base64UrlEncode(input);
427 assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ');
428 assert.ok(!encoded.includes('+'));
429 assert.ok(!encoded.includes('/'));
430 assert.ok(!encoded.includes('='));
431 });
432
433 test('base64UrlDecode decodes string correctly', () => {
434 const decoded = base64UrlDecode('aGVsbG8gd29ybGQ');
435 const str = new TextDecoder().decode(decoded);
436 assert.strictEqual(str, 'hello world');
437 });
438
439 test('base64url roundtrip', () => {
440 const original = new Uint8Array([0, 1, 2, 255, 254, 253]);
441 const encoded = base64UrlEncode(original);
442 const decoded = base64UrlDecode(encoded);
443 assert.deepStrictEqual(decoded, original);
444 });
445});
446
447describe('JWT Creation', () => {
448 test('createAccessJwt creates valid JWT structure', async () => {
449 const did = 'did:web:test.example';
450 const secret = 'test-secret-key';
451 const jwt = await createAccessJwt(did, secret);
452
453 const parts = jwt.split('.');
454 assert.strictEqual(parts.length, 3);
455
456 // Decode header
457 const header = JSON.parse(
458 new TextDecoder().decode(base64UrlDecode(parts[0])),
459 );
460 assert.strictEqual(header.typ, 'at+jwt');
461 assert.strictEqual(header.alg, 'HS256');
462
463 // Decode payload
464 const payload = JSON.parse(
465 new TextDecoder().decode(base64UrlDecode(parts[1])),
466 );
467 assert.strictEqual(payload.scope, 'com.atproto.access');
468 assert.strictEqual(payload.sub, did);
469 assert.strictEqual(payload.aud, did);
470 assert.ok(payload.iat > 0);
471 assert.ok(payload.exp > payload.iat);
472 });
473
474 test('createRefreshJwt creates valid JWT with jti', async () => {
475 const did = 'did:web:test.example';
476 const secret = 'test-secret-key';
477 const jwt = await createRefreshJwt(did, secret);
478
479 const parts = jwt.split('.');
480 const header = JSON.parse(
481 new TextDecoder().decode(base64UrlDecode(parts[0])),
482 );
483 assert.strictEqual(header.typ, 'refresh+jwt');
484
485 const payload = JSON.parse(
486 new TextDecoder().decode(base64UrlDecode(parts[1])),
487 );
488 assert.strictEqual(payload.scope, 'com.atproto.refresh');
489 assert.ok(payload.jti); // has unique token ID
490 });
491});
492
493describe('JWT Verification', () => {
494 test('verifyAccessJwt returns payload for valid token', async () => {
495 const did = 'did:web:test.example';
496 const secret = 'test-secret-key';
497 const jwt = await createAccessJwt(did, secret);
498
499 const payload = await verifyAccessJwt(jwt, secret);
500 assert.strictEqual(payload.sub, did);
501 assert.strictEqual(payload.scope, 'com.atproto.access');
502 });
503
504 test('verifyAccessJwt throws for wrong secret', async () => {
505 const did = 'did:web:test.example';
506 const jwt = await createAccessJwt(did, 'correct-secret');
507
508 await assert.rejects(
509 () => verifyAccessJwt(jwt, 'wrong-secret'),
510 /invalid signature/i,
511 );
512 });
513
514 test('verifyAccessJwt throws for expired token', async () => {
515 const did = 'did:web:test.example';
516 const secret = 'test-secret-key';
517 // Create token that expired 1 second ago
518 const jwt = await createAccessJwt(did, secret, -1);
519
520 await assert.rejects(() => verifyAccessJwt(jwt, secret), /expired/i);
521 });
522
523 test('verifyAccessJwt throws for refresh token', async () => {
524 const did = 'did:web:test.example';
525 const secret = 'test-secret-key';
526 const jwt = await createRefreshJwt(did, secret);
527
528 await assert.rejects(
529 () => verifyAccessJwt(jwt, secret),
530 /invalid token type/i,
531 );
532 });
533
534 test('verifyRefreshJwt returns payload for valid token', async () => {
535 const did = 'did:web:test.example';
536 const secret = 'test-secret-key';
537 const jwt = await createRefreshJwt(did, secret);
538
539 const payload = await verifyRefreshJwt(jwt, secret);
540 assert.strictEqual(payload.sub, did);
541 assert.strictEqual(payload.scope, 'com.atproto.refresh');
542 assert.ok(payload.jti); // has token ID
543 });
544
545 test('verifyRefreshJwt throws for wrong secret', async () => {
546 const did = 'did:web:test.example';
547 const jwt = await createRefreshJwt(did, 'correct-secret');
548
549 await assert.rejects(
550 () => verifyRefreshJwt(jwt, 'wrong-secret'),
551 /invalid signature/i,
552 );
553 });
554
555 test('verifyRefreshJwt throws for expired token', async () => {
556 const did = 'did:web:test.example';
557 const secret = 'test-secret-key';
558 // Create token that expired 1 second ago
559 const jwt = await createRefreshJwt(did, secret, -1);
560
561 await assert.rejects(() => verifyRefreshJwt(jwt, secret), /expired/i);
562 });
563
564 test('verifyRefreshJwt throws for access token', async () => {
565 const did = 'did:web:test.example';
566 const secret = 'test-secret-key';
567 const jwt = await createAccessJwt(did, secret);
568
569 await assert.rejects(
570 () => verifyRefreshJwt(jwt, secret),
571 /invalid token type/i,
572 );
573 });
574
575 test('verifyAccessJwt throws for malformed JWT', async () => {
576 const secret = 'test-secret-key';
577
578 // Not a JWT at all
579 await assert.rejects(
580 () => verifyAccessJwt('not-a-jwt', secret),
581 /Invalid JWT format/i,
582 );
583
584 // Only two parts
585 await assert.rejects(
586 () => verifyAccessJwt('two.parts', secret),
587 /Invalid JWT format/i,
588 );
589
590 // Four parts
591 await assert.rejects(
592 () => verifyAccessJwt('one.two.three.four', secret),
593 /Invalid JWT format/i,
594 );
595 });
596
597 test('verifyRefreshJwt throws for malformed JWT', async () => {
598 const secret = 'test-secret-key';
599
600 await assert.rejects(
601 () => verifyRefreshJwt('not-a-jwt', secret),
602 /Invalid JWT format/i,
603 );
604
605 await assert.rejects(
606 () => verifyRefreshJwt('two.parts', secret),
607 /Invalid JWT format/i,
608 );
609 });
610});
611
612describe('MIME Type Sniffing', () => {
613 test('detects JPEG', () => {
614 const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
615 assert.strictEqual(sniffMimeType(bytes), 'image/jpeg');
616 });
617
618 test('detects PNG', () => {
619 const bytes = new Uint8Array([
620 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
621 ]);
622 assert.strictEqual(sniffMimeType(bytes), 'image/png');
623 });
624
625 test('detects GIF', () => {
626 const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
627 assert.strictEqual(sniffMimeType(bytes), 'image/gif');
628 });
629
630 test('detects WebP', () => {
631 const bytes = new Uint8Array([
632 0x52,
633 0x49,
634 0x46,
635 0x46, // RIFF
636 0x00,
637 0x00,
638 0x00,
639 0x00, // size (ignored)
640 0x57,
641 0x45,
642 0x42,
643 0x50, // WEBP
644 ]);
645 assert.strictEqual(sniffMimeType(bytes), 'image/webp');
646 });
647
648 test('detects MP4', () => {
649 const bytes = new Uint8Array([
650 0x00,
651 0x00,
652 0x00,
653 0x18, // size
654 0x66,
655 0x74,
656 0x79,
657 0x70, // ftyp
658 0x69,
659 0x73,
660 0x6f,
661 0x6d, // isom brand
662 ]);
663 assert.strictEqual(sniffMimeType(bytes), 'video/mp4');
664 });
665
666 test('detects AVIF', () => {
667 const bytes = new Uint8Array([
668 0x00,
669 0x00,
670 0x00,
671 0x1c, // size
672 0x66,
673 0x74,
674 0x79,
675 0x70, // ftyp
676 0x61,
677 0x76,
678 0x69,
679 0x66, // avif brand
680 ]);
681 assert.strictEqual(sniffMimeType(bytes), 'image/avif');
682 });
683
684 test('detects HEIC', () => {
685 const bytes = new Uint8Array([
686 0x00,
687 0x00,
688 0x00,
689 0x18, // size
690 0x66,
691 0x74,
692 0x79,
693 0x70, // ftyp
694 0x68,
695 0x65,
696 0x69,
697 0x63, // heic brand
698 ]);
699 assert.strictEqual(sniffMimeType(bytes), 'image/heic');
700 });
701
702 test('returns null for unknown', () => {
703 const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
704 assert.strictEqual(sniffMimeType(bytes), null);
705 });
706});
707
708describe('Blob Ref Detection', () => {
709 test('finds blob ref in simple object', () => {
710 const record = {
711 $type: 'app.bsky.feed.post',
712 text: 'Hello',
713 embed: {
714 $type: 'app.bsky.embed.images',
715 images: [
716 {
717 image: {
718 $type: 'blob',
719 ref: { $link: 'bafkreiabc123' },
720 mimeType: 'image/jpeg',
721 size: 1234,
722 },
723 alt: 'test image',
724 },
725 ],
726 },
727 };
728 const refs = findBlobRefs(record);
729 assert.deepStrictEqual(refs, ['bafkreiabc123']);
730 });
731
732 test('finds multiple blob refs', () => {
733 const record = {
734 images: [
735 {
736 image: {
737 $type: 'blob',
738 ref: { $link: 'cid1' },
739 mimeType: 'image/png',
740 size: 100,
741 },
742 },
743 {
744 image: {
745 $type: 'blob',
746 ref: { $link: 'cid2' },
747 mimeType: 'image/png',
748 size: 200,
749 },
750 },
751 ],
752 };
753 const refs = findBlobRefs(record);
754 assert.deepStrictEqual(refs, ['cid1', 'cid2']);
755 });
756
757 test('returns empty array when no blobs', () => {
758 const record = { text: 'Hello world', count: 42 };
759 const refs = findBlobRefs(record);
760 assert.deepStrictEqual(refs, []);
761 });
762
763 test('handles null and primitives', () => {
764 assert.deepStrictEqual(findBlobRefs(null), []);
765 assert.deepStrictEqual(findBlobRefs('string'), []);
766 assert.deepStrictEqual(findBlobRefs(42), []);
767 });
768});
769
770describe('JWK Thumbprint', () => {
771 test('computes deterministic thumbprint for EC key', async () => {
772 // Test vector: known JWK and its expected thumbprint
773 const jwk = {
774 kty: 'EC',
775 crv: 'P-256',
776 x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY',
777 y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ',
778 };
779
780 const jkt1 = await computeJwkThumbprint(jwk);
781 const jkt2 = await computeJwkThumbprint(jwk);
782
783 // Thumbprint must be deterministic
784 assert.strictEqual(jkt1, jkt2);
785 // Must be base64url-encoded SHA-256 (43 chars)
786 assert.strictEqual(jkt1.length, 43);
787 // Must only contain base64url characters
788 assert.match(jkt1, /^[A-Za-z0-9_-]+$/);
789 });
790
791 test('produces different thumbprints for different keys', async () => {
792 const jwk1 = {
793 kty: 'EC',
794 crv: 'P-256',
795 x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY',
796 y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ',
797 };
798 const jwk2 = {
799 kty: 'EC',
800 crv: 'P-256',
801 x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
802 y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
803 };
804
805 const jkt1 = await computeJwkThumbprint(jwk1);
806 const jkt2 = await computeJwkThumbprint(jwk2);
807
808 assert.notStrictEqual(jkt1, jkt2);
809 });
810});
811
812describe('Client Metadata', () => {
813 test('isLoopbackClient detects localhost', () => {
814 assert.strictEqual(isLoopbackClient('http://localhost:8080'), true);
815 assert.strictEqual(isLoopbackClient('http://127.0.0.1:3000'), true);
816 assert.strictEqual(isLoopbackClient('https://example.com'), false);
817 });
818
819 test('getLoopbackClientMetadata returns permissive defaults', () => {
820 const metadata = getLoopbackClientMetadata('http://localhost:8080');
821 assert.strictEqual(metadata.client_id, 'http://localhost:8080');
822 assert.ok(metadata.grant_types.includes('authorization_code'));
823 assert.strictEqual(metadata.dpop_bound_access_tokens, true);
824 });
825
826 test('validateClientMetadata rejects mismatched client_id', () => {
827 const metadata = {
828 client_id: 'https://other.com/metadata.json',
829 redirect_uris: ['https://example.com/callback'],
830 grant_types: ['authorization_code'],
831 response_types: ['code'],
832 };
833 assert.throws(
834 () =>
835 validateClientMetadata(metadata, 'https://example.com/metadata.json'),
836 /client_id mismatch/,
837 );
838 });
839});
840
841describe('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
914describe('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
1033describe('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
1197describe('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});