// HTTP routes for dev-deploy. // // Admin UI (session + admin role): // GET /admin/dev-deploy/ // GET /admin/dev-deploy/ops // GET /admin/dev-deploy/peers // POST /admin/dev-deploy/peers/add // POST /admin/dev-deploy/peers/rotate // POST /admin/dev-deploy/peers/delete // GET /admin/dev-deploy/plan // POST /admin/dev-deploy/promote // // Machine API (HMAC peer auth): // GET /dev-deploy/api/journal?since=op_id // POST /dev-deploy/api/ingest const db = require("@saltcorn/data/db"); const { PLUGIN_NAME, PLUGIN_VERSION } = require("./constants"); const { getEnv } = require("./env"); const peers = require("./peers"); const { requirePeerAuth } = require("./peerAuth"); const { signedFetch } = require("./transport"); const { applyBatch, resolveConflict, resolveConflictByMerge, conflictFieldDiff } = require("./apply"); const { revertOp } = require("./revert"); const { DATA_MODES } = require("./constants"); const getInboundAnchor = async (peerId) => { return await db.selectMaybeOne("_dd_anchors", { peer_id: peerId, direction: "inbound" }); }; const getOutboundAnchor = async (peerId) => { return await db.selectMaybeOne("_dd_anchors", { peer_id: peerId, direction: "outbound" }); }; const upsertAnchor = async (peerId, direction, opId) => { const now = new Date().toISOString(); const existing = await db.selectMaybeOne("_dd_anchors", { peer_id: peerId, direction: direction }); if (existing) { await db.updateWhere("_dd_anchors", { last_op_id: opId, updated_at: now }, { peer_id: peerId, direction: direction }); } else { await db.insert("_dd_anchors", { peer_id: peerId, direction: direction, last_op_id: opId, updated_at: now }, { noid: true }); } }; // Safety cap on the number of journal pages a single pull will fetch. The peer's // apiJournal caps each response at 1000 ops; pull loops until drained or this cap. const PULL_MAX_ITERS = 100; const isAdmin = (req) => !!(req && req.user && req.user.role_id === 1); const escape = (s) => { if (s === null || s === undefined) return ""; return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }; const csrfField = (req) => { const t = req.csrfToken ? req.csrfToken() : ""; return ``; }; const layout = (title, body, flash) => ` ${escape(title)} ${flash || ""} ${body} `; const flashMsg = (req) => { const m = req.query.msg; const e = req.query.err; if (m) return `
${escape(m)}
`; if (e) return `
${escape(e)}
`; return ""; }; // ---------------- Admin dashboard ---------------- const dashboard = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } const env = await getEnv(); const schema = db.getTenantSchemaPrefix(); const opCount = (await db.query(`SELECT COUNT(*) AS c FROM ${schema}_dd_ops`)).rows[0].c; const opsByKind = (await db.query(`SELECT op_type, COUNT(*) AS c FROM ${schema}_dd_ops GROUP BY op_type ORDER BY op_type`)).rows; const entCounts = (await db.query(`SELECT kind, COUNT(*) AS c FROM ${schema}_dd_entity_ids GROUP BY kind ORDER BY kind`)).rows; const peerList = await peers.listPeers(); const conflictCount = (await db.query(`SELECT COUNT(*) AS c FROM ${schema}_dd_ops WHERE status='conflict'`)).rows[0].c; const opsByKindRows = opsByKind.length === 0 ? `No ops recorded yet` : opsByKind.map((r) => `${escape(r.op_type)}${escape(r.c)}`).join(""); const entRows = entCounts.length === 0 ? `No entities tracked` : entCounts.map((r) => `${escape(r.kind)}${escape(r.c)}`).join(""); const body = `

dev-deploy dashboard

Env ID${escape(env ? env.env_id : "?")}
Label${env && env.env_label ? escape(env.env_label) : '(unset)'}
Destructive-op policy${escape(env ? env.on_destructive_op : "?")}
Require TLS (default)${env && env.require_tls ? "yes" : "no"}
Bootstrapped at${escape(env ? env.bootstrapped_at : "")}
Ops recorded${escape(opCount)}
Peers configured${escape(peerList.length)}
Pending conflicts${conflictCount > 0 ? `${escape(conflictCount)}` : "0"}

Ops by type

${opsByKindRows}
op_typecount

Entities tracked

