// Backend for first-run onboarding: the fresh-signup endpoint that provisions
// a brand-new did:plc identity, and the routing that serves the onboarding
// wizard on an empty server.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { verifyAccountJwt } from '../packages/core/src/auth.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';
const HOSTNAME = 'pds.example.com';
const PASSWORD = 'test-password';
const JWT_SECRET = 'test-secret';
const ACCOUNT_APP = '
';
/** A stateful, initially-empty actor store. */
function createEmptyStorage() {
/** @type {Record} */
const state = {
did: null,
handle: null,
privateKey: null,
email: null,
status: null,
};
let seq = 0;
/** @type {any[]} */
const events = [];
return {
_state: state,
_events: events,
async getDid() {
return state.did;
},
async setDid(/** @type {string} */ did) {
state.did = did;
},
async getHandle() {
return state.handle;
},
async setHandle(/** @type {string} */ handle) {
state.handle = handle;
},
async getPrivateKey() {
return state.privateKey;
},
async setPrivateKey(/** @type {Uint8Array} */ key) {
state.privateKey = key;
},
async getKemKey() {
return state.kemKey ?? null;
},
async setKemKey(/** @type {Uint8Array} */ key) {
state.kemKey = key;
},
async getAccountStatus() {
return state.status;
},
async setEmail(/** @type {any} */ email) {
state.email = email;
},
async getLatestSeq() {
return seq;
},
async putEvent(
/** @type {number} */ s,
/** @type {string} */ _did,
/** @type {string} */ _cid,
/** @type {Uint8Array} */ evt,
) {
seq = s;
events.push(evt);
},
async getLatestCommit() {
return null;
},
};
}
/**
* @param {ReturnType} storage
* @param {{accountApp?: string}} [opts]
*/
function createServer(storage, { accountApp } = {}) {
return new PersonalDataServer({
actorStorage: /** @type {any} */ (storage),
sharedStorage: /** @type {any} */ ({ putActor: async () => {} }),
blobs: /** @type {any} */ ({}),
jwtSecret: JWT_SECRET,
hostname: HOSTNAME,
password: PASSWORD,
plcUrl: 'https://plc.test',
accountApp,
});
}
/**
* Drive the fresh-signup endpoint.
* @param {PersonalDataServer} pds
* @param {Record} body
* @param {Record} [headers]
*/
function createAccount(pds, body, headers = {}) {
return pds.fetch(
new Request(`https://${HOSTNAME}/account/onboarding/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
}),
);
}
describe('onboarding: fresh signup', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
/** Stub fetch so the PLC directory registration succeeds and is captured. */
function stubPlc(status = 200) {
const calls = /** @type {{url: string, op: any}[]} */ ([]);
vi.stubGlobal(
'fetch',
vi.fn(async (/** @type {string} */ url, /** @type {any} */ init) => {
calls.push({ url, op: JSON.parse(init.body) });
return new Response('', { status });
}),
);
return calls;
}
it('provisions a new did:plc identity and returns a cookie session', async () => {
const calls = stubPlc();
const storage = createEmptyStorage();
const pds = createServer(storage);
const res = await createAccount(pds, {
handle: 'alice.pds.example.com',
password: PASSWORD,
email: 'alice@example.com',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.did).toMatch(/^did:plc:[a-z2-7]{24}$/);
expect(data.handle).toBe('alice.pds.example.com');
// Persisted identity.
expect(storage._state.did).toBe(data.did);
expect(storage._state.handle).toBe('alice.pds.example.com');
expect(storage._state.privateKey).toBeInstanceOf(Uint8Array);
expect(storage._state.email).toEqual({
address: 'alice@example.com',
verified: false,
});
// A cookie session naming the new DID.
const cookie = res.headers.get('set-cookie') || '';
expect(cookie).toContain('pdsjs_account=');
const match = /pdsjs_account=([^;]+)/.exec(cookie);
const token = decodeURIComponent(match?.[1] || '');
const payload = await verifyAccountJwt(token, JWT_SECRET);
expect(payload.sub).toBe(data.did);
// Registered a signed genesis op with the PLC directory for that DID.
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain(encodeURIComponent(data.did));
expect(calls[0].op.type).toBe('plc_operation');
expect(typeof calls[0].op.sig).toBe('string');
expect(calls[0].op.alsoKnownAs).toEqual(['at://alice.pds.example.com']);
expect(calls[0].op.services.atproto_pds.endpoint).toBe(
`https://${HOSTNAME}`,
);
});
it('strips a port that rides along from a local host', async () => {
stubPlc();
const storage = createEmptyStorage();
const res = await createAccount(createServer(storage), {
handle: 'alice.example.com:3999',
password: PASSWORD,
});
expect(res.status).toBe(200);
expect((await res.json()).handle).toBe('alice.example.com');
});
it('defaults the handle to the server hostname', async () => {
stubPlc();
const storage = createEmptyStorage();
const res = await createAccount(createServer(storage), {
password: PASSWORD,
});
expect(res.status).toBe(200);
expect((await res.json()).handle).toBe(HOSTNAME);
});
it('rejects the wrong server password', async () => {
const calls = stubPlc();
const storage = createEmptyStorage();
const res = await createAccount(createServer(storage), {
handle: 'alice.pds.example.com',
password: 'wrong',
});
expect(res.status).toBe(401);
expect(storage._state.did).toBeNull();
expect(calls).toHaveLength(0);
});
it('refuses when the server already hosts an account', async () => {
stubPlc();
const storage = createEmptyStorage();
storage._state.did = 'did:plc:alreadyhereaccount000000000';
const res = await createAccount(createServer(storage), {
handle: 'alice.pds.example.com',
password: PASSWORD,
});
expect(res.status).toBe(400);
});
it('surfaces a PLC directory rejection and stays empty', async () => {
stubPlc(400);
const storage = createEmptyStorage();
const res = await createAccount(createServer(storage), {
handle: 'alice.pds.example.com',
password: PASSWORD,
});
expect(res.status).toBe(502);
// Nothing persisted, so the operator can retry.
expect(storage._state.did).toBeNull();
});
});
describe('onboarding: first-run routing', () => {
it('serves the onboard mode on an empty server with the account app', async () => {
const pds = createServer(createEmptyStorage(), { accountApp: ACCOUNT_APP });
const res = await pds.fetch(new Request(`https://${HOSTNAME}/account`));
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/html');
const html = await res.text();
expect(html).toContain('window.__PDS__');
expect(html).toContain('onboard');
});
it('does not serve onboarding once the account is active', async () => {
const storage = createEmptyStorage();
storage._state.did = 'did:plc:existingaccount0000000000';
storage._state.status = null; // null == active
const pds = createServer(storage, { accountApp: ACCOUNT_APP });
const res = await pds.fetch(new Request(`https://${HOSTNAME}/account`));
const html = await res.text();
expect(html).not.toContain('onboard');
});
it('keeps serving onboarding while a migration is deactivated (resume)', async () => {
// createAccount lands the adopted account deactivated until the move
// finishes; the wizard must stay reachable to resume it.
const storage = createEmptyStorage();
storage._state.did = 'did:plc:migratinginprogress0000000';
storage._state.status = 'deactivated';
const pds = createServer(storage, { accountApp: ACCOUNT_APP });
const res = await pds.fetch(new Request(`https://${HOSTNAME}/account`));
const html = await res.text();
expect(html).toContain('onboard');
});
});