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