${entRows}
kindcount
`; res.type("text/html").send(layout("dev-deploy dashboard", body, flashMsg(req))); }; // ---------------- Ops viewer ---------------- const fetchOps = async (limit, since, offset) => { const schema = db.getTenantSchemaPrefix(); let sql = `SELECT op_id, source_env_id, op_type, entity_kind, entity_uuid, payload, parent_op_id, correlation_id, schema_version, created_at, applied_at, status FROM ${schema}_dd_ops`; const params = []; if (since) { const anchor = (await db.query(`SELECT created_at FROM ${schema}_dd_ops WHERE op_id = $1`, [since])).rows[0]; if (anchor) { sql += ` WHERE created_at > $${params.length + 1}`; params.push(anchor.created_at); } } sql += ` ORDER BY created_at DESC LIMIT $${params.length + 1}`; params.push(limit || 100); sql += ` OFFSET $${params.length + 1}`; params.push(offset || 0); return (await db.query(sql, params)).rows; }; const opsView = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } const wantJson = (req.headers.accept || "").includes("application/json"); const limit = Math.min(parseInt(req.query.limit || "100", 10) || 100, 500); const offset = Math.max(parseInt(req.query.offset || "0", 10) || 0, 0); const since = req.query.since; const ops = await fetchOps(limit, since, offset); if (wantJson) { res.json({ ops: ops }); return; } const rows = ops.length === 0 ? `Journal is empty` : ops.map((o) => ` ${escape(o.op_id.slice(0, 8))} ${escape(o.op_type)} ${escape((o.entity_uuid || "").slice(0, 8))} ${escape((o.parent_op_id || "").slice(0, 8))} ${escape(o.status)} ${escape(o.created_at)}
${csrfField(req)}
${escape(o.payload)}
`).join(""); const qs = (off) => { const parts = [`offset=${off}`, `limit=${limit}`]; if (since) { parts.push(`since=${encodeURIComponent(since)}`); } return "/admin/dev-deploy/ops?" + parts.join("&"); }; const prevLink = offset > 0 ? `« Prev` : `« Prev`; const nextLink = ops.length === limit ? `Next »` : `Next »`; const body = `

Journal

Showing up to ${escape(limit)} ops${since ? `, since op ${escape(since.slice(0, 8))}` : ""}, offset ${escape(offset)}. Newest first. Revert appends a compensating op rather than rewriting history.

${rows}
opop_typeentityparentstatuscreatedactionspayload

${prevLink}   ${nextLink}

`; res.type("text/html").send(layout("dev-deploy journal", body, flashMsg(req))); }; // ---------------- Peers ---------------- const peersView = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } const env = await getEnv(); const list = await peers.listPeers(); const rows = list.length === 0 ? `No peers yet` : list.map((p) => ` ${escape(p.peer_id)} ${escape(p.label || "(unset)")} ${escape(p.env_id)} ${escape(p.base_url)} ${escape(p.last_seen_at || "never")}
${csrfField(req)}
${csrfField(req)}
${csrfField(req)}
${csrfField(req)}
`).join(""); const body = `

Peers

This instance's env_id is ${escape(env ? env.env_id : "?")}. Paste this into the other instance's peer form.

${rows}
idlabelenv_idbase_urllast seenactions

Add peer

${csrfField(req)}
Peer info

Shared secret

Leave blank to generate one (shown once after submit). Paste the same secret in the peer's own pairing form.

`; res.type("text/html").send(layout("dev-deploy peers", body, flashMsg(req))); }; const peersAdd = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const envId = (req.body.env_id || "").trim(); const label = (req.body.label || "").trim() || null; const baseUrl = (req.body.base_url || "").trim(); const requireTls = !!req.body.require_tls; const provided = (req.body.existing_secret || "").trim(); let existingSecret = null; if (provided) { if (!/^[0-9a-fA-F]{64}$/.test(provided)) { throw new Error("existing_secret must be 64 hex characters"); } existingSecret = Buffer.from(provided, "hex"); } const { peer, secret } = await peers.addPeer({ envId: envId, label: label, baseUrl: baseUrl, requireTls: requireTls, existingSecret: existingSecret }); const secretHex = secret.toString("hex"); const body = `

Peer ${escape(peer.label || peer.env_id)} paired

Copy this secret into the peer's pairing form (it will not be shown again):

${escape(secretHex)}

If this is a brand-new pairing, give the peer this side's env_id too.

Back to peers

`; res.type("text/html").send(layout("Peer paired", body)); } catch (err) { res.redirect("/admin/dev-deploy/peers?err=" + encodeURIComponent(err.message)); } }; const peersRotate = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const peerId = parseInt(req.body.peer_id, 10); const { peer, secret } = await peers.rotatePeerSecret(peerId); const body = `

Peer ${escape(peer.label || peer.env_id)} secret rotated

