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