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