New secret (shown once):

${escape(secret.toString("hex"))}

Paste this on the other side via Rotate or by re-pairing.

Back to peers

`; res.type("text/html").send(layout("Secret rotated", body)); } catch (err) { res.redirect("/admin/dev-deploy/peers?err=" + encodeURIComponent(err.message)); } }; const peersDelete = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const peerId = parseInt(req.body.peer_id, 10); await peers.deletePeer(peerId); res.redirect("/admin/dev-deploy/peers?msg=" + encodeURIComponent("peer deleted")); } catch (err) { res.redirect("/admin/dev-deploy/peers?err=" + encodeURIComponent(err.message)); } }; // ---------------- Plan + promote ---------------- const planView = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } const peerIdRaw = req.query.peer; const peerList = await peers.listPeers(); if (!peerIdRaw) { const opts = peerList.length === 0 ? `` : peerList.map((p) => ``).join(""); const body = `

Plan

`; res.type("text/html").send(layout("dev-deploy plan", body, flashMsg(req))); return; } const peerId = parseInt(peerIdRaw, 10); const peer = await peers.findPeer(peerId); if (!peer) { res.status(404).send("peer not found"); return; } const limit = Math.min(parseInt(req.query.limit || "100", 10) || 100, 500); const offset = Math.max(parseInt(req.query.offset || "0", 10) || 0, 0); const env = await getEnv(); // Anchor: the last_op_id we sent outbound to this peer (or epoch) const anchor = await db.selectMaybeOne("_dd_anchors", { peer_id: peerId, direction: "outbound" }); const schema = db.getTenantSchemaPrefix(); let sql = `SELECT op_id, op_type, entity_kind, entity_uuid, payload, parent_op_id, status, created_at FROM ${schema}_dd_ops WHERE source_env_id = $1`; const params = [env.env_id]; if (anchor) { const anchorRow = await db.selectMaybeOne("_dd_ops", { op_id: anchor.last_op_id }); if (anchorRow) { sql += ` AND created_at > $${params.length + 1}`; params.push(anchorRow.created_at); } } sql += ` ORDER BY created_at ASC LIMIT $${params.length + 1}`; params.push(limit); sql += ` OFFSET $${params.length + 1}`; params.push(offset); const planRows = (await db.query(sql, params)).rows; const rowsHtml = planRows.length === 0 ? `No new ops to send` : planRows.map((o) => ` ${escape(o.op_id.slice(0, 8))} ${escape(o.op_type)} ${escape((o.entity_uuid || "").slice(0, 8))} ${escape(o.status)} ${escape(o.created_at)} `).join(""); const body = `

Plan: promote to ${escape(peer.label || peer.env_id)}

Anchor: ${anchor ? `${escape(anchor.last_op_id.slice(0, 8))}` : '(none — will send from epoch)'}

Ops that would be sent: ${escape(planRows.length)}

${rowsHtml}
opop_typeentitystatuscreated
${csrfField(req)}

