/** * The /account/api/sites and /account/api/installs routes: the deployed * sites, and the apps installed from manifests, each behind the owner's * account session. The install listing and its settings need no site * domain; previewing, installing and renaming need the installer, which * does. * * This module must stay free of node-only imports; it runs on Cloudflare * Workers. */ import { cborDecode, cborEncodeDagCbor, cidToString, createCid, toDisplayValue, } from '@pdsjs/core/repo'; /** * @param {Object} ctx * @param {import('@pdsjs/core/ports').ExtensionHost} ctx.host * @param {import('./ports.js').SiteBrowserPort|null} ctx.browser - Absent, the sites listing reports the feature off and the site routes refuse. * @param {import('./ports.js').SiteInstallerPort|null} ctx.installer - Absent, previewing, installing and renaming report installs off. * @returns {Record} */ export function createSiteAccountRoutes({ host, browser, installer }) { const { accountApi, actorStorage, createCommit, hostname, reapOrphanedBlobs, } = host; /** * GET /account/api/sites - The deployed sites, each marked as an app * install's frontend or a published app's artifact where it is one. * @param {Request} request @param {URL} url */ async function handleApiSites(request, url) { return accountApi(request, url, async (did) => { if (!browser) return { enabled: false, sites: [] }; // A site can be an app install's frontend, or the artifact a local // manifest publishes. The page shows installs in their own section // and marks artifacts, so the roles need naming here. const installedNames = new Set(); for (const record of ( await actorStorage.listRecords('dev.pdsjs.app.install', null, 100) ).records) { const value = /** @type {{site?: unknown}} */ ( cborDecode( record.value instanceof Uint8Array ? record.value : new Uint8Array(record.value), ) ); if (typeof value.site === 'string') installedNames.add(value.site); } /** @type {Map} */ const publishedBy = new Map(); for (const record of ( await actorStorage.listRecords('dev.pdsjs.app.manifest', null, 100) ).records) { const value = /** @type {{site?: {uri?: unknown}}} */ ( toDisplayValue( cborDecode( record.value instanceof Uint8Array ? record.value : new Uint8Array(record.value), ), ) ); if (typeof value.site?.uri === 'string') { publishedBy.set(value.site.uri, record.uri); } } const sites = (await browser.listSites()).map((site) => { const manifestUri = publishedBy.get( `at://${did}/dev.pdsjs.site.deploy/${site.name}`, ); return { ...site, installed: installedNames.has(site.name), publishedApp: Boolean(manifestUri), manifestUri: manifestUri || null, }; }); return { enabled: true, domain: browser.domain, apexName: browser.apexName, // The PDS answering at the site apex shadows the apex site; the // sites page warns when it detects this. apexShadowed: (hostname || '').split(':')[0].toLowerCase() === browser.domain.toLowerCase(), sites, }; }); } /** * GET /account/api/installs - Installed apps, from dev.pdsjs.app.install * records: the site each serves as, the manifest it pins, and the * settings schema snapshot with current values. * @param {Request} request @param {URL} url */ async function handleApiInstalls(request, url) { return accountApi(request, url, async () => { const page = await actorStorage.listRecords( 'dev.pdsjs.app.install', null, 100, ); const installs = []; for (const record of page.records) { const value = /** @type {Record} */ ( cborDecode( record.value instanceof Uint8Array ? record.value : new Uint8Array(record.value), ) ); const iconRef = /** @type {{ref?: {$link?: string}}|undefined} */ ( value.icon ); const iconCid = iconRef?.ref instanceof Uint8Array ? cidToString(iconRef.ref) : typeof iconRef?.ref?.$link === 'string' ? iconRef.ref.$link : null; installs.push({ uri: record.uri, name: typeof value.name === 'string' ? value.name : null, site: typeof value.site === 'string' ? value.site : null, iconUrl: iconCid ? `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(record.uri.split('/')[2])}&cid=${iconCid}` : null, manifest: toDisplayValue(value.manifest), settings: Array.isArray(value.settings) ? value.settings : [], config: value.config && typeof value.config === 'object' ? value.config : {}, createdAt: typeof value.createdAt === 'string' ? value.createdAt : null, }); } return { installs }; }); } /** * POST /account/api/installs/preview - The install sheet's view of a * manifest at-uri: name, author, icon, settings, what it will copy. * @param {Request} request @param {URL} url */ async function handleApiInstallPreview(request, url) { return accountApi(request, url, async (_did, body) => { if (!installer) throw new Error('Installs are not enabled.'); const uri = typeof body?.uri === 'string' ? body.uri.trim() : ''; if (!uri) throw new Error('Name the app to preview.'); return installer.preview(uri); }); } /** * POST /account/api/installs/install - Install or update an app from its * manifest at-uri. The installer copies blobs with local hashing and * writes every record in one commit. * @param {Request} request @param {URL} url */ async function handleApiInstallRun(request, url) { return accountApi( request, url, async (_did, body) => { if (!installer) throw new Error('Installs are not enabled.'); const uri = typeof body?.uri === 'string' ? body.uri.trim() : ''; if (!uri) throw new Error('Name the app to install.'); return installer.install(uri, { site: typeof body?.site === 'string' ? body.site : undefined, config: body?.config && typeof body.config === 'object' ? /** @type {Record} */ (body.config) : undefined, }); }, { write: true }, ); } /** * POST /account/api/installs/rename - Move an install's site to another * subdomain label. The site record moves byte for byte, so its CID and * the manifest pin are untouched. * @param {Request} request @param {URL} url */ async function handleApiInstallRename(request, url) { return accountApi( request, url, async (did, body) => { if (!installer) throw new Error('Installs are not enabled.'); const uri = typeof body?.uri === 'string' ? body.uri : ''; const site = typeof body?.site === 'string' ? body.site.trim() : ''; if (!uri.startsWith(`at://${did}/dev.pdsjs.app.install/`)) { throw new Error('Name the install to rename.'); } if (!site) throw new Error('Name the new subdomain.'); return installer.rename(uri, site); }, { write: true }, ); } /** * GET /account/api/installs/updates - For each install, whether the * developer's manifest moved past the pinned version. An unreachable * developer is omitted rather than failing the check for the rest. * @param {Request} request @param {URL} url */ async function handleApiInstallUpdates(request, url) { return accountApi(request, url, async () => { if (!installer) return { updates: {} }; const page = await actorStorage.listRecords( 'dev.pdsjs.app.install', null, 100, ); /** @type {Record} */ const updates = {}; await Promise.all( page.records.map(async (record) => { const value = /** @type {{manifest?: {uri?: string, cid?: string}}} */ ( toDisplayValue( cborDecode( record.value instanceof Uint8Array ? record.value : new Uint8Array(record.value), ), ) ); const uri = value.manifest?.uri; const cid = value.manifest?.cid; if (typeof uri !== 'string' || typeof cid !== 'string') return; try { updates[record.uri] = await installer.checkUpdate(uri, cid); } catch {} }), ); return { updates }; }); } /** * POST /account/api/installs/config - Replace an install's chosen * settings. Values are checked against the record's own schema snapshot: * only declared keys, each of its declared type. * @param {Request} request @param {URL} url */ async function handleApiInstallConfig(request, url) { return accountApi( request, url, async (did, body) => { const uri = typeof body?.uri === 'string' ? body.uri : ''; const config = body?.config; if (!uri.startsWith(`at://${did}/dev.pdsjs.app.install/`)) { throw new Error('Name the install to configure.'); } if (!config || typeof config !== 'object' || Array.isArray(config)) { throw new Error('Send the chosen settings as an object.'); } const stored = await actorStorage.getRecord(uri); if (!stored) throw new Error('No such install.'); const value = /** @type {Record} */ ( cborDecode( stored.value instanceof Uint8Array ? stored.value : new Uint8Array(stored.value), ) ); const schema = Array.isArray(value.settings) ? value.settings : []; for (const [key, chosen] of Object.entries(config)) { const field = /** @type {{key?: string, type?: string, options?: unknown[]}|undefined} */ ( schema.find((f) => /** @type {{key?: string}} */ (f)?.key === key) ); if (!field) throw new Error(`No such setting: ${key}`); if (field.type === 'boolean' && typeof chosen !== 'boolean') { throw new Error(`Setting ${key} takes true or false.`); } if (field.type === 'string' && typeof chosen !== 'string') { throw new Error(`Setting ${key} takes a string.`); } if ( field.type === 'select' && !(Array.isArray(field.options) && field.options.includes(chosen)) ) { throw new Error(`Setting ${key} is not one of its options.`); } } const updated = { ...value, config }; const encoded = cborEncodeDagCbor(updated); const cid = cidToString(await createCid(encoded)); const rkey = uri.split('/').pop() || ''; await actorStorage.putBlock(cid, encoded); await actorStorage.putRecord( uri, cid, 'dev.pdsjs.app.install', rkey, encoded, ); await createCommit(did, [{ action: 'update', uri, cid }]); return {}; }, { write: true }, ); } /** @param {Request} request @param {URL} url */ async function handleApiSiteFiles(request, url) { return accountApi(request, url, async () => { const name = url.searchParams.get('name') || ''; if (!name) throw new Error('Name the site to list.'); if (!browser) throw new Error('Sites are not enabled.'); const files = await browser.listFiles(name); if (!files) throw new Error('No such site.'); return { files }; }); } /** @param {Request} request @param {URL} url */ async function handleApiSiteDelete(request, url) { return accountApi( request, url, async (did, body) => { const name = typeof body?.name === 'string' ? body.name : ''; if (!name) throw new Error('Name the site to delete.'); if (!browser) throw new Error('Sites are not enabled.'); const uri = `at://${did}/dev.pdsjs.site.deploy/${name}`; if (!(await actorStorage.getRecord(uri))) { throw new Error('No such site.'); } // The same sequence deleteRecord runs: unlink, delete, commit, then // reap this site's now-orphaned blobs (guarded against a stale link // table, so blobs other records reference survive). await actorStorage.unlinkBlobsFromRecord(uri); await actorStorage.deleteRecord(uri); await createCommit(did, [{ action: 'delete', uri }]); await reapOrphanedBlobs(did, Date.now() + 1000); return {}; }, { write: true }, ); } return { '/account/api/sites': { method: 'GET', handler: handleApiSites, }, '/account/api/installs': { method: 'GET', handler: handleApiInstalls, }, '/account/api/installs/config': { method: 'POST', handler: handleApiInstallConfig, }, '/account/api/installs/preview': { method: 'POST', handler: handleApiInstallPreview, }, '/account/api/installs/install': { method: 'POST', handler: handleApiInstallRun, }, '/account/api/installs/updates': { method: 'GET', handler: handleApiInstallUpdates, }, '/account/api/installs/rename': { method: 'POST', handler: handleApiInstallRename, }, '/account/api/sites/files': { method: 'GET', handler: handleApiSiteFiles, }, '/account/api/sites/delete': { method: 'POST', handler: handleApiSiteDelete, }, }; }