Files
archy/docker/dojobay/server/fix-payload-version.mjs
T

125 lines
5.7 KiB
JavaScript

#!/usr/bin/env node
// =============================================================================
// The Dojo Bay — restore payload.pairing.version to the value that was signed.
//
// Some records have a stored pairing payload whose `version` was updated after
// the operator signed it (a Dojo upgrade, typically), so the published payload
// no longer matches the signature that attests to it. The version is purely
// informational and the live value is read from the node's X-Dojo-Version
// header on every probe, so the right correction is to put the payload back to
// what was signed and let the next signed submission move both together.
//
// STRICTLY LIMITED: this only ever writes payload.pairing.version, and only on
// records where the signed block and the stored payload are otherwise
// identical (key order and whitespace ignored). Anything else is reported and
// left alone.
//
// Usage, on the box:
// cd /var/www/dojobay/server
// node fix-payload-version.mjs # dry run, changes nothing
// sudo systemctl stop dojobay-server.service
// node fix-payload-version.mjs --apply # writes, after a backup
// sudo systemctl start dojobay-server.service
//
// The stop/start matters: server/store.ts keeps the store in memory and is
// designed as a single writer, so editing store.json underneath a running
// server would be overwritten by its next session or nonce write. --apply
// refuses to run while the service is active unless you pass --force.
// =============================================================================
import { readFile, writeFile, rename, copyFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseSignedBlock } from "./crypto.ts";
const APPLY = process.argv.includes("--apply");
const FORCE = process.argv.includes("--force");
const DIR = process.env.SERVER_DATA_DIR
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
const FILE = path.join(DIR, "store.json");
const stable = (v) => {
if (Array.isArray(v)) return "[" + v.map(stable).join(",") + "]";
if (v && typeof v === "object") {
return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + stable(v[k])).join(",") + "}";
}
return JSON.stringify(v) ?? "null";
};
// Everything except pairing.version, so we can prove that is the only difference.
const withoutVersion = (payload) => {
const p = structuredClone(payload || {});
if (p.pairing && typeof p.pairing === "object") delete p.pairing.version;
return { pairing: p.pairing, explorer: p.explorer };
};
if (APPLY && !FORCE) {
let active = "";
try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); } catch (e) { active = (e.stdout || "").trim(); }
if (active === "active") {
console.error("REFUSING: dojobay-server.service is running.\n" +
"The store is held in memory by the server and would overwrite this edit.\n" +
" sudo systemctl stop dojobay-server.service\n" +
" node fix-payload-version.mjs --apply\n" +
" sudo systemctl start dojobay-server.service\n" +
"(--force overrides this check, but do not use it on a live instance.)");
process.exit(2);
}
}
const raw = await readFile(FILE, "utf8");
const doc = JSON.parse(raw);
const recs = Object.values(doc.submissions || {}).sort((a, b) => a.id.localeCompare(b.id));
const planned = [];
const skipped = [];
for (const rec of recs) {
if (!rec.signed) continue;
const p = parseSignedBlock(rec.signed);
if (!p) { skipped.push([rec.id, "signed block does not parse"]); continue; }
let signedObj;
try { signedObj = JSON.parse(p.pairingText); } catch { skipped.push([rec.id, "signed text is not a bare pairing JSON (extra content around it)"]); continue; }
const sv = signedObj?.pairing?.version ?? null;
const cv = rec.payload?.pairing?.version ?? null;
if (sv === cv) continue; // nothing to do
if (stable(withoutVersion(signedObj)) !== stable(withoutVersion(rec.payload))) {
skipped.push([rec.id, `differs beyond the version (signed ${JSON.stringify(sv)} vs stored ${JSON.stringify(cv)}), left alone`]);
continue;
}
planned.push({ rec, from: cv, to: sv });
}
console.log(`Store: ${FILE}`);
console.log(`Records with a signed block: ${recs.filter((r) => r.signed).length}\n`);
if (planned.length) {
console.log(`Version-only differences (${planned.length}) — payload.pairing.version will be set back to the signed value:`);
for (const { rec, from, to } of planned) console.log(` ${rec.id}: ${JSON.stringify(from)} -> ${JSON.stringify(to)}`);
console.log("");
}
if (skipped.length) {
console.log(`Not touched (${skipped.length}):`);
for (const [id, why] of skipped) console.log(` ${id}: ${why}`);
console.log("");
}
if (!planned.length) { console.log("Nothing to change."); process.exit(0); }
if (!APPLY) {
console.log("DRY RUN — nothing written. Re-run with --apply (with the service stopped) to make these changes.");
process.exit(0);
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const backup = `${FILE}.bak-${stamp}`;
await copyFile(FILE, backup);
for (const { rec, to } of planned) doc.submissions[rec.id].payload.pairing.version = to;
// A temporary name no other writer can take; see build-public.ts. One write per
// run, so the pid alone distinguishes it.
const tmp = `${FILE}.${process.pid}.tmp`;
await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n");
await rename(tmp, FILE);
console.log(`Backup written: ${backup}`);
console.log(`Applied ${planned.length} change(s).`);
console.log("Start the service again, then re-run audit-signed.mjs. The published\n" +
"dojos.json picks the corrected payload up on the next updater cycle.");