chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Server root endpoint tests - health, root, atproto-did, status, /init, and
// the two endpoints that announce repo state
import { describe, expect, it } from 'vitest';
import { decodeStoredKey } from '../packages/core/src/crypto.js';
import { createServerMetaHandlers } from '../packages/core/src/handlers/server-meta.js';
const DID = 'did:plc:servermetaaccount2345672';
const KEY_HEX = 'ab'.repeat(32);
/**
* @param {{
* did?: string|null, handle?: string|null, readOnly?: boolean,
* auth?: {did: string, scope: string}|null, k256?: boolean,
* latestCommit?: {cid: string, rev: string}|null,
* rebuildMst?: () => Promise<{rev: string, cid: string, changed: boolean}|null>,
* }} [options]
*/
function createMeta({
did = null,
handle = null,
readOnly = false,
auth = null,
k256 = false,
latestCommit = null,
rebuildMst = async () => null,
} = {}) {
const stored = {
did,
handle,
/** @type {Uint8Array|null} */ privateKey: null,
/** @type {Uint8Array|null} */ kemKey: null,
};
/** @type {Array<{type: string, body: Object}>} */
const events = [];
/** @type {Object[]} */
const syncEvents = [];
/** @type {Array<{did: string, handle: string|null}>} */
const actors = [];
let cacheCleared = 0;
const handlers = createServerMetaHandlers({
actorStorage: /** @type {any} */ ({
async getDid() {
return stored.did;
},
async setDid(/** @type {string} */ d) {
stored.did = d;
},
async getHandle() {
return stored.handle;
},
async setHandle(/** @type {string} */ h) {
stored.handle = h;
},
async setPrivateKey(/** @type {Uint8Array} */ k) {
stored.privateKey = k;
},
async getKemKey() {
return stored.kemKey ?? null;
},
async setKemKey(/** @type {Uint8Array} */ k) {
stored.kemKey = k;
},
async getLatestCommit() {
return latestCommit;
},
async getAccountStatus() {
return 'active';
},
}),
sharedStorage: /** @type {any} */ ({
async putActor(/** @type {string} */ d, /** @type {string|null} */ h) {
actors.push({ did: d, handle: h });
},
}),
readOnly,
version: '9.9.9',
k256Signer: k256 ? /** @type {any} */ ({}) : undefined,
getDid: async () => stored.did,
authenticate: async () => auth,
emitEvent: async (type, body) => {
events.push({ type, body });
},
emitSyncEvent: async (commit) => {
syncEvents.push(commit);
},
rebuildMst,
readOnlyError: () =>
Response.json(
{ error: 'AuthenticationRequired', message: 'This PDS is read-only' },
{ status: 401 },
),
setCachedIdentity: () => {
cacheCleared += 1;
},
});
return {
handlers,
stored,
events,
syncEvents,
actors,
cleared: () => cacheCleared,
};
}
/**
* @param {ReturnType<typeof createServerMetaHandlers>} handlers
* @param {string} path
* @param {Object} [body]
* @param {{did: string, scope: string}|null} [auth] - What dispatch resolved, for the routes it authenticates
*/
async function call(handlers, path, body, auth = null) {
const route = handlers.routes[path];
const url = new URL(`https://pds.example.com${path}`);
/** @type {RequestInit} */
const init = { method: body ? 'POST' : 'GET' };
if (body) init.body = JSON.stringify(body);
const handler =
/** @type {(request: Request, url: URL, auth: {did: string, scope: string}|null) => Promise<Response>} */ (
route.handler
);
return handler(new Request(url, init), url, auth);
}
describe('root and health', () => {
it('serves the banner as plain text', async () => {
const { handlers } = createMeta();
const response = await call(handlers, '/');
expect(response.headers.get('Content-Type')).toBe(
'text/plain; charset=utf-8',
);
expect(await response.text()).toContain('ATProto PDS');
});
it('answers health with the version', async () => {
const { handlers } = createMeta();
const response = await call(handlers, '/xrpc/_health');
expect(response.status).toBe(200);
expect((await response.json()).version).toBe('9.9.9');
});
});
describe('atproto-did', () => {
it('serves the DID once one is adopted', async () => {
const { handlers } = createMeta({ did: DID });
const response = await call(handlers, '/.well-known/atproto-did');
expect(response.status).toBe(200);
expect((await response.text()).trim()).toBe(DID);
});
it('is a 404 before one is', async () => {
const { handlers } = createMeta();
const response = await call(handlers, '/.well-known/atproto-did');
expect(response.status).toBe(404);
});
});
describe('status', () => {
it('reports whether an identity is adopted', async () => {
const { handlers } = createMeta({ did: DID, handle: 'alice.example.com' });
const body = await (await call(handlers, '/status')).json();
expect(body.initialized).toBe(true);
expect(body.did).toBe(DID);
});
it('reports an empty server as uninitialized', async () => {
const { handlers } = createMeta();
const body = await (await call(handlers, '/status')).json();
expect(body.initialized).toBe(false);
expect(body.did).toBe(null);
});
});
describe('init', () => {
it('stores the KEM key the genesis operation published', async () => {
const { handlers, stored } = createMeta();
const response = await call(handlers, '/init', {
did: DID,
privateKey: KEY_HEX,
kemKey: 'cd'.repeat(32),
});
expect(response.status).toBe(200);
expect(stored.kemKey).toEqual(new Uint8Array(32).fill(0xcd));
});
it('makes a KEM key when the caller brings none', async () => {
const { handlers, stored } = createMeta();
await call(handlers, '/init', { did: DID, privateKey: KEY_HEX });
const made = stored.kemKey;
expect(made).toHaveLength(32);
// Repointing the server keeps the key it has.
await call(
handlers,
'/init',
{ did: DID, privateKey: KEY_HEX },
{
did: DID,
scope: 'com.atproto.access',
},
);
expect(stored.kemKey).toBe(made);
});
it('refuses a KEM key that is not 32 bytes of hex', async () => {
const { handlers, stored } = createMeta();
const response = await call(handlers, '/init', {
did: DID,
privateKey: KEY_HEX,
kemKey: 'cd'.repeat(31),
});
expect(response.status).toBe(400);
expect(stored.did).toBeNull();
});
it('adopts an identity on an empty server without auth', async () => {
const { handlers, stored, events, actors, cleared } = createMeta();
const response = await call(handlers, '/init', {
did: DID,
privateKey: KEY_HEX,
handle: 'alice.example.com',
});
expect(response.status).toBe(200);
expect(stored.did).toBe(DID);
expect(stored.handle).toBe('alice.example.com');
expect(
decodeStoredKey(/** @type {Uint8Array} */ (stored.privateKey)).curve,
).toBe('p256');
expect(actors).toEqual([{ did: DID, handle: 'alice.example.com' }]);
expect(cleared()).toBe(1);
// A fresh bootstrap announces both the identity and the account.
expect(events.map((e) => e.type)).toEqual(['identity', 'account']);
});
it('announces a sync event when a repo already exists', async () => {
const { handlers, syncEvents } = createMeta({
latestCommit: { cid: 'bafycommit', rev: 'rev1' },
});
await call(handlers, '/init', { did: DID, privateKey: KEY_HEX });
expect(syncEvents).toEqual([{ cid: 'bafycommit', rev: 'rev1' }]);
});
// Once a DID is adopted this endpoint can repoint the account at another
// identity, so it stops being open.
it('needs the account credentials once a DID is adopted', async () => {
const { handlers, stored } = createMeta({ did: DID });
const response = await call(handlers, '/init', {
did: 'did:plc:attackeraccount234567234',
privateKey: KEY_HEX,
});
expect(response.status).toBe(401);
expect((await response.json()).message).toMatch(/already initialized/);
expect(stored.did).toBe(DID);
});
it('lets the account itself repoint the server', async () => {
const other = 'did:plc:secondaccount23456723456';
const { handlers, stored } = createMeta({
did: DID,
auth: { did: DID, scope: 'com.atproto.access' },
});
const response = await call(handlers, '/init', {
did: other,
privateKey: KEY_HEX,
});
expect(response.status).toBe(200);
expect(stored.did).toBe(other);
});
it('refuses a read-only server', async () => {
const { handlers, stored } = createMeta({ readOnly: true });
const response = await call(handlers, '/init', {
did: DID,
privateKey: KEY_HEX,
});
expect(response.status).toBe(401);
expect(stored.did).toBe(null);
});
it('needs a did and a privateKey', async () => {
const { handlers } = createMeta();
const response = await call(handlers, '/init', { did: DID });
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(
/missing did or privateKey/,
);
});
it('refuses a did it cannot parse', async () => {
const { handlers } = createMeta();
const response = await call(handlers, '/init', {
did: 'not-a-did',
privateKey: KEY_HEX,
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/invalid DID format/);
});
it('refuses a privateKey that is not hex', async () => {
const { handlers } = createMeta();
const response = await call(handlers, '/init', {
did: DID,
privateKey: 'zz'.repeat(32),
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/valid hex/);
});
it('takes a privateKey as a byte array', async () => {
const { handlers, stored } = createMeta();
const response = await call(handlers, '/init', {
did: DID,
privateKey: Array.from({ length: 32 }, () => 7),
});
expect(response.status).toBe(200);
expect(stored.privateKey).toBeTruthy();
});
it('refuses a privateKey of the wrong length', async () => {
const { handlers } = createMeta();
const response = await call(handlers, '/init', {
did: DID,
privateKey: 'ab'.repeat(16),
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/32 bytes/);
});
it('refuses a privateKey that is neither hex nor bytes', async () => {
const { handlers } = createMeta();
const response = await call(handlers, '/init', {
did: DID,
privateKey: { nope: true },
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/hex string or byte array/);
});
it('refuses a curve it does not know', async () => {
const { handlers } = createMeta();
const response = await call(handlers, '/init', {
did: DID,
privateKey: KEY_HEX,
curve: 'ed25519',
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/p256 or secp256k1/);
});
// secp256k1 needs the injected signer, so a deployment without one must say so
// rather than storing a key it cannot sign with.
it('refuses secp256k1 without an injected signer', async () => {
const { handlers, stored } = createMeta();
const response = await call(handlers, '/init', {
did: DID,
privateKey: KEY_HEX,
curve: 'secp256k1',
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(
/cannot sign with secp256k1/,
);
expect(stored.privateKey).toBe(null);
});
it('stores a secp256k1 key when a signer is present', async () => {
const { handlers, stored } = createMeta({ k256: true });
const response = await call(handlers, '/init', {
did: DID,
privateKey: KEY_HEX,
curve: 'secp256k1',
});
expect(response.status).toBe(200);
expect(
decodeStoredKey(/** @type {Uint8Array} */ (stored.privateKey)).curve,
).toBe('secp256k1');
});
it('refuses a handle it cannot parse', async () => {
const { handlers } = createMeta();
const response = await call(handlers, '/init', {
did: DID,
privateKey: KEY_HEX,
handle: 'not a handle',
});
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/invalid handle format/);
});
});
const ACCESS = { did: DID, scope: 'com.atproto.access' };
const OTHER_DID = 'did:plc:secondaccount23456723456';
const COMMIT = { cid: 'bafycommitcid', rev: '3kzz' };
const SYNC_PATH = '/xrpc/com.atproto.sync.requestSync';
const REBUILD_PATH = '/xrpc/dev.pdsjs.repo.rebuildMst';
describe('requestSync', () => {
it('announces the current head', async () => {
const { handlers, syncEvents } = createMeta({
did: DID,
latestCommit: COMMIT,
});
const response = await call(handlers, SYNC_PATH, {}, ACCESS);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
rev: COMMIT.rev,
cid: COMMIT.cid,
});
expect(syncEvents).toEqual([COMMIT]);
});
it('refuses a read-only server', async () => {
const { handlers, syncEvents } = createMeta({
did: DID,
latestCommit: COMMIT,
readOnly: true,
});
const response = await call(handlers, SYNC_PATH, {}, ACCESS);
expect(response.status).toBe(401);
expect(syncEvents).toEqual([]);
});
it('refuses a server with no DID', async () => {
const { handlers, syncEvents } = createMeta({ latestCommit: COMMIT });
const response = await call(handlers, SYNC_PATH, {}, ACCESS);
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/not initialized/);
expect(syncEvents).toEqual([]);
});
it('refuses an unauthenticated caller', async () => {
const { handlers, syncEvents } = createMeta({
did: DID,
latestCommit: COMMIT,
});
const response = await call(handlers, SYNC_PATH, {});
expect(response.status).toBe(403);
expect(syncEvents).toEqual([]);
});
it('refuses another account', async () => {
const { handlers, syncEvents } = createMeta({
did: DID,
latestCommit: COMMIT,
});
const response = await call(
handlers,
SYNC_PATH,
{},
{
did: OTHER_DID,
scope: 'com.atproto.access',
},
);
expect(response.status).toBe(403);
expect(syncEvents).toEqual([]);
});
it('refuses a repo with no commits', async () => {
const { handlers, syncEvents } = createMeta({ did: DID });
const response = await call(handlers, SYNC_PATH, {}, ACCESS);
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/no commits/);
expect(syncEvents).toEqual([]);
});
});
describe('rebuildMst', () => {
const rebuilt = { rev: '3kzz', cid: 'bafyrebuiltroot', changed: true };
it('returns the rebuilt head', async () => {
let calls = 0;
const { handlers } = createMeta({
did: DID,
rebuildMst: async () => {
calls += 1;
return rebuilt;
},
});
const response = await call(handlers, REBUILD_PATH, {}, ACCESS);
expect(response.status).toBe(200);
expect(await response.json()).toEqual(rebuilt);
expect(calls).toBe(1);
});
it('refuses a read-only server', async () => {
const { handlers } = createMeta({
did: DID,
readOnly: true,
rebuildMst: async () => rebuilt,
});
const response = await call(handlers, REBUILD_PATH, {}, ACCESS);
expect(response.status).toBe(401);
});
it('refuses a server with no DID', async () => {
const { handlers } = createMeta({ rebuildMst: async () => rebuilt });
const response = await call(handlers, REBUILD_PATH, {}, ACCESS);
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/not initialized/);
});
it('refuses an unauthenticated caller', async () => {
let calls = 0;
const { handlers } = createMeta({
did: DID,
rebuildMst: async () => {
calls += 1;
return rebuilt;
},
});
const response = await call(handlers, REBUILD_PATH, {});
expect(response.status).toBe(403);
expect(calls).toBe(0);
});
it('refuses another account', async () => {
let calls = 0;
const { handlers } = createMeta({
did: DID,
rebuildMst: async () => {
calls += 1;
return rebuilt;
},
});
const response = await call(
handlers,
REBUILD_PATH,
{},
{
did: OTHER_DID,
scope: 'com.atproto.access',
},
);
expect(response.status).toBe(403);
expect(calls).toBe(0);
});
it('refuses a repo with no commits', async () => {
const { handlers } = createMeta({ did: DID });
const response = await call(handlers, REBUILD_PATH, {}, ACCESS);
expect(response.status).toBe(400);
expect((await response.json()).message).toMatch(/no commits/);
});
});