// 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:
`
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.
` + 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 `Warnings (${warnings.length}):` +
warnings.map((w) => `- ${escapeHtml(w)}
`).join("") + `
`;
};
// 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:
`${escapeHtml(err.message)}
` +
(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 ``;
};
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 `${escapeHtml(label)}`;
}
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 ? `locked (user)` : 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
};