sc-dev-deploy/lib/routes.js
2026-06-01 16:43:43 -05:00

1128 lines
53 KiB
JavaScript

// 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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
};
const csrfField = (req) => {
const t = req.csrfToken ? req.csrfToken() : "";
return `<input type="hidden" name="_csrf" value="${escape(t)}">`;
};
const layout = (title, body, flash) => `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<title>${escape(title)}</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 1.5rem; max-width: 1100px; }
h1 { font-size: 1.4rem; }
h2 { font-size: 1.1rem; margin-top: 1.5rem; }
table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
th, td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; text-align: left; vertical-align: top; }
th { background: #f5f5f5; }
tr:nth-child(even) td { background: #fafafa; }
code, pre { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.8rem; }
pre { background: #f5f5f5; padding: 0.5rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
.muted { color: #888; }
.pill { display: inline-block; padding: 0.05rem 0.5rem; border-radius: 0.5rem; background: #eef; font-size: 0.75rem; }
nav { margin-bottom: 1rem; }
nav a { margin-right: 1rem; }
.right { float: right; color: #888; font-size: 0.85rem; }
form.inline { display: inline; }
input[type=text], input[type=url] { width: 28rem; padding: 0.2rem; }
fieldset { margin-bottom: 1rem; padding: 0.5rem 1rem; }
legend { font-weight: bold; }
.flash { padding: 0.5rem 1rem; margin-bottom: 1rem; background: #efe; border: 1px solid #cdc; border-radius: 0.3rem; }
.flash.err { background: #fee; border-color: #fcc; }
.secret { font-family: ui-monospace, Menlo, Consolas, monospace; background: #fff8dc; padding: 0.5rem; border: 1px solid #d4af37; border-radius: 0.3rem; word-break: break-all; }
button { padding: 0.25rem 0.6rem; cursor: pointer; }
</style>
</head><body>
<nav>
<a href="/admin/dev-deploy/">Dashboard</a>
<a href="/admin/dev-deploy/ops">Journal</a>
<a href="/admin/dev-deploy/peers">Peers</a>
<a href="/admin/dev-deploy/plan">Plan</a>
<a href="/admin/dev-deploy/tables">Tables</a>
<a href="/admin/dev-deploy/conflicts">Conflicts</a>
<span class="right">${escape(PLUGIN_NAME)} v${escape(PLUGIN_VERSION)}</span>
</nav>
${flash || ""}
${body}
</body></html>`;
const flashMsg = (req) => {
const m = req.query.msg;
const e = req.query.err;
if (m) return `<div class="flash">${escape(m)}</div>`;
if (e) return `<div class="flash err">${escape(e)}</div>`;
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
? `<tr><td colspan="2" class="muted">No ops recorded yet</td></tr>`
: opsByKind.map((r) => `<tr><td>${escape(r.op_type)}</td><td>${escape(r.c)}</td></tr>`).join("");
const entRows = entCounts.length === 0
? `<tr><td colspan="2" class="muted">No entities tracked</td></tr>`
: entCounts.map((r) => `<tr><td>${escape(r.kind)}</td><td>${escape(r.c)}</td></tr>`).join("");
const body = `
<h1>dev-deploy dashboard</h1>
<table>
<tr><th>Env ID</th><td><code>${escape(env ? env.env_id : "?")}</code></td></tr>
<tr><th>Label</th><td>${env && env.env_label ? escape(env.env_label) : '<span class="muted">(unset)</span>'}</td></tr>
<tr><th>Destructive-op policy</th><td><span class="pill">${escape(env ? env.on_destructive_op : "?")}</span></td></tr>
<tr><th>Require TLS (default)</th><td>${env && env.require_tls ? "yes" : "no"}</td></tr>
<tr><th>Bootstrapped at</th><td>${escape(env ? env.bootstrapped_at : "")}</td></tr>
<tr><th>Ops recorded</th><td>${escape(opCount)}</td></tr>
<tr><th>Peers configured</th><td>${escape(peerList.length)}</td></tr>
<tr><th>Pending conflicts</th><td>${conflictCount > 0 ? `<a href="/admin/dev-deploy/conflicts"><strong>${escape(conflictCount)}</strong></a>` : "0"}</td></tr>
</table>
<h2>Ops by type</h2>
<table><tr><th>op_type</th><th>count</th></tr>${opsByKindRows}</table>
<h2>Entities tracked</h2>
<table><tr><th>kind</th><th>count</th></tr>${entRows}</table>
`;
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
? `<tr><td colspan="8" class="muted">Journal is empty</td></tr>`
: ops.map((o) => `<tr>
<td><code>${escape(o.op_id.slice(0, 8))}</code></td>
<td>${escape(o.op_type)}</td>
<td><code>${escape((o.entity_uuid || "").slice(0, 8))}</code></td>
<td><code>${escape((o.parent_op_id || "").slice(0, 8))}</code></td>
<td>${escape(o.status)}</td>
<td>${escape(o.created_at)}</td>
<td>
<form class="inline" method="post" action="/admin/dev-deploy/revert" onsubmit="return confirm('Append a compensating op to revert ${escape(o.op_type)} ${escape(o.op_id.slice(0,8))}?')">
${csrfField(req)}
<input type="hidden" name="op_id" value="${escape(o.op_id)}">
<button type="submit">Revert</button>
</form>
</td>
<td><pre>${escape(o.payload)}</pre></td>
</tr>`).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
? `<a href="${escape(qs(Math.max(offset - limit, 0)))}">&laquo; Prev</a>`
: `<span class="muted">&laquo; Prev</span>`;
const nextLink = ops.length === limit
? `<a href="${escape(qs(offset + limit))}">Next &raquo;</a>`
: `<span class="muted">Next &raquo;</span>`;
const body = `
<h1>Journal</h1>
<p class="muted">Showing up to ${escape(limit)} ops${since ? `, since op <code>${escape(since.slice(0, 8))}</code>` : ""}, offset ${escape(offset)}. Newest first. Revert appends a compensating op rather than rewriting history.</p>
<table>
<tr><th>op</th><th>op_type</th><th>entity</th><th>parent</th><th>status</th><th>created</th><th>actions</th><th>payload</th></tr>
${rows}
</table>
<p>${prevLink} &nbsp; ${nextLink}</p>
`;
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
? `<tr><td colspan="6" class="muted">No peers yet</td></tr>`
: list.map((p) => `<tr>
<td>${escape(p.peer_id)}</td>
<td>${escape(p.label || "(unset)")}</td>
<td><code>${escape(p.env_id)}</code></td>
<td>${escape(p.base_url)}</td>
<td>${escape(p.last_seen_at || "never")}</td>
<td>
<form class="inline" method="post" action="/admin/dev-deploy/promote">
${csrfField(req)}
<input type="hidden" name="peer_id" value="${escape(p.peer_id)}">
<button type="submit">Promote</button>
</form>
<form class="inline" method="post" action="/admin/dev-deploy/pull">
${csrfField(req)}
<input type="hidden" name="peer_id" value="${escape(p.peer_id)}">
<button type="submit">Pull</button>
</form>
<form class="inline" method="post" action="/admin/dev-deploy/peers/rotate">
${csrfField(req)}
<input type="hidden" name="peer_id" value="${escape(p.peer_id)}">
<button type="submit">Rotate</button>
</form>
<form class="inline" method="post" action="/admin/dev-deploy/peers/delete" onsubmit="return confirm('Delete peer ${escape(p.label || p.env_id)}?')">
${csrfField(req)}
<input type="hidden" name="peer_id" value="${escape(p.peer_id)}">
<button type="submit">Delete</button>
</form>
</td>
</tr>`).join("");
const body = `
<h1>Peers</h1>
<p>This instance's <strong>env_id</strong> is <code>${escape(env ? env.env_id : "?")}</code>. Paste this into the other instance's peer form.</p>
<table>
<tr><th>id</th><th>label</th><th>env_id</th><th>base_url</th><th>last seen</th><th>actions</th></tr>
${rows}
</table>
<h2>Add peer</h2>
<form method="post" action="/admin/dev-deploy/peers/add">
${csrfField(req)}
<fieldset>
<legend>Peer info</legend>
<p><label>Peer env_id <input type="text" name="env_id" required></label></p>
<p><label>Label <input type="text" name="label" placeholder="test, prod, etc."></label></p>
<p><label>Base URL <input type="url" name="base_url" required placeholder="http://localhost:3001"></label></p>
<p><label><input type="checkbox" name="require_tls"> Require TLS</label></p>
</fieldset>
<fieldset>
<legend>Shared secret</legend>
<p>Leave blank to generate one (shown once after submit). Paste the same secret in the peer's own pairing form.</p>
<p><label>Existing secret (hex) <input type="text" name="existing_secret" placeholder="64 hex characters"></label></p>
</fieldset>
<p><button type="submit">Pair</button></p>
</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 = `
<h1>Peer ${escape(peer.label || peer.env_id)} paired</h1>
<p>Copy this secret into the peer's pairing form (it will not be shown again):</p>
<p class="secret">${escape(secretHex)}</p>
<p>If this is a brand-new pairing, give the peer this side's env_id too.</p>
<p><a href="/admin/dev-deploy/peers">Back to peers</a></p>
`;
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 = `
<h1>Peer ${escape(peer.label || peer.env_id)} secret rotated</h1>
<p>New secret (shown once):</p>
<p class="secret">${escape(secret.toString("hex"))}</p>
<p>Paste this on the other side via Rotate or by re-pairing.</p>
<p><a href="/admin/dev-deploy/peers">Back to peers</a></p>
`;
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
? `<option value="" disabled selected>No peers configured</option>`
: peerList.map((p) => `<option value="${escape(p.peer_id)}">${escape(p.label || p.env_id)}</option>`).join("");
const body = `
<h1>Plan</h1>
<form method="get" action="/admin/dev-deploy/plan">
<label>Peer <select name="peer">${opts}</select></label>
<button type="submit">Show plan</button>
</form>
`;
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
? `<tr><td colspan="5" class="muted">No new ops to send</td></tr>`
: planRows.map((o) => `<tr>
<td><code>${escape(o.op_id.slice(0, 8))}</code></td>
<td>${escape(o.op_type)}</td>
<td><code>${escape((o.entity_uuid || "").slice(0, 8))}</code></td>
<td>${escape(o.status)}</td>
<td>${escape(o.created_at)}</td>
</tr>`).join("");
const body = `
<h1>Plan: promote to ${escape(peer.label || peer.env_id)}</h1>
<p>Anchor: ${anchor ? `<code>${escape(anchor.last_op_id.slice(0, 8))}</code>` : '<span class="muted">(none — will send from epoch)</span>'}</p>
<p>Ops that would be sent: ${escape(planRows.length)}</p>
<table>
<tr><th>op</th><th>op_type</th><th>entity</th><th>status</th><th>created</th></tr>
${rowsHtml}
</table>
<form method="post" action="/admin/dev-deploy/promote">
${csrfField(req)}
<input type="hidden" name="peer_id" value="${escape(peerId)}">
<p><button type="submit"${planRows.length === 0 ? " disabled" : ""}>Promote ${escape(planRows.length)} op${planRows.length === 1 ? "" : "s"} to ${escape(peer.label || peer.env_id)}</button></p>
</form>
`;
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
? `<tr><td colspan="3" class="muted">No pending conflicts</td></tr>`
: conflicts.map((c) => `<tr>
<td>
<strong>incoming</strong> <code>${escape(c.i_op_id.slice(0, 8))}</code> from <code>${escape((c.i_source || "").slice(0, 8))}</code><br>
${escape(c.i_op_type)} on entity <code>${escape((c.i_uuid || "").slice(0, 8))}</code><br>
<span class="muted">created ${escape(c.i_created)}</span>
<pre>${escape(c.i_payload)}</pre>
</td>
<td>
${c.l_op_id ? `<strong>local</strong> <code>${escape(c.l_op_id.slice(0, 8))}</code><br>
${escape(c.l_op_type)}<br>
<span class="muted">applied ${escape(c.l_applied || "")}</span>
<pre>${escape(c.l_payload)}</pre>` : '<span class="muted">(no local op recorded)</span>'}
</td>
<td>
${isMergeable(c) ? `<a href="/admin/dev-deploy/conflicts/merge?op_id=${encodeURIComponent(c.i_op_id)}"><button type="button">Merge per field</button></a><br><br>` : ""}
<form class="inline" method="post" action="/admin/dev-deploy/conflicts/resolve">
${csrfField(req)}
<input type="hidden" name="op_id" value="${escape(c.i_op_id)}">
<input type="hidden" name="action" value="theirs">
<button type="submit">Use theirs</button>
</form>
<form class="inline" method="post" action="/admin/dev-deploy/conflicts/resolve">
${csrfField(req)}
<input type="hidden" name="op_id" value="${escape(c.i_op_id)}">
<input type="hidden" name="action" value="mine">
<button type="submit">Use mine</button>
</form>
</td>
</tr>`).join("");
const body = `
<h1>Pending conflicts</h1>
<p>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.</p>
<ul>
<li><strong>Use theirs</strong>: applies the incoming op now (overwrites local change). The local op stays in the journal but its effect is overwritten.</li>
<li><strong>Use mine</strong>: marks the incoming op as <code>rejected</code>. The local state stands. The peer may re-send the op on future syncs; subsequent pulls will skip it via idempotency.</li>
</ul>
<table>
<tr><th style="width: 40%">Theirs</th><th style="width: 40%">Mine</th><th>Action</th></tr>
${rowsHtml}
</table>
`;
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 '<span class="muted">(unset)</span>';
}
if (typeof v === "object") {
return `<pre>${escape(JSON.stringify(v, null, 2))}</pre>`;
}
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
? `<p>Entity: <code>${escape(diff.kind)}</code> <strong>${escape(diff.instance.name || diff.instance.role || diff.instance.id)}</strong> (local id ${escape(diff.instance.id)})</p>`
: `<p class="flash err">${escape(diff.reason || "no entity diff available")}</p>`;
let formBody;
if (diffs.length === 0) {
formBody = `<p class="muted">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.</p>`;
} else {
const rows = diffs.map((d) => `<tr>
<td><code>${escape(d.field)}</code></td>
<td>${renderValue(d.currentValue)}</td>
<td>${renderValue(d.incomingValue)}</td>
<td>
<label><input type="radio" name="choice_${escape(d.field)}" value="current" checked> keep current</label><br>
<label><input type="radio" name="choice_${escape(d.field)}" value="incoming"> take incoming</label><br>
<label><input type="radio" name="choice_${escape(d.field)}" value="custom"> custom:
<input type="text" name="custom_${escape(d.field)}" value="${escape(typeof d.currentValue === "string" ? d.currentValue : "")}">
</label>
</td>
</tr>`).join("");
formBody = `
<table>
<tr><th>field</th><th>current (mine)</th><th>incoming (theirs)</th><th>resolution</th></tr>
${rows}
</table>
<p>Defaults to <strong>keep current</strong> for every field — submit as-is for a no-op resolution that just clears the conflict marker.</p>
`;
}
const body = `
<h1>Merge conflict per field</h1>
${ent}
<p>Incoming op <code>${escape(op.op_id.slice(0, 8))}</code> from <code>${escape((op.source_env_id || "").slice(0, 8))}</code>; ${escape(op.op_type)}.</p>
<form method="post" action="/admin/dev-deploy/conflicts/merge/apply">
${csrfField(req)}
<input type="hidden" name="op_id" value="${escape(op.op_id)}">
${formBody}
<p>
<button type="submit">Apply merge</button>
<a href="/admin/dev-deploy/conflicts">Cancel</a>
</p>
</form>
`;
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_<field>" 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
? `<tr><td colspan="6" class="muted">No tables tracked yet</td></tr>`
: rows.map((r) => {
const locked = lockedNames.has(r.current_name);
const select = locked
? `<span class="pill">user (locked)</span>`
: `<form class="inline" method="post" action="/admin/dev-deploy/tables/set" onsubmit="return confirm('Switching to managed/starter ADDS a hidden _dd_row_uuid column to this table and ships the current rows. Existing rows on target instances may be overwritten on next promote. Continue?');">
${csrfField(req)}
<input type="hidden" name="table_uuid" value="${escape(r.uuid)}">
<select name="data_mode">
${modeOpts.map((m) => `<option value="${m}"${m === r.data_mode ? " selected" : ""}>${m}</option>`).join("")}
</select>
<button type="submit">Set</button>
</form>`;
const shipped = r.starter_shipped_at ? `<br><span class="muted">shipped ${escape(r.starter_shipped_at)}</span>` : "";
return `<tr>
<td>${escape(r.current_name)}</td>
<td><code>${escape(r.uuid.slice(0, 8))}</code></td>
<td>${escape(r.current_id)}</td>
<td>${select}${shipped}</td>
<td>${escape(r.updated_at || "—")}</td>
</tr>`;
}).join("");
const body = `
<h1>Tables &mdash; data mode</h1>
<p>Controls how each table's row content propagates between environments. Choose carefully — switching from <strong>user</strong> to <strong>managed</strong> or <strong>starter</strong> rewrites the table's schema (adds a hidden <code>_dd_row_uuid</code> column) and ships existing rows.</p>
<ul>
<li><strong>user</strong> (default) &mdash; rows belong to the local environment; deploys never touch them. The only safe choice for end-user-entered data.</li>
<li><strong>starter</strong> &mdash; rows ship to target on first install, then the target owns them; future changes on this side don't propagate. Good for default user roles, sample categories, template data the user expects to customize.</li>
<li><strong>managed</strong> &mdash; rows always sync from source. Source is canonical; target's edits get overwritten or surface as conflicts. Good for catalogs, lookup tables, anything dev-curated.</li>
</ul>
<p>The Saltcorn <code>users</code> table is locked to <strong>user</strong> and cannot be changed.</p>
<table>
<tr><th>table</th><th>uuid</th><th>local id</th><th>data_mode</th><th>updated_at</th></tr>
${rowsHtml}
</table>
`;
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
};