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 getLoopbackClientMetadata,
23 hexToBytes,
24 importPrivateKey,
25 isLoopbackClient,
26 matchesMime,
27 parseBlobScope,
28 parseRepoScope,
29 parseScopesForDisplay,
30 ScopePermissions,
31 sign,
32 sniffMimeType,
33 validateClientMetadata,
34 varint,
35 verifyAccessJwt,
36 verifyRefreshJwt,
37} from '../src/pds.js';
38
39describe('CBOR Encoding', () => {
40 test('encodes simple map', () => {
41 const encoded = cborEncode({ hello: 'world', num: 42 });
42 // Expected: a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a
43 const expected = new Uint8Array([
44 0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c,
45 0x64, 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a,
46 ]);
47 assert.deepStrictEqual(encoded, expected);
48 });
49
50 test('encodes null', () => {
51 const encoded = cborEncode(null);
52 assert.deepStrictEqual(encoded, new Uint8Array([0xf6]));
53 });
54
55 test('encodes booleans', () => {
56 assert.deepStrictEqual(cborEncode(true), new Uint8Array([0xf5]));
57 assert.deepStrictEqual(cborEncode(false), new Uint8Array([0xf4]));
58 });
59
60 test('encodes small integers', () => {
61 assert.deepStrictEqual(cborEncode(0), new Uint8Array([0x00]));
62 assert.deepStrictEqual(cborEncode(1), new Uint8Array([0x01]));
63 assert.deepStrictEqual(cborEncode(23), new Uint8Array([0x17]));
64 });
65
66 test('encodes integers >= 24', () => {
67 assert.deepStrictEqual(cborEncode(24), new Uint8Array([0x18, 0x18]));
68 assert.deepStrictEqual(cborEncode(255), new Uint8Array([0x18, 0xff]));
69 });
70
71 test('encodes negative integers', () => {
72 assert.deepStrictEqual(cborEncode(-1), new Uint8Array([0x20]));
73 assert.deepStrictEqual(cborEncode(-10), new Uint8Array([0x29]));
74 });
75
76 test('encodes strings', () => {
77 const encoded = cborEncode('hello');
78 // 0x65 = text string of length 5
79 assert.deepStrictEqual(
80 encoded,
81 new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f]),
82 );
83 });
84
85 test('encodes byte strings', () => {
86 const bytes = new Uint8Array([1, 2, 3]);
87 const encoded = cborEncode(bytes);
88 // 0x43 = byte string of length 3
89 assert.deepStrictEqual(encoded, new Uint8Array([0x43, 1, 2, 3]));
90 });
91
92 test('encodes arrays', () => {
93 const encoded = cborEncode([1, 2, 3]);
94 // 0x83 = array of length 3
95 assert.deepStrictEqual(encoded, new Uint8Array([0x83, 0x01, 0x02, 0x03]));
96 });
97
98 test('sorts map keys deterministically', () => {
99 const encoded1 = cborEncode({ z: 1, a: 2 });
100 const encoded2 = cborEncode({ a: 2, z: 1 });
101 assert.deepStrictEqual(encoded1, encoded2);
102 // First key should be 'a' (0x61)
103 assert.strictEqual(encoded1[1], 0x61);
104 });
105
106 test('encodes large integers >= 2^31 without overflow', () => {
107 // 2^31 would overflow with bitshift operators (treated as signed 32-bit)
108 const twoTo31 = 2147483648;
109 const encoded = cborEncode(twoTo31);
110 const decoded = cborDecode(encoded);
111 assert.strictEqual(decoded, twoTo31);
112
113 // 2^32 - 1 (max unsigned 32-bit)
114 const maxU32 = 4294967295;
115 const encoded2 = cborEncode(maxU32);
116 const decoded2 = cborDecode(encoded2);
117 assert.strictEqual(decoded2, maxU32);
118 });
119
120 test('encodes 2^31 with correct byte format', () => {
121 // 2147483648 = 0x80000000
122 // CBOR: major type 0 (unsigned int), additional info 26 (4-byte follows)
123 const encoded = cborEncode(2147483648);
124 assert.strictEqual(encoded[0], 0x1a); // type 0 | info 26
125 assert.strictEqual(encoded[1], 0x80);
126 assert.strictEqual(encoded[2], 0x00);
127 assert.strictEqual(encoded[3], 0x00);
128 assert.strictEqual(encoded[4], 0x00);
129 });
130});
131
132describe('Base32 Encoding', () => {
133 test('encodes bytes to base32lower', () => {
134 const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]);
135 const encoded = base32Encode(bytes);
136 assert.strictEqual(typeof encoded, 'string');
137 assert.match(encoded, /^[a-z2-7]+$/);
138 });
139});
140
141describe('CID Generation', () => {
142 test('createCid uses dag-cbor codec', async () => {
143 const data = cborEncode({ test: 'data' });
144 const cid = await createCid(data);
145
146 assert.strictEqual(cid.length, 36); // 2 prefix + 2 multihash header + 32 hash
147 assert.strictEqual(cid[0], 0x01); // CIDv1
148 assert.strictEqual(cid[1], 0x71); // dag-cbor
149 assert.strictEqual(cid[2], 0x12); // sha-256
150 assert.strictEqual(cid[3], 0x20); // 32 bytes
151 });
152
153 test('createBlobCid uses raw codec', async () => {
154 const data = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); // JPEG magic bytes
155 const cid = await createBlobCid(data);
156
157 assert.strictEqual(cid.length, 36);
158 assert.strictEqual(cid[0], 0x01); // CIDv1
159 assert.strictEqual(cid[1], 0x55); // raw codec
160 assert.strictEqual(cid[2], 0x12); // sha-256
161 assert.strictEqual(cid[3], 0x20); // 32 bytes
162 });
163
164 test('same bytes produce different CIDs with different codecs', async () => {
165 const data = new Uint8Array([1, 2, 3, 4]);
166 const dagCborCid = cidToString(await createCid(data));
167 const rawCid = cidToString(await createBlobCid(data));
168
169 assert.notStrictEqual(dagCborCid, rawCid);
170 });
171
172 test('cidToString returns base32lower with b prefix', async () => {
173 const data = cborEncode({ test: 'data' });
174 const cid = await createCid(data);
175 const cidStr = cidToString(cid);
176
177 assert.strictEqual(cidStr[0], 'b');
178 assert.match(cidStr, /^b[a-z2-7]+$/);
179 });
180
181 test('same input produces same CID', async () => {
182 const data1 = cborEncode({ test: 'data' });
183 const data2 = cborEncode({ test: 'data' });
184 const cid1 = cidToString(await createCid(data1));
185 const cid2 = cidToString(await createCid(data2));
186
187 assert.strictEqual(cid1, cid2);
188 });
189
190 test('different input produces different CID', async () => {
191 const cid1 = cidToString(await createCid(cborEncode({ a: 1 })));
192 const cid2 = cidToString(await createCid(cborEncode({ a: 2 })));
193
194 assert.notStrictEqual(cid1, cid2);
195 });
196});
197
198describe('TID Generation', () => {
199 test('creates 13-character TIDs', () => {
200 const tid = createTid();
201 assert.strictEqual(tid.length, 13);
202 });
203
204 test('uses valid base32-sort characters', () => {
205 const tid = createTid();
206 assert.match(tid, /^[234567abcdefghijklmnopqrstuvwxyz]+$/);
207 });
208
209 test('generates monotonically increasing TIDs', () => {
210 const tid1 = createTid();
211 const tid2 = createTid();
212 const tid3 = createTid();
213
214 assert.ok(tid1 < tid2, `${tid1} should be less than ${tid2}`);
215 assert.ok(tid2 < tid3, `${tid2} should be less than ${tid3}`);
216 });
217
218 test('generates unique TIDs', () => {
219 const tids = new Set();
220 for (let i = 0; i < 100; i++) {
221 tids.add(createTid());
222 }
223 assert.strictEqual(tids.size, 100);
224 });
225});
226
227describe('P-256 Signing', () => {
228 test('generates key pair with correct sizes', async () => {
229 const kp = await generateKeyPair();
230
231 assert.strictEqual(kp.privateKey.length, 32);
232 assert.strictEqual(kp.publicKey.length, 33); // compressed
233 assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03);
234 });
235
236 test('can sign data with generated key', async () => {
237 const kp = await generateKeyPair();
238 const key = await importPrivateKey(kp.privateKey);
239 const data = new TextEncoder().encode('test message');
240 const sig = await sign(key, data);
241
242 assert.strictEqual(sig.length, 64); // r (32) + s (32)
243 });
244
245 test('different messages produce different signatures', async () => {
246 const kp = await generateKeyPair();
247 const key = await importPrivateKey(kp.privateKey);
248
249 const sig1 = await sign(key, new TextEncoder().encode('message 1'));
250 const sig2 = await sign(key, new TextEncoder().encode('message 2'));
251
252 assert.notDeepStrictEqual(sig1, sig2);
253 });
254
255 test('bytesToHex and hexToBytes roundtrip', () => {
256 const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]);
257 const hex = bytesToHex(original);
258 const back = hexToBytes(hex);
259
260 assert.strictEqual(hex, '000ff0ffabcd');
261 assert.deepStrictEqual(back, original);
262 });
263
264 test('importPrivateKey rejects invalid key lengths', async () => {
265 // Too short
266 await assert.rejects(
267 () => importPrivateKey(new Uint8Array(31)),
268 /expected 32 bytes, got 31/,
269 );
270
271 // Too long
272 await assert.rejects(
273 () => importPrivateKey(new Uint8Array(33)),
274 /expected 32 bytes, got 33/,
275 );
276
277 // Empty
278 await assert.rejects(
279 () => importPrivateKey(new Uint8Array(0)),
280 /expected 32 bytes, got 0/,
281 );
282 });
283
284 test('importPrivateKey rejects non-Uint8Array input', async () => {
285 // Arrays have .length but aren't Uint8Array
286 await assert.rejects(
287 () => importPrivateKey([1, 2, 3]),
288 /Invalid private key/,
289 );
290
291 // Strings don't work either
292 await assert.rejects(
293 () => importPrivateKey('not bytes'),
294 /Invalid private key/,
295 );
296
297 // null/undefined
298 await assert.rejects(() => importPrivateKey(null), /Invalid private key/);
299 });
300});
301
302describe('MST Key Depth', () => {
303 test('returns a non-negative integer', async () => {
304 const depth = await getKeyDepth('app.bsky.feed.post/abc123');
305 assert.strictEqual(typeof depth, 'number');
306 assert.ok(depth >= 0);
307 });
308
309 test('is deterministic for same key', async () => {
310 const key = 'app.bsky.feed.post/test123';
311 const depth1 = await getKeyDepth(key);
312 const depth2 = await getKeyDepth(key);
313 assert.strictEqual(depth1, depth2);
314 });
315
316 test('different keys can have different depths', async () => {
317 // Generate many keys and check we get some variation
318 const depths = new Set();
319 for (let i = 0; i < 100; i++) {
320 depths.add(await getKeyDepth(`collection/key${i}`));
321 }
322 // Should have at least 1 unique depth (realistically more)
323 assert.ok(depths.size >= 1);
324 });
325
326 test('handles empty string', async () => {
327 const depth = await getKeyDepth('');
328 assert.strictEqual(typeof depth, 'number');
329 assert.ok(depth >= 0);
330 });
331
332 test('handles unicode strings', async () => {
333 const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉');
334 assert.strictEqual(typeof depth, 'number');
335 assert.ok(depth >= 0);
336 });
337});
338
339describe('CBOR Decoding', () => {
340 test('decodes what encode produces (roundtrip)', () => {
341 const original = { hello: 'world', num: 42 };
342 const encoded = cborEncode(original);
343 const decoded = cborDecode(encoded);
344 assert.deepStrictEqual(decoded, original);
345 });
346
347 test('decodes null', () => {
348 const encoded = cborEncode(null);
349 const decoded = cborDecode(encoded);
350 assert.strictEqual(decoded, null);
351 });
352
353 test('decodes booleans', () => {
354 assert.strictEqual(cborDecode(cborEncode(true)), true);
355 assert.strictEqual(cborDecode(cborEncode(false)), false);
356 });
357
358 test('decodes integers', () => {
359 assert.strictEqual(cborDecode(cborEncode(0)), 0);
360 assert.strictEqual(cborDecode(cborEncode(42)), 42);
361 assert.strictEqual(cborDecode(cborEncode(255)), 255);
362 assert.strictEqual(cborDecode(cborEncode(-1)), -1);
363 assert.strictEqual(cborDecode(cborEncode(-10)), -10);
364 });
365
366 test('decodes strings', () => {
367 assert.strictEqual(cborDecode(cborEncode('hello')), 'hello');
368 assert.strictEqual(cborDecode(cborEncode('')), '');
369 });
370
371 test('decodes arrays', () => {
372 assert.deepStrictEqual(cborDecode(cborEncode([1, 2, 3])), [1, 2, 3]);
373 assert.deepStrictEqual(cborDecode(cborEncode([])), []);
374 });
375
376 test('decodes nested structures', () => {
377 const original = { arr: [1, { nested: true }], str: 'test' };
378 const decoded = cborDecode(cborEncode(original));
379 assert.deepStrictEqual(decoded, original);
380 });
381});
382
383describe('CAR File Builder', () => {
384 test('varint encodes small numbers', () => {
385 assert.deepStrictEqual(varint(0), new Uint8Array([0]));
386 assert.deepStrictEqual(varint(1), new Uint8Array([1]));
387 assert.deepStrictEqual(varint(127), new Uint8Array([127]));
388 });
389
390 test('varint encodes multi-byte numbers', () => {
391 // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01]
392 assert.deepStrictEqual(varint(128), new Uint8Array([0x80, 0x01]));
393 // 300 = 0x12c -> [0xac, 0x02]
394 assert.deepStrictEqual(varint(300), new Uint8Array([0xac, 0x02]));
395 });
396
397 test('base32 encode/decode roundtrip', () => {
398 const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]);
399 const encoded = base32Encode(original);
400 const decoded = base32Decode(encoded);
401 assert.deepStrictEqual(decoded, original);
402 });
403
404 test('buildCarFile produces valid structure', async () => {
405 const data = cborEncode({ test: 'data' });
406 const cid = await createCid(data);
407 const cidStr = cidToString(cid);
408
409 const car = buildCarFile(cidStr, [{ cid: cidStr, data }]);
410
411 assert.ok(car instanceof Uint8Array);
412 assert.ok(car.length > 0);
413 // First byte should be varint of header length
414 assert.ok(car[0] > 0);
415 });
416});
417
418describe('JWT Base64URL', () => {
419 test('base64UrlEncode encodes bytes correctly', () => {
420 const input = new TextEncoder().encode('hello world');
421 const encoded = base64UrlEncode(input);
422 assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ');
423 assert.ok(!encoded.includes('+'));
424 assert.ok(!encoded.includes('/'));
425 assert.ok(!encoded.includes('='));
426 });
427
428 test('base64UrlDecode decodes string correctly', () => {
429 const decoded = base64UrlDecode('aGVsbG8gd29ybGQ');
430 const str = new TextDecoder().decode(decoded);
431 assert.strictEqual(str, 'hello world');
432 });
433
434 test('base64url roundtrip', () => {
435 const original = new Uint8Array([0, 1, 2, 255, 254, 253]);
436 const encoded = base64UrlEncode(original);
437 const decoded = base64UrlDecode(encoded);
438 assert.deepStrictEqual(decoded, original);
439 });
440});
441
442describe('JWT Creation', () => {
443 test('createAccessJwt creates valid JWT structure', async () => {
444 const did = 'did:web:test.example';
445 const secret = 'test-secret-key';
446 const jwt = await createAccessJwt(did, secret);
447
448 const parts = jwt.split('.');
449 assert.strictEqual(parts.length, 3);
450
451 // Decode header
452 const header = JSON.parse(
453 new TextDecoder().decode(base64UrlDecode(parts[0])),
454 );
455 assert.strictEqual(header.typ, 'at+jwt');
456 assert.strictEqual(header.alg, 'HS256');
457
458 // Decode payload
459 const payload = JSON.parse(
460 new TextDecoder().decode(base64UrlDecode(parts[1])),
461 );
462 assert.strictEqual(payload.scope, 'com.atproto.access');
463 assert.strictEqual(payload.sub, did);
464 assert.strictEqual(payload.aud, did);
465 assert.ok(payload.iat > 0);
466 assert.ok(payload.exp > payload.iat);
467 });
468
469 test('createRefreshJwt creates valid JWT with jti', async () => {
470 const did = 'did:web:test.example';
471 const secret = 'test-secret-key';
472 const jwt = await createRefreshJwt(did, secret);
473
474 const parts = jwt.split('.');
475 const header = JSON.parse(
476 new TextDecoder().decode(base64UrlDecode(parts[0])),
477 );
478 assert.strictEqual(header.typ, 'refresh+jwt');
479
480 const payload = JSON.parse(
481 new TextDecoder().decode(base64UrlDecode(parts[1])),
482 );
483 assert.strictEqual(payload.scope, 'com.atproto.refresh');
484 assert.ok(payload.jti); // has unique token ID
485 });
486});
487
488describe('JWT Verification', () => {
489 test('verifyAccessJwt returns payload for valid token', async () => {
490 const did = 'did:web:test.example';
491 const secret = 'test-secret-key';
492 const jwt = await createAccessJwt(did, secret);
493
494 const payload = await verifyAccessJwt(jwt, secret);
495 assert.strictEqual(payload.sub, did);
496 assert.strictEqual(payload.scope, 'com.atproto.access');
497 });
498
499 test('verifyAccessJwt throws for wrong secret', async () => {
500 const did = 'did:web:test.example';
501 const jwt = await createAccessJwt(did, 'correct-secret');
502
503 await assert.rejects(
504 () => verifyAccessJwt(jwt, 'wrong-secret'),
505 /invalid signature/i,
506 );
507 });
508
509 test('verifyAccessJwt throws for expired token', async () => {
510 const did = 'did:web:test.example';
511 const secret = 'test-secret-key';
512 // Create token that expired 1 second ago
513 const jwt = await createAccessJwt(did, secret, -1);
514
515 await assert.rejects(() => verifyAccessJwt(jwt, secret), /expired/i);
516 });
517
518 test('verifyAccessJwt throws for refresh token', async () => {
519 const did = 'did:web:test.example';
520 const secret = 'test-secret-key';
521 const jwt = await createRefreshJwt(did, secret);
522
523 await assert.rejects(
524 () => verifyAccessJwt(jwt, secret),
525 /invalid token type/i,
526 );
527 });
528
529 test('verifyRefreshJwt returns payload for valid token', async () => {
530 const did = 'did:web:test.example';
531 const secret = 'test-secret-key';
532 const jwt = await createRefreshJwt(did, secret);
533
534 const payload = await verifyRefreshJwt(jwt, secret);
535 assert.strictEqual(payload.sub, did);
536 assert.strictEqual(payload.scope, 'com.atproto.refresh');
537 assert.ok(payload.jti); // has token ID
538 });
539
540 test('verifyRefreshJwt throws for wrong secret', async () => {
541 const did = 'did:web:test.example';
542 const jwt = await createRefreshJwt(did, 'correct-secret');
543
544 await assert.rejects(
545 () => verifyRefreshJwt(jwt, 'wrong-secret'),
546 /invalid signature/i,
547 );
548 });
549
550 test('verifyRefreshJwt throws for expired token', async () => {
551 const did = 'did:web:test.example';
552 const secret = 'test-secret-key';
553 // Create token that expired 1 second ago
554 const jwt = await createRefreshJwt(did, secret, -1);
555
556 await assert.rejects(() => verifyRefreshJwt(jwt, secret), /expired/i);
557 });
558
559 test('verifyRefreshJwt throws for access token', async () => {
560 const did = 'did:web:test.example';
561 const secret = 'test-secret-key';
562 const jwt = await createAccessJwt(did, secret);
563
564 await assert.rejects(
565 () => verifyRefreshJwt(jwt, secret),
566 /invalid token type/i,
567 );
568 });
569
570 test('verifyAccessJwt throws for malformed JWT', async () => {
571 const secret = 'test-secret-key';
572
573 // Not a JWT at all
574 await assert.rejects(
575 () => verifyAccessJwt('not-a-jwt', secret),
576 /Invalid JWT format/i,
577 );
578
579 // Only two parts
580 await assert.rejects(
581 () => verifyAccessJwt('two.parts', secret),
582 /Invalid JWT format/i,
583 );
584
585 // Four parts
586 await assert.rejects(
587 () => verifyAccessJwt('one.two.three.four', secret),
588 /Invalid JWT format/i,
589 );
590 });
591
592 test('verifyRefreshJwt throws for malformed JWT', async () => {
593 const secret = 'test-secret-key';
594
595 await assert.rejects(
596 () => verifyRefreshJwt('not-a-jwt', secret),
597 /Invalid JWT format/i,
598 );
599
600 await assert.rejects(
601 () => verifyRefreshJwt('two.parts', secret),
602 /Invalid JWT format/i,
603 );
604 });
605});
606
607describe('MIME Type Sniffing', () => {
608 test('detects JPEG', () => {
609 const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
610 assert.strictEqual(sniffMimeType(bytes), 'image/jpeg');
611 });
612
613 test('detects PNG', () => {
614 const bytes = new Uint8Array([
615 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
616 ]);
617 assert.strictEqual(sniffMimeType(bytes), 'image/png');
618 });
619
620 test('detects GIF', () => {
621 const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
622 assert.strictEqual(sniffMimeType(bytes), 'image/gif');
623 });
624
625 test('detects WebP', () => {
626 const bytes = new Uint8Array([
627 0x52,
628 0x49,
629 0x46,
630 0x46, // RIFF
631 0x00,
632 0x00,
633 0x00,
634 0x00, // size (ignored)
635 0x57,
636 0x45,
637 0x42,
638 0x50, // WEBP
639 ]);
640 assert.strictEqual(sniffMimeType(bytes), 'image/webp');
641 });
642
643 test('detects MP4', () => {
644 const bytes = new Uint8Array([
645 0x00,
646 0x00,
647 0x00,
648 0x18, // size
649 0x66,
650 0x74,
651 0x79,
652 0x70, // ftyp
653 0x69,
654 0x73,
655 0x6f,
656 0x6d, // isom brand
657 ]);
658 assert.strictEqual(sniffMimeType(bytes), 'video/mp4');
659 });
660
661 test('detects AVIF', () => {
662 const bytes = new Uint8Array([
663 0x00,
664 0x00,
665 0x00,
666 0x1c, // size
667 0x66,
668 0x74,
669 0x79,
670 0x70, // ftyp
671 0x61,
672 0x76,
673 0x69,
674 0x66, // avif brand
675 ]);
676 assert.strictEqual(sniffMimeType(bytes), 'image/avif');
677 });
678
679 test('detects HEIC', () => {
680 const bytes = new Uint8Array([
681 0x00,
682 0x00,
683 0x00,
684 0x18, // size
685 0x66,
686 0x74,
687 0x79,
688 0x70, // ftyp
689 0x68,
690 0x65,
691 0x69,
692 0x63, // heic brand
693 ]);
694 assert.strictEqual(sniffMimeType(bytes), 'image/heic');
695 });
696
697 test('returns null for unknown', () => {
698 const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
699 assert.strictEqual(sniffMimeType(bytes), null);
700 });
701});
702
703describe('Blob Ref Detection', () => {
704 test('finds blob ref in simple object', () => {
705 const record = {
706 $type: 'app.bsky.feed.post',
707 text: 'Hello',
708 embed: {
709 $type: 'app.bsky.embed.images',
710 images: [
711 {
712 image: {
713 $type: 'blob',
714 ref: { $link: 'bafkreiabc123' },
715 mimeType: 'image/jpeg',
716 size: 1234,
717 },
718 alt: 'test image',
719 },
720 ],
721 },
722 };
723 const refs = findBlobRefs(record);
724 assert.deepStrictEqual(refs, ['bafkreiabc123']);
725 });
726
727 test('finds multiple blob refs', () => {
728 const record = {
729 images: [
730 {
731 image: {
732 $type: 'blob',
733 ref: { $link: 'cid1' },
734 mimeType: 'image/png',
735 size: 100,
736 },
737 },
738 {
739 image: {
740 $type: 'blob',
741 ref: { $link: 'cid2' },
742 mimeType: 'image/png',
743 size: 200,
744 },
745 },
746 ],
747 };
748 const refs = findBlobRefs(record);
749 assert.deepStrictEqual(refs, ['cid1', 'cid2']);
750 });
751
752 test('returns empty array when no blobs', () => {
753 const record = { text: 'Hello world', count: 42 };
754 const refs = findBlobRefs(record);
755 assert.deepStrictEqual(refs, []);
756 });
757
758 test('handles null and primitives', () => {
759 assert.deepStrictEqual(findBlobRefs(null), []);
760 assert.deepStrictEqual(findBlobRefs('string'), []);
761 assert.deepStrictEqual(findBlobRefs(42), []);
762 });
763});
764
765describe('JWK Thumbprint', () => {
766 test('computes deterministic thumbprint for EC key', async () => {
767 // Test vector: known JWK and its expected thumbprint
768 const jwk = {
769 kty: 'EC',
770 crv: 'P-256',
771 x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY',
772 y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ',
773 };
774
775 const jkt1 = await computeJwkThumbprint(jwk);
776 const jkt2 = await computeJwkThumbprint(jwk);
777
778 // Thumbprint must be deterministic
779 assert.strictEqual(jkt1, jkt2);
780 // Must be base64url-encoded SHA-256 (43 chars)
781 assert.strictEqual(jkt1.length, 43);
782 // Must only contain base64url characters
783 assert.match(jkt1, /^[A-Za-z0-9_-]+$/);
784 });
785
786 test('produces different thumbprints for different keys', async () => {
787 const jwk1 = {
788 kty: 'EC',
789 crv: 'P-256',
790 x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY',
791 y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ',
792 };
793 const jwk2 = {
794 kty: 'EC',
795 crv: 'P-256',
796 x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
797 y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
798 };
799
800 const jkt1 = await computeJwkThumbprint(jwk1);
801 const jkt2 = await computeJwkThumbprint(jwk2);
802
803 assert.notStrictEqual(jkt1, jkt2);
804 });
805});
806
807describe('Client Metadata', () => {
808 test('isLoopbackClient detects localhost', () => {
809 assert.strictEqual(isLoopbackClient('http://localhost:8080'), true);
810 assert.strictEqual(isLoopbackClient('http://127.0.0.1:3000'), true);
811 assert.strictEqual(isLoopbackClient('https://example.com'), false);
812 });
813
814 test('getLoopbackClientMetadata returns permissive defaults', () => {
815 const metadata = getLoopbackClientMetadata('http://localhost:8080');
816 assert.strictEqual(metadata.client_id, 'http://localhost:8080');
817 assert.ok(metadata.grant_types.includes('authorization_code'));
818 assert.strictEqual(metadata.dpop_bound_access_tokens, true);
819 });
820
821 test('validateClientMetadata rejects mismatched client_id', () => {
822 const metadata = {
823 client_id: 'https://other.com/metadata.json',
824 redirect_uris: ['https://example.com/callback'],
825 grant_types: ['authorization_code'],
826 response_types: ['code'],
827 };
828 assert.throws(
829 () =>
830 validateClientMetadata(metadata, 'https://example.com/metadata.json'),
831 /client_id mismatch/,
832 );
833 });
834});
835
836describe('Scope Parsing', () => {
837 describe('parseRepoScope', () => {
838 test('parses repo scope with query parameter action', () => {
839 const result = parseRepoScope('repo:app.bsky.feed.post?action=create');
840 assert.deepStrictEqual(result, {
841 collection: 'app.bsky.feed.post',
842 actions: ['create'],
843 });
844 });
845
846 test('parses repo scope with multiple query parameter actions', () => {
847 const result = parseRepoScope(
848 'repo:app.bsky.feed.post?action=create&action=update',
849 );
850 assert.deepStrictEqual(result, {
851 collection: 'app.bsky.feed.post',
852 actions: ['create', 'update'],
853 });
854 });
855
856 test('parses repo scope without actions as all actions', () => {
857 const result = parseRepoScope('repo:app.bsky.feed.post');
858 assert.deepStrictEqual(result, {
859 collection: 'app.bsky.feed.post',
860 actions: ['create', 'update', 'delete'],
861 });
862 });
863
864 test('parses wildcard collection with action', () => {
865 const result = parseRepoScope('repo:*?action=create');
866 assert.deepStrictEqual(result, {
867 collection: '*',
868 actions: ['create'],
869 });
870 });
871
872 test('parses query-only format', () => {
873 const result = parseRepoScope(
874 'repo?collection=app.bsky.feed.post&action=create',
875 );
876 assert.deepStrictEqual(result, {
877 collection: 'app.bsky.feed.post',
878 actions: ['create'],
879 });
880 });
881
882 test('deduplicates repeated actions', () => {
883 const result = parseRepoScope(
884 'repo:app.bsky.feed.post?action=create&action=create&action=update',
885 );
886 assert.deepStrictEqual(result, {
887 collection: 'app.bsky.feed.post',
888 actions: ['create', 'update'],
889 });
890 });
891
892 test('returns null for non-repo scope', () => {
893 assert.strictEqual(parseRepoScope('atproto'), null);
894 assert.strictEqual(parseRepoScope('blob:image/*'), null);
895 assert.strictEqual(parseRepoScope('transition:generic'), null);
896 });
897
898 test('returns null for invalid repo scope', () => {
899 assert.strictEqual(parseRepoScope('repo:'), null);
900 assert.strictEqual(parseRepoScope('repo?'), null);
901 });
902 });
903
904 describe('parseBlobScope', () => {
905 test('parses wildcard MIME', () => {
906 const result = parseBlobScope('blob:*/*');
907 assert.deepStrictEqual(result, { accept: ['*/*'] });
908 });
909
910 test('parses type wildcard', () => {
911 const result = parseBlobScope('blob:image/*');
912 assert.deepStrictEqual(result, { accept: ['image/*'] });
913 });
914
915 test('parses specific MIME', () => {
916 const result = parseBlobScope('blob:image/png');
917 assert.deepStrictEqual(result, { accept: ['image/png'] });
918 });
919
920 test('parses multiple MIMEs', () => {
921 const result = parseBlobScope('blob:image/png,image/jpeg');
922 assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] });
923 });
924
925 test('returns null for non-blob scope', () => {
926 assert.strictEqual(parseBlobScope('atproto'), null);
927 assert.strictEqual(parseBlobScope('repo:*:create'), null);
928 });
929 });
930
931 describe('matchesMime', () => {
932 test('wildcard matches everything', () => {
933 assert.strictEqual(matchesMime('*/*', 'image/png'), true);
934 assert.strictEqual(matchesMime('*/*', 'video/mp4'), true);
935 });
936
937 test('type wildcard matches same type', () => {
938 assert.strictEqual(matchesMime('image/*', 'image/png'), true);
939 assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true);
940 assert.strictEqual(matchesMime('image/*', 'video/mp4'), false);
941 });
942
943 test('exact match', () => {
944 assert.strictEqual(matchesMime('image/png', 'image/png'), true);
945 assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false);
946 });
947
948 test('case insensitive', () => {
949 assert.strictEqual(matchesMime('image/PNG', 'image/png'), true);
950 assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true);
951 });
952 });
953});
954
955describe('ScopePermissions', () => {
956 describe('static scopes', () => {
957 test('atproto grants full access', () => {
958 const perms = new ScopePermissions('atproto');
959 assert.strictEqual(
960 perms.allowsRepo('app.bsky.feed.post', 'create'),
961 true,
962 );
963 assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true);
964 assert.strictEqual(perms.allowsBlob('image/png'), true);
965 assert.strictEqual(perms.allowsBlob('video/mp4'), true);
966 });
967
968 test('transition:generic grants full repo/blob access', () => {
969 const perms = new ScopePermissions('transition:generic');
970 assert.strictEqual(
971 perms.allowsRepo('app.bsky.feed.post', 'create'),
972 true,
973 );
974 assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true);
975 assert.strictEqual(perms.allowsBlob('image/png'), true);
976 });
977 });
978
979 describe('repo scopes', () => {
980 test('wildcard collection allows any collection', () => {
981 const perms = new ScopePermissions('repo:*?action=create');
982 assert.strictEqual(
983 perms.allowsRepo('app.bsky.feed.post', 'create'),
984 true,
985 );
986 assert.strictEqual(
987 perms.allowsRepo('app.bsky.feed.like', 'create'),
988 true,
989 );
990 assert.strictEqual(
991 perms.allowsRepo('app.bsky.feed.post', 'delete'),
992 false,
993 );
994 });
995
996 test('specific collection restricts to that collection', () => {
997 const perms = new ScopePermissions(
998 'repo:app.bsky.feed.post?action=create',
999 );
1000 assert.strictEqual(
1001 perms.allowsRepo('app.bsky.feed.post', 'create'),
1002 true,
1003 );
1004 assert.strictEqual(
1005 perms.allowsRepo('app.bsky.feed.like', 'create'),
1006 false,
1007 );
1008 });
1009
1010 test('multiple actions', () => {
1011 const perms = new ScopePermissions('repo:*?action=create&action=update');
1012 assert.strictEqual(perms.allowsRepo('x', 'create'), true);
1013 assert.strictEqual(perms.allowsRepo('x', 'update'), true);
1014 assert.strictEqual(perms.allowsRepo('x', 'delete'), false);
1015 });
1016
1017 test('multiple scopes combine', () => {
1018 const perms = new ScopePermissions(
1019 'repo:app.bsky.feed.post?action=create repo:app.bsky.feed.like?action=delete',
1020 );
1021 assert.strictEqual(
1022 perms.allowsRepo('app.bsky.feed.post', 'create'),
1023 true,
1024 );
1025 assert.strictEqual(
1026 perms.allowsRepo('app.bsky.feed.like', 'delete'),
1027 true,
1028 );
1029 assert.strictEqual(
1030 perms.allowsRepo('app.bsky.feed.post', 'delete'),
1031 false,
1032 );
1033 });
1034
1035 test('allowsRepo with query param format scopes', () => {
1036 const perms = new ScopePermissions(
1037 'atproto repo:app.bsky.feed.post?action=create',
1038 );
1039 assert.strictEqual(
1040 perms.allowsRepo('app.bsky.feed.post', 'create'),
1041 true,
1042 );
1043 assert.strictEqual(
1044 perms.allowsRepo('app.bsky.feed.post', 'delete'),
1045 true,
1046 ); // atproto grants full access
1047 });
1048 });
1049
1050 describe('blob scopes', () => {
1051 test('wildcard allows any MIME', () => {
1052 const perms = new ScopePermissions('blob:*/*');
1053 assert.strictEqual(perms.allowsBlob('image/png'), true);
1054 assert.strictEqual(perms.allowsBlob('video/mp4'), true);
1055 });
1056
1057 test('type wildcard restricts to type', () => {
1058 const perms = new ScopePermissions('blob:image/*');
1059 assert.strictEqual(perms.allowsBlob('image/png'), true);
1060 assert.strictEqual(perms.allowsBlob('image/jpeg'), true);
1061 assert.strictEqual(perms.allowsBlob('video/mp4'), false);
1062 });
1063
1064 test('specific MIME restricts exactly', () => {
1065 const perms = new ScopePermissions('blob:image/png');
1066 assert.strictEqual(perms.allowsBlob('image/png'), true);
1067 assert.strictEqual(perms.allowsBlob('image/jpeg'), false);
1068 });
1069 });
1070
1071 describe('empty/no scope', () => {
1072 test('no scope denies everything', () => {
1073 const perms = new ScopePermissions('');
1074 assert.strictEqual(perms.allowsRepo('x', 'create'), false);
1075 assert.strictEqual(perms.allowsBlob('image/png'), false);
1076 });
1077
1078 test('undefined scope denies everything', () => {
1079 const perms = new ScopePermissions(undefined);
1080 assert.strictEqual(perms.allowsRepo('x', 'create'), false);
1081 });
1082 });
1083
1084 describe('assertRepo', () => {
1085 test('throws ScopeMissingError when denied', () => {
1086 const perms = new ScopePermissions(
1087 'repo:app.bsky.feed.post?action=create',
1088 );
1089 assert.throws(() => perms.assertRepo('app.bsky.feed.like', 'create'), {
1090 message: /Missing required scope/,
1091 });
1092 });
1093
1094 test('does not throw when allowed', () => {
1095 const perms = new ScopePermissions(
1096 'repo:app.bsky.feed.post?action=create',
1097 );
1098 assert.doesNotThrow(() =>
1099 perms.assertRepo('app.bsky.feed.post', 'create'),
1100 );
1101 });
1102 });
1103
1104 describe('assertBlob', () => {
1105 test('throws ScopeMissingError when denied', () => {
1106 const perms = new ScopePermissions('blob:image/*');
1107 assert.throws(() => perms.assertBlob('video/mp4'), {
1108 message: /Missing required scope/,
1109 });
1110 });
1111
1112 test('does not throw when allowed', () => {
1113 const perms = new ScopePermissions('blob:image/*');
1114 assert.doesNotThrow(() => perms.assertBlob('image/png'));
1115 });
1116 });
1117});
1118
1119describe('parseScopesForDisplay', () => {
1120 test('parses identity-only scope', () => {
1121 const result = parseScopesForDisplay('atproto');
1122 assert.strictEqual(result.hasAtproto, true);
1123 assert.strictEqual(result.hasTransitionGeneric, false);
1124 assert.strictEqual(result.repoPermissions.size, 0);
1125 assert.deepStrictEqual(result.blobPermissions, []);
1126 });
1127
1128 test('parses granular repo scopes', () => {
1129 const result = parseScopesForDisplay(
1130 'atproto repo:app.bsky.feed.post?action=create&action=update',
1131 );
1132 assert.strictEqual(result.repoPermissions.size, 1);
1133 const postPerms = result.repoPermissions.get('app.bsky.feed.post');
1134 assert.deepStrictEqual(postPerms, {
1135 create: true,
1136 update: true,
1137 delete: false,
1138 });
1139 });
1140
1141 test('merges multiple scopes for same collection', () => {
1142 const result = parseScopesForDisplay(
1143 'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete',
1144 );
1145 const postPerms = result.repoPermissions.get('app.bsky.feed.post');
1146 assert.deepStrictEqual(postPerms, {
1147 create: true,
1148 update: false,
1149 delete: true,
1150 });
1151 });
1152
1153 test('parses blob scopes', () => {
1154 const result = parseScopesForDisplay('atproto blob:image/*');
1155 assert.deepStrictEqual(result.blobPermissions, ['image/*']);
1156 });
1157
1158 test('detects transition:generic', () => {
1159 const result = parseScopesForDisplay('atproto transition:generic');
1160 assert.strictEqual(result.hasTransitionGeneric, true);
1161 });
1162
1163 test('handles empty scope string', () => {
1164 const result = parseScopesForDisplay('');
1165 assert.strictEqual(result.hasAtproto, false);
1166 assert.strictEqual(result.hasTransitionGeneric, false);
1167 assert.strictEqual(result.repoPermissions.size, 0);
1168 assert.deepStrictEqual(result.blobPermissions, []);
1169 });
1170});