import assert from 'node:assert'; import { describe, test } from 'node:test'; import { base32Decode, base32Encode, base64UrlDecode, base64UrlEncode, buildCarFile, bytesToHex, cborDecode, cborEncode, cidToString, computeJwkThumbprint, createAccessJwt, createBlobCid, createCid, createRefreshJwt, createTid, findBlobRefs, generateKeyPair, getKeyDepth, getKnownServiceUrl, getLoopbackClientMetadata, hexToBytes, importPrivateKey, isLoopbackClient, matchesMime, parseAtprotoProxyHeader, parseBlobScope, parseRepoScope, parseScopesForDisplay, ScopePermissions, sign, sniffMimeType, validateClientMetadata, varint, verifyAccessJwt, verifyRefreshJwt, } from '../src/pds.js'; // Internal constant - not exported from pds.js due to Cloudflare Workers limitation const BSKY_APPVIEW_URL = 'https://api.bsky.app'; describe('CBOR Encoding', () => { test('encodes simple map', () => { const encoded = cborEncode({ hello: 'world', num: 42 }); // Expected: a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a const expected = new Uint8Array([ 0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a, ]); assert.deepStrictEqual(encoded, expected); }); test('encodes null', () => { const encoded = cborEncode(null); assert.deepStrictEqual(encoded, new Uint8Array([0xf6])); }); test('encodes booleans', () => { assert.deepStrictEqual(cborEncode(true), new Uint8Array([0xf5])); assert.deepStrictEqual(cborEncode(false), new Uint8Array([0xf4])); }); test('encodes small integers', () => { assert.deepStrictEqual(cborEncode(0), new Uint8Array([0x00])); assert.deepStrictEqual(cborEncode(1), new Uint8Array([0x01])); assert.deepStrictEqual(cborEncode(23), new Uint8Array([0x17])); }); test('encodes integers >= 24', () => { assert.deepStrictEqual(cborEncode(24), new Uint8Array([0x18, 0x18])); assert.deepStrictEqual(cborEncode(255), new Uint8Array([0x18, 0xff])); }); test('encodes negative integers', () => { assert.deepStrictEqual(cborEncode(-1), new Uint8Array([0x20])); assert.deepStrictEqual(cborEncode(-10), new Uint8Array([0x29])); }); test('encodes strings', () => { const encoded = cborEncode('hello'); // 0x65 = text string of length 5 assert.deepStrictEqual( encoded, new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f]), ); }); test('encodes byte strings', () => { const bytes = new Uint8Array([1, 2, 3]); const encoded = cborEncode(bytes); // 0x43 = byte string of length 3 assert.deepStrictEqual(encoded, new Uint8Array([0x43, 1, 2, 3])); }); test('encodes arrays', () => { const encoded = cborEncode([1, 2, 3]); // 0x83 = array of length 3 assert.deepStrictEqual(encoded, new Uint8Array([0x83, 0x01, 0x02, 0x03])); }); test('sorts map keys deterministically', () => { const encoded1 = cborEncode({ z: 1, a: 2 }); const encoded2 = cborEncode({ a: 2, z: 1 }); assert.deepStrictEqual(encoded1, encoded2); // First key should be 'a' (0x61) assert.strictEqual(encoded1[1], 0x61); }); test('encodes large integers >= 2^31 without overflow', () => { // 2^31 would overflow with bitshift operators (treated as signed 32-bit) const twoTo31 = 2147483648; const encoded = cborEncode(twoTo31); const decoded = cborDecode(encoded); assert.strictEqual(decoded, twoTo31); // 2^32 - 1 (max unsigned 32-bit) const maxU32 = 4294967295; const encoded2 = cborEncode(maxU32); const decoded2 = cborDecode(encoded2); assert.strictEqual(decoded2, maxU32); }); test('encodes 2^31 with correct byte format', () => { // 2147483648 = 0x80000000 // CBOR: major type 0 (unsigned int), additional info 26 (4-byte follows) const encoded = cborEncode(2147483648); assert.strictEqual(encoded[0], 0x1a); // type 0 | info 26 assert.strictEqual(encoded[1], 0x80); assert.strictEqual(encoded[2], 0x00); assert.strictEqual(encoded[3], 0x00); assert.strictEqual(encoded[4], 0x00); }); }); describe('Base32 Encoding', () => { test('encodes bytes to base32lower', () => { const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20]); const encoded = base32Encode(bytes); assert.strictEqual(typeof encoded, 'string'); assert.match(encoded, /^[a-z2-7]+$/); }); }); describe('CID Generation', () => { test('createCid uses dag-cbor codec', async () => { const data = cborEncode({ test: 'data' }); const cid = await createCid(data); assert.strictEqual(cid.length, 36); // 2 prefix + 2 multihash header + 32 hash assert.strictEqual(cid[0], 0x01); // CIDv1 assert.strictEqual(cid[1], 0x71); // dag-cbor assert.strictEqual(cid[2], 0x12); // sha-256 assert.strictEqual(cid[3], 0x20); // 32 bytes }); test('createBlobCid uses raw codec', async () => { const data = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]); // JPEG magic bytes const cid = await createBlobCid(data); assert.strictEqual(cid.length, 36); assert.strictEqual(cid[0], 0x01); // CIDv1 assert.strictEqual(cid[1], 0x55); // raw codec assert.strictEqual(cid[2], 0x12); // sha-256 assert.strictEqual(cid[3], 0x20); // 32 bytes }); test('same bytes produce different CIDs with different codecs', async () => { const data = new Uint8Array([1, 2, 3, 4]); const dagCborCid = cidToString(await createCid(data)); const rawCid = cidToString(await createBlobCid(data)); assert.notStrictEqual(dagCborCid, rawCid); }); test('cidToString returns base32lower with b prefix', async () => { const data = cborEncode({ test: 'data' }); const cid = await createCid(data); const cidStr = cidToString(cid); assert.strictEqual(cidStr[0], 'b'); assert.match(cidStr, /^b[a-z2-7]+$/); }); test('same input produces same CID', async () => { const data1 = cborEncode({ test: 'data' }); const data2 = cborEncode({ test: 'data' }); const cid1 = cidToString(await createCid(data1)); const cid2 = cidToString(await createCid(data2)); assert.strictEqual(cid1, cid2); }); test('different input produces different CID', async () => { const cid1 = cidToString(await createCid(cborEncode({ a: 1 }))); const cid2 = cidToString(await createCid(cborEncode({ a: 2 }))); assert.notStrictEqual(cid1, cid2); }); }); describe('TID Generation', () => { test('creates 13-character TIDs', () => { const tid = createTid(); assert.strictEqual(tid.length, 13); }); test('uses valid base32-sort characters', () => { const tid = createTid(); assert.match(tid, /^[234567abcdefghijklmnopqrstuvwxyz]+$/); }); test('generates monotonically increasing TIDs', () => { const tid1 = createTid(); const tid2 = createTid(); const tid3 = createTid(); assert.ok(tid1 < tid2, `${tid1} should be less than ${tid2}`); assert.ok(tid2 < tid3, `${tid2} should be less than ${tid3}`); }); test('generates unique TIDs', () => { const tids = new Set(); for (let i = 0; i < 100; i++) { tids.add(createTid()); } assert.strictEqual(tids.size, 100); }); }); describe('P-256 Signing', () => { test('generates key pair with correct sizes', async () => { const kp = await generateKeyPair(); assert.strictEqual(kp.privateKey.length, 32); assert.strictEqual(kp.publicKey.length, 33); // compressed assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03); }); test('can sign data with generated key', async () => { const kp = await generateKeyPair(); const key = await importPrivateKey(kp.privateKey); const data = new TextEncoder().encode('test message'); const sig = await sign(key, data); assert.strictEqual(sig.length, 64); // r (32) + s (32) }); test('different messages produce different signatures', async () => { const kp = await generateKeyPair(); const key = await importPrivateKey(kp.privateKey); const sig1 = await sign(key, new TextEncoder().encode('message 1')); const sig2 = await sign(key, new TextEncoder().encode('message 2')); assert.notDeepStrictEqual(sig1, sig2); }); test('bytesToHex and hexToBytes roundtrip', () => { const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd]); const hex = bytesToHex(original); const back = hexToBytes(hex); assert.strictEqual(hex, '000ff0ffabcd'); assert.deepStrictEqual(back, original); }); test('importPrivateKey rejects invalid key lengths', async () => { // Too short await assert.rejects( () => importPrivateKey(new Uint8Array(31)), /expected 32 bytes, got 31/, ); // Too long await assert.rejects( () => importPrivateKey(new Uint8Array(33)), /expected 32 bytes, got 33/, ); // Empty await assert.rejects( () => importPrivateKey(new Uint8Array(0)), /expected 32 bytes, got 0/, ); }); test('importPrivateKey rejects non-Uint8Array input', async () => { // Arrays have .length but aren't Uint8Array await assert.rejects( () => importPrivateKey([1, 2, 3]), /Invalid private key/, ); // Strings don't work either await assert.rejects( () => importPrivateKey('not bytes'), /Invalid private key/, ); // null/undefined await assert.rejects(() => importPrivateKey(null), /Invalid private key/); }); }); describe('MST Key Depth', () => { test('returns a non-negative integer', async () => { const depth = await getKeyDepth('app.bsky.feed.post/abc123'); assert.strictEqual(typeof depth, 'number'); assert.ok(depth >= 0); }); test('is deterministic for same key', async () => { const key = 'app.bsky.feed.post/test123'; const depth1 = await getKeyDepth(key); const depth2 = await getKeyDepth(key); assert.strictEqual(depth1, depth2); }); test('different keys can have different depths', async () => { // Generate many keys and check we get some variation const depths = new Set(); for (let i = 0; i < 100; i++) { depths.add(await getKeyDepth(`collection/key${i}`)); } // Should have at least 1 unique depth (realistically more) assert.ok(depths.size >= 1); }); test('handles empty string', async () => { const depth = await getKeyDepth(''); assert.strictEqual(typeof depth, 'number'); assert.ok(depth >= 0); }); test('handles unicode strings', async () => { const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉'); assert.strictEqual(typeof depth, 'number'); assert.ok(depth >= 0); }); }); describe('CBOR Decoding', () => { test('decodes what encode produces (roundtrip)', () => { const original = { hello: 'world', num: 42 }; const encoded = cborEncode(original); const decoded = cborDecode(encoded); assert.deepStrictEqual(decoded, original); }); test('decodes null', () => { const encoded = cborEncode(null); const decoded = cborDecode(encoded); assert.strictEqual(decoded, null); }); test('decodes booleans', () => { assert.strictEqual(cborDecode(cborEncode(true)), true); assert.strictEqual(cborDecode(cborEncode(false)), false); }); test('decodes integers', () => { assert.strictEqual(cborDecode(cborEncode(0)), 0); assert.strictEqual(cborDecode(cborEncode(42)), 42); assert.strictEqual(cborDecode(cborEncode(255)), 255); assert.strictEqual(cborDecode(cborEncode(-1)), -1); assert.strictEqual(cborDecode(cborEncode(-10)), -10); }); test('decodes strings', () => { assert.strictEqual(cborDecode(cborEncode('hello')), 'hello'); assert.strictEqual(cborDecode(cborEncode('')), ''); }); test('decodes arrays', () => { assert.deepStrictEqual(cborDecode(cborEncode([1, 2, 3])), [1, 2, 3]); assert.deepStrictEqual(cborDecode(cborEncode([])), []); }); test('decodes nested structures', () => { const original = { arr: [1, { nested: true }], str: 'test' }; const decoded = cborDecode(cborEncode(original)); assert.deepStrictEqual(decoded, original); }); }); describe('CAR File Builder', () => { test('varint encodes small numbers', () => { assert.deepStrictEqual(varint(0), new Uint8Array([0])); assert.deepStrictEqual(varint(1), new Uint8Array([1])); assert.deepStrictEqual(varint(127), new Uint8Array([127])); }); test('varint encodes multi-byte numbers', () => { // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01] assert.deepStrictEqual(varint(128), new Uint8Array([0x80, 0x01])); // 300 = 0x12c -> [0xac, 0x02] assert.deepStrictEqual(varint(300), new Uint8Array([0xac, 0x02])); }); test('base32 encode/decode roundtrip', () => { const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd]); const encoded = base32Encode(original); const decoded = base32Decode(encoded); assert.deepStrictEqual(decoded, original); }); test('buildCarFile produces valid structure', async () => { const data = cborEncode({ test: 'data' }); const cid = await createCid(data); const cidStr = cidToString(cid); const car = buildCarFile(cidStr, [{ cid: cidStr, data }]); assert.ok(car instanceof Uint8Array); assert.ok(car.length > 0); // First byte should be varint of header length assert.ok(car[0] > 0); }); }); describe('JWT Base64URL', () => { test('base64UrlEncode encodes bytes correctly', () => { const input = new TextEncoder().encode('hello world'); const encoded = base64UrlEncode(input); assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ'); assert.ok(!encoded.includes('+')); assert.ok(!encoded.includes('/')); assert.ok(!encoded.includes('=')); }); test('base64UrlDecode decodes string correctly', () => { const decoded = base64UrlDecode('aGVsbG8gd29ybGQ'); const str = new TextDecoder().decode(decoded); assert.strictEqual(str, 'hello world'); }); test('base64url roundtrip', () => { const original = new Uint8Array([0, 1, 2, 255, 254, 253]); const encoded = base64UrlEncode(original); const decoded = base64UrlDecode(encoded); assert.deepStrictEqual(decoded, original); }); }); describe('JWT Creation', () => { test('createAccessJwt creates valid JWT structure', async () => { const did = 'did:web:test.example'; const secret = 'test-secret-key'; const jwt = await createAccessJwt(did, secret); const parts = jwt.split('.'); assert.strictEqual(parts.length, 3); // Decode header const header = JSON.parse( new TextDecoder().decode(base64UrlDecode(parts[0])), ); assert.strictEqual(header.typ, 'at+jwt'); assert.strictEqual(header.alg, 'HS256'); // Decode payload const payload = JSON.parse( new TextDecoder().decode(base64UrlDecode(parts[1])), ); assert.strictEqual(payload.scope, 'com.atproto.access'); assert.strictEqual(payload.sub, did); assert.strictEqual(payload.aud, did); assert.ok(payload.iat > 0); assert.ok(payload.exp > payload.iat); }); test('createRefreshJwt creates valid JWT with jti', async () => { const did = 'did:web:test.example'; const secret = 'test-secret-key'; const jwt = await createRefreshJwt(did, secret); const parts = jwt.split('.'); const header = JSON.parse( new TextDecoder().decode(base64UrlDecode(parts[0])), ); assert.strictEqual(header.typ, 'refresh+jwt'); const payload = JSON.parse( new TextDecoder().decode(base64UrlDecode(parts[1])), ); assert.strictEqual(payload.scope, 'com.atproto.refresh'); assert.ok(payload.jti); // has unique token ID }); }); describe('JWT Verification', () => { test('verifyAccessJwt returns payload for valid token', async () => { const did = 'did:web:test.example'; const secret = 'test-secret-key'; const jwt = await createAccessJwt(did, secret); const payload = await verifyAccessJwt(jwt, secret); assert.strictEqual(payload.sub, did); assert.strictEqual(payload.scope, 'com.atproto.access'); }); test('verifyAccessJwt throws for wrong secret', async () => { const did = 'did:web:test.example'; const jwt = await createAccessJwt(did, 'correct-secret'); await assert.rejects( () => verifyAccessJwt(jwt, 'wrong-secret'), /invalid signature/i, ); }); test('verifyAccessJwt throws for expired token', async () => { const did = 'did:web:test.example'; const secret = 'test-secret-key'; // Create token that expired 1 second ago const jwt = await createAccessJwt(did, secret, -1); await assert.rejects(() => verifyAccessJwt(jwt, secret), /expired/i); }); test('verifyAccessJwt throws for refresh token', async () => { const did = 'did:web:test.example'; const secret = 'test-secret-key'; const jwt = await createRefreshJwt(did, secret); await assert.rejects( () => verifyAccessJwt(jwt, secret), /invalid token type/i, ); }); test('verifyRefreshJwt returns payload for valid token', async () => { const did = 'did:web:test.example'; const secret = 'test-secret-key'; const jwt = await createRefreshJwt(did, secret); const payload = await verifyRefreshJwt(jwt, secret); assert.strictEqual(payload.sub, did); assert.strictEqual(payload.scope, 'com.atproto.refresh'); assert.ok(payload.jti); // has token ID }); test('verifyRefreshJwt throws for wrong secret', async () => { const did = 'did:web:test.example'; const jwt = await createRefreshJwt(did, 'correct-secret'); await assert.rejects( () => verifyRefreshJwt(jwt, 'wrong-secret'), /invalid signature/i, ); }); test('verifyRefreshJwt throws for expired token', async () => { const did = 'did:web:test.example'; const secret = 'test-secret-key'; // Create token that expired 1 second ago const jwt = await createRefreshJwt(did, secret, -1); await assert.rejects(() => verifyRefreshJwt(jwt, secret), /expired/i); }); test('verifyRefreshJwt throws for access token', async () => { const did = 'did:web:test.example'; const secret = 'test-secret-key'; const jwt = await createAccessJwt(did, secret); await assert.rejects( () => verifyRefreshJwt(jwt, secret), /invalid token type/i, ); }); test('verifyAccessJwt throws for malformed JWT', async () => { const secret = 'test-secret-key'; // Not a JWT at all await assert.rejects( () => verifyAccessJwt('not-a-jwt', secret), /Invalid JWT format/i, ); // Only two parts await assert.rejects( () => verifyAccessJwt('two.parts', secret), /Invalid JWT format/i, ); // Four parts await assert.rejects( () => verifyAccessJwt('one.two.three.four', secret), /Invalid JWT format/i, ); }); test('verifyRefreshJwt throws for malformed JWT', async () => { const secret = 'test-secret-key'; await assert.rejects( () => verifyRefreshJwt('not-a-jwt', secret), /Invalid JWT format/i, ); await assert.rejects( () => verifyRefreshJwt('two.parts', secret), /Invalid JWT format/i, ); }); }); describe('MIME Type Sniffing', () => { test('detects JPEG', () => { const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); assert.strictEqual(sniffMimeType(bytes), 'image/jpeg'); }); test('detects PNG', () => { const bytes = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]); assert.strictEqual(sniffMimeType(bytes), 'image/png'); }); test('detects GIF', () => { const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); assert.strictEqual(sniffMimeType(bytes), 'image/gif'); }); test('detects WebP', () => { const bytes = new Uint8Array([ 0x52, 0x49, 0x46, 0x46, // RIFF 0x00, 0x00, 0x00, 0x00, // size (ignored) 0x57, 0x45, 0x42, 0x50, // WEBP ]); assert.strictEqual(sniffMimeType(bytes), 'image/webp'); }); test('detects MP4', () => { const bytes = new Uint8Array([ 0x00, 0x00, 0x00, 0x18, // size 0x66, 0x74, 0x79, 0x70, // ftyp 0x69, 0x73, 0x6f, 0x6d, // isom brand ]); assert.strictEqual(sniffMimeType(bytes), 'video/mp4'); }); test('detects AVIF', () => { const bytes = new Uint8Array([ 0x00, 0x00, 0x00, 0x1c, // size 0x66, 0x74, 0x79, 0x70, // ftyp 0x61, 0x76, 0x69, 0x66, // avif brand ]); assert.strictEqual(sniffMimeType(bytes), 'image/avif'); }); test('detects HEIC', () => { const bytes = new Uint8Array([ 0x00, 0x00, 0x00, 0x18, // size 0x66, 0x74, 0x79, 0x70, // ftyp 0x68, 0x65, 0x69, 0x63, // heic brand ]); assert.strictEqual(sniffMimeType(bytes), 'image/heic'); }); test('returns null for unknown', () => { const bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]); assert.strictEqual(sniffMimeType(bytes), null); }); }); describe('Blob Ref Detection', () => { test('finds blob ref in simple object', () => { const record = { $type: 'app.bsky.feed.post', text: 'Hello', embed: { $type: 'app.bsky.embed.images', images: [ { image: { $type: 'blob', ref: { $link: 'bafkreiabc123' }, mimeType: 'image/jpeg', size: 1234, }, alt: 'test image', }, ], }, }; const refs = findBlobRefs(record); assert.deepStrictEqual(refs, ['bafkreiabc123']); }); test('finds multiple blob refs', () => { const record = { images: [ { image: { $type: 'blob', ref: { $link: 'cid1' }, mimeType: 'image/png', size: 100, }, }, { image: { $type: 'blob', ref: { $link: 'cid2' }, mimeType: 'image/png', size: 200, }, }, ], }; const refs = findBlobRefs(record); assert.deepStrictEqual(refs, ['cid1', 'cid2']); }); test('returns empty array when no blobs', () => { const record = { text: 'Hello world', count: 42 }; const refs = findBlobRefs(record); assert.deepStrictEqual(refs, []); }); test('handles null and primitives', () => { assert.deepStrictEqual(findBlobRefs(null), []); assert.deepStrictEqual(findBlobRefs('string'), []); assert.deepStrictEqual(findBlobRefs(42), []); }); }); describe('JWK Thumbprint', () => { test('computes deterministic thumbprint for EC key', async () => { // Test vector: known JWK and its expected thumbprint const jwk = { kty: 'EC', crv: 'P-256', x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ', }; const jkt1 = await computeJwkThumbprint(jwk); const jkt2 = await computeJwkThumbprint(jwk); // Thumbprint must be deterministic assert.strictEqual(jkt1, jkt2); // Must be base64url-encoded SHA-256 (43 chars) assert.strictEqual(jkt1.length, 43); // Must only contain base64url characters assert.match(jkt1, /^[A-Za-z0-9_-]+$/); }); test('produces different thumbprints for different keys', async () => { const jwk1 = { kty: 'EC', crv: 'P-256', x: 'WbbCfHGZ9QtKsVuMdPZ8hBbP2949N_CSLG3LVV0nnKY', y: 'eSgPlDj0RVMw8t8u4MvCYG4j_JfDwvrMUUwEEHVLmqQ', }; const jwk2 = { kty: 'EC', crv: 'P-256', x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', }; const jkt1 = await computeJwkThumbprint(jwk1); const jkt2 = await computeJwkThumbprint(jwk2); assert.notStrictEqual(jkt1, jkt2); }); }); describe('Client Metadata', () => { test('isLoopbackClient detects localhost', () => { assert.strictEqual(isLoopbackClient('http://localhost:8080'), true); assert.strictEqual(isLoopbackClient('http://127.0.0.1:3000'), true); assert.strictEqual(isLoopbackClient('https://example.com'), false); }); test('getLoopbackClientMetadata returns permissive defaults', () => { const metadata = getLoopbackClientMetadata('http://localhost:8080'); assert.strictEqual(metadata.client_id, 'http://localhost:8080'); assert.ok(metadata.grant_types.includes('authorization_code')); assert.strictEqual(metadata.dpop_bound_access_tokens, true); }); test('validateClientMetadata rejects mismatched client_id', () => { const metadata = { client_id: 'https://other.com/metadata.json', redirect_uris: ['https://example.com/callback'], grant_types: ['authorization_code'], response_types: ['code'], }; assert.throws( () => validateClientMetadata(metadata, 'https://example.com/metadata.json'), /client_id mismatch/, ); }); }); describe('Proxy Utilities', () => { describe('parseAtprotoProxyHeader', () => { test('parses valid header', () => { const result = parseAtprotoProxyHeader( 'did:web:api.bsky.app#bsky_appview', ); assert.deepStrictEqual(result, { did: 'did:web:api.bsky.app', serviceId: 'bsky_appview', }); }); test('parses header with did:plc', () => { const result = parseAtprotoProxyHeader( 'did:plc:z72i7hdynmk6r22z27h6tvur#atproto_labeler', ); assert.deepStrictEqual(result, { did: 'did:plc:z72i7hdynmk6r22z27h6tvur', serviceId: 'atproto_labeler', }); }); test('returns null for null/undefined', () => { assert.strictEqual(parseAtprotoProxyHeader(null), null); assert.strictEqual(parseAtprotoProxyHeader(undefined), null); assert.strictEqual(parseAtprotoProxyHeader(''), null); }); test('returns null for header without fragment', () => { assert.strictEqual(parseAtprotoProxyHeader('did:web:api.bsky.app'), null); }); test('returns null for header with only fragment', () => { assert.strictEqual(parseAtprotoProxyHeader('#bsky_appview'), null); }); test('returns null for header with trailing fragment', () => { assert.strictEqual( parseAtprotoProxyHeader('did:web:api.bsky.app#'), null, ); }); }); describe('getKnownServiceUrl', () => { test('returns URL for known Bluesky AppView', () => { const result = getKnownServiceUrl('did:web:api.bsky.app', 'bsky_appview'); assert.strictEqual(result, BSKY_APPVIEW_URL); }); test('returns null for unknown service DID', () => { const result = getKnownServiceUrl( 'did:web:unknown.service', 'bsky_appview', ); assert.strictEqual(result, null); }); test('returns null for unknown service ID', () => { const result = getKnownServiceUrl( 'did:web:api.bsky.app', 'unknown_service', ); assert.strictEqual(result, null); }); test('returns null for both unknown', () => { const result = getKnownServiceUrl('did:web:unknown', 'unknown'); assert.strictEqual(result, null); }); }); }); describe('Scope Parsing', () => { describe('parseRepoScope', () => { test('parses repo scope with query parameter action', () => { const result = parseRepoScope('repo:app.bsky.feed.post?action=create'); assert.deepStrictEqual(result, { collection: 'app.bsky.feed.post', actions: ['create'], }); }); test('parses repo scope with multiple query parameter actions', () => { const result = parseRepoScope( 'repo:app.bsky.feed.post?action=create&action=update', ); assert.deepStrictEqual(result, { collection: 'app.bsky.feed.post', actions: ['create', 'update'], }); }); test('parses repo scope without actions as all actions', () => { const result = parseRepoScope('repo:app.bsky.feed.post'); assert.deepStrictEqual(result, { collection: 'app.bsky.feed.post', actions: ['create', 'update', 'delete'], }); }); test('parses wildcard collection with action', () => { const result = parseRepoScope('repo:*?action=create'); assert.deepStrictEqual(result, { collection: '*', actions: ['create'], }); }); test('parses query-only format', () => { const result = parseRepoScope( 'repo?collection=app.bsky.feed.post&action=create', ); assert.deepStrictEqual(result, { collection: 'app.bsky.feed.post', actions: ['create'], }); }); test('deduplicates repeated actions', () => { const result = parseRepoScope( 'repo:app.bsky.feed.post?action=create&action=create&action=update', ); assert.deepStrictEqual(result, { collection: 'app.bsky.feed.post', actions: ['create', 'update'], }); }); test('returns null for non-repo scope', () => { assert.strictEqual(parseRepoScope('atproto'), null); assert.strictEqual(parseRepoScope('blob:image/*'), null); assert.strictEqual(parseRepoScope('transition:generic'), null); }); test('returns null for invalid repo scope', () => { assert.strictEqual(parseRepoScope('repo:'), null); assert.strictEqual(parseRepoScope('repo?'), null); }); }); describe('parseBlobScope', () => { test('parses wildcard MIME', () => { const result = parseBlobScope('blob:*/*'); assert.deepStrictEqual(result, { accept: ['*/*'] }); }); test('parses type wildcard', () => { const result = parseBlobScope('blob:image/*'); assert.deepStrictEqual(result, { accept: ['image/*'] }); }); test('parses specific MIME', () => { const result = parseBlobScope('blob:image/png'); assert.deepStrictEqual(result, { accept: ['image/png'] }); }); test('parses multiple MIMEs', () => { const result = parseBlobScope('blob:image/png,image/jpeg'); assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] }); }); test('returns null for non-blob scope', () => { assert.strictEqual(parseBlobScope('atproto'), null); assert.strictEqual(parseBlobScope('repo:*:create'), null); }); }); describe('matchesMime', () => { test('wildcard matches everything', () => { assert.strictEqual(matchesMime('*/*', 'image/png'), true); assert.strictEqual(matchesMime('*/*', 'video/mp4'), true); }); test('type wildcard matches same type', () => { assert.strictEqual(matchesMime('image/*', 'image/png'), true); assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true); assert.strictEqual(matchesMime('image/*', 'video/mp4'), false); }); test('exact match', () => { assert.strictEqual(matchesMime('image/png', 'image/png'), true); assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false); }); test('case insensitive', () => { assert.strictEqual(matchesMime('image/PNG', 'image/png'), true); assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true); }); }); }); describe('ScopePermissions', () => { describe('static scopes', () => { test('atproto grants full access', () => { const perms = new ScopePermissions('atproto'); assert.strictEqual( perms.allowsRepo('app.bsky.feed.post', 'create'), true, ); assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); assert.strictEqual(perms.allowsBlob('image/png'), true); assert.strictEqual(perms.allowsBlob('video/mp4'), true); }); test('transition:generic grants full repo/blob access', () => { const perms = new ScopePermissions('transition:generic'); assert.strictEqual( perms.allowsRepo('app.bsky.feed.post', 'create'), true, ); assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); assert.strictEqual(perms.allowsBlob('image/png'), true); }); }); describe('repo scopes', () => { test('wildcard collection allows any collection', () => { const perms = new ScopePermissions('repo:*?action=create'); assert.strictEqual( perms.allowsRepo('app.bsky.feed.post', 'create'), true, ); assert.strictEqual( perms.allowsRepo('app.bsky.feed.like', 'create'), true, ); assert.strictEqual( perms.allowsRepo('app.bsky.feed.post', 'delete'), false, ); }); test('specific collection restricts to that collection', () => { const perms = new ScopePermissions( 'repo:app.bsky.feed.post?action=create', ); assert.strictEqual( perms.allowsRepo('app.bsky.feed.post', 'create'), true, ); assert.strictEqual( perms.allowsRepo('app.bsky.feed.like', 'create'), false, ); }); test('multiple actions', () => { const perms = new ScopePermissions('repo:*?action=create&action=update'); assert.strictEqual(perms.allowsRepo('x', 'create'), true); assert.strictEqual(perms.allowsRepo('x', 'update'), true); assert.strictEqual(perms.allowsRepo('x', 'delete'), false); }); test('multiple scopes combine', () => { const perms = new ScopePermissions( 'repo:app.bsky.feed.post?action=create repo:app.bsky.feed.like?action=delete', ); assert.strictEqual( perms.allowsRepo('app.bsky.feed.post', 'create'), true, ); assert.strictEqual( perms.allowsRepo('app.bsky.feed.like', 'delete'), true, ); assert.strictEqual( perms.allowsRepo('app.bsky.feed.post', 'delete'), false, ); }); test('allowsRepo with query param format scopes', () => { const perms = new ScopePermissions( 'atproto repo:app.bsky.feed.post?action=create', ); assert.strictEqual( perms.allowsRepo('app.bsky.feed.post', 'create'), true, ); assert.strictEqual( perms.allowsRepo('app.bsky.feed.post', 'delete'), true, ); // atproto grants full access }); }); describe('blob scopes', () => { test('wildcard allows any MIME', () => { const perms = new ScopePermissions('blob:*/*'); assert.strictEqual(perms.allowsBlob('image/png'), true); assert.strictEqual(perms.allowsBlob('video/mp4'), true); }); test('type wildcard restricts to type', () => { const perms = new ScopePermissions('blob:image/*'); assert.strictEqual(perms.allowsBlob('image/png'), true); assert.strictEqual(perms.allowsBlob('image/jpeg'), true); assert.strictEqual(perms.allowsBlob('video/mp4'), false); }); test('specific MIME restricts exactly', () => { const perms = new ScopePermissions('blob:image/png'); assert.strictEqual(perms.allowsBlob('image/png'), true); assert.strictEqual(perms.allowsBlob('image/jpeg'), false); }); }); describe('empty/no scope', () => { test('no scope denies everything', () => { const perms = new ScopePermissions(''); assert.strictEqual(perms.allowsRepo('x', 'create'), false); assert.strictEqual(perms.allowsBlob('image/png'), false); }); test('undefined scope denies everything', () => { const perms = new ScopePermissions(undefined); assert.strictEqual(perms.allowsRepo('x', 'create'), false); }); }); describe('assertRepo', () => { test('throws ScopeMissingError when denied', () => { const perms = new ScopePermissions( 'repo:app.bsky.feed.post?action=create', ); assert.throws(() => perms.assertRepo('app.bsky.feed.like', 'create'), { message: /Missing required scope/, }); }); test('does not throw when allowed', () => { const perms = new ScopePermissions( 'repo:app.bsky.feed.post?action=create', ); assert.doesNotThrow(() => perms.assertRepo('app.bsky.feed.post', 'create'), ); }); }); describe('assertBlob', () => { test('throws ScopeMissingError when denied', () => { const perms = new ScopePermissions('blob:image/*'); assert.throws(() => perms.assertBlob('video/mp4'), { message: /Missing required scope/, }); }); test('does not throw when allowed', () => { const perms = new ScopePermissions('blob:image/*'); assert.doesNotThrow(() => perms.assertBlob('image/png')); }); }); }); describe('parseScopesForDisplay', () => { test('parses identity-only scope', () => { const result = parseScopesForDisplay('atproto'); assert.strictEqual(result.hasAtproto, true); assert.strictEqual(result.hasTransitionGeneric, false); assert.strictEqual(result.repoPermissions.size, 0); assert.deepStrictEqual(result.blobPermissions, []); }); test('parses granular repo scopes', () => { const result = parseScopesForDisplay( 'atproto repo:app.bsky.feed.post?action=create&action=update', ); assert.strictEqual(result.repoPermissions.size, 1); const postPerms = result.repoPermissions.get('app.bsky.feed.post'); assert.deepStrictEqual(postPerms, { create: true, update: true, delete: false, }); }); test('merges multiple scopes for same collection', () => { const result = parseScopesForDisplay( 'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete', ); const postPerms = result.repoPermissions.get('app.bsky.feed.post'); assert.deepStrictEqual(postPerms, { create: true, update: false, delete: true, }); }); test('parses blob scopes', () => { const result = parseScopesForDisplay('atproto blob:image/*'); assert.deepStrictEqual(result.blobPermissions, ['image/*']); }); test('detects transition:generic', () => { const result = parseScopesForDisplay('atproto transition:generic'); assert.strictEqual(result.hasTransitionGeneric, true); }); test('handles empty scope string', () => { const result = parseScopesForDisplay(''); assert.strictEqual(result.hasAtproto, false); assert.strictEqual(result.hasTransitionGeneric, false); assert.strictEqual(result.repoPermissions.size, 0); assert.deepStrictEqual(result.blobPermissions, []); }); });