// 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"); // Saltcorn native markup primitives -- the same ones core admin pages use, so // these pages inherit the active theme by construction (mkTable/renderForm/ // post_btn instead of hand-rolled HTML + manual Bootstrap classes). const { mkTable, post_btn, renderForm, link, alert, badge, tags } = require("@saltcorn/markup"); const { p, code, pre, div, span, strong } = tags; const Form = require("@saltcorn/data/models/form"); 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 ``; }; // Bootstrap nav-pills with the current page marked active. The active link is the // one whose href is the LONGEST boundary-safe prefix of the request path, so a // sub-page (e.g. /conflicts/merge) still highlights its parent tab. const navPills = (req, links) => { const cur = String((req && (req.originalUrl || req.path)) || "").split("?")[0].replace(/\/+$/, ""); let activeHref = ""; for (const [href] of links) { const h = href.replace(/\/+$/, ""); if ((cur === h || cur.startsWith(h + "/")) && h.length > activeHref.length) { activeHref = h; } } const items = links.map(([href, label]) => { const on = href.replace(/\/+$/, "") === activeHref; return ` `; }).join("\n"); return ``; }; const DD_NAV = [ ["/admin/dev-deploy/", "Dashboard"], ["/admin/dev-deploy/ops", "Journal"], ["/admin/dev-deploy/peers", "Peers"], ["/admin/dev-deploy/plan", "Plan"], ["/admin/dev-deploy/tables", "Tables"], ["/admin/dev-deploy/conflicts", "Conflicts"], ]; // Render an admin page in the ACTIVE Saltcorn theme: breadcrumbs + sub-nav + // flash alerts (from ?msg/?err) + the given content segments (cards or strings), // all via res.sendWrap structured layout. This is the one rendering path; pages // build their content with mkTable / renderForm / post_btn (no raw page HTML). const adminPage = (req, res, title, ...segments) => { const above = [ { type: "breadcrumbs", crumbs: [{ text: "Settings", href: "/settings" }, { text: "dev-deploy" }, { text: title }] }, navPills(req, DD_NAV), ]; if (req.query.msg) above.push(alert("success", escape(req.query.msg))); if (req.query.err) above.push(alert("danger", escape(req.query.err))); res.sendWrap(`dev-deploy ${title}`, { above: above.concat(segments) }); }; // ---------------- 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 envRows = [ { k: "Env ID", v: code(escape(env ? env.env_id : "?")) }, { k: "Label", v: env && env.env_label ? escape(env.env_label) : span({ class: "text-muted" }, "(unset)") }, { k: "Destructive-op policy", v: span({ class: "badge bg-info" }, escape(env ? env.on_destructive_op : "?")) }, { k: "Require TLS (default)", v: env && env.require_tls ? "yes" : "no" }, { k: "Bootstrapped at", v: escape(env ? env.bootstrapped_at : "") }, { k: "Ops recorded", v: escape(opCount) }, { k: "Peers configured", v: escape(peerList.length) }, { k: "Pending conflicts", v: conflictCount > 0 ? link("/admin/dev-deploy/conflicts", strong(escape(conflictCount))) : "0" }, ]; const kv = mkTable([{ label: "Setting", key: "k" }, { label: "Value", key: (r) => r.v }], envRows); const countTable = (rows, c1) => rows.length === 0 ? p({ class: "text-muted" }, "None") : mkTable([{ label: c1, key: (r) => escape(r[c1]) }, { label: "count", key: (r) => escape(r.c) }], rows); adminPage(req, res, "dashboard", { type: "card", title: "Environment", contents: kv }, { type: "card", title: "Ops by type", contents: countTable(opsByKind, "op_type") }, { type: "card", title: "Entities tracked", contents: countTable(entCounts, "kind") } ); }; // ---------------- 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 opsTable = ops.length === 0 ? p({ class: "text-muted" }, "Journal is empty") : mkTable([ { label: "op", key: (o) => code(escape(o.op_id.slice(0, 8))) }, { label: "op_type", key: (o) => escape(o.op_type) }, { label: "entity", key: (o) => code(escape((o.entity_uuid || "").slice(0, 8))) }, { label: "parent", key: (o) => code(escape((o.parent_op_id || "").slice(0, 8))) }, { label: "status", key: (o) => escape(o.status) }, { label: "created", key: (o) => escape(o.created_at) }, { label: "actions", key: (o) => post_btn("/admin/dev-deploy/revert", "Revert", req.csrfToken(), { req, btnClass: "btn-danger", small: true, formClass: "d-inline", body: { op_id: o.op_id }, confirm: true }) }, { label: "payload", key: (o) => pre({ class: "mb-0 p-1 bg-light text-dark", style: "white-space:pre-wrap;word-break:break-all;max-width:32rem" }, escape(o.payload)) }, ], ops); 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 ? link(qs(Math.max(offset - limit, 0)), "« Prev") : span({ class: "text-muted" }, "« Prev"); const nextLink = ops.length === limit ? link(qs(offset + limit), "Next »") : span({ class: "text-muted" }, "Next »"); adminPage(req, res, "journal", { type: "card", title: "Journal", contents: p({ class: "text-muted" }, `Showing up to ${escape(limit)} ops${since ? `, since op ${code(escape(since.slice(0, 8)))}` : ""}, offset ${escape(offset)}. Newest first. Revert appends a compensating op rather than rewriting history.`) + opsTable + p(prevLink, "   ", nextLink) } ); }; // ---------------- 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 actBtn = (peer, url, label, cls) => post_btn(url, label, req.csrfToken(), { req, btnClass: cls, small: true, formClass: "d-inline me-1", body: { peer_id: peer.peer_id } }); const peerTable = mkTable([ { label: "id", key: "peer_id" }, { label: "label", key: (peer) => escape(peer.label || "(unset)") }, { label: "env_id", key: (peer) => code(escape(peer.env_id)) }, { label: "base_url", key: (peer) => escape(peer.base_url) }, { label: "last seen", key: (peer) => escape(peer.last_seen_at || "never") }, { label: "actions", key: (peer) => actBtn(peer, "/admin/dev-deploy/promote", "Promote", "btn-primary") + actBtn(peer, "/admin/dev-deploy/pull", "Pull", "btn-secondary") + actBtn(peer, "/admin/dev-deploy/peers/rotate", "Rotate", "btn-warning") + post_btn("/admin/dev-deploy/peers/delete", "Delete", req.csrfToken(), { req, btnClass: "btn-danger", small: true, formClass: "d-inline", body: { peer_id: peer.peer_id }, confirm: true }), }, ], list); const addForm = new Form({ action: "/admin/dev-deploy/peers/add", submitLabel: "Pair", blurb: "Leave the shared secret blank to generate one (shown once after submit). Paste the same secret in the peer's own pairing form.", fields: [ { name: "env_id", label: "Peer env_id", type: "String", required: true }, { name: "label", label: "Label", type: "String", attributes: { placeholder: "test, prod, etc." } }, { name: "base_url", label: "Base URL", type: "String", required: true, attributes: { placeholder: "http://localhost:3001" } }, { name: "require_tls", label: "Require TLS", type: "Bool" }, { name: "existing_secret", label: "Existing secret (hex)", type: "String", attributes: { placeholder: "64 hex characters" } }, ], }); adminPage(req, res, "peers", { type: "card", title: "Peers", contents: p("This instance's ", strong("env_id"), " is ", code(escape(env ? env.env_id : "?")), ". Paste this into the other instance's peer form.") + peerTable }, { type: "card", title: "Add peer", contents: renderForm(addForm, req.csrfToken()) } ); }; 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"); adminPage(req, res, "peer paired", { type: "card", title: `Peer ${escape(peer.label || peer.env_id)} paired`, contents: p("Copy this secret into the peer's pairing form (it will not be shown again):") + pre({ class: "user-select-all p-2 bg-light text-dark border rounded", style: "white-space:pre-wrap;word-break:break-all" }, escape(secretHex)) + p("If this is a brand-new pairing, give the peer this side's env_id too.") + p(link("/admin/dev-deploy/peers", "Back to peers")) } ); } 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); adminPage(req, res, "secret rotated", { type: "card", title: `Peer ${escape(peer.label || peer.env_id)} secret rotated`, contents: p("New secret (shown once):") + pre({ class: "user-select-all p-2 bg-light text-dark border rounded", style: "white-space:pre-wrap;word-break:break-all" }, escape(secret.toString("hex"))) + p("Paste this on the other side via Rotate or by re-pairing.") + p(link("/admin/dev-deploy/peers", "Back to peers")) } ); } 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 selForm = new Form({ methodGET: true, action: "/admin/dev-deploy/plan", submitLabel: "Show plan", fields: [ { name: "peer", label: "Peer", input_type: "select", options: peerList.map((peer) => ({ label: peer.label || peer.env_id, value: String(peer.peer_id) })) }, ], }); adminPage(req, res, "plan", { type: "card", title: "Plan", contents: peerList.length === 0 ? p({ class: "text-muted" }, "No peers configured.") : renderForm(selForm, req.csrfToken()) } ); 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 planTable = planRows.length === 0 ? p({ class: "text-muted" }, "No new ops to send") : mkTable([ { label: "op", key: (o) => code(escape(o.op_id.slice(0, 8))) }, { label: "op_type", key: (o) => escape(o.op_type) }, { label: "entity", key: (o) => code(escape((o.entity_uuid || "").slice(0, 8))) }, { label: "status", key: (o) => escape(o.status) }, { label: "created", key: (o) => escape(o.created_at) }, ], planRows); const promoteBtn = planRows.length === 0 ? "" : post_btn("/admin/dev-deploy/promote", `Promote ${escape(planRows.length)} op${planRows.length === 1 ? "" : "s"} to ${escape(peer.label || peer.env_id)}`, req.csrfToken(), { req, btnClass: "btn-primary", body: { peer_id: peerId } }); adminPage(req, res, "plan", { type: "card", title: `Plan: promote to ${escape(peer.label || peer.env_id)}`, contents: p("Anchor: ", anchor ? code(escape(anchor.last_op_id.slice(0, 8))) : span({ class: "text-muted" }, "(none - will send from epoch)")) + p(`Ops that would be sent: ${escape(planRows.length)}`) + planTable + promoteBtn } ); }; 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 conflictsTable = conflicts.length === 0 ? p({ class: "text-muted" }, "No pending conflicts") : mkTable([ { label: "Theirs", key: (c) => strong("incoming") + " " + code(escape(c.i_op_id.slice(0, 8))) + " from " + code(escape((c.i_source || "").slice(0, 8))) + "
" + escape(c.i_op_type) + " on entity " + code(escape((c.i_uuid || "").slice(0, 8))) + "
" + span({ class: "text-muted" }, "created " + escape(c.i_created)) + pre({ class: "mb-0 p-1 bg-light text-dark", style: "white-space:pre-wrap;word-break:break-all" }, escape(c.i_payload)) }, { label: "Mine", key: (c) => c.l_op_id ? strong("local") + " " + code(escape(c.l_op_id.slice(0, 8))) + "
" + escape(c.l_op_type) + "
" + span({ class: "text-muted" }, "applied " + escape(c.l_applied || "")) + pre({ class: "mb-0 p-1 bg-light text-dark", style: "white-space:pre-wrap;word-break:break-all" }, escape(c.l_payload)) : span({ class: "text-muted" }, "(no local op recorded)") }, { label: "Action", key: (c) => (isMergeable(c) ? link("/admin/dev-deploy/conflicts/merge?op_id=" + encodeURIComponent(c.i_op_id), "Merge per field") + "