`; res.type("text/html").send(layout("dev-deploy plan", body, flashMsg(req))); }; const promote = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const peerId = parseInt(req.body.peer_id, 10); const peer = await peers.findPeer(peerId); if (!peer) throw new Error(`peer ${peerId} not found`); const env = await getEnv(); const anchor = await getOutboundAnchor(peerId); const schema = db.getTenantSchemaPrefix(); let sql = `SELECT op_id, source_env_id, op_type, entity_kind, entity_uuid, payload, parent_op_id, correlation_id, schema_version, created_at, status FROM ${schema}_dd_ops WHERE source_env_id = $1`; const params = [env.env_id]; if (anchor) { const anchorRow = await db.selectMaybeOne("_dd_ops", { op_id: anchor.last_op_id }); if (anchorRow) { sql += ` AND created_at > $${params.length + 1}`; params.push(anchorRow.created_at); } } sql += ` ORDER BY created_at ASC LIMIT 500`; const ops = (await db.query(sql, params)).rows; if (ops.length === 0) { res.redirect("/admin/dev-deploy/peers?msg=" + encodeURIComponent("no ops to promote")); return; } const secret = await peers.peerSecret(peerId); const r = await signedFetch({ baseUrl: peer.base_url, method: "POST", path: "/dev-deploy/api/ingest", body: { ops: ops }, sourceEnvId: env.env_id, secret: secret, requireTls: peer.require_tls }); if (!r.ok) { throw new Error(`peer responded ${r.status}: ${JSON.stringify(r.body)}`); } await upsertAnchor(peerId, "outbound", ops[ops.length - 1].op_id); const applied = (r.body && r.body.results || []).filter((x) => x.status === "applied").length; const errors = (r.body && r.body.results || []).filter((x) => x.status === "error").length; let msg = `promoted ${ops.length} ops (${applied} applied, ${errors} errors)`; const localPlugins = (await db.query(`SELECT name, source, version FROM _sc_plugins ORDER BY name`)).rows; const warnings = await diffPluginsWithPeer(peer, env, localPlugins); if (warnings.length > 0) { msg += " | WARNINGS: " + warnings.join("; "); } res.redirect("/admin/dev-deploy/peers?msg=" + encodeURIComponent(msg)); } catch (err) { res.redirect("/admin/dev-deploy/peers?err=" + encodeURIComponent(err.message)); } }; const pull = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const peerId = parseInt(req.body.peer_id, 10); const peer = await peers.findPeer(peerId); if (!peer) throw new Error(`peer ${peerId} not found`); const env = await getEnv(); const secret = await peers.peerSecret(peerId); // apiJournal caps each response at 1000 ops, so loop: fetch from the // advancing inbound anchor, apply, advance the anchor, repeat until a // fetch returns 0 ops (drained) or we hit the safety cap. let total = 0; let applied = 0; let errors = 0; let conflicts = 0; let iters = 0; let cappedOut = false; for (;;) { if (iters >= PULL_MAX_ITERS) { cappedOut = true; break; } iters += 1; const anchor = await getInboundAnchor(peerId); const since = anchor ? anchor.last_op_id : null; const path = since ? `/dev-deploy/api/journal?since=${encodeURIComponent(since)}` : "/dev-deploy/api/journal"; const r = await signedFetch({ baseUrl: peer.base_url, method: "GET", path: path, body: null, sourceEnvId: env.env_id, secret: secret, requireTls: peer.require_tls }); if (!r.ok) { throw new Error(`peer responded ${r.status}: ${JSON.stringify(r.body)}`); } const ops = (r.body && r.body.ops) || []; if (ops.length === 0) { break; } const results = await applyBatch(ops, { peerId: peerId, myEnvId: env.env_id }); applied += results.filter((x) => x.status === "applied").length; errors += results.filter((x) => x.status === "error").length; conflicts += results.filter((x) => x.status === "conflict").length; total += ops.length; await upsertAnchor(peerId, "inbound", ops[ops.length - 1].op_id); } if (total === 0) { res.redirect("/admin/dev-deploy/peers?msg=" + encodeURIComponent("nothing to pull")); return; } let sum = `pulled ${total} ops (${applied} applied, ${errors} errors, ${conflicts} conflicts)`; if (cappedOut) { sum += " (stopped at safety cap; pull again)"; } const localPlugins = (await db.query(`SELECT name, source, version FROM _sc_plugins ORDER BY name`)).rows; const warnings = await diffPluginsWithPeer(peer, env, localPlugins); if (warnings.length > 0) { sum += " | WARNINGS: " + warnings.join("; "); } const dest = conflicts > 0 ? "/admin/dev-deploy/conflicts?msg=" : "/admin/dev-deploy/peers?msg="; res.redirect(dest + encodeURIComponent(sum)); } catch (err) { res.redirect("/admin/dev-deploy/peers?err=" + encodeURIComponent(err.message)); } }; // ---------------- Conflicts ---------------- const conflictsView = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } const schema = db.getTenantSchemaPrefix(); const conflicts = (await db.query(` SELECT i.op_id AS i_op_id, i.source_env_id AS i_source, i.op_type AS i_op_type, i.entity_kind AS i_kind, i.entity_uuid AS i_uuid, i.payload AS i_payload, i.created_at AS i_created, l.op_id AS l_op_id, l.op_type AS l_op_type, l.payload AS l_payload, l.applied_at AS l_applied FROM ${schema}_dd_ops i LEFT JOIN ${schema}_dd_ops l ON l.op_id = i.conflict_with_op_id WHERE i.status = 'conflict' ORDER BY i.created_at ASC `)).rows; const isMergeable = (c) => c.i_op_type && c.l_op_type && c.i_op_type.startsWith("update_") && c.l_op_type.startsWith("update_") && c.i_op_type === c.l_op_type; const rowsHtml = conflicts.length === 0 ? `No pending conflicts` : conflicts.map((c) => ` incoming ${escape(c.i_op_id.slice(0, 8))} from ${escape((c.i_source || "").slice(0, 8))}
${escape(c.i_op_type)} on entity ${escape((c.i_uuid || "").slice(0, 8))}
created ${escape(c.i_created)}
${escape(c.i_payload)}
${c.l_op_id ? `local ${escape(c.l_op_id.slice(0, 8))}
${escape(c.l_op_type)}
applied ${escape(c.l_applied || "")}
${escape(c.l_payload)}
` : '(no local op recorded)'} ${isMergeable(c) ? `

` : ""}
${csrfField(req)}
${csrfField(req)}
`).join(""); const body = `

