// The PDS proxies chat.bsky.* (DMs), labelers, and other first-party services, // not just the AppView. Each needs a service-auth token addressed to that // specific service, or it answers 400. import { afterEach, describe, expect, it, vi } from 'vitest'; import { createAccessJwt } from '../packages/core/src/auth.js'; import { base64UrlDecode, generateKeyPair, } from '../packages/core/src/crypto.js'; import { PersonalDataServer } from '../packages/core/src/pds.js'; const DID = 'did:plc:proxyexampleaccount00000'; const JWT_SECRET = 'test-secret'; /** @param {string} jwt */ function decodeClaims(jwt) { return JSON.parse( new TextDecoder().decode(base64UrlDecode(jwt.split('.')[1])), ); } /** * @param {Uint8Array} privateKey * @param {{didResolver?: (did: string) => Promise}} [opts] */ function server(privateKey, opts = {}) { return new PersonalDataServer({ actorStorage: /** @type {any} */ ({ async getDid() { return DID; }, async getPrivateKey() { return privateKey; }, async getHandle() { return 'alice.example.com'; }, }), sharedStorage: /** @type {any} */ ({}), blobs: /** @type {any} */ ({}), jwtSecret: JWT_SECRET, hostname: 'pds.example.com', didResolver: opts.didResolver, }); } /** Capture the outbound proxied request. */ function captureFetch() { const calls = /** @type {{url: string, auth: string|null}[]} */ ([]); vi.stubGlobal( 'fetch', vi.fn(async (/** @type {any} */ url, /** @type {any} */ init) => { calls.push({ url: String(url), auth: init?.headers?.get?.('Authorization') ?? null, }); return new Response(JSON.stringify({ convos: [] }), { headers: { 'Content-Type': 'application/json' }, }); }), ); return calls; } describe('service proxying', () => { afterEach(() => vi.unstubAllGlobals()); it('proxies chat.bsky.* to the chat service with a service token for it', async () => { const { privateKey } = await generateKeyPair(); const calls = captureFetch(); const token = await createAccessJwt(DID, JWT_SECRET); const res = await server(privateKey).fetch( new Request('https://pds.example.com/xrpc/chat.bsky.convo.listConvos', { headers: { Authorization: `Bearer ${token}`, 'atproto-proxy': 'did:web:api.bsky.chat#bsky_chat', }, }), ); expect(res.status).toBe(200); expect(calls).toHaveLength(1); // Forwarded to the chat service's host, same method. expect(calls[0].url).toBe( 'https://api.bsky.chat/xrpc/chat.bsky.convo.listConvos', ); // With a service token addressed to the chat service, signed as the user. expect(calls[0].auth).toMatch(/^Bearer /); const claims = decodeClaims((calls[0].auth || '').slice('Bearer '.length)); expect(claims.iss).toBe(DID); expect(claims.aud).toBe('did:web:api.bsky.chat'); expect(claims.lxm).toBe('chat.bsky.convo.listConvos'); }); it('forwards an unauthenticated request unsigned (the service decides)', async () => { const { privateKey } = await generateKeyPair(); const calls = captureFetch(); await server(privateKey).fetch( new Request('https://pds.example.com/xrpc/chat.bsky.convo.listConvos', { headers: { 'atproto-proxy': 'did:web:api.bsky.chat#bsky_chat' }, }), ); // A public read is forwarded as-is; no account token is minted for it. expect(calls).toHaveLength(1); expect(calls[0].auth).toBeNull(); }); it('resolves and proxies to a feed generator via its DID document', async () => { const { privateKey } = await generateKeyPair(); const calls = captureFetch(); const token = await createAccessJwt(DID, JWT_SECRET); // The feed generator's DID document declares its #bsky_fg endpoint. const didResolver = async (/** @type {string} */ did) => did === 'did:web:foryou.club' ? { service: [ { id: '#bsky_fg', type: 'BskyFeedGenerator', serviceEndpoint: 'https://foryou.club', }, ], } : null; const res = await server(privateKey, { didResolver }).fetch( new Request( 'https://pds.example.com/xrpc/app.bsky.feed.sendInteractions', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'atproto-proxy': 'did:web:foryou.club#bsky_fg', 'content-type': 'application/json', }, body: '{"interactions":[]}', }, ), ); expect(res.status).toBe(200); expect(calls).toHaveLength(1); expect(calls[0].url).toBe( 'https://foryou.club/xrpc/app.bsky.feed.sendInteractions', ); // A service token addressed to the feed generator, signed as the user. const claims = decodeClaims((calls[0].auth || '').slice('Bearer '.length)); expect(claims.iss).toBe(DID); expect(claims.aud).toBe('did:web:foryou.club'); expect(claims.lxm).toBe('app.bsky.feed.sendInteractions'); }); it('rejects a service the DID document does not declare', async () => { const { privateKey } = await generateKeyPair(); const calls = captureFetch(); const token = await createAccessJwt(DID, JWT_SECRET); // Resolves, but has no #bsky_fg entry — nothing to proxy to. const didResolver = async () => ({ service: [{ id: '#other', serviceEndpoint: 'https://foryou.club' }], }); const res = await server(privateKey, { didResolver }).fetch( new Request( 'https://pds.example.com/xrpc/app.bsky.feed.sendInteractions', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'atproto-proxy': 'did:web:foryou.club#bsky_fg', }, }, ), ); expect(res.status).toBe(400); expect(calls).toHaveLength(0); }); it('rejects an unresolvable proxy service', async () => { const { privateKey } = await generateKeyPair(); const calls = captureFetch(); // No didResolver injected — an unknown service cannot be resolved. const res = await server(privateKey).fetch( new Request('https://pds.example.com/xrpc/com.atproto.repo.getRecord', { headers: { 'atproto-proxy': 'did:web:evil.example#service' }, }), ); expect(res.status).toBe(400); // Never contacted — no signed token leaves for a service we can't resolve. expect(calls).toHaveLength(0); }); });