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(
871 parseAtprotoProxyHeader('did:web:api.bsky.app'),
872 null,
873 );
874 });
875
876 test('returns null for header with only fragment', () => {
877 assert.strictEqual(parseAtprotoProxyHeader('#bsky_appview'), null);
878 });
879
880 test('returns null for header with trailing fragment', () => {
881 assert.strictEqual(parseAtprotoProxyHeader('did:web:api.bsky.app#'), null);
882 });
883 });
884
885 describe('getKnownServiceUrl', () => {
886 test('returns URL for known Bluesky AppView', () => {
887 const result = getKnownServiceUrl(
888 'did:web:api.bsky.app',
889 'bsky_appview',
890 );
891 assert.strictEqual(result, BSKY_APPVIEW_URL);
892 });
893
894 test('returns null for unknown service DID', () => {
895 const result = getKnownServiceUrl(
896 'did:web:unknown.service',
897 'bsky_appview',
898 );
899 assert.strictEqual(result, null);
900 });
901
902 test('returns null for unknown service ID', () => {
903 const result = getKnownServiceUrl(
904 'did:web:api.bsky.app',
905 'unknown_service',
906 );
907 assert.strictEqual(result, null);
908 });
909
910 test('returns null for both unknown', () => {
911 const result = getKnownServiceUrl('did:web:unknown', 'unknown');
912 assert.strictEqual(result, null);
913 });
914 });
915});
916
917describe('Scope Parsing', () => {
918 describe('parseRepoScope', () => {
919 test('parses repo scope with query parameter action', () => {
920 const result = parseRepoScope('repo:app.bsky.feed.post?action=create');
921 assert.deepStrictEqual(result, {
922 collection: 'app.bsky.feed.post',
923 actions: ['create'],
924 });
925 });
926
927 test('parses repo scope with multiple query parameter actions', () => {
928 const result = parseRepoScope(
929 'repo:app.bsky.feed.post?action=create&action=update',
930 );
931 assert.deepStrictEqual(result, {
932 collection: 'app.bsky.feed.post',
933 actions: ['create', 'update'],
934 });
935 });
936
937 test('parses repo scope without actions as all actions', () => {
938 const result = parseRepoScope('repo:app.bsky.feed.post');
939 assert.deepStrictEqual(result, {
940 collection: 'app.bsky.feed.post',
941 actions: ['create', 'update', 'delete'],
942 });
943 });
944
945 test('parses wildcard collection with action', () => {
946 const result = parseRepoScope('repo:*?action=create');
947 assert.deepStrictEqual(result, {
948 collection: '*',
949 actions: ['create'],
950 });
951 });
952
953 test('parses query-only format', () => {
954 const result = parseRepoScope(
955 'repo?collection=app.bsky.feed.post&action=create',
956 );
957 assert.deepStrictEqual(result, {
958 collection: 'app.bsky.feed.post',
959 actions: ['create'],
960 });
961 });
962
963 test('deduplicates repeated actions', () => {
964 const result = parseRepoScope(
965 'repo:app.bsky.feed.post?action=create&action=create&action=update',
966 );
967 assert.deepStrictEqual(result, {
968 collection: 'app.bsky.feed.post',
969 actions: ['create', 'update'],
970 });
971 });
972
973 test('returns null for non-repo scope', () => {
974 assert.strictEqual(parseRepoScope('atproto'), null);
975 assert.strictEqual(parseRepoScope('blob:image/*'), null);
976 assert.strictEqual(parseRepoScope('transition:generic'), null);
977 });
978
979 test('returns null for invalid repo scope', () => {
980 assert.strictEqual(parseRepoScope('repo:'), null);
981 assert.strictEqual(parseRepoScope('repo?'), null);
982 });
983 });
984
985 describe('parseBlobScope', () => {
986 test('parses wildcard MIME', () => {
987 const result = parseBlobScope('blob:*/*');
988 assert.deepStrictEqual(result, { accept: ['*/*'] });
989 });
990
991 test('parses type wildcard', () => {
992 const result = parseBlobScope('blob:image/*');
993 assert.deepStrictEqual(result, { accept: ['image/*'] });
994 });
995
996 test('parses specific MIME', () => {
997 const result = parseBlobScope('blob:image/png');
998 assert.deepStrictEqual(result, { accept: ['image/png'] });
999 });
1000
1001 test('parses multiple MIMEs', () => {
1002 const result = parseBlobScope('blob:image/png,image/jpeg');
1003 assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] });
1004 });
1005
1006 test('returns null for non-blob scope', () => {
1007 assert.strictEqual(parseBlobScope('atproto'), null);
1008 assert.strictEqual(parseBlobScope('repo:*:create'), null);
1009 });
1010 });
1011
1012 describe('matchesMime', () => {
1013 test('wildcard matches everything', () => {
1014 assert.strictEqual(matchesMime('*/*', 'image/png'), true);
1015 assert.strictEqual(matchesMime('*/*', 'video/mp4'), true);
1016 });
1017
1018 test('type wildcard matches same type', () => {
1019 assert.strictEqual(matchesMime('image/*', 'image/png'), true);
1020 assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true);
1021 assert.strictEqual(matchesMime('image/*', 'video/mp4'), false);
1022 });
1023
1024 test('exact match', () => {
1025 assert.strictEqual(matchesMime('image/png', 'image/png'), true);
1026 assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false);
1027 });
1028
1029 test('case insensitive', () => {
1030 assert.strictEqual(matchesMime('image/PNG', 'image/png'), true);
1031 assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true);
1032 });
1033 });
1034});
1035
1036describe('ScopePermissions', () => {
1037 describe('static scopes', () => {
1038 test('atproto grants full access', () => {
1039 const perms = new ScopePermissions('atproto');
1040 assert.strictEqual(
1041 perms.allowsRepo('app.bsky.feed.post', 'create'),
1042 true,
1043 );
1044 assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true);
1045 assert.strictEqual(perms.allowsBlob('image/png'), true);
1046 assert.strictEqual(perms.allowsBlob('video/mp4'), true);
1047 });
1048
1049 test('transition:generic grants full repo/blob access', () => {
1050 const perms = new ScopePermissions('transition:generic');
1051 assert.strictEqual(
1052 perms.allowsRepo('app.bsky.feed.post', 'create'),
1053 true,
1054 );
1055 assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true);
1056 assert.strictEqual(perms.allowsBlob('image/png'), true);
1057 });
1058 });
1059
1060 describe('repo scopes', () => {
1061 test('wildcard collection allows any collection', () => {
1062 const perms = new ScopePermissions('repo:*?action=create');
1063 assert.strictEqual(
1064 perms.allowsRepo('app.bsky.feed.post', 'create'),
1065 true,
1066 );
1067 assert.strictEqual(
1068 perms.allowsRepo('app.bsky.feed.like', 'create'),
1069 true,
1070 );
1071 assert.strictEqual(
1072 perms.allowsRepo('app.bsky.feed.post', 'delete'),
1073 false,
1074 );
1075 });
1076
1077 test('specific collection restricts to that collection', () => {
1078 const perms = new ScopePermissions(
1079 'repo:app.bsky.feed.post?action=create',
1080 );
1081 assert.strictEqual(
1082 perms.allowsRepo('app.bsky.feed.post', 'create'),
1083 true,
1084 );
1085 assert.strictEqual(
1086 perms.allowsRepo('app.bsky.feed.like', 'create'),
1087 false,
1088 );
1089 });
1090
1091 test('multiple actions', () => {
1092 const perms = new ScopePermissions('repo:*?action=create&action=update');
1093 assert.strictEqual(perms.allowsRepo('x', 'create'), true);
1094 assert.strictEqual(perms.allowsRepo('x', 'update'), true);
1095 assert.strictEqual(perms.allowsRepo('x', 'delete'), false);
1096 });
1097
1098 test('multiple scopes combine', () => {
1099 const perms = new ScopePermissions(
1100 'repo:app.bsky.feed.post?action=create repo:app.bsky.feed.like?action=delete',
1101 );
1102 assert.strictEqual(
1103 perms.allowsRepo('app.bsky.feed.post', 'create'),
1104 true,
1105 );
1106 assert.strictEqual(
1107 perms.allowsRepo('app.bsky.feed.like', 'delete'),
1108 true,
1109 );
1110 assert.strictEqual(
1111 perms.allowsRepo('app.bsky.feed.post', 'delete'),
1112 false,
1113 );
1114 });
1115
1116 test('allowsRepo with query param format scopes', () => {
1117 const perms = new ScopePermissions(
1118 'atproto repo:app.bsky.feed.post?action=create',
1119 );
1120 assert.strictEqual(
1121 perms.allowsRepo('app.bsky.feed.post', 'create'),
1122 true,
1123 );
1124 assert.strictEqual(
1125 perms.allowsRepo('app.bsky.feed.post', 'delete'),
1126 true,
1127 ); // atproto grants full access
1128 });
1129 });
1130
1131 describe('blob scopes', () => {
1132 test('wildcard allows any MIME', () => {
1133 const perms = new ScopePermissions('blob:*/*');
1134 assert.strictEqual(perms.allowsBlob('image/png'), true);
1135 assert.strictEqual(perms.allowsBlob('video/mp4'), true);
1136 });
1137
1138 test('type wildcard restricts to type', () => {
1139 const perms = new ScopePermissions('blob:image/*');
1140 assert.strictEqual(perms.allowsBlob('image/png'), true);
1141 assert.strictEqual(perms.allowsBlob('image/jpeg'), true);
1142 assert.strictEqual(perms.allowsBlob('video/mp4'), false);
1143 });
1144
1145 test('specific MIME restricts exactly', () => {
1146 const perms = new ScopePermissions('blob:image/png');
1147 assert.strictEqual(perms.allowsBlob('image/png'), true);
1148 assert.strictEqual(perms.allowsBlob('image/jpeg'), false);
1149 });
1150 });
1151
1152 describe('empty/no scope', () => {
1153 test('no scope denies everything', () => {
1154 const perms = new ScopePermissions('');
1155 assert.strictEqual(perms.allowsRepo('x', 'create'), false);
1156 assert.strictEqual(perms.allowsBlob('image/png'), false);
1157 });
1158
1159 test('undefined scope denies everything', () => {
1160 const perms = new ScopePermissions(undefined);
1161 assert.strictEqual(perms.allowsRepo('x', 'create'), false);
1162 });
1163 });
1164
1165 describe('assertRepo', () => {
1166 test('throws ScopeMissingError when denied', () => {
1167 const perms = new ScopePermissions(
1168 'repo:app.bsky.feed.post?action=create',
1169 );
1170 assert.throws(() => perms.assertRepo('app.bsky.feed.like', 'create'), {
1171 message: /Missing required scope/,
1172 });
1173 });
1174
1175 test('does not throw when allowed', () => {
1176 const perms = new ScopePermissions(
1177 'repo:app.bsky.feed.post?action=create',
1178 );
1179 assert.doesNotThrow(() =>
1180 perms.assertRepo('app.bsky.feed.post', 'create'),
1181 );
1182 });
1183 });
1184
1185 describe('assertBlob', () => {
1186 test('throws ScopeMissingError when denied', () => {
1187 const perms = new ScopePermissions('blob:image/*');
1188 assert.throws(() => perms.assertBlob('video/mp4'), {
1189 message: /Missing required scope/,
1190 });
1191 });
1192
1193 test('does not throw when allowed', () => {
1194 const perms = new ScopePermissions('blob:image/*');
1195 assert.doesNotThrow(() => perms.assertBlob('image/png'));
1196 });
1197 });
1198});
1199
1200describe('parseScopesForDisplay', () => {
1201 test('parses identity-only scope', () => {
1202 const result = parseScopesForDisplay('atproto');
1203 assert.strictEqual(result.hasAtproto, true);
1204 assert.strictEqual(result.hasTransitionGeneric, false);
1205 assert.strictEqual(result.repoPermissions.size, 0);
1206 assert.deepStrictEqual(result.blobPermissions, []);
1207 });
1208
1209 test('parses granular repo scopes', () => {
1210 const result = parseScopesForDisplay(
1211 'atproto repo:app.bsky.feed.post?action=create&action=update',
1212 );
1213 assert.strictEqual(result.repoPermissions.size, 1);
1214 const postPerms = result.repoPermissions.get('app.bsky.feed.post');
1215 assert.deepStrictEqual(postPerms, {
1216 create: true,
1217 update: true,
1218 delete: false,
1219 });
1220 });
1221
1222 test('merges multiple scopes for same collection', () => {
1223 const result = parseScopesForDisplay(
1224 'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete',
1225 );
1226 const postPerms = result.repoPermissions.get('app.bsky.feed.post');
1227 assert.deepStrictEqual(postPerms, {
1228 create: true,
1229 update: false,
1230 delete: true,
1231 });
1232 });
1233
1234 test('parses blob scopes', () => {
1235 const result = parseScopesForDisplay('atproto blob:image/*');
1236 assert.deepStrictEqual(result.blobPermissions, ['image/*']);
1237 });
1238
1239 test('detects transition:generic', () => {
1240 const result = parseScopesForDisplay('atproto transition:generic');
1241 assert.strictEqual(result.hasTransitionGeneric, true);
1242 });
1243
1244 test('handles empty scope string', () => {
1245 const result = parseScopesForDisplay('');
1246 assert.strictEqual(result.hasAtproto, false);
1247 assert.strictEqual(result.hasTransitionGeneric, false);
1248 assert.strictEqual(result.repoPermissions.size, 0);
1249 assert.deepStrictEqual(result.blobPermissions, []);
1250 });
1251});