2028 lines
123 KiB
JavaScript
2028 lines
123 KiB
JavaScript
#!/usr/bin/env node
|
|
// Offline end-to-end test of the backend. Spins a mock SOCKS proxy (so the
|
|
// connection gate passes without real Tor), simulates a wallet signing the
|
|
// Auth47 challenge and the pairing payload, and drives the HTTP API.
|
|
import net from "node:net";
|
|
import assert from "node:assert";
|
|
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";
|
|
import { mnemonicToSeedSync } from "bip39";
|
|
import os from "node:os";
|
|
import pathMod from "node:path";
|
|
|
|
// point the backend at a temp store + mock proxy BEFORE importing it
|
|
process.env.SERVER_DATA_DIR = "/tmp/dojobay-selftest";
|
|
process.env.BASE_URL = "http://exampledojobayonion.onion";
|
|
process.env.PORT = "0";
|
|
process.env.TOR_SOCKS_PORT = "19077";
|
|
// isolate the public data dir so admin approve's rebuild() never writes live data
|
|
process.env.PUBLIC_DATA_DIR = "/tmp/dojobay-selftest-data";
|
|
// make the simulated wallet's payment code an admin so /admin routes are testable
|
|
process.env.ADMIN_PAYMENT_CODES = BIP47Factory(ecc)
|
|
.fromSeed(mnemonicToSeedSync("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"))
|
|
.toPaymentCodePublic().toBase58();
|
|
await import("node:fs/promises").then(async (m) => {
|
|
await m.rm(process.env.SERVER_DATA_DIR, { recursive: true, force: true });
|
|
await m.rm(process.env.PUBLIC_DATA_DIR, { recursive: true, force: true });
|
|
await m.mkdir(process.env.PUBLIC_DATA_DIR, { recursive: true });
|
|
try { await m.copyFile(new URL("../data/seed.json", import.meta.url), process.env.PUBLIC_DATA_DIR + "/seed.json"); }
|
|
catch { await m.writeFile(process.env.PUBLIC_DATA_DIR + "/seed.json", JSON.stringify({ nodes: [] })); }
|
|
});
|
|
|
|
// always-up mock SOCKS5 proxy that plays the Dojo API (login + wallet tip),
|
|
// so the authenticated connection gate passes without real Tor.
|
|
const proxy = net.createServer((s) => {
|
|
let st = "g";
|
|
s.on("data", (d) => {
|
|
if (st === "g") { s.write(Buffer.from([5, 0])); st = "c"; return; }
|
|
if (st === "c") { s.write(Buffer.from([5, 0, 0, 1, 0, 0, 0, 0, 0, 0])); st = "t"; return; }
|
|
const req = d.toString("latin1");
|
|
let body;
|
|
if (req.includes("/auth/login")) body = JSON.stringify({ authorizations: { access_token: "tok" } });
|
|
else if (req.includes("/wallet")) body = JSON.stringify({ info: { latest_block: { height: 900000, time: 1 } } });
|
|
else { s.write("HTTP/1.0 404 x\r\n\r\n"); s.end(); return; }
|
|
s.write(`HTTP/1.0 200 OK\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`);
|
|
s.end();
|
|
});
|
|
s.on("error", () => {});
|
|
});
|
|
await new Promise((r) => proxy.listen(19077, "127.0.0.1", () => r(null)));
|
|
|
|
// The suite drives the server module itself. index.mjs is a launcher whose only
|
|
// job is to refuse an old Node before importing this; it is checked separately
|
|
// below rather than run here, so the suite is not gated on the host's version.
|
|
const { server } = await import("./index.ts");
|
|
await new Promise((r) => (server.listening ? r() : server.on("listening", r)));
|
|
const base = "http://127.0.0.1:" + /** @type {import("node:net").AddressInfo} */ (server.address()).port;
|
|
|
|
// --- simulated wallet ---
|
|
const bip47 = BIP47Factory(ecc), msg = bitcoinMessageFactory(ecc), net47 = bip47utils.networks.bitcoin;
|
|
const acct = bip47.fromSeed(mnemonicToSeedSync("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"));
|
|
const paymentCode = acct.toPaymentCodePublic().toBase58();
|
|
const priv = acct.getNotificationPrivateKey();
|
|
const notifAddr = acct.toPaymentCodePublic().getNotificationAddress();
|
|
|
|
let cookie = "";
|
|
async function api(path, method = "GET", body) {
|
|
const res = await fetch(base + path, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const sc = res.headers.get("set-cookie");
|
|
if (sc) cookie = sc.split(";")[0];
|
|
const txt = await res.text();
|
|
return { status: res.status, body: txt ? JSON.parse(txt) : null };
|
|
}
|
|
|
|
let passed = 0;
|
|
const ok = (c, label) => { assert.ok(c, label); passed++; console.log(" ok -", label); };
|
|
|
|
console.log("backend self-test");
|
|
|
|
// 1) login: challenge -> sign -> callback -> poll -> cookie
|
|
const ch = await api("/api/auth47/challenge", "POST", {});
|
|
ok(ch.status === 200 && ch.body.uri.startsWith("auth47://"), "challenge issued");
|
|
const signedChallenge = (() => { const u = new URL(ch.body.uri); u.searchParams.delete("c"); return decodeURIComponent(u.toString()); })();
|
|
const proofSig = Buffer.from(msg.sign(signedChallenge, priv, true, net47.messagePrefix)).toString("base64");
|
|
const cb = await api("/api/auth47/callback", "POST", { auth47_response: "1.0", challenge: signedChallenge, signature: proofSig, nym: paymentCode });
|
|
ok(cb.status === 200, "wallet proof accepted");
|
|
const poll = await api("/api/auth47/poll?nonce=" + ch.body.nonce);
|
|
ok(poll.status === 200 && poll.body.authenticated, "poll sets session");
|
|
const me = await api("/api/me");
|
|
ok(me.body.authenticated && me.body.paymentCode === paymentCode, "session bound to payment code");
|
|
|
|
// 2) wrong-signer proof is rejected
|
|
{
|
|
const ch2 = await api("/api/auth47/challenge", "POST", {});
|
|
const sc2 = (() => { const u = new URL(ch2.body.uri); u.searchParams.delete("c"); return decodeURIComponent(u.toString()); })();
|
|
const bad = bip47.fromSeed(mnemonicToSeedSync("legal winner thank year wave sausage worth useful legal winner thank yellow"));
|
|
const badSig = Buffer.from(msg.sign(sc2, bad.getNotificationPrivateKey(), true, net47.messagePrefix)).toString("base64");
|
|
const r = await api("/api/auth47/callback", "POST", { auth47_response: "1.0", challenge: sc2, signature: badSig, nym: paymentCode });
|
|
ok(r.status === 401, "mismatched signature rejected at login");
|
|
}
|
|
|
|
// 2b) THE RELAY. A proof is only evidence of what it was signed over, and the
|
|
// library can check that the challenge's r parameter is a well-formed URL
|
|
// but not that it is OURS. Without the binding, an attacker takes a live
|
|
// nonce from this instance, shows a victim the same challenge with r
|
|
// rewritten to their own site, and relays the signed result back here: the
|
|
// victim's wallet displays the attacker's site, the signature verifies, and
|
|
// a session is minted here in the victim's name.
|
|
{
|
|
const ch3 = await api("/api/auth47/challenge", "POST", {});
|
|
const relayed = (() => {
|
|
const u = new URL(ch3.body.uri);
|
|
u.searchParams.delete("c");
|
|
u.searchParams.set("r", "http://attacker7777777777777777777777777777777777777777777.onion");
|
|
return decodeURIComponent(u.toString());
|
|
})();
|
|
// Genuinely signed by the real operator, over the attacker's resource. This
|
|
// is the whole point: the signature is valid and the nonce is live.
|
|
const sig = Buffer.from(msg.sign(relayed, priv, true, net47.messagePrefix)).toString("base64");
|
|
const r = await api("/api/auth47/callback", "POST",
|
|
{ auth47_response: "1.0", challenge: relayed, signature: sig, nym: paymentCode });
|
|
ok(r.status === 401 && /different site/.test(r.body.error || ""),
|
|
"a validly signed proof naming another site is refused: " + JSON.stringify(r.body.error));
|
|
const p3 = await api("/api/auth47/poll?nonce=" + ch3.body.nonce);
|
|
ok(!p3.body.authenticated, "and no session is waiting to be collected with that nonce");
|
|
}
|
|
|
|
// 2c) the binding tolerates the differences that are not differences, and only
|
|
// those. A trailing slash and host case are the same site; a different
|
|
// origin or a path underneath it is not.
|
|
{
|
|
const { makeAuth47 } = await import("./crypto.ts");
|
|
const a47 = makeAuth47(process.env.BASE_URL);
|
|
const mk = async (resource) => {
|
|
const ch4 = await api("/api/auth47/challenge", "POST", {});
|
|
const u = new URL(ch4.body.uri);
|
|
u.searchParams.delete("c");
|
|
u.searchParams.set("r", resource);
|
|
const c = decodeURIComponent(u.toString());
|
|
const sg = Buffer.from(msg.sign(c, priv, true, net47.messagePrefix)).toString("base64");
|
|
return api("/api/auth47/callback", "POST",
|
|
{ auth47_response: "1.0", challenge: c, signature: sg, nym: paymentCode });
|
|
};
|
|
ok((await mk(process.env.BASE_URL + "/")).status === 200,
|
|
"a trailing slash is the same site");
|
|
ok((await mk(process.env.BASE_URL + "/somewhere")).status === 401,
|
|
"a path underneath it is not");
|
|
ok((await mk(process.env.BASE_URL.replace("http://", "https://"))).status === 401,
|
|
"nor is the same host on another scheme");
|
|
|
|
// and the shape itself: a caller who forgets the expectation gets a refusal
|
|
// rather than a silent pass, which is what made the original bug invisible.
|
|
const unbound = a47.verify({ auth47_response: "1.0", challenge: "auth47://x?r=http://y.onion",
|
|
signature: "AA==", nym: paymentCode });
|
|
ok(!unbound.ok && /expected resource/.test(unbound.error),
|
|
"verify refuses outright when no expected resource is given: " + JSON.stringify(unbound.error));
|
|
}
|
|
|
|
// 3) submit a Dojo with a valid signed payload -> passes both gates -> pending
|
|
const payload = {
|
|
pairing: { type: "dojo.api", version: "1.28.0", apikey: "deadbeef", url: "http://ebtnuwk5qayotlk7brszskn2zbtzu54y24s6lmojt6j4cv7uaiwlsyad.onion/v2" },
|
|
explorer: { type: "explorer.btc_rpc_explorer", url: "http://eaa3qxan44q2rksr23nferh5ntxsqcdcdkjmotlyo7h56widf4y3yiqd.onion" },
|
|
};
|
|
const canonical = JSON.stringify({ pairing: payload.pairing, explorer: payload.explorer });
|
|
// Real wallet exports sign the pairing JSON PLUS the BIP47 line and code (the
|
|
// full text between the markers, no trailing newline), verified against a
|
|
// genuine Samourai export. Construct blocks exactly that way.
|
|
const signedTextOf = (json, code) => `${json}\n\nBIP47:\n${code}`;
|
|
const blockOf = (msgText, addr, sig) =>
|
|
`-----BEGIN BITCOIN SIGNED MESSAGE-----\n${msgText}\n-----BEGIN BITCOIN SIGNATURE-----\nVersion: Bitcoin-qt (1.0)\nAddress: ${addr}\n\n${sig}\n-----END BITCOIN SIGNATURE-----`;
|
|
const signedText = signedTextOf(canonical, paymentCode);
|
|
const sigLine = Buffer.from(msg.sign(signedText, priv, true, net47.messagePrefix)).toString("base64");
|
|
const signedBlock = blockOf(signedText, notifAddr, sigLine);
|
|
// Any payload can be signed the same way. Pairing edits need a signature over
|
|
// the NEW details, so the suite must be able to produce one on demand rather
|
|
// than reusing the block that covers the payload being replaced.
|
|
const signBlockFor = (p) => {
|
|
const text = signedTextOf(JSON.stringify({ pairing: p.pairing, explorer: p.explorer }), paymentCode);
|
|
return blockOf(text, notifAddr, Buffer.from(msg.sign(text, priv, true, net47.messagePrefix)).toString("base64"));
|
|
};
|
|
const create = await api("/api/dojo", "POST", { network: "mainnet", name: "selftest-node", jurisdiction: "Europe", hardware: "N100 16GB", payload, signed: signedBlock });
|
|
ok(create.status === 200 && create.body.submission.status === "pending", "valid submission accepted, pending review");
|
|
|
|
// 4) signature gate failure modes, each with its own distinct error.
|
|
{
|
|
const badSigned = signedBlock.replace(notifAddr, "1BitcoinEaterAddressDontSendf59kuE");
|
|
const r = await api("/api/dojo", "POST", { network: "mainnet", name: "selftest-node", payload, signed: badSigned });
|
|
ok(r.status === 400 && /signature gate/.test(r.body.error), "wrong-address signed payload rejected");
|
|
|
|
const { verifySignedPayload } = await import("./crypto.ts");
|
|
// Regression for the truncated-message bug: a signature covering ONLY the
|
|
// pairing JSON (the old, wrong assumption) presented in a block that prints
|
|
// the BIP47 lines must be refused, because the wallet signs the full text.
|
|
const jsonOnlySig = Buffer.from(msg.sign(canonical, priv, true, net47.messagePrefix)).toString("base64");
|
|
const oldStyle = verifySignedPayload({ signedText: blockOf(signedText, notifAddr, jsonOnlySig), expectedMessage: canonical, expectedAddress: notifAddr });
|
|
const corrupted = verifySignedPayload({ signedText: signedBlock.replace(sigLine, sigLine.replace(/^./, (c) => c === "H" ? "I" : "H")), expectedMessage: canonical, expectedAddress: notifAddr });
|
|
ok(!oldStyle.ok && oldStyle.error === "invalid signature" && !corrupted.ok && /invalid signature|could not be verified/.test(corrupted.error),
|
|
"invalid signatures (truncated-coverage and corrupted) report 'invalid signature'");
|
|
|
|
// Valid signature, but the BIP47 code inside the signed text does not derive
|
|
// the signing address: sign a text carrying a DIFFERENT (valid) code.
|
|
const other = bip47.fromSeed(mnemonicToSeedSync("legal winner thank year wave sausage worth useful legal winner thank yellow"));
|
|
const otherCode = other.toBase58();
|
|
const mixedText = signedTextOf(canonical, otherCode);
|
|
const mixedSig = Buffer.from(msg.sign(mixedText, priv, true, net47.messagePrefix)).toString("base64");
|
|
const mixed = verifySignedPayload({ signedText: blockOf(mixedText, notifAddr, mixedSig), expectedMessage: canonical, expectedAddress: notifAddr });
|
|
ok(!mixed.ok && /valid, but the signing address is not the notification address/.test(mixed.error),
|
|
"valid signature over a mismatched payment code reports the derivation failure, not 'invalid signature'");
|
|
|
|
// Valid signature, garbage where the payment code should be.
|
|
const junkText = signedTextOf(canonical, "PM8TJnotacode");
|
|
const junkSig = Buffer.from(msg.sign(junkText, priv, true, net47.messagePrefix)).toString("base64");
|
|
const junk = verifySignedPayload({ signedText: blockOf(junkText, notifAddr, junkSig), expectedMessage: canonical, expectedAddress: notifAddr });
|
|
ok(!junk.ok && /not a valid payment code/.test(junk.error),
|
|
"valid signature over an undecodable BIP47 line reports the invalid code");
|
|
|
|
// Ground truth: a GENUINE wallet export (the maxtannahill node; the apikey
|
|
// is public). This pins the real signed-text format independently of the
|
|
// blocks this suite constructs for itself, which is exactly how the
|
|
// truncated-message bug evaded the previous version of these tests.
|
|
const realBlock = `-----BEGIN BITCOIN SIGNED MESSAGE-----
|
|
{"pairing":{"type":"dojo.api","version":"1.27.0","apikey":"jaf8fQuGD3QBWLjso6BqU4GEFZ8rW77hXGJfpXNq","url":"http://rwijn27ypfktrhsyrfnob66sjdgpyw6cvlk3ijzyzpj6w36emyk5x5ad.onion/v2"},"explorer":{"type":"explorer.btc_rpc_explorer","url":"http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion"}}
|
|
|
|
BIP47:
|
|
PM8TJfHaHuh5xgKoEbrkWaBtytb8qrRNYdmHzxiFcvacD6HpyyxvSV3VLKYsr6UvMxB4jvJP4xxNvCp2pRY3cJPNmLB2L8nYEttaFVszXSBjXNMy8cD9
|
|
-----BEGIN BITCOIN SIGNATURE-----
|
|
Version: Bitcoin-qt (1.0)
|
|
Address: 1HmVAPcz3hyETMnu4UzgJTw1mmrNcJKVB
|
|
|
|
H6BZzINZjJQz6LVJIduOpAtXrJUt61dNlnmEf5P6DSmUUOO78YmVOc8bg5biESMFUckk1oAJ/CP9/JLqipPb0fM=
|
|
-----END BITCOIN SIGNATURE-----`;
|
|
const { parseSignedBlock, notificationAddress: notifOf } = await import("./crypto.ts");
|
|
const rp = parseSignedBlock(realBlock);
|
|
const real = verifySignedPayload({ signedText: realBlock, expectedMessage: rp.pairingText, expectedAddress: notifOf(rp.paymentCode) });
|
|
ok(real.ok && rp.message === rp.pairingText + "\n\nBIP47:\n" + rp.paymentCode
|
|
&& notifOf(rp.paymentCode) === rp.address,
|
|
"a genuine wallet export verifies: the signature covers json + BIP47 line + code");
|
|
}
|
|
|
|
// 5) connection gate: point the probe at a proxy that reports the onion down.
|
|
{
|
|
const down = net.createServer((s) => {
|
|
let st = "g";
|
|
s.on("data", () => {
|
|
if (st === "g") { s.write(Buffer.from([5, 0])); st = "c"; return; }
|
|
s.write(Buffer.from([5, 4, 0, 1, 0, 0, 0, 0, 0, 0])); s.end(); // 0x04 host unreachable
|
|
});
|
|
s.on("error", () => {});
|
|
});
|
|
await new Promise((r) => down.listen(19078, "127.0.0.1", () => r(null)));
|
|
const { PROBE_CFG } = await import("./probe.mjs");
|
|
PROBE_CFG.proxyPort = 19078; // live object, mutated in place
|
|
// mainnet with a fresh name, rather than testnet to dodge the name conflict:
|
|
// the shared fixture payload is a mainnet endpoint, and listing it as testnet
|
|
// is now refused by the payload validator before the connection gate is
|
|
// reached, which would make this test pass for the wrong reason. The signature
|
|
// covers the payload and not the name, so renaming costs nothing.
|
|
const r = await api("/api/dojo", "POST", { network: "mainnet", name: "unreachable-node", payload, signed: signedBlock });
|
|
ok(r.status === 422 && /connection gate/.test(r.body.error), "unreachable node rejected by connection gate");
|
|
PROBE_CFG.proxyPort = 19077; // restore the up proxy
|
|
down.close();
|
|
}
|
|
|
|
// 6) admin moderation via the /admin API + publish
|
|
const anon = await fetch(base + "/api/admin/submissions"); // no cookie
|
|
ok(anon.status === 401, "admin route rejects anonymous");
|
|
const alist = await api("/api/admin/submissions");
|
|
ok(alist.status === 200 && alist.body.admin === true && alist.body.submissions.some((s) => s.status === "pending"),
|
|
"admin can list pending submissions");
|
|
const pendId = alist.body.submissions.find((s) => s.status === "pending").id;
|
|
const appr = await api("/api/admin/approve", "POST", { id: pendId, paynym: "+testoperator" });
|
|
ok(appr.status === 200 && appr.body.ok && appr.body.rebuild.nodes >= 1, "admin approve publishes");
|
|
const fsp = await import("node:fs/promises");
|
|
const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8"));
|
|
ok(pub.nodes.some((n) => n.paynym === "+testoperator"), "approved submission appears in public dojos.json");
|
|
// ---- new-schema checks (paymentCodes[], operator names, migration) ---------
|
|
|
|
// 7) multi-code ownership: a PayNym commonly has two BIP47 code variants and
|
|
// the wallet may sign Auth47 with either, so a record must match on
|
|
// membership of its paymentCodes array, not equality with one code.
|
|
{
|
|
const { store } = await import("./store.ts"); // same instance the server uses
|
|
const rec = await store.getSubmission("mainnet-selftest-node");
|
|
const legacyVariant = "PMlegacyVariantOfTheSameNym";
|
|
rec.paymentCodes.push(legacyVariant);
|
|
await store.putSubmission(rec);
|
|
const viaPrimary = await store.submissionsFor(paymentCode);
|
|
const viaLegacy = await store.submissionsFor(legacyVariant);
|
|
ok(viaPrimary.some((r) => r.id === "mainnet-selftest-node")
|
|
&& viaLegacy.some((r) => r.id === "mainnet-selftest-node"),
|
|
"both payment-code variants match the same record");
|
|
const meAgain = await api("/api/me");
|
|
ok(meAgain.body.submissions.some((r) => r.id === "mainnet-selftest-node"),
|
|
"/api/me still lists the record after the second code is added");
|
|
}
|
|
|
|
// 8) name uniqueness: another operator may not take a name that is in use.
|
|
{
|
|
const jarB = { cookie: "" };
|
|
const apiB = async (path, method = "GET", body) => {
|
|
const res = await fetch(base + path, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", ...(jarB.cookie ? { Cookie: jarB.cookie } : {}) },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const sc = res.headers.get("set-cookie");
|
|
if (sc) jarB.cookie = sc.split(";")[0];
|
|
const txt = await res.text();
|
|
return { status: res.status, body: txt ? JSON.parse(txt) : null };
|
|
};
|
|
const acctB = bip47.fromSeed(mnemonicToSeedSync("legal winner thank year wave sausage worth useful legal winner thank yellow"));
|
|
const chB = await apiB("/api/auth47/challenge", "POST", {});
|
|
const scB = (() => { const u = new URL(chB.body.uri); u.searchParams.delete("c"); return decodeURIComponent(u.toString()); })();
|
|
const sigB = Buffer.from(msg.sign(scB, acctB.getNotificationPrivateKey(), true, net47.messagePrefix)).toString("base64");
|
|
await apiB("/api/auth47/callback", "POST", { auth47_response: "1.0", challenge: scB, signature: sigB, nym: acctB.toPaymentCodePublic().toBase58() });
|
|
await apiB("/api/auth47/poll?nonce=" + chB.body.nonce);
|
|
const ncB = await apiB("/api/dojo/name-check?network=mainnet&name=Selftest%20Node");
|
|
ok(ncB.status === 200 && ncB.body.available === false, "name-check reports a taken name (case/punctuation-insensitive)");
|
|
const dup = await apiB("/api/dojo", "POST", { network: "mainnet", name: "selftest-node", payload, signed: signedBlock });
|
|
ok(dup.status === 409, "duplicate name from another operator rejected with 409");
|
|
const ncOwner = await api("/api/dojo/name-check?network=mainnet&name=selftest-node");
|
|
ok(ncOwner.status === 200 && ncOwner.body.available === true && ncOwner.body.update === true,
|
|
"owner's own name reads as available (an update, keeping the record id)");
|
|
}
|
|
|
|
// 9) manage-panel ordering: /api/me returns mainnet before testnet, then
|
|
// alphabetical by name.
|
|
{
|
|
const { store } = await import("./store.ts");
|
|
/**
|
|
* @param {"mainnet"|"testnet"} network
|
|
* @param {string} name
|
|
* @returns {import("../types.js").StoreRecord}
|
|
*/
|
|
const stub = (network, name) => ({
|
|
id: `${network}-${name}`, network, name, paymentCodes: [paymentCode],
|
|
paynym: null, payload: { pairing: { type: "dojo.api", url: "http://" + "a".repeat(56) + ".onion/v2" } },
|
|
signed: signedBlock,
|
|
status: "pending", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z",
|
|
});
|
|
await store.putSubmission(stub("testnet", "alpha"));
|
|
await store.putSubmission(stub("mainnet", "zulu"));
|
|
const meOrd = await api("/api/me");
|
|
const order = meOrd.body.submissions.map((r) => r.name);
|
|
ok(JSON.stringify(order) === JSON.stringify(["selftest-node", "zulu", "alpha"]),
|
|
"submissions ordered mainnet-then-testnet, then by name (" + order.join(", ") + ")");
|
|
}
|
|
|
|
// 10) migration script: dry-run prints its plan (including the code-less
|
|
// adoption warning) and writes nothing; a real run creates owned records
|
|
// and adopts code-less ones as admin-managed exceptions; seed.json is
|
|
// never rewritten; a second run skips everything (byte-identical store).
|
|
{
|
|
const { execFile } = await import("node:child_process");
|
|
const { promisify } = await import("node:util");
|
|
const run = promisify(execFile);
|
|
const MIG_DATA = "/tmp/dojobay-selftest-mig-data";
|
|
const MIG_STORE = "/tmp/dojobay-selftest-mig-store";
|
|
await fsp.rm(MIG_DATA, { recursive: true, force: true });
|
|
await fsp.rm(MIG_STORE, { recursive: true, force: true });
|
|
await fsp.mkdir(MIG_DATA, { recursive: true });
|
|
const fixturePayload = { pairing: { type: "dojo.api", url: "http://" + "b".repeat(56) + ".onion/v2" } };
|
|
await fsp.writeFile(MIG_DATA + "/seed.json", JSON.stringify({ nodes: [
|
|
{ id: "mainnet-fam-one", network: "mainnet", name: "Fam One", paynym: "+fam", payload: fixturePayload, signed: signedBlock },
|
|
{ id: "mainnet-fam-two", network: "mainnet", name: "Fam Two", paynym: "+fam", payload: fixturePayload, signed: signedBlock },
|
|
{ id: "testnet-keeper", network: "testnet", name: "wanderinKeeper", paynym: null, payload: fixturePayload },
|
|
{ id: "mainnet-fam-mute", network: "mainnet", name: "Fam Mute", paynym: "+fam", payload: fixturePayload },
|
|
] }, null, 2));
|
|
await fsp.writeFile(MIG_DATA + "/paynym-codes.json", JSON.stringify({ mapping: {
|
|
"+fam": { nymName: "+fam", codes: [{ code: "PMfamSegwit", segwit: true }, { code: "PMfamLegacy", segwit: false }] },
|
|
} }, null, 2));
|
|
const env = { ...process.env, PUBLIC_DATA_DIR: MIG_DATA, SERVER_DATA_DIR: MIG_STORE };
|
|
const script = new URL("../scripts/migrate-seed-to-store.mjs", import.meta.url).pathname;
|
|
const seedBefore = await fsp.readFile(MIG_DATA + "/seed.json", "utf8");
|
|
|
|
const dry = await run(process.execPath, [script, "--dry-run"], { env });
|
|
const storeAbsent = await fsp.access(MIG_STORE + "/store.json").then(() => false, () => true);
|
|
ok(/create\s+mainnet-fam-one\s+name=one/.test(dry.stdout)
|
|
&& /refuse\s+testnet-keeper\s+name=wanderinKeeper/.test(dry.stdout)
|
|
&& /REFUSED: testnet-keeper no BIP47 payment code/.test(dry.stdout)
|
|
&& storeAbsent,
|
|
"migration --dry-run: family prefix stripped, a code-less node refused, nothing written");
|
|
ok(/refuse\s+mainnet-fam-mute/.test(dry.stdout)
|
|
&& /REFUSED: mainnet-fam-mute no signed pairing block/.test(dry.stdout)
|
|
&& /2 refused: testnet-keeper, mainnet-fam-mute/.test(dry.stdout),
|
|
"and an owned node with no signed pairing block is refused too, and counted in the summary");
|
|
|
|
await run(process.execPath, [script], { env });
|
|
const store1 = await fsp.readFile(MIG_STORE + "/store.json", "utf8");
|
|
const migrated = JSON.parse(store1).submissions;
|
|
const seedAfter = await fsp.readFile(MIG_DATA + "/seed.json", "utf8");
|
|
ok(migrated["mainnet-fam-one"].status === "approved"
|
|
&& migrated["mainnet-fam-one"].paymentCodes.length === 2
|
|
&& migrated["mainnet-fam-one"].source === "seed-migration"
|
|
&& !migrated["testnet-keeper"]
|
|
&& !migrated["mainnet-fam-mute"]
|
|
&& seedAfter === seedBefore,
|
|
"migration creates owned records, never writes a code-less or unsigned one, never rewrites seed.json");
|
|
|
|
const second = await run(process.execPath, [script], { env });
|
|
const store2 = await fsp.readFile(MIG_STORE + "/store.json", "utf8");
|
|
ok(/nothing to do/.test(second.stdout) && /skip\s+mainnet-fam-one/.test(second.stdout) && store2 === store1,
|
|
"second migration run skips existing ids (store byte-identical)");
|
|
await fsp.rm(MIG_DATA, { recursive: true, force: true });
|
|
await fsp.rm(MIG_STORE, { recursive: true, force: true });
|
|
}
|
|
|
|
// 11) a moderation change whose publish (rebuild) fails must report the
|
|
// failure to the admin, not swallow it: this is how an approved node
|
|
// silently never reached the public dojos.json.
|
|
{
|
|
const goodDir = process.env.PUBLIC_DATA_DIR;
|
|
process.env.PUBLIC_DATA_DIR = "/dev/null/not-a-directory"; // rebuild will throw
|
|
const rej = await api("/api/admin/reject", "POST", { id: "mainnet-selftest-node" });
|
|
ok(rej.status === 200 && rej.body.ok && rej.body.rebuild && rej.body.rebuild.error,
|
|
"moderation succeeds but a failed publish is reported (rebuild.error)");
|
|
process.env.PUBLIC_DATA_DIR = goodDir;
|
|
const reAppr = await api("/api/admin/approve", "POST", { id: "mainnet-selftest-node", paynym: "+testoperator" });
|
|
ok(reAppr.status === 200 && reAppr.body.rebuild && !reAppr.body.rebuild.error, "publish succeeds again once writable");
|
|
}
|
|
|
|
// 12) updater reconciliation: an approved node deleted from dojos.json (the
|
|
// approve-mid-probe-cycle clobber) is restored by reconcilePublicList(),
|
|
// which the updater now runs at the start of every cycle.
|
|
{
|
|
const dojosPath = process.env.PUBLIC_DATA_DIR + "/dojos.json";
|
|
const doc = JSON.parse(await fsp.readFile(dojosPath, "utf8"));
|
|
doc.nodes = doc.nodes.filter((n) => n.id !== "mainnet-selftest-node");
|
|
await fsp.writeFile(dojosPath, JSON.stringify(doc, null, 2) + "\n");
|
|
const { reconcilePublicList } = await import("../scripts/update.mjs");
|
|
await reconcilePublicList();
|
|
const healed = JSON.parse(await fsp.readFile(dojosPath, "utf8"));
|
|
ok(healed.nodes.some((n) => n.id === "mainnet-selftest-node"),
|
|
"reconcile restores an approved node clobbered out of dojos.json");
|
|
}
|
|
|
|
// 13) history grace period: delisting a node stamps its history `retired`
|
|
// instead of deleting it; relisting within the window clears the stamp
|
|
// with the data intact; only a long-expired retiree is deleted.
|
|
{
|
|
const histPath = process.env.PUBLIC_DATA_DIR + "/history.json";
|
|
const marker = [{ t: "2026-07-14 00:00", up: true }];
|
|
const doc = JSON.parse(await fsp.readFile(histPath, "utf8"));
|
|
doc.nodes["mainnet-selftest-node"] = { checks: marker.slice() };
|
|
doc.nodes["mainnet-long-gone"] = { checks: marker.slice(), retired: "2026-06-01T00:00:00Z" };
|
|
await fsp.writeFile(histPath, JSON.stringify(doc, null, 2) + "\n");
|
|
|
|
const rej = await api("/api/admin/reject", "POST", { id: "mainnet-selftest-node" }); // delists + rebuilds
|
|
const afterRej = JSON.parse(await fsp.readFile(histPath, "utf8")).nodes;
|
|
ok(rej.status === 200 && afterRej["mainnet-selftest-node"]
|
|
&& afterRej["mainnet-selftest-node"].retired
|
|
&& JSON.stringify(afterRej["mainnet-selftest-node"].checks) === JSON.stringify(marker),
|
|
"delisted node's history is retired (stamped), not deleted");
|
|
ok(!afterRej["mainnet-long-gone"], "history retired beyond the grace window is deleted");
|
|
|
|
await api("/api/admin/approve", "POST", { id: "mainnet-selftest-node", paynym: "+testoperator" }); // relists + rebuilds
|
|
const afterAppr = JSON.parse(await fsp.readFile(histPath, "utf8")).nodes["mainnet-selftest-node"];
|
|
ok(afterAppr && !afterAppr.retired
|
|
&& JSON.stringify(afterAppr.checks) === JSON.stringify(marker),
|
|
"relisting within the grace window resurrects the history untouched");
|
|
}
|
|
|
|
// 14) display-field edits: owner can amend name and hardware; the id, status
|
|
// and history are untouched; renames respect per-network uniqueness. The
|
|
// Dojo version is NOT editable: a version sent in the edit is ignored and
|
|
// the card keeps the API-derived value (here the pairing default, since no
|
|
// live probe has run in this test).
|
|
{
|
|
const ed = await api("/api/dojo/edit", "POST", { id: "mainnet-selftest-node", name: "selftest-node", hardware: "RPi5 8GB", version: "9.9.9-test" });
|
|
const rec = await api("/api/me").then((r) => r.body.submissions.find((x) => x.id === "mainnet-selftest-node"));
|
|
ok(ed.status === 200 && rec.hardware === "RPi5 8GB" && rec.version == null && rec.status === "approved",
|
|
"owner edit updates hardware, keeps id and approved status, and cannot set a version");
|
|
const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8"));
|
|
const pubNode = pub.nodes.find((n) => n.id === "mainnet-selftest-node");
|
|
ok(pubNode && pubNode.version === "1.28.0" && pubNode.paymentCode === rec.paymentCodes[0],
|
|
"approved edit publishes immediately; card version stays the API-derived value, ignoring the edit");
|
|
|
|
const clashOwn = await api("/api/dojo/edit", "POST", { id: "mainnet-selftest-node", name: "zulu" });
|
|
const clashSeed = await api("/api/dojo/edit", "POST", { id: "mainnet-selftest-node", name: "Maxtannahill" });
|
|
ok(clashOwn.status === 409 && clashSeed.status === 409,
|
|
"renames rejected when colliding with own other record or the anchor seed node");
|
|
|
|
const anon = await fetch(base + "/api/dojo/edit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: "mainnet-selftest-node", name: "x" }) });
|
|
const admEd = await api("/api/admin/edit", "POST", { id: "testnet-alpha", name: "alpha", hardware: "edited-by-admin" });
|
|
const stub = await api("/api/me").then((r) => r.body.submissions.find((x) => x.id === "testnet-alpha"));
|
|
ok(anon.status === 401 && admEd.status === 200 && stub.hardware === "edited-by-admin",
|
|
"anonymous edit rejected; admin can edit any record via /api/admin/edit");
|
|
}
|
|
|
|
// 15) the card shows the PayNym's canonical (non-segwit) code variant when the
|
|
// mapping identifies it, falling back to the record's first code.
|
|
{
|
|
const { displayPaymentCode } = await import("./build-public.ts");
|
|
const sub = { paynym: "+max", paymentCodes: ["PMsegwitVariant", "PMlegacyVariant"] };
|
|
const mapping = { "+max": { codes: [{ code: "PMsegwitVariant", segwit: true }, { code: "PMlegacyVariant", segwit: false }] } };
|
|
ok(displayPaymentCode(sub, mapping) === "PMlegacyVariant"
|
|
&& displayPaymentCode(sub, {}) === "PMsegwitVariant"
|
|
&& displayPaymentCode({ paymentCodes: [] }, mapping) === null,
|
|
"display code prefers the non-segwit variant, falls back to the first, null when none");
|
|
}
|
|
|
|
// 16) intake hygiene: pasted CRLF/zero-width bytes are stripped from signed
|
|
// blocks before verification; export endpoint merges both history windows.
|
|
//
|
|
// The card link is gone. It let an operator point the card title anywhere
|
|
// they had proven they controlled, which meant one listing could carry two
|
|
// claims of identity: the verified domain badge and a title link. One is
|
|
// enough, and it is the one with a TXT record behind it.
|
|
{
|
|
// signed cleaning: resubmit the check-3 record with a clipboard-mangled
|
|
// signed block (CRLF + zero-width space); it must still pass the signature
|
|
// gate and be STORED byte-clean.
|
|
const mangled = signedBlock.replace(/\n/g, "\r\n") + "\u200b";
|
|
const resub = await api("/api/dojo", "POST", { network: "mainnet", name: "selftest-node", jurisdiction: "Europe", hardware: "N100 16GB", payload, signed: mangled });
|
|
const rec = await api("/api/me").then((r) => r.body.submissions.find((x) => x.id === "mainnet-selftest-node"));
|
|
ok(resub.status === 200 && rec.signed === signedBlock && !rec.signed.includes("\r"),
|
|
"CRLF/zero-width paste artefacts stripped before verification; stored block byte-clean");
|
|
|
|
// restore approved status (resubmission re-enters moderation)
|
|
await api("/api/admin/approve", "POST", { id: "mainnet-selftest-node", paynym: "+testoperator" });
|
|
|
|
// A verified domain is still granted here, because the checks below and the
|
|
// published badge depend on one. Granted directly in the store, since the API
|
|
// path needs DNS.
|
|
const { store: st } = await import("./store.ts");
|
|
await st.putDomain({ paymentCode, domain: "example.org", signed: "(test)",
|
|
verified: true, verified_at: new Date().toISOString(), last_check: new Date().toISOString(),
|
|
last_result: "ok", fail_since: null, created_at: new Date().toISOString() });
|
|
|
|
// The card link is not merely unused, it is unreachable: a request carrying
|
|
// one is accepted and the field ignored, rather than silently stored where a
|
|
// future rebuild might publish it again.
|
|
const withUrl = await api("/api/dojo/edit", "POST",
|
|
{ id: "mainnet-selftest-node", name: "selftest-node", name_url: "https://example.org/mynode" });
|
|
const after = await api("/api/me").then((r) => r.body.submissions.find((x) => x.id === "mainnet-selftest-node"));
|
|
const pub16 = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8"))
|
|
.nodes.find((n) => n.id === "mainnet-selftest-node");
|
|
ok(withUrl.status === 200 && !after.name_url,
|
|
"an edit carrying a card link succeeds and stores nothing for it");
|
|
ok(!("name_url" in pub16) && pub16.operator_domain === "example.org",
|
|
"and the published node has no link field at all, only the verified domain");
|
|
|
|
// export endpoint: both windows merged, per-node filter, 404 on unknown
|
|
const all = await api("/api/history/export");
|
|
const one = await api("/api/history/export?id=mainnet-selftest-node");
|
|
const none = await api("/api/history/export?id=no-such-node");
|
|
ok(all.status === 200 && all.body.nodes["mainnet-selftest-node"]
|
|
&& Array.isArray(one.body.nodes["mainnet-selftest-node"].checks)
|
|
&& Array.isArray(one.body.nodes["mainnet-selftest-node"].days)
|
|
&& Object.keys(one.body.nodes).length === 1 && none.status === 404,
|
|
"history export merges 24h checks and daily rollups, filters by id, 404s unknown ids");
|
|
}
|
|
|
|
// 17) update check: commits behind main, and which RELEASE we are running.
|
|
// "Releases behind" used to count releases published after the local build
|
|
// timestamp, so an instance running the exact commit of the newest release
|
|
// always reported itself one behind — a tag is always created after the
|
|
// commit it points at was built. It now resolves tags to commits.
|
|
{
|
|
const { checkUpdates } = await import("./updates.mjs");
|
|
const releases = [
|
|
{ tag_name: "v0.2", published_at: "2026-06-01T00:00:00Z" },
|
|
{ tag_name: "v0.1", published_at: "2025-12-01T00:00:00Z" },
|
|
];
|
|
const tags = [
|
|
{ name: "v0.2", commit: { sha: "abc1234def5678900000000000000000000000a" } },
|
|
{ name: "v0.1", commit: { sha: "0000000000000000000000000000000000000b" } },
|
|
];
|
|
const transportFor = (withTags) => async (apiPath) => {
|
|
if (apiPath.startsWith("/repos/Dojobay/dojobay/compare/"))
|
|
return { status: 200, body: JSON.stringify({ status: "behind", ahead_by: 4, behind_by: 0 }) };
|
|
if (apiPath.startsWith("/repos/Dojobay/dojobay/releases"))
|
|
return { status: 200, body: JSON.stringify(releases) };
|
|
if (apiPath.startsWith("/repos/Dojobay/dojobay/tags"))
|
|
return withTags ? { status: 200, body: JSON.stringify(tags) } : { status: 500, body: "{}" };
|
|
return { status: 404, body: "{}" };
|
|
};
|
|
const setVersion = (commit, built) => fsp.writeFile(process.env.PUBLIC_DATA_DIR + "/version.json",
|
|
JSON.stringify({ commit, built }));
|
|
|
|
// running the exact commit of the newest release, tagged AFTER we built it
|
|
await setVersion("abc1234", "2026-01-01T00:00:00Z");
|
|
const onLatest = await checkUpdates({ transport: /** @type {any} */ (transportFor(true)) });
|
|
ok(onLatest.releases_behind === 0 && onLatest.current_release === "v0.2"
|
|
&& onLatest.releases_behind_approx === false,
|
|
"running the newest release's commit reports zero behind, however late the tag was created");
|
|
|
|
// an untagged commit mid-cycle: no identity match, so the timestamp guess,
|
|
// flagged as approximate rather than presented as fact
|
|
await setVersion("deadbee", "2026-01-01T00:00:00Z");
|
|
const midCycle = await checkUpdates({ transport: /** @type {any} */ (transportFor(true)) });
|
|
ok(midCycle.releases_behind === 1 && midCycle.current_release === null
|
|
&& midCycle.releases_behind_approx === true,
|
|
"an untagged commit falls back to the timestamp count and says it is approximate");
|
|
|
|
// The tags call failing must not break the check, and must not invent a
|
|
// number either: the timestamp guess is systematically wrong for the
|
|
// commonest case, an instance running the very newest release.
|
|
await setVersion("abc1234", "2026-01-01T00:00:00Z");
|
|
const noTags = await checkUpdates({ transport: /** @type {any} */ (transportFor(false)) });
|
|
ok(noTags.releases_behind === null && noTags.releases_behind_approx === true
|
|
&& /tag lookup/.test(noTags.releases_note || ""),
|
|
"an unavailable tags endpoint reports unknown, with the reason, rather than a guess");
|
|
|
|
await setVersion("abc1234", "2026-01-01T00:00:00Z");
|
|
const u = await checkUpdates({ transport: /** @type {any} */ (transportFor(true)) });
|
|
ok(u.commits_behind === 4 && u.latest_release === "v0.2" && u.commit === "abc1234",
|
|
"update check still reports commits behind main and the latest release");
|
|
|
|
const anon = await fetch(base + "/api/admin/updates");
|
|
const admin = await api("/api/admin/updates");
|
|
ok(anon.status === 401 && admin.status === 200 && admin.body.available === false && admin.body.error,
|
|
"updates route: anonymous 401; unreachable GitHub reported in-band to the admin");
|
|
|
|
// A rate-limited exit is the commonest way this check fails and the least
|
|
// like a fault: GitHub allows sixty unauthenticated requests an hour per IP,
|
|
// and a Tor exit is one address shared with everyone using it. An operator
|
|
// told "HTTP 403" goes looking for a broken instance.
|
|
const { githubRefusal } = await import("./updates.mjs");
|
|
for (const code of [403, 429]) {
|
|
const msg = githubRefusal("compare", code);
|
|
ok(/rate-limit/i.test(msg) && /exit/.test(msg) && msg.includes(String(code)),
|
|
`HTTP ${code} is explained as a rate-limited exit, with the status still in it`);
|
|
}
|
|
ok(/peer/i.test(githubRefusal("compare", 403)),
|
|
"and points at the route that does not touch GitHub");
|
|
ok(githubRefusal("compare", 500) === "compare: HTTP 500",
|
|
"while anything else is reported as what it was, with no story attached");
|
|
// No call-site prefix on the rate-limit message. Which of the three requests
|
|
// hit the limit tells an operator nothing, and "compare: GitHub is
|
|
// rate-limiting..." reads as though compare were the thing that failed.
|
|
ok(!/^compare:/.test(githubRefusal("compare", 403)),
|
|
"and the rate-limit message does not open with the name of the call that hit it");
|
|
|
|
// The import routes. Their argument checking and their refusal to run two at
|
|
// once are testable here; the fetch itself needs another instance over Tor,
|
|
// which this suite has no way to provide, so what is asserted is everything
|
|
// that happens before the first byte leaves the machine.
|
|
const anonImport = await fetch(base + "/api/admin/import", { method: "POST",
|
|
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ onion: "x" }) });
|
|
ok(anonImport.status === 401, "import route refuses anyone who is not an admin");
|
|
|
|
const badOnion = await api("/api/admin/import", "POST", { onion: "not-an-onion", code: paymentCode });
|
|
ok(badOnion.status === 400 && /\.onion/.test(badOnion.body.error || ""),
|
|
"and refuses an address that is not a 56-character onion");
|
|
|
|
// Without the peer's payment code there is nothing for the operator binding
|
|
// to be checked against, so the import would establish only that the remote
|
|
// signed something, not that it is the instance the operator chose to trust.
|
|
const noCode = await api("/api/admin/import", "POST", { onion: "a".repeat(56) + ".onion" });
|
|
ok(noCode.status === 400 && /payment code/.test(noCode.body.error || ""),
|
|
"and refuses without the payment code of the instance being imported from");
|
|
|
|
// A real start, which will fail at the fetch because that onion does not
|
|
// exist. What matters is that it is a job rather than a held-open request,
|
|
// and that the failure is reported in-band rather than as a dead panel.
|
|
const started = await api("/api/admin/import", "POST",
|
|
{ onion: "b".repeat(56) + ".onion", code: paymentCode });
|
|
ok(started.status === 202 && started.body.started === true && started.body.apply === false,
|
|
"an import starts as a background job, and defaults to planning rather than writing");
|
|
|
|
// Two imports must not run at once, and a second request is refused while the
|
|
// first is unfinished. That is deliberately NOT asserted by firing a second
|
|
// request and expecting 409: whether the first is still running by then
|
|
// depends on how quickly the fetch fails, which is a property of the machine
|
|
// rather than of this code. On a box with Tor it hangs for seconds; in CI
|
|
// there is no Tor at all, the connection is refused on the next turn of the
|
|
// loop, and a second import is then correctly ACCEPTED because nothing is
|
|
// running. The first version of this test asserted 409 unconditionally and
|
|
// failed in CI for exactly that reason.
|
|
//
|
|
// What is asserted instead is the rule itself, which is monotonic and does
|
|
// not depend on timing: the guard is on the job being unfinished, and the
|
|
// job is created before the work starts so there is no window in which two
|
|
// could begin. Weaker than a behavioural test, and said so here rather than
|
|
// dressed up as one.
|
|
const idx = await fsp.readFile(new URL("./index.ts", import.meta.url), "utf8");
|
|
const importRoute = idx.slice(idx.indexOf('route("POST", /^\\/api\\/admin\\/import$/'),
|
|
idx.indexOf('route("GET", /^\\/api\\/admin\\/import\\/status$/'));
|
|
ok(/IMPORT_JOB && !IMPORT_JOB\.done\) return json\(res, 409/.test(importRoute),
|
|
"a second import while one is running is refused rather than interleaved");
|
|
ok(importRoute.indexOf("IMPORT_JOB = {") < importRoute.indexOf("bootstrapImport({"),
|
|
"and the job exists before the work starts, so there is no window in which two could begin");
|
|
|
|
for (let i = 0; i < 60; i++) {
|
|
const st = await api("/api/admin/import/status");
|
|
if (st.body.job && st.body.job.done) break;
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
const fin = await api("/api/admin/import/status");
|
|
ok(fin.status === 200 && fin.body.job && fin.body.job.done === true && fin.body.job.ok === false
|
|
&& typeof fin.body.job.error === "string",
|
|
"an unreachable peer ends the job with a reason rather than leaving it running");
|
|
|
|
// The cache rule the "Check again" button depends on. Tested here rather
|
|
// than through the route, because the route only fills its cache on a
|
|
// successful check and GitHub is unreachable in this suite, so the cached
|
|
// path is never taken and the floor would never run. A rule that cannot be
|
|
// exercised is a rule nobody has checked.
|
|
const { updateCacheDecision } = await import("./updates.mjs");
|
|
const now = 1_000_000_000_000;
|
|
const hour = 3600 * 1000;
|
|
|
|
ok(updateCacheDecision({ cachedAt: null, now }).serveCached === false,
|
|
"no cached answer means the check goes out");
|
|
ok(updateCacheDecision({ cachedAt: now - 5 * hour, now }).serveCached === true,
|
|
"an ordinary request inside six hours is answered from the cache");
|
|
ok(updateCacheDecision({ cachedAt: now - 7 * hour, now }).serveCached === false,
|
|
"and outside six hours it is not");
|
|
|
|
// The button's whole purpose: an operator who pushed while already signed in
|
|
// gets a real check rather than an answer from before their push.
|
|
ok(updateCacheDecision({ cachedAt: now - 5 * hour, now, forced: true, forcedAt: 0 }).serveCached === false,
|
|
"a forced check bypasses a cache that is still fresh");
|
|
ok(updateCacheDecision({ cachedAt: now - 5 * hour, now, forced: true, forcedAt: now - 90 * 1000 }).serveCached === false,
|
|
"and again once the floor has passed");
|
|
|
|
// And the floor, which is what stops that button hammering GitHub through an
|
|
// exit node shared with every other Tor user. Refusing outright would tell
|
|
// the operator nothing, so the last known answer comes back with the wait.
|
|
const held = updateCacheDecision({ cachedAt: now - 5 * hour, now, forced: true, forcedAt: now - 20 * 1000 });
|
|
ok(held.serveCached === true && held.waitS === 40,
|
|
"inside the floor the cached answer comes back with the seconds remaining: " + held.waitS);
|
|
ok(updateCacheDecision({ cachedAt: now - 5 * hour, now, forced: false, forcedAt: now - 20 * 1000 }).waitS === 0,
|
|
"and an unforced request is never told to wait, since it asked for nothing");
|
|
}
|
|
|
|
// 18) operator binding + bootstrap import: the binding verifies a real
|
|
// wallet signature over "onion + BIP47 line"; the import refuses an
|
|
// instance whose binding fails or whose code differs from the one the
|
|
// operator trusted, and otherwise imports nodes (skipping existing ids)
|
|
// with full code variants and carried histories.
|
|
{
|
|
const onionHost = "b".repeat(56) + ".onion";
|
|
const opCode = paymentCode; // the test wallet from the Auth47 checks
|
|
const opMessage = `http://${onionHost}/\n\nBIP47: ${opCode}`;
|
|
const opSig = Buffer.from(msg.sign(opMessage, acct.getNotificationPrivateKey(), true, net47.messagePrefix)).toString("base64");
|
|
const opBlock = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${opMessage}\n-----BEGIN BITCOIN SIGNATURE-----\nAddress: ${notifAddr}\n\n${opSig}\n-----END BITCOIN SIGNATURE-----`;
|
|
const opDoc = { onion: `http://${onionHost}/`, paymentCode: opCode, verifySigned: opBlock };
|
|
|
|
const { verifyOperatorDoc, notificationAddresses } = await import("./crypto.ts");
|
|
const vOk = verifyOperatorDoc(opDoc, { expectedOnion: `http://${onionHost}` });
|
|
const vWrongOnion = verifyOperatorDoc(opDoc, { expectedOnion: "http://" + "c".repeat(56) + ".onion" });
|
|
const vTampered = verifyOperatorDoc({ ...opDoc, verifySigned: opBlock.replace(onionHost, "c".repeat(56) + ".onion") });
|
|
ok(vOk.ok && !vWrongOnion.ok && !vTampered.ok,
|
|
"operator binding: valid signature accepted; wrong onion and tampered message refused");
|
|
|
|
// The same payment code signed from a TESTNET wallet.
|
|
//
|
|
// A PayNym is a mainnet identity, but a wallet in testnet mode derives the
|
|
// notification address for that network, so the same code signs from a
|
|
// different address. Requiring the mainnet form refused perfectly good
|
|
// bindings from anyone running a testnet wallet.
|
|
const tnetAddr = notificationAddresses(opCode).find((a) => a !== notifAddr);
|
|
ok(tnetAddr && tnetAddr !== notifAddr, "the code derives a second, testnet address: " + tnetAddr);
|
|
const tnetBlock = opBlock.replace(`Address: ${notifAddr}`, `Address: ${tnetAddr}`);
|
|
const vTestnet = verifyOperatorDoc({ ...opDoc, verifySigned: tnetBlock }, { expectedOnion: `http://${onionHost}` });
|
|
ok(vTestnet.ok && vTestnet.address === tnetAddr,
|
|
"operator binding accepts a testnet-derived signing address: " + JSON.stringify(vTestnet.error || ""));
|
|
|
|
// but an address that is neither derivation is still refused, and the error
|
|
// names both so an operator can see which their wallet actually used
|
|
const strayAddr = notificationAddresses(bip47.fromSeed(mnemonicToSeedSync(
|
|
"legal winner thank year wave sausage worth useful legal winner thank yellow")).toBase58())[0];
|
|
const vStray = verifyOperatorDoc({ ...opDoc, verifySigned: opBlock.replace(`Address: ${notifAddr}`, `Address: ${strayAddr}`) },
|
|
{ expectedOnion: `http://${onionHost}` });
|
|
ok(!vStray.ok && /on mainnet, or/.test(vStray.error) && vStray.error.includes(notifAddr),
|
|
"an unrelated signing address is refused, naming both addresses the code could have used");
|
|
|
|
// A terminal that swallows the newline after the BEGIN marker must not break
|
|
// an otherwise valid binding: that newline is not part of the signed text.
|
|
const eaten = opBlock.replace("MESSAGE-----\n", "MESSAGE-----");
|
|
ok(verifyOperatorDoc({ ...opDoc, verifySigned: eaten }, { expectedOnion: `http://${onionHost}` }).ok,
|
|
"operator binding survives a paste that lost the newline after the BEGIN marker");
|
|
|
|
// A truncated paste must say so rather than blaming the wallet, and a
|
|
// signature from the wrong account must name both addresses.
|
|
const truncated = verifyOperatorDoc({ ...opDoc, verifySigned: opBlock.split("\n").slice(0, 3).join("\n") });
|
|
const otherAcct = bip47.fromSeed(mnemonicToSeedSync("legal winner thank year wave sausage worth useful legal winner thank yellow"));
|
|
const wrongSig = Buffer.from(msg.sign(opMessage, otherAcct.getNotificationPrivateKey(), true, net47.messagePrefix)).toString("base64");
|
|
const wrongSigner = verifyOperatorDoc({ ...opDoc,
|
|
verifySigned: `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${opMessage}\n-----BEGIN BITCOIN SIGNATURE-----\nAddress: ${otherAcct.getNotificationAddress()}\n\n${wrongSig}\n-----END BITCOIN SIGNATURE-----` });
|
|
ok(/truncated/.test(truncated.error)
|
|
&& wrongSigner.error.includes(otherAcct.getNotificationAddress()) && wrongSigner.error.includes(notifAddr),
|
|
"truncated paste and wrong-signer errors are diagnosable (names what is missing / both addresses)");
|
|
|
|
// A real, portable domain proof: signed over "https://example.org/" + blank
|
|
// line + the BIP47 line, exactly as the site produces it.
|
|
const urlClaimText = `https://example.org/\n\nBIP47: ${paymentCode}`;
|
|
const signedUrlBlock = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${urlClaimText}\n` +
|
|
`-----BEGIN BITCOIN SIGNATURE-----\nVersion: Bitcoin-qt (1.0)\nAddress: ${notifAddr}\n\n` +
|
|
`${Buffer.from(msg.sign(urlClaimText, priv, true, net47.messagePrefix)).toString("base64")}\n` +
|
|
`-----END BITCOIN SIGNATURE-----`;
|
|
const remoteNodes = {
|
|
nodes: [
|
|
{ id: "mainnet-selftest-node", network: "mainnet", name: "selftest-node",
|
|
payload: { pairing: { type: "dojo.api", url: "http://" + "d".repeat(56) + ".onion/v2", apikey: "k" } },
|
|
operator_domain: "example.org",
|
|
operator_domain_proof: { domain: "example.org", paymentCode, txt_name: "_dojobay.example.org",
|
|
txt_value: `dojobay-domain-v1 pm=${paymentCode}`, signed: signedUrlBlock, verified_at: "2026-07-01T00:00:00Z" } },
|
|
// Signed over ITS OWN payload. It used to carry the block covering a
|
|
// different node's pairing details and imported cleanly, because nothing
|
|
// verified the signature: the source instance's word was the only thing
|
|
// vouching for it.
|
|
{ id: "mainnet-imported", network: "mainnet", name: "imported", paynym: "+imp",
|
|
paymentCode: "PMimpDisplay",
|
|
signed: signBlockFor({ pairing: { type: "dojo.api", url: "http://" + "e".repeat(56) + ".onion/v2", apikey: "k" } }),
|
|
payload: { pairing: { type: "dojo.api", url: "http://" + "e".repeat(56) + ".onion/v2", apikey: "k" } },
|
|
// a forged proof: the signature does not check out against the code
|
|
operator_domain: "evil.example",
|
|
operator_domain_proof: { domain: "evil.example", paymentCode: "PM8T" + "9".repeat(112),
|
|
txt_name: "_dojobay.evil.example", txt_value: "dojobay-domain-v1 pm=PM8T" + "9".repeat(112),
|
|
signed: signedUrlBlock, verified_at: "2026-07-01T00:00:00Z" } },
|
|
// published by an instance that does not enforce the signature rule, or
|
|
// from before it existed. It must not enter this store.
|
|
{ id: "mainnet-hearsay", network: "mainnet", name: "hearsay", paynym: "+imp",
|
|
paymentCode: "PMimpDisplay",
|
|
payload: { pairing: { type: "dojo.api", url: "http://" + "f".repeat(56) + ".onion/v2", apikey: "k" } } },
|
|
// A well-formed block that covers somebody else's payload. This is what a
|
|
// careless or compromised directory publishes, and what taking the
|
|
// source's word for a signature would let through.
|
|
{ id: "mainnet-forged", network: "mainnet", name: "forged", paynym: "+imp",
|
|
paymentCode: "PMimpDisplay", signed: signedBlock,
|
|
payload: { pairing: { type: "dojo.api", url: "http://" + "g".repeat(56) + ".onion/v2", apikey: "k" } } },
|
|
],
|
|
};
|
|
const remoteDocs = {
|
|
"/data/operator.json": opDoc,
|
|
"/data/dojos.json": remoteNodes, // proofs are attached to its nodes below
|
|
"/data/history.json": { interval_minutes: 10, window_checks: 144, nodes: {
|
|
"mainnet-imported": { checks: [{ t: "2026-07-01 00:00", up: true }] },
|
|
"mainnet-selftest-node": { checks: [{ t: "2026-07-01 00:00", up: false }] },
|
|
} },
|
|
"/data/history-daily.json": { nodes: { "mainnet-imported": { days: [{ d: "2026-07-01", pct: 99, close: 1 }] } } },
|
|
};
|
|
const { bootstrapImport } = await import("../scripts/bootstrap-import.mjs");
|
|
const { store } = await import("./store.ts");
|
|
const fetchDoc = async (p) => { if (!(p in remoteDocs)) throw new Error("404 " + p); return remoteDocs[p]; };
|
|
const fetchCodes = async () => [{ code: "PMimpSegwit", segwit: true }, { code: "PMimpLegacy", segwit: false }];
|
|
|
|
await ok(await bootstrapImport({
|
|
onionHost, trustedCode: "PM8T" + "2".repeat(112), fetchDoc, fetchCodes, dataDir: process.env.PUBLIC_DATA_DIR, log: () => {},
|
|
}).then(() => false, (e) => /DIFFERENT payment code/.test(e.message)),
|
|
"bootstrap refuses an instance operated by a different code than the one trusted");
|
|
|
|
// clear any claim an earlier check left behind, so the import starts clean
|
|
await store.deleteDomain(paymentCode);
|
|
const r = await bootstrapImport({ onionHost, trustedCode: opCode, fetchDoc, fetchCodes, dataDir: process.env.PUBLIC_DATA_DIR, log: () => {} });
|
|
const imp = await store.getSubmission("mainnet-imported");
|
|
const untouched = await store.getSubmission("mainnet-selftest-node");
|
|
const histAfter = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/history.json", "utf8")).nodes;
|
|
ok(!(await store.getSubmission("mainnet-hearsay")),
|
|
"an unsigned node published by the remote instance is refused rather than imported");
|
|
// The block is well-formed and genuinely signed; it just covers a different
|
|
// node's payload. hasSignedBlock cannot tell the difference and neither can
|
|
// putSubmission, so before the signature was verified here this imported
|
|
// cleanly on the source instance's word alone.
|
|
ok(!(await store.getSubmission("mainnet-forged")),
|
|
"a well-formed block over somebody else's payload is refused: signatures are verified here, not taken on trust");
|
|
|
|
// An import into a RUNNING instance arrives pending, so it lands in the
|
|
// moderation queue the operator already uses. At install approved is right,
|
|
// because choosing to bootstrap is the decision to trust that list wholesale;
|
|
// in a running instance a listing that appeared on the site without passing
|
|
// the queue would be another directory publishing here.
|
|
{
|
|
const url = "http://" + "h".repeat(56) + ".onion/v2";
|
|
const one = { pairing: { type: "dojo.api", url, apikey: "k" } };
|
|
const docs = {
|
|
"/data/operator.json": opDoc,
|
|
"/data/dojos.json": { nodes: [{ id: "mainnet-queued", network: "mainnet", name: "queued",
|
|
paynym: "+imp", paymentCode: "PMimpDisplay", signed: signBlockFor(one), payload: one }] },
|
|
};
|
|
const r2 = await bootstrapImport({ onionHost, trustedCode: opCode, status: "pending",
|
|
fetchDoc: async (pth) => { if (!(pth in docs)) throw new Error("404 " + pth); return docs[pth]; },
|
|
fetchCodes, dataDir: process.env.PUBLIC_DATA_DIR, log: () => {} });
|
|
const queued = await store.getSubmission("mainnet-queued");
|
|
ok(r2.imported === 1 && queued && queued.status === "pending",
|
|
"an import can arrive pending, for a running instance with a moderation queue: " + (queued && queued.status));
|
|
ok(r2.plan && r2.plan.some((row) => row.id === "mainnet-queued" && row.action === "import" && row.url === url),
|
|
"and the plan comes back as data, so a console can render it rather than parse log lines");
|
|
await store.deleteSubmission("mainnet-queued");
|
|
}
|
|
|
|
// The route asks for that, rather than inheriting the installer's default.
|
|
{
|
|
const idx = await fsp.readFile(new URL("./index.ts", import.meta.url), "utf8");
|
|
const block = idx.slice(idx.indexOf('route("POST", /^\\/api\\/admin\\/import$/'),
|
|
idx.indexOf('route("GET", /^\\/api\\/admin\\/import\\/status$/'));
|
|
ok(block.length > 200 && /status: "pending"/.test(block),
|
|
"the admin import route asks for pending records rather than taking the installer's default");
|
|
|
|
// A pending record's live status comes from pending-probe.json, which only
|
|
// the update cycle writes. Until one runs, an imported listing has no
|
|
// status and the moderation queue shows it as inactive, which is not what
|
|
// it is: nothing has asked it yet, and the moderator deciding whether to
|
|
// approve is the person who needs the answer.
|
|
const job = idx.slice(idx.indexOf('route("POST", /^\\/api\\/admin\\/import$/'),
|
|
idx.indexOf('route("GET", /^\\/api\\/admin\\/import\\/status$/'));
|
|
ok(/"scripts", "update.mjs"/.test(job) && /job\.phase = "probing"/.test(job),
|
|
"an applied import runs a probe cycle, as the installer does before declaring success");
|
|
ok(job.indexOf("tryRebuild()") < job.indexOf('job.phase = "probing"'),
|
|
"after the rebuild, so the cycle sees the records it is about to probe");
|
|
ok(/is-active", "--quiet", "dojobay-update\.service"/.test(job),
|
|
"and skips it when a cycle is already running, since there is no lock and two would race");
|
|
ok(/\(job\.result\?\.imported \?\? 0\) > 0/.test(job),
|
|
"nothing is probed when nothing was imported");
|
|
ok(/dryRun: !job\.apply/.test(block),
|
|
"and plans unless the operator explicitly asked to apply");
|
|
}
|
|
ok(r.imported === 1 && imp && imp.status === "approved"
|
|
&& imp.paymentCodes.includes("PMimpSegwit") && imp.paymentCodes.includes("PMimpLegacy") && imp.paymentCodes.includes("PMimpDisplay")
|
|
&& imp.source === `bootstrap-import:${onionHost}`
|
|
&& untouched && !String(untouched.source || "").startsWith("bootstrap")
|
|
&& histAfter["mainnet-imported"] && histAfter["mainnet-imported"].checks.length === 1
|
|
&& histAfter["mainnet-selftest-node"].checks[0].t !== "2026-07-01 00:00",
|
|
"bootstrap imports new nodes with all code variants and history; existing ids untouched");
|
|
|
|
// The duplicate an operator actually hits. Bootstrapping a new instance from a
|
|
// directory that already lists your own node used to create a second record
|
|
// for it, because each instance derives an id from the name it was given and
|
|
// the two differ. The anchor is in seed.json rather than the store, so it was
|
|
// invisible to the existing-id check: the operator's own node was the one
|
|
// guaranteed to duplicate.
|
|
{
|
|
const anchorUrl = "http://" + "d".repeat(56) + ".onion/v2";
|
|
const seedPath = process.env.PUBLIC_DATA_DIR + "/seed.json";
|
|
const seedDoc = JSON.parse(await fsp.readFile(seedPath, "utf8"));
|
|
const keptSeed = JSON.stringify(seedDoc);
|
|
seedDoc.nodes[0].payload = { pairing: { type: "dojo.api", apikey: "k", url: anchorUrl } };
|
|
const anchorId = seedDoc.nodes[0].id;
|
|
await fsp.writeFile(seedPath, JSON.stringify(seedDoc, null, 2) + "\n");
|
|
|
|
// the same machine, published by the remote instance under its own id
|
|
// Cast: the checker infers a literal shape from the fixtures these replace,
|
|
// and the import reads them as plain documents.
|
|
remoteDocs["/data/dojos.json"] = /** @type {any} */ ({ nodes: [{
|
|
id: "mainnet-their-name-for-it", network: "mainnet", name: "their name for it",
|
|
paynym: "+imp", paymentCode: "PMimpDisplay", signed: signedBlock,
|
|
payload: { pairing: { type: "dojo.api", apikey: "k", url: anchorUrl.toUpperCase() + "/" } },
|
|
}] });
|
|
remoteDocs["/data/history.json"] = /** @type {any} */ ({ nodes: { "mainnet-their-name-for-it": { checks: [{ t: "2026-07-02 00:00", up: true }] } } });
|
|
remoteDocs["/data/history-daily.json"] = /** @type {any} */ ({ nodes: {} });
|
|
|
|
const dup = await bootstrapImport({ onionHost, trustedCode: opCode, fetchDoc, fetchCodes,
|
|
dataDir: process.env.PUBLIC_DATA_DIR, log: () => {} });
|
|
ok(dup.imported === 0 && dup.merged === 1,
|
|
"the operator's own node arrives as a merge rather than a second listing");
|
|
ok(!(await store.getSubmission("mainnet-their-name-for-it")),
|
|
"and no record is created for it under the other instance's id");
|
|
// Upper-cased and with a trailing slash in the fixture, because neither
|
|
// changes which endpoint is meant and both are the sort of difference that
|
|
// would defeat a naive string compare.
|
|
const h = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/history.json", "utf8")).nodes;
|
|
ok(h[anchorId] && h[anchorId].checks.some((c) => c.t === "2026-07-02 00:00"),
|
|
"its history is carried onto the id this instance uses, so months of uptime survive");
|
|
|
|
await fsp.writeFile(seedPath, keptSeed + "\n");
|
|
}
|
|
|
|
// A verified domain travels with the data, because the signed statement names
|
|
// the domain and the code but never the instance that verified it. It must NOT
|
|
// arrive verified: importing a badge on another instance's word would let one
|
|
// compromised directory mint verified domains across a federation.
|
|
const claimed = await store.getDomain(paymentCode);
|
|
ok(claimed && claimed.domain === "example.org" && claimed.signed === signedUrlBlock,
|
|
"a domain claim published by the source is carried across intact");
|
|
ok(claimed.verified === false && claimed.last_check === null
|
|
&& /awaiting our own DNS/.test(claimed.last_result || ""),
|
|
"and arrives UNVERIFIED, so this instance must see the TXT record itself");
|
|
|
|
// a proof whose signature does not check out is refused outright
|
|
ok(r.domains_imported === 1 && r.domains_refused === 1,
|
|
"a proof with a bad signature is refused rather than imported: " + JSON.stringify({ i: r.domains_imported, x: r.domains_refused }));
|
|
ok((await store.getDomain("PM8T" + "9".repeat(112))) === null,
|
|
"and nothing is stored for it");
|
|
|
|
await store.deleteDomain(paymentCode);
|
|
}
|
|
|
|
// 19) self-update sourcing: GitHub and peer fetchers verify before trusting,
|
|
// apply() stages a real archive, and the admin routes are gated.
|
|
{
|
|
const { fetchFromPeer, applyUpdate } = await import("./self-update.mjs");
|
|
const { packSource } = await import("../scripts/pack-source.mjs");
|
|
|
|
// build a real archive to feed the peer fetcher's zip step
|
|
const tmp = await fsp.mkdtemp(pathMod.join(os.tmpdir(), "dojobay-su-"));
|
|
const packed = await packSource({ outDir: tmp });
|
|
const zipBytes = await fsp.readFile(packed.out);
|
|
|
|
// a valid peer operator binding (reuse the operator doc from check 18 shape)
|
|
const peerOnion = "f".repeat(56) + ".onion";
|
|
const peerMsg = `http://${peerOnion}/\n\nBIP47: ${paymentCode}`;
|
|
const peerSig = Buffer.from(msg.sign(peerMsg, priv, true, net47.messagePrefix)).toString("base64");
|
|
const peerBlock = `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${peerMsg}\n-----BEGIN BITCOIN SIGNATURE-----\nAddress: ${notifAddr}\n\n${peerSig}\n-----END BITCOIN SIGNATURE-----`;
|
|
const peerOpDoc = { onion: `http://${peerOnion}/`, paymentCode, verifySigned: peerBlock };
|
|
const fetchDoc = async (p) => {
|
|
if (p === "/data/operator.json") return { status: 200, body: JSON.stringify(peerOpDoc) };
|
|
if (p === "/data/version.json") return { status: 200, body: JSON.stringify({ commit: "peercommit" }) };
|
|
throw new Error("404 " + p);
|
|
};
|
|
const fetchZip = async () => ({ status: 200, bodyBuf: zipBytes });
|
|
|
|
// wrong trusted code -> refuse before fetching the zip
|
|
await ok(await fetchFromPeer({ onionHost: peerOnion, trustedCode: "PM8T" + "3".repeat(112), fetchDoc, fetchZip, log: () => {} })
|
|
.then(() => false, (e) => /different payment code/.test(e.message)),
|
|
"peer update refuses a peer whose operator code differs from the trusted one");
|
|
|
|
// correct code -> returns verified bytes, which apply() stages
|
|
const got = await fetchFromPeer({ onionHost: peerOnion, trustedCode: paymentCode, fetchDoc, fetchZip, log: () => {} });
|
|
// A web root with code in it, because applyUpdate now refuses to replace a
|
|
// tree it could not back up, and an empty directory is not a tree anyone
|
|
// updates. Instance data alongside, so the backup filter is exercised rather
|
|
// than assumed.
|
|
const webRoot = await fsp.mkdtemp(pathMod.join(os.tmpdir(), "dojobay-suweb-"));
|
|
await fsp.mkdir(pathMod.join(webRoot, "server", "data"), { recursive: true });
|
|
await fsp.mkdir(pathMod.join(webRoot, "assets"), { recursive: true });
|
|
await fsp.writeFile(pathMod.join(webRoot, "server", "index.mjs"), "// current");
|
|
await fsp.writeFile(pathMod.join(webRoot, "server", "data", "store.json"), "{}");
|
|
await fsp.writeFile(pathMod.join(webRoot, "assets", "app.js"), "// current");
|
|
const applied = await applyUpdate({ ...got, webRoot, spawnHelper: false, log: () => {} });
|
|
const backedUpStore = await fsp.readFile(pathMod.join(applied.backupDir, "server/data/store.json")).then(() => true, () => false);
|
|
ok(!backedUpStore, "the backup carries code but never the store, which holds sessions and node API keys");
|
|
const stagedOk = await fsp.readFile(pathMod.join(applied.staging, "server/index.mjs")).then(() => true, () => false);
|
|
ok(got.version === "peercommit" && stagedOk && applied.entries > 30,
|
|
"verified peer archive is staged for apply");
|
|
await fsp.rm(tmp, { recursive: true, force: true });
|
|
await fsp.rm(webRoot, { recursive: true, force: true });
|
|
|
|
// admin gating of the job routes
|
|
const anonStart = await fetch(base + "/api/admin/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
|
const anonStatus = await fetch(base + "/api/admin/update/status");
|
|
const adminStatus = await api("/api/admin/update/status");
|
|
ok(anonStart.status === 401 && anonStatus.status === 401 && adminStatus.status === 200,
|
|
"update routes require admin; status readable by admin");
|
|
}
|
|
|
|
// 20) live-detected Dojo version (X-Dojo-Version): rebuild carries the value the
|
|
// updater wrote and folds it into the card version. The version is derived
|
|
// entirely from the node's API detected live wins, pairing is only the
|
|
// bootstrap fallback, and an operator edit can never change it.
|
|
{
|
|
const { rebuild, effectiveVersion } = await import("./build-public.ts");
|
|
const id = "mainnet-selftest-node";
|
|
const dojosPath = process.env.PUBLIC_DATA_DIR + "/dojos.json";
|
|
|
|
ok(effectiveVersion("1.33.7", "1.28.0") === "1.33.7"
|
|
&& effectiveVersion(null, "1.28.0") === "1.28.0"
|
|
&& effectiveVersion(null, null) === null,
|
|
"effectiveVersion: detected live version wins, pairing is the fallback");
|
|
|
|
// simulate the updater having recorded a live version on the node
|
|
const snap = JSON.parse(await fsp.readFile(dojosPath, "utf8"));
|
|
snap.nodes.find((n) => n.id === id).detected_version = "1.33.7";
|
|
await fsp.writeFile(dojosPath, JSON.stringify(snap, null, 2) + "\n");
|
|
await rebuild();
|
|
let n = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === id);
|
|
ok(n.detected_version === "1.33.7" && n.version === "1.33.7",
|
|
"rebuild carries detected_version and shows it as the card version");
|
|
|
|
// an edit that tries to set a version is ignored; the detected value stands
|
|
await api("/api/dojo/edit", "POST", { id, name: "selftest-node", hardware: "RPi5 8GB", version: "0.0.1-hax" });
|
|
n = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === id);
|
|
ok(n.version === "1.33.7" && n.detected_version === "1.33.7",
|
|
"an operator edit cannot override the live-detected version");
|
|
}
|
|
|
|
// 21) live-detected Electrum endpoint (/support/services): rebuild carries what
|
|
// the updater read and publishes it as indexer_url, and nothing else can
|
|
// put a URL there. A node that publishes none yields null so the card can
|
|
// show N/A, which is why a declared URL is no longer a fallback: a healthy
|
|
// node exposing no indexer never acquires a detected value, so the declared
|
|
// one would have been published for good.
|
|
{
|
|
const { rebuild, effectiveIndexer } = await import("./build-public.ts");
|
|
const id = "mainnet-selftest-node";
|
|
const dojosPath = process.env.PUBLIC_DATA_DIR + "/dojos.json";
|
|
const live = "tcp://" + "i".repeat(56) + ".onion:50001";
|
|
const declared = "ssl://" + "d".repeat(56) + ".onion:50002";
|
|
|
|
ok(effectiveIndexer(live) === live && effectiveIndexer(null) === null
|
|
&& effectiveIndexer(undefined) === null,
|
|
"effectiveIndexer: the probed endpoint or nothing");
|
|
|
|
// A payload carrying an indexer must not reach a card by any route. The
|
|
// fixture is built the way a Dojo export builds one, with both the flattened
|
|
// indexer and the modern services[] array, because the gate used to read
|
|
// either. `declared` exists in this test only to be refused.
|
|
const withIdx = { pairing: { ...payload.pairing }, explorer: payload.explorer,
|
|
indexer: { type: "indexer", url: declared },
|
|
services: [{ type: "indexer", url: declared }] };
|
|
const upd = await api("/api/dojo/pairing", "POST", { id, payload: withIdx, signed: signBlockFor(withIdx) });
|
|
const { store: idxStore } = await import("./store.ts");
|
|
const idxRec = await idxStore.getSubmission(id);
|
|
ok(upd.status === 200 && Object.keys(idxRec.payload).sort().join(",") === "explorer,pairing",
|
|
"an indexer block posted with a pairing update is discarded: the stored payload is what was signed");
|
|
|
|
await rebuild();
|
|
const idxNode = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === id);
|
|
ok(idxNode.indexer_url === null && !("indexer" in idxNode.payload),
|
|
"and the card publishes N/A rather than the declared endpoint");
|
|
|
|
const snap = JSON.parse(await fsp.readFile(dojosPath, "utf8"));
|
|
snap.nodes.find((n) => n.id === id).detected_indexer = live;
|
|
await fsp.writeFile(dojosPath, JSON.stringify(snap, null, 2) + "\n");
|
|
await rebuild();
|
|
const n = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === id);
|
|
ok(n.detected_indexer === live && n.indexer_url === live,
|
|
"rebuild carries detected_indexer and publishes it as indexer_url");
|
|
|
|
const other = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id !== id);
|
|
ok(!other || other.indexer_url === null || typeof other.indexer_url === "string",
|
|
"nodes without a probed endpoint publish null (card shows N/A)");
|
|
}
|
|
|
|
// 22) signature gate robustness, from real listings found by the store audit.
|
|
// Wallets and admin panels serialise the pairing JSON differently, and a
|
|
// PayNym signs from its mainnet notification address even for a testnet
|
|
// node. Both used to fail the gate despite the signature being perfect.
|
|
{
|
|
const { verifySignedPayload, sameSignedPayload, notificationAddresses } = await import("./crypto.ts");
|
|
const sign = (text, acct) => Buffer.from(msg.sign(text, acct.getNotificationPrivateKey(), true, net47.messagePrefix)).toString("base64");
|
|
const blockOf2 = (text, addr) => `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${text}\n-----BEGIN BITCOIN SIGNATURE-----\nAddress: ${addr}\n\n${sign(text, acct)}\n-----END BITCOIN SIGNATURE-----`;
|
|
|
|
// pretty-printed, exactly as several real listings were signed
|
|
const pretty = JSON.stringify(JSON.parse(canonical), null, 2);
|
|
const prettySigned = pretty + "\n\nBIP47:\n" + paymentCode;
|
|
const rPretty = verifySignedPayload({ signedText: blockOf2(prettySigned, notifAddr), expectedMessage: canonical, expectedAddress: notifAddr });
|
|
|
|
// same data, keys in a different order
|
|
const src = JSON.parse(canonical);
|
|
const reordered = JSON.stringify({ explorer: src.explorer, pairing: Object.fromEntries(Object.keys(src.pairing).reverse().map((k) => [k, src.pairing[k]])) });
|
|
const reSigned = reordered + "\n\nBIP47:\n" + paymentCode;
|
|
const rReorder = verifySignedPayload({ signedText: blockOf2(reSigned, notifAddr), expectedMessage: canonical, expectedAddress: notifAddr });
|
|
|
|
ok(rPretty.ok && rReorder.ok,
|
|
"pretty-printed and key-reordered signatures verify: the same payload, serialised differently");
|
|
|
|
// a changed value must still be refused
|
|
const changed = JSON.parse(canonical); changed.pairing.version = "9.9.9";
|
|
const chSigned = JSON.stringify(changed) + "\n\nBIP47:\n" + paymentCode;
|
|
const rChanged = verifySignedPayload({ signedText: blockOf2(chSigned, notifAddr), expectedMessage: canonical, expectedAddress: notifAddr });
|
|
ok(!rChanged.ok && /does not match/.test(rChanged.error)
|
|
&& !sameSignedPayload('{"a":1}', '{"a":2}') && sameSignedPayload('{"a":1,"b":2}', '{"b":2,"a":1}')
|
|
&& !sameSignedPayload('{"a":1}', '{"a":1,"b":2}'),
|
|
"a changed value, or an added or removed field, is still refused");
|
|
|
|
// a PayNym signs from its mainnet address even for a testnet listing
|
|
const addrs = notificationAddresses(paymentCode);
|
|
ok(addrs.length === 2 && addrs[0] === notifAddr,
|
|
"notificationAddresses returns both derivations, mainnet first");
|
|
const rTestnet = verifySignedPayload({ signedText: blockOf2(signedText, notifAddr), expectedMessage: canonical, expectedAddress: addrs, network: "testnet" });
|
|
ok(rTestnet.ok, "a testnet listing signed with the mainnet notification address verifies");
|
|
}
|
|
|
|
// 23) the store auditor must reproduce the gate's verdict, not its own. It
|
|
// once derived the notification address for the record's own network, so
|
|
// every testnet listing was reported as failing while the gate accepted it.
|
|
{
|
|
const { auditRecord } = await import("./audit-signed.mjs");
|
|
const rec = (net) => ({ id: `${net}-audit`, network: net, name: "audit", status: "approved",
|
|
paymentCodes: [paymentCode], payload, signed: signedBlock });
|
|
ok(auditRecord(rec("mainnet")).bucket === "VERIFIED", "auditor verifies a good mainnet record");
|
|
ok(auditRecord(rec("testnet")).bucket === "VERIFIED",
|
|
"auditor verifies a testnet record signed with the mainnet notification address (mirrors the gate)");
|
|
ok(auditRecord({ ...rec("mainnet"), signed: null }).bucket === "UNSIGNED", "auditor reports an unsigned record");
|
|
ok(auditRecord({ ...rec("mainnet"), payload: { ...payload, pairing: { ...payload.pairing, apikey: "changed" } } }).bucket === "FAILED",
|
|
"auditor fails a record whose payload no longer matches what was signed");
|
|
}
|
|
|
|
// 24) verified operator domains: the pure parts (normalisation, the TXT record,
|
|
// the DoH answer shape and the grace policy), then the API path with DNS
|
|
// stubbed, since a self-test must not depend on the internet.
|
|
{
|
|
const dom = await import("./domains.ts");
|
|
const dns = await import("./dns.ts");
|
|
const { store: st } = await import("./store.ts");
|
|
|
|
// normalisation: accept what an operator is likely to type, reject the rest
|
|
ok(dom.normaliseDomain("Example.COM").domain === "example.com"
|
|
&& dom.normaliseDomain("https://example.com/").domain === "example.com"
|
|
&& dom.normaliseDomain(" https://sub.example.com/path?q=1 ").domain === "sub.example.com"
|
|
&& dom.normaliseDomain("xn--bcher-kva.de").domain === "xn--bcher-kva.de",
|
|
"domain normalisation reduces what operators type to a bare ASCII host");
|
|
ok(!dom.normaliseDomain("").ok && !dom.normaliseDomain("localhost").ok
|
|
&& !dom.normaliseDomain("192.168.0.1").ok && !dom.normaliseDomain("example.com:8080").ok
|
|
&& !dom.normaliseDomain("abc.onion").ok && !dom.normaliseDomain("nodots").ok
|
|
&& /onion address cannot be verified/.test(dom.normaliseDomain("abc.onion").error),
|
|
"domain normalisation refuses IPs, ports, localhost, bare labels and onions");
|
|
|
|
// the TXT record: strict about the code, tolerant of quoting and whitespace
|
|
const rec = dom.txtValue(paymentCode);
|
|
ok(dom.txtName("example.com") === "_dojobay.example.com"
|
|
&& dom.txtMatches(rec, paymentCode)
|
|
&& dom.txtMatches('"' + rec + '"', paymentCode)
|
|
&& dom.txtMatches(rec.replace(" ", " "), paymentCode)
|
|
&& !dom.txtMatches(rec.replace(/pm=PM8T/, "pm=PM8Tx"), paymentCode)
|
|
&& !dom.txtMatches("v=spf1 include:example.com", paymentCode)
|
|
&& !dom.txtMatches("dojobay-domain-v1", paymentCode),
|
|
"the TXT record matcher accepts real-world quoting but pins the payment code");
|
|
|
|
// DoH answers, including a long record split across quoted strings
|
|
ok(JSON.stringify(dns.parseTxtAnswer(JSON.stringify({ Status: 0, Answer: [{ type: 16, data: '"a" "b"' }] }))) === '["ab"]'
|
|
&& JSON.stringify(dns.parseTxtAnswer(JSON.stringify({ Status: 3 }))) === "[]"
|
|
&& dns.parseTxtAnswer(JSON.stringify({ Status: 2 })) === null
|
|
&& dns.parseTxtAnswer("not json") === null,
|
|
"DoH answers parse: split strings joined, NXDOMAIN empty, SERVFAIL unusable");
|
|
|
|
// the signed claim reuses the operator-binding shape
|
|
const claim = dom.signingText("example.com", paymentCode);
|
|
ok(claim === `https://example.com/\n\nBIP47: ${paymentCode}`, "the text to sign is the URL, a blank line, then the BIP47 line");
|
|
const { verifySignedUrlClaim } = await import("./crypto.ts");
|
|
const blockFor = (text) => `-----BEGIN BITCOIN SIGNED MESSAGE-----\n${text}\n-----BEGIN BITCOIN SIGNATURE-----\nAddress: ${notifAddr}\n\n${Buffer.from(msg.sign(text, priv, true, net47.messagePrefix)).toString("base64")}\n-----END BITCOIN SIGNATURE-----`;
|
|
const good = verifySignedUrlClaim({ signed: blockFor(claim), expectedUrl: "https://example.com", paymentCode });
|
|
const wrongDomain = verifySignedUrlClaim({ signed: blockFor(claim), expectedUrl: "https://other.com", paymentCode });
|
|
const tampered = verifySignedUrlClaim({ signed: blockFor(claim).replace("example.com/", "evil.com/"), expectedUrl: "https://evil.com", paymentCode });
|
|
ok(good.ok && !wrongDomain.ok && /this claim is for/.test(wrongDomain.error)
|
|
&& !tampered.ok && /invalid signature|does not/.test(tampered.error),
|
|
"a signed domain claim verifies, and is refused for another domain or if altered");
|
|
|
|
// grace policy: a badge survives an unreachable resolver, and only drops after
|
|
// a sustained failure, keeping the claim so a restored record restores it
|
|
/** @type {import("../types.js").DomainClaim} */
|
|
const base = { paymentCode, domain: "example.com", signed: "(test)", verified: true,
|
|
verified_at: "2026-01-01T00:00:00Z", last_check: "2026-01-01T00:00:00Z", last_result: "ok",
|
|
fail_since: null, created_at: "2026-01-01T00:00:00Z" };
|
|
const now = Date.parse("2026-07-01T00:00:00Z");
|
|
const inc = dom.applyRecheck(base, { ok: false, inconclusive: true, error: "tor down" }, now);
|
|
const failed1 = dom.applyRecheck(base, { ok: false, inconclusive: false, error: "no TXT record" }, now);
|
|
const failedLong = dom.applyRecheck({ ...base, fail_since: "2026-06-01T00:00:00Z" }, { ok: false, inconclusive: false, error: "no TXT record" }, now);
|
|
const recovered = dom.applyRecheck(failedLong, { ok: true, inconclusive: false }, now);
|
|
ok(inc.verified === true && inc.fail_since === null && /inconclusive/.test(inc.last_result),
|
|
"an unreachable resolver never strips a badge");
|
|
ok(failed1.verified === true && failed1.fail_since
|
|
&& failedLong.verified === false
|
|
&& recovered.verified === true && recovered.fail_since === null,
|
|
`a missing record drops the badge only after ${dom.GRACE_DAYS} days, and restoring it recovers without re-signing`);
|
|
|
|
ok(dom.urlOnDomain("https://example.com/x", "example.com")
|
|
&& dom.urlOnDomain("https://a.example.com/", "example.com")
|
|
&& !dom.urlOnDomain("https://notexample.com/", "example.com")
|
|
&& !dom.urlOnDomain("https://example.com.evil.net/", "example.com")
|
|
&& !dom.urlOnDomain("javascript:alert(1)", "example.com"),
|
|
"a card link is only on-domain for the domain itself or a true subdomain");
|
|
|
|
// API: prepare returns the exact record and text; submission verifies with DNS
|
|
// stubbed, and admin revocation clears the badge
|
|
const prep = await api("/api/domain/prepare", "POST", { domain: "Example.COM" });
|
|
ok(prep.status === 200 && prep.body.txt_name === "_dojobay.example.com"
|
|
&& prep.body.txt_value === rec && prep.body.sign_text === claim,
|
|
"prepare returns the exact TXT record and text to sign");
|
|
|
|
await st.deleteDomain(paymentCode);
|
|
const listed = await api("/api/admin/domains");
|
|
ok(listed.status === 200 && Array.isArray(listed.body.domains), "admin can list domain claims");
|
|
|
|
await st.putDomain({ paymentCode, domain: "example.org", signed: "(test)", verified: true,
|
|
verified_at: new Date().toISOString(), last_check: new Date().toISOString(), last_result: "ok",
|
|
fail_since: null, created_at: new Date().toISOString() });
|
|
const revoked = await api("/api/admin/domain/revoke", "POST", { paymentCode });
|
|
const after = await st.getDomain(paymentCode);
|
|
const { rebuild: rb } = await import("./build-public.ts");
|
|
await rb();
|
|
const node = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8"))
|
|
.nodes.find((n) => n.id === "mainnet-selftest-node");
|
|
ok(revoked.status === 200 && after.verified === false && after.revoked === true
|
|
&& node.operator_domain === null && !("name_url" in node),
|
|
"admin revocation drops the badge on the next rebuild, and there is no card link "
|
|
+ "left to withhold: the domain badge is the only claim a card carries");
|
|
}
|
|
|
|
// 25) the launcher: server/index.mjs must keep existing and must refuse an old
|
|
// Node before importing the TypeScript server. self-update.mjs sanity-checks
|
|
// an archive by looking for server/index.mjs, and systemd, npm start and the
|
|
// README all name it, so renaming it would break more than it appears.
|
|
{
|
|
const launcher = await fsp.readFile(new URL("./index.mjs", import.meta.url), "utf8");
|
|
ok(/process\.versions\.node/.test(launcher) && /< 24/.test(launcher) && /process\.exit\(1\)/.test(launcher),
|
|
"index.mjs refuses Node older than 24 with a message, before importing index.ts");
|
|
ok(/await import\("\.\/index\.ts"\)/.test(launcher) && !/^import .*index\.ts/m.test(launcher),
|
|
"the launcher imports the server dynamically, so the version check runs first");
|
|
ok(/export const server/.test(launcher), "the launcher re-exports the server for callers");
|
|
|
|
// build-public.mjs is the same pattern, and its name is depended on from
|
|
// further away: the deploy workflow, npm run build-public, install.mjs and
|
|
// apply-update.mjs, which spawns it DURING a self-update while still running
|
|
// the old copy of itself. A rename would break an instance mid-update.
|
|
const bp = await fsp.readFile(new URL("./build-public.mjs", import.meta.url), "utf8");
|
|
ok(/< 24/.test(bp) && /await import\("\.\/build-public\.ts"\)/.test(bp),
|
|
"build-public.mjs guards the Node version, then imports build-public.ts dynamically");
|
|
ok(/export const rebuild/.test(bp) && /pathToFileURL/.test(bp),
|
|
"the rebuild launcher re-exports rebuild and still runs it when invoked directly");
|
|
}
|
|
|
|
// 26) whitespace repair: the signature covers the blank line before the BIP47
|
|
// line, and copying a block through chat, a form or a mail client routinely
|
|
// eats it. A reconstruction is accepted ONLY if it verifies against an
|
|
// address the declared code derives, so this is search, not trust.
|
|
{
|
|
const { repairSignedBlock, verifySignedPayload, notificationAddresses } = await import("./crypto.ts");
|
|
const intact = signedBlock; // json + blank line + BIP47 line
|
|
const mangled = intact.replace(`${canonical}\n\nBIP47:`, `${canonical}\nBIP47:`);
|
|
ok(mangled !== intact, "the fixture really did lose its blank line");
|
|
|
|
const before = verifySignedPayload({ signedText: mangled, expectedMessage: canonical, expectedAddress: notifAddr });
|
|
ok(!before.ok && /invalid signature/.test(before.error),
|
|
"a block that lost its blank line fails verification as supplied");
|
|
|
|
const fixed = repairSignedBlock(mangled);
|
|
ok(fixed && /blank line/.test(fixed.note || ""), "the repair reports what it changed");
|
|
const after = verifySignedPayload({ signedText: fixed.block, expectedMessage: canonical, expectedAddress: notifAddr });
|
|
ok(after.ok, "the repaired block verifies, so it is safe to store");
|
|
|
|
// an intact block is returned unchanged, with nothing to report
|
|
const untouched = repairSignedBlock(intact);
|
|
ok(untouched && untouched.note === null, "an intact block is passed through unrepaired");
|
|
|
|
// repair must never rescue a genuinely bad signature, or one whose code does
|
|
// not own the signing address
|
|
const corrupt = mangled.replace(sigLine, sigLine.replace(/^./, (c) => (c === "H" ? "I" : "H")));
|
|
ok(repairSignedBlock(corrupt) === null, "a corrupted signature is not rescued by repair");
|
|
const otherCode = bip47.fromSeed(mnemonicToSeedSync("legal winner thank year wave sausage worth useful legal winner thank yellow")).toBase58();
|
|
ok(repairSignedBlock(intact.replace(paymentCode, otherCode)) === null,
|
|
"a block whose BIP47 code does not derive the signing address is refused");
|
|
}
|
|
|
|
// 27) retention: a rejected submission is kept briefly so a maintainer can undo
|
|
// a mistake, then removed. Nothing used to remove one, so the store kept the
|
|
// payment code, pairing payload, apikey and signature of every operator ever
|
|
// turned down, indefinitely.
|
|
{
|
|
const { store: st } = await import("./store.ts");
|
|
/**
|
|
* @param {string} id
|
|
* @param {"pending"|"approved"|"rejected"} status
|
|
* @param {string|undefined} updated
|
|
* @returns {import("../types.js").StoreRecord}
|
|
*/
|
|
const mk = (id, status, updated) => ({ id, network: "mainnet", name: id, status,
|
|
paymentCodes: [paymentCode], payload, signed: signedBlock, updated_at: updated });
|
|
const day = 86400 * 1000, now = Date.now();
|
|
await st.putSubmission(mk("mainnet-rej-old", "rejected", new Date(now - 30 * day).toISOString()));
|
|
await st.putSubmission(mk("mainnet-rej-new", "rejected", new Date(now - 2 * day).toISOString()));
|
|
await st.putSubmission(mk("mainnet-rej-nodate", "rejected", undefined));
|
|
await st.putSubmission(mk("mainnet-keep-approved", "approved", new Date(now - 400 * day).toISOString()));
|
|
await st.putSubmission(mk("mainnet-keep-pending", "pending", new Date(now - 400 * day).toISOString()));
|
|
|
|
const gone = await st.pruneRejected(14, now);
|
|
const left = (await st.listSubmissions()).map((r) => r.id);
|
|
ok(gone.includes("mainnet-rej-old") && !left.includes("mainnet-rej-old"),
|
|
"a rejection older than the retention window is removed");
|
|
ok(!gone.includes("mainnet-rej-new") && left.includes("mainnet-rej-new"),
|
|
"a recent rejection is kept, so a mistaken rejection can be undone");
|
|
ok(gone.includes("mainnet-rej-nodate"),
|
|
"a rejection with no usable timestamp is removed rather than kept forever");
|
|
ok(left.includes("mainnet-keep-approved") && left.includes("mainnet-keep-pending"),
|
|
"approved and pending records are never touched, however old");
|
|
|
|
const stored = await fsp.readFile(process.env.SERVER_DATA_DIR + "/store.json", "utf8");
|
|
ok(!stored.includes("mainnet-rej-old"),
|
|
"the removed record is gone from the store file, apikey and signature included");
|
|
for (const id of ["mainnet-rej-new", "mainnet-rej-nodate", "mainnet-keep-approved", "mainnet-keep-pending"]) {
|
|
await st.deleteSubmission(id);
|
|
}
|
|
}
|
|
|
|
// 28) the domain badge publishes its own proof, so a reader can check the claim
|
|
// with their own tools rather than trusting this instance's tick.
|
|
{
|
|
const { store: st } = await import("./store.ts");
|
|
const { rebuild: rb } = await import("./build-public.ts");
|
|
const dojosPath = process.env.PUBLIC_DATA_DIR + "/dojos.json";
|
|
await st.putDomain({ paymentCode, domain: "example.org", signed: signedBlock, verified: true,
|
|
verified_at: "2026-07-01T00:00:00Z", last_check: "2026-07-02T00:00:00Z", last_result: "ok",
|
|
fail_since: null, created_at: "2026-07-01T00:00:00Z" });
|
|
await rb();
|
|
const n = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === "mainnet-selftest-node");
|
|
const pf = n.operator_domain_proof;
|
|
ok(pf && pf.domain === "example.org" && pf.paymentCode === paymentCode,
|
|
"the proof names the domain and the payment code it is bound to");
|
|
ok(pf.txt_name === "_dojobay.example.org" && pf.txt_value === `dojobay-domain-v1 pm=${paymentCode}`,
|
|
"it publishes the exact TXT record a reader should look up");
|
|
ok(pf.signed === signedBlock && pf.verified_at === "2026-07-01T00:00:00Z",
|
|
"and the signed statement, so the signature half can be checked independently");
|
|
|
|
// a node whose operator has no verified domain publishes nothing
|
|
await st.deleteDomain(paymentCode);
|
|
await rb();
|
|
const n2 = JSON.parse(await fsp.readFile(dojosPath, "utf8")).nodes.find((x) => x.id === "mainnet-selftest-node");
|
|
ok(n2.operator_domain === null && n2.operator_domain_proof === null,
|
|
"no verified domain means no badge and no proof");
|
|
}
|
|
|
|
// 29) the submission gate repairs a paste that lost its blank line, end to end.
|
|
// Operators paste into a web form, which mangles whitespace exactly as a
|
|
// chat window does, and the signature covers that blank line.
|
|
{
|
|
const mangled = signedBlock.replace(`${canonical}\n\nBIP47:`, `${canonical}\nBIP47:`);
|
|
ok(mangled !== signedBlock, "the fixture really did lose its blank line");
|
|
|
|
const r = await api("/api/dojo", "POST", {
|
|
network: "mainnet", name: "paste-repair", jurisdiction: "Testland",
|
|
payload, signed: mangled,
|
|
});
|
|
ok(r.status === 200, "a submission whose paste lost the blank line is accepted: " + JSON.stringify(r.body?.error || ""));
|
|
|
|
// and what is STORED is the repaired block, so a later audit verifies
|
|
const { store: st } = await import("./store.ts");
|
|
const { auditRecord } = await import("./audit-signed.mjs");
|
|
const rec = (await st.listSubmissions()).find((x) => x.name === "paste-repair");
|
|
ok(rec && rec.signed !== mangled, "the repaired block is stored, not the mangled paste");
|
|
ok(auditRecord(rec).bucket === "VERIFIED", "so the stored record passes a later audit");
|
|
|
|
// repair must not rescue a signature that is actually wrong
|
|
const corrupt = mangled.replace(sigLine, sigLine.replace(/^./, (c) => (c === "H" ? "I" : "H")));
|
|
const bad = await api("/api/dojo", "POST", {
|
|
network: "mainnet", name: "paste-repair-bad", jurisdiction: "Testland",
|
|
payload, signed: corrupt,
|
|
});
|
|
ok(bad.status === 400 && /signature gate/.test(bad.body.error),
|
|
"a genuinely bad signature is still refused: " + JSON.stringify(bad.body?.error || ""));
|
|
|
|
await st.deleteSubmission(rec.id);
|
|
}
|
|
|
|
// 30) updating pairing details: an operator whose onion changes keeps their
|
|
// listing. Approval binds to the payment code that owns the record, not to
|
|
// a particular address, so the moderation status, the id and therefore the
|
|
// reliability history all survive.
|
|
{
|
|
const { store: st } = await import("./store.ts");
|
|
const id = "mainnet-selftest-node";
|
|
const before = await st.getSubmission(id);
|
|
ok(before.status === "approved", "the record under test starts approved");
|
|
|
|
const movedUrl = "http://" + "m".repeat(56) + ".onion/v2";
|
|
const moved = { pairing: { ...payload.pairing, url: movedUrl }, explorer: payload.explorer };
|
|
|
|
const r = await api("/api/dojo/pairing", "POST", { id, payload: moved, signed: signBlockFor(moved) });
|
|
const after = await st.getSubmission(id);
|
|
ok(r.status === 200 && after.payload.pairing.url === movedUrl,
|
|
"the pairing payload is replaced: " + JSON.stringify(r.body?.error || ""));
|
|
ok(after.status === "approved" && after.id === id,
|
|
"and the listing keeps its approval and its id, so its history survives");
|
|
|
|
// published immediately, rather than waiting for the next probe cycle
|
|
const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8"))
|
|
.nodes.find((n) => n.id === id);
|
|
ok(pub && pub.payload.pairing.url === movedUrl, "and the card shows the new address at once");
|
|
|
|
// a signature, when supplied, must cover the payload being submitted
|
|
const bad = await api("/api/dojo/pairing", "POST", { id, payload: moved, signed: signedBlock });
|
|
ok(bad.status === 400 && /signature gate/.test(bad.body.error),
|
|
"a signature that does not cover the new payload is refused");
|
|
|
|
// and omitting it entirely is refused rather than nulling the stored one. An
|
|
// edit with no block used to assign rec.signed = null, which is how a
|
|
// verified listing could quietly become an unattested one.
|
|
const none = await api("/api/dojo/pairing", "POST", { id, payload: moved });
|
|
const stillSigned = await st.getSubmission(id);
|
|
ok(none.status === 400 && /cannot carry over/.test(none.body.error) && stillSigned.signed,
|
|
"an edit with no signed block is refused, told why the old one will not do, and the "
|
|
+ "stored signature survives: " + JSON.stringify(none.body?.error || ""));
|
|
|
|
// an unreachable address never replaces a working one: point the prober at the
|
|
// proxy that reports "host unreachable", as the submission gate test does
|
|
const dead = { pairing: { ...payload.pairing, url: "http://" + "z".repeat(56) + ".onion/v2" }, explorer: payload.explorer };
|
|
const { PROBE_CFG: PC } = await import("./probe.mjs");
|
|
PC.proxyPort = 19078;
|
|
const down = await api("/api/dojo/pairing", "POST", { id, payload: dead, signed: signBlockFor(dead) });
|
|
PC.proxyPort = 19077;
|
|
const stillThere = await st.getSubmission(id);
|
|
ok(down.status === 422 && /connection gate/.test(down.body.error)
|
|
&& stillThere.payload.pairing.url === movedUrl,
|
|
"an unreachable node is refused and the current listing is left alone");
|
|
|
|
// and only the owner may do it
|
|
const otherRec = { ...before, id: "mainnet-not-mine", name: "not-mine", paymentCodes: ["PM8T" + "7".repeat(112)] };
|
|
await st.putSubmission(otherRec);
|
|
const notMine = await api("/api/dojo/pairing", "POST", { id: "mainnet-not-mine", payload: moved, signed: signBlockFor(moved) });
|
|
ok(notMine.status === 404, "a record owned by another payment code is not editable");
|
|
await st.deleteSubmission("mainnet-not-mine");
|
|
|
|
// restore for later checks
|
|
before.payload = payload; await st.putSubmission(before);
|
|
}
|
|
|
|
// 31) the admin panel shows the same reliability data as the cards. It used to
|
|
// read only pending-probe.json, which the updater stops writing once a
|
|
// record is approved, so every approved listing said "not yet probed" and
|
|
// showed a strip frozen at whatever it had when it was approved.
|
|
{
|
|
const dir = process.env.PUBLIC_DATA_DIR;
|
|
const id = "mainnet-selftest-node";
|
|
const dojos = JSON.parse(await fsp.readFile(dir + "/dojos.json", "utf8"));
|
|
const n = dojos.nodes.find((x) => x.id === id);
|
|
n.status = "active"; n.block_height = 906123; n.checked_at = "2026-08-05 00:00";
|
|
n.detected_version = "1.31.0";
|
|
await fsp.writeFile(dir + "/dojos.json", JSON.stringify(dojos, null, 2) + "\n");
|
|
await fsp.writeFile(dir + "/history.json", JSON.stringify({
|
|
interval_minutes: 10, window_checks: 144,
|
|
nodes: { [id]: { checks: Array.from({ length: 12 }, (_, i) => ({ t: "2026-08-05T0" + i, up: true })) } },
|
|
}, null, 2) + "\n");
|
|
|
|
const r = await api("/api/admin/submissions");
|
|
const row = r.body.submissions.find((x) => x.id === id);
|
|
ok(row && row.probe && row.probe_source === "published",
|
|
"an approved record's probe data comes from the published view");
|
|
ok(row.probe.status === "active" && row.probe.block_height === 906123,
|
|
"so its live status and chain tip are what the card shows");
|
|
ok(Array.isArray(row.probe.checks) && row.probe.checks.length === 12,
|
|
"and its reliability strip has the full window, not a single block");
|
|
ok(row.version === "1.31.0",
|
|
"the version shown is the live-detected one, not the pairing payload's");
|
|
}
|
|
|
|
// 32) a listing without a BIP47 payment code is structurally impossible. The
|
|
// store is the single chokepoint every write passes through, so refusing
|
|
// there is what makes it impossible rather than merely discouraged, and the
|
|
// rebuild withholds any that predate the rule instead of publishing them.
|
|
{
|
|
const { store: st } = await import("./store.ts");
|
|
const { rebuild: rb } = await import("./build-public.ts");
|
|
const base = { network: "mainnet", name: "orphan", status: "approved", payload };
|
|
|
|
let threw = null;
|
|
try { await st.putSubmission(/** @type {any} */ ({ ...base, id: "mainnet-orphan", paymentCodes: [] })); }
|
|
catch (e) { threw = e; }
|
|
ok(threw && /must carry a BIP47 payment code/.test(threw.message),
|
|
"the store refuses a record with no payment code");
|
|
|
|
let threw2 = null;
|
|
try { await st.putSubmission(/** @type {any} */ ({ ...base, id: "mainnet-orphan2" })); }
|
|
catch (e) { threw2 = e; }
|
|
ok(threw2, "and one with no paymentCodes field at all");
|
|
ok((await st.getSubmission("mainnet-orphan")) === null, "nothing is written when it refuses");
|
|
|
|
// A record that predates the rule, injected past putSubmission, is withheld
|
|
// from the published list rather than shown. Injected into the LOADED store,
|
|
// not into store.json: the store holds itself in memory as a single writer
|
|
// and load() returns that cache, so a record written to the file behind a
|
|
// running process is invisible to the rebuild. This test used to write the
|
|
// file, which meant it asserted that a record the rebuild had never heard of
|
|
// did not appear — true, and no evidence of anything.
|
|
const loaded = await st.get();
|
|
loaded.submissions["mainnet-legacy-orphan"] =
|
|
/** @type {any} */ ({ ...base, id: "mainnet-legacy-orphan", paymentCodes: [], signed: signedBlock });
|
|
await rb();
|
|
const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8"));
|
|
ok(!pub.nodes.some((n) => n.id === "mainnet-legacy-orphan"),
|
|
"a code-less record already in the store is withheld from the published list");
|
|
|
|
delete loaded.submissions["mainnet-legacy-orphan"];
|
|
await rb();
|
|
}
|
|
|
|
// 33) minimum Dojo version, judged on what the node reports live and applied to
|
|
// registration only. The version inside a pairing payload is frozen when
|
|
// that payload was generated, so a current node can honestly declare an
|
|
// ancient one; judging on the declared value would refuse working nodes and
|
|
// admit old ones.
|
|
{
|
|
const dv = await import("./dojo-version.ts");
|
|
|
|
ok(dv.compareVersions("1.27", "1.27.0") === 0
|
|
&& dv.compareVersions("1.29.2", "1.27.0") === 1
|
|
&& dv.compareVersions("1.4.5", "1.27.0") === -1
|
|
&& dv.compareVersions("v1.28.0-rc1", "1.28.0") === 0,
|
|
"versions compare numerically, so 1.4.5 is below 1.27.0 and 1.27 equals 1.27.0");
|
|
|
|
ok(dv.meetsMinimum("1.27.0", "1.27.0") && dv.meetsMinimum("1.31.0", "1.27.0")
|
|
&& !dv.meetsMinimum("1.26.1", "1.27.0") && dv.meetsMinimum("1.0.0", ""),
|
|
"an empty minimum disables the check entirely");
|
|
|
|
// the detected version wins over a stale declared one, in both directions
|
|
const stale = dv.judgeVersion("1.26.1", "1.29.9", "1.27.0");
|
|
const current = dv.judgeVersion("1.29.2", "1.4.5", "1.27.0");
|
|
ok(!stale.ok && stale.source === "detected"
|
|
&& current.ok && current.source === "detected" && current.version === "1.29.2",
|
|
"the live-detected version decides, not the payload's frozen claim");
|
|
|
|
const silent = dv.judgeVersion(null, null, "1.27.0");
|
|
ok(!silent.ok && silent.version === null && /did not report a version/.test(silent.reason || ""),
|
|
"a node reporting no version at all is refused, and told why");
|
|
|
|
// registration is gated; an existing operator updating a listing is not
|
|
const { store: st } = await import("./store.ts");
|
|
const before = await st.getSubmission("mainnet-selftest-node");
|
|
const resubmit = await api("/api/dojo", "POST", {
|
|
network: "mainnet", name: "selftest-node", jurisdiction: "Testland", payload, signed: signedBlock,
|
|
});
|
|
ok(resubmit.status === 200,
|
|
"an operator updating a listing they already hold is not re-judged: " + JSON.stringify(resubmit.body?.error || ""));
|
|
ok((await st.getSubmission("mainnet-selftest-node")).id === before.id,
|
|
"and keeps the same record");
|
|
}
|
|
|
|
// 34) the resource diagnostic must not hand an operator-set path to a
|
|
// subprocess. It used to run `df ... $WEB_ROOT` and `du -sb $PUBLIC_DIR`,
|
|
// which CodeQL flagged as js/shell-command-injection-from-environment and
|
|
// which really does misbehave: df and du read a leading hyphen as an option,
|
|
// so WEB_ROOT=-x measured something other than what was asked for and said
|
|
// nothing about it. statfs() and a walk answer both questions inside Node.
|
|
// Two of the checks below read the source rather than the behaviour, because
|
|
// the point is that the dataflow is gone and not merely escaped; the rest
|
|
// assert on values, which is the better instrument wherever it is available.
|
|
{
|
|
const cr = await import("./check-resources.ts");
|
|
const src = await fsp.readFile(new URL("./check-resources.ts", import.meta.url), "utf8");
|
|
|
|
// every sh() call site takes a literal command and a literal argument list.
|
|
// A variable in either is the regression this exists to catch. One identifier
|
|
// is allowed through: `unit`, which the loop takes from the exported UNITS.
|
|
// Anything else must be a string literal, so reinstating `sh("du", ["-sb", p])`
|
|
// fails here rather than shipping. Widening the allowlist should require an
|
|
// argument about where that value comes from.
|
|
const ALLOWED = new Set(["UNITS", "unit"]);
|
|
const calls = [...src.matchAll(/\bsh\(([^)]*?)\)/gs)].map((m) => m[1]);
|
|
ok(calls.length >= 2, "the diagnostic still shells out for what only a binary can answer");
|
|
const identifiers = calls.flatMap((c) =>
|
|
[...c.replace(/"(?:[^"\\]|\\.)*"/g, "").matchAll(/[A-Za-z_$][\w$.]*/g)].map((m) => m[0]));
|
|
ok(identifiers.every((i) => ALLOWED.has(i)) && !calls.some((c) => c.includes("`")),
|
|
"no sh() call site passes anything but a string literal and a known unit name: "
|
|
+ JSON.stringify(identifiers));
|
|
|
|
// UNITS is checked as a value, not as text. The previous version of this
|
|
// matched the declaration with a regex whose repeated group could match the
|
|
// same input two ways, which CodeQL flagged as js/redos and which really was
|
|
// exponential: 24 quoted tokens with no closing bracket took 356 ms, doubling
|
|
// every two. Nothing untrusted ever reached it, but a test asserting on the
|
|
// spelling of a line was the wrong instrument for the question anyway.
|
|
ok(Array.isArray(cr.UNITS) && cr.UNITS.length >= 2
|
|
&& cr.UNITS.every((u) => typeof u === "string" && u.endsWith(".service") && !u.includes("/")),
|
|
"the units the diagnostic asks systemctl about are a fixed list: " + cr.UNITS.join(", "));
|
|
const decl = src.slice(src.indexOf("export const UNITS"), src.indexOf("];", src.indexOf("export const UNITS")));
|
|
ok(decl.length > 0 && !/process\.env|`|\$\{|\(/.test(decl),
|
|
"and that list is written out in the file, not read from the environment");
|
|
ok(!/sh\(\s*"(?:du|df|sh|bash)"/.test(src),
|
|
"df, du and a shell are gone: nothing spawns a process that parses a path");
|
|
|
|
// dirSize replaces `du -sb`, so it must agree with it, and it must not follow
|
|
// a symlink out of the tree it was asked about.
|
|
const root = pathMod.join(os.tmpdir(), "dojobay-dirsize-" + Date.now());
|
|
await fsp.mkdir(pathMod.join(root, "nested"), { recursive: true });
|
|
await fsp.writeFile(pathMod.join(root, "a.bin"), Buffer.alloc(1000));
|
|
await fsp.writeFile(pathMod.join(root, "nested", "b.bin"), Buffer.alloc(2000));
|
|
const outside = pathMod.join(os.tmpdir(), "dojobay-dirsize-outside-" + Date.now());
|
|
await fsp.writeFile(outside, Buffer.alloc(9_000_000));
|
|
await fsp.symlink(outside, pathMod.join(root, "link.bin"));
|
|
const size = await cr.dirSize(root);
|
|
const linkSize = (await fsp.lstat(pathMod.join(root, "link.bin"))).size;
|
|
ok(size === 3000 + linkSize,
|
|
`dirSize sums a tree and counts a symlink without following it: ${size}`);
|
|
ok(await cr.dirSize(pathMod.join(root, "missing")) === null,
|
|
"a path that is not there reports nothing rather than zero");
|
|
|
|
// the bug itself: a root whose name begins with a hyphen. du read that as an
|
|
// option and reported the wrong tree; nothing in Node cares.
|
|
const cwd = process.cwd();
|
|
process.chdir(os.tmpdir());
|
|
const hyphen = "-dojobay-" + Date.now();
|
|
await fsp.mkdir(hyphen, { recursive: true });
|
|
await fsp.writeFile(pathMod.join(hyphen, "c.bin"), Buffer.alloc(4096));
|
|
ok(await cr.dirSize(hyphen) === 4096,
|
|
"a path beginning with a hyphen is measured, not parsed as an option");
|
|
await fsp.rm(hyphen, { recursive: true, force: true });
|
|
process.chdir(cwd);
|
|
|
|
// diskUsage replaces `df`, and the identity it must keep is df's: available
|
|
// excludes the root reserve, so it is never more than what is unused.
|
|
const du = await cr.diskUsage(os.tmpdir());
|
|
ok(du && du.size > 0 && du.used >= 0 && du.avail >= 0 && du.used + du.avail <= du.size,
|
|
"diskUsage reports a filesystem's size, used and available consistently");
|
|
ok(await cr.diskUsage(pathMod.join(root, "nowhere", "at", "all")) === null,
|
|
"and reports nothing for a path on no filesystem it can see");
|
|
|
|
await fsp.rm(root, { recursive: true, force: true });
|
|
await fsp.rm(outside, { force: true });
|
|
}
|
|
|
|
// 35) a listing without a signed pairing block is structurally impossible, on
|
|
// the same three-point pattern as the payment-code rule in 32: the gates
|
|
// refuse it with something an operator can act on, the store refuses it
|
|
// however the record was assembled, and the rebuild withholds anything that
|
|
// predates the rule rather than publishing it. The signature is the only
|
|
// part of a listing a visitor can check without trusting this site, so a
|
|
// listing without one asks for trust that cannot be earned.
|
|
{
|
|
const { store: st } = await import("./store.ts");
|
|
const { rebuild: rb } = await import("./build-public.ts");
|
|
const base = { network: "mainnet", name: "mute", status: "approved",
|
|
paymentCodes: [paymentCode], payload };
|
|
|
|
let threw = null;
|
|
try { await st.putSubmission(/** @type {any} */ ({ ...base, id: "mainnet-mute" })); }
|
|
catch (e) { threw = e; }
|
|
ok(threw && /must carry a signed pairing block/.test(threw.message),
|
|
"the store refuses a record with no signed block");
|
|
ok(threw && /remove-listing/.test(threw.message),
|
|
"and says what to do about it rather than only that it refused");
|
|
ok((await st.getSubmission("mainnet-mute")) === null, "nothing is written when it refuses");
|
|
|
|
// a shape check, not a verification: whether the block verifies is settled at
|
|
// the gates, which have the session and the canonical message to hand.
|
|
let threw2 = null;
|
|
try { await st.putSubmission(/** @type {any} */ ({ ...base, id: "mainnet-mute2", signed: "I promise it is mine" })); }
|
|
catch (e) { threw2 = e; }
|
|
ok(threw2, "and refuses a signed field that is not a signed-message block at all");
|
|
|
|
// the submit gate refuses first, before the connection gate spends thirty
|
|
// seconds probing a node whose submission cannot be accepted anyway
|
|
const noSig = await api("/api/dojo", "POST", {
|
|
network: "mainnet", name: "gate-mute", jurisdiction: "Testland", payload,
|
|
});
|
|
ok(noSig.status === 400 && /signature gate/.test(noSig.body.error)
|
|
&& /PayNym/.test(noSig.body.error),
|
|
"the submit gate refuses an unsigned submission and says how to sign: "
|
|
+ JSON.stringify(noSig.body?.error || ""));
|
|
ok(!(await st.getSubmission("mainnet-gate-mute")), "and no record is created by the attempt");
|
|
|
|
// a record that predates the rule, injected past the store, is withheld from
|
|
// the published list rather than shown
|
|
const legacy = /** @type {any} */ ({ ...base, id: "mainnet-legacy-mute", name: "legacy-mute" });
|
|
const loaded = await st.get();
|
|
loaded.submissions["mainnet-legacy-mute"] = legacy;
|
|
await rb();
|
|
const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8"));
|
|
ok(!pub.nodes.some((n) => n.id === "mainnet-legacy-mute"),
|
|
"an unsigned record already in the store is withheld from the published list");
|
|
|
|
// and the audit calls it a failure now, rather than leaving it as a decision
|
|
const { auditRecord } = await import("./audit-signed.mjs");
|
|
ok(auditRecord(legacy).bucket === "UNSIGNED",
|
|
"the auditor still buckets it as UNSIGNED, which now exits non-zero");
|
|
|
|
delete loaded.submissions["mainnet-legacy-mute"];
|
|
await rb();
|
|
}
|
|
|
|
// 34a) signing in discards the cached update check. The cache lives in the
|
|
// process, not the session, so signing out and back in did not clear it
|
|
// and only a service restart would: an operator who had just pushed was
|
|
// told they were up to date with no way to say otherwise.
|
|
{
|
|
const idx = await fsp.readFile(new URL("./index.ts", import.meta.url), "utf8");
|
|
const cb = idx.slice(idx.indexOf('/api\\/auth47\\/callback'), idx.indexOf("/api\\/auth47\\/poll"));
|
|
ok(/UPDATES_CACHE = null/.test(cb),
|
|
"the Auth47 callback clears it, which is the moment somebody is about to look");
|
|
// and the declaration precedes the use, so reordering the file cannot turn
|
|
// this into a dead-zone error at runtime
|
|
ok(idx.indexOf("let UPDATES_CACHE = null;") < idx.indexOf("UPDATES_CACHE = null;",
|
|
idx.indexOf("let UPDATES_CACHE = null;") + 10),
|
|
"and is declared above the code that clears it");
|
|
// The TTL itself lives in updateCacheDecision's default, with the floor, and
|
|
// its behaviour is asserted in section 17 rather than by matching a literal.
|
|
// What matters here is that the route does not carry a second copy: two
|
|
// definitions of a cache window disagree eventually, and the disagreement is
|
|
// invisible until somebody wonders why a check is older than it should be.
|
|
const upd = await fsp.readFile(new URL("./updates.mjs", import.meta.url), "utf8");
|
|
ok(/ttlMs = 6 \* 3600 \* 1000/.test(upd) && !/6 \* 3600 \* 1000/.test(idx),
|
|
"the unattended TTL is defined once, in updates.mjs: six hours is right for a background check over Tor");
|
|
ok(/updateCacheDecision\(/.test(idx),
|
|
"and the route asks for the decision rather than reimplementing the window");
|
|
}
|
|
|
|
// 34b) the flag on a card is inferred from the one free-text answer an operator
|
|
// gives about where they are, and nothing is enforced. There used to be a
|
|
// separate code field, sliced to two characters and upper-cased, which
|
|
// rejected nothing: "FIN" silently became "FI" and was published as
|
|
// whichever country those letters name, while a single letter or half a
|
|
// pasted flag emoji were stored as given and rendered as letterboxes.
|
|
{
|
|
const { countryFor } = await import("./dojo-version.ts");
|
|
ok(countryFor("Finland") === "FI" && countryFor("Helsinki, Finland") === "FI",
|
|
"a country is recognised, in a sentence or on its own");
|
|
ok(countryFor("UK") === "GB", "including the one that is not a code");
|
|
ok(countryFor("Europe") === null && countryFor("Ancapistan") === null,
|
|
"and a generic or invented answer is allowed, with no flag and no complaint");
|
|
ok(countryFor("XX") === null, "an unassigned pair is not a flag");
|
|
|
|
const idx = await fsp.readFile(new URL("./index.ts", import.meta.url), "utf8");
|
|
ok(/country: countryFor\(body\.jurisdiction\)/.test(idx),
|
|
"the gate infers from the location answer rather than asking separately");
|
|
ok(!/slice\(0, 2\)\.toUpperCase\(\)/.test(idx), "the old truncation is gone");
|
|
|
|
await api("/api/dojo", "POST", { network: "mainnet", name: "cc-node",
|
|
jurisdiction: "Ancapistan", payload, signed: signedBlock });
|
|
const { store: st34b } = await import("./store.ts");
|
|
const rec = await st34b.getSubmission("mainnet-cc-node");
|
|
ok(!rec || rec.country === null,
|
|
"a location that names nowhere stores no code: " + JSON.stringify(rec && rec.country));
|
|
ok(!rec || rec.jurisdiction === "Ancapistan",
|
|
"while the answer itself is kept exactly as written");
|
|
}
|
|
|
|
// 35a) a listing's endpoint must be for the network it claims. A Dojo serves
|
|
// testnet under a `test` path segment and mainnet without one, so the two
|
|
// are checkable against each other, and a crossed pair is wrong in a way
|
|
// nothing downstream catches: it answers, reports a height, and probes
|
|
// green forever. The installer applies the same rule from the same
|
|
// function; this is the other door into the same store.
|
|
{
|
|
const { pairingNetwork } = await import("./dojo-version.ts");
|
|
const onion = "b3krcphqdbrzkblvti2eiuogfrx6b5lynenv5dxjwsw7hq47dlrc4pid";
|
|
ok(pairingNetwork(`http://${onion}.onion/test/v2`) === "testnet", "a /test/ segment reads as testnet");
|
|
ok(pairingNetwork(`http://${onion}.onion/v2`) === "mainnet", "and its absence as mainnet");
|
|
// a whole segment, never a substring: an onion address is base32 and can carry
|
|
// those four letters by chance, and /v2/testing is not a testnet endpoint
|
|
ok(pairingNetwork(`http://test${onion.slice(4)}.onion/v2`) === "mainnet",
|
|
"letters inside the onion address are not a path segment");
|
|
ok(pairingNetwork(`http://${onion}.onion/v2/testing`) === "mainnet",
|
|
"nor is a segment that merely begins with them");
|
|
|
|
const testnetPayload = { pairing: { type: "dojo.api", version: "1.29.2", apikey: "k",
|
|
url: `http://${onion}.onion/test/v2` } };
|
|
const crossed = await api("/api/dojo", "POST",
|
|
{ network: "mainnet", name: "crossed-node", payload: testnetPayload, signed: signedBlock });
|
|
ok(crossed.status === 400 && /testnet endpoint/.test(crossed.body.error || ""),
|
|
"a testnet endpoint submitted as mainnet is refused: " + JSON.stringify(crossed.body.error));
|
|
|
|
const otherWay = await api("/api/dojo", "POST",
|
|
{ network: "testnet", name: "crossed-node", payload, signed: signedBlock });
|
|
ok(otherWay.status === 400 && /mainnet endpoint/.test(otherWay.body.error || ""),
|
|
"and a mainnet endpoint submitted as testnet: " + JSON.stringify(otherWay.body.error));
|
|
ok(/test\/v2/.test(otherWay.body.error || ""),
|
|
"with the shape the operator should be looking for, not just a refusal");
|
|
|
|
const { store: st35a } = await import("./store.ts");
|
|
ok(!(await st35a.getSubmission("mainnet-crossed-node"))
|
|
&& !(await st35a.getSubmission("testnet-crossed-node")),
|
|
"and neither attempt leaves a record behind");
|
|
}
|
|
|
|
// 35b) the archive download must ask for a media type the endpoint will serve.
|
|
// It asked for application/octet-stream, and GitHub's archive route
|
|
// answers 415 to that, so self-update failed on its first request and was
|
|
// never once seen to work. Checked against the live endpoint while fixing
|
|
// it: octet-stream 415, application/vnd.github+json 302, */* 302, and the
|
|
// 302 goes to codeload, which this transport already follows.
|
|
{
|
|
const { githubRequestHead } = await import("./updates.mjs");
|
|
const head = (opts) => githubRequestHead("/repos/Dojobay/dojobay/zipball/abc", "api.github.com", opts);
|
|
|
|
const accept = (h) => (h.match(/\r\nAccept:\s*([^\r\n]+)/) || [])[1];
|
|
ok(accept(head({ binary: true })) === "*/*",
|
|
"a download asks for anything the route serves: " + JSON.stringify(accept(head({ binary: true }))));
|
|
ok(!/octet-stream/.test(head({ binary: true })),
|
|
"and specifically not octet-stream, which this endpoint refuses outright");
|
|
ok(accept(head({})) === "application/vnd.github+json",
|
|
"while metadata calls keep the type that pins the API version");
|
|
|
|
// the rest of the request has to stay a well-formed HTTP/1.1 head, since the
|
|
// reply parser depends on Connection: close and on identity encoding.
|
|
const h = head({ binary: true });
|
|
ok(/^GET \/repos\/Dojobay\/dojobay\/zipball\/abc HTTP\/1\.1\r\n/.test(h), "request line intact");
|
|
ok(/\r\nHost: api\.github\.com\r\n/.test(h), "Host is the hop's host, not a constant");
|
|
ok(/\r\nAccept-Encoding: identity\r\n/.test(h) && /\r\nConnection: close\r\n\r\n$/.test(h),
|
|
"identity encoding and Connection: close, which the reply parser relies on");
|
|
}
|
|
|
|
// 36) the published file is produced by an allowlist, and only by that
|
|
// allowlist. The store holds moderation status, the owning payment codes,
|
|
// submission timestamps, the probe result recorded at submission and import
|
|
// provenance, none of which belong to the public. A redaction list would be
|
|
// wrong by default and would need updating every time the store grew a
|
|
// field; naming the output instead is right by default. These checks are
|
|
// what stop that property being lost quietly.
|
|
{
|
|
const { store: st } = await import("./store.ts");
|
|
const { rebuild: rb, PUBLIC_NODE_KEYS } = await import("./build-public.ts");
|
|
const loaded = await st.get();
|
|
|
|
// A record carrying every field the store type knows about, plus four it does
|
|
// not. The extras are the point: store.json is JSON, so the TypeScript
|
|
// interface constrains what we WRITE and not what is there, and a field added
|
|
// by a future endpoint, a migration or a hand edit is exactly the case an
|
|
// allowlist has to survive.
|
|
loaded.submissions["mainnet-allowlist"] = /** @type {any} */ ({
|
|
id: "mainnet-allowlist", network: "mainnet", name: "allowlist", status: "approved",
|
|
paymentCodes: [paymentCode, "PMsecondCodeNobodyShouldSee"], payload, signed: signedBlock,
|
|
jurisdiction: "Testland", country: "TL", hardware: "a box", paynym: "+al", name_url: null,
|
|
last_probe: { up: true, reason: "http", ms: 5 },
|
|
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-02-02T00:00:00Z",
|
|
source: "bootstrap-import:some.onion",
|
|
// not in StoreRecord at all
|
|
moderator_note: "operator was rude in DMs",
|
|
session_hint: "sid-should-never-be-published",
|
|
internal_score: 0.42,
|
|
admin_only: { reviewer: "max" },
|
|
});
|
|
await rb();
|
|
const pub = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8"));
|
|
const node = pub.nodes.find((n) => n.id === "mainnet-allowlist");
|
|
ok(node, "the record is published at all, so the rest of this is meaningful");
|
|
|
|
// The exact key set. Not a subset check: a field appearing in the output
|
|
// without being named here must fail, which is the whole request.
|
|
const keys = Object.keys(node).sort();
|
|
ok(JSON.stringify(keys) === JSON.stringify([...PUBLIC_NODE_KEYS].sort()),
|
|
"the published node's keys are exactly the allowlist. Unexpected: "
|
|
+ JSON.stringify(keys.filter((k) => !PUBLIC_NODE_KEYS.includes(k)))
|
|
+ " missing: " + JSON.stringify(PUBLIC_NODE_KEYS.filter((k) => !keys.includes(k))));
|
|
|
|
// And named, so a failure says which secret escaped rather than only that the
|
|
// shape changed.
|
|
for (const k of ["moderator_note", "session_hint", "internal_score", "admin_only",
|
|
"last_probe", "created_at", "updated_at", "source", "paymentCodes"]) {
|
|
ok(!(k in node), `${k} is not published`);
|
|
}
|
|
// The moderation status is published as a FIELD, but never with a moderation
|
|
// VALUE: the public status is a liveness state written by the probe, and the
|
|
// store's pending/approved/rejected must not reach it.
|
|
ok(!["pending", "approved", "rejected"].includes(node.status),
|
|
"the public status is a liveness state, not a moderation state: " + node.status);
|
|
// Ownership is published as one display code, never the full set.
|
|
ok(typeof node.paymentCode === "string" && !JSON.stringify(pub).includes("PMsecondCodeNobodyShouldSee"),
|
|
"only the display payment code is published, not every code the owner holds");
|
|
|
|
// The seed anchor is held to the same allowlist. It used to be published as
|
|
// it sits in seed.json, so the file had two producers and one filter.
|
|
const seedPath = process.env.PUBLIC_DATA_DIR + "/seed.json";
|
|
const seedDoc = JSON.parse(await fsp.readFile(seedPath, "utf8"));
|
|
seedDoc.nodes[0].operator_private_note = "not for publication";
|
|
await fsp.writeFile(seedPath, JSON.stringify(seedDoc, null, 2) + "\n");
|
|
await rb();
|
|
const pub2 = JSON.parse(await fsp.readFile(process.env.PUBLIC_DATA_DIR + "/dojos.json", "utf8"));
|
|
const anchor = pub2.nodes.find((n) => n.id === seedDoc.nodes[0].id);
|
|
ok(anchor, "the seed anchor is published");
|
|
ok(!("operator_private_note" in anchor),
|
|
"a field added to seed.json is not published just because it is in seed.json");
|
|
ok(JSON.stringify(Object.keys(anchor).sort()) === JSON.stringify([...PUBLIC_NODE_KEYS].sort()),
|
|
"and the anchor's keys are the same allowlist as any other node: "
|
|
+ JSON.stringify(Object.keys(anchor).sort()));
|
|
|
|
delete loaded.submissions["mainnet-allowlist"];
|
|
delete seedDoc.nodes[0].operator_private_note;
|
|
await fsp.writeFile(seedPath, JSON.stringify(seedDoc, null, 2) + "\n");
|
|
await rb();
|
|
}
|
|
|
|
// 37) one definition of the canonical pairing string. crypto.ts owns it, and
|
|
// every other file under server/ must import it rather than write the
|
|
// expression again. This has already gone wrong twice: the string lived in
|
|
// the submission gate and in audit-signed.mjs, the second carrying a comment
|
|
// warning it MUST mirror the first, and after that consolidation two more
|
|
// copies appeared in apply-signed-payload.ts and diagnose-signed.mjs. A
|
|
// canonical message with several definitions eventually disagrees with
|
|
// itself, and the disagreement is silent in the worst direction: a signature
|
|
// accepted at submission and reported invalid by a later audit.
|
|
//
|
|
// Scoped to non-test source under server/. The expression is deliberately
|
|
// reimplemented twice in this file, so a test does not check the code
|
|
// against itself, and assets/js/app.js writes its own because it cannot
|
|
// import server code and is building display text, not a message to verify.
|
|
{
|
|
const dir = new URL("./", import.meta.url);
|
|
const sources = (await fsp.readdir(dir))
|
|
.filter((f) => /\.(ts|mjs)$/.test(f) && f !== "selftest.mjs")
|
|
.sort();
|
|
ok(sources.includes("crypto.ts") && sources.length >= 10,
|
|
`the scan sees the server source: ${sources.length} files`);
|
|
|
|
// JSON.stringify over an object literal naming both keys, whatever the
|
|
// payload is called and whichever order they are in. Matching on the shape
|
|
// rather than an exact string is the point: a copy that renamed its argument
|
|
// is still a copy.
|
|
const stringifyLiterals = (src) =>
|
|
[...src.matchAll(/JSON\.stringify\(\s*\{([^{}]*)\}/g)]
|
|
.map((m) => m[1])
|
|
.filter((body) => /\bpairing\s*:/.test(body) && /\bexplorer\s*:/.test(body));
|
|
|
|
// Comment lines are dropped before any of this runs. The scan is about
|
|
// bindings and uses in code, and a file is entitled to discuss the canonical
|
|
// string in prose without being told to import it: build-public.ts explains
|
|
// in a comment that the signature does not cover the declared indexer, names
|
|
// canonicalPairing while doing so, and imports nothing. Only whole-line
|
|
// comments are removed, so a trailing comment can still hide a use from the
|
|
// scan; that direction is a missed alarm rather than a false one.
|
|
const codeOnly = (src) => {
|
|
const out = [];
|
|
let inBlock = false;
|
|
for (const line of src.split("\n")) {
|
|
const t = line.trim();
|
|
if (inBlock) { if (t.includes("*/")) inBlock = false; continue; }
|
|
if (t.startsWith("/*")) { if (!t.includes("*/")) inBlock = true; continue; }
|
|
if (t.startsWith("//") || t.startsWith("*")) continue;
|
|
out.push(line);
|
|
}
|
|
return out.join("\n");
|
|
};
|
|
|
|
const defines = [], redeclares = [], missingImport = [];
|
|
for (const f of sources) {
|
|
const src = codeOnly(await fsp.readFile(new URL(f, dir), "utf8"));
|
|
if (stringifyLiterals(src).length) defines.push(f);
|
|
if (!/\bcanonicalPairing\b/.test(src)) continue;
|
|
// A local binding of that name shadows the shared one and defeats the check
|
|
// above the moment it is written any other way.
|
|
if (f !== "crypto.ts" && /(?:const|let|var|function)\s+canonicalPairing\b/.test(src)) redeclares.push(f);
|
|
if (f !== "crypto.ts" && !/import\s*\{[^}]*\bcanonicalPairing\b[^}]*\}\s*from\s*["']\.\/crypto\.ts["']/.test(src)) missingImport.push(f);
|
|
}
|
|
|
|
ok(defines.length === 1 && defines[0] === "crypto.ts",
|
|
`the canonical pairing expression is written once, in crypto.ts (found in: ${defines.join(", ") || "nothing"})`);
|
|
ok(!redeclares.length,
|
|
`no file redeclares canonicalPairing locally (offenders: ${redeclares.join(", ") || "none"})`);
|
|
ok(!missingImport.length,
|
|
`every user of canonicalPairing imports it from crypto.ts (offenders: ${missingImport.join(", ") || "none"})`);
|
|
|
|
// And the shared definition is the one the gate actually verifies against, so
|
|
// the files above are not merely agreeing with each other about the wrong text.
|
|
const { canonicalPairing: cp } = await import("./crypto.ts");
|
|
ok(cp(payload) === JSON.stringify({ pairing: payload.pairing, explorer: payload.explorer }),
|
|
"crypto.ts's canonicalPairing produces the text this suite signs");
|
|
}
|
|
|
|
await fsp.rm(process.env.PUBLIC_DATA_DIR, { recursive: true, force: true });
|
|
|
|
console.log(`\nall ${passed} checks passed`);
|
|
proxy.close();
|
|
process.exit(0);
|