162 lines
8.0 KiB
JavaScript
162 lines
8.0 KiB
JavaScript
#!/usr/bin/env node
|
|
// Move seed nodes into the operator-managed store, idempotently.
|
|
//
|
|
// node scripts/migrate-seed-to-store.mjs --dry-run print the plan, write nothing
|
|
// node scripts/migrate-seed-to-store.mjs apply it
|
|
//
|
|
// The seed's role is the instance ANCHOR: exactly one node, the instance
|
|
// operator's own Dojo (mainnet or testnet), carrying their PayNym and BIP47
|
|
// payment code. Everything else belongs in the store, where operators manage
|
|
// their listings over Auth47. This script is the transition tool for an
|
|
// instance whose seed still carries an old-style curated list:
|
|
//
|
|
// - a seed node with a PayNym present in data/paynym-codes.json becomes an
|
|
// APPROVED store record owned by every BIP47 code variant of that PayNym
|
|
// - a seed node WITHOUT a PayNym is REFUSED. Every listing must carry a BIP47
|
|
// payment code: it is the identity a listing is owned, edited, verified and
|
|
// recognised by. Code-less records were once adopted as admin-managed
|
|
// exceptions; that door is closed, and the store refuses to write one.
|
|
// - a seed node whose id already exists in the store is SKIPPED untouched,
|
|
// which is what makes re-runs no-ops and lets the anchor node coexist as
|
|
// both seed entry (bootstrap guarantee) and store record (Auth47-managed:
|
|
// the store record shadows the seed copy in the public list)
|
|
//
|
|
// The script never rewrites data/seed.json: slimming the seed down to the
|
|
// anchor is a deliberate, separate commit made AFTER the store records exist,
|
|
// because a deploy that removes a node's seed entry before its store record
|
|
// exists delists it (the history survives under the fourteen-day grace stamp,
|
|
// but there is no reason to invite the gap).
|
|
//
|
|
// Record ids are the original seed ids, so reliability history (keyed by id)
|
|
// carries over untouched. Afterwards run `node server/build-public.mjs`.
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { store, hasSignedBlock } from "../server/store.ts";
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data");
|
|
const SEED_PATH = path.join(DATA_DIR, "seed.json");
|
|
const CODES_PATH = path.join(DATA_DIR, "paynym-codes.json");
|
|
const DRY = process.argv.includes("--dry-run");
|
|
|
|
async function readJSON(p, fallback) {
|
|
try { return JSON.parse(await readFile(p, "utf8")); }
|
|
catch (e) { if (fallback !== undefined) return fallback; throw e; }
|
|
}
|
|
|
|
const slugOf = (v) => String(v || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
|
|
// Name derivation for owned groups. Remainder = seed id minus `${network}-`.
|
|
// When one owner's several nodes share a first hyphen-token and stripping it
|
|
// leaves something for each, drop the shared token; and prefer the seed's
|
|
// display name whenever it slugs to the derived value, so capitalisation like
|
|
// "wanderinKing072" survives.
|
|
function deriveNames(nodes) {
|
|
const rem = nodes.map((n) => n.id.replace(new RegExp(`^${n.network}-`), ""));
|
|
let names = rem;
|
|
if (nodes.length > 1) {
|
|
const first = rem.map((r) => r.split("-")[0]);
|
|
if (first.every((t) => t === first[0]) && rem.every((r) => r.includes("-"))) {
|
|
names = rem.map((r) => r.split("-").slice(1).join("-"));
|
|
}
|
|
}
|
|
return nodes.map((n, i) => (n.name && slugOf(n.name) === names[i]) ? n.name : names[i]);
|
|
}
|
|
|
|
function toRecord(n, name, codes, now) {
|
|
return {
|
|
id: n.id, network: n.network, name,
|
|
paymentCodes: codes,
|
|
paynym: n.paynym || null,
|
|
jurisdiction: n.jurisdiction || null,
|
|
country: n.country || null,
|
|
hardware: n.hardware || null,
|
|
payload: n.payload,
|
|
signed: n.signed || null,
|
|
status: "approved",
|
|
source: "seed-migration",
|
|
created_at: now, updated_at: now,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const seed = await readJSON(SEED_PATH);
|
|
const mapping = (await readJSON(CODES_PATH, { mapping: {} })).mapping || {};
|
|
const existing = await store.listSubmissions();
|
|
const nodes = seed.nodes || [];
|
|
|
|
const owned = nodes.filter((n) => n.paynym);
|
|
const missing = owned.filter((n) => !mapping[n.paynym]);
|
|
if (missing.length) {
|
|
console.error("aborting: no payment codes in", path.relative(ROOT, CODES_PATH), "for:");
|
|
for (const n of missing) console.error(" ", n.id, n.paynym);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Derive names per owner; code-less nodes keep their seed name (or the id
|
|
// remainder). Then refuse any per-network name collision against the plan
|
|
// itself or records already in the store under a DIFFERENT id.
|
|
const byOwner = new Map();
|
|
for (const n of owned) (byOwner.get(n.paynym) || byOwner.set(n.paynym, []).get(n.paynym)).push(n);
|
|
const nameOf = new Map();
|
|
for (const group of byOwner.values()) deriveNames(group).forEach((nm, i) => nameOf.set(group[i].id, nm));
|
|
for (const n of nodes.filter((x) => !x.paynym)) {
|
|
const rem = n.id.replace(new RegExp(`^${n.network}-`), "");
|
|
nameOf.set(n.id, (n.name && slugOf(n.name) === rem) ? n.name : (n.name || rem));
|
|
}
|
|
const seen = new Set();
|
|
for (const n of nodes) {
|
|
const key = `${n.network}:${slugOf(nameOf.get(n.id))}`;
|
|
if (seen.has(key)) { console.error("aborting: duplicate node name per network:", key); process.exit(1); }
|
|
seen.add(key);
|
|
}
|
|
for (const r of existing) {
|
|
for (const n of nodes) {
|
|
if (r.id !== n.id && r.network === n.network && slugOf(r.name) === slugOf(nameOf.get(n.id))) {
|
|
console.error(`aborting: seed node ${n.id} clashes with store record ${r.id} on name "${r.name}"`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const byId = new Map(existing.map((r) => [r.id, r]));
|
|
const plan = nodes.map((n) => {
|
|
if (byId.has(n.id)) return { action: "skip", why: "already in store (left untouched)", node: byId.get(n.id) };
|
|
const codes = n.paynym ? mapping[n.paynym].codes.map((c) => c.code) : [];
|
|
// Two things make a node unmigratable, and both are the store's rules
|
|
// rather than this script's: no payment code means no owner, and no signed
|
|
// pairing block means nothing a visitor can check. Refusing here rather
|
|
// than letting putSubmission throw is what turns a stack trace part-way
|
|
// through a migration into a plan you can read before anything is written.
|
|
const node = toRecord(n, nameOf.get(n.id), codes, now);
|
|
if (!codes.length) return { action: "refuse", why: "no BIP47 payment code", node };
|
|
if (!hasSignedBlock(node)) return { action: "refuse", why: "no signed pairing block", node };
|
|
return { action: "create", node };
|
|
});
|
|
|
|
console.log(`${DRY ? "DRY RUN — " : ""}migration plan (${nodes.length} seed nodes):`);
|
|
for (const { action, why, node } of plan) {
|
|
const owner = node.paynym || "(no PayNym)";
|
|
console.log(` ${action.padEnd(6)} ${node.id.padEnd(26)} name=${String(node.name).padEnd(18)} ${owner} (${(node.paymentCodes || []).length} codes)${why ? " — " + why : ""}`);
|
|
if (action === "refuse") {
|
|
console.log(` REFUSED: ${node.id} ${why}, so it cannot be migrated.`);
|
|
console.log(` Give it a PayNym in data/paynym-codes.json and a signed pairing block, or drop it from the seed.`);
|
|
}
|
|
}
|
|
|
|
const changes = plan.filter((p) => p.action === "create");
|
|
const refused = plan.filter((p) => p.action === "refuse");
|
|
const tail = refused.length ? ` ${refused.length} refused: ${refused.map((p) => p.node.id).join(", ")}.` : "";
|
|
if (DRY) { console.log(`\ndry run: ${changes.length} change(s) would be made, nothing written.${tail}`); return; }
|
|
if (!changes.length) { console.log(`\nnothing to do: every seed node already has a store record.${tail}`); return; }
|
|
for (const { node } of changes) await store.putSubmission(node);
|
|
console.log(`\napplied ${changes.length} change(s).${tail} Now run: node server/build-public.mjs`);
|
|
console.log("Once the store records exist, slim data/seed.json to the anchor (your own node) in a separate commit.");
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
|
main().catch((e) => { console.error("fatal:", e.message); process.exit(1); });
|
|
}
|