chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// The PLC operation flow, driven through the identity handlers: the mailed
// confirmation code, signing an operation chained onto the DID's log, and the
// endpoint wrapper around the recommended credentials.
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
generateKeyPair,
importPrivateKey,
} from '../packages/core/src/crypto.js';
import { createIdentityHandlers } from '../packages/core/src/handlers/xrpc-identity.js';
const DID = 'did:plc:plcoperationtestacct2345';
const AUTH = { did: DID, scope: 'com.atproto.access' };
const SECRET = 'test-secret';
afterEach(() => vi.unstubAllGlobals());
/** Answer the PLC directory's /data and /log/last endpoints. */
function stubPlcDirectory() {
vi.stubGlobal(
'fetch',
vi.fn(async (/** @type {string} */ url) => {
const u = String(url);
if (u.endsWith('/data')) {
return new Response(JSON.stringify({ rotationKeys: [] }));
}
if (u.endsWith('/log/last')) {
return new Response(JSON.stringify({ cid: 'bafyreiprevop' }));
}
return new Response('', { status: 404 });
}),
);
}
/**
* @param {Object} [options]
* @param {{address: string|null, verified: boolean}|null} [options.email]
* @param {boolean} [options.withEmailer]
* @param {boolean} [options.withKey]
* @param {boolean} [options.failSend]
*/
async function createIdentity({
email = { address: 'chad@example.com', verified: true },
withEmailer = true,
withKey = true,
failSend = false,
} = {}) {
const { privateKey } = await generateKeyPair();
const signingKey = withKey ? await importPrivateKey(privateKey) : null;
/** @type {{token: {codeHash: string, expiresAt: number}|null}} */
const state = { token: null };
/** @type {Array<{to: string, text: string}>} */
const sent = [];
const identity = createIdentityHandlers(
/** @type {any} */ ({
actorStorage: {
getHandle: async () => 'alice.example.com',
getPrivateKey: async () => (withKey ? privateKey : null),
getKemKey: async () => null,
getEmail: async () => email && { ...email, pending: null },
setPlcToken: async (/** @type {any} */ token) => {
state.token = token;
},
getPlcToken: async () => state.token,
},
hostname: 'pds.example.com',
plcUrl: 'https://plc.example.com',
jwtSecret: SECRET,
emailer: withEmailer
? {
async send(/** @type {any} */ message) {
if (failSend) throw new Error('smtp down');
sent.push(message);
},
}
: null,
k256Signer: null,
getDid: async () => DID,
getSigningKey: async () => signingKey,
readJsonBounded: async (/** @type {Request} */ request) => request.json(),
}),
);
return { identity, state, sent };
}
/**
* @param {ReturnType<typeof createIdentityHandlers>} identity
* @param {string} path
* @param {{body?: Object, auth?: Object|null}} [options]
*/
function call(identity, path, { body, auth = null } = {}) {
const url = new URL(`https://pds.example.com${path}`);
const request = new Request(url, {
method: body === undefined ? 'GET' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const handler = /** @type {any} */ (identity.routes[path]).handler;
return handler(request, url, auth);
}
const REQUEST = '/xrpc/com.atproto.identity.requestPlcOperationSignature';
const SIGN = '/xrpc/com.atproto.identity.signPlcOperation';
const CREDENTIALS = '/xrpc/com.atproto.identity.getRecommendedDidCredentials';
describe('getRecommendedDidCredentials endpoint', () => {
it('answers the credentials, gating on auth and initialization', async () => {
stubPlcDirectory();
const { identity } = await createIdentity();
expect((await call(identity, CREDENTIALS)).status).toBe(401);
const { identity: uninit } = await createIdentity({ withKey: false });
expect((await call(uninit, CREDENTIALS, { auth: AUTH })).status).toBe(400);
const res = await call(identity, CREDENTIALS, { auth: AUTH });
const body = await res.json();
expect(body.alsoKnownAs).toEqual(['at://alice.example.com']);
expect(body.verificationMethods.atproto).toMatch(/^did:key:/);
expect(body.services.atproto_pds.endpoint).toBe('https://pds.example.com');
});
});
describe('requestPlcOperationSignature', () => {
it('requires authentication', async () => {
const { identity } = await createIdentity();
expect((await call(identity, REQUEST, { body: {} })).status).toBe(401);
});
it('issues no code when it cannot reach the account holder', async () => {
// No sender, and separately no confirmed address: both answer 200 with no
// code, leaving the session as the only authorization.
for (const options of [
{ withEmailer: false },
{ email: { address: 'chad@example.com', verified: false } },
{ email: null },
]) {
const { identity, state } = await createIdentity(options);
const res = await call(identity, REQUEST, { body: {}, auth: AUTH });
expect(res.status).toBe(200);
expect(state.token).toBeNull();
}
});
it('mails a code and stores its hash', async () => {
const { identity, state, sent } = await createIdentity();
const res = await call(identity, REQUEST, { body: {}, auth: AUTH });
expect(res.status).toBe(200);
expect(sent).toHaveLength(1);
expect(sent[0].to).toBe('chad@example.com');
expect(sent[0].text).toMatch(/\d{6}/);
expect(state.token?.codeHash).toBeTruthy();
});
it('clears the stored token when the message cannot be sent', async () => {
const { identity, state } = await createIdentity({ failSend: true });
const res = await call(identity, REQUEST, { body: {}, auth: AUTH });
expect(res.status).toBe(502);
expect(state.token).toBeNull();
});
});
describe('signPlcOperation', () => {
it('requires authentication', async () => {
const { identity } = await createIdentity();
expect((await call(identity, SIGN, { body: {} })).status).toBe(401);
});
it('signs without a code when the server cannot confirm by email', async () => {
stubPlcDirectory();
const { identity } = await createIdentity({ withEmailer: false });
const res = await call(identity, SIGN, { body: {}, auth: AUTH });
expect(res.status).toBe(200);
const { operation } = await res.json();
expect(operation.type).toBe('plc_operation');
expect(operation.prev).toBe('bafyreiprevop');
expect(operation.sig).toBeTruthy();
});
it('demands the mailed code once one can be sent', async () => {
stubPlcDirectory();
const { identity, sent } = await createIdentity();
const withoutCode = await call(identity, SIGN, { body: {}, auth: AUTH });
expect(withoutCode.status).toBe(400);
expect((await withoutCode.json()).message).toContain(
'requestPlcOperationSignature',
);
await call(identity, REQUEST, { body: {}, auth: AUTH });
const code = /** @type {RegExpMatchArray} */ (
sent[0].text.match(/\d{6}/)
)[0];
const wrongCode = await call(identity, SIGN, {
body: { token: '000000' },
auth: AUTH,
});
expect(wrongCode.status).toBe(400);
expect((await wrongCode.json()).message).toBe(
'Confirmation code is invalid',
);
const res = await call(identity, SIGN, {
body: { token: code },
auth: AUTH,
});
expect(res.status).toBe(200);
expect((await res.json()).operation.sig).toBeTruthy();
// Spent: the same code signs exactly one operation.
const reuse = await call(identity, SIGN, {
body: { token: code },
auth: AUTH,
});
expect(reuse.status).toBe(400);
});
it('lets caller-supplied fields override the recommended defaults', async () => {
stubPlcDirectory();
const { identity } = await createIdentity({ withEmailer: false });
const res = await call(identity, SIGN, {
body: { rotationKeys: ['did:key:zCallerChoice'] },
auth: AUTH,
});
const { operation } = await res.json();
expect(operation.rotationKeys).toEqual(['did:key:zCallerChoice']);
expect(operation.alsoKnownAs).toEqual(['at://alice.example.com']);
});
it('refuses before the server holds a signing key', async () => {
const { identity } = await createIdentity({
withKey: false,
withEmailer: false,
});
const res = await call(identity, SIGN, { body: {}, auth: AUTH });
expect(res.status).toBe(400);
expect((await res.json()).message).toBe('PDS not initialized');
});
});