chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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,
};
}