A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds
at main 39 kB view raw
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(parseAtprotoProxyHeader('did:web:api.bsky.app'), null); 871 }); 872 873 test('returns null for header with only fragment', () => { 874 assert.strictEqual(parseAtprotoProxyHeader('#bsky_appview'), null); 875 }); 876 877 test('returns null for header with trailing fragment', () => { 878 assert.strictEqual( 879 parseAtprotoProxyHeader('did:web:api.bsky.app#'), 880 null, 881 ); 882 }); 883 }); 884 885 describe('getKnownServiceUrl', () => { 886 test('returns URL for known Bluesky AppView', () => { 887 const result = getKnownServiceUrl('did:web:api.bsky.app', 'bsky_appview'); 888 assert.strictEqual(result, BSKY_APPVIEW_URL); 889 }); 890 891 test('returns null for unknown service DID', () => { 892 const result = getKnownServiceUrl( 893 'did:web:unknown.service', 894 'bsky_appview', 895 ); 896 assert.strictEqual(result, null); 897 }); 898 899 test('returns null for unknown service ID', () => { 900 const result = getKnownServiceUrl( 901 'did:web:api.bsky.app', 902 'unknown_service', 903 ); 904 assert.strictEqual(result, null); 905 }); 906 907 test('returns null for both unknown', () => { 908 const result = getKnownServiceUrl('did:web:unknown', 'unknown'); 909 assert.strictEqual(result, null); 910 }); 911 }); 912}); 913 914describe('Scope Parsing', () => { 915 describe('parseRepoScope', () => { 916 test('parses repo scope with query parameter action', () => { 917 const result = parseRepoScope('repo:app.bsky.feed.post?action=create'); 918 assert.deepStrictEqual(result, { 919 collection: 'app.bsky.feed.post', 920 actions: ['create'], 921 }); 922 }); 923 924 test('parses repo scope with multiple query parameter actions', () => { 925 const result = parseRepoScope( 926 'repo:app.bsky.feed.post?action=create&action=update', 927 ); 928 assert.deepStrictEqual(result, { 929 collection: 'app.bsky.feed.post', 930 actions: ['create', 'update'], 931 }); 932 }); 933 934 test('parses repo scope without actions as all actions', () => { 935 const result = parseRepoScope('repo:app.bsky.feed.post'); 936 assert.deepStrictEqual(result, { 937 collection: 'app.bsky.feed.post', 938 actions: ['create', 'update', 'delete'], 939 }); 940 }); 941 942 test('parses wildcard collection with action', () => { 943 const result = parseRepoScope('repo:*?action=create'); 944 assert.deepStrictEqual(result, { 945 collection: '*', 946 actions: ['create'], 947 }); 948 }); 949 950 test('parses query-only format', () => { 951 const result = parseRepoScope( 952 'repo?collection=app.bsky.feed.post&action=create', 953 ); 954 assert.deepStrictEqual(result, { 955 collection: 'app.bsky.feed.post', 956 actions: ['create'], 957 }); 958 }); 959 960 test('deduplicates repeated actions', () => { 961 const result = parseRepoScope( 962 'repo:app.bsky.feed.post?action=create&action=create&action=update', 963 ); 964 assert.deepStrictEqual(result, { 965 collection: 'app.bsky.feed.post', 966 actions: ['create', 'update'], 967 }); 968 }); 969 970 test('returns null for non-repo scope', () => { 971 assert.strictEqual(parseRepoScope('atproto'), null); 972 assert.strictEqual(parseRepoScope('blob:image/*'), null); 973 assert.strictEqual(parseRepoScope('transition:generic'), null); 974 }); 975 976 test('returns null for invalid repo scope', () => { 977 assert.strictEqual(parseRepoScope('repo:'), null); 978 assert.strictEqual(parseRepoScope('repo?'), null); 979 }); 980 }); 981 982 describe('parseBlobScope', () => { 983 test('parses wildcard MIME', () => { 984 const result = parseBlobScope('blob:*/*'); 985 assert.deepStrictEqual(result, { accept: ['*/*'] }); 986 }); 987 988 test('parses type wildcard', () => { 989 const result = parseBlobScope('blob:image/*'); 990 assert.deepStrictEqual(result, { accept: ['image/*'] }); 991 }); 992 993 test('parses specific MIME', () => { 994 const result = parseBlobScope('blob:image/png'); 995 assert.deepStrictEqual(result, { accept: ['image/png'] }); 996 }); 997 998 test('parses multiple MIMEs', () => { 999 const result = parseBlobScope('blob:image/png,image/jpeg'); 1000 assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] }); 1001 }); 1002 1003 test('returns null for non-blob scope', () => { 1004 assert.strictEqual(parseBlobScope('atproto'), null); 1005 assert.strictEqual(parseBlobScope('repo:*:create'), null); 1006 }); 1007 }); 1008 1009 describe('matchesMime', () => { 1010 test('wildcard matches everything', () => { 1011 assert.strictEqual(matchesMime('*/*', 'image/png'), true); 1012 assert.strictEqual(matchesMime('*/*', 'video/mp4'), true); 1013 }); 1014 1015 test('type wildcard matches same type', () => { 1016 assert.strictEqual(matchesMime('image/*', 'image/png'), true); 1017 assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true); 1018 assert.strictEqual(matchesMime('image/*', 'video/mp4'), false); 1019 }); 1020 1021 test('exact match', () => { 1022 assert.strictEqual(matchesMime('image/png', 'image/png'), true); 1023 assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false); 1024 }); 1025 1026 test('case insensitive', () => { 1027 assert.strictEqual(matchesMime('image/PNG', 'image/png'), true); 1028 assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true); 1029 }); 1030 }); 1031}); 1032 1033describe('ScopePermissions', () => { 1034 describe('static scopes', () => { 1035 test('atproto grants full access', () => { 1036 const perms = new ScopePermissions('atproto'); 1037 assert.strictEqual( 1038 perms.allowsRepo('app.bsky.feed.post', 'create'), 1039 true, 1040 ); 1041 assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); 1042 assert.strictEqual(perms.allowsBlob('image/png'), true); 1043 assert.strictEqual(perms.allowsBlob('video/mp4'), true); 1044 }); 1045 1046 test('transition:generic grants full repo/blob access', () => { 1047 const perms = new ScopePermissions('transition:generic'); 1048 assert.strictEqual( 1049 perms.allowsRepo('app.bsky.feed.post', 'create'), 1050 true, 1051 ); 1052 assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); 1053 assert.strictEqual(perms.allowsBlob('image/png'), true); 1054 }); 1055 }); 1056 1057 describe('repo scopes', () => { 1058 test('wildcard collection allows any collection', () => { 1059 const perms = new ScopePermissions('repo:*?action=create'); 1060 assert.strictEqual( 1061 perms.allowsRepo('app.bsky.feed.post', 'create'), 1062 true, 1063 ); 1064 assert.strictEqual( 1065 perms.allowsRepo('app.bsky.feed.like', 'create'), 1066 true, 1067 ); 1068 assert.strictEqual( 1069 perms.allowsRepo('app.bsky.feed.post', 'delete'), 1070 false, 1071 ); 1072 }); 1073 1074 test('specific collection restricts to that collection', () => { 1075 const perms = new ScopePermissions( 1076 'repo:app.bsky.feed.post?action=create', 1077 ); 1078 assert.strictEqual( 1079 perms.allowsRepo('app.bsky.feed.post', 'create'), 1080 true, 1081 ); 1082 assert.strictEqual( 1083 perms.allowsRepo('app.bsky.feed.like', 'create'), 1084 false, 1085 ); 1086 }); 1087 1088 test('multiple actions', () => { 1089 const perms = new ScopePermissions('repo:*?action=create&action=update'); 1090 assert.strictEqual(perms.allowsRepo('x', 'create'), true); 1091 assert.strictEqual(perms.allowsRepo('x', 'update'), true); 1092 assert.strictEqual(perms.allowsRepo('x', 'delete'), false); 1093 }); 1094 1095 test('multiple scopes combine', () => { 1096 const perms = new ScopePermissions( 1097 'repo:app.bsky.feed.post?action=create repo:app.bsky.feed.like?action=delete', 1098 ); 1099 assert.strictEqual( 1100 perms.allowsRepo('app.bsky.feed.post', 'create'), 1101 true, 1102 ); 1103 assert.strictEqual( 1104 perms.allowsRepo('app.bsky.feed.like', 'delete'), 1105 true, 1106 ); 1107 assert.strictEqual( 1108 perms.allowsRepo('app.bsky.feed.post', 'delete'), 1109 false, 1110 ); 1111 }); 1112 1113 test('allowsRepo with query param format scopes', () => { 1114 const perms = new ScopePermissions( 1115 'atproto repo:app.bsky.feed.post?action=create', 1116 ); 1117 assert.strictEqual( 1118 perms.allowsRepo('app.bsky.feed.post', 'create'), 1119 true, 1120 ); 1121 assert.strictEqual( 1122 perms.allowsRepo('app.bsky.feed.post', 'delete'), 1123 true, 1124 ); // atproto grants full access 1125 }); 1126 }); 1127 1128 describe('blob scopes', () => { 1129 test('wildcard allows any MIME', () => { 1130 const perms = new ScopePermissions('blob:*/*'); 1131 assert.strictEqual(perms.allowsBlob('image/png'), true); 1132 assert.strictEqual(perms.allowsBlob('video/mp4'), true); 1133 }); 1134 1135 test('type wildcard restricts to type', () => { 1136 const perms = new ScopePermissions('blob:image/*'); 1137 assert.strictEqual(perms.allowsBlob('image/png'), true); 1138 assert.strictEqual(perms.allowsBlob('image/jpeg'), true); 1139 assert.strictEqual(perms.allowsBlob('video/mp4'), false); 1140 }); 1141 1142 test('specific MIME restricts exactly', () => { 1143 const perms = new ScopePermissions('blob:image/png'); 1144 assert.strictEqual(perms.allowsBlob('image/png'), true); 1145 assert.strictEqual(perms.allowsBlob('image/jpeg'), false); 1146 }); 1147 }); 1148 1149 describe('empty/no scope', () => { 1150 test('no scope denies everything', () => { 1151 const perms = new ScopePermissions(''); 1152 assert.strictEqual(perms.allowsRepo('x', 'create'), false); 1153 assert.strictEqual(perms.allowsBlob('image/png'), false); 1154 }); 1155 1156 test('undefined scope denies everything', () => { 1157 const perms = new ScopePermissions(undefined); 1158 assert.strictEqual(perms.allowsRepo('x', 'create'), false); 1159 }); 1160 }); 1161 1162 describe('assertRepo', () => { 1163 test('throws ScopeMissingError when denied', () => { 1164 const perms = new ScopePermissions( 1165 'repo:app.bsky.feed.post?action=create', 1166 ); 1167 assert.throws(() => perms.assertRepo('app.bsky.feed.like', 'create'), { 1168 message: /Missing required scope/, 1169 }); 1170 }); 1171 1172 test('does not throw when allowed', () => { 1173 const perms = new ScopePermissions( 1174 'repo:app.bsky.feed.post?action=create', 1175 ); 1176 assert.doesNotThrow(() => 1177 perms.assertRepo('app.bsky.feed.post', 'create'), 1178 ); 1179 }); 1180 }); 1181 1182 describe('assertBlob', () => { 1183 test('throws ScopeMissingError when denied', () => { 1184 const perms = new ScopePermissions('blob:image/*'); 1185 assert.throws(() => perms.assertBlob('video/mp4'), { 1186 message: /Missing required scope/, 1187 }); 1188 }); 1189 1190 test('does not throw when allowed', () => { 1191 const perms = new ScopePermissions('blob:image/*'); 1192 assert.doesNotThrow(() => perms.assertBlob('image/png')); 1193 }); 1194 }); 1195}); 1196 1197describe('parseScopesForDisplay', () => { 1198 test('parses identity-only scope', () => { 1199 const result = parseScopesForDisplay('atproto'); 1200 assert.strictEqual(result.hasAtproto, true); 1201 assert.strictEqual(result.hasTransitionGeneric, false); 1202 assert.strictEqual(result.repoPermissions.size, 0); 1203 assert.deepStrictEqual(result.blobPermissions, []); 1204 }); 1205 1206 test('parses granular repo scopes', () => { 1207 const result = parseScopesForDisplay( 1208 'atproto repo:app.bsky.feed.post?action=create&action=update', 1209 ); 1210 assert.strictEqual(result.repoPermissions.size, 1); 1211 const postPerms = result.repoPermissions.get('app.bsky.feed.post'); 1212 assert.deepStrictEqual(postPerms, { 1213 create: true, 1214 update: true, 1215 delete: false, 1216 }); 1217 }); 1218 1219 test('merges multiple scopes for same collection', () => { 1220 const result = parseScopesForDisplay( 1221 'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete', 1222 ); 1223 const postPerms = result.repoPermissions.get('app.bsky.feed.post'); 1224 assert.deepStrictEqual(postPerms, { 1225 create: true, 1226 update: false, 1227 delete: true, 1228 }); 1229 }); 1230 1231 test('parses blob scopes', () => { 1232 const result = parseScopesForDisplay('atproto blob:image/*'); 1233 assert.deepStrictEqual(result.blobPermissions, ['image/*']); 1234 }); 1235 1236 test('detects transition:generic', () => { 1237 const result = parseScopesForDisplay('atproto transition:generic'); 1238 assert.strictEqual(result.hasTransitionGeneric, true); 1239 }); 1240 1241 test('handles empty scope string', () => { 1242 const result = parseScopesForDisplay(''); 1243 assert.strictEqual(result.hasAtproto, false); 1244 assert.strictEqual(result.hasTransitionGeneric, false); 1245 assert.strictEqual(result.repoPermissions.size, 0); 1246 assert.deepStrictEqual(result.blobPermissions, []); 1247 }); 1248});