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