Pending conflicts

A conflict means an incoming op and a local op both touched the same entity since the last sync. The incoming op was NOT applied; pick which version wins.

${rowsHtml}
TheirsMineAction
`; res.type("text/html").send(layout("dev-deploy conflicts", body, flashMsg(req))); }; const conflictsResolve = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const opId = (req.body.op_id || "").trim(); const action = (req.body.action || "").trim(); if (!opId) throw new Error("op_id required"); if (!["theirs", "mine"].includes(action)) throw new Error("action must be 'theirs' or 'mine'"); // For file ops, "use theirs" re-applies create_file which fetches bytes // from the originating peer -- resolve the peer from the op's source env. const op = await db.selectMaybeOne("_dd_ops", { op_id: opId }); const peer = op ? await peers.findPeerByEnvId(op.source_env_id) : null; const env = await getEnv(); const opts = { peerId: peer ? peer.peer_id : null, myEnvId: env ? env.env_id : null }; const r = await resolveConflict(opId, action, opts); res.redirect("/admin/dev-deploy/conflicts?msg=" + encodeURIComponent(`resolved ${opId.slice(0, 8)} with action=${action}: ${JSON.stringify(r)}`)); } catch (err) { res.redirect("/admin/dev-deploy/conflicts?err=" + encodeURIComponent(err.message)); } }; const renderValue = (v) => { if (v === null || v === undefined) { return '(unset)'; } if (typeof v === "object") { return `
${escape(JSON.stringify(v, null, 2))}
`; } return escape(String(v)); }; const conflictsMergeView = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } const opId = (req.query.op_id || "").trim(); if (!opId) { res.redirect("/admin/dev-deploy/conflicts?err=op_id+required"); return; } try { const op = await db.selectMaybeOne("_dd_ops", { op_id: opId }); if (!op) throw new Error(`op ${opId} not found`); if (op.status !== "conflict") throw new Error(`op ${opId} is not in conflict status`); const diff = await conflictFieldDiff(op); const diffs = diff.diffs || []; const ent = diff.instance ? `

Entity: ${escape(diff.kind)} ${escape(diff.instance.name || diff.instance.role || diff.instance.id)} (local id ${escape(diff.instance.id)})

` : `

${escape(diff.reason || "no entity diff available")}

`; let formBody; if (diffs.length === 0) { formBody = `

