Something went wrong. Try again.
An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285// @pdsjs/core/proxy - Forwarding a request to another atproto service.//// Two ways in. An `atproto-proxy` header names the service, which covers feed// generators, labelers and the chat service. Absent that header, an app.bsky.*// path goes to the configured AppView.//// A proxied call carries a service-auth token whose audience is the named// service's DID and whose `lxm` is the single method being called. That is what// makes resolving an arbitrary service safe: the token the client gets is usable// nowhere else.
import { createServiceJwt } from './auth.js';
export const BSKY_APPVIEW_URL = 'https://api.bsky.app';const BSKY_CHAT_URL = 'https://api.bsky.chat';
/** * Parse atproto-proxy header to get service DID and service ID * Format: "did:web:api.bsky.app#bsky_appview" * @param {string} header * @returns {{ did: string, serviceId: string } | null} */export function parseAtprotoProxyHeader(header) { if (!header) return null; const hashIndex = header.indexOf('#'); if (hashIndex === -1 || hashIndex === 0 || hashIndex === header.length - 1) { return null; } return { did: header.slice(0, hashIndex), serviceId: header.slice(hashIndex + 1), };}
/** * Fast path for the first-party services, mapping their DID + service id to a * configured base URL without a DID-document round trip — the AppView (feeds, * profiles) and the chat service (DMs). Any other service is resolved from its * DID document instead (see resolveServiceEndpoint); this returns null for * those so the caller falls through to resolution. * @param {string} did - Service DID (e.g., "did:web:api.bsky.chat") * @param {string} serviceId - Service ID (e.g., "bsky_chat") * @returns {string | null} */export function getKnownServiceUrl(did, serviceId) { if (did === 'did:web:api.bsky.app' && serviceId === 'bsky_appview') { return BSKY_APPVIEW_URL; } if (did === 'did:web:api.bsky.chat' && serviceId === 'bsky_chat') { return BSKY_CHAT_URL; } return null;}
/** * Proxy a request to a service * @param {Request} request - Original request * @param {string} serviceUrl - Target service URL * @param {string} [authHeader] - Optional Authorization header * @returns {Promise<Response>} */export async function proxyToService(request, serviceUrl, authHeader) { const url = new URL(request.url); const targetUrl = new URL(url.pathname + url.search, serviceUrl);
const headers = new Headers(); if (authHeader) { headers.set('Authorization', authHeader); } headers.set( 'Content-Type', request.headers.get('Content-Type') || 'application/json', ); const acceptHeader = request.headers.get('Accept'); if (acceptHeader) { headers.set('Accept', acceptHeader); } const acceptLangHeader = request.headers.get('Accept-Language'); if (acceptLangHeader) { headers.set('Accept-Language', acceptLangHeader); } // Forward atproto-specific headers const labelersHeader = request.headers.get('atproto-accept-labelers'); if (labelersHeader) { headers.set('atproto-accept-labelers', labelersHeader); } // The Bluesky client's own headers, whose names grow with the client. The // prefix matches them all, so a new one needs no change here. atproto-proxy // stays behind: this server read it to choose the target. for (const [name, value] of request.headers) { if (name.startsWith('x-bsky-')) headers.set(name, value); }
try { const response = await fetch(targetUrl.toString(), { method: request.method, headers, body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined, }); const responseHeaders = new Headers(response.headers); responseHeaders.set('Access-Control-Allow-Origin', '*'); return new Response(response.body, { status: response.status, statusText: response.statusText, headers: responseHeaders, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return Response.json( { error: 'UpstreamFailure', message: `Failed to reach service: ${message}`, }, { status: 502 }, ); }}
/** * @typedef {Object} ProxyContext * @property {string} appviewUrl * @property {string} [appviewDid] * @property {import('./ports.js').DidResolverPort|null} didResolver - Absent, only the first-party services resolve * @property {() => Promise<import('./crypto.js').SigningKey|null>} getSigningKey * @property {(request: Request) => Promise<{did: string, scope: string}|null>} authenticate * @property {(auth: {did: string, scope: string}, lxm: string, aud: string) => Response|null} checkRpcScope */
/** * @param {ProxyContext} ctx * @returns {{ * createServiceAuthForAppView: (did: string, lxm: string|null) => Promise<string>, * handleAppViewProxy: (request: Request, userDid: string) => Promise<Response>, * resolveServiceEndpoint: (did: string, serviceId: string) => Promise<string|null>, * handleProxyHeader: (request: Request) => Promise<Response|null>, * }} */export function createProxy(ctx) { const { appviewUrl, appviewDid, didResolver, getSigningKey, authenticate, checkRpcScope, } = ctx;
/** * Create service auth JWT for AppView requests * @param {string} did - User DID * @param {string|null} lxm - Lexicon method being called * @returns {Promise<string>} */ async function createServiceAuthForAppView(did, lxm) { const signingKey = await getSigningKey(); if (!signingKey) throw new Error('No signing key available'); return createServiceJwt({ iss: did, aud: appviewDid || 'did:web:api.bsky.app', lxm, signingKey, }); }
/** * Proxy request to AppView with service auth * @param {Request} request * @param {string} userDid * @returns {Promise<Response>} */ async function handleAppViewProxy(request, userDid) { const url = new URL(request.url); const lxm = url.pathname.replace('/xrpc/', ''); const serviceJwt = await createServiceAuthForAppView(userDid, lxm); return proxyToService(request, appviewUrl, `Bearer ${serviceJwt}`); }
/** * Resolve the base URL of a service named in an atproto-proxy header. The * first-party AppView and chat services use their configured URLs; anything * else — feed generators, labelers, custom services — is resolved from the * matching entry in its DID document. * @param {string} did * @param {string} serviceId * @returns {Promise<string|null>} */ async function resolveServiceEndpoint(did, serviceId) { const known = getKnownServiceUrl(did, serviceId); if (known) return known; if (!didResolver) return null;
let didDoc; try { didDoc = /** @type {any} */ (await didResolver(did)); } catch { return null; } const services = Array.isArray(didDoc?.service) ? didDoc.service : []; const service = services.find( (/** @type {{id?: string}} */ s) => typeof s?.id === 'string' && (s.id === `#${serviceId}` || s.id === `${did}#${serviceId}`), ); const endpoint = service && typeof service.serviceEndpoint === 'string' ? service.serviceEndpoint : null; // Only proxy to an https endpoint; drop any trailing slash for joining. if (!endpoint?.startsWith('https://')) return null; return endpoint.replace(/\/+$/, ''); }
/** * Handle atproto-proxy header for proxying to external services * @param {Request} request * @returns {Promise<Response|null>} Response if handled, null if not */ async function handleProxyHeader(request) { const proxyHeader = request.headers.get('atproto-proxy'); if (!proxyHeader) return null;
const parsed = parseAtprotoProxyHeader(proxyHeader); if (!parsed) { return Response.json( { error: 'InvalidRequest', message: `Malformed atproto-proxy header: ${proxyHeader}`, }, { status: 400 }, ); }
const serviceUrl = await resolveServiceEndpoint( parsed.did, parsed.serviceId, ); if (serviceUrl) { const url = new URL(request.url); const lxm = url.pathname.replace('/xrpc/', ''); const auth = await authenticate(request);
// An authenticated call is signed as the account, addressed to the service // the header named. An unauthenticated call is a public read, forwarded // unsigned. if (auth) { const scopeError = checkRpcScope( auth, lxm, `${parsed.did}#${parsed.serviceId}`, ); if (scopeError) return scopeError;
const signingKey = await getSigningKey(); if (!signingKey) throw new Error('No signing key available'); const serviceJwt = await createServiceJwt({ iss: auth.did, aud: parsed.did, lxm, signingKey, }); return proxyToService(request, serviceUrl, `Bearer ${serviceJwt}`); } return proxyToService(request, serviceUrl); }
return Response.json( { error: 'InvalidRequest', message: `Unknown proxy service: ${proxyHeader}`, }, { status: 400 }, ); }
return { createServiceAuthForAppView, handleAppViewProxy, resolveServiceEndpoint, handleProxyHeader, };}