A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
46
fork

Configure Feed

Select the types of activity you want to include in your feed.

at d7017aec5670e8617606551c00af89e9f17d6585 1248 lines 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});