" : "") + post_btn("/admin/dev-deploy/conflicts/resolve", "Use theirs", req.csrfToken(), { req, btnClass: "btn-primary", small: true, formClass: "d-inline me-1", body: { op_id: c.i_op_id, action: "theirs" } }) + post_btn("/admin/dev-deploy/conflicts/resolve", "Use mine", req.csrfToken(), { req, btnClass: "btn-secondary", small: true, formClass: "d-inline", body: { op_id: c.i_op_id, action: "mine" } }) }, ], conflicts); const intro = `

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.

`; adminPage(req, res, "conflicts", { type: "card", title: "Pending conflicts", contents: intro + conflictsTable } ); }; 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 contents = `${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

`; adminPage(req, res, "merge", { type: "card", title: "Merge conflict per field", contents: contents }); } 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 tablesTable = rows.length === 0 ? p({ class: "text-muted" }, "No tables tracked yet") : mkTable([ { label: "table", key: (r) => escape(r.current_name) }, { label: "uuid", key: (r) => code(escape(r.uuid.slice(0, 8))) }, { label: "local id", key: (r) => escape(r.current_id) }, { label: "data_mode", key: (r) => { const shipped = r.starter_shipped_at ? "
" + span({ class: "text-muted" }, "shipped " + escape(r.starter_shipped_at)) : ""; if (lockedNames.has(r.current_name)) return span({ class: "badge bg-secondary" }, "user (locked)") + shipped; const opts = modeOpts.map((m) => ``).join(""); return `
${csrfField(req)}
` + shipped; } }, { label: "updated_at", key: (r) => escape(r.updated_at || "-") }, ], rows); const intro = `

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.

`; adminPage(req, res, "tables", { type: "card", title: "Tables - data mode", contents: intro + tablesTable } ); }; 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 };