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});