// 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 };