~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
257 lines · 8.8 KB Raw History
// @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,
  };
}