chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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<string, unknown>} */ (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<string, unknown>} [overrides]
* @returns {Record<string, unknown>}
* @throws {Error} on an override for an unknown key or of the wrong type
*/
export function resolveConfig(schema, overrides = {}) {
/** @type {Record<string, unknown>} */
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<string, unknown>} siteRecord - the local dev.pdsjs.site.deploy value
* @property {Array<{rkey: string, record: Record<string, unknown>}>} queryDefs
* @property {Record<string, unknown>} 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<string, any>}} input.manifest - the developer's manifest, as fetched
* @param {{uri: string, cid: string, value: Record<string, any>}} 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<string, unknown>} [input.overrides] - chosen settings
* @param {Record<string, unknown>} [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<string, unknown>} */
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<string, unknown>} */
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<string, unknown>} */
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<string, unknown>} */
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,
};
}