Initial commit

This commit is contained in:
Scott Duensing 2026-05-17 17:31:49 -05:00
commit 4d62c45b8d
30 changed files with 6497 additions and 0 deletions

44
.gitattributes vendored Normal file
View file

@ -0,0 +1,44 @@
# Default line-ending handling for text files.
* text=auto
# Git LFS. Run `git lfs install` once per clone to activate the filters below.
# Nothing in the project currently requires LFS, but these patterns catch
# common binary blobs so anyone dropping one into the tree gets it tracked
# correctly without remembering to update this file.
# Archives
*.zip filter=lfs diff=lfs merge=lfs -text
*.tar filter=lfs diff=lfs merge=lfs -text
*.tar.gz filter=lfs diff=lfs merge=lfs -text
*.tgz filter=lfs diff=lfs merge=lfs -text
*.gz filter=lfs diff=lfs merge=lfs -text
*.bz2 filter=lfs diff=lfs merge=lfs -text
*.7z filter=lfs diff=lfs merge=lfs -text
# Databases / snapshots
*.sqlite filter=lfs diff=lfs merge=lfs -text
*.sqlite3 filter=lfs diff=lfs merge=lfs -text
*.db filter=lfs diff=lfs merge=lfs -text
# Images
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text
*.ico filter=lfs diff=lfs merge=lfs -text
# Documents
*.pdf filter=lfs diff=lfs merge=lfs -text
# Fonts
*.ttf filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text
*.woff filter=lfs diff=lfs merge=lfs -text
*.woff2 filter=lfs diff=lfs merge=lfs -text
# Audio / video
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.webm filter=lfs diff=lfs merge=lfs -text

21
.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
# Upstream Saltcorn checkout. It has its own .git and remote; manage it via
# `git -C saltcorn ...` or re-clone via ./installSaltcorn.sh.
/saltcorn/
# Per-instance runtime state. Contains SQLite DB, uploaded files, and an
# env.sh with a generated session secret -- none of which should be shared.
# Recreate via installSaltcorn.sh.
/.dev-state/
/.dev-state-test/
# npm output (in case dev-deploy or future siblings ever take deps).
node_modules/
# Editor / OS junk.
.DS_Store
Thumbs.db
*.swp
*.swo
*~
.idea/
.vscode/

62
dev-deploy/index.js Normal file
View file

@ -0,0 +1,62 @@
// dev-deploy: Saltcorn plugin for migrating metadata changes across
// Dev/Test/Prod environments via an ops journal with stable UUIDs.
const { PLUGIN_NAME, PLUGIN_VERSION } = require("./lib/constants");
const { createAllTables } = require("./lib/schema");
const { getEnv, initEnvIfMissing, markBootstrapped } = require("./lib/env");
const { backfillAll } = require("./lib/entityIds");
const { installAllWraps } = require("./lib/wrap");
const { routes } = require("./lib/routes");
const log = (msg) => {
// eslint-disable-next-line no-console
console.log(`[${PLUGIN_NAME}] ${msg}`);
};
const ensureCsrfBypass = async () => {
try {
const { getState } = require("@saltcorn/data/db/state");
const current = getState().getConfig("disable_csrf_routes", "");
const want = "/dev-deploy/api/";
const entries = current.split(",").map((s) => s.trim()).filter(Boolean);
if (!entries.includes(want)) {
entries.push(want);
await getState().setConfig("disable_csrf_routes", entries.join(","));
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] failed to register csrf bypass:`, e);
}
};
const onLoad = async (cfg) => {
try {
await createAllTables();
const env = await initEnvIfMissing();
if (!env.bootstrapped_at) {
const counts = await backfillAll();
const total = Object.values(counts).reduce((a, b) => a + b, 0);
await markBootstrapped(env.env_id);
log(`v${PLUGIN_VERSION} bootstrapped env_id=${env.env_id} backfilled ${total} entities ${JSON.stringify(counts)}`);
} else {
log(`v${PLUGIN_VERSION} loaded env_id=${env.env_id}`);
}
installAllWraps();
await ensureCsrfBypass();
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] onLoad failed:`, err);
throw err;
}
};
module.exports = {
sc_plugin_api_version: 1,
plugin_name: PLUGIN_NAME,
onLoad: onLoad,
routes: routes
};

1135
dev-deploy/lib/apply.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,65 @@
// Compile-time constants for the dev-deploy plugin.
const PLUGIN_NAME = "dev-deploy";
const PLUGIN_VERSION = "0.0.1";
// Namespace UUID for deterministic IDs derived from (kind, name).
// Generated once via crypto.randomUUID() and frozen here forever.
// Two environments that bootstrap from the same metadata population
// will assign identical UUIDs to entities with the same (kind, name).
const ID_NAMESPACE = "8b3a1e0d-4f6c-4d2a-9c5b-7e8f9a0b1c2d";
const OP_SCHEMA_VERSION = 1;
const DATA_MODES = {
MANAGED: "managed",
STARTER: "starter",
USER: "user"
};
const DESTRUCTIVE_POLICY = {
AUTO: "auto",
CONFIRM: "confirm",
REFUSE: "refuse"
};
// Entity kinds the plugin tracks. Children point at parents via parent_uuid.
const ENTITY_KINDS = {
TABLE: "table",
FIELD: "field",
VIEW: "view",
PAGE: "page",
TRIGGER: "trigger",
ROLE: "role",
LIBRARY: "library",
TAG: "tag",
CONSTRAINT: "constraint",
FILE: "file",
PAGE_GROUP: "page_group",
PAGE_GROUP_MEMBER: "page_group_member",
WORKFLOW_STEP: "workflow_step"
};
// Files don't have a meaningful integer id in current Saltcorn (File.create
// doesn't insert into _sc_files anymore — files are identified by their disk
// location and metadata via xattrs). We derive a stable 31-bit int from the
// location string so files fit the existing UNIQUE(kind, current_id) shape of
// _dd_entity_ids without a schema change.
const fileLocationToId = (location) => {
const crypto = require("crypto");
const hash = crypto.createHash("sha256").update(String(location)).digest();
return hash.readUInt32BE(0) & 0x7FFFFFFF;
};
module.exports = {
PLUGIN_NAME,
PLUGIN_VERSION,
ID_NAMESPACE,
OP_SCHEMA_VERSION,
DATA_MODES,
DESTRUCTIVE_POLICY,
ENTITY_KINDS,
fileLocationToId
};

65
dev-deploy/lib/context.js Normal file
View file

@ -0,0 +1,65 @@
// Per-async-flow context for the mutation wraps.
//
// AsyncLocalStorage gives every wrap a stack of in-flight op_ids so that ops
// triggered while another op is running (e.g. cascading field deletes inside
// a table delete) can attach themselves to the outer op as parent_op_id, and
// share its correlation_id.
const { AsyncLocalStorage } = require("async_hooks");
const { randomUuid } = require("./ids");
const storage = new AsyncLocalStorage();
const enterOp = async (opId, fn) => {
const current = storage.getStore();
const correlationId = current ? current.correlationId : randomUuid();
const parentStack = current ? current.stack : [];
const newStore = {
stack: [...parentStack, opId],
correlationId: correlationId
};
return await storage.run(newStore, fn);
};
const currentParentOpId = () => {
const store = storage.getStore();
if (!store || store.stack.length < 2) {
return null;
}
return store.stack[store.stack.length - 2];
};
const currentCorrelationId = () => {
const store = storage.getStore();
return store ? store.correlationId : null;
};
// Suppression mode: when applying ingested ops we run the underlying Saltcorn
// model methods but want the CRUD wraps to pass through silently — no journal
// writes, no entity_id assignment. The apply handler manages those side effects
// itself with the source-env's UUID.
const runSuppressed = async (fn) => {
const current = storage.getStore() || { stack: [], correlationId: null };
return await storage.run({ ...current, suppressed: true }, fn);
};
const isSuppressed = () => {
const s = storage.getStore();
return !!(s && s.suppressed);
};
module.exports = {
enterOp,
currentParentOpId,
currentCorrelationId,
runSuppressed,
isSuppressed
};

150
dev-deploy/lib/crypto.js Normal file
View file

@ -0,0 +1,150 @@
// Crypto primitives for dev-deploy peer auth.
//
// seal/open — AES-256-GCM for at-rest encryption of peer secrets.
// The 32-byte KEK is derived once per process via
// HKDF-SHA256 from SALTCORN_SESSION_SECRET.
// sign/verify — HMAC-SHA256 for signed peer-to-peer requests.
// buildCanonical — canonical string format every signed request agrees on.
// randomSecret — 32 random bytes (peer_secret) at pairing time.
//
// Rotating SALTCORN_SESSION_SECRET invalidates all peer pairings (the KEK
// changes, so existing ciphertexts no longer decrypt). Documented behavior.
const crypto = require("crypto");
const KEK_INFO = "dev-deploy:peer-secrets:aes-gcm-key:v1";
const HMAC_ALGORITHM = "sha256";
const GCM_ALGORITHM = "aes-256-gcm";
const IV_BYTES = 12;
const TAG_BYTES = 16;
const SECRET_BYTES = 32;
const NONCE_BYTES = 16;
const SKEW_TOLERANCE_MS = 5 * 60 * 1000;
let cachedKek = null;
const getSessionSecret = () => {
const fromEnv = process.env.SALTCORN_SESSION_SECRET;
if (fromEnv && fromEnv.length > 0) {
return fromEnv;
}
// Fallback to Saltcorn state config if available
try {
const { getState } = require("@saltcorn/data/db/state");
const v = getState().getConfig("session_secret");
if (v) return v;
} catch (e) {
// ignore
}
throw new Error("dev-deploy: SALTCORN_SESSION_SECRET not available; cannot derive KEK");
};
const getKek = () => {
if (cachedKek) {
return cachedKek;
}
const sessionSecret = getSessionSecret();
const salt = Buffer.from(KEK_INFO, "utf8");
const ikm = Buffer.from(sessionSecret, "utf8");
cachedKek = crypto.hkdfSync(HMAC_ALGORITHM, ikm, salt, Buffer.from(KEK_INFO, "utf8"), 32);
return cachedKek;
};
const seal = (plaintext) => {
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(GCM_ALGORITHM, getKek(), iv);
const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
const tag = cipher.getAuthTag();
return { ciphertext: ct, iv: iv, tag: tag };
};
const open = (sealed) => {
const decipher = crypto.createDecipheriv(GCM_ALGORITHM, getKek(), sealed.iv);
decipher.setAuthTag(sealed.tag);
return Buffer.concat([decipher.update(sealed.ciphertext), decipher.final()]);
};
const randomSecret = () => {
return crypto.randomBytes(SECRET_BYTES);
};
const randomNonce = () => {
return crypto.randomBytes(NONCE_BYTES);
};
const sha256Hex = (data) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data || "");
return crypto.createHash("sha256").update(buf).digest("hex");
};
const buildCanonical = ({ timestamp, nonce, method, path, body }) => {
const bodyHash = sha256Hex(body || "");
return [
String(timestamp),
String(nonce),
String(method).toUpperCase(),
String(path),
bodyHash
].join("\n");
};
const sign = (secret, canonical) => {
const mac = crypto.createHmac(HMAC_ALGORITHM, secret);
mac.update(canonical, "utf8");
return mac.digest("hex");
};
const verifySignature = (secret, canonical, providedHex) => {
if (!providedHex || typeof providedHex !== "string") {
return false;
}
let expectedBuf;
let providedBuf;
try {
expectedBuf = Buffer.from(sign(secret, canonical), "hex");
providedBuf = Buffer.from(providedHex, "hex");
} catch (e) {
return false;
}
if (expectedBuf.length !== providedBuf.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuf, providedBuf);
};
const timestampWithinSkew = (tsString) => {
const ts = Number(tsString);
if (!Number.isFinite(ts)) {
return false;
}
const now = Date.now();
return Math.abs(now - ts) <= SKEW_TOLERANCE_MS;
};
module.exports = {
seal,
open,
randomSecret,
randomNonce,
buildCanonical,
sign,
verifySignature,
timestampWithinSkew,
sha256Hex,
SKEW_TOLERANCE_MS
};

349
dev-deploy/lib/entityIds.js Normal file
View file

@ -0,0 +1,349 @@
// Stable UUIDs for Saltcorn metadata entities.
//
// Saltcorn core identifies entities by integer id and human name. dev-deploy
// needs a stable identity that survives across environments and renames, so we
// maintain a side-table _dd_entity_ids mapping (kind, current_id) -> uuid.
//
// First-run "backfill" assigns deterministic UUIDs (hash of namespace + kind
// + canonical name) so two environments installed from the same pack converge
// on the same UUIDs without a coordination step.
const db = require("@saltcorn/data/db");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const View = require("@saltcorn/data/models/view");
const Page = require("@saltcorn/data/models/page");
const Trigger = require("@saltcorn/data/models/trigger");
const Role = require("@saltcorn/data/models/role");
const Library = require("@saltcorn/data/models/library");
const Tag = require("@saltcorn/data/models/tag");
const TableConstraint = require("@saltcorn/data/models/table_constraints");
const PageGroup = require("@saltcorn/data/models/page_group");
const WorkflowStep = require("@saltcorn/data/models/workflow_step");
const { deterministicUuid, randomUuid } = require("./ids");
const { ENTITY_KINDS } = require("./constants");
const assignNewUuid = async (kind, currentId, currentName, parentUuid) => {
const row = {
uuid: randomUuid(),
kind: kind,
current_name: currentName,
current_id: currentId,
parent_uuid: parentUuid || null,
created_at: new Date().toISOString()
};
await db.insert("_dd_entity_ids", row, { noid: true });
return row.uuid;
};
const canonicalKey = (kind, entity, parentName) => {
if (kind === ENTITY_KINDS.FIELD) {
return `${parentName || "?"}.${entity.name}`;
}
return entity.name;
};
// Stable fingerprint for a constraint, scoped to its parent table's UUID so
// it's globally unique across the journal. Two instances installed from the
// same base agree on this fingerprint and so derive identical UUIDs.
const constraintFingerprint = (con, parentUuid) => {
const cfg = con.configuration || {};
let detail;
if (con.type === "Unique" && cfg.fields) {
detail = [...cfg.fields].sort().join(",");
} else if (con.type === "Index") {
detail = cfg.field || "";
} else if (con.type === "Formula") {
detail = cfg.formula || "";
} else {
detail = JSON.stringify(cfg);
}
return `${parentUuid || "?"}|${con.type}|${detail}`;
};
// Friendly display name for _dd_entity_ids.current_name. Not used for identity.
const constraintDisplayName = (con) => {
const cfg = con.configuration || {};
if (con.type === "Unique") return `unique(${(cfg.fields || []).join(",")})`;
if (con.type === "Index") return `index(${cfg.field || ""})`;
if (con.type === "Formula") return `formula`;
return `${con.type}_${con.id || "new"}`;
};
const ensureUuid = async (kind, currentId, currentName, parentUuid, canonical) => {
const existing = await db.selectMaybeOne("_dd_entity_ids", { kind: kind, current_id: currentId });
if (existing) {
return existing.uuid;
}
const uuid = deterministicUuid(kind, canonical || currentName);
const row = {
uuid: uuid,
kind: kind,
current_name: currentName,
current_id: currentId,
parent_uuid: parentUuid || null,
created_at: new Date().toISOString()
};
await db.insert("_dd_entity_ids", row, { noid: true });
return uuid;
};
const lookupByCurrent = async (kind, currentId) => {
return await db.selectMaybeOne("_dd_entity_ids", { kind: kind, current_id: currentId });
};
const lookupByUuid = async (uuid) => {
return await db.selectMaybeOne("_dd_entity_ids", { uuid: uuid });
};
// Insert a row with a specific UUID (used by apply.js when ingesting an op
// that created an entity on a peer — we want to preserve the peer's UUID).
const adoptUuid = async (uuid, kind, currentId, currentName, parentUuid) => {
const row = {
uuid: uuid,
kind: kind,
current_name: currentName,
current_id: currentId,
parent_uuid: parentUuid || null,
created_at: new Date().toISOString()
};
await db.insert("_dd_entity_ids", row, { noid: true });
return uuid;
};
const updateName = async (kind, currentId, newName) => {
await db.updateWhere("_dd_entity_ids", { current_name: newName }, { kind: kind, current_id: currentId });
};
const removeEntityRow = async (kind, currentId) => {
await db.deleteWhere("_dd_entity_ids", { kind: kind, current_id: currentId });
};
// Backfill helpers: each returns the count of new UUIDs inserted.
const backfillTables = async () => {
const tables = await Table.find({}, { cached: false });
let added = 0;
for (const t of tables) {
const before = await lookupByCurrent(ENTITY_KINDS.TABLE, t.id);
await ensureUuid(ENTITY_KINDS.TABLE, t.id, t.name, null, t.name);
if (!before) {
added += 1;
}
}
return added;
};
const backfillFields = async () => {
const tables = await Table.find({}, { cached: false });
let added = 0;
for (const t of tables) {
const tableUuidRow = await lookupByCurrent(ENTITY_KINDS.TABLE, t.id);
if (!tableUuidRow) {
continue;
}
const fields = t.getFields ? t.getFields() : (await Field.find({ table_id: t.id }));
for (const f of fields) {
const before = await lookupByCurrent(ENTITY_KINDS.FIELD, f.id);
await ensureUuid(ENTITY_KINDS.FIELD, f.id, f.name, tableUuidRow.uuid, `${t.name}.${f.name}`);
if (!before) {
added += 1;
}
}
}
return added;
};
const backfillViews = async () => {
const views = await View.find({});
let added = 0;
for (const v of views) {
const before = await lookupByCurrent(ENTITY_KINDS.VIEW, v.id);
let parentUuid = null;
if (v.table_id) {
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, v.table_id);
parentUuid = t ? t.uuid : null;
}
await ensureUuid(ENTITY_KINDS.VIEW, v.id, v.name, parentUuid, v.name);
if (!before) {
added += 1;
}
}
return added;
};
const backfillPages = async () => {
const pages = await Page.find({});
let added = 0;
for (const p of pages) {
const before = await lookupByCurrent(ENTITY_KINDS.PAGE, p.id);
await ensureUuid(ENTITY_KINDS.PAGE, p.id, p.name, null, p.name);
if (!before) {
added += 1;
}
}
return added;
};
const backfillTriggers = async () => {
const triggers = Trigger.find({});
let added = 0;
for (const tr of triggers) {
const before = await lookupByCurrent(ENTITY_KINDS.TRIGGER, tr.id);
let parentUuid = null;
if (tr.table_id) {
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, tr.table_id);
parentUuid = t ? t.uuid : null;
}
await ensureUuid(ENTITY_KINDS.TRIGGER, tr.id, tr.name || `trigger_${tr.id}`, parentUuid, tr.name || `trigger_${tr.id}`);
if (!before) {
added += 1;
}
}
return added;
};
const backfillRoles = async () => {
const roles = await Role.find({});
let added = 0;
for (const r of roles) {
const before = await lookupByCurrent(ENTITY_KINDS.ROLE, r.id);
await ensureUuid(ENTITY_KINDS.ROLE, r.id, r.role, null, r.role);
if (!before) {
added += 1;
}
}
return added;
};
const backfillLibrary = async () => {
const items = await Library.find({});
let added = 0;
for (const li of items) {
const before = await lookupByCurrent(ENTITY_KINDS.LIBRARY, li.id);
await ensureUuid(ENTITY_KINDS.LIBRARY, li.id, li.name, null, li.name);
if (!before) {
added += 1;
}
}
return added;
};
const backfillTags = async () => {
const tags = await Tag.find({});
let added = 0;
for (const tg of tags) {
const before = await lookupByCurrent(ENTITY_KINDS.TAG, tg.id);
await ensureUuid(ENTITY_KINDS.TAG, tg.id, tg.name, null, tg.name);
if (!before) {
added += 1;
}
}
return added;
};
const backfillTableConstraints = async () => {
const cons = await TableConstraint.find({});
let added = 0;
for (const c of cons) {
const before = await lookupByCurrent(ENTITY_KINDS.CONSTRAINT, c.id);
let parentUuid = null;
if (c.table_id) {
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, c.table_id);
parentUuid = t ? t.uuid : null;
}
const canonical = constraintFingerprint(c, parentUuid);
await ensureUuid(ENTITY_KINDS.CONSTRAINT, c.id, constraintDisplayName(c), parentUuid, canonical);
if (!before) {
added += 1;
}
}
return added;
};
const backfillPageGroups = async () => {
const groups = await PageGroup.find({});
let added = 0;
for (const g of groups) {
const before = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, g.id);
await ensureUuid(ENTITY_KINDS.PAGE_GROUP, g.id, g.name, null, g.name);
if (!before) {
added += 1;
}
}
return added;
};
const backfillWorkflowSteps = async () => {
const steps = await WorkflowStep.find({});
let added = 0;
for (const s of steps) {
const before = await lookupByCurrent(ENTITY_KINDS.WORKFLOW_STEP, s.id);
let parentUuid = null;
if (s.trigger_id) {
const tr = await lookupByCurrent(ENTITY_KINDS.TRIGGER, s.trigger_id);
parentUuid = tr ? tr.uuid : null;
}
const canonical = `${parentUuid || "?"}|${s.name || s.id}`;
await ensureUuid(ENTITY_KINDS.WORKFLOW_STEP, s.id, s.name || `step_${s.id}`, parentUuid, canonical);
if (!before) {
added += 1;
}
}
return added;
};
const backfillAll = async () => {
const counts = {};
counts.tables = await backfillTables();
counts.fields = await backfillFields();
counts.views = await backfillViews();
counts.pages = await backfillPages();
counts.triggers = await backfillTriggers();
counts.roles = await backfillRoles();
counts.library = await backfillLibrary();
counts.tags = await backfillTags();
counts.constraints = await backfillTableConstraints();
counts.page_groups = await backfillPageGroups();
counts.workflow_steps = await backfillWorkflowSteps();
return counts;
};
module.exports = {
backfillAll,
ensureUuid,
assignNewUuid,
lookupByCurrent,
lookupByUuid,
adoptUuid,
updateName,
removeEntityRow,
canonicalKey,
constraintFingerprint,
constraintDisplayName
};

