// @pdsjs/sites - install planning // // The pure half of `pdsjs-site install`: given a developer's manifest and // site records (already fetched), decide what to copy and what to write. // An install is stock atproto traffic, exactly as a deploy is: blob reads // from the developer's PDS, blob uploads and record writes to the host. // Content addressing makes the copy verifiable: every uploaded blob must // come back with the CID the manifest named, or the install aborts. import { isValidSiteName } from './hostname.js'; import { QUERY_COLLECTION, SITE_COLLECTION } from './lexicons.js'; /** * A blob ref in its JSON (XRPC) shape. * @typedef {{$type: 'blob', ref: {$link: string}, mimeType: string, size: number}} JsonBlobRef */ /** * One declared setting from a manifest. * @typedef {Object} SettingField * @property {string} key * @property {string} [label] * @property {'boolean'|'string'|'select'} type * @property {unknown} [default] * @property {string[]} [options] - for select */ /** * The manifest's settings schema, dropped to the entries this code can * render and validate. Unknown field types are removed rather than carried. * @param {unknown} raw * @returns {SettingField[]} */ export function settingsSchema(raw) { if (!Array.isArray(raw)) return []; /** @type {SettingField[]} */ const fields = []; for (const entry of raw) { const field = /** @type {Record} */ (entry); if (typeof field?.key !== 'string' || field.key === '') continue; if ( field.type !== 'boolean' && field.type !== 'string' && field.type !== 'select' ) { continue; } fields.push({ key: field.key, label: typeof field.label === 'string' ? field.label : field.key, type: field.type, default: field.default, options: field.type === 'select' && Array.isArray(field.options) ? field.options.filter((o) => typeof o === 'string') : undefined, }); } return fields; } /** * Chosen values for a schema: declared defaults, then overrides, each * checked against its field's type. * @param {SettingField[]} schema * @param {Record} [overrides] * @returns {Record} * @throws {Error} on an override for an unknown key or of the wrong type */ export function resolveConfig(schema, overrides = {}) { /** @type {Record} */ const config = {}; for (const field of schema) { if (field.default !== undefined) config[field.key] = field.default; } for (const [key, value] of Object.entries(overrides)) { const field = schema.find((f) => f.key === key); if (!field) throw new Error(`No such setting: ${key}`); if (field.type === 'boolean' && typeof value !== 'boolean') { throw new Error(`Setting ${key} takes true or false.`); } if (field.type === 'string' && typeof value !== 'string') { throw new Error(`Setting ${key} takes a string.`); } if (field.type === 'select') { if (!field.options?.includes(/** @type {string} */ (value))) { throw new Error( `Setting ${key} takes one of: ${(field.options || []).join(', ')}`, ); } } config[key] = value; } return config; } /** * @typedef {Object} InstallPlan * @property {string} localName - site name the install serves under * @property {Array<{path: string, cid: string, contentType: string}>} copies - blobs to move, with the Content-Type to upload under * @property {Record} siteRecord - the local dev.pdsjs.site.deploy value * @property {Array<{rkey: string, record: Record}>} queryDefs * @property {Record} installRecord - the dev.pdsjs.app.install value * @property {{cid: string, contentType: string}|null} icon - the manifest's icon blob to copy, if declared */ /** * Plan an install. * * @param {Object} input * @param {{uri: string, cid: string, value: Record}} input.manifest - the developer's manifest, as fetched * @param {{uri: string, cid: string, value: Record}} input.site - the site record the manifest's strongRef names * @param {string} [input.localName] - site name on the host (default: the developer's site rkey) * @param {Record} [input.overrides] - chosen settings * @param {Record} [input.existingConfig] - a prior install's chosen settings; values still valid under the new schema carry forward * @param {string} [input.now] - createdAt timestamp * @returns {InstallPlan} * @throws {Error} when the pinned site CID does not match, the name is not a DNS label, or a setting is invalid */ export function planInstall({ manifest, site, localName, overrides, existingConfig, now, }) { const pinned = manifest.value.site; if (!pinned?.uri || pinned.uri !== site.uri) { throw new Error('The site record is not the one the manifest names.'); } // The manifest pins its frontend by CID; a drifted site record means the // developer changed the app after this manifest version. Install what the // manifest reviewed or nothing. if (pinned.cid !== site.cid) { throw new Error( `The developer's site has changed since this manifest pinned it (${pinned.cid} vs ${site.cid}). Ask them to publish an updated manifest.`, ); } const suggested = typeof manifest.value.suggestedSite === 'string' ? manifest.value.suggestedSite : ''; const name = localName || suggested || site.uri.split('/').pop() || ''; if (!isValidSiteName(name)) { throw new Error( `"${name}" cannot name a site: names are lowercase DNS labels.`, ); } const files = Array.isArray(site.value.files) ? site.value.files : []; /** @type {InstallPlan['copies']} */ const copies = []; const recordFiles = []; for (const entry of files) { const cid = entry?.blob?.ref?.$link; if (typeof entry?.path !== 'string' || typeof cid !== 'string') continue; copies.push({ path: entry.path, cid, contentType: entry.contentType || entry.blob.mimeType, }); recordFiles.push({ path: entry.path, blob: entry.blob, ...(entry.contentType ? { contentType: entry.contentType } : {}), ...(entry.cacheControl ? { cacheControl: entry.cacheControl } : {}), }); } if (copies.length === 0) { throw new Error("The developer's site record holds no files."); } const created = now || new Date().toISOString(); // The site record copies verbatim, createdAt included: the local record // then has the very CID the manifest pinned, so a reinstall of an // unchanged app never moves anything. /** @type {Record} */ const siteRecord = { $type: SITE_COLLECTION, files: recordFiles, createdAt: typeof site.value.createdAt === 'string' ? site.value.createdAt : created, }; if (typeof site.value.fallback === 'string') { siteRecord.fallback = site.value.fallback; } const queryDefs = []; if (Array.isArray(manifest.value.queries)) { for (const query of manifest.value.queries) { if ( typeof query?.name !== 'string' || typeof query?.collection !== 'string' ) { continue; } /** @type {Record} */ const record = { $type: QUERY_COLLECTION, collection: query.collection, createdAt: created, }; if (query.where) record.where = query.where; if (query.sort) record.sort = query.sort; queryDefs.push({ rkey: query.name, record }); } } const schema = settingsSchema(manifest.value.settings); // An update keeps the user's earlier choices where the new schema still // accepts them; explicit overrides win over both. /** @type {Record} */ const carried = {}; for (const [key, value] of Object.entries(existingConfig || {})) { try { resolveConfig(schema, { [key]: value }); carried[key] = value; } catch {} } const config = resolveConfig(schema, { ...carried, ...overrides }); /** @type {Record} */ const installRecord = { $type: 'dev.pdsjs.app.install', manifest: { uri: manifest.uri, cid: manifest.cid }, site: name, createdAt: created, }; if (typeof manifest.value.name === 'string') { installRecord.name = manifest.value.name; } if (schema.length) { installRecord.settings = schema; installRecord.config = config; } // The manifest's icon is copied like the site files, so the installed // app's identity survives the developer's repo the same way its code does. /** @type {{cid: string, contentType: string}|null} */ let icon = null; const iconRef = manifest.value.icon; if (iconRef?.ref?.$link) { icon = { cid: iconRef.ref.$link, contentType: iconRef.mimeType }; installRecord.icon = iconRef; } return { localName: name, copies, siteRecord, queryDefs, installRecord, icon, }; }