No field-level differences detected (current state already matches the incoming op's patch on every field). Applying the merge will just mark this conflict as resolved.

`; } else { const rows = diffs.map((d) => ` ${escape(d.field)} ${renderValue(d.currentValue)} ${renderValue(d.incomingValue)}

`).join(""); formBody = ` ${rows}
fieldcurrent (mine)incoming (theirs)resolution

Defaults to keep current for every field — submit as-is for a no-op resolution that just clears the conflict marker.

`; } const body = `

Merge conflict per field

${ent}

Incoming op ${escape(op.op_id.slice(0, 8))} from ${escape((op.source_env_id || "").slice(0, 8))}; ${escape(op.op_type)}.

${csrfField(req)} ${formBody}

Cancel

`; res.type("text/html").send(layout("dev-deploy merge", body, flashMsg(req))); } catch (err) { res.redirect("/admin/dev-deploy/conflicts?err=" + encodeURIComponent(err.message)); } }; // Parse number/boolean/null literal strings from a text input. JSON.parse first // (handles "true", "42", "null"); fall back to the raw string. const coerce = (s) => { if (s === undefined || s === null) return s; try { const parsed = JSON.parse(s); if (parsed === null || ["boolean", "number"].includes(typeof parsed)) return parsed; return s; } catch (e) { return s; } }; const conflictsMergeApply = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const opId = (req.body.op_id || "").trim(); if (!opId) throw new Error("op_id required"); const op = await db.selectMaybeOne("_dd_ops", { op_id: opId }); if (!op) throw new Error(`op ${opId} not found`); if (op.status !== "conflict") throw new Error(`op ${opId} is not in conflict status`); const payload = typeof op.payload === "string" ? JSON.parse(op.payload) : (op.payload || {}); const incomingPatch = payload.patch || {}; // For each "choice_" entry in the form body, decide what value // to write -- if any. "current" means don't touch the field. const choices = {}; for (const [k, v] of Object.entries(req.body || {})) { if (!k.startsWith("choice_")) continue; const field = k.substring("choice_".length); if (v === "incoming") { choices[field] = incomingPatch[field]; } else if (v === "custom") { const customVal = req.body[`custom_${field}`]; choices[field] = coerce(customVal); } } const r = await resolveConflictByMerge(opId, choices); res.redirect("/admin/dev-deploy/conflicts?msg=" + encodeURIComponent(`merged ${opId.slice(0, 8)}: ${JSON.stringify(r)}`)); } catch (err) { res.redirect("/admin/dev-deploy/conflicts?err=" + encodeURIComponent(err.message)); } }; // ---------------- Tables (data_mode) ---------------- const tablesView = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } const schema = db.getTenantSchemaPrefix(); const rows = (await db.query(` SELECT e.uuid, e.current_name, e.current_id, COALESCE(m.data_mode, 'user') AS data_mode, m.updated_at, m.starter_shipped_at FROM ${schema}_dd_entity_ids e LEFT JOIN ${schema}_dd_table_modes m ON m.table_uuid = e.uuid WHERE e.kind = 'table' ORDER BY e.current_name `)).rows; const lockedNames = new Set(["users"]); const modeOpts = [DATA_MODES.USER, DATA_MODES.STARTER, DATA_MODES.MANAGED]; const rowsHtml = rows.length === 0 ? `No tables tracked yet` : rows.map((r) => { const locked = lockedNames.has(r.current_name); const select = locked ? `user (locked)` : `
${csrfField(req)}
`; const shipped = r.starter_shipped_at ? `
shipped ${escape(r.starter_shipped_at)}` : ""; return ` ${escape(r.current_name)} ${escape(r.uuid.slice(0, 8))} ${escape(r.current_id)} ${select}${shipped} ${escape(r.updated_at || "—")} `; }).join(""); const body = `

Tables — data mode

Controls how each table's row content propagates between environments. Choose carefully — switching from user to managed or starter rewrites the table's schema (adds a hidden _dd_row_uuid column) and ships existing rows.

The Saltcorn users table is locked to user and cannot be changed.

${rowsHtml}
tableuuidlocal iddata_modeupdated_at
`; res.type("text/html").send(layout("dev-deploy tables", body, flashMsg(req))); }; const tablesSet = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const tableUuid = (req.body.table_uuid || "").trim(); const dataMode = (req.body.data_mode || "").trim(); if (!tableUuid) throw new Error("table_uuid required"); const allowed = new Set(Object.values(DATA_MODES)); if (!allowed.has(dataMode)) throw new Error(`data_mode must be one of ${[...allowed].join(", ")}`); const ent = await db.selectMaybeOne("_dd_entity_ids", { uuid: tableUuid }); if (!ent || ent.kind !== "table") throw new Error("table not found"); if (ent.current_name === "users") throw new Error("the users table is locked to data_mode=user"); const { ensureManagedSchema, dropManagedSchema, allRowsWithUuid, setRowUuid, newRowUuid, COLUMN_NAME: ROW_UUID_COL } = require("./rowIdentity"); const { rowToPortable, markStarterShipped } = require("./rowPayload"); const Table = require("@saltcorn/data/models/table"); const { randomUuid } = require("./ids"); const { enterOp } = require("./context"); const { recordOpSafely } = require("./ops"); const { refreshState } = require("./state"); await refreshState(); const prior = await db.selectMaybeOne("_dd_table_modes", { table_uuid: tableUuid }); const now = new Date().toISOString(); // Upsert mode row first. if (prior) { await db.updateWhere("_dd_table_modes", { data_mode: dataMode, updated_at: now, starter_shipped_at: null }, { table_uuid: tableUuid }); } else { await db.insert("_dd_table_modes", { table_uuid: tableUuid, data_mode: dataMode, updated_at: now, starter_shipped_at: null }, { noid: true }); } // Journal set_table_mode FIRST so target's apply sees mode change before any row ops. { const opId = randomUuid(); await enterOp(opId, async () => { await recordOpSafely({ op_id: opId, op_type: "set_table_mode", entity_kind: "table_mode", entity_uuid: tableUuid, payload: { table_uuid: tableUuid, data_mode: dataMode, before_mode: (prior && prior.data_mode) || DATA_MODES.USER } }); }); } let initialShipped = 0; if (dataMode === DATA_MODES.MANAGED || dataMode === DATA_MODES.STARTER) { // Make sure THIS instance has the hidden column + UUIDs on existing rows. await ensureManagedSchema(ent.current_name); // Initial ship: journal an insert_row op for every existing row. const table = Table.findOne({ id: ent.current_id }); if (table) { const rows = await allRowsWithUuid(ent.current_name); for (const row of rows) { let rowUuid = row[ROW_UUID_COL]; if (!rowUuid) { rowUuid = newRowUuid(); await setRowUuid(ent.current_name, row.id, rowUuid); } const { portable } = await rowToPortable(row, table); const opId = randomUuid(); await enterOp(opId, async () => { await recordOpSafely({ op_id: opId, op_type: "insert_row", entity_kind: "table_row", entity_uuid: rowUuid, payload: { table_uuid: tableUuid, after: portable } }); }); initialShipped++; } } // For starter: lock out further row ops. if (dataMode === DATA_MODES.STARTER) { await markStarterShipped(tableUuid); } } else if (prior && (prior.data_mode === DATA_MODES.MANAGED || prior.data_mode === DATA_MODES.STARTER)) { // Reverting to user — drop the hidden column for cleanliness. Best-effort. try { await dropManagedSchema(ent.current_name); } catch (e) { // ignore on older SQLite that doesn't support DROP COLUMN } } const summary = initialShipped > 0 ? `set ${ent.current_name} to ${dataMode}; shipped ${initialShipped} rows` : `set ${ent.current_name} to ${dataMode}`; res.redirect("/admin/dev-deploy/tables?msg=" + encodeURIComponent(summary)); } catch (err) { res.redirect("/admin/dev-deploy/tables?err=" + encodeURIComponent(err.message)); } }; // ---------------- Revert ---------------- const revertView = async (req, res) => { if (!isAdmin(req)) { res.status(403).type("text/plain").send("admin only"); return; } try { const opId = (req.body.op_id || "").trim(); if (!opId) throw new Error("op_id required"); const result = await revertOp(opId); res.redirect("/admin/dev-deploy/ops?msg=" + encodeURIComponent(`reverted op ${opId.slice(0, 8)}: ${JSON.stringify(result)}`)); } catch (err) { res.redirect("/admin/dev-deploy/ops?err=" + encodeURIComponent(err.message)); } }; // ---------------- Machine endpoints ---------------- const apiJournal = async (req, res) => { const peer = await requirePeerAuth(req, res); if (!peer) return; const since = req.query.since; const env = await getEnv(); const schema = db.getTenantSchemaPrefix(); let sql = `SELECT op_id, source_env_id, op_type, entity_kind, entity_uuid, payload, parent_op_id, correlation_id, schema_version, created_at, status FROM ${schema}_dd_ops WHERE source_env_id = $1`; const params = [env.env_id]; if (since) { const anchorRow = await db.selectMaybeOne("_dd_ops", { op_id: since }); if (anchorRow) { sql += ` AND created_at > $${params.length + 1}`; params.push(anchorRow.created_at); } } sql += ` ORDER BY created_at ASC LIMIT 1000`; const ops = (await db.query(sql, params)).rows; res.json({ source_env_id: env.env_id, ops: ops }); }; const apiHealth = async (req, res) => { const peer = await requirePeerAuth(req, res); if (!peer) return; const env = await getEnv(); const plugins = (await db.query(`SELECT name, source, version FROM _sc_plugins ORDER BY name`)).rows; res.json({ env_id: env.env_id, label: env.env_label, plugins: plugins }); }; // Compare local plugin list with peer's. Returns array of human-readable // warning strings (empty if all match). Best-effort: if the peer's health // endpoint is unreachable or returns non-200, returns a single "couldn't // reach peer's health endpoint" warning and lets the caller proceed. const diffPluginsWithPeer = async (peerRow, env, localPlugins) => { let r; try { const secret = await peers.peerSecret(peerRow.peer_id); r = await signedFetch({ baseUrl: peerRow.base_url, method: "GET", path: "/dev-deploy/api/health", body: null, sourceEnvId: env.env_id, secret: secret, requireTls: peerRow.require_tls }); } catch (e) { return [`could not check peer plugin list: ${e.message}`]; } if (!r.ok || !r.body || !Array.isArray(r.body.plugins)) { return [`peer's health endpoint returned ${r.status}`]; } const localByName = new Map(localPlugins.map((p) => [p.name, p])); const peerByName = new Map(r.body.plugins.map((p) => [p.name, p])); const warnings = []; for (const [name, mine] of localByName) { const theirs = peerByName.get(name); if (!theirs) { warnings.push(`peer missing plugin "${name}"`); } else if ((mine.version || "") !== (theirs.version || "")) { warnings.push(`plugin version mismatch on "${name}": local ${mine.version || "?"}, peer ${theirs.version || "?"}`); } } for (const [name, theirs] of peerByName) { if (!localByName.has(name)) { warnings.push(`peer has plugin not installed here: "${name}"`); } } return warnings; }; const apiFile = async (req, res) => { try { const peer = await requirePeerAuth(req, res); if (!peer) return; const uuid = req.params.uuid; if (!uuid) { res.status(400).json({ error: "uuid required" }); return; } const mapping = await db.selectMaybeOne("_dd_entity_ids", { uuid: uuid, kind: "file" }); if (!mapping) { res.status(404).json({ error: "file not found", uuid: uuid }); return; } const path = require("path"); const dbMod = require("@saltcorn/data/db"); const absPath = path.join(dbMod.connectObj.file_store, dbMod.getTenantSchema(), mapping.current_name); res.type("application/octet-stream"); // dotfiles: 'allow' so paths containing .dev-state (etc.) aren't // silently treated as not-found by Express's default dotfile policy. res.sendFile(absPath, { dotfiles: "allow" }, (err) => { if (err && !res.headersSent) { // eslint-disable-next-line no-console console.error(`[dev-deploy] sendFile failed for ${absPath}:`, err.message); res.status(500).json({ error: "failed to read file: " + err.message, path: absPath }); } }); } catch (err) { // eslint-disable-next-line no-console console.error(`[dev-deploy] apiFile crashed:`, err && err.stack ? err.stack : err); if (!res.headersSent) { res.status(500).json({ error: err.message }); } } }; const apiIngest = async (req, res) => { const peer = await requirePeerAuth(req, res); if (!peer) return; const ops = (req.body && req.body.ops) || []; if (!Array.isArray(ops)) { res.status(400).json({ error: "ops must be an array" }); return; } const env = await getEnv(); const results = await applyBatch(ops, { peerId: peer.peer_id, myEnvId: env.env_id }); // Advance inbound anchor to the last op_id from the source side if (ops.length > 0) { const lastOp = ops[ops.length - 1]; const now = new Date().toISOString(); const existing = await db.selectMaybeOne("_dd_anchors", { peer_id: peer.peer_id, direction: "inbound" }); if (existing) { await db.updateWhere("_dd_anchors", { last_op_id: lastOp.op_id, updated_at: now }, { peer_id: peer.peer_id, direction: "inbound" }); } else { await db.insert("_dd_anchors", { peer_id: peer.peer_id, direction: "inbound", last_op_id: lastOp.op_id, updated_at: now }, { noid: true }); } } res.json({ received: ops.length, results: results }); }; // ---------------- Route registration ---------------- const routes = [ { url: "/admin/dev-deploy/", method: "get", callback: dashboard }, { url: "/admin/dev-deploy/ops", method: "get", callback: opsView }, { url: "/admin/dev-deploy/peers", method: "get", callback: peersView }, { url: "/admin/dev-deploy/peers/add", method: "post", callback: peersAdd }, { url: "/admin/dev-deploy/peers/rotate", method: "post", callback: peersRotate }, { url: "/admin/dev-deploy/peers/delete", method: "post", callback: peersDelete }, { url: "/admin/dev-deploy/plan", method: "get", callback: planView }, { url: "/admin/dev-deploy/promote", method: "post", callback: promote }, { url: "/admin/dev-deploy/pull", method: "post", callback: pull }, { url: "/admin/dev-deploy/revert", method: "post", callback: revertView }, { url: "/admin/dev-deploy/tables", method: "get", callback: tablesView }, { url: "/admin/dev-deploy/tables/set", method: "post", callback: tablesSet }, { url: "/admin/dev-deploy/conflicts", method: "get", callback: conflictsView }, { url: "/admin/dev-deploy/conflicts/resolve", method: "post", callback: conflictsResolve }, { url: "/admin/dev-deploy/conflicts/merge", method: "get", callback: conflictsMergeView }, { url: "/admin/dev-deploy/conflicts/merge/apply", method: "post", callback: conflictsMergeApply }, { url: "/dev-deploy/api/journal", method: "get", callback: apiJournal, noCsrf: true }, { url: "/dev-deploy/api/ingest", method: "post", callback: apiIngest, noCsrf: true }, { url: "/dev-deploy/api/file/:uuid", method: "get", callback: apiFile, noCsrf: true }, { url: "/dev-deploy/api/health", method: "get", callback: apiHealth, noCsrf: true } ]; module.exports = { routes };