feat(dojobay): add DojoBay app (manifest, image, catalog, ports)

This commit is contained in:
2026-09-11 02:11:42 +00:00
parent db52c06a72
commit 2d41b082d7
61 changed files with 13360 additions and 3 deletions
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
// Maintainer moderation CLI (run on the server by a maintainer over SSH).
// node admin.mjs list show pending/approved/rejected
// node admin.mjs approve <id> [paynym] approve a submission (optionally set its PayNym)
// node admin.mjs reject <id> reject a submission
// node admin.mjs remove <id> delete a submission outright
// After approving/rejecting, run build-public.mjs to regenerate the public list.
import { store } from "./store.ts";
import { resolvePayNym } from "./paynym.mjs";
const [cmd, id, extra] = process.argv.slice(2);
function line(r) {
return `${r.status.padEnd(8)} ${r.id.padEnd(26)} ${r.network.padEnd(7)} ${(r.paynym || "-").padEnd(18)} ${(r.name || "-").padEnd(18)} ${r.payload?.pairing?.url || ""}`;
}
const cmds = {
async list() {
const subs = await store.listSubmissions();
if (!subs.length) return console.log("(no submissions)");
for (const r of subs.sort((a, b) => (a.status).localeCompare(b.status))) console.log(line(r));
},
async approve() {
const r = await store.getSubmission(id);
if (!r) return console.error("no such submission:", id);
r.status = "approved";
if (extra) {
r.paynym = extra.startsWith("+") ? extra : "+" + extra; // maintainer override
} else if (!r.paynym) {
const resolved = await resolvePayNym((r.paymentCodes || [])[0]).catch(() => null);
if (resolved) r.paynym = resolved;
}
r.updated_at = new Date().toISOString();
await store.putSubmission(r);
console.log("approved:", id, "paynym:", r.paynym || "(none set — pass one as the 3rd arg)");
console.log("now run: node build-public.mjs");
},
async reject() {
const r = await store.getSubmission(id);
if (!r) return console.error("no such submission:", id);
r.status = "rejected"; r.updated_at = new Date().toISOString();
await store.putSubmission(r);
console.log("rejected:", id, "(run build-public.mjs to drop it from the public list)");
},
async remove() {
await store.deleteSubmission(id);
console.log("removed:", id);
},
};
(cmds[cmd] || (async () => { console.log("usage: node admin.mjs [list|approve <id> [paynym]|reject <id>|remove <id>]"); }))()
.then(() => process.exit(0))
.catch((e) => { console.error("error:", e.message); process.exit(1); });
@@ -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);
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
// =============================================================================
// The Dojo Bay — audit stored signed pairing blocks.
//
// READ-ONLY. Walks every record in the submission store and re-checks its
// stored `signed` block with exactly the gate the submit endpoint uses
// (verifySignedPayload over canonicalPairing(payload), against the notification
// address of the record's own payment code). Nothing is written, no network is
// touched, and the store is only ever read.
//
// Why this exists: records approved before the signed-message parser was fixed
// were checked by a parser that excised the BIP47 tail before verifying, so the
// verdict they received then is not the verdict they would receive now. This
// tells you whether anything was left behind.
//
// Run on the box as the deploy user:
// cd /var/www/dojobay/server && node audit-signed.mjs
// SERVER_DATA_DIR defaults to ./data, the same path the server uses; set it
// only if your store lives elsewhere.
//
// Buckets:
// VERIFIED the stored signature is valid for one of the record's codes
// FAILED a signature is present but verifies for none of them
// UNSIGNED no signature stored (pre-gate migration, or a code-less record)
// ERROR the record could not be evaluated at all
// Exits non-zero if anything is FAILED, ERROR or UNSIGNED, so it can back a
// cron check. UNSIGNED counted as a failure since the signature became a
// structural requirement: the store refuses to write such a record and the
// rebuild withholds it, so one showing up here is not awaiting a decision.
// =============================================================================
import { store } from "./store.ts";
import { verifySignedPayload, notificationAddresses, canonicalPairing } from "./crypto.ts";
const networkOf = (rec) => (rec.network === "testnet" ? "testnet" : "bitcoin");
// Exported so the test suite can assert this reproduces the gate's verdict.
// This MUST mirror server/index.mjs's signature gate exactly: same canonical
// message, and the same set of acceptable signing addresses. An earlier version
// derived the notification address for the record's own network, which meant
// every testnet listing was reported as failing even though the gate accepted
// it, because a PayNym signs from its mainnet address whatever the node is.
export function auditRecord(rec) {
const codes = Array.isArray(rec.paymentCodes) ? rec.paymentCodes : [];
if (!rec.signed) {
return { bucket: "UNSIGNED", detail: codes.length ? "record has a payment code but no signed block" : "no signed block and no payment code" };
}
const net = networkOf(rec);
const expectedMessage = canonicalPairing(rec.payload);
const tried = [];
// A PayNym may have signed with either BIP47 variant, so every code on the
// record is a legitimate candidate; the first that verifies wins.
for (const code of codes) {
const addrs = notificationAddresses(code);
if (!addrs.length) { tried.push(`${code.slice(0, 12)}…: undecodable code`); continue; }
const r = verifySignedPayload({ signedText: rec.signed, expectedMessage, expectedAddress: addrs, network: net });
if (r.ok) return { bucket: "VERIFIED", detail: `${code.slice(0, 12)}… → ${addrs[0]}` };
tried.push(`${code.slice(0, 12)}… (${addrs.join(" / ")}): ${r.error}`);
}
if (!codes.length) {
const r = verifySignedPayload({ signedText: rec.signed, expectedMessage, network: net });
return r.ok
? { bucket: "FAILED", detail: "signature is internally valid but the record carries no payment code to bind it to" }
: { bucket: "FAILED", detail: r.error };
}
return { bucket: "FAILED", detail: tried.join("\n ") };
}
// ---- CLI ---------------------------------------------------------------
// Only runs when executed directly, so tests can import auditRecord.
const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
if (!isMain) { /* imported for testing */ } else {
const recs = (await store.listSubmissions())
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
const buckets = { VERIFIED: [], FAILED: [], UNSIGNED: [], ERROR: [] };
for (const rec of recs) {
let res;
try { res = auditRecord(rec); } catch (e) { res = { bucket: "ERROR", detail: e.message }; }
buckets[res.bucket].push({ rec, detail: res.detail });
}
console.log(`Audited ${recs.length} record(s) in the store.\n`);
for (const b of ["FAILED", "ERROR", "UNSIGNED", "VERIFIED"]) {
if (!buckets[b].length) continue;
console.log(`${b}: ${buckets[b].length}`);
for (const { rec, detail } of buckets[b]) {
// Show the name as well as the id. Ids are immutable (reliability history
// keys on them), so a record created before operator naming keeps its
// payment-code-derived id even after its operator sets a name, and the id
// alone is then unrecognisable.
const label = rec.name && `${rec.network}-${rec.name}` !== rec.id ? `${rec.id} ("${rec.name}")` : rec.id;
console.log(` [${b}] ${label} (${rec.status})${detail ? "\n " + detail : ""}`);
}
console.log("");
}
// An UNSIGNED record is now a failure, not a decision. Until the signature rule
// existed there was a legitimate answer to "this record predates the gate" and
// the audit deliberately left the judgement to a maintainer. The store now
// refuses to write such a record and the rebuild withholds it, so one appearing
// here means something got in around those rules or predates them, and either
// way it is not being published and needs dealing with.
const bad = buckets.FAILED.length + buckets.ERROR.length + buckets.UNSIGNED.length;
console.log(
`Summary: ${buckets.VERIFIED.length} verified, ${buckets.FAILED.length} failed, ` +
`${buckets.UNSIGNED.length} unsigned, ${buckets.ERROR.length} error.` +
(buckets.UNSIGNED.length ? "\nUNSIGNED records are withheld from the public list. Ask the operator to sign their\npairing payload and resubmit, or remove the listing with server/remove-listing.ts." : "") +
(bad ? `\nNON-ZERO EXIT: ${bad} record(s) need attention.` : "\nEvery record carries a signature and every signature verifies under the current gate."));
process.exit(bad ? 1 : 0);
}
+39
View File
@@ -0,0 +1,39 @@
// Launcher for the public-list rebuild, which lives in build-public.ts.
//
// Kept as plain JavaScript, and kept under this name, for the same reasons as
// index.mjs:
//
// 1. It parses on any Node, so an operator on an older runtime gets the
// message below rather than a syntax error from a file their Node cannot
// execute. The check must precede the import, hence the dynamic import.
// 2. A lot of things outside this file invoke it by name: the deploy workflow,
// `npm run build-public`, scripts/install.mjs, and — importantly —
// scripts/apply-update.mjs, which spawns it during a self-update. That
// helper is the OLD copy still running while new files are swapped in, so
// an instance updating ACROSS a rename would spawn a file that no longer
// exists and its rebuild would fail.
//
// New in-process callers should import ./build-public.ts directly.
const major = Number(process.versions.node.split(".")[0]);
if (Number.isNaN(major) || major < 24) {
console.error(
`The Dojo Bay rebuild needs Node 24 or newer (found ${process.versions.node}).\n` +
"It runs TypeScript directly, which relies on type stripping added in Node 24.\n" +
"Upgrade Node, then re-run the rebuild.");
process.exit(1);
}
const mod = await import("./build-public.ts");
export const rebuild = mod.rebuild;
export const displayPaymentCode = mod.displayPaymentCode;
export const effectiveVersion = mod.effectiveVersion;
export const effectiveIndexer = mod.effectiveIndexer;
export const retireUnlisted = mod.retireUnlisted;
// Run the rebuild when invoked directly (the .ts module's own check does not
// fire in that case, because argv[1] is this launcher).
import { pathToFileURL } from "node:url";
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
const r = await mod.rebuild();
console.log(r.msg);
}
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env node
// Merge the curated seed list with APPROVED self-service submissions into the
// public data/dojos.json that the front-end and the 10-minute updater consume.
// The seed list (data/seed.json) stays under maintainer control; only approved
// submissions are added. A newly-approved node inherits the status, block
// height and reliability history the updater already recorded for it while it
// was pending (see scripts/update.mjs and server/data/pending-probe.json), so
// it appears active with its uptime intact the moment it is published.
//
// Exposes rebuild() for in-process use by the admin API; runs it when invoked
// directly from the CLI.
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { pathToFileURL } from "node:url";
import { store, hasSignedBlock } from "./store.ts";
import { urlOnDomain } from "./domains.ts";
import type { PublicNode, PairingPayload, StoreRecord } from "../types.js";
/** The generated data/dojos.json. */
interface PublicDoc {
generated_at?: string;
interval_minutes?: number;
nodes: PublicNode[];
}
/** A history file: per-node check lists or daily rollups, keyed by record id. */
type HistoryMap = Record<string, any>;
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
async function readJSON<T>(p: string, fallback: T): Promise<T> {
try { return JSON.parse(await readFile(p, "utf8")); }
catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return fallback; throw e; }
}
// A temporary name no other writer can take. `<file>.tmp` is not atomic
// between processes: two writers produce the same path, the first rename
// consumes it, and the second fails with ENOENT on a file it had just written.
// See scripts/update.mjs for the install that did exactly that.
let tmpSeq = 0;
async function writeAtomic(p, obj) {
await mkdir(path.dirname(p), { recursive: true });
const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
await writeFile(tmp, JSON.stringify(obj, null, 2) + "\n");
await rename(tmp, p);
}
// The payment code shown on a card. A PayNym commonly has two BIP47 variants
// and records store every variant; the canonical one people share (and the one
// shown on paynym.rs profiles) is the NON-segwit code, so prefer that when the
// paynym-codes mapping can identify it, falling back to the record's first.
// Exported for the self-test.
/** Only the two fields it actually reads, so callers need not build a whole
* record to ask which variant to display. */
type CodeBearing = { paymentCodes?: string[] | null; paynym?: string | null };
export function displayPaymentCode(sub: CodeBearing, mapping: any): string | null {
const codes = Array.isArray(sub.paymentCodes) ? sub.paymentCodes : [];
if (!codes.length) return null;
const entry = sub.paynym && mapping && mapping[sub.paynym];
const legacy = entry && (entry.codes || []).find((c) => !c.segwit && codes.includes(c.code));
return (legacy && legacy.code) || codes[0];
}
// The version shown on a card is derived entirely from the node's API, never
// set by an operator. In priority order:
// 1. the version the updater last read live from the node's X-Dojo-Version
// response header (detected_version, carried in dojos.json),
// 2. the version in the pairing payload, used only as a bootstrap fallback
// until the first probe reads a live header (and for older nodes that do
// not emit the header). It is itself an API value, captured from the
// Dojo's pairing output at submission time.
// There is deliberately no operator override: the version always reflects what
// the node reports. To show nothing until a live header is read, drop the
// pairing fallback.
export function effectiveVersion(detected: string | null | undefined, pairing: string | null | undefined): string | null {
return detected || pairing || null;
}
// The Electrum endpoint shown on a card. Only what the node reported about
// itself: the updater reads it from the Dojo's /support/services each cycle,
// over the API onion the operator's signature fixes.
//
// A URL declared in a submitted payload is NOT a fallback and must not become
// one: nothing signs it, and a node that is healthy but exposes no indexer
// never acquires a detected value, so a declared URL would be published for
// good. docs/decisions.md, entry 00d07ae, has the reasoning.
//
// Null means the card shows N/A, which is a real answer (no exposed indexer)
// rather than an omission, and is now reachable for every node.
export function effectiveIndexer(detected: string | null | undefined): string | null {
return detected || null;
}
// Every key the published dojos.json may contain for a node. Exported so the
// suite can assert on it rather than restating it, and so that adding a field
// to toPublicNode without adding it here fails the gate: publishing a new field
// should be a decision somebody makes, not a consequence of editing a record
// shape somewhere else.
export const PUBLIC_NODE_KEYS = Object.freeze([
"id", "network", "name", "status", "paynym", "paymentCode",
"jurisdiction", "country", "hardware", "version", "detected_version",
"detected_indexer", "operator_domain", "operator_domain_proof",
"block_height", "indexer_url", "checked_at", "payload", "signed",
]);
// The allowlist itself, and the only producer of a published node.
//
// It names every field rather than deleting the ones it does not want, which is
// the distinction that matters: a redaction list is wrong by default and has to
// be updated whenever the store gains a field, whereas this is right by default
// and has to be updated whenever the PUBLIC shape should change. The store
// holds things that must never be published (moderation status, the owning
// payment codes, submission timestamps, the probe result recorded at
// submission, import provenance) and it will hold more in future.
//
// One field is copied wholesale rather than picked apart: `payload`. That is
// deliberate, since the pairing payload including its API key is the entire
// point of a listing and a visitor needs it byte for byte to pair. It does mean
// the allowlist has a nested edge: anything added inside payload is published.
// The store gate is what keeps that honest, since payload is what the operator
// signed and the signature covers its exact contents.
function toPublicNode(sub: StoreRecord, paymentCode: string | null): PublicNode {
return {
id: sub.id,
network: sub.network,
name: sub.name || sub.paynym || sub.id,
status: "inactive",
paynym: sub.paynym || null,
paymentCode: paymentCode || null,
jurisdiction: sub.jurisdiction || null,
country: sub.country || null,
hardware: sub.hardware || null,
// Initial version is the pairing-payload fallback; rebuild() recomputes it
// via effectiveVersion once the live-detected value is known.
version: sub.payload?.pairing?.version || null,
detected_version: null,
detected_indexer: null,
operator_domain: null,
operator_domain_proof: null,
block_height: null,
indexer_url: null,
checked_at: null,
payload: sub.payload,
signed: sub.signed || null,
};
}
// Grace-period retirement for history entries. Deleting history the instant an
// id leaves the node list turned a transient list mistake into permanent data
// loss (the seed-migration deploy wiped every migrated node's history seconds
// after rsync, via the post-deploy rebuild, before the migration could run on
// the box). Instead: an unlisted id is STAMPED `retired` and kept; it is only
// deleted after HISTORY_GRACE_DAYS (default 14); if the id is listed again
// within the window, the stamp is cleared and its history resumes untouched.
// Exported because scripts/update.mjs rewrites the same two files every cycle
// and must apply identical rules.
export function retireUnlisted(nodesMap: HistoryMap, isListed: (id: string) => boolean,
nowIso: string, graceDays: number = Number(process.env.HISTORY_GRACE_DAYS || 14)): boolean {
let touched = false;
const cutoffMs = Date.parse(nowIso) - graceDays * 86400000;
for (const id of Object.keys(nodesMap)) {
const entry = nodesMap[id];
if (isListed(id)) {
if (entry.retired) { delete entry.retired; touched = true; }
} else if (!entry.retired) {
entry.retired = nowIso; touched = true;
} else if (Date.parse(entry.retired) < cutoffMs) {
delete nodesMap[id]; touched = true;
}
}
return touched;
}
export async function rebuild(): Promise<{ nodes: number; approved: number; msg: string }> {
const DATA_DIR = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data");
const SERVER_DATA = process.env.SERVER_DATA_DIR || path.join(ROOT, "server", "data");
const SEED = path.join(DATA_DIR, "seed.json");
const OUT = path.join(DATA_DIR, "dojos.json");
const HIST = path.join(DATA_DIR, "history.json");
const DAILY = path.join(DATA_DIR, "history-daily.json");
const PENDING_PROBE = path.join(SERVER_DATA, "pending-probe.json");
const seed = await readJSON(SEED, { nodes: [] });
// Optional: identifies each PayNym's non-segwit code variant for display.
const codesDoc = await readJSON(path.join(DATA_DIR, "paynym-codes.json"), { mapping: {} });
// The operator binding is REQUIRED: an instance must prove who runs it.
// Warn (unmissably) rather than fail, so a malformed signature nags the
// operator without taking the directory down for its visitors. The crypto
// import is lazy so the dependency-free scripts/ chain can still import
// this module on a box where server/node_modules is not installed yet.
try {
const opDoc = await readJSON(path.join(DATA_DIR, "operator.json"), null);
if (!opDoc) {
console.error("[rebuild] REQUIRED: data/operator.json is missing. Sign your onion URL with your wallet and install the binding (the installer does this); see README.");
} else {
try {
const { verifyOperatorDoc } = await import("./crypto.ts");
const v = verifyOperatorDoc(opDoc);
if (!v.ok) console.error(`[rebuild] REQUIRED: data/operator.json does not verify: ${v.error}`);
} catch { console.error("[rebuild] note: cannot verify operator.json (server dependencies not installed)."); }
}
} catch (e) { console.error(`[rebuild] operator.json check skipped: ${e.message}`); }
// Anchor-model checks (warnings, never fatal: a fresh instance mid-setup or
// mid-transition should build, just noisily). The seed should hold exactly
// one node -- the instance operator's own, carrying their payment code --
// and every listed node should carry a BIP47 code; code-less records are
// grandfathered exceptions managed from /admin.
if ((seed.nodes || []).length !== 1) {
console.error(`[rebuild] note: seed carries ${(seed.nodes || []).length} node(s); the anchor model expects exactly one (the instance operator's own node).`);
} else if (!seed.nodes[0].paymentCode) {
console.error(`[rebuild] REFUSING to publish the anchor seed node ${seed.nodes[0].id}: it has no BIP47 payment code.`);
}
// A record with no payment code and no signed pairing block is not published.
// The store refuses to write either, so this only fires for something that
// predates those rules or was edited by hand — and in that case it is
// withheld rather than shown, because a listing nobody can be held to, or
// whose details nobody has attested to, is exactly what this directory must
// not carry. Withheld, not deleted: the record stays for a maintainer to look
// at. The two are reported separately because the remedies differ: a missing
// code cannot be supplied by anyone but the operator, while a missing
// signature usually means asking them to sign what they already gave us.
const allApproved = (await store.listSubmissions()).filter((s) => s.status === "approved");
const codeless = allApproved.filter((s) => !(s.paymentCodes || []).length);
if (codeless.length) {
console.error(`[rebuild] REFUSING to publish ${codeless.length} listing(s) with no BIP47 payment code: ${codeless.map((s) => s.id).join(", ")}. A listing must carry a payment code; remove it with server/remove-listing.ts, or give it one.`);
}
const unsigned = allApproved.filter((s) => (s.paymentCodes || []).length && !hasSignedBlock(s));
if (unsigned.length) {
console.error(`[rebuild] REFUSING to publish ${unsigned.length} listing(s) with no signed pairing block: ${unsigned.map((s) => s.id).join(", ")}. Ask the operator to sign their pairing payload and resubmit, or remove the listing with server/remove-listing.ts.`);
}
const approvedSubs = allApproved.filter((s) => (s.paymentCodes || []).length && hasSignedBlock(s));
const approved = approvedSubs.map((s) => toPublicNode(s, displayPaymentCode(s, codesDoc.mapping)));
const approvedIds = new Set(approved.map((n) => n.id));
const byId = new Map();
// The seed anchor is held to the same rules as any other listing.
const seedNodes = (seed.nodes || []).filter((n) => {
if (!n || !n.paymentCode) {
console.error(`[rebuild] withholding seed node ${n?.id}: no BIP47 payment code.`);
return false;
}
if (!hasSignedBlock(n)) {
console.error(`[rebuild] withholding seed node ${n?.id}: no signed pairing block.`);
return false;
}
return true;
});
// Seed nodes go through the SAME allowlist as store records. They used to be
// published as they sit in data/seed.json, which meant the public file had two
// producers and only one of them filtered anything. Nothing has ever leaked
// that way, because seed.json is written by the installer and its fields
// happen to be a subset of what toPublicNode emits, but "happens to be a
// subset" is not a property anybody was maintaining: seed.json is
// instance-owned and documented as hand-editable, so a field added there went
// straight to the published file unread. One producer, one allowlist.
//
// The cast is safe because toPublicNode reads only fields a seed node has;
// the owning code is passed as an argument rather than read from the record,
// which is why a seed node's singular paymentCode needs no reshaping.
for (const n of seedNodes) byId.set(n.id, toPublicNode(n as unknown as StoreRecord, n.paymentCode || null));
for (const n of approved) byId.set(n.id, n);
const nodes = [...byId.values()];
// Per-id pairing version, the bootstrap fallback used until a live version is
// detected. The card version is never operator-set (see effectiveVersion).
const pairingById = new Map();
for (const n of seedNodes) pairingById.set(n.id, n.payload?.pairing?.version || null);
for (const s of approvedSubs) pairingById.set(s.id, s.payload?.pairing?.version || null);
// Owner payment codes per node, for the verified-domain lookup below. The seed
// anchor carries a single paymentCode; store records carry paymentCodes[].
const ownerCodesById = new Map();
for (const n of seedNodes) ownerCodesById.set(n.id, [n.paymentCode]);
for (const sub of approvedSubs) ownerCodesById.set(sub.id, sub.paymentCodes || []);
// Carry over the live status the updater last wrote, so a rebuild does not
// blank a node for a probe cycle.
const prior = await readJSON(OUT, { nodes: [] });
const priorById = new Map((prior.nodes || []).map((n) => [n.id, n]));
// Pending-probe results (updater-owned): seed a just-approved node's status
// and height from what was observed while it was pending.
const pending = await readJSON(PENDING_PROBE, { nodes: {} });
// Verified operator domains: published per node so the card can show the badge
// without another lookup, and used to filter the card-title link. A link that
// is not on the operator's verified domain is withheld rather than deleted, so
// an operator who verifies later gets their link back untouched.
const domainByCode = await store.verifiedDomainMap();
// The proof is published alongside the badge so a reader can check it with
// their own tools instead of taking our tick on trust: the TXT record proves
// the domain names the payment code, and the signed statement proves the code
// names the domain. Everything here is already public (the payment code is on
// the card, the domain is the claim), so publishing it discloses nothing new.
const claimByCode = new Map<string, { signed: string; verified_at: string | null }>();
for (const c of await store.listDomains()) {
if (c?.verified && c.domain) claimByCode.set(c.paymentCode, { signed: c.signed, verified_at: c.verified_at ?? null });
}
for (const n of nodes) {
const codes = ownerCodesById.get(n.id) || [];
const code = codes.find((c) => domainByCode.get(c)) || null;
const domain = code ? domainByCode.get(code) || null : null;
n.operator_domain = domain;
const claim = code ? claimByCode.get(code) : null;
n.operator_domain_proof = domain && claim ? {
domain,
paymentCode: code,
txt_name: `_dojobay.${domain}`,
txt_value: `dojobay-domain-v1 pm=${code}`,
signed: claim.signed,
verified_at: claim.verified_at,
} : null;
}
for (const n of nodes) {
const p = priorById.get(n.id);
const pr = (!p && approvedIds.has(n.id)) ? pending.nodes?.[n.id] : null;
if (p) {
n.status = p.status ?? n.status;
n.checked_at = p.checked_at ?? n.checked_at;
if (p.block_height != null) n.block_height = p.block_height;
} else if (pr) {
n.status = pr.status ?? n.status;
n.checked_at = pr.checked_at ?? n.checked_at;
if (pr.block_height != null) n.block_height = pr.block_height;
}
// Carry the live-detected version (prior snapshot, then a just-approved
// node's pending probe) and fold it into the effective card version. The
// updater writes detected_version each cycle; a rebuild must preserve it,
// exactly as it preserves status and block height.
const detected = (p && p.detected_version) || (pr && pr.detected_version) || null;
n.detected_version = detected;
n.version = effectiveVersion(detected, pairingById.get(n.id));
// Same treatment for the Electrum endpoint: carry what the updater read and
// publish it as indexer_url, which the card renders (N/A when null).
const detectedIdx = (p && p.detected_indexer) || (pr && pr.detected_indexer) || null;
n.detected_indexer = detectedIdx;
n.indexer_url = effectiveIndexer(detectedIdx);
}
await writeAtomic(OUT, {
generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
interval_minutes: 10,
nodes,
});
// Reliability history: ensure a bucket per node, seed a newly-approved node's
// history from its pending history, and retire (grace period) unlisted ids.
const hist = await readJSON(HIST, { interval_minutes: 10, window_checks: 144, nodes: {} });
let touched = false;
for (const n of nodes) {
if (!hist.nodes[n.id]) {
const seedChecks = (approvedIds.has(n.id) && pending.nodes?.[n.id]?.checks) || [];
hist.nodes[n.id] = { checks: seedChecks.slice() };
touched = true;
}
}
const nowIso = new Date().toISOString();
touched = retireUnlisted(hist.nodes, (id) => byId.has(id), nowIso) || touched;
if (touched) { (hist as any).generated_at = (hist as any).generated_at || null; await writeAtomic(HIST, hist); }
// 90-day daily rollup membership.
const dailyDoc = await readJSON(DAILY, { retention_days: 90, nodes: {} });
let dailyTouched = false;
for (const n of nodes) if (!dailyDoc.nodes[n.id]) {
dailyDoc.nodes[n.id] = { days: (approvedIds.has(n.id) && pending.nodes?.[n.id]?.days) ? pending.nodes[n.id].days.slice() : [] };
dailyTouched = true;
}
dailyTouched = retireUnlisted(dailyDoc.nodes, (id) => byId.has(id), nowIso) || dailyTouched;
if (dailyTouched) await writeAtomic(DAILY, dailyDoc);
const msg = `public list rebuilt: ${nodes.length} nodes (${approved.length} approved submissions).`;
return { nodes: nodes.length, approved: approved.length, msg };
}
// Run when invoked directly.
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
const r = await rebuild();
console.log(r.msg);
}
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env node
// =============================================================================
// The Dojo Bay — resource diagnostic.
//
// READ-ONLY. Measures what this instance actually uses, rather than guessing,
// so an operator can size a VPS from evidence and this project can document a
// requirement it has tested.
//
// What it looks at, and why each matters for THIS workload:
//
// memory the backend is a small long-running Node process; the updater is
// a second one every ten minutes; tor and nginx sit alongside.
// Peak matters more than current, because `npm ci` during a deploy
// and the unzip during a self-update are the two spikes.
// disk node_modules, the published data, and — the one that grows
// without limit — data/backups, a full copy of the code kept by
// every self-update.
// cpu idle almost always, with a burst each probe cycle: one Tor
// circuit per listed node, plus secp256k1 verification.
// strain swap in use, OOM kills and load average are the evidence that a
// box is actually too small, as opposed to merely modest.
//
// NO PATH FROM THE ENVIRONMENT REACHES A SUBPROCESS. WEB_ROOT and
// PUBLIC_DATA_DIR are operator-set, and this file used to hand them to `df` and
// `du`, which CodeQL flagged (js/shell-command-injection-from-environment) and
// which is a real if narrow bug: a value beginning with a hyphen is read by
// those tools as an option, not a path, so `WEB_ROOT=-x` silently measures
// something other than what was asked for. Both are now answered by Node
// itself, statfs() and a walk, which removes the class rather than escaping
// around it. The two subprocesses that remain (systemctl, journalctl) exist
// because nothing in Node can answer what they answer, and both take arguments
// written here. Keep it that way: see sh() below.
//
// Usage, on the box:
// cd /var/www/dojobay/server && node check-resources.ts
// =============================================================================
import { readFile, stat, readdir, lstat, statfs } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import os from "node:os";
import { fileURLToPath, pathToFileURL } from "node:url";
const exec = promisify(execFile);
const HERE = path.dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = process.env.WEB_ROOT || path.resolve(HERE, "..");
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(WEB_ROOT, "data");
const MB = 1024 * 1024;
const mb = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < MB) return (bytes / 1024).toFixed(0) + " KB";
return (bytes / MB).toFixed(bytes < 10 * MB ? 1 : 0) + " MB";
};
const gb = (bytes: number) => (bytes / (1024 * MB)).toFixed(1) + " GB";
const read = async (p: string) => { try { return await readFile(p, "utf8"); } catch { return null; } };
// Every call site passes a command and an argument list written in this file,
// never a path, a name or anything else derived from the environment. The one
// exception is UNITS below, which the suite checks directly. A future edit that
// interpolates a variable in here fails the gate rather than shipping.
const sh = async (cmd: string, args: string[]) => {
try { return (await exec(cmd, args)).stdout.trim(); } catch { return null; }
};
// The only values this file passes to a subprocess that are not written inline
// at the call site. They are exported so the suite can assert on the array
// itself rather than reading this source and guessing: an assertion about what
// a program does is worth more than one about how it is spelled.
export const UNITS = [
"dojobay-server.service",
"dojobay-update.service",
"tor.service",
"nginx.service",
];
// Replaces `df`. statfs reports the filesystem holding the path, and the
// arithmetic matches what df prints: used counts the blocks the filesystem
// considers occupied, while available excludes the root reserve, so used plus
// available is legitimately less than the total.
export const diskUsage = async (p: string) => {
try {
const fs = await statfs(p);
const block = Number(fs.bsize);
return {
size: Number(fs.blocks) * block,
used: (Number(fs.blocks) - Number(fs.bfree)) * block,
avail: Number(fs.bavail) * block,
};
} catch { return null; }
};
// Replaces `du -sb`: apparent size of a tree, symlinks counted but never
// followed, unreadable entries skipped rather than fatal, and directory inodes
// excluded, which is what `du -sb` does and is why this agrees with it to the
// byte on a real node_modules. Counting the directories instead would add 4 KB
// per directory of filesystem bookkeeping to a figure meant to describe
// content. One difference remains: du counts a hard-linked file once, this
// counts it once per link, which node_modules does not contain and which would
// overstate rather than hide. It also walks in JavaScript, so a populated
// node_modules takes a second or so rather than being instant, which is nothing
// for a diagnostic run by hand a few times a year.
export const dirSize = async (p: string): Promise<number | null> => {
const root = await lstat(p).catch(() => null);
if (!root) return null;
if (!root.isDirectory()) return root.size;
let total = 0;
const walk = async (dir: string) => {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
if (!entries) return;
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { await walk(full); continue; }
const s = await lstat(full).catch(() => null);
if (s) total += s.size;
}
};
await walk(p);
return total;
};
const report = async () => {
console.log("The Dojo Bay — what this instance actually uses\n");
// ---- the machine ----------------------------------------------------------
const meminfo = (await read("/proc/meminfo")) || "";
const kb = (key: string) => {
const m = meminfo.match(new RegExp("^" + key + ":\\s+(\\d+) kB", "m"));
return m ? Number(m[1]) * 1024 : null;
};
const memTotal = kb("MemTotal"), memAvail = kb("MemAvailable");
const swapTotal = kb("SwapTotal"), swapFree = kb("SwapFree");
const swapUsed = swapTotal != null && swapFree != null ? swapTotal - swapFree : null;
const cpus = os.cpus();
console.log("MACHINE");
console.log(` cpu ${cpus.length} × ${cpus[0]?.model?.trim() || "unknown"}`);
console.log(` memory ${memTotal ? gb(memTotal) : "?"} total, ${memAvail ? gb(memAvail) : "?"} available`);
console.log(` swap ${swapTotal ? gb(swapTotal) + " total, " + mb(swapUsed || 0) + " in use" : "none configured"}`);
const la = os.loadavg();
console.log(` load average ${la.map((n) => n.toFixed(2)).join(" ")} (1, 5, 15 min; ${cpus.length} core${cpus.length === 1 ? "" : "s"})`);
const disk = await diskUsage(WEB_ROOT);
const diskFree = disk ? disk.avail : null;
if (disk) console.log(` disk ${gb(disk.size)} total, ${gb(disk.used)} used, ${gb(disk.avail)} free`);
// ---- what our services use ------------------------------------------------
console.log("\nSERVICES (current / peak since boot)");
let ourPeak = 0;
for (const unit of UNITS) {
const base = `/sys/fs/cgroup/system.slice/${unit}`;
const cur = Number((await read(`${base}/memory.current`)) || 0);
const peak = Number((await read(`${base}/memory.peak`)) || 0);
const active = await sh("systemctl", ["is-active", unit]);
if (!cur && active !== "active") { console.log(` ${unit.padEnd(24)} not running`); continue; }
if (unit.startsWith("dojobay")) ourPeak += peak || cur;
console.log(` ${unit.padEnd(24)} ${cur ? mb(cur) : "—"}${peak ? " / " + mb(peak) : ""}`);
}
// ---- disk, broken down ----------------------------------------------------
console.log("\nDISK USED BY THIS INSTALLATION");
const parts: [string, string][] = [
["everything", WEB_ROOT],
[" server/node_modules", path.join(WEB_ROOT, "server", "node_modules")],
[" data (published)", PUBLIC_DIR],
[" data/avatars", path.join(PUBLIC_DIR, "avatars")],
[" data/backups", path.join(PUBLIC_DIR, "backups")],
[" data/updates", path.join(PUBLIC_DIR, "updates")],
];
let backupsBytes = 0, backupCount = 0;
for (const [label, p] of parts) {
const bytes = await dirSize(p);
if (bytes == null) { console.log(` ${label.padEnd(24)} —`); continue; }
if (label.includes("backups")) {
backupsBytes = bytes;
try { backupCount = (await readdir(p)).length; } catch { /* none */ }
}
console.log(` ${label.padEnd(24)} ${mb(bytes)}${label.includes("backups") && backupCount ? ` (${backupCount} kept)` : ""}`);
}
// ---- the workload ---------------------------------------------------------
console.log("\nWORKLOAD");
let nodeCount = 0, intervalMin = 10;
try {
const dojos = JSON.parse((await read(path.join(PUBLIC_DIR, "dojos.json"))) || "{}");
nodeCount = (dojos.nodes || []).length;
intervalMin = Number(dojos.interval_minutes) || 10;
} catch { /* not built yet */ }
const concurrency = Number(process.env.CONCURRENCY || 4);
console.log(` listed nodes ${nodeCount}`);
console.log(` probe cycle every ${intervalMin} min, up to ${concurrency} Tor circuits at once`);
for (const f of ["dojos.json", "history.json", "history-daily.json"]) {
const s = await stat(path.join(PUBLIC_DIR, f)).catch(() => null);
if (s) console.log(` ${f.padEnd(22)} ${mb(s.size)}`);
}
// ---- evidence of strain ---------------------------------------------------
// journalctl does its own matching, so there is no pipeline and no shell: the
// filter is an argument, the output is one line per matching entry, and a
// journalctl that cannot answer leaves this null exactly as an absent one did.
console.log("\nSIGNS OF STRAIN");
const oom = await sh("journalctl", ["-k", "--no-pager", "--case-sensitive=false",
"--grep=out of memory", "--output=cat"]);
const oomCount = oom ? oom.split("\n").filter((l) => l.trim()).length : 0;
const findings: string[] = [];
if (oomCount > 0) findings.push(`${oomCount} out-of-memory event(s) in the kernel log — the box IS too small`);
if (swapUsed && swapUsed > 64 * MB) findings.push(`${mb(swapUsed)} of swap in use — memory pressure, though not fatal`);
if (memAvail && memTotal && memAvail < memTotal * 0.15) findings.push("under 15% of memory available right now");
if (la[2] > cpus.length) findings.push(`15-minute load ${la[2].toFixed(2)} exceeds ${cpus.length} core(s)`);
if (diskFree != null && diskFree < 2 * 1024 * MB) findings.push(`only ${gb(diskFree)} of disk free`);
if (backupCount > 3) findings.push(`${backupCount} self-update backups kept (${mb(backupsBytes)}); nothing prunes these`);
if (!findings.length) console.log(" none. Nothing here suggests this machine is short of anything.");
else for (const f of findings) console.log(` · ${f}`);
// ---- what to tell other operators -----------------------------------------
console.log("\nWHAT THIS SUGGESTS FOR A MINIMUM SPEC");
const ourMb = ourPeak / MB;
if (ourPeak > 0) {
console.log(` This instance's own services peaked at about ${mb(ourPeak)}, carrying ${nodeCount} node(s).`);
console.log(" Add tor, nginx and the operating system, and headroom for `npm ci`");
console.log(" during a deploy, which is the largest transient by some way.");
} else {
console.log(" The services are not running here, so nothing was measured. Run this ON");
console.log(" the instance, with the backend up, for numbers that mean anything.");
}
console.log("");
console.log(` Suggested minimum: 1 vCPU, ${ourPeak > 0 && ourMb < 200 ? "1 GB" : "2 GB"} RAM, 20 GB disk, plus swap.`);
console.log(" The work is almost entirely waiting on Tor, so cores buy little; memory");
console.log(" and a little disk headroom are what matter. Run this again after a");
console.log(" deploy and after a self-update to catch the peaks rather than the calm.");
};
// Run when invoked, importable when tested. The suite exercises dirSize and
// diskUsage directly; printing a report on import would make that impossible.
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) await report();
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// =============================================================================
// The Dojo Bay — report the Dojo version of every listing.
//
// READ-ONLY. Nothing is written and no network is touched: it reads what the
// updater has already recorded.
//
// Two versions per node, and the difference matters when choosing a minimum:
//
// detected from the node's own X-Dojo-Version header, read on every probe.
// This is what it is actually running.
// declared the version inside the pairing payload. Frozen when that payload
// was generated and signed, so it can be years out of date while
// the node itself is current. At least one listing here declares
// 1.4.5 for exactly that reason.
//
// A minimum-version rule should therefore judge the DETECTED version. This
// report shows both, so a threshold can be chosen against the real spread.
//
// Usage, on the box:
// cd /var/www/dojobay/server
// node check-versions.ts # against the configured minimum
// node check-versions.ts 1.27.0 # against a threshold you are weighing
// =============================================================================
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { store } from "./store.ts";
import { MIN_DOJO_VERSION, judgeVersion, compareVersions } from "./dojo-version.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(HERE, "..", "data");
const minimum = (process.argv.find((a) => /^\d/.test(a)) || MIN_DOJO_VERSION || "1.27.0").trim();
const dojos = await readFile(path.join(PUBLIC_DIR, "dojos.json"), "utf8")
.then((t) => JSON.parse(t)).catch(() => ({ nodes: [] }));
const published = new Map((dojos.nodes || []).map((n: any) => [n.id, n]));
const records = (await store.listSubmissions())
.filter((r) => r.status === "approved")
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
const rows = records.map((r) => {
const pub: any = published.get(r.id) || {};
const detected = pub.detected_version || null;
const declared = r.payload?.pairing?.version || null;
const verdict = judgeVersion(detected, declared, minimum);
return { id: r.id, name: r.name || r.id, detected, declared, verdict, status: pub.status || "?" };
});
const pad = (s: string, n: number) => (s || "").padEnd(n);
console.log(`Minimum being applied: ${minimum}\n`);
console.log(pad("RECORD", 30) + pad("DETECTED", 12) + pad("DECLARED", 12) + pad("NODE", 10) + "VERDICT");
console.log("-".repeat(78));
for (const r of rows) {
const v = r.verdict.ok ? "ok" : (r.verdict.version ? "BELOW MINIMUM" : "no version reported");
console.log(pad(r.id, 30) + pad(r.detected || "—", 12) + pad(r.declared || "—", 12) + pad(r.status, 10) + v);
}
const below = rows.filter((r) => !r.verdict.ok && r.verdict.version);
const unknown = rows.filter((r) => !r.verdict.ok && !r.verdict.version);
const ok = rows.length - below.length - unknown.length;
console.log(`\n${ok} at or above ${minimum}, ${below.length} below, ${unknown.length} with no version reported.`);
if (below.length) {
console.log("\nBelow the minimum:");
for (const r of below) console.log(` ${r.id}: ${r.verdict.version} (${r.verdict.source})`);
}
if (unknown.length) {
console.log("\nNo version reported. A node that has never been probed successfully shows nothing here,");
console.log("so check whether these are down rather than old before reading anything into it:");
for (const r of unknown) console.log(` ${r.id} (node currently ${r.status})`);
}
// The spread, which is what a threshold should actually be chosen against.
const seen = rows.map((r) => r.detected).filter(Boolean) as string[];
if (seen.length) {
const uniq = [...new Set(seen)].sort(compareVersions);
console.log(`\nDetected versions in use: ${uniq.join(", ")}`);
console.log(`Oldest running: ${uniq[0]}. A minimum above that would refuse a node currently listed,`);
console.log("though existing listings are never re-judged — the check applies to new submissions.");
}
process.exit(below.length || unknown.length ? 1 : 0);
+429
View File
@@ -0,0 +1,429 @@
// Auth47 login and BIP47 signed-payload verification for The Dojo Bay backend.
// Thin wrappers over the audited Samourai libraries; the exact call shapes here
// were verified against the libraries end to end (see selftest.mjs).
import { Auth47Verifier } from "@dojo-tools/auth47";
import { BIP47Factory } from "@dojo-tools/bip47";
import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message";
import * as bip47utils from "@dojo-tools/bip47/utils";
import ecc from "@bitcoinerlab/secp256k1";
/** Outcome of a signature check: either accepted, or refused with a reason an
* operator can act on. */
export type VerifyResult =
| { ok: true; error?: undefined; address?: string; paymentCode?: string | null }
| { ok: false; error: string; address?: undefined; paymentCode?: undefined };
/** The parts of a wallet-exported signed block. */
export interface ParsedBlock {
/** Everything the signature covers, including the BIP47 tail. */
message: string;
/** The pairing JSON alone. */
pairingText: string;
/** The payment code inside the signed text, when present. */
paymentCode: string | null;
address: string;
signature: string;
}
const bip47 = BIP47Factory(ecc);
const message = bitcoinMessageFactory(ecc);
// ---- Auth47 ----------------------------------------------------------------
// The verifier needs to know its own callback URL. We build it from the site's
// base URL (the .onion origin) at construction time.
export function makeAuth47(baseUrl) {
const callback = new URL("/api/auth47/callback", baseUrl).toString();
const verifier = new Auth47Verifier(ecc, callback);
// Full challenge URI shown to the wallet (includes the callback `c`).
function challengeURI(nonce, expires, resource) {
return verifier.generateURI({ nonce, expires, resource });
}
// Per the spec, the wallet signs the challenge WITHOUT the callback param.
// Given the full URI we generated, produce the value the proof must contain.
function signedForm(fullUri) {
const u = new URL(fullUri);
u.searchParams.delete("c");
return decodeURIComponent(u.toString());
}
// Two URLs naming the same resource. Compared as parsed URLs rather than as
// strings, so a trailing slash or a difference in host case is not treated as
// a different site, while a different origin or path is. Anything that does
// not parse is not equal to anything.
function sameResource(a: string, b: string): boolean {
try {
const norm = (u: string) => {
const x = new URL(u);
return x.origin.toLowerCase() + x.pathname.replace(/\/+$/, "") + x.search;
};
return norm(a) === norm(b);
} catch { return false; }
}
// Verify a posted proof. Returns { ok, paymentCode } or { ok:false, error }.
//
// expectedResource is REQUIRED, and the shape is the point. A signature is
// only ever evidence of what it was made over, so a verifier that takes only
// the thing being verified can answer "is this signed?" but never "is this
// signed FOR ME?". The other three verifiers in this file all take an
// expectation for that reason: verifySignedPayload takes expectedMessage and
// expectedAddress, verifySignedUrlClaim takes expectedUrl, verifyOperatorDoc
// takes expectedOnion. This one did not, and the missing binding was
// invisible rather than a missing argument.
//
// What it prevents: the library checks that the challenge's r parameter is a
// well-formed http(s) URL, but it cannot know which URL is ours. Without this
// comparison an attacker could take a live nonce from this instance, show a
// victim the same challenge with r rewritten to their own site, and relay the
// resulting proof back here. The victim's wallet would display the attacker's
// site, the signature would verify, and a session would be minted here in the
// victim's name. The r parameter exists so a person can see what they are
// signing into, and this check is what makes that display mean anything.
function verify(proof: unknown, { expectedResource }: { expectedResource?: string } = {}): VerifyResult {
// Fail closed rather than throwing: a caller who forgot this is a bug, but
// a 500 from an auth endpoint is a worse way to find out than a refusal
// that names the omission.
if (!expectedResource) {
return { ok: false, error: "internal: no expected resource supplied, refusing to verify an unbound proof" };
}
const res = verifier.verifyProof(proof);
if (res.result !== "ok") return { ok: false, error: res.error };
// Read the resource from the challenge the signature actually covers, not
// from anything the caller passed alongside it.
const challenge = (proof as { challenge?: unknown }).challenge;
let resource: string | null = null;
try { resource = new URL(String(challenge)).searchParams.get("r"); } catch { /* unparseable */ }
if (!resource || !sameResource(resource, expectedResource)) {
return { ok: false, error: `proof was signed for a different site (${resource || "no resource"}), not this one` };
}
// Auth47 defines two proof shapes: a nym proof carrying a payment code, and
// an address proof carrying a plain address. Only the former identifies an
// operator here, and reading .nym off the wrong one would bind a session to
// undefined, so require it explicitly rather than assuming.
const nym = (res.data as { nym?: string }).nym;
if (typeof nym !== "string" || !nym) {
return { ok: false, error: "proof does not carry a payment code (an address proof cannot identify an operator)" };
}
return { ok: true, paymentCode: nym };
}
return { challengeURI, signedForm, verify, callback };
}
// ---- payment code -> notification address ----------------------------------
export function notificationAddress(paymentCode: string, network: string = "bitcoin"): string {
const net = bip47utils.networks[network];
return bip47.fromBase58(paymentCode, net).getNotificationAddress();
}
// The exact text an operator signs to attest to a pairing payload, and the
// exact text every gate checks a signature against.
//
// It lives here because it had grown two copies, in the submission gate and in
// audit-signed.mjs, the second carrying a comment warning that it MUST mirror
// the first. A canonical message that exists twice is a canonical message
// waiting to disagree with itself, and the failure would be quiet in the worst
// direction: signatures accepted at submission and reported as invalid by a
// later audit, or the reverse. The installer needs it too, which would have
// made three.
export function canonicalPairing(payload: { pairing?: unknown; explorer?: unknown } | null | undefined): string {
return JSON.stringify({ pairing: payload?.pairing, explorer: payload?.explorer });
}
// Every address a given payment code could legitimately have signed from.
// A PayNym is a MAINNET identity: an operator listing a testnet node still
// signs with their mainnet notification address, because that is the only key
// their wallet holds for that code. Deriving on testnet yields an "m…" address
// that can never match, which silently made every testnet listing unverifiable.
// Both derivations come from the same code, so accepting either is no weaker.
export function notificationAddresses(paymentCode: string): string[] {
const out: string[] = [];
for (const net of ["bitcoin", "testnet"]) {
try { const a = notificationAddress(paymentCode, net); if (!out.includes(a)) out.push(a); } catch { /* skip */ }
}
return out;
}
// ---- lab-style signed pairing payload verification -------------------------
// The submitted `signed` blob is a BIP-signed message. We require it to be
// signed by the notification address of the operator's authenticated payment
// code, over the exact pairing JSON they are submitting. This is the same
// verify() the paymentcode.io lab uses.
//
// The signed message format Samourai/Ashigaru export wraps the payload between
// BEGIN/END markers. CRITICAL, verified against a real wallet export: the text
// the wallet signs is EVERYTHING between the markers, i.e. the pairing JSON
// PLUS the trailing "BIP47:" line and payment code (no trailing newline). An
// earlier revision excised the BIP47 tail before verifying, which made every
// genuine wallet signature fail as "invalid signature"; the selftest did not
// catch it because it constructed its own blocks under the same assumption.
// Because the BIP47 line is inside the signed text, the payment code is
// covered by the signature and can itself be verified against the signing
// address (see verifySignedPayload).
// Repair a signed block whose whitespace was mangled in transit.
//
// The signature covers the exact bytes between the markers, and the blank line
// before the "BIP47:" line is part of them. Copying a block through a chat
// window, a web form or a mail client routinely collapses that blank line, at
// which point a perfectly good signature stops verifying and the operator is
// told their signature is invalid, which is both wrong and unhelpful.
//
// This is safe rather than a fudge: a candidate is accepted ONLY if it verifies
// cryptographically against an address the declared payment code derives, so
// nothing is taken on trust. The repaired block is what gets stored, so later
// audits verify too. Returns null when no candidate verifies.
export function repairSignedBlock(text: unknown): { block: string; note: string | null } | null {
const raw = String(text || "").replace(/\r\n/g, "\n");
const addrM = raw.match(/Address:\s*(\S+)/);
const sigM = raw.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
const innerM = raw.match(/SIGNED MESSAGE-----[ \t]*\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
if (!addrM || !sigM || !innerM) return null;
const address = addrM[1].trim(), signature = sigM[1].trim();
const inner = innerM[1];
const codeM = inner.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
const json = inner.replace(/\n*[ \t]*BIP47:[\s\S]*$/, "").replace(/\n+$/, "");
const code = codeM ? codeM[1] : null;
const candidates: [string, string][] = [["", inner]];
if (code) {
candidates.push(
["a blank line before the BIP47 line was restored", `${json}\n\nBIP47: ${code}`],
["a blank line before the BIP47 line was restored", `${json}\n\nBIP47:\n${code}`],
);
}
const accept = code ? notificationAddresses(code) : [];
if (code && !accept.includes(address)) return null; // the code does not own this address
const net = bip47utils.networks.bitcoin;
for (const [note, candidate] of candidates) {
let ok = false;
try { ok = message.verify(candidate, address, signature, net.messagePrefix); } catch { ok = false; }
if (!ok) continue;
const block = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${candidate}\n` +
`-----BEGIN BITCOIN SIGNATURE-----\nVersion: Bitcoin-qt (1.0)\nAddress: ${address}\n\n${signature}\n` +
`-----END BITCOIN SIGNATURE-----`;
return { block, note: note || null };
}
return null;
}
export function parseSignedBlock(text: unknown): ParsedBlock | null {
if (!text || typeof text !== "string") return null;
const t = text.replace(/\r\n/g, "\n");
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----\n([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
const addrM = t.match(/Address:\s*(\S+)/);
const sigM = t.match(/\n([A-Za-z0-9+/=]{80,})\n-----END BITCOIN SIGNATURE/);
if (!msgM || !addrM || !sigM) return null;
const message = msgM[1].trim(); // the full signed text
const tail = message.match(/^([\s\S]*?)\n\s*BIP47:\s*\n?(\S+)$/);
return {
message, // what the signature covers
pairingText: tail ? tail[1].trim() : message, // the pairing JSON alone
paymentCode: tail ? tail[2] : null, // code inside the signed text
address: addrM[1].trim(),
signature: sigM[1].trim(),
};
}
// Verify a signed pairing block. Checks, in order, with distinct errors:
// 1. the block parses at all;
// 2. the pairing JSON inside it matches the payload being submitted;
// 3. the signature is cryptographically valid over the FULL signed text;
// 4. (signature now known valid) the BIP47 payment code inside the signed
// text is a valid code whose notification address IS the signing address;
// 5. the signing address matches the authenticated payment code's
// notification address (the session binding the API supplies).
// Does the signed pairing text describe the same payload being submitted?
//
// Wallets and admin panels serialise this JSON differently: pretty-printed with
// newlines and indentation, or with the object keys in another order. All of
// those are the SAME payload, and a byte-exact comparison against our own
// re-serialisation rejects them, which is what made genuine, correctly signed
// listings fail the gate. So compare the parsed structures instead: identical
// keys and identical values, order-insensitive, at every level. Anything that
// is not valid JSON, or that differs in any value or key, still fails.
export function sameSignedPayload(signedText: string, expected: string): boolean {
const a = String(signedText).trim(), b = String(expected).trim();
if (a === b) return true;
let pa, pb;
try { pa = JSON.parse(a); pb = JSON.parse(b); } catch { return false; }
return stableStringify(pa) === stableStringify(pb);
}
function stableStringify(v: unknown): string {
if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
return "{" + Object.keys(o).sort().map((k) => JSON.stringify(k) + ":" + stableStringify(o[k])).join(",") + "}";
}
return JSON.stringify(v) ?? "null";
}
export function verifySignedPayload({ signedText, expectedMessage, expectedAddress, network = "bitcoin" }: {
signedText: string;
expectedMessage?: string | null;
expectedAddress?: string | string[] | null;
network?: string;
}): VerifyResult {
const parsed = parseSignedBlock(signedText);
if (!parsed) return { ok: false, error: "unrecognised signed message format" };
if (expectedMessage != null && !sameSignedPayload(parsed.pairingText, expectedMessage)) {
return { ok: false, error: "signed message does not match the submitted pairing code" };
}
const net = bip47utils.networks[network];
let verified = false;
try {
verified = message.verify(parsed.message, parsed.address, parsed.signature, net.messagePrefix);
} catch (e) {
return { ok: false, error: "signature could not be verified (" + e.message + ")" };
}
if (!verified) return { ok: false, error: "invalid signature" };
if (parsed.paymentCode) {
const derived = notificationAddresses(parsed.paymentCode);
if (!derived.length) {
return { ok: false, error: "signature is valid, but the BIP47 line inside the signed message is not a valid payment code" };
}
if (!derived.includes(parsed.address)) {
return { ok: false, error: "signature is valid, but the signing address is not the notification address of the payment code inside the message" };
}
}
// expectedAddress may be a single address or every address the authenticated
// code could have signed from (see notificationAddresses).
const accept = expectedAddress == null ? null : (Array.isArray(expectedAddress) ? expectedAddress : [expectedAddress]);
if (accept && !accept.includes(parsed.address)) {
return { ok: false, error: "signed by a different address than the authenticated payment code" };
}
return { ok: true, address: parsed.address, paymentCode: parsed.paymentCode };
}
// ---- operator binding (data/operator.json) ----------------------------------
// A Dojo Bay instance MUST prove who runs it: operator.json binds the onion
// address to the operator's payment code via a wallet signature over the text
//
// http://<onion>/
//
// BIP47: <payment code>
//
// (unlike pairing blocks, the BIP47 line here is INSIDE the signed message:
// the operator pastes the whole text into the wallet's Sign tool). Verified at
// install, at bootstrap import before trusting a remote instance's data, and
// on every rebuild.
// ---- signed URL claims -----------------------------------------------------
// A verified operator domain is proven the same way the instance's own onion is:
// the operator signs the URL, a blank line, then "BIP47: <their code>". Same
// shape, same wallet procedure (PayNym → Sign message), so nothing new to learn
// and no new crypto. This is deliberately a separate field from the pairing
// payload: the pairing block attests to pairing data only, and operators
// stuffing identity material into it is exactly what this feature replaces.
export function claimText(url: string, paymentCode: string): string {
return `${String(url).replace(/\/+$/, "")}/\n\nBIP47: ${paymentCode}`;
}
// Verify a signed claim over `expectedUrl` by `paymentCode`. Returns
// { ok } or { ok: false, error } with errors an operator can act on.
export function verifySignedUrlClaim({ signed, expectedUrl, paymentCode }: {
signed: string;
expectedUrl: string;
paymentCode: string;
}): VerifyResult {
if (!signed) return { ok: false, error: "no signed block supplied" };
if (!paymentCode) return { ok: false, error: "no payment code supplied" };
const t = String(signed).replace(/\r\n/g, "\n");
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----[ \t]*\n?([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
const addrM = t.match(/Address:\s*(\S+)/);
const sigM = t.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
if (!msgM || !addrM || !sigM) {
const missing = [
!msgM && "the BEGIN BITCOIN SIGNED MESSAGE section",
!addrM && "the Address: line",
!sigM && "the signature line before END BITCOIN SIGNATURE",
].filter(Boolean).join(", ");
return { ok: false, error: `not a recognisable signed block (missing ${missing}) — the paste may have been truncated` };
}
const signedMessage = msgM[1].replace(/\n+$/, "");
const norm = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase();
const firstLine = signedMessage.split("\n")[0].trim();
if (norm(firstLine) !== norm(expectedUrl)) {
return { ok: false, error: `the signed message starts with ${firstLine || "(nothing)"}, but this claim is for ${expectedUrl}` };
}
const bipM = signedMessage.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
if (!bipM) return { ok: false, error: "the signed message has no BIP47: line" };
if (bipM[1] !== paymentCode) {
return { ok: false, error: "the BIP47 line inside the signed message is a different payment code from the one you are signed in with" };
}
const accept = notificationAddresses(paymentCode);
if (!accept.includes(addrM[1].trim())) {
return { ok: false, error: `signed by ${addrM[1].trim()}, but your payment code's notification address is ${accept[0]} — sign under PayNym → Sign message, which uses your PayNym's notification address` };
}
const net = bip47utils.networks.bitcoin;
try {
if (!message.verify(signedMessage, addrM[1].trim(), sigM[1].trim(), net.messagePrefix)) {
return { ok: false, error: "invalid signature" };
}
} catch (e) {
return { ok: false, error: "signature could not be verified (" + e.message + ")" };
}
return { ok: true, address: addrM[1].trim() };
}
export function verifyOperatorDoc(doc: any, { expectedOnion }: { expectedOnion?: string } = {}): VerifyResult {
if (!doc || typeof doc !== "object") return { ok: false, error: "operator.json missing or unreadable" };
if (!doc.paymentCode) return { ok: false, error: "operator.json has no paymentCode" };
if (!doc.verifySigned) return { ok: false, error: "operator.json has no verifySigned block" };
const t = String(doc.verifySigned).replace(/\r\n/g, "\n");
// The newline after the BEGIN marker is optional: some terminals swallow it
// when a block is pasted. It is not part of the signed text either way, so
// tolerating it recovers the correct message rather than changing it.
const msgM = t.match(/BEGIN BITCOIN SIGNED MESSAGE-----[ \t]*\n?([\s\S]*?)\n-----BEGIN BITCOIN SIGNATURE/);
const addrM = t.match(/Address:\s*(\S+)/);
const sigM = t.match(/\n([A-Za-z0-9+\/=]{80,})\n*-----END BITCOIN SIGNATURE/);
if (!msgM || !addrM || !sigM) {
// Name what is missing: a truncated or line-dropped paste is by far the
// most common cause, and "not recognisable" alone sends people hunting
// for a problem with their wallet instead of re-pasting.
const missing = [
!msgM && "the BEGIN BITCOIN SIGNED MESSAGE section",
!addrM && "the Address: line",
!sigM && "the signature line before END BITCOIN SIGNATURE",
].filter(Boolean).join(", ");
return { ok: false, error: `verifySigned is not a recognisable signed block (missing ${missing}) — the paste may have been truncated; paste the whole block again` };
}
const signedMessage = msgM[1].replace(/\n+$/, "");
const norm = (u) => String(u || "").trim().replace(/\/+$/, "");
const firstLine = signedMessage.split("\n")[0].trim();
if (norm(firstLine) !== norm(doc.onion)) return { ok: false, error: "signed message does not match the declared onion" };
if (expectedOnion && norm(doc.onion) !== norm(expectedOnion)) {
return { ok: false, error: "declared onion does not match the address this document was fetched from" };
}
const bipM = signedMessage.match(/BIP47:\s*(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
if (!bipM || bipM[1] !== doc.paymentCode) {
return { ok: false, error: "the BIP47 line inside the signed message does not match the declared payment code" };
}
// Accept either derivation of the notification address.
//
// A PayNym is a mainnet identity, but a wallet running on testnet derives the
// notification address for THAT network, so the same payment code signs from
// a different address depending on which mode the operator's wallet is in.
// Insisting on the mainnet form refused perfectly good bindings from anyone
// running a testnet wallet — the same defect fixed for listing signatures,
// which this path missed.
const accept = notificationAddresses(doc.paymentCode);
const signer = addrM[1].trim();
if (!accept.includes(signer)) {
// Naming the addresses matters: the usual cause is signing from a different
// account than the payment code entered, and the operator can only spot
// that if they can see which address their wallet actually used.
const expected = accept.length > 1
? `${accept[0]} on mainnet, or ${accept[1]} from a testnet wallet`
: accept[0] || "(the code could not be decoded)";
return { ok: false, error: `signed by ${signer}, but the payment code's notification address is ${expected} — sign under PayNym → Sign message, which uses your PayNym's notification address` };
}
const net = bip47utils.networks.bitcoin; // the message prefix is the same on both
try {
if (!message.verify(signedMessage, signer, sigM[1].trim(), net.messagePrefix)) {
return { ok: false, error: "invalid signature" };
}
} catch (e) { return { ok: false, error: "signature could not be verified (" + e.message + ")" }; }
return { ok: true, address: signer };
}
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// =============================================================================
// The Dojo Bay — diagnose signed-block mismatches.
//
// READ-ONLY. For every record whose stored block does not pass the gate, this
// answers the question the audit cannot: is the SIGNATURE bad, or is the stored
// payload merely a different representation of the same signed text?
//
// For each record it reports, in order:
// 1. INTERNAL VALIDITY - does the signature verify over the block's own text,
// and does the BIP47 code inside that text derive the signing address?
// If yes, the block is a genuine wallet export and nothing is forged.
// 2. MESSAGE MATCH - does the pairing JSON inside the block equal
// canonicalPairing(stored payload) byte for byte? If not, it shows the
// first differing offset with a window either side, and whether the two
// are the same DATA in a different serialisation (key order, spacing) or
// genuinely different values.
//
// Run on the box: cd /var/www/dojobay/server && node diagnose-signed.mjs
// Add --all to include records that already pass.
// =============================================================================
import { store } from "./store.ts";
// canonicalPairing is imported, never reimplemented: a diagnostic that computes
// the canonical message its own way can only ever report on itself.
// server/selftest.mjs enforces the single definition.
import { parseSignedBlock, verifySignedPayload, notificationAddresses, canonicalPairing } from "./crypto.ts";
import { bitcoinMessageFactory } from "@dojo-tools/bitcoinjs-message";
import * as bip47utils from "@dojo-tools/bip47/utils";
import ecc from "@bitcoinerlab/secp256k1";
const message = bitcoinMessageFactory(ecc);
const ALL = process.argv.includes("--all");
const netOf = (rec) => (rec.network === "testnet" ? "testnet" : "bitcoin");
// Same data, different serialisation? Compare parsed structures, not strings.
const deepEq = (a, b) => {
try { return JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b)); } catch { return false; }
};
const sortDeep = (v) => {
if (Array.isArray(v)) return v.map(sortDeep);
if (v && typeof v === "object") {
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortDeep(v[k])]));
}
return v;
};
function firstDiff(a, b) {
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i;
return a.length === b.length ? -1 : n;
}
const window_ = (s, i) => JSON.stringify(s.slice(Math.max(0, i - 30), i + 30));
const recs = (await store.listSubmissions())
.sort((a, b) => (a.network + a.name).localeCompare(b.network + b.name));
let genuine = 0, forged = 0, drifted = 0, reorder = 0;
for (const rec of recs) {
if (!rec.signed) continue;
const net = netOf(rec);
const codes = Array.isArray(rec.paymentCodes) ? rec.paymentCodes : [];
const canon = canonicalPairing(rec.payload);
const passes = codes.some((c) => {
try { return verifySignedPayload({ signedText: rec.signed, expectedMessage: canon, expectedAddress: notificationAddresses(c), network: net }).ok; }
catch { return false; }
});
if (passes && !ALL) continue;
const label = rec.name && `${rec.network}-${rec.name}` !== rec.id ? `${rec.id} ("${rec.name}")` : rec.id;
console.log(`\n=== ${label} (${rec.status})${passes ? " [currently passes]" : ""}`);
const p = parseSignedBlock(rec.signed);
if (!p) { console.log(" block does not parse at all"); continue; }
// 1. internal validity
let sigOk = false;
try { sigOk = message.verify(p.message, p.address, p.signature, bip47utils.networks[net].messagePrefix); } catch (e) { console.log(" verify threw:", e.message); }
// A PayNym signs from its mainnet notification address whatever network the
// node is on, so both derivations are legitimate.
let derived = null;
try { derived = p.paymentCode ? notificationAddresses(p.paymentCode) : null; } catch { derived = null; }
const bound = Array.isArray(derived) && derived.includes(p.address);
const derivedTxt = Array.isArray(derived) ? derived.join(" / ") : "(undecodable)";
console.log(` signature over the block's own text : ${sigOk ? "VALID" : "INVALID"}`);
console.log(` signing address : ${p.address}`);
console.log(` BIP47 code inside the signed text : ${p.paymentCode ? p.paymentCode.slice(0, 12) + "…" : "(none)"} -> ${p.paymentCode ? derivedTxt : "n/a"} ${p.paymentCode ? (bound ? "(binds)" : "(DOES NOT BIND)") : ""}`);
console.log(` record's payment code(s) : ${codes.map((c) => c.slice(0, 12) + "…").join(", ") || "(none)"}`);
if (sigOk && bound) genuine++; else forged++;
// 2. message match
if (p.pairingText === canon) { console.log(" pairing text matches the stored payload exactly"); continue; }
const i = firstDiff(p.pairingText, canon);
let signedObj, storedObj;
try { signedObj = JSON.parse(p.pairingText); } catch {}
try { storedObj = JSON.parse(canon); } catch {}
const same = signedObj && storedObj && deepEq(signedObj, storedObj);
if (same) reorder++; else drifted++;
console.log(` pairing text DIFFERS from the stored payload`);
console.log(` same data, different serialisation? ${same ? "YES - key order/spacing only" : "NO - the values themselves differ"}`);
console.log(` lengths: signed ${p.pairingText.length}, stored ${canon.length}; first difference at offset ${i}`);
console.log(` signed: ${window_(p.pairingText, i)}`);
console.log(` stored: ${window_(canon, i)}`);
if (!same && signedObj && storedObj) {
const keys = new Set([...Object.keys(signedObj), ...Object.keys(storedObj)]);
for (const k of keys) {
if (JSON.stringify(sortDeep(signedObj[k])) !== JSON.stringify(sortDeep(storedObj[k]))) {
console.log(` top-level key "${k}" differs:`);
console.log(` signed: ${JSON.stringify(signedObj[k])}`);
console.log(` stored: ${JSON.stringify(storedObj[k])}`);
}
}
}
}
console.log(`\nSummary of blocks examined: ${genuine} genuine (valid signature, code binds), ${forged} not genuine.`);
console.log(`Mismatches: ${reorder} serialisation-only, ${drifted} with genuinely different values.`);
console.log(genuine && !forged
? "\nEvery block examined is a real wallet export; the failures are a stored-payload representation problem, not a trust problem."
: "");
+182
View File
@@ -0,0 +1,182 @@
// =============================================================================
// TXT record lookups over Tor, for verified operator domains.
//
// A Tor-only instance has no ordinary path to a TXT record: Tor's SOCKS
// interface resolves names but cannot fetch arbitrary record types. So we ask
// public DNS-over-HTTPS resolvers, tunnelling the HTTPS through the same SOCKS
// proxy the probes use.
//
// Two deliberate choices, because a resolver's answer decides whether a
// verified badge appears and a lying resolver could mint one:
//
// 1. Several independent resolvers are queried and a fixed number must agree
// before a domain is treated as verified (DOH_AGREEMENT, default 2).
// 2. "Could not reach enough resolvers" is reported as INCONCLUSIVE, never as
// a failure, so a Tor hiccup cannot strip a badge from an honest operator.
//
// The HTTPS-over-Tor fetch here duplicates a little of updates.mjs on purpose:
// that module is on the self-update path, which has never been exercised on real
// hardware, and refactoring it to share code is not a risk worth taking for a
// feature that only reads DNS.
// =============================================================================
import tls from "node:tls";
import { socks5Connect } from "../scripts/update.mjs";
import type { ProbeCfg } from "../types.js";
/** Transport settings a lookup needs; the caller may supply a subset. */
type LookupCfg = Partial<ProbeCfg> & {
/**
* An extra certificate authority to trust for this lookup, and nothing else.
*
* Exists so the self-test can run the whole path — SOCKS, TLS, HTTP, DoH JSON
* — against a mock resolver holding a self-signed certificate, WITHOUT
* reaching for NODE_TLS_REJECT_UNAUTHORIZED, which switches validation off
* for the entire process and every other connection made while it is set.
* Certificate validation stays on here; the test simply supplies the anchor
* that makes its own certificate valid.
*/
tlsCa?: string | Buffer | Array<string | Buffer>;
};
interface ResolverAnswer { host: string; records: string[] }
export interface TxtLookup {
records: string[];
answered: number;
byResolver: ResolverAnswer[];
errors: string[];
}
export interface TxtAgreement {
ok: boolean;
/** True when too few resolvers replied to draw any conclusion. */
inconclusive: boolean;
/** Absent when the lookup threw before any resolver could be counted. */
answered?: number;
agreed?: number;
error?: string;
}
// Resolvers use different JSON paths but the same response shape.
const RESOLVERS: { host: string; path: string }[] = [
{ host: "cloudflare-dns.com", path: "/dns-query" },
{ host: "dns.quad9.net", path: "/dns-query" },
{ host: "dns.google", path: "/resolve" },
];
export const DOH_AGREEMENT = Math.max(1, +(process.env.DOH_AGREEMENT || 2));
const MAX_BODY = 64 * 1024; // a TXT answer is tiny; cap the read
function resolverList(): { host: string; path: string }[] {
const only = (process.env.DOH_RESOLVERS || "").trim();
if (!only) return RESOLVERS;
const wanted = only.split(",").map((s) => s.trim()).filter(Boolean);
return RESOLVERS.filter((r) => wanted.includes(r.host));
}
// One HTTPS GET through the Tor SOCKS proxy, returning the response body as
// text. Deliberately minimal: no redirects (a resolver that redirects is not
// one we want), and a hard body cap.
async function httpsGetOverTor(host: string, path: string,
{ proxyHost, proxyPort, timeoutMs = 20000, tlsCa }: LookupCfg): Promise<string> {
const raw = await socks5Connect(proxyHost, proxyPort, host, 443, timeoutMs);
return new Promise<string>((resolve, reject) => {
let done = false;
const finish = (fn: (a?: any) => void, arg?: any) => { if (!done) { done = true; clearTimeout(timer); try { socket.destroy(); } catch {} fn(arg); } };
const timer = setTimeout(() => finish(reject, new Error("timeout")), timeoutMs);
const socket = tls.connect({ socket: raw, servername: host, ...(tlsCa ? { ca: tlsCa } : {}) }, () => {
socket.write(
`GET ${path} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-domain-check\r\n` +
`Accept: application/dns-json\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n`);
});
const chunks: Buffer[] = [];
let size = 0;
socket.on("data", (d: Buffer) => {
size += d.length;
if (size > MAX_BODY) return finish(reject, new Error("response too large"));
chunks.push(d);
});
socket.on("error", (e: Error) => finish(reject, e));
socket.on("close", () => {
if (done) return;
try {
const all = Buffer.concat(chunks);
const headEnd = all.indexOf("\r\n\r\n");
if (headEnd < 0) return finish(reject, new Error("malformed reply"));
const headText = all.subarray(0, headEnd).toString("latin1");
const m = headText.match(/^HTTP\/1\.[01] (\d{3})/);
if (!m) return finish(reject, new Error("malformed reply"));
if (+m[1] !== 200) return finish(reject, new Error("HTTP " + m[1]));
let body = all.subarray(headEnd + 4);
if (/transfer-encoding:\s*chunked/i.test(headText)) {
const parts: Buffer[] = []; let p = 0;
for (;;) {
const nl = body.indexOf("\r\n", p);
if (nl < 0) break;
const n = parseInt(body.subarray(p, nl).toString("latin1"), 16);
if (!n) break;
parts.push(body.subarray(nl + 2, nl + 2 + n));
p = nl + 2 + n + 2;
}
body = Buffer.concat(parts);
}
finish(resolve, body.toString("utf8"));
} catch (e) { finish(reject, e); }
});
});
}
// A DoH JSON answer gives TXT data as a quoted string, and a long record as
// several quoted strings that must be concatenated. Normalise both to one line.
export function parseTxtAnswer(json: string): string[] | null {
let doc: any;
try { doc = JSON.parse(json); } catch { return null; }
if (typeof doc !== "object" || doc === null) return null;
if (doc.Status === 3) return []; // NXDOMAIN: no records
if (doc.Status !== 0) return null; // SERVFAIL etc: no answer
const answers = Array.isArray(doc.Answer) ? doc.Answer : [];
return answers
.filter((a: any) => a && (a.type === 16 || a.type === undefined))
.map((a: any) => String(a.data || ""))
.map((d: string) => (d.includes('"') ? (d.match(/"([^"]*)"/g) || []).map((s) => s.slice(1, -1)).join("") : d))
.map((d: string) => d.trim())
.filter(Boolean);
}
// Look up TXT records for `name` across the resolvers. Returns
// { records, answered, byResolver, errors } where `records` is the set of
// records seen and `answered` counts resolvers that gave a usable answer.
export async function lookupTxt(name: string, cfg: LookupCfg = {}): Promise<TxtLookup> {
const resolvers = resolverList();
const q = `?name=${encodeURIComponent(name)}&type=TXT`;
const results = await Promise.allSettled(resolvers.map(async (r) => {
const body = await httpsGetOverTor(r.host, r.path + q, cfg);
const recs = parseTxtAnswer(body);
if (recs === null) throw new Error("resolver returned no usable answer");
return { host: r.host, records: recs };
}));
const byResolver: ResolverAnswer[] = [];
const errors: string[] = [];
for (let i = 0; i < results.length; i++) {
const r = results[i]; // a local, so the union narrows
if (r.status === "fulfilled") byResolver.push(r.value);
else errors.push(`${resolvers[i].host}: ${r.reason?.message || "failed"}`);
}
const records = [...new Set(byResolver.flatMap((r) => r.records))];
return { records, answered: byResolver.length, byResolver, errors };
}
// Do at least DOH_AGREEMENT resolvers see a record satisfying `predicate`?
// Distinguishes "not there" from "we could not tell".
export async function txtRecordAgreed(name: string, predicate: (r: string) => boolean,
cfg: LookupCfg = {}): Promise<TxtAgreement> {
const { answered, byResolver, errors, records } = await lookupTxt(name, cfg);
if (answered < DOH_AGREEMENT) {
return { ok: false, inconclusive: true, answered, agreed: 0,
error: `only ${answered} of ${DOH_AGREEMENT} required resolvers answered (${errors.join("; ") || "no detail"})` };
}
const agreed = byResolver.filter((r) => r.records.some(predicate)).length;
if (agreed >= DOH_AGREEMENT) return { ok: true, inconclusive: false, answered, agreed };
return { ok: false, inconclusive: false, answered, agreed,
error: records.length
? `${agreed} of ${DOH_AGREEMENT} required resolvers saw a matching record; ${records.length} TXT record(s) present but not matching`
: `no TXT record found at ${name}` };
}
+162
View File
@@ -0,0 +1,162 @@
// =============================================================================
// Dojo version comparison, and the minimum this directory will accept.
//
// A version reaches us two ways, and they are not equally trustworthy:
//
// detected read from the node's own X-Dojo-Version response header during a
// probe. This is what the node is actually running.
// declared the `version` inside the pairing payload. Informational, frozen
// when the payload was generated, and often stale — one listing
// here declares 1.4.5 while running something far newer, because
// the payload was restored to match the signature that covers it.
//
// So anything deciding on a version prefers the detected one, and falls back to
// the declared one only when the node did not report a header at all.
// =============================================================================
// A country code inferred from whatever an operator wrote about where they are,
// or nothing at all.
//
// The point is flags where we can manage them and no obligation anywhere else.
// An operator is asked one free-text question and may answer "Finland", "FI",
// "Central America", "Europe" or "Ancapistan"; the first two get a flag and the
// rest do not, and none of them is an error. Nothing is enforced and nothing is
// refused, because a directory of onion services has no business insisting that
// somebody name a state.
//
// The names come from the runtime rather than a table in this repository.
// Intl.DisplayNames knows 280 region codes and their English names, so the
// lookup is current with the platform's ICU data instead of decaying in a file
// nobody revisits. That also means an unassigned pair like XX yields nothing:
// the runtime does not recognise it, so it cannot be a flag, and letterboxes on
// a card read as a broken listing rather than a missing flag.
const REGION_NAMES = new Intl.DisplayNames(["en"], { type: "region" });
/** lowercased name -> code, built once from whatever the runtime knows. */
const NAME_TO_CODE: Map<string, string> = (() => {
const m = new Map<string, string>();
for (let a = 65; a < 91; a++) {
for (let b = 65; b < 91; b++) {
const cc = String.fromCharCode(a, b);
let name: string | undefined;
try { name = REGION_NAMES.of(cc); } catch { continue; }
if (name && name !== cc) m.set(name.toLowerCase(), cc);
}
}
// The handful the runtime will not answer to, because people do not write
// country names the way the standard does. UK is the one that matters: it is
// not a code, and typed as one it renders as two letterboxes.
for (const [alias, cc] of [
["uk", "GB"], ["united kingdom", "GB"], ["great britain", "GB"], ["britain", "GB"],
["england", "GB"], ["scotland", "GB"], ["wales", "GB"], ["northern ireland", "GB"],
["usa", "US"], ["u.s.a.", "US"], ["u.s.", "US"], ["america", "US"],
["holland", "NL"], ["czech republic", "CZ"], ["south korea", "KR"], ["north korea", "KP"],
["russia", "RU"], ["uae", "AE"], ["eu", "EU"], ["european union", "EU"],
]) m.set(alias, cc);
return m;
})();
export function countryFor(text: unknown): string | null {
const raw = String(text ?? "").trim();
if (!raw) return null;
// Segments, so "Helsinki, Finland" and "Europe (Finland)" both find something.
// Longest first: "United States" should win over a stray "US" elsewhere in
// the same answer.
const parts = raw.split(/[,;/()\u2013\u2014|]+/).map((x) => x.trim()).filter(Boolean);
for (const part of [raw, ...parts].sort((a, b) => b.length - a.length)) {
const key = part.toLowerCase().replace(/\.$/, "");
const named = NAME_TO_CODE.get(key);
if (named) return named;
if (/^[a-z]{2}$/i.test(part)) {
const cc = part.toUpperCase();
// Only if the runtime recognises it: an unassigned pair has no flag, and
// two letterboxes look like a fault rather than an absence.
try { if (REGION_NAMES.of(cc) !== cc) return cc; } catch { /* not a region */ }
}
}
return null;
}
// Which network a pairing URL is for, read from the URL itself.
//
// A Dojo serves its testnet API under a `test` path segment and its mainnet API
// without one: http://<onion>/test/v2 against http://<onion>/v2. That makes the
// operator's declared network checkable against the endpoint they gave, and it
// is worth checking, because a crossed pair is wrong in a way nothing
// downstream catches. A testnet node listed as mainnet answers, reports a
// height and probes green indefinitely; the only symptom is a block height a
// few hundred thousand adrift, which reads as nothing at all, and anyone
// pairing with it is sent to a chain they did not ask for.
//
// A whole path SEGMENT, never a substring: an onion address is base32 and can
// carry those four letters in a row by chance, and /v2/testing is not a testnet
// endpoint either.
//
// It lives here rather than in the installer because the same judgement belongs
// at the submission gate, and this module is already where a node's declared
// properties are judged against what it actually is.
export function pairingNetwork(url: string): "mainnet" | "testnet" | null {
try {
return new URL(url).pathname.split("/").some((seg) => seg.toLowerCase() === "test")
? "testnet" : "mainnet";
} catch { return null; }
}
/** The lowest Dojo this directory will accept for a NEW listing. Set to "" or
* "0" to disable the check entirely. Existing listings are never re-judged. */
export const MIN_DOJO_VERSION = (process.env.MIN_DOJO_VERSION ?? "1.27.0").trim();
/** "v1.27.0-rc1" -> [1, 27, 0]. Null when there is no version in there at all. */
export function parseVersion(v: unknown): number[] | null {
if (typeof v !== "string") return null;
const m = v.trim().replace(/^v/i, "").match(/^(\d+(?:\.\d+)*)/);
if (!m) return null;
const parts = m[1].split(".").map((n) => Number(n));
return parts.every((n) => Number.isFinite(n)) ? parts : null;
}
/** -1, 0 or 1. Missing components count as zero, so 1.27 equals 1.27.0. */
export function compareVersions(a: unknown, b: unknown): number {
const x = parseVersion(a) || [], y = parseVersion(b) || [];
for (let i = 0; i < Math.max(x.length, y.length); i++) {
const d = (x[i] || 0) - (y[i] || 0);
if (d) return d > 0 ? 1 : -1;
}
return 0;
}
export function meetsMinimum(version: unknown, minimum: string = MIN_DOJO_VERSION): boolean {
if (!minimum || compareVersions(minimum, "0") === 0) return true; // check disabled
return compareVersions(version, minimum) >= 0;
}
/**
* Judge a node's version for the submission gates.
*
* `unknown` is deliberately its own outcome rather than a silent pass or a
* silent refusal: a node that reports no version at all is almost certainly too
* old to carry the endpoints this directory reads, but saying so plainly is
* more useful to an operator than either guessing.
*/
export function judgeVersion(
detected: unknown, declared: unknown, minimum: string = MIN_DOJO_VERSION,
): { ok: boolean; version: string | null; source: "detected" | "declared" | null; reason?: string } {
if (!minimum || compareVersions(minimum, "0") === 0) {
return { ok: true, version: (detected as string) || (declared as string) || null,
source: detected ? "detected" : declared ? "declared" : null };
}
const version = (parseVersion(detected) ? detected : parseVersion(declared) ? declared : null) as string | null;
const source = parseVersion(detected) ? "detected" as const : parseVersion(declared) ? "declared" as const : null;
if (!version) {
return { ok: false, version: null, source: null,
reason: `this Dojo did not report a version, so it cannot be checked against the minimum of ${minimum}. `
+ "Dojo has sent an X-Dojo-Version header on every response since well before that, so a node that "
+ "sends none is almost certainly older. Upgrade, then submit again." };
}
if (!meetsMinimum(version, minimum)) {
return { ok: false, version, source,
reason: `this Dojo reports version ${version}, and this directory requires ${minimum} or newer. `
+ "Earlier versions do not serve the endpoints listings are checked against. Upgrade, then submit again." };
}
return { ok: true, version, source };
}
+160
View File
@@ -0,0 +1,160 @@
// =============================================================================
// Verified operator domains.
//
// One domain per operator, bound to their BIP47 payment code and proven in both
// directions, so neither half alone is enough:
//
// the domain asserts the code a TXT record at _dojobay.<domain> naming the
// payment code; publishing it needs control of
// the domain
// the code asserts the domain a wallet-signed statement naming the domain;
// producing it needs the PayNym's notification
// key
//
// The signature is permanent and the TXT record is the revocable half. Remove
// the record and the next sweep fails; after a grace period the badge drops,
// while the claim is kept so restoring the record restores the badge without
// re-signing. A domain that changes hands therefore stops being claimable by
// its old owner without anyone having to notice.
//
// The signed statement deliberately omits this instance's onion, so a proof is
// portable: a bootstrap import or peer sync carries it intact.
//
// A verified badge attests to CONTROL of a domain, not to trustworthiness: a
// lookalike domain verifies exactly as easily as a real one. Hence admin
// revocation, and punycode display for anything non-ASCII.
// =============================================================================
import { claimText, verifySignedUrlClaim } from "./crypto.ts";
import { txtRecordAgreed } from "./dns.ts";
import type { TxtAgreement } from "./dns.ts";
import type { DomainClaim, ProbeCfg } from "../types.js";
type LookupCfg = Partial<ProbeCfg>;
/** A normalised domain, or the reason the input could not be one. */
export type NormalisedDomain =
| { ok: true; domain: string; punycode: boolean; error?: undefined }
| { ok: false; error: string; domain?: undefined; punycode?: undefined };
export interface ClaimVerification {
ok: boolean;
stage?: "signature" | "dns";
error?: string;
hint?: string | null;
inconclusive?: boolean;
agreed?: number;
answered?: number;
}
export const TXT_PREFIX = "_dojobay";
export const CLAIM_VERSION = "dojobay-domain-v1";
export const RECHECK_MS = +(process.env.DOMAIN_RECHECK_HOURS || 24) * 3600 * 1000;
/** A claim awaiting its first successful lookup is retried far more often: the
* operator has just published a TXT record and is waiting for propagation. */
export const PENDING_RECHECK_MS = +(process.env.DOMAIN_PENDING_RECHECK_MINUTES || 5) * 60 * 1000;
export const GRACE_DAYS = +(process.env.DOMAIN_GRACE_DAYS || 7);
// Accept "example.com", "example.com/", "https://example.com" or a full URL and
// reduce it to the bare ASCII host. Rejects anything that cannot be a public
// domain an operator could publish a TXT record on.
export function normaliseDomain(input: unknown): NormalisedDomain {
let raw = String(input || "").trim().toLowerCase();
if (!raw) return { ok: false, error: "enter a domain" };
if (raw.includes(" ")) return { ok: false, error: "a domain cannot contain spaces" };
if (!/^[a-z][a-z0-9+.-]*:\/\//.test(raw)) raw = "https://" + raw;
let u: URL;
try { u = new URL(raw); } catch { return { ok: false, error: "that is not a valid domain" }; }
if (u.protocol !== "https:" && u.protocol !== "http:") return { ok: false, error: "use a plain domain, not a " + u.protocol.replace(":", "") + " URL" };
if (u.username || u.password) return { ok: false, error: "a domain cannot contain a username or password" };
if (u.port) return { ok: false, error: "leave the port off: verification uses DNS, not a web server" };
const host = u.hostname; // WHATWG URL gives punycode for IDN
if (host.endsWith(".onion")) return { ok: false, error: "an onion address cannot be verified by DNS; this field is for a clearnet domain you own" };
if (host === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.startsWith("[")) {
return { ok: false, error: "use a domain name, not an IP address" };
}
if (!/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/.test(host)) {
return { ok: false, error: "that is not a valid domain name" };
}
if (host.split(".").length < 2) return { ok: false, error: "include the full domain, for example example.com" };
return { ok: true, domain: host, punycode: /[^\x00-\x7F]/.test(String(input)) || host.includes("xn--") };
}
export const txtName = (domain: string): string => `${TXT_PREFIX}.${domain}`;
/** The Host/Name field in a DNS panel is relative to the zone, so most control
* panels want just this label. Handing over the fully-qualified name instead is
* the classic way to end up with _dojobay.example.com.example.com. */
export const txtHost = (): string => TXT_PREFIX;
export const txtValue = (paymentCode: string): string => `${CLAIM_VERSION} pm=${paymentCode}`;
export const signingText = (domain: string, paymentCode: string): string => claimText(`https://${domain}`, paymentCode);
// Does a TXT record claim this payment code? Tolerant of extra whitespace and
// of the record being wrapped in quotes by a DNS UI, strict about the code.
export function txtMatches(record: unknown, paymentCode: string): boolean {
const r = String(record || "").trim().replace(/^"|"$/g, "").replace(/\s+/g, " ");
if (!r.startsWith(CLAIM_VERSION)) return false;
const m = r.match(/\bpm=(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
return !!m && m[1] === paymentCode;
}
// Full verification: the signature first (cheap, local, and the operator's most
// likely mistake), then DNS (slow, over Tor).
export async function verifyClaim(
{ domain, paymentCode, signed }: { domain: string; paymentCode: string; signed: string },
cfg: LookupCfg = {},
): Promise<ClaimVerification> {
const sig = verifySignedUrlClaim({ signed, expectedUrl: `https://${domain}`, paymentCode });
if (!sig.ok) return { ok: false, stage: "signature", error: sig.error };
const dns = await txtRecordAgreed(txtName(domain), (r) => txtMatches(r, paymentCode), cfg);
if (!dns.ok) {
return { ok: false, stage: "dns", inconclusive: !!dns.inconclusive, error: dns.error,
hint: dns.inconclusive ? null : `publish a TXT record at ${txtName(domain)} containing: ${txtValue(paymentCode)}` };
}
return { ok: true, agreed: dns.agreed, answered: dns.answered };
}
// DNS-only re-check for the periodic sweep: the signature is immutable once
// accepted, so there is nothing to re-verify locally.
export async function recheckClaim(claim: DomainClaim, cfg: LookupCfg = {}): Promise<TxtAgreement> {
return txtRecordAgreed(txtName(claim.domain), (r) => txtMatches(r, claim.paymentCode), cfg);
}
// Fold a re-check result into a claim, applying the grace period. Pure, so the
// policy is testable without any network.
export function applyRecheck(claim: DomainClaim, result: TxtAgreement, now: number = Date.now()): DomainClaim {
const next: DomainClaim = { ...claim, last_check: new Date(now).toISOString() };
if (result.inconclusive) {
// Could not tell. Change nothing except the timestamp, and say so.
next.last_result = "inconclusive: " + (result.error || "no detail");
return next;
}
if (result.ok) {
next.verified = true;
next.verified_at = next.verified_at || new Date(now).toISOString();
next.fail_since = null;
next.last_result = "ok";
return next;
}
next.last_result = result.error || "no matching TXT record";
next.fail_since = claim.fail_since || new Date(now).toISOString();
const failingMs = now - Date.parse(next.fail_since);
if (failingMs >= GRACE_DAYS * 86400 * 1000) next.verified = false;
return next;
}
export const isDue = (claim: DomainClaim, now: number = Date.now()): boolean => {
if (!claim.last_check) return true;
const since = now - Date.parse(claim.last_check);
return since >= (claim.verified ? RECHECK_MS : PENDING_RECHECK_MS);
};
// A URL is publishable as a card link only if it sits on the operator's verified
// domain (the domain itself or a subdomain of it). This is what stops a card
// carrying an unverifiable social profile while keeping "link to my own site".
export function urlOnDomain(url: unknown, domain: string | null | undefined): boolean {
if (!url || !domain) return false;
let u: URL;
try { u = new URL(String(url)); } catch { return false; }
if (u.protocol !== "https:" && u.protocol !== "http:") return false;
const h = u.hostname.toLowerCase();
return h === domain || h.endsWith("." + domain);
}
@@ -0,0 +1,124 @@
#!/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.");
+25
View File
@@ -0,0 +1,25 @@
// Launcher for the backend, which lives in index.ts.
//
// This file stays plain JavaScript on purpose, for three reasons:
//
// 1. It can be parsed by ANY Node version, so an operator on an older runtime
// gets the message below instead of a syntax error from a .ts file they
// cannot execute. The check must run before the import, hence the dynamic
// import rather than a static one.
// 2. systemd, `npm start` and the README all name index.mjs, so nothing about
// deployment changes.
// 3. self-update.mjs sanity-checks that an update archive contains
// server/index.mjs before accepting it. Renaming this file outright would
// make every legitimate update look malformed.
const major = Number(process.versions.node.split(".")[0]);
if (Number.isNaN(major) || major < 24) {
console.error(
`The Dojo Bay backend needs Node 24 or newer (found ${process.versions.node}).\n` +
"It runs TypeScript directly, which relies on type stripping added in Node 24,\n" +
"and its BIP47 libraries require it too. Upgrade Node, then restart the service.");
process.exit(1);
}
const mod = await import("./index.ts");
export const server = mod.server;
export const routes = mod.routes;
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
{
"name": "dojobay-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dojobay-server",
"version": "1.0.0",
"dependencies": {
"@bitcoinerlab/secp256k1": "1.2.0",
"@dojo-tools/auth47": "2.0.0",
"@dojo-tools/bip47": "2.0.0",
"@dojo-tools/bitcoinjs-message": "4.0.0"
},
"devDependencies": {
"bip39": "3.1.0"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@bitcoinerlab/secp256k1": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
"integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.7.0"
}
},
"node_modules/@dojo-tools/auth47": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@dojo-tools/auth47/-/auth47-2.0.0.tgz",
"integrity": "sha512-uoSG3MGy0TLanBftq49v9MdcFgy4xzCVU2iUUWEpQreFvfeVuxQbaTMGi5g7R0UafLiVVMIFf1rcF6JvvkxHbQ==",
"license": "LGPL-3.0",
"dependencies": {
"@dojo-tools/bip47": "2.0.0",
"@dojo-tools/bitcoinjs-message": "4.0.0"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@dojo-tools/bip47": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@dojo-tools/bip47/-/bip47-2.0.0.tgz",
"integrity": "sha512-0B5nlP/71ArOY8yPOjuJacPPiHPeyZOlI8618VJcfiibZQGugEFV3HIG5Doxo6sSSwU5NrX/96OF6KG4pmzsOA==",
"license": "LGPL-3.0",
"dependencies": {
"@noble/hashes": "2.2.0",
"@scure/base": "^2.0.0",
"bip32": "5.0.1"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@dojo-tools/bitcoinjs-message": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@dojo-tools/bitcoinjs-message/-/bitcoinjs-message-4.0.0.tgz",
"integrity": "sha512-bVK5tsrORzZ3aMgI5kn9YcfAqJzdiObFL/3Pxg8+ouTsXdyDOrBoITpCniemLxMUCanXUWou9LWg7Th5PzFLOg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0",
"@scure/base": "^2.0.0",
"varuint-bitcoin": "^2.0.0"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/base-x": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
"license": "MIT"
},
"node_modules/bip32": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/bip32/-/bip32-5.0.1.tgz",
"integrity": "sha512-PWlHIAgYCfVhwqNpZyeakHXuLAGyN6rEQZnhxHxKI3BoFJRVWLl26455fhRlHsmbYcV986HqtPnt33Edu5sTCw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.2.0",
"@scure/base": "^1.1.1",
"uint8array-tools": "^0.0.8",
"valibot": "^1.2.0",
"wif": "^5.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/bip32/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/bip32/node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/bip39": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz",
"integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==",
"dev": true,
"license": "ISC",
"dependencies": {
"@noble/hashes": "^1.2.0"
}
},
"node_modules/bip39/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/bs58": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
"license": "MIT",
"dependencies": {
"base-x": "^5.0.0"
}
},
"node_modules/bs58check": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz",
"integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.2.0",
"bs58": "^6.0.0"
}
},
"node_modules/bs58check/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/uint8array-tools": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/valibot": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
"integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
"license": "MIT",
"peerDependencies": {
"typescript": ">=5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/varuint-bitcoin": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.8"
}
},
"node_modules/wif": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
"integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==",
"license": "MIT",
"dependencies": {
"bs58check": "^4.0.0"
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "dojobay-server",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Auth47-gated self-service submission API for The Dojo Bay.",
"engines": {
"node": ">=24"
},
"scripts": {
"start": "node index.mjs",
"build-public": "node build-public.mjs",
"test": "node selftest.mjs"
},
"dependencies": {
"@dojo-tools/auth47": "2.0.0",
"@dojo-tools/bip47": "2.0.0",
"@dojo-tools/bitcoinjs-message": "4.0.0",
"@bitcoinerlab/secp256k1": "1.2.0"
},
"devDependencies": {
"bip39": "3.1.0"
}
}
+95
View File
@@ -0,0 +1,95 @@
// PayNym.rs lookup. paynym.rs runs the same API the historical Samourai server
// exposed, and offers both a clearnet host and a Tor onion. We prefer the onion
// (the box already has a SOCKS proxy for the connection gate, and it keeps the
// lookup inside Tor), falling back to clearnet.
//
// The call is POST {base}/api/v1/nym body {"nym": "<payment code>"} and the
// response carries the registered nym label. This resolution is ALWAYS
// best-effort: any failure returns null and callers must carry on, because a
// paynym.rs outage must never block a submission or an approval.
import { socks5Connect, PROBE_CFG } from "./probe.mjs";
// Override via env if the onion address changes.
const PAYNYM_ONION = process.env.PAYNYM_ONION
|| "http://paynym25chftmsywv4v2r67agbrr62lcxagsf4tymbzpeeucucy2ivad.onion";
const PAYNYM_CLEARNET = process.env.PAYNYM_CLEARNET || "https://paynym.rs";
// Pull the human label out of whatever shape the API returns. The legacy API
// nests it under codes[].claimed / nymName; we probe a few known keys so a
// minor schema change degrades to "not found" rather than a wrong value.
function extractNym(obj) {
if (!obj || typeof obj !== "object") return null;
const direct = obj.nymName || obj.nym_name || obj.nym;
if (typeof direct === "string" && direct.length) return direct;
if (Array.isArray(obj.codes) && obj.codes[0] && typeof obj.codes[0].claimed === "string") return obj.codes[0].claimed;
return null;
}
// Minimal HTTP POST over a SOCKS5 stream (onion), reading the JSON body.
function postOverTor(onionUrl, path, jsonBody, timeoutMs) {
return new Promise(async (resolve) => {
let socket;
try {
const u = new URL(onionUrl);
socket = await socks5Connect(PROBE_CFG.proxyHost, PROBE_CFG.proxyPort, u.hostname, +(u.port || 80), timeoutMs);
} catch { return resolve(null); }
const body = Buffer.from(JSON.stringify(jsonBody), "utf8");
const host = new URL(onionUrl).hostname;
const req =
`POST ${path} HTTP/1.0\r\nHost: ${host}\r\nContent-Type: application/json\r\n` +
`Content-Length: ${body.length}\r\nConnection: close\r\n\r\n`;
let buf = "";
const done = (v) => { try { socket.destroy(); } catch {} resolve(v); };
const timer = setTimeout(() => done(null), timeoutMs);
socket.on("data", (d) => { buf += d.toString("utf8"); });
socket.on("close", () => {
clearTimeout(timer);
const i = buf.indexOf("\r\n\r\n");
if (i < 0) return resolve(null);
try { resolve(JSON.parse(buf.slice(i + 4))); } catch { resolve(null); }
});
socket.on("error", () => done(null));
socket.write(req + body.toString("utf8"));
});
}
async function postClearnet(base, paymentCode, timeoutMs) {
if (typeof fetch !== "function") return null;
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
const r = await fetch(base + "/api/v1/nym", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nym: paymentCode }),
signal: ctrl.signal,
});
clearTimeout(t);
if (!r.ok) return null;
return await r.json();
} catch { return null; }
}
// Fetch the raw nym document (codes[], nymName, ...) for a handle or payment
// code, Tor first. Returns the parsed object or null. Never throws.
export async function fetchNymInfo(nymOrCode, { timeoutMs = 20000, preferTor = true } = {}) {
if (!nymOrCode) return null;
let obj = null;
if (preferTor) obj = await postOverTor(PAYNYM_ONION, "/api/v1/nym", { nym: nymOrCode }, timeoutMs);
if (!obj) obj = await postClearnet(PAYNYM_CLEARNET, nymOrCode, timeoutMs);
return obj && typeof obj === "object" ? obj : null;
}
// Every BIP47 code variant registered for a PayNym (segwit + legacy), because
// the wallet may sign Auth47 with either. [] when unresolvable.
export async function fetchNymCodes(nymOrCode, opts) {
const info = await fetchNymInfo(nymOrCode, opts);
return Array.isArray(info?.codes) ? info.codes.filter((c) => c && typeof c.code === "string") : [];
}
// Resolve a payment code to its registered PayNym label, or null. Never throws.
export async function resolvePayNym(paymentCode, opts) {
const name = extractNym(await fetchNymInfo(paymentCode, opts));
if (!name) return null;
return name.startsWith("+") ? name : "+" + name;
}
+10
View File
@@ -0,0 +1,10 @@
// On-demand Tor reachability check, reusing the exact probe from the updater so
// the self-service "connection gate" and the cron checker agree.
export { probe, socks5Connect } from "../scripts/update.mjs";
export const PROBE_CFG = {
proxyHost: process.env.TOR_SOCKS_HOST || "127.0.0.1",
proxyPort: +(process.env.TOR_SOCKS_PORT || 9050),
timeoutMs: +(process.env.TIMEOUT_MS || 30000),
connectOnly: process.env.CONNECT_ONLY === "1",
};
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env node
// =============================================================================
// The Dojo Bay — remove a listing and its reliability history.
//
// Deleting a record through the API leaves its history behind: unlisting stamps
// it `retired` and keeps it for HISTORY_GRACE_DAYS so that a node relisted
// within the window resurrects its uptime intact. That is right for a node
// coming back, and wrong for one being removed deliberately. This removes both,
// now.
//
// Usage, on the box:
// cd /var/www/dojobay/server
// node remove-listing.ts <record-id> # dry run
// sudo systemctl stop dojobay-server.service
// node remove-listing.ts --apply <record-id>
// sudo systemctl start dojobay-server.service
// node build-public.mjs
//
// As with the other write tools, --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. Both files are backed up first.
// =============================================================================
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 type { StoreRecord } from "../types.js";
const argv = process.argv.slice(2);
const APPLY = argv.includes("--apply");
const FORCE = argv.includes("--force");
const IDS = argv.filter((a) => !a.startsWith("--"));
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STORE_DIR = process.env.SERVER_DATA_DIR || path.join(HERE, "data");
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(HERE, "..", "data");
const STORE = path.join(STORE_DIR, "store.json");
const HISTORY = ["history.json", "history-daily.json"].map((f) => path.join(PUBLIC_DIR, f));
if (!IDS.length) {
console.error("Usage: node remove-listing.ts [--apply] <record-id>…\n" +
"Record ids are shown by audit-signed.mjs, for example mainnet-kilombino.");
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 remove-listing.ts --apply <record-id>\n" +
" sudo systemctl start dojobay-server.service");
process.exit(2);
}
}
const readJSON = async (p: string, fallback: any) => {
try { return JSON.parse(await readFile(p, "utf8")); } catch { return fallback; }
};
// A temporary name no other writer can take; see build-public.ts. The counter
// matters as well as the pid, because one run rewrites both the store and the
// seed in quick succession.
let tmpSeq = 0;
const writeAtomic = async (p: string, doc: any) => {
const tmp = `${p}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
await writeFile(tmp, JSON.stringify(doc, null, 2) + "\n");
await rename(tmp, p);
};
const store = await readJSON(STORE, { submissions: {} });
const found: StoreRecord[] = [];
const missing: string[] = [];
for (const id of IDS) {
const rec = store.submissions?.[id];
if (rec) found.push(rec); else missing.push(id);
}
console.log(`Store: ${STORE}`);
console.log(`History: ${PUBLIC_DIR}\n`);
for (const rec of found) {
const codes = (rec.paymentCodes || []).length;
console.log(` ${rec.id} (${rec.status})`);
console.log(` name ${rec.name || "(none)"}`);
console.log(` onion ${rec.payload?.pairing?.url || "(none)"}`);
console.log(` codes ${codes || "NONE — this listing has no owner"}`);
}
for (const id of missing) console.log(` ${id}: not in the store`);
console.log("");
let histCounts: Record<string, number> = {};
for (const f of HISTORY) {
const doc = await readJSON(f, { nodes: {} });
histCounts[path.basename(f)] = IDS.filter((id) => doc.nodes && doc.nodes[id]).length;
}
console.log("History entries to remove: " +
Object.entries(histCounts).map(([f, n]) => `${f}: ${n}`).join(", ") + "\n");
if (!found.length && !Object.values(histCounts).some(Boolean)) {
console.log("Nothing to remove."); process.exit(missing.length ? 1 : 0);
}
if (!APPLY) {
console.log("DRY RUN — nothing written. Re-run with --apply (service stopped) to remove.");
process.exit(0);
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
await copyFile(STORE, `${STORE}.bak-${stamp}`);
for (const rec of found) delete store.submissions[rec.id];
await writeAtomic(STORE, store);
console.log(`Backup written: ${STORE}.bak-${stamp}`);
for (const f of HISTORY) {
const doc = await readJSON(f, null);
if (!doc || !doc.nodes) continue;
let touched = false;
for (const id of IDS) if (doc.nodes[id]) { delete doc.nodes[id]; touched = true; }
if (!touched) continue;
await copyFile(f, `${f}.bak-${stamp}`);
await writeAtomic(f, doc);
console.log(`Purged history from ${path.basename(f)} (backup alongside).`);
}
console.log(`\nRemoved ${found.length} listing(s). Start the service, then run\n` +
"build-public.mjs to republish, and audit-signed.mjs to confirm the result.");
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
// Tiny JSON-file store for the backend. Single-writer (one server process),
// atomic writes, no external database. Holds submissions, live sessions and
// outstanding Auth47 nonces.
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomBytes } from "node:crypto";
import type { StoreRecord, DomainClaim } from "../types.js";
/** A short-lived, single-use Auth47 nonce. */
export interface Nonce { expires: number; [k: string]: unknown }
/** A signed-in operator's session, keyed by a random cookie id. */
export interface Session { paymentCode: string; expires: number; [k: string]: unknown }
interface StoreShape {
submissions: Record<string, StoreRecord>;
sessions: Record<string, Session>;
nonces: Record<string, Nonce>;
domains: Record<string, DomainClaim>;
}
const DIR = process.env.SERVER_DATA_DIR
|| path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data");
const FILE = path.join(DIR, "store.json");
const EMPTY: StoreShape = { submissions: {}, sessions: {}, nonces: {}, domains: {} };
let cache: StoreShape | null = null;
// Whether a record carries a signed pairing block at all. A shape check, not a
// verification: the submit gate decided whether the block verifies against the
// operator's own payment code, and re-deriving that at every read would mean
// the store and the rebuild silently dropping listings over a cryptographic
// judgement made elsewhere. This asks only what a caller is entitled to ask
// here, which is whether there is anything for a visitor to check.
// server/audit-signed.mjs is the tool that re-runs the real verification over
// the whole store. It lives in this file rather than beside the verifier so
// that store.ts stays on node builtins alone: remove-listing.ts and the
// migration scripts import the store, and should not have to pull in secp256k1
// to ask a question about a string.
export function hasSignedBlock(rec: { signed?: string | null } | null | undefined): boolean {
const signed = typeof rec?.signed === "string" ? rec.signed.trim() : "";
return signed.includes("BEGIN BITCOIN SIGNED MESSAGE") && signed.includes("BEGIN BITCOIN SIGNATURE");
}
// A submission's ownership is a paymentCodes ARRAY, because one PayNym often
// carries two BIP47 codes (segwit and legacy variants) and the wallet may sign
// Auth47 with either. Records written before this schema carried a scalar
// paymentCode; normalise those on read so old store files keep working.
function normaliseSubmission<T>(rec: T): T {
if (!rec || typeof rec !== "object") return rec;
const r = rec as { paymentCodes?: unknown; paymentCode?: string };
if (!Array.isArray(r.paymentCodes)) {
r.paymentCodes = r.paymentCode ? [r.paymentCode] : [];
}
r.paymentCodes = [...new Set((r.paymentCodes as unknown[]).filter((c): c is string => typeof c === "string" && !!c))];
delete r.paymentCode;
return rec;
}
async function load(): Promise<StoreShape> {
if (cache) return cache;
await mkdir(DIR, { recursive: true });
try {
cache = { ...EMPTY, ...JSON.parse(await readFile(FILE, "utf8")) };
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
cache = structuredClone(EMPTY);
}
for (const rec of Object.values(cache.submissions)) normaliseSubmission(rec);
return cache;
}
// A temporary name no other writer can take; see build-public.ts. The store has
// a single writer by design, but the backend and a maintenance script can both
// be pointed at it, and that is precisely when a shared temporary name bites.
let tmpSeq = 0;
async function persist() {
const tmp = `${FILE}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`;
await writeFile(tmp, JSON.stringify(cache, null, 2) + "\n");
await rename(tmp, FILE);
}
export const store = {
async get() { return load(); },
async save() { await persist(); },
// --- nonces (single-use, short lived) ---
async putNonce(nonce: string, data: Nonce) { (await load()).nonces[nonce] = data; await persist(); },
async takeNonce(nonce: string): Promise<Nonce | null> {
const s = await load();
const n = s.nonces[nonce];
if (n) { delete s.nonces[nonce]; await persist(); }
return n || null;
},
async gcNonces(now: number = Date.now()) {
const s = await load();
let changed = false;
for (const [k, v] of Object.entries(s.nonces)) {
if (!v || v.expires < now) { delete s.nonces[k]; changed = true; }
}
if (changed) await persist();
},
// --- sessions ---
async putSession(data: Session): Promise<string> {
const s = await load();
const id = randomBytes(32).toString("hex");
s.sessions[id] = data;
await persist();
return id;
},
async getSession(id: string | null | undefined): Promise<Session | null> {
if (!id) return null;
const s = await load();
const sess = s.sessions[id];
if (!sess) return null;
if (sess.expires < Date.now()) { delete s.sessions[id]; await persist(); return null; }
return sess;
},
async dropSession(id: string) {
const s = await load();
if (s.sessions[id]) { delete s.sessions[id]; await persist(); }
},
// --- submissions (keyed by network + name slug; owned by paymentCodes[]) ---
async listSubmissions(): Promise<StoreRecord[]> { return Object.values((await load()).submissions); },
async submissionsFor(paymentCode: string): Promise<StoreRecord[]> {
return Object.values((await load()).submissions)
.filter((r) => Array.isArray(r.paymentCodes) && r.paymentCodes.includes(paymentCode));
},
// Every record must carry at least one BIP47 payment code and a signed
// pairing block. This is the single chokepoint through which every write to
// the store passes, so enforcing both here is what makes an unowned or
// unattested listing structurally impossible rather than merely discouraged:
// the payment code is the identity the directory rests on, and the signature
// is what lets a visitor check the pairing details against that identity
// without trusting this site at all. A listing without one cannot be owned,
// edited, verified or recognised by a visitor; a listing without the other
// asks the visitor to take our word for an onion address and an API key,
// which is the one thing this directory exists not to require. Historically a
// few pre-Auth47 records existed without a code, and rather more predate the
// signature gate; both doors are now closed.
async putSubmission(rec: StoreRecord): Promise<StoreRecord> {
const normalised = normaliseSubmission(rec);
const codes = (normalised as StoreRecord).paymentCodes;
// An emptiness guard, deliberately, not a validator: whether a code is a
// real BIP47 payment code is settled at the gates that admit it — an Auth47
// session proves possession, and the signature checks derive its
// notification address. What must be impossible HERE is a listing with no
// owner at all.
if (!Array.isArray(codes) || !codes.some((c) => typeof c === "string" && /^PM\w{6,}/.test(c.trim()))) {
throw new Error(`refusing to store ${rec?.id}: a listing must carry a BIP47 payment code`);
}
// The same kind of guard for the signature: a shape check, not a
// verification. Whether the block verifies against the record's own code is
// settled at the submit and pairing-edit gates, which have the session and
// the canonical message to hand and can say precisely what is wrong. What
// must be impossible HERE is a record whose pairing details nobody has
// attested to, however it was assembled — by an admin action, an import, a
// migration or a future endpoint that has not been written yet.
if (!hasSignedBlock(normalised)) {
throw new Error(`refusing to store ${rec?.id}: a listing must carry a signed pairing block. ` +
`Ask the operator to sign their pairing payload, or remove the listing with server/remove-listing.ts.`);
}
const s = await load();
s.submissions[rec.id] = normalised;
await persist();
return rec;
},
async getSubmission(id: string): Promise<StoreRecord | null> {
const rec = (await load()).submissions[id] || null;
return rec ? normaliseSubmission(rec) : null;
},
// Retention: a rejected submission is kept briefly so a maintainer can reverse
// a mistake, then deleted. Nothing else ever removed one, so the store
// accumulated the payment code, pairing payload and signature of every
// operator ever turned down — including the apikey, which is a live
// credential to their Dojo, not merely metadata. Returns the ids removed.
async pruneRejected(days: number, now: number = Date.now()): Promise<string[]> {
const s = await load();
const cutoff = now - days * 86400 * 1000;
const gone: string[] = [];
for (const [id, rec] of Object.entries(s.submissions)) {
if (rec?.status !== "rejected") continue;
const stamp = Date.parse(rec.updated_at || rec.created_at || "");
// A record with no usable timestamp is pruned rather than kept forever.
if (Number.isFinite(stamp) && stamp > cutoff) continue;
delete s.submissions[id];
gone.push(id);
}
if (gone.length) await persist();
return gone;
},
async deleteSubmission(id: string) {
const s = await load();
if (s.submissions[id]) { delete s.submissions[id]; await persist(); }
},
// --- verified operator domains (keyed by payment code) ---------------------
// One claim per code. A record is kept even after it stops verifying, so
// restoring the TXT record restores the badge without a fresh signature.
async listDomains(): Promise<DomainClaim[]> { return Object.values((await load()).domains || {}); },
async getDomain(paymentCode: string): Promise<DomainClaim | null> { return ((await load()).domains || {})[paymentCode] || null; },
async putDomain(claim: DomainClaim): Promise<DomainClaim> {
const s = await load();
s.domains = s.domains || {};
s.domains[claim.paymentCode] = claim;
await persist();
return claim;
},
async deleteDomain(paymentCode: string) {
const s = await load();
if (s.domains && s.domains[paymentCode]) { delete s.domains[paymentCode]; await persist(); }
},
// Every verified domain, as a payment code -> domain map, for the rebuild.
async verifiedDomainMap(): Promise<Map<string, string>> {
const out = new Map<string, string>();
for (const c of Object.values((await load()).domains || {})) {
if (c && c.verified && c.domain) out.set(c.paymentCode, c.domain);
}
return out;
},
};
+236
View File
@@ -0,0 +1,236 @@
// How far behind is this instance? Compares the local data/version.json
// commit against the GitHub repository, over Tor (TLS through the same SOCKS
// tunnel the probes use), and reports commits behind plus releases published
// since this instance was built. Consumed by GET /api/admin/updates for the
// admin console's update line. Everything degrades gracefully: if GitHub is
// unreachable over Tor, the endpoint says so rather than failing the panel.
//
// "Releases behind" resolves release tags to commits and compares identity, so
// an instance running the exact commit of the newest release reports zero. The
// earlier version counted releases published after the local build timestamp,
// which always reported one behind: a tag is created after the commit it points
// at has been built and deployed. When the running commit is not itself a
// released tag, it falls back to that timestamp guess and flags it as such.
import tls from "node:tls";
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { socks5Connect } from "../scripts/update.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const GITHUB_REPO = process.env.GITHUB_REPO || "Dojobay/dojobay";
const API_HOST = "api.github.com";
// The request line and headers, separated out so the Accept value is testable
// without a network or a TLS mock. It is not a detail: a download used to ask
// for `application/octet-stream`, and GitHub's archive route answers 415
// Unsupported Media Type to that, so self-update never got past its first
// request and no operator ever saw it work. Verified against the live endpoint:
// octet-stream returns 415, while both `application/vnd.github+json` and `*/*`
// return the 302 to codeload that this transport already follows.
//
// `*/*` rather than the JSON type, because a download genuinely will take
// whatever the route serves and saying so is true; asking for JSON to obtain a
// zip works only by convention and would be the next thing to break quietly.
// The metadata calls keep the JSON type, which is what those routes serve and
// what pins the API version.
export function githubRequestHead(apiPath, host, { binary = false } = {}) {
return `GET ${apiPath} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: dojobay-update-check\r\n` +
`Accept: ${binary ? "*/*" : "application/vnd.github+json"}\r\n` +
`Accept-Encoding: identity\r\nConnection: close\r\n\r\n`;
}
// One HTTPS GET over the Tor SOCKS proxy. Handles chunked replies, returns
// both a text `body` and a raw `bodyBuf`, and follows GitHub's redirect from
// api.github.com to codeload for zipball downloads (binary: true) up to a few
// hops. Host is derived per hop so codeload.github.com is reached correctly.
/**
* @param {string} apiPath
* @param {{ proxyHost?: string, proxyPort?: number, timeoutMs?: number,
* binary?: boolean, _host?: string, _hops?: number }} [opts]
*/
export async function githubGet(apiPath, { proxyHost, proxyPort, timeoutMs = 30000, binary = false, _host = API_HOST, _hops = 0 } = {}) {
const raw = await socks5Connect(proxyHost, proxyPort, _host, 443, timeoutMs);
const res = await new Promise((resolve, reject) => {
const timer = setTimeout(() => { socket.destroy(); reject(new Error("timeout")); }, timeoutMs);
const socket = tls.connect({ socket: raw, servername: _host }, () => {
socket.write(githubRequestHead(apiPath, _host, { binary }));
});
const chunks = [];
socket.on("data", (d) => chunks.push(d));
socket.on("error", (e) => { clearTimeout(timer); reject(e); });
socket.on("close", () => {
clearTimeout(timer);
try {
const all = Buffer.concat(chunks);
const headEnd = all.indexOf("\r\n\r\n");
if (headEnd < 0) return reject(new Error("malformed reply"));
const headText = all.subarray(0, headEnd).toString("latin1");
const m = headText.match(/^HTTP\/1\.[01] (\d{3})/);
if (!m) return reject(new Error("malformed reply"));
const status = +m[1];
const locM = headText.match(/\r\nlocation:\s*([^\r\n]+)/i);
let bodyBuf = all.subarray(headEnd + 4);
if (/transfer-encoding:\s*chunked/i.test(headText)) {
const parts = []; let p = 0;
for (;;) {
const nl = bodyBuf.indexOf("\r\n", p);
if (nl < 0) break;
const size = parseInt(bodyBuf.subarray(p, nl).toString("latin1"), 16);
if (!size) break;
parts.push(bodyBuf.subarray(nl + 2, nl + 2 + size));
p = nl + 2 + size + 2;
}
bodyBuf = Buffer.concat(parts);
}
resolve({ status, location: locM ? locM[1].trim() : null, bodyBuf });
} catch (e) { reject(e); }
});
});
if ([301, 302, 307, 308].includes(res.status) && res.location && _hops < 4) {
const u = new URL(res.location);
return githubGet(u.pathname + u.search, { proxyHost, proxyPort, timeoutMs, binary, _host: u.hostname, _hops: _hops + 1 });
}
return { status: res.status, body: res.bodyBuf.toString("utf8"), bodyBuf: res.bodyBuf };
}
/** What to tell an operator when GitHub refuses a request.
*
* A bare "HTTP 403" reads as something broken here, and it is not: GitHub
* allows sixty unauthenticated requests an hour PER IP ADDRESS, and a Tor exit
* is one address shared with everyone else using it, so an instance can arrive
* at an exit whose hour was already spent by strangers. Observed on a live
* instance: limit 60, remaining 0, used 60, for an exit nobody here had made a
* single request through.
*
* It clears by itself when the window rolls over, and often sooner on a new
* circuit, since the limit follows the exit rather than the client. Saying so
* is the difference between an operator waiting and an operator going looking
* for a fault that does not exist. 429 is included because GitHub uses it for
* secondary limits and means the same thing to a reader.
*/
export function githubRefusal(what, status) {
if (status === 403 || status === 429) {
// No call-site prefix. Which of the three requests hit the limit is of no
// use to an operator, and "compare: GitHub is rate-limiting..." reads as
// though "compare" were a thing that had gone wrong.
return `GitHub is rate-limiting this Tor exit (HTTP ${status}). `
+ "The limit is per exit address and shared with every other user of it, so it clears on its "
+ "own within the hour, usually sooner on a new circuit. Updating from a peer .onion does not "
+ "touch GitHub and works meanwhile.";
}
return `${what}: HTTP ${status}`;
}
export async function checkUpdates({ repo = GITHUB_REPO, transport = githubGet, cfg = {} } = {}) {
const verPath = path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "version.json");
const version = JSON.parse(await readFile(verPath, "utf8"));
if (!version.commit || version.commit === "dev") throw new Error("local version.json has no deployed commit");
const cmp = await transport(`/repos/${repo}/compare/${encodeURIComponent(version.commit)}...main`, cfg);
if (cmp.status !== 200) throw new Error(githubRefusal("compare", cmp.status));
const compare = JSON.parse(cmp.body);
const rel = await transport(`/repos/${repo}/releases?per_page=30`, cfg);
if (rel.status !== 200) throw new Error(githubRefusal("releases", rel.status));
const releases = JSON.parse(rel.body);
// Which release are we actually running?
//
// This used to count releases published after the local build timestamp,
// which is wrong in the ordinary case: a tag is created AFTER the commit it
// points at has been built and deployed, so an instance running the exact
// commit of the newest release always reported itself one release behind.
//
// Resolve each release's tag to a commit instead and compare identity. If our
// commit IS a released tag, the number of releases published after it is the
// honest answer (zero, when we are on the latest). Only when no tag matches do
// we fall back to the timestamp approximation, and say so.
let tagSha = new Map();
let tagsError = null;
try {
const tg = await transport(`/repos/${repo}/tags?per_page=100`, cfg);
if (tg.status === 200) {
for (const t of JSON.parse(tg.body)) {
if (t?.name && t?.commit?.sha) tagSha.set(t.name, String(t.commit.sha));
}
} else {
tagsError = githubRefusal("tag lookup", tg.status);
}
} catch (e) {
tagsError = "tag lookup: " + (e?.message || "failed");
}
// version.json carries a short commit, the API a full sha; match either way.
const sameCommit = (a, b) => {
if (!a || !b) return false;
const x = String(a).toLowerCase(), y = String(b).toLowerCase();
return x.startsWith(y) || y.startsWith(x);
};
const runningIndex = releases.findIndex((r) => sameCommit(tagSha.get(r.tag_name), version.commit));
const approximate = runningIndex < 0;
const builtAt = Date.parse(version.built || 0) || 0;
// Three states, and the third used to be reported as the second.
//
// matched our commit IS a released tag: the count is exact.
// no match we are on an untagged commit mid-cycle: the timestamp count
// is a fair approximation, because we are genuinely not on a
// release.
// no tag data we could not look tags up at all, usually because a shared
// Tor exit hit GitHub's rate limit. The timestamp count is
// then WORSE than saying nothing: a tag is always created
// after its commit was built, so an instance running the very
// newest release scores one behind. Report null instead.
const releasesBehind = !approximate ? runningIndex
: tagsError ? null
: releases.filter((r) => Date.parse(r.published_at || 0) > builtAt).length;
return {
commit: version.commit,
built: version.built || null,
commits_behind: compare.ahead_by ?? 0, // main is ahead of us by this many
status: compare.status || "unknown", // identical | behind | ahead | diverged
latest_release: releases[0] ? releases[0].tag_name : null,
/** The release we are running, when our commit is exactly a released tag. */
current_release: approximate ? null : releases[runningIndex].tag_name,
releases_behind: releasesBehind,
/** True when releases_behind is the timestamp guess rather than an identity
* match, which happens when the running commit is not itself a released
* tag (mid-cycle, or a local build). */
releases_behind_approx: approximate,
/** Why the release could not be identified, when it could not. */
releases_note: tagsError,
repo,
checked_at: new Date().toISOString(),
};
}
/** Whether an update check may be answered from the cache, and what to tell the
* operator if a forced check was refused.
*
* Three rules, and the third is the only interesting one. An ordinary request
* takes the cache while it is fresh, because six hours is right for an
* unattended check over Tor where GitHub rate limits shared exit nodes. A
* forced request goes out. A forced request inside the floor is answered from
* the cache with the wait attached rather than refused, because the operator
* asked what the state is and the honest answer is the last one known plus how
* stale it is.
*
* Pure, and separate from the route, so the floor can be tested without a
* reachable GitHub: the route only fills its cache on success, so an
* unreachable GitHub means the cached path is never taken and the floor never
* runs. A rule that cannot be exercised is a rule nobody has checked.
*/
export function updateCacheDecision({ cachedAt = null, now = Date.now(), forced = false,
forcedAt = 0, ttlMs = 6 * 3600 * 1000, floorMs = 60 * 1000 } = {}) {
const fresh = cachedAt !== null && now - cachedAt < ttlMs;
if (!fresh) return { serveCached: false, waitS: 0 };
if (!forced) return { serveCached: true, waitS: 0 };
const since = now - forcedAt;
if (forcedAt && since < floorMs) {
return { serveCached: true, waitS: Math.ceil((floorMs - since) / 1000) };
}
return { serveCached: false, waitS: 0 };
}