// The /account/api/sites endpoints: session-gated, backed entirely by the // injected siteBrowser, write path blocked in read-only mode. import { PersonalDataServer } from '@pdsjs/core'; import { CID, cborEncodeDagCbor, cidToString, createBlobCid, createCid, } from '@pdsjs/core/repo'; import { describe, expect, it } from 'vitest'; import { createSiteAccountRoutes } from '../src/account.js'; import { createSiteBrowser } from '../src/browser.js'; const DID = 'did:plc:apitest'; const PASSWORD = 'account-api-password'; const PDS_HOST = 'pds.example.com'; async function fixtures() { const data = new TextEncoder().encode('

hi

'); const value = cborEncodeDagCbor({ $type: 'dev.pdsjs.site.deploy', files: [ { path: 'index.html', blob: { $type: 'blob', ref: new CID(await createBlobCid(data)), mimeType: 'text/html', size: data.length, }, }, ], createdAt: '2026-08-06T00:00:00.000Z', }); return { uri: `at://${DID}/dev.pdsjs.site.deploy/home`, cid: cidToString(await createCid(value)), value, }; } /** * @param {{sites?: boolean, readOnly?: boolean, installer?: unknown, installs?: boolean}} [opts] */ async function makePds(opts = {}) { const record = await fixtures(); const deleted = /** @type {string[]} */ ([]); const records = new Map([[record.uri, record]]); if (opts.installs !== false) { const installValue = cborEncodeDagCbor({ $type: 'dev.pdsjs.app.install', name: 'Photos', manifest: { uri: `at://${DID}/dev.pdsjs.app.manifest/photos`, cid: 'bafym1', }, site: 'home', settings: [ { key: 'topo', label: 'Terrain', type: 'boolean', default: true }, { key: 'rows', type: 'select', options: ['compact', 'roomy'] }, ], config: { topo: true }, icon: { $type: 'blob', ref: new CID(await createBlobCid(new Uint8Array([1]))), mimeType: 'image/svg+xml', size: 1, }, createdAt: '2026-08-11T00:00:00.000Z', }); records.set(`at://${DID}/dev.pdsjs.app.install/3installtid`, { uri: `at://${DID}/dev.pdsjs.app.install/3installtid`, cid: cidToString(await createCid(installValue)), value: installValue, }); } const actorStorage = /** @type {any} */ ({ getDid: async () => DID, getHandle: async () => 'user.example.com', getAccountStatus: async () => 'active', getRecord: async (/** @type {string} */ uri) => records.get(uri) || null, listRecords: async (/** @type {string} */ collection) => ({ records: [...records.values()] .filter((row) => row.uri.includes(`/${collection}/`)) .map((row) => ({ ...row })), cursor: null, }), putBlock: async () => {}, putRecord: async ( /** @type {string} */ uri, /** @type {string} */ cid, /** @type {string} */ _collection, /** @type {string} */ _rkey, /** @type {Uint8Array} */ value, ) => { records.set(uri, { uri, cid, value }); }, deleteRecord: async (/** @type {string} */ uri) => { deleted.push(uri); records.delete(uri); }, unlinkBlobsFromRecord: async () => {}, }); const siteBrowser = opts.sites === false ? null : createSiteBrowser({ actorStorage, getDid: async () => DID, siteDomain: 'example.com', }); const pds = new PersonalDataServer({ actorStorage, sharedStorage: /** @type {any} */ ({}), blobs: /** @type {any} */ ({}), jwtSecret: 'account-api-secret', hostname: PDS_HOST, password: PASSWORD, readOnly: opts.readOnly, extensions: [ (host) => ({ name: 'sites', collections: [ 'dev.pdsjs.site.deploy', 'dev.pdsjs.query.def', 'dev.pdsjs.app.install', 'dev.pdsjs.app.manifest', ], routes: createSiteAccountRoutes({ host, browser: siteBrowser, installer: /** @type {any} */ (opts.installer ?? null), }), features: { sites: Boolean(siteBrowser) }, }), ], }); // Commit and reap run their full course in integration; here the API // contract is under test, not the repo machinery. const commits = /** @type {Array<{action: string, uri: string}[]>} */ ([]); pds.createCommit = /** @type {any} */ ( async (/** @type {string} */ _did, /** @type {any[]} */ writes) => { commits.push(writes); } ); let reaped = 0; pds.reapOrphanedBlobs = /** @type {any} */ ( async () => { reaped++; return []; } ); return { pds, deleted, commits, reapCount: () => reaped }; } /** @param {PersonalDataServer} pds */ async function signInCookie(pds) { const response = await pds.fetch( new Request(`https://${PDS_HOST}/account/sign-in`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username: DID, password: PASSWORD, }).toString(), }), ); const header = response.headers.get('set-cookie') || ''; const value = decodeURIComponent( header.match(/pdsjs_account=([^;]*)/)?.[1] ?? '', ); expect(value).not.toBe(''); return `pdsjs_account=${encodeURIComponent(value)}`; } /** * @param {PersonalDataServer} pds * @param {string} path * @param {{cookie?: string, method?: string, body?: unknown}} [opts] */ async function api(pds, path, opts = {}) { const response = await pds.fetch( new Request(`https://${PDS_HOST}${path}`, { method: opts.method || 'GET', headers: { ...(opts.cookie ? { Cookie: opts.cookie } : {}), ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), }, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, }), ); return { status: response.status, body: await response.json() }; } describe('/account/api/sites', () => { it('requires a session', async () => { const { pds } = await makePds(); const { status } = await api(pds, '/account/api/sites'); expect(status).toBe(401); }); it('lists sites through the browser port', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status, body } = await api(pds, '/account/api/sites', { cookie }); expect(status).toBe(200); expect(body).toMatchObject({ enabled: true, domain: 'example.com', apexName: 'home', apexShadowed: false, }); expect(body.sites).toHaveLength(1); expect(body.sites[0]).toMatchObject({ name: 'home', url: 'https://example.com', fileCount: 1, servable: true, }); }); it('reports disabled without a browser port', async () => { const { pds } = await makePds({ sites: false }); const cookie = await signInCookie(pds); const { body } = await api(pds, '/account/api/sites', { cookie }); expect(body).toEqual({ enabled: false, sites: [] }); }); }); describe('/account/api/sites/files', () => { it('lists the served files of a named site', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status, body } = await api( pds, '/account/api/sites/files?name=home', { cookie }, ); expect(status).toBe(200); expect(body.files).toHaveLength(1); expect(body.files[0]).toMatchObject({ path: 'index.html', contentType: 'text/html', }); expect(body.files[0].cid).toMatch(/^b/); }); it('rejects unknown sites and missing names', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const missing = await api(pds, '/account/api/sites/files', { cookie }); expect(missing.status).toBe(400); const unknown = await api(pds, '/account/api/sites/files?name=nope', { cookie, }); expect(unknown.status).toBe(400); expect(unknown.body.error).toMatch(/No such site/); }); }); describe('/account/api/installs', () => { it('lists installs with the snapshot the page renders', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const { status, body } = await api(pds, '/account/api/installs', { cookie, }); expect(status).toBe(200); expect(body.installs).toHaveLength(1); expect(body.installs[0]).toMatchObject({ name: 'Photos', site: 'home' }); expect(body.installs[0].iconUrl).toMatch(/getBlob/); expect(body.installs[0].settings).toHaveLength(2); }); it('saves config against the snapshot schema and rejects what it refuses', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const uri = `at://${DID}/dev.pdsjs.app.install/3installtid`; const ok = await api(pds, '/account/api/installs/config', { cookie, method: 'POST', body: { uri, config: { topo: false, rows: 'compact' } }, }); expect(ok.status).toBe(200); const listed = await api(pds, '/account/api/installs', { cookie }); expect(listed.body.installs[0].config).toEqual({ topo: false, rows: 'compact', }); for (const [body, error] of /** @type {Array<[Object, RegExp]>} */ ([ [{ uri, config: { nope: 1 } }, /No such setting/], [{ uri, config: { topo: 'yes' } }, /true or false/], [{ uri, config: { rows: 'wide' } }, /not one of/], [ { uri: `at://${DID}/dev.pdsjs.app.install/none`, config: {} }, /No such install/, ], [ { uri: 'at://did:plc:other/dev.pdsjs.app.install/x', config: {} }, /Name the install/, ], [{ uri, config: [] }, /as an object/], ])) { const bad = await api(pds, '/account/api/installs/config', { cookie, method: 'POST', body, }); expect(bad.status).toBe(400); expect(bad.body.error).toMatch(error); } }); it('delegates preview, install, and rename to the installer port', async () => { const calls = /** @type {string[]} */ ([]); const installer = { preview: async (/** @type {string} */ uri) => { calls.push(`preview:${uri}`); return { name: 'Photos' }; }, install: async ( /** @type {string} */ uri, /** @type {any} */ options, ) => { calls.push(`install:${uri}:${options.site}`); return { site: options.site || 'photos', name: 'Photos' }; }, rename: async (/** @type {string} */ uri, /** @type {string} */ name) => { calls.push(`rename:${name}`); return { site: name }; }, checkUpdate: async () => ({ currentCid: 'bafym2', changed: true }), }; const { pds } = await makePds({ installer }); const cookie = await signInCookie(pds); const preview = await api(pds, '/account/api/installs/preview', { cookie, method: 'POST', body: { uri: 'at://dev/dev.pdsjs.app.manifest/photos' }, }); expect(preview.status).toBe(200); expect(preview.body.name).toBe('Photos'); const install = await api(pds, '/account/api/installs/install', { cookie, method: 'POST', body: { uri: 'at://dev/dev.pdsjs.app.manifest/photos', site: 'pics', config: { topo: false }, }, }); expect(install.status).toBe(200); expect(install.body.site).toBe('pics'); const rename = await api(pds, '/account/api/installs/rename', { cookie, method: 'POST', body: { uri: `at://${DID}/dev.pdsjs.app.install/3installtid`, site: 'pics', }, }); expect(rename.status).toBe(200); const updates = await api(pds, '/account/api/installs/updates', { cookie }); expect(updates.status).toBe(200); expect( updates.body.updates[`at://${DID}/dev.pdsjs.app.install/3installtid`], ).toEqual({ currentCid: 'bafym2', changed: true }); expect(calls).toEqual([ 'preview:at://dev/dev.pdsjs.app.manifest/photos', 'install:at://dev/dev.pdsjs.app.manifest/photos:pics', 'rename:pics', ]); }); it('reports the feature off without an installer, and blocks writes read-only', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); for (const path of [ '/account/api/installs/preview', '/account/api/installs/install', '/account/api/installs/rename', ]) { const res = await api(pds, path, { cookie, method: 'POST', body: { uri: 'at://x/dev.pdsjs.app.manifest/y', site: 'z' }, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/not enabled/); } const updates = await api(pds, '/account/api/installs/updates', { cookie }); expect(updates.body.updates).toEqual({}); const readOnly = await makePds({ readOnly: true, installer: { install: async () => ({ site: 'x', name: 'x' }) }, }); const roCookie = await signInCookie(readOnly.pds); const blocked = await api(readOnly.pds, '/account/api/installs/install', { cookie: roCookie, method: 'POST', body: { uri: 'at://x/dev.pdsjs.app.manifest/y' }, }); expect(blocked.status).toBe(403); }); it('rejects an update check with missing fields quietly and bad rename input loudly', async () => { const installer = { rename: async () => ({ site: 'x' }), checkUpdate: async () => { throw new Error('unreachable developer'); }, }; const { pds } = await makePds({ installer }); const cookie = await signInCookie(pds); // A developer that cannot be reached is omitted, not an error const updates = await api(pds, '/account/api/installs/updates', { cookie }); expect(updates.status).toBe(200); expect(updates.body.updates).toEqual({}); const noSite = await api(pds, '/account/api/installs/rename', { cookie, method: 'POST', body: { uri: `at://${DID}/dev.pdsjs.app.install/3installtid` }, }); expect(noSite.status).toBe(400); expect(noSite.body.error).toMatch(/new subdomain/); const wrongRepo = await api(pds, '/account/api/installs/rename', { cookie, method: 'POST', body: { uri: 'at://did:plc:other/dev.pdsjs.app.install/x', site: 'y' }, }); expect(wrongRepo.status).toBe(400); const noUri = await api(pds, '/account/api/installs/preview', { cookie, method: 'POST', body: {}, }); expect(noUri.status).toBe(400); }); }); describe('/account/api/sites/delete', () => { it('deletes the record, commits, and reaps orphans', async () => { const { pds, deleted, commits, reapCount } = await makePds(); const cookie = await signInCookie(pds); const { status } = await api(pds, '/account/api/sites/delete', { cookie, method: 'POST', body: { name: 'home' }, }); expect(status).toBe(200); expect(deleted).toEqual([`at://${DID}/dev.pdsjs.site.deploy/home`]); expect(commits).toEqual([ [{ action: 'delete', uri: `at://${DID}/dev.pdsjs.site.deploy/home` }], ]); expect(reapCount()).toBe(1); }); it('rejects unknown sites and missing names', async () => { const { pds } = await makePds(); const cookie = await signInCookie(pds); const missing = await api(pds, '/account/api/sites/delete', { cookie, method: 'POST', body: { name: 'nope' }, }); expect(missing.status).toBe(400); expect(missing.body.error).toMatch(/No such site/); const unnamed = await api(pds, '/account/api/sites/delete', { cookie, method: 'POST', body: {}, }); expect(unnamed.status).toBe(400); }); it('is blocked in read-only mode while listing still works', async () => { const { pds, deleted } = await makePds({ readOnly: true }); const cookie = await signInCookie(pds); const list = await api(pds, '/account/api/sites', { cookie }); expect(list.status).toBe(200); const del = await api(pds, '/account/api/sites/delete', { cookie, method: 'POST', body: { name: 'home' }, }); expect(del.status).toBe(403); expect(deleted).toHaveLength(0); }); });