659 lines
28 KiB
JavaScript
659 lines
28 KiB
JavaScript
// HTTP routes for site-sync: the single route-registration hub. The admin
|
|
// handlers are thin adapters over operations.js (shared with the MCP tools, so the
|
|
// rules live there); the heavy lifting lives in plan.js / apply.js / rollback.js /
|
|
// rows.js; this module holds the handlers, their rendering and the route table.
|
|
//
|
|
// Admin UI (session admin of THIS tenant; POSTs are CSRF-protected):
|
|
// GET /admin/site-sync/ dashboard + settings
|
|
// POST /admin/site-sync/settings
|
|
// GET /admin/site-sync/peers peers, pairing, secret replacement
|
|
// POST /admin/site-sync/peers/add
|
|
// POST /admin/site-sync/peers/rotate
|
|
// POST /admin/site-sync/peers/delete
|
|
// GET /admin/site-sync/plan choose a source peer
|
|
// POST /admin/site-sync/plan pull the peer's state + preview (persists a plan)
|
|
// POST /admin/site-sync/apply
|
|
// POST /admin/site-sync/rollback
|
|
// GET /admin/site-sync/tables reference-data modes
|
|
// POST /admin/site-sync/tables/set
|
|
//
|
|
// Machine API (HMAC peer auth, CSRF-exempt, every response signed):
|
|
// GET /site-sync/api/health
|
|
// GET /site-sync/api/state
|
|
// GET /site-sync/api/file/:hash
|
|
// POST /site-sync/api/plan
|
|
// POST /site-sync/api/apply
|
|
|
|
const fs = require("fs");
|
|
const db = require("@saltcorn/data/db");
|
|
|
|
const { toAbsolutePath } = require("./common/fileStore");
|
|
const { HTTP_STATUS } = require("./common/httpStatus");
|
|
const { localPluginList } = require("./common/pluginParity");
|
|
const {
|
|
PLUGIN_NAME,
|
|
PLUGIN_VERSION,
|
|
APPLY_POLICY,
|
|
DATA_MODES
|
|
} = require("./constants");
|
|
const { requireEnv } = require("./env");
|
|
const peers = require("./peers");
|
|
const { requirePeerAuth, sendSigned, sendSignedJson } = require("./peerAuth");
|
|
const { API_PATHS, PEER_LIMITS } = require("./transport");
|
|
const { buildDesiredState } = require("./capture");
|
|
const planLib = require("./plan");
|
|
const rowsLib = require("./rows");
|
|
const diff = require("./diff");
|
|
const ops = require("./operations");
|
|
const { LOCK_STATES } = require("./siteLock");
|
|
const {
|
|
ADMIN_URLS,
|
|
requireAdmin,
|
|
escapeHtml,
|
|
csrfField,
|
|
adminPage,
|
|
flashRedirect,
|
|
mkTable,
|
|
post_btn,
|
|
renderForm,
|
|
link,
|
|
Form,
|
|
p, code, pre, span, strong
|
|
} = require("./render");
|
|
|
|
|
|
// A content hash in a file URL: lowercase sha256 hex.
|
|
const HASH_RE = /^[0-9a-f]{64}$/;
|
|
// The only plan direction a remote peer may apply (pull/rollback plans are local).
|
|
const PUSH_DIRECTION = "push";
|
|
|
|
// Comma-separated (or repeated) form field -> trimmed non-empty strings.
|
|
const parseList = (raw) => {
|
|
if (Array.isArray(raw)) {
|
|
return raw.map((s) => String(s).trim()).filter(Boolean);
|
|
}
|
|
return String(raw || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
};
|
|
|
|
|
|
// ---------------- Admin dashboard ----------------
|
|
|
|
// The site lock's state (operations.applyLockView) as a dashboard value.
|
|
const lockBadge = (lock) => {
|
|
if (lock.state === LOCK_STATES.RUNNING) {
|
|
const h = lock.holder;
|
|
return span({ class: "badge bg-primary" }, escapeHtml(`${h.operation} of plan ${h.plan_id} running since ${h.acquired_at}`));
|
|
}
|
|
if (lock.state === LOCK_STATES.INTERRUPTED) {
|
|
return span({ class: "badge bg-danger" }, "interrupted (see below)");
|
|
}
|
|
return span({ class: "text-muted" }, "idle");
|
|
};
|
|
|
|
|
|
// A card for the plans an apply or rollback left in flight when the process running
|
|
// it stopped (none: no card): roll each back from its restore point.
|
|
const interruptedCards = (lock, csrf) => {
|
|
if (lock.state !== LOCK_STATES.INTERRUPTED) {
|
|
return [];
|
|
}
|
|
const items = lock.plans.map((pl) => {
|
|
return p("Plan ", code(escapeHtml(pl.plan_id)), ` (${escapeHtml(pl.direction)}) was left ${escapeHtml(pl.status)}.`) +
|
|
(pl.has_restore_point ? rollbackForm(pl.plan_id, csrf) : p({ class: "text-muted" }, "It has no restore point; the next apply or rollback releases it."));
|
|
});
|
|
return [{ type: "card", title: "Interrupted apply or rollback", contents:
|
|
`<div class="alert alert-danger">The process running an apply or rollback stopped part-way, so this site may be partly changed. ` +
|
|
`Roll the plan back to its restore point, or apply it again; either recovers it.</div>` + items.join("") }];
|
|
};
|
|
|
|
|
|
const dashboard = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const st = await ops.status();
|
|
const env = st.env;
|
|
const syncedCount = Object.values(st.syncedTables).reduce((a, n) => a + n, 0);
|
|
|
|
const envRows = [
|
|
{ k: "Env ID", v: code(escapeHtml(env.env_id)) },
|
|
{ k: "Label", v: env.env_label ? escapeHtml(env.env_label) : span({ class: "text-muted" }, "(unset)") },
|
|
{ k: "Sync mode", v: span({ class: "badge bg-info" }, escapeHtml(env.sync_mode)) },
|
|
{ k: "Delete policy", v: span({ class: "badge bg-warning text-dark" }, escapeHtml(env.on_delete)) },
|
|
{ k: "Apply policy", v: span({ class: "badge bg-secondary" }, escapeHtml(env.on_apply)) },
|
|
{ k: "Require TLS", v: env.require_tls ? "yes" : "no" },
|
|
{ k: "Apply / rollback", v: lockBadge(st.applyLock) },
|
|
{ k: "Peers configured", v: escapeHtml(st.peerCount) },
|
|
{ k: "Non-user tables", v: escapeHtml(syncedCount) },
|
|
{ k: "Plugin version", v: escapeHtml(st.version) },
|
|
];
|
|
const kv = mkTable([{ label: "Setting", key: "k" }, { label: "Value", key: (r) => r.v }], envRows);
|
|
|
|
const settingsForm = new Form({
|
|
action: ADMIN_URLS.SETTINGS,
|
|
submitLabel: "Save settings",
|
|
fields: [
|
|
{ name: "env_label", label: "Label", type: "String", default: env.env_label || "",
|
|
attributes: { placeholder: "dev, test, prod" } },
|
|
{ name: "sync_mode", label: "Sync mode", input_type: "select", default: env.sync_mode, options: ops.SETTING_OPTIONS.sync_mode },
|
|
{ name: "on_delete", label: "Data-bearing drop policy", input_type: "select", default: env.on_delete, options: ops.SETTING_OPTIONS.on_delete },
|
|
{ name: "on_apply", label: "Remote apply policy", input_type: "select", default: env.on_apply, options: ops.SETTING_OPTIONS.on_apply },
|
|
{ name: "require_tls", label: "Require TLS for inbound peer traffic", type: "Bool", default: !!env.require_tls },
|
|
],
|
|
});
|
|
|
|
adminPage(req, res, "dashboard",
|
|
...interruptedCards(st.applyLock, csrfField(req)),
|
|
{ type: "card", title: "Environment", contents: kv },
|
|
{ type: "card", title: "Settings", contents: renderForm(settingsForm, req.csrfToken()) }
|
|
);
|
|
};
|
|
|
|
|
|
const saveSettings = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
try {
|
|
// The whole form: a blank label clears it, an unticked box turns TLS off.
|
|
await ops.updateSettings({
|
|
env_label: req.body.env_label || "",
|
|
sync_mode: req.body.sync_mode,
|
|
on_delete: req.body.on_delete,
|
|
on_apply: req.body.on_apply,
|
|
require_tls: !!req.body.require_tls
|
|
});
|
|
flashRedirect(req, res, ADMIN_URLS.DASHBOARD, "success", "settings saved");
|
|
} catch (err) {
|
|
flashRedirect(req, res, ADMIN_URLS.DASHBOARD, "danger", err.message);
|
|
}
|
|
};
|
|
|
|
|
|
// ---------------- Peers ----------------
|
|
|
|
const peerOptions = (list) => list.map((peer) => ({ label: peer.label || peer.env_id, value: String(peer.peer_id) }));
|
|
|
|
|
|
const peersView = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const env = await requireEnv();
|
|
const list = await peers.listPeers();
|
|
const actBtn = (url, label, cls, body, confirm) =>
|
|
post_btn(url, label, req.csrfToken(), { req, btnClass: cls, small: true, formClass: "d-inline me-1", body: body, confirm: confirm });
|
|
const peerTable = mkTable([
|
|
{ label: "id", key: "peer_id" },
|
|
{ label: "label", key: (peer) => escapeHtml(peer.label || "(unset)") },
|
|
{ label: "env_id", key: (peer) => code(escapeHtml(peer.env_id)) },
|
|
{ label: "base_url", key: (peer) => escapeHtml(peer.base_url) },
|
|
{ label: "last seen", key: (peer) => escapeHtml(peer.last_seen_at || "never") },
|
|
{ label: "actions", key: (peer) =>
|
|
actBtn(ADMIN_URLS.PLAN, "Plan", "btn-primary", { peer: peer.peer_id }, false) +
|
|
actBtn(ADMIN_URLS.PEERS_ROTATE, "Rotate", "btn-warning", { peer_id: peer.peer_id }, true) +
|
|
actBtn(ADMIN_URLS.PEERS_DELETE, "Delete", "btn-danger", { peer_id: peer.peer_id }, true),
|
|
},
|
|
], list);
|
|
|
|
const addForm = new Form({
|
|
action: ADMIN_URLS.PEERS_ADD,
|
|
submitLabel: "Pair",
|
|
blurb: "A peer is a site-sync instance you can pull design state FROM (or push TO). 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: "dev, prod, etc." } },
|
|
{ name: "base_url", label: "Base URL", type: "String", required: true, attributes: { placeholder: "https://dev.example.com" } },
|
|
{ name: "require_tls", label: "Require TLS", type: "Bool" },
|
|
{ name: "existing_secret", label: "Existing secret (hex)", type: "String", attributes: { placeholder: "64 hex characters" } },
|
|
],
|
|
});
|
|
const replaceForm = new Form({
|
|
action: ADMIN_URLS.PEERS_ROTATE,
|
|
submitLabel: "Replace secret",
|
|
blurb: "After the other instance rotated this pairing's secret, paste the new secret it displayed here. Leave it blank to generate a new secret on this side instead.",
|
|
fields: [
|
|
{ name: "peer_id", label: "Peer", input_type: "select", required: true, options: peerOptions(list) },
|
|
{ name: "secret", label: "New 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(escapeHtml(env.env_id)), ". Paste this into the other instance's peer form.") + peerTable },
|
|
{ type: "card", title: "Add peer", contents: renderForm(addForm, req.csrfToken()) },
|
|
{ type: "card", title: "Replace a peer's secret", contents: list.length === 0 ? p({ class: "text-muted" }, "No peers configured.") : renderForm(replaceForm, req.csrfToken()) }
|
|
);
|
|
};
|
|
|
|
|
|
// The one-time display of a shared secret (pairing / rotation).
|
|
const secretCard = (title, secret, instruction) => ({
|
|
type: "card",
|
|
title: title,
|
|
contents:
|
|
p("Shared secret (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" }, escapeHtml(secret.toString("hex"))) +
|
|
p(escapeHtml(instruction)) +
|
|
p(link(ADMIN_URLS.PEERS, "Back to peers"))
|
|
});
|
|
|
|
|
|
const peersAdd = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
try {
|
|
const { peer, secret } = await ops.addPeer({
|
|
envId: req.body.env_id,
|
|
label: req.body.label,
|
|
baseUrl: req.body.base_url,
|
|
requireTls: req.body.require_tls,
|
|
existingSecret: req.body.existing_secret
|
|
});
|
|
adminPage(req, res, "peer paired",
|
|
secretCard(`Peer ${escapeHtml(ops.peerName(peer))} paired`, secret,
|
|
"Paste this secret into the peer's own pairing form for this instance (and give it this instance's env_id if it is a new pairing)."));
|
|
} catch (err) {
|
|
flashRedirect(req, res, ADMIN_URLS.PEERS, "danger", err.message);
|
|
}
|
|
};
|
|
|
|
|
|
// Rotate (generate + display) or replace (a secret pasted from the other side).
|
|
const peersRotate = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
try {
|
|
const { peer, secret, generated } = await ops.rotatePeerSecret(req.body.peer_id, req.body.secret);
|
|
if (!generated) {
|
|
flashRedirect(req, res, ADMIN_URLS.PEERS, "success", `secret for peer ${ops.peerName(peer)} replaced`);
|
|
return;
|
|
}
|
|
adminPage(req, res, "secret rotated",
|
|
secretCard(`Peer ${escapeHtml(ops.peerName(peer))} secret rotated`, secret,
|
|
"On the other instance, open Peers -> Replace a peer's secret, choose this instance, and paste this secret. Until then the pairing cannot authenticate."));
|
|
} catch (err) {
|
|
flashRedirect(req, res, ADMIN_URLS.PEERS, "danger", err.message);
|
|
}
|
|
};
|
|
|
|
|
|
const peersDelete = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
try {
|
|
const { keptForRollback } = await ops.deletePeer(req.body.peer_id);
|
|
flashRedirect(req, res, ADMIN_URLS.PEERS, "success",
|
|
keptForRollback ? `peer deleted (${keptForRollback} applied plan(s) kept so they can still be rolled back)` : "peer deleted");
|
|
} catch (err) {
|
|
flashRedirect(req, res, ADMIN_URLS.PEERS, "danger", err.message);
|
|
}
|
|
};
|
|
|
|
|
|
// ---------------- Plan (preview) ----------------
|
|
|
|
// GET: choose a source peer. Read-only -- the pull + plan persistence happen only
|
|
// on the CSRF-protected POST below.
|
|
const planSelect = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const peerList = await peers.listPeers();
|
|
const selForm = new Form({
|
|
action: ADMIN_URLS.PLAN,
|
|
submitLabel: "Preview plan",
|
|
fields: [
|
|
{ name: "peer", label: "Source peer", input_type: "select", options: peerOptions(peerList) },
|
|
],
|
|
});
|
|
adminPage(req, res, "plan",
|
|
{ type: "card", title: "Preview a sync plan", contents:
|
|
peerList.length === 0
|
|
? p({ class: "text-muted" }, "No peers configured. Add one on the Peers tab.")
|
|
: p("Pull a source peer's current design state and preview what would change here.") + renderForm(selForm, req.csrfToken()) }
|
|
);
|
|
};
|
|
|
|
|
|
// POST: pull the chosen peer's state, persist a plan, and render the preview.
|
|
const planPreview = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
try {
|
|
const { plan_id, plan, peer } = await ops.pullPlan(req.body.peer);
|
|
const html = planLib.renderPlanHtml(plan_id, plan, ops.peerName(peer));
|
|
const applyForm = diff.isEmptyPlan(plan) ? "" : planLib.renderApplyForm(plan_id, plan, csrfField(req));
|
|
adminPage(req, res, "plan",
|
|
{ type: "card", title: "Plan preview", contents: html + applyForm }
|
|
);
|
|
} catch (err) {
|
|
flashRedirect(req, res, ADMIN_URLS.PLAN, "danger", err.message);
|
|
}
|
|
};
|
|
|
|
|
|
// ---------------- Apply ----------------
|
|
|
|
const warningList = (warnings) => {
|
|
if (!warnings || warnings.length === 0) {
|
|
return "";
|
|
}
|
|
return `<div class="alert alert-warning"><strong>Warnings (${warnings.length}):</strong><ul class="mb-0">` +
|
|
warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join("") + `</ul></div>`;
|
|
};
|
|
|
|
|
|
// One-line outcome of an apply, from applyPlan's result.
|
|
const applySummary = (result) => {
|
|
const bits = [`${result.summary.total} planned change(s)`];
|
|
if (result.deletes.applied) {
|
|
bits.push(`${result.deletes.deleted || 0} deleted`);
|
|
bits.push(`${result.deletes.skipped.length} delete(s) skipped`);
|
|
}
|
|
if (result.rows.applied) {
|
|
const rowOps = Object.values(result.rows.perTable).reduce((a, t) => a + t.inserted + t.updated + t.deleted, 0);
|
|
bits.push(`${rowOps} row op(s)`);
|
|
}
|
|
return bits.join(", ");
|
|
};
|
|
|
|
|
|
// Admin apply of a previewed plan. Data-bearing drops need the table names TYPED
|
|
// into `prunable` (+ the confirmation tick under on_delete=confirm); table renames
|
|
// need their `confirm_rename` box ticked.
|
|
const applyView = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
let result;
|
|
try {
|
|
result = await ops.applyPlan(req.body.plan_id, {
|
|
prunable: parseList(req.body.prunable),
|
|
confirmDataDrops: !!req.body.confirm_data,
|
|
confirmRenames: parseList(req.body.confirm_rename)
|
|
});
|
|
} catch (err) {
|
|
if (!err.extra) {
|
|
flashRedirect(req, res, ADMIN_URLS.PLAN, "danger", err.message);
|
|
return;
|
|
}
|
|
// A failure part-way through can leave the target half-applied; the restore
|
|
// point saved before any change is exactly what the admin needs now.
|
|
const { planId, canRollback } = err.extra;
|
|
adminPage(req, res, "apply failed",
|
|
{ type: "card", title: `Apply of plan ${escapeHtml(planId)} failed`, contents:
|
|
`<div class="alert alert-danger">${escapeHtml(err.message)}</div>` +
|
|
(canRollback
|
|
? p("A restore point was saved before this apply changed anything. Roll back to it:") + rollbackForm(planId, csrfField(req))
|
|
: p("No restore point exists for this plan.")) +
|
|
p(link(ADMIN_URLS.PLAN, "Back to plan")) }
|
|
);
|
|
return;
|
|
}
|
|
const planId = result.plan_id;
|
|
adminPage(req, res, "applied",
|
|
{ type: "card", title: `Applied plan ${escapeHtml(planId)}`, contents:
|
|
p("Applied: ", strong(escapeHtml(applySummary(result)))) +
|
|
warningList(result.warnings) +
|
|
(result.canRollback ? p("Revert if this was a mistake:") + rollbackForm(planId, csrfField(req)) : "") +
|
|
p(link(ADMIN_URLS.DASHBOARD, "Back to dashboard")) }
|
|
);
|
|
};
|
|
|
|
|
|
const rollbackForm = (planId, csrf) => {
|
|
return `<form method="post" action="${ADMIN_URLS.ROLLBACK}" onsubmit="return confirm('Revert this apply?');">` +
|
|
csrf +
|
|
`<input type="hidden" name="plan_id" value="${escapeHtml(planId)}">` +
|
|
`<div class="form-text mb-1">Rollback reverts metadata, config, and managed rows to the pre-apply state. ` +
|
|
`To also drop tables this apply created that have since gained rows, type their names and confirm:</div>` +
|
|
`<input class="form-control mb-2" name="prunable" placeholder="table names (comma-separated), optional">` +
|
|
`<div class="form-check mb-2"><input class="form-check-input" type="checkbox" name="confirm_data" value="1" id="rbConfirm">` +
|
|
`<label class="form-check-label" for="rbConfirm">Allow dropping the typed tables' data</label></div>` +
|
|
`<button class="btn btn-warning" type="submit">Rollback this apply</button></form>`;
|
|
};
|
|
|
|
|
|
const rollbackView = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
try {
|
|
const result = await ops.rollbackPlan(req.body.plan_id, {
|
|
prunable: parseList(req.body.prunable),
|
|
confirmDataDrops: !!req.body.confirm_data
|
|
});
|
|
for (const w of result.warnings) {
|
|
req.flash("warning", escapeHtml(w));
|
|
}
|
|
flashRedirect(req, res, ADMIN_URLS.DASHBOARD, "success", `rolled back plan ${result.plan_id} (${result.restoredTables} table(s) restored)`);
|
|
} catch (err) {
|
|
flashRedirect(req, res, ADMIN_URLS.DASHBOARD, "danger", err.message);
|
|
}
|
|
};
|
|
|
|
|
|
// ---------------- Tables (reference-data modes) ----------------
|
|
|
|
const tablesView = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
const info = await ops.listTables();
|
|
const modeBtn = (r, mode, label, cls) => {
|
|
if (r.mode === mode) {
|
|
return `<span class="badge bg-${cls} me-1">${escapeHtml(label)}</span>`;
|
|
}
|
|
return post_btn(ADMIN_URLS.TABLES_SET, label, req.csrfToken(),
|
|
{ req, btnClass: "btn-outline-" + cls, small: true, formClass: "d-inline me-1", body: { table: r.name, mode: mode } });
|
|
};
|
|
const tbl = mkTable([
|
|
{ label: "table", key: (r) => escapeHtml(r.name) },
|
|
{ label: "rows", key: (r) => escapeHtml(r.rows) },
|
|
{ label: "mode", key: (r) => r.locked ? `<span class="badge bg-dark">locked (user)</span>` : escapeHtml(r.mode) },
|
|
{ label: "set", key: (r) => r.locked
|
|
? span({ class: "text-muted" }, "system table")
|
|
: modeBtn(r, DATA_MODES.USER, "User", "secondary") + modeBtn(r, DATA_MODES.STARTER, "Starter", "info") + modeBtn(r, DATA_MODES.MANAGED, "Managed", "primary") },
|
|
], info);
|
|
adminPage(req, res, "tables",
|
|
{ type: "card", title: "Reference-data modes", contents:
|
|
p("Choose which tables site-sync propagates ", strong("row data"), " for. ",
|
|
strong("User"), " (default): rows are never touched. ",
|
|
strong("Starter"), ": rows are seeded once into a fresh table, then the target owns them. ",
|
|
strong("Managed"), ": the source is canonical; rows are mirrored (added/updated/removed). Mark Starter/Managed on ",
|
|
strong("reference/lookup"), " tables only -- never user-entered data.") + tbl }
|
|
);
|
|
};
|
|
|
|
|
|
const tablesSet = async (req, res) => {
|
|
if (!requireAdmin(req, res)) {
|
|
return;
|
|
}
|
|
try {
|
|
const { table, mode } = await ops.setTableMode(req.body.table, req.body.mode);
|
|
flashRedirect(req, res, ADMIN_URLS.TABLES, "success", `${table} set to ${mode}`);
|
|
} catch (err) {
|
|
flashRedirect(req, res, ADMIN_URLS.TABLES, "danger", err.message);
|
|
}
|
|
};
|
|
|
|
|
|
// ---------------- Machine API ----------------
|
|
|
|
// Wrap a machine-API handler: HMAC-authenticate the request, then run it; any
|
|
// error becomes a SIGNED 500 so the caller sees the real message instead of an
|
|
// unsigned (and therefore rejected) error page.
|
|
const peerRoute = (handler) => async (req, res) => {
|
|
const peer = await requirePeerAuth(req, res);
|
|
if (!peer) {
|
|
return;
|
|
}
|
|
try {
|
|
await handler(req, res, peer);
|
|
} catch (err) {
|
|
sendSignedJson(req, res, HTTP_STATUS.SERVER_ERROR, { error: err.message });
|
|
}
|
|
};
|
|
|
|
|
|
const apiHealth = async (req, res) => {
|
|
const env = await requireEnv();
|
|
sendSignedJson(req, res, HTTP_STATUS.OK, {
|
|
plugin: PLUGIN_NAME,
|
|
version: PLUGIN_VERSION,
|
|
env_id: env.env_id,
|
|
label: env.env_label,
|
|
plugins: await localPluginList()
|
|
});
|
|
};
|
|
|
|
|
|
// Serve THIS instance's sanitized desired-state pack to an authenticated peer.
|
|
// Read-only: the source never mutates; the target pulls this and computes/applies
|
|
// the plan on its own side, so every safety gate lives on the mutating side.
|
|
const apiState = async (req, res) => {
|
|
const env = await requireEnv();
|
|
const { pack, hash } = await buildDesiredState();
|
|
const { manifest } = await rowsLib.buildRowManifest();
|
|
sendSignedJson(req, res, HTTP_STATUS.OK, {
|
|
plugin: PLUGIN_NAME,
|
|
version: PLUGIN_VERSION,
|
|
env_id: env.env_id,
|
|
hash: hash,
|
|
pack: pack,
|
|
rows: manifest
|
|
});
|
|
};
|
|
|
|
|
|
// PUSH endpoint: a source posts its desired-state pack; the TARGET still computes
|
|
// and persists the plan on its own side (every gate lives on the mutating side).
|
|
const apiPlan = async (req, res, peer) => {
|
|
if (!req.body.pack) {
|
|
sendSignedJson(req, res, HTTP_STATUS.BAD_REQUEST, { error: "pack required" });
|
|
return;
|
|
}
|
|
const { plan_id, plan } = await planLib.buildPlan({
|
|
peerRow: peer, env: await requireEnv(), desired: req.body.pack, desiredHash: req.body.hash,
|
|
sourceEnvId: peer.env_id, direction: PUSH_DIRECTION, sourceRows: req.body.rows || {}
|
|
});
|
|
sendSignedJson(req, res, HTTP_STATUS.OK, {
|
|
plan_id: plan_id,
|
|
summary: plan.summary,
|
|
destructive: plan.destructive,
|
|
precondition_hash: plan.precondition_hash
|
|
});
|
|
};
|
|
|
|
|
|
// PUSH apply: a source triggers apply of a plan it pushed. Gated behind
|
|
// on_apply=auto -- by default the target refuses remote-triggered mutation and the
|
|
// admin must apply via the target's own UI. Even when enabled, all gates (drift
|
|
// re-check, config allow-list, delete policy) run locally.
|
|
const apiApply = async (req, res, peer) => {
|
|
const env = await requireEnv();
|
|
if (env.on_apply !== APPLY_POLICY.AUTO) {
|
|
sendSignedJson(req, res, HTTP_STATUS.FORBIDDEN, { error: "target requires admin confirmation (on_apply=confirm); apply via the target admin UI" });
|
|
return;
|
|
}
|
|
const planId = req.body.plan_id;
|
|
if (!planId) {
|
|
sendSignedJson(req, res, HTTP_STATUS.BAD_REQUEST, { error: "plan_id required" });
|
|
return;
|
|
}
|
|
// Bind the plan to the calling peer AND to the push direction: a peer may only
|
|
// apply a plan it pushed itself -- never another peer's, nor a pull plan the
|
|
// local admin previewed from it and chose not to apply.
|
|
const owned = await planLib.loadPlan(planId);
|
|
if (!owned || owned.peer_id !== peer.peer_id || owned.direction !== PUSH_DIRECTION) {
|
|
sendSignedJson(req, res, HTTP_STATUS.NOT_FOUND, { error: "unknown plan" });
|
|
return;
|
|
}
|
|
let result;
|
|
try {
|
|
// The SENDER can never satisfy the human gates: an empty typed allow-list
|
|
// means no data-bearing table/field is ever dropped remotely (whatever
|
|
// on_delete says), and no confirmed renames means no table is renamed.
|
|
result = await ops.applyPlan(planId, {
|
|
prunable: [],
|
|
confirmDataDrops: false,
|
|
confirmRenames: []
|
|
});
|
|
} catch (err) {
|
|
sendSignedJson(req, res, HTTP_STATUS.CONFLICT, { error: err.message });
|
|
return;
|
|
}
|
|
sendSignedJson(req, res, HTTP_STATUS.OK, { ok: true, result });
|
|
};
|
|
|
|
|
|
// Serve a file's bytes by CONTENT HASH to an authenticated peer. Only files
|
|
// referenced by this instance's synced (managed/starter) rows or by its page
|
|
// layouts / view configs / site logo are resolvable, the resolved path is
|
|
// containment-checked against the tenant file store, and the bytes are read whole
|
|
// (capped) so the response signature covers exactly what is sent.
|
|
const apiFile = async (req, res) => {
|
|
const hash = req.params.hash;
|
|
if (!HASH_RE.test(hash || "")) {
|
|
sendSignedJson(req, res, HTTP_STATUS.BAD_REQUEST, { error: "bad hash" });
|
|
return;
|
|
}
|
|
const fieldValue = await rowsLib.resolveFileByHash(hash);
|
|
if (!fieldValue) {
|
|
sendSignedJson(req, res, HTTP_STATUS.NOT_FOUND, { error: "file not found", hash });
|
|
return;
|
|
}
|
|
let absPath;
|
|
try {
|
|
absPath = toAbsolutePath(db, fieldValue);
|
|
} catch (e) {
|
|
sendSignedJson(req, res, HTTP_STATUS.BAD_REQUEST, { error: "refusing file path outside tenant store" });
|
|
return;
|
|
}
|
|
let stat;
|
|
try {
|
|
stat = await fs.promises.stat(absPath);
|
|
} catch (e) {
|
|
sendSignedJson(req, res, HTTP_STATUS.NOT_FOUND, { error: "file missing from the store", hash });
|
|
return;
|
|
}
|
|
if (stat.size > PEER_LIMITS.FILE_BYTES) {
|
|
sendSignedJson(req, res, HTTP_STATUS.PAYLOAD_TOO_LARGE, { error: `file larger than ${PEER_LIMITS.FILE_BYTES} bytes`, hash });
|
|
return;
|
|
}
|
|
sendSigned(req, res, HTTP_STATUS.OK, await fs.promises.readFile(absPath), "application/octet-stream");
|
|
};
|
|
|
|
|
|
// ---------------- Route registration ----------------
|
|
|
|
const routes = [
|
|
{ url: ADMIN_URLS.DASHBOARD, method: "get", callback: dashboard },
|
|
{ url: ADMIN_URLS.SETTINGS, method: "post", callback: saveSettings },
|
|
{ url: ADMIN_URLS.PEERS, method: "get", callback: peersView },
|
|
{ url: ADMIN_URLS.PEERS_ADD, method: "post", callback: peersAdd },
|
|
{ url: ADMIN_URLS.PEERS_ROTATE, method: "post", callback: peersRotate },
|
|
{ url: ADMIN_URLS.PEERS_DELETE, method: "post", callback: peersDelete },
|
|
{ url: ADMIN_URLS.PLAN, method: "get", callback: planSelect },
|
|
{ url: ADMIN_URLS.PLAN, method: "post", callback: planPreview },
|
|
{ url: ADMIN_URLS.APPLY, method: "post", callback: applyView },
|
|
{ url: ADMIN_URLS.ROLLBACK, method: "post", callback: rollbackView },
|
|
{ url: ADMIN_URLS.TABLES, method: "get", callback: tablesView },
|
|
{ url: ADMIN_URLS.TABLES_SET, method: "post", callback: tablesSet },
|
|
{ url: API_PATHS.HEALTH, method: "get", callback: peerRoute(apiHealth), noCsrf: true },
|
|
{ url: API_PATHS.STATE, method: "get", callback: peerRoute(apiState), noCsrf: true },
|
|
{ url: API_PATHS.FILE_ROUTE, method: "get", callback: peerRoute(apiFile), noCsrf: true },
|
|
{ url: API_PATHS.PLAN, method: "post", callback: peerRoute(apiPlan), noCsrf: true },
|
|
{ url: API_PATHS.APPLY, method: "post", callback: peerRoute(apiApply), noCsrf: true },
|
|
];
|
|
|
|
|
|
module.exports = {
|
|
routes
|
|
};
|