sc-site-sync/lib/capture.js
2026-10-19 01:25:16 -05:00

184 lines
8.4 KiB
JavaScript

// Desired-state capture.
//
// A site's DESIRED STATE is Saltcorn's own snapshot-mode pack: metadata (tables
// schema, views, pages, page_groups, triggers, roles, library, tags, models,
// code_pages) plus a config map. We delegate the whole serialization to
// backup.create_pack_json(false, true) -- the exact object Saltcorn's Snapshot
// feature hashes -- then apply site-sync's security sanitization so the captured
// state is safe to hand to a peer AND safe to feed into install_pack on apply:
//
// * PLUGINS are reduced to PARITY info only: {name, source, location, version}.
// plugin_pack embeds `configuration` and the secret `deploy_private_key`;
// shipping those would leak secrets and could reconfigure a sibling plugin
// (e.g. idp redirect_uris). site-sync never pushes plugin config.
// * CONFIG is intersected with SYNCABLE_CONFIG_KEYS. install_pack's setConfig
// clobbers unconditionally, so this allow-list is the only guard against a
// compromised source overwriting security config (disable_csrf_routes, ...).
// * EVENT_LOGS and MODEL_INSTANCES are forced to [] (snapshot mode already drops
// them, and install_pack's EventLog.create is append-only / non-idempotent).
// * CODE_PAGES (server-side JavaScript) stay in the pack as a first-class kind:
// diff.js previews/prunes them like any other entity, so install_pack's
// code-page write is never invisible.
//
// The capture is hashed (sha256 of the sanitized-pack JSON) so a sync of an
// unchanged source short-circuits to an empty plan: both sides sanitize
// identically and create_pack_json emits a fixed key order, so equal states hash
// equal.
const { sha256Hex } = require("./common/hash");
const { SYNCABLE_CONFIG_KEYS, isInfraName, isLockedTable } = require("./constants");
// site-sync syncs APPLICATION design state, not other plugins' private
// bookkeeping (see isInfraName): a desired-state MIRROR must never propagate or
// (worse) prune infra tables or the per-row identity columns, or a source that
// lacks dev-deploy would drop the target's _dd_* tables and their peer secrets. The
// filter is applied symmetrically on the source's captured state and the target's
// current state, so infra entities never enter the diff at all.
//
// The users table (and any ROW_SYNC_LOCKED_TABLES) must NEVER be synced as a TABLE
// spec: install_pack would apply the pack's min_role_read/min_role_write/
// ownership_formula and create arbitrary fields on it, so a hostile peer could open
// the auth table's write ACL or add fields. We drop the table entry entirely so it
// never reaches install_pack. Views/pages/triggers that merely REFERENCE users are
// kept (login/signup views are legitimate design; the users table always exists on
// the target for them to bind to).
const isExcludedTable = (name) => isInfraName(name) || isLockedTable(name);
// Drop plugin/system-infra + locked tables, infra fields of the remaining tables,
// and views/triggers/models (and tag entries) bound to infra.
const excludeInfra = (pack) => {
pack.tables = pack.tables
.filter((t) => !isExcludedTable(t.name))
.map((t) => Object.assign({}, t, { fields: (t.fields || []).filter((f) => !isInfraName(f.name)) }));
pack.views = pack.views.filter((v) => !isInfraName(v.name) && !isInfraName(v.table));
const infraTriggers = new Set(pack.triggers.filter((t) => isInfraName(t.table_name)).map((t) => t.name));
pack.triggers = pack.triggers.filter((t) => !infraTriggers.has(t.name));
pack.models = pack.models.filter((m) => !isInfraName(m.table_name));
pack.tags = pack.tags.map((tag) => {
const entries = (tag.entries || []).filter((e) =>
!isInfraName(e.table_name) && !isInfraName(e.view_name) && !isInfraName(e.page_name) && !infraTriggers.has(e.trigger_name));
return Object.assign({}, tag, { entries });
});
return pack;
};
// Keep only parity-relevant, non-secret plugin fields.
const sanitizePluginEntry = (pl) => {
return {
name: pl.name,
source: pl.source,
location: pl.location,
version: pl.version
};
};
// Restrict a config map to the syncable allow-list. Missing keys are simply
// absent (so the diff treats them as "not managed", never as a delete).
const pickSyncableConfig = (config) => {
const out = {};
if (!config || typeof config !== "object") {
return out;
}
for (const key of SYNCABLE_CONFIG_KEYS) {
if (Object.prototype.hasOwnProperty.call(config, key)) {
out[key] = config[key];
}
}
return out;
};
// Every array-valued entity kind of a sanitized pack (always present, maybe empty).
const PACK_KINDS = ["tables", "views", "pages", "page_groups", "triggers", "roles", "library", "tags", "models", "code_pages"];
// Canonicalize a pack's array order before hashing so the hash is order-INSENSITIVE.
// create_pack_json builds several kinds via find() with no ORDER BY (roles,
// library, tags, models) and getFields() (DB order); an unrelated apply can reorder
// rows on Postgres, which would otherwise flip the hash and cause a spurious
// precondition-drift refusal. Sorting by the stable key (roles by id, everything
// else by name; fields/constraints within a table) removes that flakiness. Returns
// a deep clone -- the original pack is untouched.
const canonicalizeForHash = (pack) => {
const clone = JSON.parse(JSON.stringify(pack));
const byName = (a, b) => String(a && a.name).localeCompare(String(b && b.name));
if (Array.isArray(clone.roles)) {
clone.roles.sort((a, b) => (a.id || 0) - (b.id || 0));
}
for (const k of PACK_KINDS.filter((kind) => kind !== "roles")) {
if (Array.isArray(clone[k])) {
clone[k].sort(byName);
}
}
for (const t of (clone.tables || [])) {
if (Array.isArray(t.fields)) {
t.fields.sort(byName);
}
if (Array.isArray(t.constraints)) {
t.constraints.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
}
}
return clone;
};
// Stable content hash of a sanitized pack (order-insensitive; see above).
const hashPack = (pack) => {
return sha256Hex(Buffer.from(JSON.stringify(canonicalizeForHash(pack)), "utf8"));
};
// Apply site-sync's security sanitization to a raw pack. Used BOTH to build this
// instance's desired state AND -- critically -- to sanitize a pack RECEIVED from a
// peer before diffing/applying it: the target must never trust the sender's
// sanitization, or a hostile peer could ship an infra table (_dd_peers) or a
// non-allow-listed config key and have it created/clobbered on apply.
const sanitizeDesired = (raw) => {
const pack = Object.assign({}, raw || {});
pack.plugins = (pack.plugins || []).map(sanitizePluginEntry);
pack.config = pickSyncableConfig(pack.config);
pack.event_logs = [];
pack.model_instances = [];
for (const k of PACK_KINDS) {
if (!Array.isArray(pack[k])) {
pack[k] = [];
}
}
excludeInfra(pack);
return pack;
};
// Produce this instance's sanitized desired-state pack. Returns { pack, hash }.
//
// The pack is normalized to PLAIN JSON (JSON round-trip): create_pack_json returns
// class instances for some kinds (e.g. Role objects for `roles`), but a peer's
// desired pack always arrives as plain objects over the JSON transport. lodash
// isEqual treats an instance and a plain object with identical properties as
// UNEQUAL, which would make every such entity falsely diff as "changed" on every
// real sync. Round-tripping the current pack the same way the transport does makes
// both sides structurally identical. (Hash is unaffected -- it is already computed
// over JSON.stringify.)
const buildDesiredState = async () => {
const backup = require("@saltcorn/admin-models/models/backup");
const raw = await backup.create_pack_json(false, true);
const pack = JSON.parse(JSON.stringify(sanitizeDesired(raw)));
// Replace files referenced inside page layouts / view configs / the site logo
// with content-addressed placeholders (symmetric on both sides, so the hash and
// diff are content-based). Their bytes are transferred on apply via /api/file.
await require("./layoutFiles").placeholderize(pack);
return { pack, hash: hashPack(pack) };
};
module.exports = {
buildDesiredState,
sanitizeDesired,
hashPack,
pickSyncableConfig,
PACK_KINDS
};