chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Backup account API tests - the settings the page can change, and what it reads
//
// accountApi arrives through the context, so a stand-in that keeps its contract
// (call the handler with a did and a parsed body, JSON on return, 400 on throw)
// reaches these three routes without a session.
import { describe, expect, it } from 'vitest';
import { defaultBackupState } from '../packages/core/src/backup.js';
import { base64UrlEncode } from '../packages/core/src/crypto.js';
import { createBackupHandlers } from '../packages/core/src/handlers/account-backup.js';
const DID = 'did:plc:backupapiaccount234567';
const KEY = new Uint8Array(32).fill(3);
/** @typedef {{seq: number, cid: string, rev: string}} Head */
/**
* @param {{withTarget?: boolean, withKey?: boolean, state?: Object|null, key?: Uint8Array, heads?: () => Head, buildFullRepoCar?: (cid: string) => Promise<Uint8Array>}} [options]
*/
function createBackupApi({
withTarget = true,
withKey = true,
state = null,
key = KEY,
heads = () => ({ seq: 1, cid: 'bafycommit', rev: 'rev1' }),
buildFullRepoCar = async () => new Uint8Array([1, 2, 3]),
} = {}) {
/** @type {{value: Object|null}} */
const stored = { value: state };
/** @type {Map<string, Uint8Array>} */
const objects = new Map();
const target = {
async put(/** @type {string} */ k, /** @type {Uint8Array} */ d) {
objects.set(k, d);
},
async get(/** @type {string} */ k) {
return objects.get(k) || null;
},
async list(/** @type {string} */ prefix) {
return [...objects.keys()].filter((k) => k.startsWith(prefix));
},
async delete(/** @type {string} */ k) {
objects.delete(k);
},
};
const handlers = createBackupHandlers({
actorStorage: /** @type {any} */ ({
async getBackupState() {
return stored.value;
},
async setBackupState(/** @type {Object} */ next) {
stored.value = next;
},
async getPrivateKey() {
return withKey ? key : null;
},
async getLatestCommit() {
return heads();
},
async getHandle() {
return 'alice.example.com';
},
async listBlobs() {
return { cids: [], cursor: null };
},
}),
blobs: /** @type {any} */ ({
async get() {
return null;
},
}),
backupTarget: withTarget ? /** @type {any} */ (target) : null,
hostname: 'pds.example.com',
getDid: async () => DID,
buildFullRepoCar,
// Keeps accountApi's contract: parse, delegate, JSON out, 400 on throw.
accountApi: async (request, _url, handler) => {
const body =
request.method === 'POST' ? await request.json().catch(() => ({})) : {};
try {
const result = await handler(DID, body);
return result instanceof Response
? result
: Response.json(result, { status: 200 });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 400 });
}
},
});
return { handlers, stored, objects };
}
const READ = '/account/api/backups';
const SETTINGS = '/account/api/backups/settings';
const RUN = '/account/api/backups/run';
/**
* @param {ReturnType<typeof createBackupHandlers>} handlers
* @param {string} path
* @param {Object} [body]
*/
async function call(handlers, path, body) {
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: null) => Promise<Response>} */ (
route.handler
);
return handler(new Request(url, init), url, null);
}
describe('GET backups', () => {
it('reports defaults on a server that has never run one', async () => {
const { handlers } = createBackupApi();
const body = await (await call(handlers, READ)).json();
expect(body.available).toBe(true);
expect(body.settings.enabled).toBe(false);
expect(body.settings.keyBackup).toBe(null);
expect(body.runs).toEqual([]);
});
it('reports unavailable without a target', async () => {
const { handlers } = createBackupApi({ withTarget: false });
const body = await (await call(handlers, READ)).json();
expect(body.available).toBe(false);
});
it('hides nextRunAt while backups are off', async () => {
const state = defaultBackupState();
state.nextRunAt = 1800000000000;
const { handlers } = createBackupApi({ state });
const body = await (await call(handlers, READ)).json();
expect(body.nextRunAt).toBe(null);
});
});
describe('POST backups/settings', () => {
it('turns backups on and schedules the first run', async () => {
const { handlers, stored } = createBackupApi();
const body = await (
await call(handlers, SETTINGS, { enabled: true })
).json();
expect(body.settings.enabled).toBe(true);
expect(body.nextRunAt).toBeGreaterThan(0);
expect(/** @type {any} */ (stored.value).config.enabled).toBe(true);
});
it('clears the schedule when turned off', async () => {
const { handlers } = createBackupApi();
await call(handlers, SETTINGS, { enabled: true });
const body = await (
await call(handlers, SETTINGS, { enabled: false })
).json();
expect(body.nextRunAt).toBe(null);
});
it('takes an interval and a retention count', async () => {
const { handlers } = createBackupApi();
const body = await (
await call(handlers, SETTINGS, { intervalHours: 12, retain: 7 })
).json();
expect(body.settings.intervalHours).toBe(12);
expect(body.settings.retain).toBe(7);
});
it('holds the interval between 1 and 720 hours', async () => {
const { handlers } = createBackupApi();
for (const intervalHours of [0, 721, 'nope']) {
const response = await call(handlers, SETTINGS, { intervalHours });
expect(response.status, String(intervalHours)).toBe(400);
expect((await response.json()).error).toMatch(/between 1 and 720/);
}
});
it('holds retention between 1 and 50 snapshots', async () => {
const { handlers } = createBackupApi();
for (const retain of [0, 51, 2.5]) {
const response = await call(handlers, SETTINGS, { retain });
expect(response.status, String(retain)).toBe(400);
expect((await response.json()).error).toMatch(/between 1 and 50/);
}
});
it('refuses settings without a target', async () => {
const { handlers } = createBackupApi({ withTarget: false });
const response = await call(handlers, SETTINGS, { enabled: true });
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/No backup target/);
});
// The browser derives the AES key, so the server only ever sees key, salt and
// count. It seals the signing key with them.
it('seals the signing key from a derived key', async () => {
const { handlers } = createBackupApi();
const body = await (
await call(handlers, SETTINGS, {
keyDerived: {
key: base64UrlEncode(new Uint8Array(32).fill(9)),
salt: base64UrlEncode(new Uint8Array(16).fill(1)),
iterations: 600000,
},
})
).json();
expect(body.settings.keyBackup).toBeTruthy();
expect(body.settings.keyBackup.stale).toBe(false);
});
it('takes the sealed key back out', async () => {
const { handlers } = createBackupApi();
await call(handlers, SETTINGS, {
keyDerived: {
key: base64UrlEncode(new Uint8Array(32).fill(9)),
salt: base64UrlEncode(new Uint8Array(16).fill(1)),
iterations: 600000,
},
});
const body = await (
await call(handlers, SETTINGS, { removeKeyBackup: true })
).json();
expect(body.settings.keyBackup).toBe(null);
});
it('needs a key and salt that are strings', async () => {
const { handlers } = createBackupApi();
const response = await call(handlers, SETTINGS, {
keyDerived: { key: 123, salt: 'abc', iterations: 600000 },
});
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/base64url key and salt/);
});
it('holds the iteration count in range', async () => {
const { handlers } = createBackupApi();
for (const iterations of [99999, 10000001, 1.5]) {
const response = await call(handlers, SETTINGS, {
keyDerived: {
key: base64UrlEncode(new Uint8Array(32)),
salt: base64UrlEncode(new Uint8Array(16)),
iterations,
},
});
expect(response.status, String(iterations)).toBe(400);
expect((await response.json()).error).toMatch(/iterations out of range/);
}
});
it('needs 32 key bytes and at least 8 of salt', async () => {
const { handlers } = createBackupApi();
const response = await call(handlers, SETTINGS, {
keyDerived: {
key: base64UrlEncode(new Uint8Array(16)),
salt: base64UrlEncode(new Uint8Array(4)),
iterations: 600000,
},
});
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/32 bytes, salt at least 8/);
});
it('cannot seal a key the server does not have', async () => {
const { handlers } = createBackupApi({ withKey: false });
const response = await call(handlers, SETTINGS, {
keyDerived: {
key: base64UrlEncode(new Uint8Array(32)),
salt: base64UrlEncode(new Uint8Array(16)),
iterations: 600000,
},
});
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/no signing key/);
});
});
describe('the scheduler', () => {
it('does nothing without a target', async () => {
const { handlers } = createBackupApi({ withTarget: false });
expect(await handlers.maybeRunScheduledBackup()).toBe(null);
});
it('does nothing while backups are off', async () => {
const { handlers } = createBackupApi();
expect(await handlers.maybeRunScheduledBackup()).toBe(null);
});
it('waits until the run is due', async () => {
const { handlers } = createBackupApi();
await call(handlers, SETTINGS, { enabled: true, intervalHours: 24 });
const state = await handlers.readBackupState();
// Due one interval out, so a tick before that mark does nothing.
expect(
await handlers.maybeRunScheduledBackup(Number(state.nextRunAt) - 1000),
).toBe(null);
});
it('leaves a run another isolate is still beating for', async () => {
const { handlers } = createBackupApi();
await call(handlers, SETTINGS, { enabled: true });
const state = await handlers.readBackupState();
const now = Number(state.nextRunAt) + 1000;
state.runs = [
/** @type {any} */ ({
id: 'other',
status: 'running',
startedAt: new Date(now).toISOString(),
heartbeatAt: new Date(now).toISOString(),
}),
];
await handlers.writeBackupState(state);
expect(await handlers.maybeRunScheduledBackup(now)).toBe(null);
});
it('runs when one is due', async () => {
const { handlers } = createBackupApi();
await call(handlers, SETTINGS, { enabled: true });
const state = await handlers.readBackupState();
const run = await handlers.maybeRunScheduledBackup(
Number(state.nextRunAt) + 1000,
);
expect(run?.trigger).toBe('scheduled');
expect(run?.status).toBe('ok');
});
});
describe('pruneBackups', () => {
it('keeps only the newest snapshots', async () => {
const { handlers, objects } = createBackupApi();
for (const stamp of ['2026-01-01', '2026-01-02', '2026-01-03']) {
objects.set(`snapshots/${stamp}/repo.car`, new Uint8Array([1]));
objects.set(
`snapshots/${stamp}/manifest.json`,
new TextEncoder().encode(JSON.stringify({ blobs: [] })),
);
}
await handlers.pruneBackups(2);
const stamps = [...objects.keys()]
.filter((k) => k.startsWith('snapshots/'))
.map((k) => k.split('/')[1]);
expect([...new Set(stamps)].sort()).toEqual(['2026-01-02', '2026-01-03']);
});
it('deletes blobs no kept manifest references', async () => {
const { handlers, objects } = createBackupApi();
objects.set(
'snapshots/2026-01-01/manifest.json',
new TextEncoder().encode(JSON.stringify({ blobs: ['keepme'] })),
);
objects.set('blobs/keepme', new Uint8Array([1]));
objects.set('blobs/orphan', new Uint8Array([2]));
await handlers.pruneBackups(1);
expect(objects.has('blobs/keepme')).toBe(true);
expect(objects.has('blobs/orphan')).toBe(false);
});
// Better to hold garbage than to reap a blob a live snapshot still needs.
it('reaps nothing when a kept manifest will not parse', async () => {
const { handlers, objects } = createBackupApi();
objects.set(
'snapshots/2026-01-01/manifest.json',
new TextEncoder().encode('not json'),
);
objects.set('blobs/orphan', new Uint8Array([2]));
await handlers.pruneBackups(1);
expect(objects.has('blobs/orphan')).toBe(true);
});
it('reaps nothing when no kept manifest is readable at all', async () => {
const { handlers, objects } = createBackupApi();
objects.set('snapshots/2026-01-01/repo.car', new Uint8Array([1]));
objects.set('blobs/orphan', new Uint8Array([2]));
await handlers.pruneBackups(1);
expect(objects.has('blobs/orphan')).toBe(true);
});
it('does nothing without a target', async () => {
const { handlers, objects } = createBackupApi({ withTarget: false });
await handlers.pruneBackups(1);
expect(objects.size).toBe(0);
});
});
describe('POST backups/run', () => {
it('answers with the finished run', async () => {
const { handlers, objects } = createBackupApi();
const body = await (await call(handlers, RUN, {})).json();
expect(body.status).toBe('ok');
expect(body.trigger).toBe('manual');
expect(objects.has(`${body.snapshot}/manifest.json`)).toBe(true);
expect(objects.has(`${body.snapshot}/repo.car`)).toBe(true);
});
it('refuses a second run while one is going', async () => {
const { handlers } = createBackupApi();
const first = handlers.runBackup('manual');
await expect(handlers.runBackup('manual')).rejects.toThrow(
/already running/,
);
await first;
});
it('reports a stale sealed key as stale', async () => {
const { handlers } = createBackupApi();
await call(handlers, SETTINGS, {
keyDerived: {
key: base64UrlEncode(new Uint8Array(32).fill(9)),
salt: base64UrlEncode(new Uint8Array(16).fill(1)),
iterations: 600000,
},
});
// A rotated signing key no longer matches the fingerprint that was sealed.
const rotated = createBackupApi({
state: await handlers.readBackupState(),
key: new Uint8Array(32).fill(4),
});
const view = await rotated.handlers.publicBackupView(
await rotated.handlers.readBackupState(),
);
expect(view.settings.keyBackup?.stale).toBe(true);
});
it('snapshots the new head when a commit lands during the walk', async () => {
let seq = 1;
const head = () => ({ seq, cid: `bafycommit${seq}`, rev: `rev${seq}` });
const { handlers, objects } = createBackupApi({
heads: head,
buildFullRepoCar: async (cid) => {
if (cid !== 'bafycommit2') {
seq = 2;
throw new Error(`Missing block: of ${cid}`);
}
return new Uint8Array([2]);
},
});
const body = await (await call(handlers, RUN, {})).json();
expect(body.status).toBe('ok');
const manifest = JSON.parse(
new TextDecoder().decode(objects.get(`${body.snapshot}/manifest.json`)),
);
expect(manifest.commit.cid).toBe('bafycommit2');
});
it('fails a snapshot whose head has not moved', async () => {
const { handlers } = createBackupApi({
buildFullRepoCar: async () => {
throw new Error('Missing block: x');
},
});
const body = await (await call(handlers, RUN, {})).json();
expect(body.status).toBe('error');
});
it('gives up on a head that keeps moving', async () => {
let seq = 1;
const { handlers } = createBackupApi({
heads: () => ({ seq, cid: `bafycommit${seq}`, rev: `rev${seq}` }),
buildFullRepoCar: async () => {
seq += 1;
throw new Error('Missing block: x');
},
});
const body = await (await call(handlers, RUN, {})).json();
expect(body.status).toBe('error');
expect(seq).toBe(4);
});
it('is a 400 with no target to write to', async () => {
const { handlers } = createBackupApi({ withTarget: false });
const response = await call(handlers, RUN, {});
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/No backup target/);
});
});