63
dev-deploy/lib/env.js Normal file
View file

@ -0,0 +1,63 @@
// This Saltcorn instance's dev-deploy identity (env_id, label, policies).
// Stored as a singleton row in _dd_env.
const db = require("@saltcorn/data/db");
const { randomUuid } = require("./ids");
const { DESTRUCTIVE_POLICY } = require("./constants");
let cachedEnv = null;
const getEnv = async () => {
if (cachedEnv) {
return cachedEnv;
}
const rows = await db.select("_dd_env", {});
cachedEnv = rows.length > 0 ? rows[0] : null;
return cachedEnv;
};
const refreshEnvCache = async () => {
cachedEnv = null;
return await getEnv();
};
const initEnvIfMissing = async () => {
const existing = await getEnv();
if (existing) {
return existing;
}
const now = new Date().toISOString();
const row = {
env_id: randomUuid(),
env_label: null,
on_destructive_op: DESTRUCTIVE_POLICY.CONFIRM,
require_tls: 0,
created_at: now,
bootstrapped_at: null
};
await db.insert("_dd_env", row, { noid: true });
cachedEnv = row;
return row;
};
const markBootstrapped = async (envId) => {
const now = new Date().toISOString();
await db.updateWhere("_dd_env", { bootstrapped_at: now }, { env_id: envId });
if (cachedEnv && cachedEnv.env_id === envId) {
cachedEnv.bootstrapped_at = now;
}
};
module.exports = {
getEnv,
initEnvIfMissing,
markBootstrapped,
refreshEnvCache
};

60
dev-deploy/lib/files.js Normal file
View file

@ -0,0 +1,60 @@
// File helpers: content hashing, relative-path normalization, reading bytes.
const crypto = require("crypto");
const fs = require("fs");
// SHA-256 of the file's contents, returned as lowercase hex.
const sha256File = async (absPath) => {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(absPath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => resolve(hash.digest("hex")));
stream.on("error", reject);
});
};
const sha256Buffer = (buf) => {
return crypto.createHash("sha256").update(buf).digest("hex");
};
// Convert Saltcorn's absolute file location to a tenant-relative serve path,
// using File.absPathToServePath. The relative path is what we transport
// between instances (each has a different file_store root).
const toRelativePath = (File, absPath) => {
if (!absPath) return "";
return File.absPathToServePath(absPath);
};
// Convert a relative serve path back to the absolute path on this instance.
const toAbsolutePath = (File, db, relPath) => {
const path = require("path");
const tenant = db.getTenantSchema();
return path.join(db.connectObj.file_store, tenant, relPath);
};
const readFileBytes = async (absPath) => {
return await fs.promises.readFile(absPath);
};
const writeFileBytes = async (absPath, buf) => {
const path = require("path");
await fs.promises.mkdir(path.dirname(absPath), { recursive: true });
await fs.promises.writeFile(absPath, buf);
};
module.exports = {
sha256File,
sha256Buffer,
toRelativePath,
toAbsolutePath,
readFileBytes,
writeFileBytes
};

34
dev-deploy/lib/ids.js Normal file
View file

