// 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'); }); });