feat(dojobay): add DojoBay app (manifest, image, catalog, ports)
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// The Dojo Bay — apply operator-signed pairing payload updates.
|
||||
//
|
||||
// Takes signed blocks an operator has sent out of band (a re-signed pairing
|
||||
// payload, a new apikey, a moved onion) and applies them to the store, doing
|
||||
// exactly what the submission gate would have done had they gone through the
|
||||
// site:
|
||||
//
|
||||
// 1. the block must parse, and its signature must be valid over its own text;
|
||||
// 2. the BIP47 code inside the signed text must derive the signing address;
|
||||
// 3. that code must already own a record here, which is how the update is
|
||||
// matched to a listing;
|
||||
// 4. the payload written is the one INSIDE the signed block, so what is
|
||||
// published is exactly what the operator attested to.
|
||||
//
|
||||
// The record's id is never changed, so its reliability history survives. Status
|
||||
// is left alone: an approved listing stays approved, a pending one stays pending.
|
||||
//
|
||||
// Usage, on the box:
|
||||
// cd /var/www/dojobay/server
|
||||
// node apply-signed-payload.ts blocks/*.txt # dry run
|
||||
// sudo systemctl stop dojobay-server.service
|
||||
// node apply-signed-payload.ts --apply blocks/*.txt
|
||||
// sudo systemctl start dojobay-server.service
|
||||
// node audit-signed.mjs
|
||||
//
|
||||
// Each file holds one signed block. `--id <record-id>` pins the target when a
|
||||
// payment code owns more than one listing. As with fix-payload-version, --apply
|
||||
// refuses to run while the service is up, because store.ts holds the store in
|
||||
// memory as a single writer and would overwrite the edit.
|
||||
// =============================================================================
|
||||
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";
|
||||
// canonicalPairing is imported, never reimplemented: this tool must accept
|
||||
// exactly what the submission gate accepts, and a second definition of the
|
||||
// canonical message would diverge silently. server/selftest.mjs enforces it.
|
||||
import { parseSignedBlock, verifySignedPayload, notificationAddresses, repairSignedBlock, canonicalPairing } from "./crypto.ts";
|
||||
import type { StoreRecord } from "../types.js";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const APPLY = argv.includes("--apply");
|
||||
const FORCE = argv.includes("--force");
|
||||
const idFlag = argv.indexOf("--id");
|
||||
const PINNED_ID = idFlag >= 0 ? argv[idFlag + 1] : null;
|
||||
const FILES = argv.filter((a, i) =>
|
||||
!a.startsWith("--") && !(idFlag >= 0 && i === idFlag + 1));
|
||||
|
||||
const DIR = process.env.SERVER_DATA_DIR
|
||||
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
|
||||
const FILE = path.join(DIR, "store.json");
|
||||
|
||||
if (!FILES.length) {
|
||||
console.error("Usage: node apply-signed-payload.ts [--apply] [--id <record-id>] <file>…\n" +
|
||||
"Each file contains one BEGIN BITCOIN SIGNED MESSAGE block.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (APPLY && !FORCE) {
|
||||
let active = "";
|
||||
try { active = execFileSync("systemctl", ["is-active", "dojobay-server.service"], { encoding: "utf8" }).trim(); }
|
||||
catch (e: any) { 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 apply-signed-payload.ts --apply <files…>\n" +
|
||||
" sudo systemctl start dojobay-server.service");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const doc = JSON.parse(await readFile(FILE, "utf8"));
|
||||
const records: StoreRecord[] = Object.values(doc.submissions || {});
|
||||
|
||||
interface Planned { file: string; rec: StoreRecord; payload: any; signed: string; before: string; after: string; note: string | null }
|
||||
const planned: Planned[] = [];
|
||||
const refused: [string, string][] = [];
|
||||
|
||||
for (const file of FILES) {
|
||||
let signed: string;
|
||||
try { signed = await readFile(file, "utf8"); }
|
||||
catch (e: any) { refused.push([file, "cannot read: " + e.message]); continue; }
|
||||
|
||||
// Copying a block through chat, a form or a mail client routinely eats the
|
||||
// blank line before the BIP47 line, which the signature covers. Repair it if
|
||||
// a reconstruction verifies cryptographically; nothing is taken on trust.
|
||||
let note: string | null = null;
|
||||
const repaired = repairSignedBlock(signed);
|
||||
if (repaired) { signed = repaired.block; note = repaired.note; }
|
||||
|
||||
const parsed = parseSignedBlock(signed);
|
||||
if (!parsed) { refused.push([file, "not a recognisable signed block"]); continue; }
|
||||
if (!parsed.paymentCode) { refused.push([file, "the signed text has no BIP47 line, so it cannot be matched to an operator"]); continue; }
|
||||
|
||||
// The payload published is the one inside the signed block, never a
|
||||
// hand-copied version of it.
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(parsed.pairingText); }
|
||||
catch { refused.push([file, "the signed text is not a bare pairing JSON"]); continue; }
|
||||
if (!payload?.pairing?.url || !payload?.pairing?.type) {
|
||||
refused.push([file, "the signed payload has no pairing.url/type"]); continue;
|
||||
}
|
||||
|
||||
const addrs = notificationAddresses(parsed.paymentCode);
|
||||
const v = verifySignedPayload({
|
||||
signedText: signed,
|
||||
expectedMessage: canonicalPairing(payload),
|
||||
expectedAddress: addrs,
|
||||
});
|
||||
if (!v.ok) { refused.push([file, v.error]); continue; }
|
||||
|
||||
const owned = records.filter((r) => (r.paymentCodes || []).includes(parsed.paymentCode!));
|
||||
const target = PINNED_ID ? owned.find((r) => r.id === PINNED_ID) : (owned.length === 1 ? owned[0] : undefined);
|
||||
if (!owned.length) {
|
||||
refused.push([file, `signature is valid, but ${parsed.paymentCode.slice(0, 12)}… owns no record here`]); continue;
|
||||
}
|
||||
if (!target) {
|
||||
refused.push([file, `that code owns ${owned.length} records (${owned.map((r) => r.id).join(", ")}); re-run with --id`]); continue;
|
||||
}
|
||||
|
||||
planned.push({
|
||||
file, rec: target, payload, signed: signed.trim(), note,
|
||||
before: target.payload?.pairing?.url || "(none)",
|
||||
after: payload.pairing.url,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Store: ${FILE}`);
|
||||
console.log(`Blocks read: ${FILES.length}\n`);
|
||||
|
||||
if (planned.length) {
|
||||
console.log(`Will update (${planned.length}):`);
|
||||
for (const p of planned) {
|
||||
console.log(` ${p.rec.id} (${p.rec.status}) from ${path.basename(p.file)}`);
|
||||
console.log(` url ${p.before}`);
|
||||
console.log(` -> ${p.after}`);
|
||||
const bv = p.rec.payload?.pairing?.version, av = p.payload.pairing.version;
|
||||
if (bv !== av) console.log(` version ${bv || "(none)"} -> ${av || "(none)"}`);
|
||||
if (!p.rec.signed) console.log(" (record was UNSIGNED; it gains a verified signature)");
|
||||
if (p.note) console.log(` note: ${p.note}, and the repaired block verifies`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
if (refused.length) {
|
||||
console.log(`Refused (${refused.length}):`);
|
||||
for (const [f, why] of refused) console.log(` ${path.basename(f)}: ${why}`);
|
||||
console.log("");
|
||||
}
|
||||
if (!planned.length) { console.log("Nothing to apply."); process.exit(refused.length ? 1 : 0); }
|
||||
|
||||
if (!APPLY) {
|
||||
console.log("DRY RUN — nothing written. Re-run with --apply (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);
|
||||
const nowIso = new Date().toISOString();
|
||||
for (const p of planned) {
|
||||
const rec = doc.submissions[p.rec.id];
|
||||
rec.payload = p.payload; // exactly what was signed
|
||||
rec.signed = p.signed;
|
||||
rec.updated_at = nowIso;
|
||||
}
|
||||
// A temporary name no other writer can take; see build-public.ts. This tool
|
||||
// refuses to run while the service holds the store, so a collision needs two
|
||||
// maintenance tools at once, which is exactly the case nobody plans for.
|
||||
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} update(s).`);
|
||||
console.log("Start the service again, then run audit-signed.mjs; each updated record\n" +
|
||||
"should now read VERIFIED. The published dojos.json follows on the next\n" +
|
||||
"updater cycle, or immediately if you run build-public.mjs.");
|
||||
process.exit(refused.length ? 1 : 0);
|
||||
Reference in New Issue
Block a user