@ -0,0 +1,34 @@
// UUID helpers for dev-deploy.
//
// Two flavors:
// - randomUuid(): a fresh v4 UUID, used for newly-created ops and entities
// - deterministicUuid(kind, name): a stable, repeatable UUID derived from
// (namespace, kind, name) using SHA-256. Used during first-run backfill so
// that two environments installed from the same base produce identical
// UUIDs for the same (kind, name) pair. RFC 4122 v5 shape with namespace.
const crypto = require("crypto");
const { ID_NAMESPACE } = require("./constants");
const deterministicUuid = (kind, name) => {
const input = `${ID_NAMESPACE}|${kind}|${name}`;
const hash = crypto.createHash("sha256").update(input).digest();
const bytes = Buffer.from(hash.subarray(0, 16));
bytes[6] = (bytes[6] & 0x0f) | 0x50;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.toString("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
};
const randomUuid = () => {
return crypto.randomUUID();
};
module.exports = {
deterministicUuid,
randomUuid
};

47
dev-deploy/lib/ops.js Normal file
View file

@ -0,0 +1,47 @@
// Journal append.
const db = require("@saltcorn/data/db");
const { getEnv } = require("./env");
const { randomUuid } = require("./ids");
const { OP_SCHEMA_VERSION } = require("./constants");
const { currentParentOpId, currentCorrelationId } = require("./context");
const recordOp = async (rec) => {
const env = await getEnv();
const now = new Date().toISOString();
const row = {
op_id: rec.op_id || randomUuid(),
source_env_id: env.env_id,
op_type: rec.op_type,
entity_kind: rec.entity_kind || null,
entity_uuid: rec.entity_uuid || null,
payload: JSON.stringify(rec.payload || {}),
parent_op_id: rec.parent_op_id || currentParentOpId(),
correlation_id: rec.correlation_id || currentCorrelationId(),
schema_version: OP_SCHEMA_VERSION,
created_at: now,
applied_at: now,
status: "committed"
};
await db.insert("_dd_ops", row, { noid: true });
return row.op_id;
};
const recordOpSafely = async (rec) => {
try {
return await recordOp(rec);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[dev-deploy] failed to record op ${rec.op_type}:`, err);
return null;
}
};
module.exports = {
recordOp,
recordOpSafely
};

View file

@ -0,0 +1,98 @@
// Recursive walker for op payloads: translate file references between this
// instance's local form (numeric id or relative path) and a portable
// placeholder ("__dd_file_ref::<uuid>"). Promote-time wraps call
// toPlaceholders to journal portable payloads; apply-time handlers call
// fromPlaceholders to resolve back to the target's local file path.
//
// Saltcorn's /files/serve route accepts either a numeric file id or a
// relative path, so writing the path back on apply works regardless of which
// form the source used.
const db = require("@saltcorn/data/db");
const { lookupByUuid } = require("./entityIds");
// Common keys in page/view layout JSON that reference a file.
const FILE_REF_KEYS = new Set([
"fileid",
"file_id",
"bgFileId",
"image_id"
]);
const PLACEHOLDER_PREFIX = "__dd_file_ref::";
// Look up a file entity by value (string path or numeric id). Returns the
// entity_ids row or null.
const lookupFileByValue = async (v) => {
if (v === null || v === undefined || v === "") return null;
// Match by current_name (relative path string)
let row = await db.selectMaybeOne("_dd_entity_ids", { kind: "file", current_name: String(v) });
if (row) return row;
// Also try numeric id, in case the source stored an integer id
const asNum = Number(v);
if (Number.isFinite(asNum)) {
row = await db.selectMaybeOne("_dd_entity_ids", { kind: "file", current_id: asNum });
if (row) return row;
}
return null;
};
// Walk obj, mutating in place. For each key in FILE_REF_KEYS with a non-empty
// value, replace with the result of `transform(key, value)`. Async-aware.
const transformFileRefs = async (obj, transform) => {
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
if (obj[i] && typeof obj[i] === "object") {
await transformFileRefs(obj[i], transform);
}
}
return;
}
if (!obj || typeof obj !== "object") return;
for (const k of Object.keys(obj)) {
const v = obj[k];
if (FILE_REF_KEYS.has(k) && v !== null && v !== undefined && v !== "" && typeof v !== "object") {
obj[k] = await transform(k, v);
} else if (v && typeof v === "object") {
await transformFileRefs(v, transform);
}
}
};
// Convert local file refs (id or path) -> portable placeholders.
const toPlaceholders = async (payload) => {
await transformFileRefs(payload, async (k, v) => {
const ent = await lookupFileByValue(v);
if (ent) return PLACEHOLDER_PREFIX + ent.uuid;
return v;
});
return payload;
};
// Convert portable placeholders -> local file refs (write back as relative
// path, which Saltcorn's /files/serve accepts).
const fromPlaceholders = async (payload) => {
await transformFileRefs(payload, async (k, v) => {
if (typeof v !== "string") return v;
if (!v.startsWith(PLACEHOLDER_PREFIX)) return v;
const uuid = v.substring(PLACEHOLDER_PREFIX.length);
const ent = await lookupByUuid(uuid);
return ent ? ent.current_name : v;
});
return payload;
};
module.exports = {
toPlaceholders,
fromPlaceholders,
PLACEHOLDER_PREFIX,
FILE_REF_KEYS
};

115
dev-deploy/lib/peerAuth.js Normal file
View file

@ -0,0 +1,115 @@
// Verify incoming HMAC-signed peer requests.
//
// Required headers:
// X-DD-Env-Id caller's env UUID (looked up in _dd_peers)
// X-DD-Timestamp ms-since-epoch; rejected if skew > 5 min
// X-DD-Nonce random per-request opaque bytes (replay padding)
// X-DD-Signature hex HMAC-SHA256 over canonical string
//
// On success: req.dd_peer is set (peer row), req.body is the parsed JSON body,
// and the function returns the peer. On failure: a 4xx response is sent and
// null is returned.
//
// Peer requests use Content-Type: application/vnd.dev-deploy+json so Saltcorn's
// express.json() middleware doesn't consume the stream upstream. That lets us
// read the exact raw bytes here and use them in the HMAC -- no JSON-canonical
// fragility, no re-serialization assumptions about whitespace or key order.
const {
buildCanonical,
verifySignature,
timestampWithinSkew
} = require("./crypto");
const {
findPeerByEnvId,
peerSecret,
touchPeerLastSeen
} = require("./peers");
const REQUIRED_HEADERS = ["x-dd-env-id", "x-dd-timestamp", "x-dd-nonce", "x-dd-signature"];
const readRawBody = async (req) => {
const chunks = [];
for await (const chunk of req) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString("utf8");
};
const requirePeerAuth = async (req, res) => {
for (const h of REQUIRED_HEADERS) {
if (!req.headers[h]) {
res.status(400).json({ error: `missing header ${h}` });
return null;
}
}
const callerEnvId = req.headers["x-dd-env-id"];
const timestamp = req.headers["x-dd-timestamp"];
const nonce = req.headers["x-dd-nonce"];
const signature = req.headers["x-dd-signature"];
if (!timestampWithinSkew(timestamp)) {
res.status(401).json({ error: "timestamp out of skew window" });
return null;
}
const peerRow = await findPeerByEnvId(callerEnvId);
if (!peerRow) {
res.status(401).json({ error: "unknown peer env_id" });
return null;
}
let secret;
try {
secret = await peerSecret(peerRow.peer_id);
} catch (e) {
res.status(401).json({ error: "peer not provisioned" });
return null;
}
// Read raw bytes (empty for GET/HEAD). HMAC covers exactly what arrived.
let bodyRaw = "";
if (req.method !== "GET" && req.method !== "HEAD") {
try {
bodyRaw = await readRawBody(req);
} catch (e) {
res.status(400).json({ error: "failed to read request body" });
return null;
}
}
const fullPath = req.originalUrl || req.url;
const canonical = buildCanonical({
timestamp: timestamp,
nonce: nonce,
method: req.method,
path: fullPath,
body: bodyRaw
});
if (!verifySignature(secret, canonical, signature)) {
res.status(401).json({ error: "bad signature" });
return null;
}
if (bodyRaw) {
try {
req.body = JSON.parse(bodyRaw);
} catch (e) {
res.status(400).json({ error: "body is not valid JSON" });
return null;
}
}
await touchPeerLastSeen(peerRow.peer_id);
req.dd_peer = peerRow;
return peerRow;
};
module.exports = {
requirePeerAuth
};

142
dev-deploy/lib/peers.js Normal file
View file

@ -0,0 +1,142 @@
// Peer model: CRUD on _dd_peers.
//
// Peer secrets are stored AES-256-GCM sealed; plaintext only crosses the
// process boundary at pairing time (when the operator copies the secret to
// the other instance's UI) and at HMAC sign/verify time.
const db = require("@saltcorn/data/db");
const {
seal,
open,
randomSecret
} = require("./crypto");
const rowToPeer = (row) => {
if (!row) return null;
return {
peer_id: row.peer_id,
env_id: row.env_id,
label: row.label,
base_url: row.base_url,
require_tls: !!row.require_tls,
created_at: row.created_at,
last_seen_at: row.last_seen_at,
// sealed components kept out of plain accessors -- use peerSecret()
};
};
const listPeers = async () => {
const rows = await db.select("_dd_peers", {}, { orderBy: "peer_id" });
return rows.map(rowToPeer);
};
const findPeer = async (peerId) => {
const row = await db.selectMaybeOne("_dd_peers", { peer_id: peerId });
return rowToPeer(row);
};
const findPeerByEnvId = async (envId) => {
return await db.selectMaybeOne("_dd_peers", { env_id: envId });
};
// Returns the plaintext 32-byte secret for an existing peer by id.
// Throws if the peer is missing or has no sealed secret.
const peerSecret = async (peerId) => {
const row = await db.selectMaybeOne("_dd_peers", { peer_id: peerId });
if (!row) {
throw new Error(`peer ${peerId} not found`);
}
if (!row.peer_secret_ciphertext || !row.peer_secret_iv || !row.peer_secret_tag) {
throw new Error(`peer ${peerId} has no sealed secret`);
}
return open({
ciphertext: Buffer.from(row.peer_secret_ciphertext, "hex"),
iv: Buffer.from(row.peer_secret_iv, "hex"),
tag: Buffer.from(row.peer_secret_tag, "hex")
});
};
const peerSecretByEnvId = async (envId) => {
const row = await findPeerByEnvId(envId);
if (!row) {
return null;
}
return await peerSecret(row.peer_id);
};
// Create a new peer. If `existingSecret` is provided, use it; otherwise
// generate a fresh one. Returns { peer, secret } where secret is the plaintext
// Buffer -- only available at this single moment.
const addPeer = async ({ envId, label, baseUrl, requireTls, existingSecret }) => {
if (!envId || !baseUrl) {
throw new Error("addPeer requires envId and baseUrl");
}
const dup = await findPeerByEnvId(envId);
if (dup) {
throw new Error(`peer with env_id ${envId} already exists`);
}
const secret = existingSecret || randomSecret();
const sealed = seal(secret);
const row = {
env_id: envId,
label: label || null,
base_url: baseUrl,
peer_secret_ciphertext: sealed.ciphertext.toString("hex"),
peer_secret_iv: sealed.iv.toString("hex"),
peer_secret_tag: sealed.tag.toString("hex"),
require_tls: requireTls ? 1 : 0,
created_at: new Date().toISOString(),
last_seen_at: null
};
await db.insert("_dd_peers", row);
const fresh = await findPeerByEnvId(envId);
return { peer: rowToPeer(fresh), secret: secret };
};
const rotatePeerSecret = async (peerId) => {
const peer = await findPeer(peerId);
if (!peer) {
throw new Error(`peer ${peerId} not found`);
}
const secret = randomSecret();
const sealed = seal(secret);
await db.updateWhere("_dd_peers", {
peer_secret_ciphertext: sealed.ciphertext.toString("hex"),
peer_secret_iv: sealed.iv.toString("hex"),
peer_secret_tag: sealed.tag.toString("hex")
}, { peer_id: peerId });
return { peer: peer, secret: secret };
};
const deletePeer = async (peerId) => {
await db.deleteWhere("_dd_peers", { peer_id: peerId });
await db.deleteWhere("_dd_anchors", { peer_id: peerId });
};
const touchPeerLastSeen = async (peerId) => {
await db.updateWhere("_dd_peers", { last_seen_at: new Date().toISOString() }, { peer_id: peerId });
};
module.exports = {
listPeers,
findPeer,
findPeerByEnvId,
peerSecret,
peerSecretByEnvId,
addPeer,
rotatePeerSecret,
deletePeer,
touchPeerLastSeen
};

205
dev-deploy/lib/revert.js Normal file
View file

@ -0,0 +1,205 @@
// Revert: append a compensating op for any local op_id.
//
// create_X → drop the entity that was created
// drop_X → recreate the entity from payload.before
// update_X → re-apply payload.before as a new patch
//
// The revert calls the same Saltcorn model methods the admin UI does, so the
// existing CRUD wraps fire and journal the inverse op naturally with this
// instance's env_id. The inverse op then promotes to peers like any other op.
//
// Caveats:
// - Reverting a drop produces a *new* entity with a new random UUID. The
// original UUID is gone for good. Content is restored; identity is fresh.
// - Reverting a field/view/trigger drop requires the parent table to still
// exist locally. parent_uuid was captured by the drop wrap; we resolve it
// to the current local id.
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const View = require("@saltcorn/data/models/view");
const Page = require("@saltcorn/data/models/page");
const Trigger = require("@saltcorn/data/models/trigger");
const Role = require("@saltcorn/data/models/role");
const Library = require("@saltcorn/data/models/library");
const Tag = require("@saltcorn/data/models/tag");
const db = require("@saltcorn/data/db");
const { lookupByUuid } = require("./entityIds");
const { ENTITY_KINDS } = require("./constants");
const MODELS = {
[ENTITY_KINDS.TABLE]: Table,
[ENTITY_KINDS.FIELD]: Field,
[ENTITY_KINDS.VIEW]: View,
[ENTITY_KINDS.PAGE]: Page,
[ENTITY_KINDS.TRIGGER]: Trigger,
[ENTITY_KINDS.ROLE]: Role,
[ENTITY_KINDS.LIBRARY]: Library,
[ENTITY_KINDS.TAG]: Tag
};
const stripIds = (obj) => {
const out = { ...(obj || {}) };
for (const k of ["id", "table_id", "view_id", "page_id"]) delete out[k];
return out;
};
const { refreshState } = require("./state");
const findLocalInstance = async (kind, entityUuid) => {
const m = await lookupByUuid(entityUuid);
if (!m) return null;
const Cls = MODELS[kind];
if (!Cls || typeof Cls.findOne !== "function") return null;
await refreshState();
const inst = await Cls.findOne({ id: m.current_id });
return inst || null;
};
// --- Drop the entity (revert of create_X) ---
const revertCreate = async (kind, op) => {
const inst = await findLocalInstance(kind, op.entity_uuid);
if (!inst) {
return { status: "noop", reason: "entity no longer present" };
}
await inst.delete();
return { status: "dropped" };
};
// --- Recreate from before-snapshot (revert of drop_X) ---
const recreateFromSnapshot = async (kind, payload) => {
const before = stripIds(payload.before || {});
const parentUuid = payload.parent_uuid;
let parentLocalId = null;
if (parentUuid) {
const parent = await lookupByUuid(parentUuid);
if (!parent) {
throw new Error(`parent uuid=${parentUuid} not present locally; cannot recreate ${kind}`);
}
parentLocalId = parent.current_id;
}
switch (kind) {
case ENTITY_KINDS.TABLE: {
if (!before.name) throw new Error("missing before.name");
const opts = { ...before };
const name = opts.name;
delete opts.name;
await Table.create(name, opts);
return { status: "recreated" };
}
case ENTITY_KINDS.FIELD: {
const cfg = { ...before, table_id: parentLocalId };
await Field.create(cfg);
return { status: "recreated" };
}
case ENTITY_KINDS.VIEW: {
const cfg = { ...before };
if (parentLocalId) cfg.table_id = parentLocalId;
await View.create(cfg);
return { status: "recreated" };
}
case ENTITY_KINDS.PAGE: {
await Page.create(before);
return { status: "recreated" };
}
case ENTITY_KINDS.TRIGGER: {
const cfg = { ...before };
if (parentLocalId) cfg.table_id = parentLocalId;
await Trigger.create(cfg);
return { status: "recreated" };
}
case ENTITY_KINDS.ROLE: {
if (!before.id || !before.role) throw new Error("missing role identity");
await Role.create({ id: before.id, role: before.role });
return { status: "recreated" };
}
case ENTITY_KINDS.LIBRARY: {
await Library.create(before);
return { status: "recreated" };
}
case ENTITY_KINDS.TAG: {
await Tag.create(before);
return { status: "recreated" };
}
default:
throw new Error(`unknown kind ${kind}`);
}
};
// --- Re-apply payload.before as a new patch (revert of update_X) ---
const reapplyBefore = async (kind, op, payload) => {
const inst = await findLocalInstance(kind, op.entity_uuid);
if (!inst) {
throw new Error(`entity_uuid=${op.entity_uuid} (${kind}) not present locally`);
}
const patch = stripIds(payload.before || {});
if (Object.keys(patch).length === 0) {
return { status: "noop", reason: "empty before-snapshot" };
}
switch (kind) {
case ENTITY_KINDS.TABLE:
case ENTITY_KINDS.FIELD:
case ENTITY_KINDS.ROLE:
case ENTITY_KINDS.LIBRARY:
case ENTITY_KINDS.TAG:
await inst.update(patch);
return { status: "updated" };
case ENTITY_KINDS.VIEW:
await View.update(patch, inst.id);
return { status: "updated" };
case ENTITY_KINDS.PAGE:
await Page.update(inst.id, patch);
return { status: "updated" };
case ENTITY_KINDS.TRIGGER:
await Trigger.update(inst.id, patch);
return { status: "updated" };
default:
throw new Error(`unknown kind ${kind}`);
}
};
const ACTION_HANDLERS = {
create: revertCreate,
drop: recreateFromSnapshot,
update: reapplyBefore
};
// Look up the original op, parse its payload, dispatch to the inverse action.
// The local wrap journals the inverse as a new op with a fresh op_id.
const revertOp = async (opId) => {
const orig = await db.selectMaybeOne("_dd_ops", { op_id: opId });
if (!orig) {
throw new Error(`op ${opId} not found`);
}
const [action, kind] = (orig.op_type || "").split("_", 2);
const handler = ACTION_HANDLERS[action];
if (!handler) {
throw new Error(`no revert handler for action '${action}' (op_type=${orig.op_type})`);
}
if (!MODELS[kind]) {
throw new Error(`no model for kind '${kind}'`);
}
const payload = typeof orig.payload === "string" ? JSON.parse(orig.payload) : (orig.payload || {});
if (action === "drop") {
return await handler(kind, payload);
}
return await handler(kind, orig, payload);
};
module.exports = {
revertOp
};

1060
dev-deploy/lib/routes.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,124 @@
// Hidden _dd_row_uuid column infrastructure for managed/starter tables.
//
// When an admin marks a user table as managed or starter, this module
// adds a TEXT column _dd_row_uuid to the underlying SQL table via raw ALTER
// (NOT registered in _sc_fields, so Saltcorn's table builder doesn't show
// it). Existing rows are backfilled with random UUIDs. From that point,
// the wrap layer reads/writes _dd_row_uuid as the cross-environment identity
// for each row.
const crypto = require("crypto");
const db = require("@saltcorn/data/db");
const COLUMN_NAME = "_dd_row_uuid";
const tableSqlRef = (tableName) => {
const schema = db.getTenantSchemaPrefix();
return `${schema}"${db.sqlsanitize(tableName)}"`;
};
const columnExists = async (tableName) => {
if (db.isSQLite) {
const rs = await db.query(`PRAGMA table_info("${db.sqlsanitize(tableName)}")`);
return rs.rows.some((r) => r.name === COLUMN_NAME);
}
const rs = await db.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = $1
AND column_name = $2`,
[tableName, COLUMN_NAME]
);
return rs.rows.length > 0;
};
const ensureManagedSchema = async (tableName) => {
if (await columnExists(tableName)) {
return { added: false };
}
await db.query(`ALTER TABLE ${tableSqlRef(tableName)} ADD COLUMN ${COLUMN_NAME} TEXT`);
// Backfill existing rows with random UUIDs.
const rs = await db.query(`SELECT id FROM ${tableSqlRef(tableName)} WHERE ${COLUMN_NAME} IS NULL`);
let backfilled = 0;
for (const r of rs.rows) {
await db.query(
`UPDATE ${tableSqlRef(tableName)} SET ${COLUMN_NAME} = $1 WHERE id = $2`,
[crypto.randomUUID(), r.id]
);
backfilled++;
}
// Index for fast lookups by uuid.
await db.query(
`CREATE INDEX IF NOT EXISTS "${db.sqlsanitize(tableName)}_dd_row_uuid_idx"
ON ${tableSqlRef(tableName)} (${COLUMN_NAME})`
).catch(() => {});
return { added: true, backfilled: backfilled };
};
const dropManagedSchema = async (tableName) => {
if (!(await columnExists(tableName))) {
return { dropped: false };
}
// SQLite 3.35+ and Postgres both support DROP COLUMN. If the SQLite is
// older it will throw — surface the error.
await db.query(`ALTER TABLE ${tableSqlRef(tableName)} DROP COLUMN ${COLUMN_NAME}`);
return { dropped: true };
};
const getRowUuid = async (tableName, id) => {
const rs = await db.query(
`SELECT ${COLUMN_NAME} AS uuid FROM ${tableSqlRef(tableName)} WHERE id = $1`,
[id]
);
return rs.rows.length > 0 ? rs.rows[0].uuid : null;
};
const findIdByRowUuid = async (tableName, uuid) => {
const rs = await db.query(
`SELECT id FROM ${tableSqlRef(tableName)} WHERE ${COLUMN_NAME} = $1`,
[uuid]
);
return rs.rows.length > 0 ? rs.rows[0].id : null;
};
const setRowUuid = async (tableName, id, uuid) => {
await db.query(
`UPDATE ${tableSqlRef(tableName)} SET ${COLUMN_NAME} = $1 WHERE id = $2`,
[uuid, id]
);
};
const newRowUuid = () => crypto.randomUUID();
// All rows currently in the table, including their _dd_row_uuid. Used during
// the initial managed/starter ship to journal an insert_row op for each.
const allRowsWithUuid = async (tableName) => {
const rs = await db.query(
`SELECT * FROM ${tableSqlRef(tableName)} ORDER BY id ASC`
);
return rs.rows;
};
module.exports = {
COLUMN_NAME,
columnExists,
ensureManagedSchema,
dropManagedSchema,
getRowUuid,
findIdByRowUuid,
setRowUuid,
newRowUuid,
allRowsWithUuid
};

View file

@ -0,0 +1,144 @@
// Row payload translation: FK ids -> row uuids (on journal) and back (on apply).
//
// For each FK field on the row's table (field.is_fkey), look up the referenced
// row's _dd_row_uuid. Store under "<fieldname>__uuid" to avoid colliding with
// the original field name. On apply, resolve back to the local row id.
//
// FKs to user-mode tables are NULLed on the target, with a warning attached
// to the payload so it surfaces in the admin UI.
const db = require("@saltcorn/data/db");
const { getRowUuid, findIdByRowUuid, COLUMN_NAME } = require("./rowIdentity");
const { lookupByCurrent } = require("./entityIds");
const UUID_SUFFIX = "__uuid";
// Returns data_mode for the table with given current_id (Saltcorn table id).
// 'user' (default), 'starter', or 'managed'.
const tableModeByCurrentId = async (tableId) => {
const ent = await lookupByCurrent("table", tableId);
if (!ent) return "user";
const row = await db.selectMaybeOne("_dd_table_modes", { table_uuid: ent.uuid });
return row ? row.data_mode : "user";
};
const tableModeByUuid = async (tableUuid) => {
if (!tableUuid) return "user";
const row = await db.selectMaybeOne("_dd_table_modes", { table_uuid: tableUuid });
return row ? row.data_mode : "user";
};
// rowData: a plain row from the table.
// table: the Saltcorn Table instance whose fields define the FK shape.
// Returns { portable: {...with __uuid keys for FKs}, warnings: [...] }.
const rowToPortable = async (rowData, table) => {
const portable = {};
const warnings = [];
for (const field of table.fields || []) {
if (field.name === COLUMN_NAME || field.name === "id") continue;
const v = rowData[field.name];
if (field.is_fkey && field.reftable_name && v !== null && v !== undefined) {
const refMode = await tableModeByCurrentId_byName(field.reftable_name);
if (refMode === "managed" || refMode === "starter") {
const refUuid = await getRowUuid(field.reftable_name, v);
if (refUuid) {
portable[field.name + UUID_SUFFIX] = refUuid;
} else {
portable[field.name + UUID_SUFFIX] = null;
warnings.push(`${field.name}: source row references ${field.reftable_name}.id=${v} but it has no _dd_row_uuid yet`);
}
} else {
// FK into a user-mode table — can't translate. Drop and warn.
portable[field.name + UUID_SUFFIX] = null;
warnings.push(`${field.name} → user-mode table ${field.reftable_name}; FK will be null on target`);
}
} else {
portable[field.name] = v;
}
}
return { portable: portable, warnings: warnings };
};
// portable: payload from a journaled row op.
// table: the Saltcorn Table instance on the local (target) side.
const portableToRow = async (portable, table) => {
const row = {};
for (const field of table.fields || []) {
if (field.name === COLUMN_NAME || field.name === "id") continue;
const uuidKey = field.name + UUID_SUFFIX;
if (uuidKey in portable) {
const refUuid = portable[uuidKey];
if (!refUuid) {
row[field.name] = null;
} else if (field.is_fkey && field.reftable_name) {
const localId = await findIdByRowUuid(field.reftable_name, refUuid);
row[field.name] = localId; // may be null if target doesn't have that row yet
} else {
row[field.name] = null;
}
} else if (field.name in portable) {
row[field.name] = portable[field.name];
}
}
return row;
};
// Helper: lookup mode by table name (via entity_ids).
const tableModeByCurrentId_byName = async (tableName) => {
const Table = require("@saltcorn/data/models/table");
const t = Table.findOne({ name: tableName });
if (!t) return "user";
return await tableModeByCurrentId(t.id);
};
// True if a starter table has already had its initial-ship completed; managed
// tables ignore this (they always journal).
const isStarterShipped = async (tableUuid) => {
const row = await db.selectMaybeOne("_dd_table_modes", { table_uuid: tableUuid });
return !!(row && row.starter_shipped_at);
};
const markStarterShipped = async (tableUuid) => {
await db.updateWhere("_dd_table_modes", { starter_shipped_at: new Date().toISOString() }, { table_uuid: tableUuid });
};
// For a given Saltcorn Table id, returns { mode, tableUuid, shouldJournal }.
// shouldJournal is the wrap-level decision: true → record the row op; false →
// pass through silently.
const journalDecision = async (tableId) => {
const { lookupByCurrent } = require("./entityIds");
const ent = await lookupByCurrent("table", tableId);
if (!ent) return { mode: "user", tableUuid: null, shouldJournal: false };
const mode = await tableModeByUuid(ent.uuid);
if (mode === "managed") {
return { mode: mode, tableUuid: ent.uuid, shouldJournal: true };
}
if (mode === "starter") {
const shipped = await isStarterShipped(ent.uuid);
return { mode: mode, tableUuid: ent.uuid, shouldJournal: !shipped };
}
return { mode: "user", tableUuid: ent.uuid, shouldJournal: false };
};
module.exports = {
rowToPortable,
portableToRow,
tableModeByCurrentId,
tableModeByUuid,
tableModeByCurrentId_byName,
isStarterShipped,
markStarterShipped,
journalDecision,
UUID_SUFFIX
};

140
dev-deploy/lib/schema.js Normal file
View file

@ -0,0 +1,140 @@
// DDL for dev-deploy's six plugin tables.
//
// Saltcorn supports SQLite and PostgreSQL. We use a portable subset:
// - TEXT for all strings, JSON payloads, ISO 8601 timestamps
// - INTEGER for booleans (0/1) and surrogate ids
// - No JSONB; payloads are TEXT containing JSON, parsed/stringified at the
// application layer
//
// Tables are created idempotently in onLoad. Re-running is safe.
const db = require("@saltcorn/data/db");
const createDdEnv = async () => {
await db.query(`
CREATE TABLE IF NOT EXISTS _dd_env (
env_id TEXT PRIMARY KEY,
env_label TEXT,
on_destructive_op TEXT NOT NULL DEFAULT 'confirm',
require_tls INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
bootstrapped_at TEXT
)
`);
};
const createDdPeers = async () => {
// Secret components stored as hex TEXT rather than BLOB: Saltcorn's
// SQLite insert layer JSON-stringifies any object value, which would
// mangle Buffer columns. Hex is portable and survives that path intact.
await db.query(`
CREATE TABLE IF NOT EXISTS _dd_peers (
peer_id INTEGER PRIMARY KEY AUTOINCREMENT,
env_id TEXT NOT NULL UNIQUE,
label TEXT,
base_url TEXT NOT NULL,
peer_secret_ciphertext TEXT,
peer_secret_iv TEXT,
peer_secret_tag TEXT,
require_tls INTEGER,
created_at TEXT NOT NULL,
last_seen_at TEXT
)
`);
};
const createDdEntityIds = async () => {
await db.query(`
CREATE TABLE IF NOT EXISTS _dd_entity_ids (
uuid TEXT PRIMARY KEY,
kind TEXT NOT NULL,
current_name TEXT NOT NULL,
current_id INTEGER NOT NULL,
parent_uuid TEXT,
created_at TEXT NOT NULL,
UNIQUE (kind, current_id)
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS _dd_entity_ids_kind_name ON _dd_entity_ids (kind, current_name)`);
await db.query(`CREATE INDEX IF NOT EXISTS _dd_entity_ids_parent ON _dd_entity_ids (parent_uuid)`);
};
const createDdOps = async () => {
await db.query(`
CREATE TABLE IF NOT EXISTS _dd_ops (
op_id TEXT PRIMARY KEY,
source_env_id TEXT NOT NULL,
op_type TEXT NOT NULL,
entity_kind TEXT,
entity_uuid TEXT,
payload TEXT NOT NULL,
parent_op_id TEXT,
correlation_id TEXT,
schema_version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
applied_at TEXT,
status TEXT NOT NULL DEFAULT 'committed',
conflict_with_op_id TEXT
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_created ON _dd_ops (created_at)`);
await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_source ON _dd_ops (source_env_id, created_at)`);
await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_entity ON _dd_ops (entity_uuid)`);
await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_correlation ON _dd_ops (correlation_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_status ON _dd_ops (status) WHERE status = 'conflict'`).catch(() => {});
// Idempotent migration for instances installed before conflict_with_op_id existed
try {
await db.query(`ALTER TABLE _dd_ops ADD COLUMN conflict_with_op_id TEXT`);
} catch (e) {
// column already exists; ignore
}
};
const createDdAnchors = async () => {
await db.query(`
CREATE TABLE IF NOT EXISTS _dd_anchors (
peer_id INTEGER NOT NULL,
direction TEXT NOT NULL,
last_op_id TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (peer_id, direction)
)
`);
};
const createDdTableModes = async () => {
await db.query(`
CREATE TABLE IF NOT EXISTS _dd_table_modes (
table_uuid TEXT PRIMARY KEY,
data_mode TEXT NOT NULL,
updated_at TEXT NOT NULL,
starter_shipped_at TEXT
)
`);
// Idempotent migration for older installs that lack starter_shipped_at.
try {
await db.query(`ALTER TABLE _dd_table_modes ADD COLUMN starter_shipped_at TEXT`);
} catch (e) { /* column exists */ }
};
const createAllTables = async () => {
await createDdEnv();
await createDdPeers();
await createDdEntityIds();
await createDdOps();
await createDdAnchors();
await createDdTableModes();
};
module.exports = {
createAllTables
};

27
dev-deploy/lib/state.js Normal file
View file

@ -0,0 +1,27 @@
// Shared helpers for Saltcorn state-cache interactions.
// Refresh Saltcorn's in-memory model caches. Mutations done in another process
// (e.g. saltcorn run-js, prior ingest, or a peer's push) are not visible to
// Model.findOne / Model.find until we refresh, because those methods read from
// state.tables/views/pages/triggers arrays populated at startup.
//
// Best-effort: if Saltcorn changes its API and a refresh function disappears,
// callers fall back to whatever's in the cache.
const refreshState = async () => {
try {
const { getState } = require("@saltcorn/data/db/state");
const s = getState();
if (s.refresh_tables) await s.refresh_tables(true);
if (s.refresh_views) await s.refresh_views(true);
if (s.refresh_pages) await s.refresh_pages(true);
if (s.refresh_triggers) await s.refresh_triggers(true);
if (s.refresh_page_groups) await s.refresh_page_groups(true);
} catch (e) {
// ignore — best-effort
}
};
module.exports = {
refreshState
};

View file

@ -0,0 +1,98 @@
// Outbound signed peer requests.
//
// Build a canonical string, sign it with the peer's shared secret, and send
// it via fetch. Body is JSON-serialized once and used for both the HTTP body
// and the signature payload to keep client/server hash agreement trivial.
const {
buildCanonical,
sign,
randomNonce
} = require("./crypto");
const signedFetch = async ({ baseUrl, method, path, body, sourceEnvId, secret }) => {
const timestamp = String(Date.now());
const nonce = randomNonce().toString("hex");
const bodyStr = body ? JSON.stringify(body) : "";
const canonical = buildCanonical({
timestamp: timestamp,
nonce: nonce,
method: method,
path: path,
body: bodyStr
});
const signature = sign(secret, canonical);
const url = baseUrl.replace(/\/+$/, "") + path;
const headers = {
"X-DD-Env-Id": sourceEnvId,
"X-DD-Timestamp": timestamp,
"X-DD-Nonce": nonce,
"X-DD-Signature": signature
};
if (bodyStr) {
// Custom Content-Type so Saltcorn's express.json() middleware leaves the
// request stream untouched; the server reads exact bytes for HMAC.
headers["Content-Type"] = "application/vnd.dev-deploy+json";
}
const init = { method: method, headers: headers };
if (bodyStr) {
init.body = bodyStr;
}
const res = await fetch(url, init);
const text = await res.text();
let parsed = null;
if (text) {
try {
parsed = JSON.parse(text);
} catch (e) {
parsed = { raw: text };
}
}
return { status: res.status, ok: res.ok, body: parsed };
};
// Like signedFetch but returns the response body as a Buffer (raw bytes).
// For binary endpoints like GET /dev-deploy/api/file/:uuid.
const signedFetchBinary = async ({ baseUrl, method, path, body, sourceEnvId, secret }) => {
const timestamp = String(Date.now());
const nonce = randomNonce().toString("hex");
const bodyStr = body ? JSON.stringify(body) : "";
const canonical = buildCanonical({
timestamp: timestamp,
nonce: nonce,
method: method,
path: path,
body: bodyStr
});
const signature = sign(secret, canonical);
const url = baseUrl.replace(/\/+$/, "") + path;
const headers = {
"X-DD-Env-Id": sourceEnvId,
"X-DD-Timestamp": timestamp,
"X-DD-Nonce": nonce,
"X-DD-Signature": signature
};
if (bodyStr) {
headers["Content-Type"] = "application/vnd.dev-deploy+json";
}
const init = { method: method, headers: headers };
if (bodyStr) {
init.body = bodyStr;
}
const res = await fetch(url, init);
const bytes = Buffer.from(await res.arrayBuffer());
return { status: res.status, ok: res.ok, bytes: bytes };
};
module.exports = {
signedFetch,
signedFetchBinary
};

999
dev-deploy/lib/wrap.js Normal file
View file

@ -0,0 +1,999 @@
// Monkey-patches Saltcorn metadata model classes to journal every CRUD action.
//
// Each wrap:
// 1. Pre-generates an op_id and enters an AsyncLocalStorage scope so any
// child mutations triggered by the original method (e.g. cascading field
// deletes from a table delete) see this op as their parent.
// 2. Optionally captures pre-state via a "before" hook.
// 3. Invokes the original method.
// 4. On success, runs an "after" hook to compute entity uuid + payload and
// appends the op to _dd_ops.
//
// Failures inside the after-hook are logged but do not throw -- we never want
// the journal to corrupt user-facing operations. Failures inside the original
// method propagate normally (no op is recorded for an aborted mutation).
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const View = require("@saltcorn/data/models/view");
const Page = require("@saltcorn/data/models/page");
const Trigger = require("@saltcorn/data/models/trigger");
const Role = require("@saltcorn/data/models/role");
const Library = require("@saltcorn/data/models/library");
const Tag = require("@saltcorn/data/models/tag");
const TableConstraint = require("@saltcorn/data/models/table_constraints");
const File = require("@saltcorn/data/models/file");
const PageGroup = require("@saltcorn/data/models/page_group");
const PageGroupMember = require("@saltcorn/data/models/page_group_member");
const WorkflowStep = require("@saltcorn/data/models/workflow_step");
const Plugin = require("@saltcorn/data/models/plugin");
const db = require("@saltcorn/data/db");
const { randomUuid } = require("./ids");
const { enterOp, isSuppressed } = require("./context");
const { recordOpSafely } = require("./ops");
const {
assignNewUuid,
lookupByCurrent,
updateName,
removeEntityRow,
constraintDisplayName
} = require("./entityIds");
const { ENTITY_KINDS, fileLocationToId } = require("./constants");
const { sha256File, toRelativePath } = require("./files");
const { toPlaceholders } = require("./payloadRefs");
const {
setRowUuid,
getRowUuid,
newRowUuid,
COLUMN_NAME: ROW_UUID_COL
} = require("./rowIdentity");
const {
rowToPortable,
journalDecision
} = require("./rowPayload");
const snapshotInstance = (inst, keys) => {
const out = {};
for (const k of keys) {
if (inst[k] !== undefined) {
out[k] = inst[k];
}
}
return out;
};
// Shared before/after hooks for instance `delete()` methods.
// Captures the entity's UUID, parent UUID (if any), and snapshot, then removes
// the entity_ids row after the original delete completes so reused integer ids
// don't collide. parent_uuid is preserved so revert can find the parent later.
const standardDropHooks = (kind, keys) => ({
before: async function () {
const existing = await lookupByCurrent(kind, this.id);
return {
uuid: existing ? existing.uuid : null,
currentId: this.id,
parentUuid: existing ? existing.parent_uuid : null,
snapshot: snapshotInstance(this, keys)
};
},
after: async function ({ before }) {
if (!before || !before.uuid) return null;
await removeEntityRow(kind, before.currentId);
return {
entityUuid: before.uuid,
payload: { before: before.snapshot, parent_uuid: before.parentUuid }
};
}
});
const wrap = (target, method, kind, action, hooks) => {
const original = target[method];
if (typeof original !== "function") {
// eslint-disable-next-line no-console
console.warn(`[dev-deploy] no method ${kind}.${method} to wrap`);
return;
}
if (original.__ddWrapped) {
return;
}
const wrapped = async function (...args) {
if (isSuppressed()) {
return await original.apply(this, args);
}
const opId = randomUuid();
const beforeCtx = hooks.before ? await safeCall(hooks.before, this, [args]) : null;
return await enterOp(opId, async () => {
const result = await original.apply(this, args);
const details = await safeCall(hooks.after, this, [{ args: args, result: result, before: beforeCtx }]);
if (details) {
// Translate any local file refs (numeric ids or paths) in the
// payload to portable __dd_file_ref::<uuid> placeholders so
// the journal stores cross-environment-stable references.
if (details.payload) {
try {
await toPlaceholders(details.payload);
} catch (e) {
// best-effort; payload still journals as-is
}
}
await recordOpSafely({
op_id: opId,
op_type: `${action}_${kind}`,
entity_kind: kind,
entity_uuid: details.entityUuid,
payload: details.payload
});
}
return result;
});
};
wrapped.__ddWrapped = true;
wrapped.__ddOriginal = original;
target[method] = wrapped;
};
const safeCall = async (fn, ctx, args) => {
if (!fn) {
return null;
}
try {
return await fn.apply(ctx, args);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[dev-deploy] hook error:`, err);
return null;
}
};
// Common keys to snapshot for each entity. Skips heavy/derived fields.
const TABLE_KEYS = ["id", "name", "min_role_read", "min_role_write", "versioned", "description", "ownership_field_id", "ownership_formula", "external", "provider_name", "provider_cfg"];
const FIELD_KEYS = ["id", "name", "label", "type", "table_id", "required", "is_unique", "calculated", "stored", "expression", "reftable_name", "reftype", "refname", "primary_key", "attributes"];
const VIEW_KEYS = ["id", "name", "viewtemplate", "table_id", "configuration", "min_role", "default_render_page", "exttable_name", "description", "slug"];
const PAGE_KEYS = ["id", "name", "title", "description", "min_role", "layout", "fixed_states", "menu_label"];
const TRIGGER_KEYS = ["id", "name", "action", "when_trigger", "table_id", "configuration", "min_role", "description", "channel"];
const ROLE_KEYS = ["id", "role"];
const LIBRARY_KEYS = ["id", "name", "icon", "layout"];
const TAG_KEYS = ["id", "name"];
const CONSTRAINT_KEYS = ["id", "type", "table_id", "configuration"];
const PAGE_GROUP_KEYS = ["id", "name", "description", "min_role", "random_allocation"];
const WORKFLOW_STEP_KEYS = ["id", "name", "trigger_id", "action_name", "next_step", "initial_step", "configuration", "only_if"];
// ----- Table -----
const wrapTable = () => {
wrap(Table, "create", ENTITY_KINDS.TABLE, "create", {
after: async ({ result }) => {
if (!result || !result.id) return null;
const uuid = await assignNewUuid(ENTITY_KINDS.TABLE, result.id, result.name, null);
return {
entityUuid: uuid,
payload: { after: snapshotInstance(result, TABLE_KEYS) }
};
}
});
wrap(Table.prototype, "update", ENTITY_KINDS.TABLE, "update", {
before: async function (args) {
const existing = await lookupByCurrent(ENTITY_KINDS.TABLE, this.id);
return {
uuid: existing ? existing.uuid : null,
oldName: existing ? existing.current_name : this.name,
snapshot: snapshotInstance(this, TABLE_KEYS)
};
},
after: async function ({ args, before }) {
if (!before || !before.uuid) return null;
const patch = args[0] || {};
const newName = patch.name !== undefined ? patch.name : before.oldName;
if (newName !== before.oldName) {
await updateName(ENTITY_KINDS.TABLE, this.id, newName);
}
return {
entityUuid: before.uuid,
payload: {
before: before.snapshot,
patch: patch,
after: snapshotInstance(this, TABLE_KEYS)
}
};
}
});
wrap(Table.prototype, "delete", ENTITY_KINDS.TABLE, "drop", {
before: async function (args) {
const existing = await lookupByCurrent(ENTITY_KINDS.TABLE, this.id);
return {
uuid: existing ? existing.uuid : null,
currentId: this.id,
snapshot: snapshotInstance(this, TABLE_KEYS),
onlyForget: !!args[0]
};
},
after: async function ({ before }) {
if (!before || !before.uuid) return null;
await removeEntityRow(ENTITY_KINDS.TABLE, before.currentId);
return {
entityUuid: before.uuid,
payload: { before: before.snapshot, only_forget: before.onlyForget }
};
}
});
};
// ----- Field -----
const wrapField = () => {
wrap(Field, "create", ENTITY_KINDS.FIELD, "create", {
after: async ({ args, result }) => {
if (!result || !result.id) return null;
let parentUuid = null;
if (result.table_id) {
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id);
parentUuid = t ? t.uuid : null;
}
const uuid = await assignNewUuid(ENTITY_KINDS.FIELD, result.id, result.name, parentUuid);
return {
entityUuid: uuid,
payload: {
after: snapshotInstance(result, FIELD_KEYS),
parent_uuid: parentUuid
}
};
}
});
wrap(Field.prototype, "update", ENTITY_KINDS.FIELD, "update", {
before: async function (args) {
const existing = await lookupByCurrent(ENTITY_KINDS.FIELD, this.id);
return {
uuid: existing ? existing.uuid : null,
oldName: existing ? existing.current_name : this.name,
snapshot: snapshotInstance(this, FIELD_KEYS)
};
},
after: async function ({ args, before }) {
if (!before || !before.uuid) return null;
const patch = args[0] || {};
const newName = patch.name !== undefined ? patch.name : before.oldName;
if (newName !== before.oldName) {
await updateName(ENTITY_KINDS.FIELD, this.id, newName);
}
return {
entityUuid: before.uuid,
payload: {
before: before.snapshot,
patch: patch
}
};
}
});
wrap(Field.prototype, "delete", ENTITY_KINDS.FIELD, "drop", standardDropHooks(ENTITY_KINDS.FIELD, FIELD_KEYS));
};
// ----- View -----
const wrapView = () => {
wrap(View, "create", ENTITY_KINDS.VIEW, "create", {
after: async ({ result }) => {
if (!result || !result.id) return null;
let parentUuid = null;
if (result.table_id) {
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id);
parentUuid = t ? t.uuid : null;
}
const uuid = await assignNewUuid(ENTITY_KINDS.VIEW, result.id, result.name, parentUuid);
return {
entityUuid: uuid,
payload: { after: snapshotInstance(result, VIEW_KEYS), parent_uuid: parentUuid }
};
}
});
// View.update is static (v, id)
wrap(View, "update", ENTITY_KINDS.VIEW, "update", {
before: async ({ args }) => {
const patch = args[0] || {};
const id = args[1];
const existing = await lookupByCurrent(ENTITY_KINDS.VIEW, id);
return {
id: id,
uuid: existing ? existing.uuid : null,
oldName: existing ? existing.current_name : null,
patch: patch
};
},
after: async ({ before }) => {
if (!before || !before.uuid) return null;
const newName = before.patch.name !== undefined ? before.patch.name : before.oldName;
if (newName !== before.oldName) {
await updateName(ENTITY_KINDS.VIEW, before.id, newName);
}
return {
entityUuid: before.uuid,
payload: { patch: before.patch }
};
}
});
wrap(View.prototype, "delete", ENTITY_KINDS.VIEW, "drop", standardDropHooks(ENTITY_KINDS.VIEW, VIEW_KEYS));
};
// ----- Page -----
const wrapPage = () => {
wrap(Page, "create", ENTITY_KINDS.PAGE, "create", {
after: async ({ result }) => {
if (!result || !result.id) return null;
const uuid = await assignNewUuid(ENTITY_KINDS.PAGE, result.id, result.name, null);
return { entityUuid: uuid, payload: { after: snapshotInstance(result, PAGE_KEYS) } };
}
});
// Page.update is static (id, row)
wrap(Page, "update", ENTITY_KINDS.PAGE, "update", {
before: async ({ args }) => {
const id = args[0];
const row = args[1] || {};
const existing = await lookupByCurrent(ENTITY_KINDS.PAGE, id);
return {
id: id,
uuid: existing ? existing.uuid : null,
oldName: existing ? existing.current_name : null,
patch: row
};
},
after: async ({ before }) => {
if (!before || !before.uuid) return null;
const newName = before.patch.name !== undefined ? before.patch.name : before.oldName;
if (newName !== before.oldName) {
await updateName(ENTITY_KINDS.PAGE, before.id, newName);
}
return { entityUuid: before.uuid, payload: { patch: before.patch } };
}
});
wrap(Page.prototype, "delete", ENTITY_KINDS.PAGE, "drop", standardDropHooks(ENTITY_KINDS.PAGE, PAGE_KEYS));
};
// ----- Trigger -----
const wrapTrigger = () => {
wrap(Trigger, "create", ENTITY_KINDS.TRIGGER, "create", {
after: async ({ result }) => {
if (!result || !result.id) return null;
let parentUuid = null;
if (result.table_id) {
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id);
parentUuid = t ? t.uuid : null;
}
const name = result.name || `trigger_${result.id}`;
const uuid = await assignNewUuid(ENTITY_KINDS.TRIGGER, result.id, name, parentUuid);
return { entityUuid: uuid, payload: { after: snapshotInstance(result, TRIGGER_KEYS), parent_uuid: parentUuid } };
}
});
// Trigger.update is static (id, row)
wrap(Trigger, "update", ENTITY_KINDS.TRIGGER, "update", {
before: async ({ args }) => {
const id = args[0];
const row = args[1] || {};
const existing = await lookupByCurrent(ENTITY_KINDS.TRIGGER, id);
return {
id: id,
uuid: existing ? existing.uuid : null,
oldName: existing ? existing.current_name : null,
patch: row
};
},
after: async ({ before }) => {
if (!before || !before.uuid) return null;
const newName = before.patch.name !== undefined ? before.patch.name : before.oldName;
if (newName && newName !== before.oldName) {
await updateName(ENTITY_KINDS.TRIGGER, before.id, newName);
}
return { entityUuid: before.uuid, payload: { patch: before.patch } };
}
});
wrap(Trigger.prototype, "delete", ENTITY_KINDS.TRIGGER, "drop", standardDropHooks(ENTITY_KINDS.TRIGGER, TRIGGER_KEYS));
};
// ----- Role -----
const wrapRole = () => {
wrap(Role, "create", ENTITY_KINDS.ROLE, "create", {
after: async ({ args, result }) => {
// Role.create returns inserted row; id is in result. Some return shapes vary.
const id = result && result.id ? result.id : (args[0] && args[0].id);
const role = result && result.role ? result.role : (args[0] && args[0].role);
if (!id || !role) return null;
const uuid = await assignNewUuid(ENTITY_KINDS.ROLE, id, role, null);
return { entityUuid: uuid, payload: { after: { id: id, role: role } } };
}
});
wrap(Role.prototype, "update", ENTITY_KINDS.ROLE, "update", {
before: async function () {
const existing = await lookupByCurrent(ENTITY_KINDS.ROLE, this.id);
return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.role, snapshot: snapshotInstance(this, ROLE_KEYS) };
},
after: async function ({ args, before }) {
if (!before || !before.uuid) return null;
const patch = args[0] || {};
const newName = patch.role !== undefined ? patch.role : before.oldName;
if (newName !== before.oldName) {
await updateName(ENTITY_KINDS.ROLE, this.id, newName);
}
return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } };
}
});
wrap(Role.prototype, "delete", ENTITY_KINDS.ROLE, "drop", standardDropHooks(ENTITY_KINDS.ROLE, ROLE_KEYS));
};
// ----- Library -----
const wrapLibrary = () => {
wrap(Library, "create", ENTITY_KINDS.LIBRARY, "create", {
after: async ({ args, result }) => {
// Library.create returns void; we look up by name from args
const cfg = args[0] || {};
if (!cfg.name) return null;
const Library = require("@saltcorn/data/models/library");
const lib = await Library.findOne({ name: cfg.name });
if (!lib) return null;
const uuid = await assignNewUuid(ENTITY_KINDS.LIBRARY, lib.id, lib.name, null);
return { entityUuid: uuid, payload: { after: snapshotInstance(lib, LIBRARY_KEYS) } };
}
});
wrap(Library.prototype, "update", ENTITY_KINDS.LIBRARY, "update", {
before: async function () {
const existing = await lookupByCurrent(ENTITY_KINDS.LIBRARY, this.id);
return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.name, snapshot: snapshotInstance(this, LIBRARY_KEYS) };
},
after: async function ({ args, before }) {
if (!before || !before.uuid) return null;
const patch = args[0] || {};
const newName = patch.name !== undefined ? patch.name : before.oldName;
if (newName !== before.oldName) {
await updateName(ENTITY_KINDS.LIBRARY, this.id, newName);
}
return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } };
}
});
wrap(Library.prototype, "delete", ENTITY_KINDS.LIBRARY, "drop", standardDropHooks(ENTITY_KINDS.LIBRARY, LIBRARY_KEYS));
};
// ----- Tag -----
const wrapTag = () => {
wrap(Tag, "create", ENTITY_KINDS.TAG, "create", {
after: async ({ result }) => {
if (!result || !result.id) return null;
const uuid = await assignNewUuid(ENTITY_KINDS.TAG, result.id, result.name, null);
return { entityUuid: uuid, payload: { after: snapshotInstance(result, TAG_KEYS) } };
}
});
wrap(Tag.prototype, "update", ENTITY_KINDS.TAG, "update", {
before: async function () {
const existing = await lookupByCurrent(ENTITY_KINDS.TAG, this.id);
return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.name, snapshot: snapshotInstance(this, TAG_KEYS) };
},
after: async function ({ args, before }) {
if (!before || !before.uuid) return null;
const patch = args[0] || {};
const newName = patch.name !== undefined ? patch.name : before.oldName;
if (newName !== before.oldName) {
await updateName(ENTITY_KINDS.TAG, this.id, newName);
}
return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } };
}
});
wrap(Tag.prototype, "delete", ENTITY_KINDS.TAG, "drop", standardDropHooks(ENTITY_KINDS.TAG, TAG_KEYS));
};
// ----- File -----
const FILE_KEYS = ["filename", "location", "mime_super", "mime_sub", "size_kb", "min_role_read"];
const wrapFile = () => {
wrap(File, "create", ENTITY_KINDS.FILE, "create", {
after: async ({ result }) => {
if (!result || !result.location) return null;
const relPath = toRelativePath(File, result.location);
const synthId = fileLocationToId(relPath);
let contentHash = null;
try {
contentHash = await sha256File(result.location);
} catch (e) {
// best-effort; some create paths may set up metadata only
}
const uuid = await assignNewUuid(ENTITY_KINDS.FILE, synthId, relPath, null);
return {
entityUuid: uuid,
payload: {
after: {
filename: result.filename,
relative_path: relPath,
mime_super: result.mime_super,
mime_sub: result.mime_sub,
size_kb: result.size_kb,
min_role_read: result.min_role_read,
content_hash: contentHash
}
}
};
}
});
wrap(File.prototype, "delete", ENTITY_KINDS.FILE, "drop", {
before: async function () {
const relPath = toRelativePath(File, this.location);
const synthId = fileLocationToId(relPath);
const existing = await lookupByCurrent(ENTITY_KINDS.FILE, synthId);
return {
uuid: existing ? existing.uuid : null,
currentId: synthId,
relPath: relPath,
parentUuid: null,
snapshot: snapshotInstance(this, FILE_KEYS)
};
},
after: async function ({ before }) {
if (!before || !before.uuid) return null;
await removeEntityRow(ENTITY_KINDS.FILE, before.currentId);
return {
entityUuid: before.uuid,
payload: { before: { ...before.snapshot, relative_path: before.relPath } }
};
}
});
};
// ----- TableConstraint -----
const wrapTableConstraint = () => {
wrap(TableConstraint, "create", ENTITY_KINDS.CONSTRAINT, "create", {
after: async ({ result }) => {
if (!result || !result.id) return null;
// Guard against double-firing (e.g. the wrap may be installed in
// multiple module-resolution contexts when sc-exec uses createRequire).
const already = await lookupByCurrent(ENTITY_KINDS.CONSTRAINT, result.id);
if (already) return null;
let parentUuid = null;
if (result.table_id) {
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id);
parentUuid = t ? t.uuid : null;
}
const name = constraintDisplayName(result);
const uuid = await assignNewUuid(ENTITY_KINDS.CONSTRAINT, result.id, name, parentUuid);
return {
entityUuid: uuid,
payload: { after: snapshotInstance(result, CONSTRAINT_KEYS), parent_uuid: parentUuid }
};
}
});
wrap(TableConstraint.prototype, "delete", ENTITY_KINDS.CONSTRAINT, "drop", standardDropHooks(ENTITY_KINDS.CONSTRAINT, CONSTRAINT_KEYS));
};
// ----- Table row CRUD (managed/starter data_mode) -----
//
// Wraps Table.prototype.insertRow / updateRow / deleteRows. Each checks the
// table's data_mode (looked up via _dd_table_modes). 'user' tables pass
// through silently. 'managed' tables always journal. 'starter' tables journal
// only during their initial ship (when starter_shipped_at is NULL); subsequent
// row changes pass through.
const safeJournal = async (opType, entityUuid, payload) => {
try {
const opId = randomUuid();
await enterOp(opId, async () => {
await recordOpSafely({
op_id: opId,
op_type: opType,
entity_kind: "table_row",
entity_uuid: entityUuid,
payload: payload
});
});
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[dev-deploy] ${opType} journal failed:`, e);
}
};
const wrapTableRows = () => {
// --- insertRow ---
const origInsert = Table.prototype.insertRow;
if (origInsert && !origInsert.__ddWrapped) {
const wrapped = async function (...args) {
if (isSuppressed()) {
return await origInsert.apply(this, args);
}
const decision = await journalDecision(this.id);
if (!decision.shouldJournal) {
return await origInsert.apply(this, args);
}
const v_in = args[0] || {};
const newId = await origInsert.apply(this, args);
if (!newId) return newId;
const rowUuid = newRowUuid();
await setRowUuid(this.name, newId, rowUuid);
const { portable, warnings } = await rowToPortable({ ...v_in, id: newId }, this);
await safeJournal("insert_row", rowUuid, {
table_uuid: decision.tableUuid,
after: portable,
warnings: warnings.length > 0 ? warnings : undefined
});
return newId;
};
wrapped.__ddWrapped = true;
wrapped.__ddOriginal = origInsert;
Table.prototype.insertRow = wrapped;
}
// --- updateRow ---
const origUpdate = Table.prototype.updateRow;
if (origUpdate && !origUpdate.__ddWrapped) {
const wrapped = async function (v_in, id_in, ...rest) {
if (isSuppressed()) {
return await origUpdate.apply(this, [v_in, id_in, ...rest]);
}
const decision = await journalDecision(this.id);
if (!decision.shouldJournal) {
return await origUpdate.apply(this, [v_in, id_in, ...rest]);
}
const id = typeof id_in === "object" && id_in !== null ? id_in.id : id_in;
const rowUuid = await getRowUuid(this.name, id);
const result = await origUpdate.apply(this, [v_in, id_in, ...rest]);
if (rowUuid) {
const { portable } = await rowToPortable({ ...v_in, id: id }, this);
await safeJournal("update_row", rowUuid, {
table_uuid: decision.tableUuid,
patch: portable
});
}
return result;
};
wrapped.__ddWrapped = true;
wrapped.__ddOriginal = origUpdate;
Table.prototype.updateRow = wrapped;
}
// --- deleteRows ---
const origDelete = Table.prototype.deleteRows;
if (origDelete && !origDelete.__ddWrapped) {
const wrapped = async function (where, ...rest) {
if (isSuppressed()) {
return await origDelete.apply(this, [where, ...rest]);
}
const decision = await journalDecision(this.id);
if (!decision.shouldJournal) {
return await origDelete.apply(this, [where, ...rest]);
}
// Capture rows BEFORE deletion so we can journal their uuids.
const rows = await db.select(this.name, where || {});
const result = await origDelete.apply(this, [where, ...rest]);
for (const row of rows) {
const rowUuid = row[ROW_UUID_COL];
if (!rowUuid) continue;
const { portable } = await rowToPortable(row, this);
await safeJournal("drop_row", rowUuid, {
table_uuid: decision.tableUuid,
before: portable
});
}
return result;
};
wrapped.__ddWrapped = true;
wrapped.__ddOriginal = origDelete;
Table.prototype.deleteRows = wrapped;
}
};
// ----- Plugin configuration -----
// Wrap Plugin.prototype.upsert to journal configuration changes. Skip the
// dev-deploy plugin itself (no self-reference). Skip cases where the upsert
// doesn't change the configuration — Saltcorn calls upsert on every plugin
// load, which would otherwise flood the journal.
const wrapPlugin = () => {
wrap(Plugin.prototype, "upsert", "plugin_config", "update", {
before: async function () {
if (!this.name || this.name === "dev-deploy") return null;
let priorConfig = null;
if (this.id) {
const existing = await db.selectMaybeOne("_sc_plugins", { id: this.id });
if (existing) {
try {
priorConfig = typeof existing.configuration === "string"
? JSON.parse(existing.configuration)
: (existing.configuration || null);
} catch (e) {
priorConfig = null;
}
}
}
return { name: this.name, priorConfig: priorConfig };
},
after: async function ({ before }) {
if (!before) return null;
const newConfig = this.configuration || {};
if (JSON.stringify(before.priorConfig || {}) === JSON.stringify(newConfig)) {
return null;
}
return {
entityUuid: null,
payload: { name: before.name, configuration: newConfig }
};
}
});
};
// ----- Config keys (menu, etc.) -----
// Config keys we journal when state.setConfig is called. The op carries the
// full key+value pair; apply replays it on the target via setConfig.
// menu_items references pages/views by NAME (not id), which is naturally
// stable across instances, so no UUID translation is needed.
const TRACKED_CONFIG_KEYS = new Set([
"menu_items",
"site_name",
"site_logo_id",
"base_url"
]);
const wrapSetConfig = () => {
const { getState } = require("@saltcorn/data/db/state");
const state = getState();
if (!state || typeof state.setConfig !== "function") return;
if (state.setConfig.__ddWrapped) return;
const original = state.setConfig.bind(state);
const wrapped = async function (key, value) {
if (isSuppressed()) {
return await original(key, value);
}
const result = await original(key, value);
if (!TRACKED_CONFIG_KEYS.has(key)) {
return result;
}
const opId = randomUuid();
await enterOp(opId, async () => {
await recordOpSafely({
op_id: opId,
op_type: "set_config",
entity_kind: "config",
entity_uuid: null, // config keys don't have UUIDs
payload: { key: key, value: value }
});
});
return result;
};
wrapped.__ddWrapped = true;
wrapped.__ddOriginal = original;
state.setConfig = wrapped;
};
// ----- PageGroup -----
// Members of a PageGroup reference page_id (integer). For cross-env identity
// we translate page_id <-> page_uuid in payloads.
const translateMembersToPortable = async (members) => {
const out = [];
for (const m of members || []) {
const pageEnt = m.page_id ? await lookupByCurrent(ENTITY_KINDS.PAGE, m.page_id) : null;
out.push({
page_uuid: pageEnt ? pageEnt.uuid : null,
eligible_formula: m.eligible_formula,
description: m.description,
sequence: m.sequence
});
}
return out;
};
const wrapPageGroup = () => {
wrap(PageGroup, "create", ENTITY_KINDS.PAGE_GROUP, "create", {
after: async ({ result }) => {
if (!result || !result.id) return null;
const already = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, result.id);
if (already) return null;
const uuid = await assignNewUuid(ENTITY_KINDS.PAGE_GROUP, result.id, result.name, null);
const members = await translateMembersToPortable(result.members || []);
return {
entityUuid: uuid,
payload: { after: snapshotInstance(result, PAGE_GROUP_KEYS), members: members }
};
}
});
wrap(PageGroup, "update", ENTITY_KINDS.PAGE_GROUP, "update", {
before: async ({ args }) => {
const id = args[0];
const row = args[1] || {};
const existing = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, id);
return {
id: id,
uuid: existing ? existing.uuid : null,
oldName: existing ? existing.current_name : null,
patch: row
};
},
after: async ({ before }) => {
if (!before || !before.uuid) return null;
const newName = before.patch.name !== undefined ? before.patch.name : before.oldName;
if (newName && newName !== before.oldName) {
await updateName(ENTITY_KINDS.PAGE_GROUP, before.id, newName);
}
return { entityUuid: before.uuid, payload: { patch: before.patch } };
}
});
wrap(PageGroup.prototype, "delete", ENTITY_KINDS.PAGE_GROUP, "drop", standardDropHooks(ENTITY_KINDS.PAGE_GROUP, PAGE_GROUP_KEYS));
};
// ----- PageGroupMember -----
const wrapPageGroupMember = () => {
wrap(PageGroupMember, "create", ENTITY_KINDS.PAGE_GROUP_MEMBER, "create", {
after: async ({ result }) => {
if (!result || !result.id) return null;
const already = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP_MEMBER, result.id);
if (already) return null;
let groupUuid = null;
if (result.page_group_id) {
const g = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, result.page_group_id);
groupUuid = g ? g.uuid : null;
}
let pageUuid = null;
if (result.page_id) {
const p = await lookupByCurrent(ENTITY_KINDS.PAGE, result.page_id);
pageUuid = p ? p.uuid : null;
}
const uuid = await assignNewUuid(ENTITY_KINDS.PAGE_GROUP_MEMBER, result.id, `member_${result.id}`, groupUuid);
return {
entityUuid: uuid,
payload: {
after: {
page_uuid: pageUuid,
eligible_formula: result.eligible_formula,
description: result.description,
sequence: result.sequence
},
parent_uuid: groupUuid
}
};
}
});
wrap(PageGroupMember, "delete", ENTITY_KINDS.PAGE_GROUP_MEMBER, "drop", {
before: async ({ args }) => {
const id = args[0];
const existing = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP_MEMBER, id);
return {
uuid: existing ? existing.uuid : null,
currentId: id,
parentUuid: existing ? existing.parent_uuid : null
};
},
after: async ({ before }) => {
if (!before || !before.uuid) return null;
await removeEntityRow(ENTITY_KINDS.PAGE_GROUP_MEMBER, before.currentId);
return {
entityUuid: before.uuid,
payload: { before: { id: before.currentId }, parent_uuid: before.parentUuid }
};
}
});
};
// ----- WorkflowStep -----
const wrapWorkflowStep = () => {
wrap(WorkflowStep, "create", ENTITY_KINDS.WORKFLOW_STEP, "create", {
after: async ({ args, result }) => {
// WorkflowStep.create returns the inserted integer id (db.insert),
// not an instance. Use the args + id to reconstruct the snapshot.
const id = typeof result === "number" ? result : (result && result.id);
if (!id) return null;
const already = await lookupByCurrent(ENTITY_KINDS.WORKFLOW_STEP, id);
if (already) return null;
const step = await WorkflowStep.findOne({ id: id });
if (!step) return null;
let parentUuid = null;
if (step.trigger_id) {
const tr = await lookupByCurrent(ENTITY_KINDS.TRIGGER, step.trigger_id);
parentUuid = tr ? tr.uuid : null;
}
const name = step.name || `step_${id}`;
const uuid = await assignNewUuid(ENTITY_KINDS.WORKFLOW_STEP, id, name, parentUuid);
return {
entityUuid: uuid,
payload: { after: snapshotInstance(step, WORKFLOW_STEP_KEYS), parent_uuid: parentUuid }
};
}
});
wrap(WorkflowStep.prototype, "update", ENTITY_KINDS.WORKFLOW_STEP, "update", {
before: async function () {
const existing = await lookupByCurrent(ENTITY_KINDS.WORKFLOW_STEP, this.id);
return {
uuid: existing ? existing.uuid : null,
oldName: existing ? existing.current_name : this.name,
snapshot: snapshotInstance(this, WORKFLOW_STEP_KEYS)
};
},
after: async function ({ args, before }) {
if (!before || !before.uuid) return null;
const patch = args[0] || {};
const newName = patch.name !== undefined ? patch.name : before.oldName;
if (newName && newName !== before.oldName) {
await updateName(ENTITY_KINDS.WORKFLOW_STEP, this.id, newName);
}
return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } };
}
});
wrap(WorkflowStep.prototype, "delete", ENTITY_KINDS.WORKFLOW_STEP, "drop", standardDropHooks(ENTITY_KINDS.WORKFLOW_STEP, WORKFLOW_STEP_KEYS));
};
const installAllWraps = () => {
wrapTable();
wrapField();
wrapView();
wrapPage();
wrapTrigger();
wrapRole();
wrapLibrary();
wrapTag();
wrapTableConstraint();
wrapFile();
wrapPageGroup();
wrapPageGroupMember();
wrapWorkflowStep();
wrapSetConfig();
wrapPlugin();
wrapTableRows();
};
module.exports = {
installAllWraps
};

21
dev-deploy/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "dev-deploy",
"version": "0.0.1",
"description": "Saltcorn plugin: migrate metadata changes (tables, fields, views, pages, triggers, roles, library, tags) across Dev/Test/Prod environments via an ops journal with stable UUIDs and HMAC-authenticated peer endpoints. Detects concurrent-edit conflicts and offers theirs/mine resolution. User-table row data is never touched.",
"main": "index.js",
"scripts": {
"test": "node test/e2e.js"
},
"keywords": [
"deploy",
"migration",
"ops-journal",
"hmac",
"metadata-sync"
],
"engines": {
"node": ">=20"
},
"author": "Scott Duensing",
"license": "MIT"
}

980
dev-deploy/test/e2e.js Normal file
View file

@ -0,0 +1,980 @@
// dev-deploy integration test suite.
//
// Prereqs: both saltcorn instances running and reachable, plugin installed +
// bootstrapped, admin@local / AdminP@ss1 present on both. Run from the
// dev-deploy/ directory:
//
// cd ~/claude/saltcorn && ./startServer.sh & ./startServerTest.sh &
// node test/e2e.js
//
// Each test resets only what it needs to. Tests run in order; assertion
// failures are caught and counted -- one failure does not stop the suite.
const { execFileSync, spawn } = require("node:child_process");
const assert = require("node:assert/strict");
const path = require("node:path");
const MAIN = { url: "http://localhost:3000", db: "/home/scott/claude/saltcorn/.dev-state/saltcorn.sqlite", env: "/home/scott/claude/saltcorn/.dev-state/env.sh" };
const TEST = { url: "http://localhost:3001", db: "/home/scott/claude/saltcorn/.dev-state-test/saltcorn.sqlite", env: "/home/scott/claude/saltcorn/.dev-state-test/env.sh" };
const MAIN_COOKIES = "/tmp/dd-test-jar-main.txt";
const TEST_COOKIES = "/tmp/dd-test-jar-test.txt";
// --- helpers ---
const sql = (dbPath, query) => {
const out = execFileSync("sqlite3", [dbPath, query], { encoding: "utf8" });
return out.trim();
};
const sqlRows = (dbPath, query) => {
const out = sql(dbPath, query);
if (!out) return [];
return out.split("\n");
};
const resetInstanceDb = (dbPath) => {
sql(dbPath, `
DELETE FROM _dd_ops;
DELETE FROM _dd_peers;
DELETE FROM _dd_anchors;
DELETE FROM _dd_table_modes;
DELETE FROM _sc_table_constraints;
DELETE FROM _sc_workflow_steps;
DELETE FROM _sc_page_group_members;
DELETE FROM _sc_page_groups;
DELETE FROM _sc_pages;
DELETE FROM _sc_triggers;
DELETE FROM _sc_views;
DELETE FROM _sc_config WHERE key IN ('menu_items', 'unrolled_menu_items');
UPDATE _sc_plugins SET configuration = NULL WHERE name != 'dev-deploy';
`);
// Strip _dd_entity_ids back to Saltcorn's bootstrap baseline.
sql(dbPath, `
DELETE FROM _dd_entity_ids WHERE kind NOT IN ('table','field','role');
DELETE FROM _dd_entity_ids WHERE kind='table' AND current_name != 'users';
DELETE FROM _dd_entity_ids WHERE kind='field' AND current_name NOT IN ('id','email','role_id');
DELETE FROM _dd_entity_ids WHERE kind='role' AND current_name NOT IN ('admin','staff','user','public');
`);
const userTables = sqlRows(dbPath, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\\_%' ESCAPE '\\' AND name != 'users' AND name NOT LIKE 'sqlite_%';");
for (const t of userTables) {
if (t) sql(dbPath, `DROP TABLE IF EXISTS "${t}"`);
}
sql(dbPath, "DELETE FROM _sc_tables WHERE name != 'users'; DELETE FROM _sc_fields WHERE table_id NOT IN (SELECT id FROM _sc_tables);");
};
const runJs = (envPath, code) => {
return execFileSync(
"bash",
["-c", `source ${envPath} && saltcorn run-js --code ${JSON.stringify(code)}`],
{ encoding: "utf8" }
);
};
// scExec is like runJs but uses our test/sc-exec.js shim, giving the JS body
// full require() access -- needed for Field, TableConstraint, File, etc. that
// saltcorn run-js's vm sandbox doesn't expose. Code is piped via stdin to
// dodge shell-escape pitfalls with multi-line strings.
const scExec = (envPath, code) => {
return execFileSync(
"bash",
["-c", `source ${envPath} && node ${path.join(__dirname, "sc-exec.js")}`],
{ encoding: "utf8", input: code }
);
};
// --- HTTP helpers (browser-style admin auth via cookie jar) ---
const curl = (args) => {
const out = execFileSync("curl", args, { encoding: "utf8" });
return out;
};
const extractCsrfFromHtml = (html) => {
const m = html.match(/name="_csrf"[^>]*value="([^"]+)"/);
return m ? m[1] : null;
};
const login = (instance, jar) => {
execFileSync("rm", ["-f", jar]);
const page = curl(["-sS", "-c", jar, `${instance.url}/auth/login`]);
const csrf = extractCsrfFromHtml(page);
if (!csrf) throw new Error(`no csrf on login page for ${instance.url}`);
curl([
"-sS", "-c", jar, "-b", jar, "-o", "/dev/null",
"-X", "POST", `${instance.url}/auth/login`,
"-d", `email=admin@local&password=AdminP@ss1&_csrf=${csrf}`
]);
};
const envIdOf = (instance, jar) => {
const html = curl(["-sS", "-b", jar, `${instance.url}/admin/dev-deploy/`]);
const m = html.match(/<code>([0-9a-f-]{36})<\/code>/);
if (!m) throw new Error(`no env_id in dashboard html for ${instance.url}`);
return m[1];
};
const freshCsrf = (instance, jar, urlPath) => {
const html = curl(["-sS", "-b", jar, `${instance.url}${urlPath}`]);
return extractCsrfFromHtml(html);
};
// Each admin POST handler embeds CSRF in a form on a specific GET page.
// Match the POST path to the page that renders its form.
const CSRF_PAGE_FOR = [
["/admin/dev-deploy/peers/add", "/admin/dev-deploy/peers"],
["/admin/dev-deploy/peers/rotate", "/admin/dev-deploy/peers"],
["/admin/dev-deploy/peers/delete", "/admin/dev-deploy/peers"],
["/admin/dev-deploy/promote", "/admin/dev-deploy/peers"],
["/admin/dev-deploy/pull", "/admin/dev-deploy/peers"],
["/admin/dev-deploy/conflicts/resolve", "/admin/dev-deploy/conflicts"],
["/admin/dev-deploy/tables/set", "/admin/dev-deploy/tables"],
["/admin/dev-deploy/revert", "/admin/dev-deploy/ops"]
];
const adminPost = (instance, jar, urlPath, fields) => {
const mapping = CSRF_PAGE_FOR.find(([p]) => urlPath === p);
if (!mapping) throw new Error(`no CSRF source page mapped for POST ${urlPath}`);
const csrf = freshCsrf(instance, jar, mapping[1]);
if (!csrf) throw new Error(`no CSRF token on ${mapping[1]} for POST ${urlPath}`);
const args = ["-sS", "-b", jar, "-o", "/tmp/dd-test-postbody.html",
"-w", "%{http_code}|%{redirect_url}",
"-X", "POST", `${instance.url}${urlPath}`,
"--data-urlencode", `_csrf=${csrf}`];
for (const [k, v] of Object.entries(fields)) {
args.push("--data-urlencode", `${k}=${v}`);
}
const out = curl(args);
const [code, redirect] = out.split("|");
return { status: parseInt(code, 10), redirect: redirect, body: require("node:fs").readFileSync("/tmp/dd-test-postbody.html", "utf8") };
};
// --- test runner ---
let passed = 0, failed = 0;
const failures = [];
const test = async (name, fn) => {
try {
await fn();
console.log(` PASS ${name}`);
passed++;
} catch (err) {
console.log(` FAIL ${name}`);
console.log(` ${err.message}`);
failures.push({ name, err });
failed++;
}
};
const section = (name) => {
console.log(`\n[${name}]`);
};
// --- scenarios ---
const main = async () => {
section("preconditions");
await test("both servers respond to /auth/login", async () => {
for (const inst of [MAIN, TEST]) {
const out = curl(["-sS", "-o", "/dev/null", "-w", "%{http_code}", `${inst.url}/auth/login`]);
assert.equal(out, "200", `${inst.url} returned ${out}`);
}
});
section("reset both instances");
resetInstanceDb(MAIN.db);
resetInstanceDb(TEST.db);
login(MAIN, MAIN_COOKIES);
login(TEST, TEST_COOKIES);
const mainEnv = envIdOf(MAIN, MAIN_COOKIES);
const testEnv = envIdOf(TEST, TEST_COOKIES);
console.log(` main env_id: ${mainEnv}`);
console.log(` test env_id: ${testEnv}`);
section("schema");
await test("six _dd_* tables exist on main", async () => {
const tables = sqlRows(MAIN.db, "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '_dd_%' ORDER BY name").join(",");
assert.equal(tables, "_dd_anchors,_dd_entity_ids,_dd_env,_dd_ops,_dd_peers,_dd_table_modes");
});
await test("_dd_ops has conflict_with_op_id column", async () => {
const cols = sqlRows(MAIN.db, "PRAGMA table_info(_dd_ops)").map((r) => r.split("|")[1]);
assert.ok(cols.includes("conflict_with_op_id"), `cols: ${cols.join(",")}`);
});
section("bootstrap identity");
await test("env_ids are distinct UUIDs", async () => {
assert.notEqual(mainEnv, testEnv);
assert.match(mainEnv, /^[0-9a-f-]{36}$/);
});
await test("'users' table has identical UUID on both instances (deterministic backfill)", async () => {
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='users'");
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='users'");
assert.equal(m, t, "deterministic UUIDs diverged");
assert.notEqual(m, "");
});
await test("all baseline role UUIDs match across instances", async () => {
const m = sqlRows(MAIN.db, "SELECT current_name||'|'||uuid FROM _dd_entity_ids WHERE kind='role' ORDER BY current_name").join(",");
const t = sqlRows(TEST.db, "SELECT current_name||'|'||uuid FROM _dd_entity_ids WHERE kind='role' ORDER BY current_name").join(",");
assert.equal(m, t);
});
section("mutation capture");
runJs(MAIN.env, 'const t = await Table.create("widgets"); await t.update({ description: "initial" });');
await test("create_table op is journaled", async () => {
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_table' AND source_env_id='" + mainEnv + "'");
assert.equal(c, "1");
});
await test("update_table op is journaled", async () => {
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='update_table' AND source_env_id='" + mainEnv + "'");
assert.ok(parseInt(c, 10) >= 1);
});
section("pairing");
const addRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/peers/add", {
env_id: testEnv,
label: "test",
base_url: TEST.url
});
await test("main /peers/add returns 200 with shared secret", async () => {
assert.equal(addRes.status, 200);
});
const secretMatch = addRes.body.match(/class="secret">([0-9a-f]{64})</);
if (!secretMatch) throw new Error("no shared secret in /peers/add response");
const secret = secretMatch[1];
const pairTest = adminPost(TEST, TEST_COOKIES, "/admin/dev-deploy/peers/add", {
env_id: mainEnv,
label: "main",
base_url: MAIN.url,
existing_secret: secret
});
await test("test /peers/add accepts the shared secret", async () => {
assert.equal(pairTest.status, 200);
});
section("promote main -> test");
const peerIdOnMain = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const promoteRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdOnMain });
await test("promote returns 302 with success message", async () => {
assert.equal(promoteRes.status, 302);
assert.match(promoteRes.redirect, /msg=promoted/);
});
await test("test instance now has widgets table", async () => {
const r = sql(TEST.db, "SELECT name FROM _sc_tables WHERE name='widgets'");
assert.equal(r, "widgets");
});
await test("widgets UUID matches between main and test", async () => {
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='widgets'");
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='widgets'");
assert.equal(m, t);
});
section("pull test -> main (no conflicts)");
runJs(TEST.env, 'await Table.create("test_added");');
const peerIdOnMain2 = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const pullRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/pull", { peer_id: peerIdOnMain2 });
await test("pull returns 302 with success message", async () => {
assert.equal(pullRes.status, 302);
assert.match(pullRes.redirect, /msg=pulled/);
});
await test("main now has test_added", async () => {
const r = sql(MAIN.db, "SELECT name FROM _sc_tables WHERE name='test_added'");
assert.equal(r, "test_added");
});
await test("test_added UUID matches across instances", async () => {
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='test_added'");
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='test_added'");
assert.equal(m, t);
});
section("conflict detection + resolution");
runJs(MAIN.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "from MAIN" });');
runJs(TEST.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "from TEST" });');
const peerIdForConflict = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const pullConflictRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/pull", { peer_id: peerIdForConflict });
await test("pull with divergent edits returns 1 conflict", async () => {
assert.match(pullConflictRes.redirect, /1%20conflicts/);
});
await test("conflict op recorded with status=conflict", async () => {
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
assert.equal(c, "1");
});
await test("conflict op has conflict_with_op_id set", async () => {
const r = sql(MAIN.db, "SELECT conflict_with_op_id FROM _dd_ops WHERE status='conflict'");
assert.match(r, /^[0-9a-f-]{36}$/);
});
await test("widgets description on main is still 'from MAIN' (held op not applied)", async () => {
const r = sql(MAIN.db, "SELECT description FROM _sc_tables WHERE name='widgets'");
assert.equal(r, "from MAIN");
});
const conflictOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE status='conflict' LIMIT 1");
const resolveRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/conflicts/resolve", {
op_id: conflictOp,
action: "theirs"
});
await test("resolve theirs returns 302 with success", async () => {
assert.equal(resolveRes.status, 302);
assert.match(resolveRes.redirect, /msg=resolved/);
});
await test("widgets description on main is now 'from TEST'", async () => {
const r = sql(MAIN.db, "SELECT description FROM _sc_tables WHERE name='widgets'");
assert.equal(r, "from TEST");
});
await test("resolved op status is committed", async () => {
const r = sql(MAIN.db, `SELECT status FROM _dd_ops WHERE op_id='${conflictOp}'`);
assert.equal(r, "committed");
});
await test("pending conflicts is 0", async () => {
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
assert.equal(c, "0");
});
section("conflict merge per-field");
runJs(MAIN.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "M2" });');
runJs(TEST.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "T2" });');
const peerIdForMerge = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/pull", { peer_id: peerIdForMerge });
await test("a fresh conflict appears for the second divergence", async () => {
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
assert.equal(c, "1");
});
const mergeOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE status='conflict' LIMIT 1");
await test("merge view returns 200 for an update-vs-update conflict", async () => {
const out = curl(["-sS", "-b", MAIN_COOKIES, "-o", "/dev/null", "-w", "%{http_code}",
`${MAIN.url}/admin/dev-deploy/conflicts/merge?op_id=${mergeOp}`]);
assert.equal(out, "200");
});
await test("merge view shows the diverging description field", async () => {
const html = curl(["-sS", "-b", MAIN_COOKIES,
`${MAIN.url}/admin/dev-deploy/conflicts/merge?op_id=${mergeOp}`]);
assert.match(html, /choice_description/);
assert.match(html, /T2/);
});
// Fetch the merge page for CSRF then POST a custom value
const mergeHtml = curl(["-sS", "-b", MAIN_COOKIES,
`${MAIN.url}/admin/dev-deploy/conflicts/merge?op_id=${mergeOp}`]);
const mergeCsrf = extractCsrfFromHtml(mergeHtml);
const mergeResp = execFileSync("curl", [
"-sS", "-b", MAIN_COOKIES, "-o", "/dev/null",
"-w", "%{http_code}|%{redirect_url}",
"-X", "POST", `${MAIN.url}/admin/dev-deploy/conflicts/merge/apply`,
"--data-urlencode", `_csrf=${mergeCsrf}`,
"--data-urlencode", `op_id=${mergeOp}`,
"--data-urlencode", "choice_description=custom",
"--data-urlencode", "custom_description=hybrid value"
], { encoding: "utf8" });
const [mergeStatus, mergeRedirect] = mergeResp.split("|");
await test("merge apply returns 302 with success message", async () => {
assert.equal(mergeStatus, "302");
assert.match(mergeRedirect, /msg=merged/);
});
await test("widgets description on main is now 'hybrid value'", async () => {
const r = sql(MAIN.db, "SELECT description FROM _sc_tables WHERE name='widgets'");
assert.equal(r, "hybrid value");
});
await test("merged op has status=merged, conflict_with cleared", async () => {
const r = sql(MAIN.db, `SELECT status||'|'||COALESCE(conflict_with_op_id,'') FROM _dd_ops WHERE op_id='${mergeOp}'`);
assert.equal(r, "merged|");
});
await test("pending conflicts is 0 after merge", async () => {
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
assert.equal(c, "0");
});
section("table constraints");
// Add a field to widgets and a unique constraint on it. Promote to test.
scExec(MAIN.env, `
const t = Table.findOne({name: "widgets"});
await Field.create({ table_id: t.id, name: "sku", label: "SKU", type: "String" });
const fresh = Table.findOne({name: "widgets"});
await TableConstraint.create({ table_id: fresh.id, type: "Unique", configuration: { fields: ["sku"] } });
`);
await test("create_field op journaled for sku", async () => {
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_field' AND payload LIKE '%sku%'");
assert.equal(c, "1");
});
await test("create_constraint op journaled with type=Unique", async () => {
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_constraint' AND payload LIKE '%Unique%'");
assert.equal(c, "1");
});
const peerIdForConstraint = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const promoteConstraintRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForConstraint });
await test("promote with constraint returns success", async () => {
assert.equal(promoteConstraintRes.status, 302);
assert.match(promoteConstraintRes.redirect, /msg=promoted/);
});
await test("test now has the sku field", async () => {
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_fields WHERE name='sku' AND table_id=(SELECT id FROM _sc_tables WHERE name='widgets')");
assert.equal(r, "1");
});
await test("test now has the unique constraint on sku", async () => {
// _sc_table_constraints uses JSON for configuration; SQLite stores TEXT
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_table_constraints WHERE type='Unique' AND configuration LIKE '%sku%'");
assert.equal(r, "1");
});
await test("constraint UUID matches across instances", async () => {
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='constraint' AND current_name='unique(sku)'");
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='constraint' AND current_name='unique(sku)'");
assert.equal(m, t);
assert.notEqual(m, "");
});
section("file propagation");
const FILE_BYTES = "Hello, dev-deploy! This is a test document.";
scExec(MAIN.env, `
const fs = require("fs");
const path = require("path");
const tenant = db.getTenantSchema();
const absPath = path.join(db.connectObj.file_store, tenant, "test_doc.txt");
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, ${JSON.stringify(FILE_BYTES)});
await File.create({
filename: "test_doc.txt",
location: absPath,
uploaded_at: new Date(),
size_kb: 1,
mime_super: "text",
mime_sub: "plain",
min_role_read: 1
});
`);
await test("create_file op journaled with content_hash + relative_path", async () => {
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_file' LIMIT 1");
assert.match(r, /content_hash/);
assert.match(r, /test_doc\.txt/);
});
await test("file entry in _dd_entity_ids on main", async () => {
const r = sql(MAIN.db, "SELECT current_name FROM _dd_entity_ids WHERE kind='file' LIMIT 1");
assert.equal(r, "test_doc.txt");
});
const peerIdForFile = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const fileRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForFile });
await test("file promote returns success", async () => {
assert.equal(fileRes.status, 302);
assert.match(fileRes.redirect, /msg=promoted/);
});
await test("file exists on test instance with same bytes", async () => {
const fs = require("node:fs");
const out = fs.readFileSync("/home/scott/claude/saltcorn/.dev-state-test/files/public/test_doc.txt", "utf8");
assert.equal(out, FILE_BYTES);
});
await test("file UUID matches across instances", async () => {
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='file' AND current_name='test_doc.txt'");
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='file' AND current_name='test_doc.txt'");
assert.equal(m, t);
assert.notEqual(m, "");
});
section("plugin mismatch warning");
await test("health endpoint is reachable and HMAC-required", async () => {
const out = curl(["-sS", "-o", "/dev/null", "-w", "%{http_code}", `${TEST.url}/dev-deploy/api/health`]);
assert.equal(out, "400"); // missing HMAC headers
});
await test("promote includes no plugin warnings when peers match", async () => {
// Both instances have identical plugin lists (base, sbadmin2, dev-deploy)
// so the warning suffix should be absent.
runJs(MAIN.env, 'await Table.create("warning_demo_table");');
const peerId = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const r = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerId });
assert.equal(r.status, 302);
// Both sides have the same plugins so no WARNINGS suffix should appear.
assert.ok(!/WARNINGS/.test(r.redirect || ""), `unexpected warnings: ${r.redirect}`);
});
section("file refs in page layout");
scExec(MAIN.env, `
const Page = require("@saltcorn/data/models/page");
// Pages with a file ref in their layout — fileid as a relative-path
// string (the form Saltcorn produces on a fresh upload).
await Page.create({
name: "page_with_image",
title: "Page With Image",
description: "Has an image",
min_role: 100,
layout: { type: "image", srctype: "File", fileid: "test_doc.txt", alt: "test" },
fixed_states: {}
});
`);
await test("create_page op replaces fileid with placeholder in journal", async () => {
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_page' AND payload LIKE '%page_with_image%' LIMIT 1");
assert.match(r, /__dd_file_ref::/);
// The original string must NOT appear as the raw fileid value
assert.ok(!/"fileid":"test_doc\.txt"/.test(r), `fileid was not translated: ${r}`);
});
const peerIdForRefs = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const refsPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRefs });
await test("page-with-image promote returns success", async () => {
assert.equal(refsPromote.status, 302);
assert.match(refsPromote.redirect, /msg=promoted/);
});
await test("page exists on test with fileid resolved back to local path", async () => {
const layout = sql(TEST.db, "SELECT layout FROM _sc_pages WHERE name='page_with_image'");
// On test, fileid should be the relative path that resolves locally
assert.match(layout, /"fileid":"test_doc\.txt"/);
// Placeholder must NOT leak into the live entity
assert.ok(!/__dd_file_ref::/.test(layout), `placeholder leaked into live page: ${layout}`);
});
section("page_group propagation");
scExec(MAIN.env, `
const Page = require("@saltcorn/data/models/page");
const PageGroup = require("@saltcorn/data/models/page_group");
const page = await Page.create({
name: "intro_page",
title: "Intro",
description: "Intro page",
min_role: 100,
layout: { type: "blank", contents: "Hello" },
fixed_states: {}
});
const pg = await PageGroup.create({
name: "home_group",
description: "home routing",
min_role: 100,
random_allocation: false,
members: []
});
await pg.addMember({
page_id: page.id,
eligible_formula: "true",
description: "default"
});
`);
await test("create_page_group op journaled", async () => {
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_page_group' LIMIT 1");
assert.match(r, /home_group/);
});
await test("create_page_group_member op journaled with page_uuid", async () => {
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_page_group_member' LIMIT 1");
assert.match(r, /page_uuid/);
});
const peerIdForPG = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const pgPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForPG });
await test("page_group promote returns success", async () => {
assert.equal(pgPromote.status, 302);
assert.match(pgPromote.redirect, /msg=promoted/);
});
await test("test instance has home_group page_group", async () => {
const r = sql(TEST.db, "SELECT name FROM _sc_page_groups WHERE name='home_group'");
assert.equal(r, "home_group");
});
await test("page_group on test has its member row", async () => {
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_page_group_members m JOIN _sc_page_groups g ON m.page_group_id=g.id WHERE g.name='home_group'");
assert.equal(r, "1");
});
await test("page_group UUID matches across instances", async () => {
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='page_group' AND current_name='home_group'");
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='page_group' AND current_name='home_group'");
assert.equal(m, t);
assert.notEqual(m, "");
});
section("workflow_step propagation");
scExec(MAIN.env, `
const Trigger = require("@saltcorn/data/models/trigger");
const WorkflowStep = require("@saltcorn/data/models/workflow_step");
// clean any prior
const oldT = Trigger.findOne({name: "demo_workflow"});
if (oldT) await oldT.delete();
const tr = await Trigger.create({
name: "demo_workflow",
action: "Workflow",
when_trigger: "Never",
min_role: 1,
configuration: {}
});
await WorkflowStep.create({
name: "step1",
trigger_id: tr.id,
action_name: "set_context",
next_step: "",
initial_step: true,
configuration: { ctx_values: '{ "hello": "world" }' },
only_if: ""
});
`);
await test("create_workflow_step op journaled", async () => {
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_workflow_step'");
assert.equal(r, "1");
});
const peerIdForWf = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const wfPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForWf });
await test("workflow_step promote returns success", async () => {
assert.equal(wfPromote.status, 302);
assert.match(wfPromote.redirect, /msg=promoted/);
});
await test("test has the demo_workflow trigger", async () => {
const r = sql(TEST.db, "SELECT name FROM _sc_triggers WHERE name='demo_workflow'");
assert.equal(r, "demo_workflow");
});
await test("test has the workflow step", async () => {
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_workflow_steps s JOIN _sc_triggers t ON s.trigger_id=t.id WHERE t.name='demo_workflow' AND s.name='step1'");
assert.equal(r, "1");
});
await test("workflow_step UUID matches across instances", async () => {
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='workflow_step' AND current_name='step1'");
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='workflow_step' AND current_name='step1'");
assert.equal(m, t);
assert.notEqual(m, "");
});
section("config + menu propagation");
scExec(MAIN.env, `
const { getState } = require("@saltcorn/data/db/state");
await getState().setConfig("menu_items", [
{ label: "Widgets", type: "View", viewname: "widget_list" },
{ label: "About", type: "Page", pagename: "about" }
]);
`);
await test("set_config op journaled for menu_items", async () => {
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='set_config' AND payload LIKE '%menu_items%'");
assert.ok(parseInt(r, 10) >= 1, `got ${r} set_config ops`);
});
const peerIdForMenu = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const menuPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForMenu });
await test("menu promote returns success", async () => {
assert.equal(menuPromote.status, 302);
assert.match(menuPromote.redirect, /msg=promoted/);
});
await test("test instance has matching menu_items", async () => {
const v = sql(TEST.db, "SELECT value FROM _sc_config WHERE key='menu_items'");
assert.match(v, /widget_list/);
assert.match(v, /about/);
});
section("plugin configuration propagation");
scExec(MAIN.env, `
const Plugin = require("@saltcorn/data/models/plugin");
const p = await Plugin.findOne({ name: "sbadmin2" });
if (!p) throw new Error("sbadmin2 plugin not found");
p.configuration = { test_key: "from MAIN", color_scheme: "dark" };
await p.upsert();
`);
await test("update_plugin_config op journaled for sbadmin2", async () => {
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='update_plugin_config' AND payload LIKE '%sbadmin2%'");
assert.equal(r, "1");
});
const peerIdForPC = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const pcPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForPC });
await test("plugin-config promote returns success", async () => {
assert.equal(pcPromote.status, 302);
assert.match(pcPromote.redirect, /msg=promoted/);
});
await test("test sbadmin2 has updated configuration", async () => {
const cfg = sql(TEST.db, "SELECT configuration FROM _sc_plugins WHERE name='sbadmin2'");
assert.match(cfg, /from MAIN/);
assert.match(cfg, /color_scheme/);
});
section("managed row data propagation");
// Create two tables on main: categories + products with FK products.category_id -> categories.id.
// Add some rows. Then mark both managed. Promote. Verify rows on test with same UUIDs.
scExec(MAIN.env, `
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const cats = await Table.create("dd_categories");
await Field.create({ table_id: cats.id, name: "name", label: "Name", type: "String" });
const prods = await Table.create("dd_products");
await Field.create({ table_id: prods.id, name: "name", label: "Name", type: "String" });
await Field.create({ table_id: prods.id, name: "price", label: "Price", type: "Float" });
await Field.create({ table_id: prods.id, name: "category", label: "Category", type: "Key to dd_categories", reftable_name: "dd_categories", reftype: "Integer" });
const cats2 = Table.findOne({name: "dd_categories"});
const electId = await cats2.insertRow({ name: "Electronics" });
const apparelId = await cats2.insertRow({ name: "Apparel" });
const prods2 = Table.findOne({name: "dd_products"});
await prods2.insertRow({ name: "Widget", price: 9.99, category: electId });
await prods2.insertRow({ name: "Gadget", price: 19.95, category: electId });
await prods2.insertRow({ name: "T-shirt", price: 14.99, category: apparelId });
`);
// Mark both tables managed via the admin UI (causes initial ship)
const catsUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='dd_categories'");
const prodsUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='dd_products'");
const setCatsManaged = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", { table_uuid: catsUuid, data_mode: "managed" });
const setProdsManaged = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", { table_uuid: prodsUuid, data_mode: "managed" });
await test("marking dd_categories managed ships 2 rows", async () => {
assert.match(setCatsManaged.redirect, /shipped.{1,3}2.{1,3}rows/);
});
await test("marking dd_products managed ships 3 rows", async () => {
assert.match(setProdsManaged.redirect, /shipped.{1,3}3.{1,3}rows/);
});
await test("_dd_row_uuid column exists on dd_categories on main", async () => {
const info = sqlRows(MAIN.db, "PRAGMA table_info(dd_categories);").join(",");
assert.match(info, /_dd_row_uuid/);
});
await test("insert_row ops journaled for the seed rows", async () => {
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='insert_row'");
assert.ok(parseInt(c, 10) >= 5, `expected >=5 insert_row ops, got ${c}`);
});
// Promote
const peerIdForRows = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
const rowPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
await test("managed row promote returns success", async () => {
assert.equal(rowPromote.status, 302);
assert.match(rowPromote.redirect, /msg=promoted/);
});
await test("test has dd_categories rows", async () => {
const r = sql(TEST.db, "SELECT COUNT(*) FROM dd_categories");
assert.equal(r, "2");
});
await test("test has dd_products rows", async () => {
const r = sql(TEST.db, "SELECT COUNT(*) FROM dd_products");
assert.equal(r, "3");
});
await test("a product on test has its FK resolved to a local category id", async () => {
const r = sql(TEST.db, "SELECT p.name || '|' || c.name FROM dd_products p JOIN dd_categories c ON p.category=c.id WHERE p.name='Widget'");
assert.equal(r, "Widget|Electronics");
});
await test("row UUIDs match across instances for a product", async () => {
const m = sql(MAIN.db, "SELECT _dd_row_uuid FROM dd_products WHERE name='Widget'");
const t = sql(TEST.db, "SELECT _dd_row_uuid FROM dd_products WHERE name='Widget'");
assert.equal(m, t);
assert.notEqual(m, "");
});
// Update a row on main, promote again, verify on test.
scExec(MAIN.env, `
const Table = require("@saltcorn/data/models/table");
const prods = Table.findOne({name: "dd_products"});
const widget = (await prods.getJoinedRows({ where: { name: "Widget" }}))[0];
await prods.updateRow({ price: 12.50 }, widget.id);
`);
const rowPromote2 = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
await test("update_row op promoted", async () => {
assert.match(rowPromote2.redirect, /msg=promoted/);
});
await test("test sees Widget at new price 12.5", async () => {
const r = sql(TEST.db, "SELECT price FROM dd_products WHERE name='Widget'");
assert.equal(r, "12.5");
});
// Delete a row on main, promote, verify removed on test.
scExec(MAIN.env, `
const Table = require("@saltcorn/data/models/table");
const prods = Table.findOne({name: "dd_products"});
await prods.deleteRows({ name: "T-shirt" });
`);
const rowPromote3 = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
await test("drop_row op promoted", async () => {
assert.match(rowPromote3.redirect, /msg=promoted/);
});
await test("test no longer has T-shirt row", async () => {
const r = sql(TEST.db, "SELECT COUNT(*) FROM dd_products WHERE name='T-shirt'");
assert.equal(r, "0");
});
section("starter rows: ship once then detach");
scExec(MAIN.env, `
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const seed = await Table.create("dd_templates");
await Field.create({ table_id: seed.id, name: "name", label: "Name", type: "String" });
const t = Table.findOne({name: "dd_templates"});
await t.insertRow({ name: "Welcome" });
await t.insertRow({ name: "Goodbye" });
`);
const seedUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='dd_templates'");
const setSeedStarter = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", { table_uuid: seedUuid, data_mode: "starter" });
await test("marking dd_templates starter ships 2 rows", async () => {
assert.match(setSeedStarter.redirect, /shipped.{1,3}2.{1,3}rows/);
});
await test("dd_templates marked starter_shipped_at", async () => {
const r = sql(MAIN.db, `SELECT starter_shipped_at IS NOT NULL FROM _dd_table_modes WHERE table_uuid='${seedUuid}'`);
assert.equal(r, "1");
});
// After starter ship, further row ops on main should NOT journal
const beforeCount = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='insert_row' OR op_type='update_row'");
scExec(MAIN.env, `
const Table = require("@saltcorn/data/models/table");
const t = Table.findOne({name: "dd_templates"});
await t.insertRow({ name: "Post-ship row" });
`);
const afterCount = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='insert_row' OR op_type='update_row'");
await test("subsequent inserts on starter table do NOT journal", async () => {
assert.equal(afterCount, beforeCount, `journal grew from ${beforeCount} to ${afterCount} on a starter table`);
});
const starterPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
await test("starter promote ships initial rows", async () => {
assert.match(starterPromote.redirect, /msg=promoted/);
});
await test("test has the 2 initial templates (but not the post-ship row)", async () => {
const c = sql(TEST.db, "SELECT COUNT(*) FROM dd_templates");
assert.equal(c, "2");
const has = sql(TEST.db, "SELECT COUNT(*) FROM dd_templates WHERE name='Welcome'");
assert.equal(has, "1");
const postship = sql(TEST.db, "SELECT COUNT(*) FROM dd_templates WHERE name='Post-ship row'");
assert.equal(postship, "0");
});
section("revert");
runJs(MAIN.env, 'await Table.create("ephemeral");');
const createOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE op_type='create_table' AND payload LIKE '%ephemeral%' LIMIT 1");
const revertRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/revert", { op_id: createOp });
await test("revert returns 302", async () => {
assert.equal(revertRes.status, 302);
});
await test("ephemeral table is gone after revert", async () => {
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _sc_tables WHERE name='ephemeral'");
assert.equal(r, "0");
});
await test("drop_table op recorded as the revert", async () => {
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='drop_table' AND payload LIKE '%ephemeral%'");
assert.ok(parseInt(r, 10) >= 1);
});
section("data_mode");
const widgetsUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='widgets'");
const setRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", {
table_uuid: widgetsUuid,
data_mode: "starter"
});
await test("tables/set redirects with success", async () => {
assert.equal(setRes.status, 302);
assert.match(setRes.redirect, /msg=set/);
});
await test("_dd_table_modes row inserted with starter", async () => {
const r = sql(MAIN.db, `SELECT data_mode FROM _dd_table_modes WHERE table_uuid='${widgetsUuid}'`);
assert.equal(r, "starter");
});
await test("users table is locked: cannot change data_mode", async () => {
const usersUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='users' AND kind='table'");
const r = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", {
table_uuid: usersUuid,
data_mode: "managed"
});
assert.match(r.redirect || "", /err=/);
const mode = sql(MAIN.db, `SELECT COUNT(*) FROM _dd_table_modes WHERE table_uuid='${usersUuid}'`);
assert.equal(mode, "0", "users table should have no row in _dd_table_modes");
});
section("machine-endpoint security");
// Test that requests without HMAC are rejected
await test("unsigned POST to ingest returns 400 (missing header)", async () => {
const out = curl([
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST",
"-H", "Content-Type: application/vnd.dev-deploy+json",
"--data", '{"ops":[]}',
`${TEST.url}/dev-deploy/api/ingest`
]);
assert.equal(out, "400");
});
await test("ingest with bogus signature returns 401", async () => {
const out = curl([
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST",
"-H", "Content-Type: application/vnd.dev-deploy+json",
"-H", `X-DD-Env-Id: ${mainEnv}`,
"-H", `X-DD-Timestamp: ${Date.now()}`,
"-H", "X-DD-Nonce: deadbeefdeadbeefdeadbeefdeadbeef",
"-H", "X-DD-Signature: 0000000000000000000000000000000000000000000000000000000000000000",
"--data", '{"ops":[]}',
`${TEST.url}/dev-deploy/api/ingest`
]);
assert.equal(out, "401");
});
await test("ingest from unknown env_id returns 401", async () => {
const out = curl([
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST",
"-H", "Content-Type: application/vnd.dev-deploy+json",
"-H", "X-DD-Env-Id: 00000000-0000-4000-8000-000000000000",
"-H", `X-DD-Timestamp: ${Date.now()}`,
"-H", "X-DD-Nonce: deadbeefdeadbeefdeadbeefdeadbeef",
"-H", "X-DD-Signature: 0000000000000000000000000000000000000000000000000000000000000000",
"--data", '{"ops":[]}',
`${TEST.url}/dev-deploy/api/ingest`
]);
assert.equal(out, "401");
});
await test("ingest with stale timestamp returns 401", async () => {
const stale = String(Date.now() - 10 * 60 * 1000);
const out = curl([
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST",
"-H", "Content-Type: application/vnd.dev-deploy+json",
"-H", `X-DD-Env-Id: ${mainEnv}`,
"-H", `X-DD-Timestamp: ${stale}`,
"-H", "X-DD-Nonce: deadbeefdeadbeefdeadbeefdeadbeef",
"-H", "X-DD-Signature: 0000000000000000000000000000000000000000000000000000000000000000",
"--data", '{"ops":[]}',
`${TEST.url}/dev-deploy/api/ingest`
]);
assert.equal(out, "401");
});
section("admin auth");
await test("unauthenticated GET /admin/dev-deploy/ returns 403", async () => {
const out = curl(["-sS", "-o", "/dev/null", "-w", "%{http_code}", `${MAIN.url}/admin/dev-deploy/`]);
assert.equal(out, "403");
});
// --- summary ---
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) {
console.log("\nFailures:");
for (const f of failures) {
console.log(` - ${f.name}`);
console.log(` ${f.err.stack.split("\n").slice(0, 4).join("\n ")}`);
}
process.exit(1);
}
process.exit(0);
};
main().catch((err) => {
console.error("test runner crashed:", err);
process.exit(2);
});

View file

@ -0,0 +1,57 @@
// Like `saltcorn run-js` but without the vm sandbox -- the supplied JS body
// runs with full `require()` access so the e2e suite can drive Field /
// TableConstraint / File creation. Invoked from the test runner via:
//
// source <env.sh> && node test/sc-exec.js "<javascript code>"
const { createRequire } = require("node:module");
const path = require("node:path");
// Resolve @saltcorn/* against the Saltcorn checkout's node_modules. Node
// resolves require from the script's path by default, which doesn't see
// Saltcorn's deps; createRequire reroots the resolution. Layout:
// <project-root>/
// dev-deploy/test/sc-exec.js (this file)
// saltcorn/packages/saltcorn-data/
// So: up 2 to project root, then into saltcorn/packages/saltcorn-data/.
const scRequire = createRequire(path.join(__dirname, "..", "..", "saltcorn", "packages", "saltcorn-data", "package.json"));
const main = async () => {
// Read code from stdin -- avoids shell-escape gymnastics for multi-line
// bodies (notably \n which bash double-quotes pass through literally).
const code = require("node:fs").readFileSync(0, "utf8");
if (!code) {
console.error("usage: pipe JS code into sc-exec.js's stdin");
process.exit(2);
}
const Plugin = scRequire("@saltcorn/data/models/plugin");
const { init_multi_tenant } = scRequire("@saltcorn/data/db/state");
await Plugin.loadAllPlugins();
await init_multi_tenant(Plugin.loadAllPlugins, undefined, []);
const Table = scRequire("@saltcorn/data/models/table");
const Field = scRequire("@saltcorn/data/models/field");
const View = scRequire("@saltcorn/data/models/view");
const Page = scRequire("@saltcorn/data/models/page");
const Trigger = scRequire("@saltcorn/data/models/trigger");
const TableConstraint = scRequire("@saltcorn/data/models/table_constraints");
const File = scRequire("@saltcorn/data/models/file");
const db = scRequire("@saltcorn/data/db");
// Build an async closure with the models + a re-rooted require in scope.
// (`new Function` bodies don't inherit require from the script scope.)
const fn = new Function(
"Table", "Field", "View", "Page", "Trigger", "TableConstraint", "File", "db", "require",
`return (async () => { ${code} })();`
);
await fn(Table, Field, View, Page, Trigger, TableConstraint, File, db, scRequire);
process.exit(0);
};
main().catch((err) => {
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});

15
devServer.sh Executable file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
source .dev-state/env.sh
# See startServer.sh for the rationale. Note: `dev:serve` watches Saltcorn
# repo files but explicitly ignores plugins_folder/, so plugin source edits
# still need an install step to land in localversion/ -- this handles it.
saltcorn install-plugin -d ./dev-deploy 2>&1 | tail -2 || true
# dev:serve spawns `npm run tsc`, which needs cwd=upstream root.
# Side effect: sessions.sqlite lands in saltcorn/ (not .dev-state/) when using
# dev:serve. Don't run this alongside ./startServer.sh on the MAIN instance.
cd saltcorn
exec saltcorn dev:serve "$@"

150
installSaltcorn.sh Executable file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env bash
# installSaltcorn.sh -- reproduce this Saltcorn dev environment from scratch.
#
# Layout created:
# <dest>/
# saltcorn/ upstream saltcorn checkout (cloned + built)
# .dev-state/ per-instance state (sqlite, files, env.sh, sessions)
# startServer.sh launcher
#
# After this, `cd <dest> && ./startServer.sh` boots Saltcorn on
# http://localhost:3000/.
#
# Usage: ./installSaltcorn.sh [destination] (default: ./saltcorn)
set -euo pipefail
DEST="${1:-./saltcorn}"
REPO_URL="https://github.com/saltcorn/saltcorn.git"
ADMIN_EMAIL="admin@local"
ADMIN_PASSWORD="AdminP@ss1"
# --- prerequisites ---
for cmd in git curl; do
command -v "$cmd" >/dev/null || { echo "$cmd not found in PATH"; exit 1; }
done
# Use a user-local nvm at ~/.nvm rather than system node, matching this dev
# environment. The .dev-state/env.sh written below sources the same nvm, so
# `saltcorn` keeps working in fresh shells without further setup.
export NVM_DIR="$HOME/.nvm"
NVM_VERSION="v0.40.1"
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
echo "==> nvm not found at $NVM_DIR -- installing $NVM_VERSION"
curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" | bash
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
echo "nvm install did not produce $NVM_DIR/nvm.sh"
exit 1
fi
fi
# nvm.sh references some unset variables on load; relax 'set -u' while sourcing.
set +u
# shellcheck source=/dev/null
. "$NVM_DIR/nvm.sh"
set -u
echo "==> Ensuring node 20 is installed and active"
nvm install 20
nvm use 20
NODE_MAJOR=$(node -p 'process.versions.node.split(".")[0]')
if [ "$NODE_MAJOR" -lt 18 ]; then
echo "node >= 18 required, found $(node --version)"
exit 1
fi
# Refuse to clobber an existing saltcorn subfolder, but allow $DEST itself to
# pre-exist (it might already contain dev-deploy/ or other user work).
if [ -e "$DEST/saltcorn" ]; then
echo "$DEST/saltcorn already exists; refusing to overwrite"
exit 1
fi
# --- clone + build ---
mkdir -p "$DEST"
DEST_ABS="$(cd "$DEST" && pwd)"
echo "==> Cloning $REPO_URL into $DEST_ABS/saltcorn"
git clone "$REPO_URL" "$DEST_ABS/saltcorn"
echo "==> npm install (resolves workspaces; takes several minutes)"
(cd "$DEST_ABS/saltcorn" && npm install)
echo "==> npm run tsc"
(cd "$DEST_ABS/saltcorn" && npm run tsc)
# --- .dev-state/ with generated session secret ---
DEV_DIR="$DEST_ABS/.dev-state"
mkdir -p "$DEV_DIR/files"
SECRET=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
cat > "$DEV_DIR/env.sh" <<EOF
#!/usr/bin/env bash
# Source this file before running saltcorn: source .dev-state/env.sh
# Keeps all dev state (DB + uploaded files) under .dev-state/ inside the
# project root. Upstream saltcorn lives in <project-root>/saltcorn/.
__SC_DEV_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
__SC_REPO_DIR="\$(dirname "\$__SC_DEV_DIR")"
export NVM_DIR="\$HOME/.nvm"
# shellcheck disable=SC1091
[ -s "\$NVM_DIR/nvm.sh" ] && . "\$NVM_DIR/nvm.sh"
export SQLITE_FILEPATH="\$__SC_DEV_DIR/saltcorn.sqlite"
export SALTCORN_FILE_STORE="\$__SC_DEV_DIR/files"
export SALTCORN_SESSION_SECRET="$SECRET"
# Put the in-tree CLI on PATH so \`saltcorn ...\` resolves to this checkout.
case ":\$PATH:" in
*":\$__SC_REPO_DIR/saltcorn/packages/saltcorn-cli/bin:"*) ;;
*) export PATH="\$__SC_REPO_DIR/saltcorn/packages/saltcorn-cli/bin:\$PATH" ;;
esac
unset __SC_DEV_DIR __SC_REPO_DIR
EOF
chmod +x "$DEV_DIR/env.sh"
# --- initialize schema and admin user ---
# shellcheck source=/dev/null
source "$DEV_DIR/env.sh"
echo "==> Initializing SQLite schema at $SQLITE_FILEPATH"
saltcorn reset-schema -f
echo "==> Creating admin user $ADMIN_EMAIL"
saltcorn create-user -a -e "$ADMIN_EMAIL" -p "$ADMIN_PASSWORD"
# --- startServer.sh ---
cat > "$DEST_ABS/startServer.sh" <<'EOF'
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
source .dev-state/env.sh
# Pick up any local plugin source changes before booting the server.
# install-plugin copies ./dev-deploy into Saltcorn's
# plugins_folder/.../localversion/, so source edits go live on each restart.
# Failures here are non-fatal: the previously-installed version still loads.
saltcorn install-plugin -d ./dev-deploy 2>&1 | tail -2 || true
# Saltcorn's SQLite session store writes sessions.sqlite at process cwd
# (packages/server/routes/utils.js). Run from inside .dev-state/ so each
# instance gets its own sessions.sqlite alongside its saltcorn.sqlite.
cd .dev-state
exec saltcorn serve "$@"
EOF
chmod +x "$DEST_ABS/startServer.sh"
echo
echo "Done."
echo " cd $DEST_ABS && ./startServer.sh"
echo " -> http://localhost:3000/ (login: $ADMIN_EMAIL / $ADMIN_PASSWORD)"

16
startServer.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
source .dev-state/env.sh
# Pick up any local plugin source changes before booting the server.
# install-plugin copies ./dev-deploy into Saltcorn's
# plugins_folder/.../localversion/, so source edits go live on each restart.
# Failures here are non-fatal: the previously-installed version still loads.
saltcorn install-plugin -d ./dev-deploy 2>&1 | tail -2 || true
# Saltcorn's SQLite session store writes sessions.sqlite at process cwd
# (packages/server/routes/utils.js). Run from inside .dev-state/ so each
# instance gets its own sessions.sqlite alongside its saltcorn.sqlite.
cd .dev-state
exec saltcorn serve "$@"

11
startServerTest.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
source .dev-state-test/env.sh
# See startServer.sh for the rationale.
saltcorn install-plugin -d ./dev-deploy 2>&1 | tail -2 || true
# See startServer.sh for why we cd into the state dir before serving.
cd .dev-state-test
exec saltcorn serve -p "$SALTCORN_PORT" "$@"