#!/usr/bin/env node // The Dojo Bay — self-service submission backend (step 2 feature). // // Auth47 login, then a gated "manage my Dojo" API. Two hard gates on any create // or pairing-changing edit: // 1. connection gate: the pairing code's .onion must currently answer over Tor // 2. signature gate: a signed pairing payload is required, and must verify // against the notification address of the authenticated payment code (lab // logic). The store refuses an unsigned record too, so this gate is where // an operator is told what to do, not the only thing standing in the way. // Passing both puts the record in a moderation queue; a maintainer approves it // (see admin.mjs) before build-public.mjs merges it into the public dojos.json. // // Runs behind nginx on 127.0.0.1. No passwords, no external database. import http from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { StoreRecord } from "../types.js"; /** A route handler. Registered against a method and a path pattern. */ type Handler = (req: IncomingMessage, res: ServerResponse) => unknown | Promise; import { randomBytes } from "node:crypto"; import { store } from "./store.ts"; import { makeAuth47, notificationAddresses, verifySignedPayload, repairSignedBlock, canonicalPairing, claimText, verifySignedUrlClaim, verifyOperatorDoc, } from "./crypto.ts"; import osMod from "node:os"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileP = promisify(execFile); import { probe, PROBE_CFG } from "./probe.mjs"; import { checkUpdates, updateCacheDecision } from "./updates.mjs"; import { judgeVersion, MIN_DOJO_VERSION, pairingNetwork, countryFor } from "./dojo-version.ts"; import { normaliseDomain, txtName, txtHost, txtValue, signingText, verifyClaim, recheckClaim, applyRecheck, isDue, urlOnDomain, GRACE_DAYS, } from "./domains.ts"; import { resolvePayNym } from "./paynym.mjs"; import { rebuild } from "./build-public.ts"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const PORT = +(process.env.PORT || 8787); // The public origin of the site (its .onion). Containerized under // Archipelago, the onion is assigned by the platform's Tor daemon AFTER this // process has already started (auto-provisioned for the app's gated port), // so it cannot be baked in at build or container-start time. BASE_URL stays // available as an explicit override (e.g. once a clearnet domain is // verified); absent that, originOf() below derives the current origin from // whatever host the request actually arrived on. const CONFIGURED_BASE_URL = process.env.BASE_URL || null; function originOf(req: IncomingMessage): string { if (CONFIGURED_BASE_URL) return CONFIGURED_BASE_URL; const host = String(req.headers["x-forwarded-host"] || req.headers.host || "localhost"); return `http://${host}`; } const NONCE_TTL = 5 * 60 * 1000; // Auth47 nonces valid 5 minutes const SESSION_TTL = 12 * 60 * 60 * 1000; // BIP47 payment codes permitted to moderate at /admin. Either is enough: an // explicit ADMIN_PAYMENT_CODES env var (a fork's operator sets their own), or // the payment code recorded in a verified data/operator.json — completing the // "claim this instance" first-run step (see /api/setup/claim below) makes its // signer the admin immediately, no restart and no env var to hand-set. const ADMIN_CODES = (process.env.ADMIN_PAYMENT_CODES || "") .split(",").map((s) => s.trim()).filter(Boolean); async function operatorPaymentCode(): Promise { try { const dir = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); const doc = JSON.parse(await readFile(path.join(dir, "operator.json"), "utf8")); return verifyOperatorDoc(doc).ok ? doc.paymentCode : null; } catch { return null; } } async function isAdmin(pc: string | null | undefined): Promise { if (!pc) return false; if (ADMIN_CODES.includes(pc)) return true; return pc === (await operatorPaymentCode()); } const SERVER_DATA = process.env.SERVER_DATA_DIR || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data"); // The published view of a node, for the admin panel. A pending submission is // probed separately into pending-probe.json, but once it is APPROVED the // updater stops writing there and its live status, chain tip and 24-hour checks // live in the published files instead. Reading only the pending file therefore // left every approved listing saying "not yet probed" with a reliability strip // frozen at whatever it had when it was approved. async function publishedView() { const dir = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); const read = async (name, fallback) => { try { return JSON.parse(await readFile(path.join(dir, name), "utf8")); } catch { return fallback; } }; const [dojos, hist] = await Promise.all([ read("dojos.json", { nodes: [] }), read("history.json", { nodes: {} }), ]); const byId = new Map(); for (const n of dojos.nodes || []) { byId.set(n.id, { status: n.status || null, checked_at: n.checked_at || null, block_height: n.block_height ?? null, detected_version: n.detected_version || null, checks: (hist.nodes?.[n.id]?.checks) || [], }); } return byId; } async function pendingProbe() { try { return JSON.parse(await readFile(path.join(SERVER_DATA, "pending-probe.json"), "utf8")); } catch { return { nodes: {} }; } } // ---- helpers --------------------------------------------------------------- const json = (res: ServerResponse, code: number, obj: unknown) => { const body = JSON.stringify(obj); res.writeHead(code, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(body); }; const readBody = (req: IncomingMessage, limit = 64 * 1024) => new Promise((resolve, reject) => { let data = ""; let size = 0; req.on("data", (c: Buffer | string) => { size += c.length; if (size > limit) { reject(new Error("body too large")); req.destroy(); } else data += c; }); req.on("end", () => resolve(data)); req.on("error", reject); }); function parseCookies(req: IncomingMessage): Record { const out: Record = {}; const h = req.headers.cookie || ""; h.split(";").forEach((p) => { const i = p.indexOf("="); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); return out; } async function sessionFrom(req) { const sid = parseCookies(req).dojobay_sid; return sid ? await store.getSession(sid) : null; } function networkOf(rec) { return rec === "testnet" ? "testnet" : "bitcoin"; } // Ownership: a record is owned by whoever holds ANY of its payment codes, // because a PayNym commonly has two BIP47 codes (segwit + legacy) and the // wallet may sign Auth47 with either variant. const owns = (rec, pc) => !!rec && Array.isArray(rec.paymentCodes) && rec.paymentCodes.includes(pc); // Node names: operator-chosen, unique per network. The slug both keys the // record (`${network}-${slug}`) and enforces case/punctuation-insensitive // uniqueness of the display name. // Signed pairing blocks arrive by clipboard, which is where stray bytes creep // in: CRLF line endings, zero-width characters, non-breaking spaces. Wallets // emit LF-only ASCII, so stripping these BEFORE signature verification keeps a // mangled paste verifiable while never altering what the wallet actually // signed. Applied at intake only; stored and emitted bytes are then clean. const cleanSigned = (v) => { const t = String(v || "").replace(/\r/g, "").replace(/[\u200b\u200c\u200d\ufeff]/g, "").replace(/\u00a0/g, " ").trim(); return t || null; }; const slugify = (name) => String(name || "") .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40); // Curated seed nodes are not in the store but still occupy the same public // namespace, so a submission may not take a seed node's name or id. const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); async function seedNodes() { const p = path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "seed.json"); try { return JSON.parse(await readFile(p, "utf8")).nodes || []; } catch { return []; } } // Is `slug` free on `network` for the holder of `pc`? Returns null when free // or when it names a record the caller already owns (an update), otherwise a // human-readable reason. Checks every store record regardless of status plus // the curated seed, so a rejected or pending record cannot be hijacked either. async function nameConflict(network, slug, pc) { for (const r of await store.listSubmissions()) { if (r.network !== network) continue; if (slugify(r.name) !== slug && r.id !== `${network}-${slug}`) continue; if (!owns(r, pc)) return `the name is already used by another operator's ${r.status} record`; } for (const n of await seedNodes()) { if (n.network !== network) continue; if (slugify(n.name) === slug || n.id === `${network}-${slug}`) return "the name is reserved by a curated seed node"; } return null; } // The record an owner's (network, slug) submission should update, if any. async function ownedRecordFor(network, slug, pc) { for (const r of await store.listSubmissions()) { if (r.network === network && owns(r, pc) && (slugify(r.name) === slug || r.id === `${network}-${slug}`)) return r; } return null; } // Manage-panel ordering: mainnet before testnet, then alphabetical by name. const submissionOrder = (a, b) => a.network !== b.network ? (a.network === "mainnet" ? -1 : 1) : String(a.name || a.id).localeCompare(String(b.name || b.id), "en", { sensitivity: "base" }); const isPlainOnionUrl = (u) => { try { const x = new URL(u); return x.protocol === "http:" && /\.onion$/.test(x.hostname); } catch { return false; } }; function validatePayload(payload, network = null) { if (!payload || typeof payload !== "object") return "missing pairing payload"; const p = payload.pairing; if (!p || p.type !== "dojo.api" || !p.url) return "pairing.type must be dojo.api with a url"; if (!isPlainOnionUrl(p.url)) return "pairing.url must be an http .onion address"; if (payload.explorer && !isPlainOnionUrl(payload.explorer.url)) return "explorer.url must be an http .onion address"; // The endpoint must be for the network the listing claims. See pairingNetwork // for why a crossed pair is worth refusing: it probes green forever and the // only symptom is a block height nobody reads as an error. Network omitted, // the URL is not judged, which keeps this usable where the network is not yet // known. if (network) { const looks = pairingNetwork(p.url); if (looks && looks !== network) { return network === "testnet" ? "this is a mainnet endpoint: a testnet Dojo serves http:///test/v2. " + "Either paste your testnet pairing payload or list it as mainnet." : "this is a testnet endpoint: a mainnet Dojo serves http:///v2. " + "Either paste your mainnet pairing payload or list it as testnet."; } } return null; } // ---- routes ---------------------------------------------------------------- const routes: { method: string; re: RegExp; fn: Handler }[] = []; const route = (method: string, re: RegExp, fn: Handler) => routes.push({ method, re, fn }); // 1) begin login: mint nonce + challenge URI (QR-encoded client-side) route("POST", /^\/api\/auth47\/challenge$/, async (req, res) => { await store.gcNonces(); const nonce = randomBytes(16).toString("hex"); // 32 alphanumeric chars const expires = Date.now() + NONCE_TTL; const base = originOf(req); // Built fresh per request (not a shared module-level instance): the // Auth47Verifier bakes its callback URL in at construction time, and that // URL has to match whatever origin the wallet is actually going to reach // this instance at right now. See originOf() above. const auth47 = makeAuth47(base); const uri = auth47.challengeURI(nonce, Math.floor(expires / 1000), base); await store.putNonce(nonce, { expires, used: false, sid: null }); json(res, 200, { nonce, uri, expires }); }); // 2) wallet callback: verify proof, bind nonce -> payment code // The update check's answer, cached in the process. Declared here rather than // beside the route that fills it, because the login below clears it and a const // used before its declaration is a trap waiting for someone to reorder a file. let UPDATES_CACHE = null; // When a forced check last actually went out, and the shortest gap between two // of them. Declared beside the cache they qualify, and above their first use: // a const read by code that runs before its declaration has failed outright // here before. let FORCED_UPDATE_AT = 0; const FORCED_UPDATE_FLOOR = 60 * 1000; route("POST", /^\/api\/auth47\/callback$/, async (req, res) => { let proof; try { proof = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } // originOf(req) is what challengeURI put in the r parameter (a wallet always // calls its callback back on the same host it read the challenge from), so // it is what the proof must name. See makeAuth47.verify for what this stops. const base = originOf(req); const auth47 = makeAuth47(base); const v = auth47.verify(proof, { expectedResource: base }); if (!v.ok) return json(res, 401, { error: v.error }); // tie proof back to a live nonce (prevents replay to a different session) let nonce = null; try { nonce = new URL(proof.challenge).hostname; } catch {} const rec = nonce ? await store.takeNonce(nonce) : null; if (!rec) return json(res, 401, { error: "unknown or expired nonce" }); if (rec.expires < Date.now()) return json(res, 401, { error: "challenge expired" }); const sid = await store.putSession({ paymentCode: v.paymentCode, expires: Date.now() + SESSION_TTL }); // Signing in is the moment somebody is about to look at the console, so it is // the moment to stop answering from a six-hour-old cache. Without this an // operator who pushed a commit twenty minutes ago is told they are up to date // and has no way to say otherwise: signing out and back in did not help, // because the cache lives in the process rather than the session, and only a // service restart cleared it. Discarding it here costs one request over Tor // per login and removes the whole confusion. UPDATES_CACHE = null; // stash the sid against the nonce value so the browser poll can pick it up await store.putNonce("claimed:" + nonce, { expires: Date.now() + NONCE_TTL, sid }); json(res, 200, { ok: true }); }); // 3) browser poll: has my nonce been claimed? if so, set the session cookie route("GET", /^\/api\/auth47\/poll$/, async (req, res) => { const u = new URL(req.url, "http://x"); const nonce = u.searchParams.get("nonce") || ""; const claim = await store.takeNonce("claimed:" + nonce); if (!claim) return json(res, 200, { authenticated: false }); res.setHeader("Set-Cookie", `dojobay_sid=${claim.sid}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(SESSION_TTL / 1000)}`); json(res, 200, { authenticated: true }); }); // 4) who am I route("GET", /^\/api\/me$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 200, { authenticated: false }); const mine = (await store.submissionsFor(s.paymentCode)).sort(submissionOrder); json(res, 200, { authenticated: true, paymentCode: s.paymentCode, admin: await isAdmin(s.paymentCode), submissions: mine }); }); // ---- first-run: claim this instance ----------------------------------------- // Containerized under Archipelago, this instance starts with no data/operator.json // (see docker/dojobay/data-template) — deliberately: there is no install-time // wizard to have written one, unlike the upstream CLI installer. Whoever signs // in with Auth47 first and completes this claim becomes the operator (first // claim wins, refused once one exists), the same "first admin" shape most // self-hosted apps use for their initial setup. No new cryptography: claimText // and verifySignedUrlClaim/verifyOperatorDoc are the exact primitives the // upstream README already has an operator sign and verify by hand. route("GET", /^\/api\/instance$/, async (req, res) => { const seeds = await seedNodes(); json(res, 200, { onion: originOf(req), operatorConfigured: !!(await operatorPaymentCode()), hasAnchor: seeds.length > 0, }); }); route("GET", /^\/api\/setup\/claim-text$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); if (await operatorPaymentCode()) return json(res, 409, { error: "this instance is already claimed" }); const onion = originOf(req); json(res, 200, { onion, text: claimText(onion, s.paymentCode) }); }); route("POST", /^\/api\/setup\/claim$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); if (await operatorPaymentCode()) return json(res, 409, { error: "this instance is already claimed" }); let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } let signed = cleanSigned(body.signed); if (!signed) { return json(res, 400, { error: "paste the signed block. Sign the exact text shown, under PayNym → Sign message." }); } const repaired = repairSignedBlock(signed); if (repaired) signed = repaired.block; const onion = originOf(req); const claim = verifySignedUrlClaim({ signed, expectedUrl: onion, paymentCode: s.paymentCode }); if (!claim.ok) return json(res, 400, { error: claim.error }); const doc = { onion, paymentCode: s.paymentCode, verifySigned: signed }; // Belt and braces: write only a document that the same check every other // instance (bootstrap import, self-update's peer trust check, every rebuild) // will itself accept as this instance's operator binding. const recheck = verifyOperatorDoc(doc, { expectedOnion: onion }); if (!recheck.ok) return json(res, 500, { error: "internal: claim verified but the assembled operator.json did not (" + recheck.error + ")" }); const dir = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); await writeFile(path.join(dir, "operator.json"), JSON.stringify(doc, null, 2) + "\n"); json(res, 200, { ok: true, onion, paymentCode: s.paymentCode }); }); // ---- admin (moderation) ---------------------------------------------------- // All require an authenticated session whose payment code is in ADMIN_CODES. async function adminFrom(req, res) { const s = await sessionFrom(req); if (!s) { json(res, 401, { error: "not authenticated" }); return null; } if (!(await isAdmin(s.paymentCode))) { json(res, 403, { error: "not authorised" }); return null; } return s; } // list submissions with their pending-probe status + reliability history route("GET", /^\/api\/admin\/submissions$/, async (req, res) => { if (!(await adminFrom(req, res))) return; const probes = (await pendingProbe()).nodes || {}; const live = await publishedView(); const subs = (await store.listSubmissions()).map((s) => ({ id: s.id, network: s.network, status: s.status, name: s.name || null, paynym: s.paynym || null, paymentCodes: s.paymentCodes, jurisdiction: s.jurisdiction || null, country: s.country || null, hardware: s.hardware || null, signed: !!s.signed, version: (live.get(s.id)?.detected_version) || (probes[s.id] && probes[s.id].detected_version) || s.payload?.pairing?.version || null, pairingUrl: s.payload?.pairing?.url || null, created_at: s.created_at || null, updated_at: s.updated_at || null, // Prefer the published view: it is what the card shows and it keeps being // updated. Fall back to the pending probe for a record not yet approved. probe: live.get(s.id) || probes[s.id] || null, // { status, checked_at, block_height, checks[] } probe_source: live.has(s.id) ? "published" : (probes[s.id] ? "pending" : null), })); json(res, 200, { admin: true, submissions: subs }); }); // The store change (approve/reject/remove) is committed before the public list // is rebuilt, so a rebuild failure must be REPORTED, not thrown as a 500 that // hides which half happened: the moderation applied but publication did not. // The updater re-runs the rebuild at the start of every 10-minute cycle, so a // failed publish heals itself; the error here tells the admin why it deferred. async function tryRebuild() { try { return await rebuild(); } catch (e) { return { error: e.message, msg: "rebuild failed: " + e.message + " (the updater retries every 10 minutes)" }; } } route("POST", /^\/api\/admin\/approve$/, async (req, res) => { if (!(await adminFrom(req, res))) return; let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } const rec = await store.getSubmission(body.id); if (!rec) return json(res, 404, { error: "not found" }); rec.status = "approved"; rec.updated_at = new Date().toISOString(); if (body.paynym) rec.paynym = body.paynym.startsWith("+") ? body.paynym : "+" + body.paynym; else if (!rec.paynym) { const r = await resolvePayNym(rec.paymentCodes[0]).catch(() => null); if (r) rec.paynym = r; } // Mirror the operator's PayNym avatar now rather than waiting a cycle; the // updater retries missing ones every ten minutes, so failure here is fine. import("../scripts/update.mjs").then(({ fetchAvatar }) => Promise.all( rec.paymentCodes.map((c) => fetchAvatar(c, { proxyHost: PROBE_CFG.proxyHost, proxyPort: PROBE_CFG.proxyPort, destDir: path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "avatars"), }).catch(() => {})) )).catch(() => {}); await store.putSubmission(rec); const out = await tryRebuild(); json(res, 200, { ok: true, submission: rec, rebuild: out }); }); route("POST", /^\/api\/admin\/reject$/, async (req, res) => { if (!(await adminFrom(req, res))) return; let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } const rec = await store.getSubmission(body.id); if (!rec) return json(res, 404, { error: "not found" }); rec.status = "rejected"; rec.updated_at = new Date().toISOString(); await store.putSubmission(rec); const out = await tryRebuild(); // drops it from the public list if it was approved json(res, 200, { ok: true, rebuild: out }); }); route("POST", /^\/api\/admin\/remove$/, async (req, res) => { if (!(await adminFrom(req, res))) return; let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } await store.deleteSubmission(body.id); const out = await tryRebuild(); json(res, 200, { ok: true, rebuild: out }); }); // 5) logout route("POST", /^\/api\/logout$/, async (req, res) => { const sid = parseCookies(req).dojobay_sid; if (sid) await store.dropSession(sid); res.setHeader("Set-Cookie", "dojobay_sid=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"); json(res, 200, { ok: true }); }); // 6) is a node name free on a network? (pre-flight for the submission form; // the POST below re-checks and is the authority) route("GET", /^\/api\/dojo\/name-check$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); const u = new URL(req.url, "http://x"); const network = u.searchParams.get("network") === "testnet" ? "testnet" : "mainnet"; const slug = slugify(u.searchParams.get("name")); if (!slug) return json(res, 400, { error: "name must contain at least one letter or digit" }); const conflict = await nameConflict(network, slug, s.paymentCode); const mine = conflict ? null : await ownedRecordFor(network, slug, s.paymentCode); json(res, 200, { available: !conflict, reason: conflict, slug, update: !!mine, id: mine ? mine.id : `${network}-${slug}` }); }); // 7) create or replace one of my Dojo records (keyed by network + node name) route("POST", /^\/api\/dojo$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } const network: "mainnet" | "testnet" | null = body.network === "testnet" ? "testnet" : (body.network === "mainnet" ? "mainnet" : null); if (!network) return json(res, 400, { error: "network must be mainnet or testnet" }); const name = String(body.name || "").trim().slice(0, 40); const slug = slugify(name); if (!slug) return json(res, 400, { error: "name is required (letters, digits and hyphens)" }); const conflict = await nameConflict(network, slug, s.paymentCode); if (conflict) return json(res, 409, { error: `name "${name}" is taken on ${network}: ${conflict}` }); const payloadErr = validatePayload(body.payload, network); if (payloadErr) return json(res, 400, { error: payloadErr }); body.signed = cleanSigned(body.signed); // signature gate. The signed block is REQUIRED: it is the only part of a // listing a visitor can check without trusting this site, so a listing // without one asks for trust we have no way to earn. Refused here rather than // at the store so the operator is told what to do about it while they still // have the form open, and before the connection gate spends thirty seconds // probing a node whose submission cannot be accepted anyway. if (!body.signed) { return json(res, 400, { error: "signature gate: paste the signed pairing block. " + "Sign the exact pairing text shown above in your wallet under PayNym → Sign message, " + "then paste the whole block, headers included." }); } { // Operators paste this into a web form, which eats whitespace as readily as // a chat window does — and the signature covers the blank line before the // BIP47 line. Repair that before judging, so a correct signature is not // reported as invalid. A reconstruction is only accepted when it verifies // against an address the declared code derives, and the repaired block is // what gets stored, so a later audit verifies too. const repaired = repairSignedBlock(body.signed); if (repaired) body.signed = repaired.block; const sig = verifySignedPayload({ signedText: body.signed, expectedMessage: canonicalPairing(body.payload), // A PayNym signs from its mainnet notification address even when the // node being listed is testnet, so accept either derivation. expectedAddress: notificationAddresses(s.paymentCode), network: networkOf(network), }); if (!sig.ok) return json(res, 400, { error: "signature gate: " + sig.error }); } // connection gate: the node must answer right now over Tor. When the pairing // payload carries an apikey (it should), this performs the same authenticated // chain-tip read the health checker uses, so a submission must prove its // apikey works and the Dojo is serving block data, not merely that the onion // is reachable. Without an apikey it falls back to a plain reachability probe. const check = await probe(body.payload.pairing.url, { ...PROBE_CFG, apikey: body.payload.pairing.apikey, network }); if (!check.up) return json(res, 422, { error: "connection gate: node unreachable or not serving block data over Tor (" + (check.reason || "no response") + ")", probe: check }); // An owned record with this name (or this id, for records that predate // operator naming) is updated in place, keeping its id and therefore its // reliability history; otherwise a new record is created at network-slug. const existing = await ownedRecordFor(network, slug, s.paymentCode); // Minimum Dojo version, on REGISTRATION only. // // Judged on what the node just told us in its X-Dojo-Version header rather // than on the version inside the payload: that field is frozen when the // payload is generated and can be years stale, so a current node can declare // an ancient version quite honestly. // // Existing operators are not re-judged. An operator updating a listing they // already hold — a moved onion, a rotated key — is not registering, and // trapping them behind a rule introduced after they joined would punish them // for maintaining their node. New listings only. if (!existing) { const v = judgeVersion(check.detectedVersion, body.payload?.pairing?.version, MIN_DOJO_VERSION); if (!v.ok) { return json(res, 422, { error: "version gate: " + v.reason, minimum: MIN_DOJO_VERSION, detected: check.detectedVersion || null, }); } } const id = existing ? existing.id : `${network}-${slug}`; const now = new Date().toISOString(); // Resolve the registered PayNym from paynym.rs (best-effort, over Tor). Keep a // previously resolved value if the lookup is momentarily unavailable. const resolvedNym = await resolvePayNym(s.paymentCode).catch(() => null); const rec: StoreRecord = { id, network, name, // Union with any codes already on the record, so a record migrated with // both PayNym variants keeps them when the operator edits via either. paymentCodes: [...new Set([...(existing?.paymentCodes || []), s.paymentCode])], paynym: resolvedNym || (existing && existing.paynym) || null, jurisdiction: (body.jurisdiction || "").toString().slice(0, 64) || null, // Inferred from what the operator wrote about where they are, never asked // for separately. It used to be its own 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 on a card // as letterboxes. Now there is one question, no validation, and a flag when // the answer happens to name somewhere. // // An existing code survives an edit that no longer implies one, so nobody // loses a flag they already had by rewording their location. country: countryFor(body.jurisdiction) || (existing && existing.country) || null, hardware: (body.hardware || "").toString().slice(0, 120) || null, // Exactly the two keys the signature covers. A Dojo export may carry more // (an indexer block, a services[] array); none of it is signed, and the // published payload is what a visitor pairs with, so it stores nothing it // cannot attest to. The Electrum endpoint on a card comes from probing // /support/services instead. payload: { pairing: body.payload.pairing, explorer: body.payload.explorer }, signed: body.signed || null, // A link supplied here is set; left blank, any existing link is kept (the // Edit panel, where the field is prefilled, is the place to clear it). status: "pending", // moderation state: pending | approved | rejected last_probe: check, created_at: existing ? existing.created_at : now, updated_at: now, }; await store.putSubmission(rec); json(res, 200, { ok: true, submission: rec, note: "Submitted for review. It will appear once a maintainer approves it." }); }); // Editable metadata: name and hardware. The Dojo version is NOT editable it is // read live from the node's X-Dojo-Version response header by the updater (see // scripts/update.mjs), so it always reflects what the node is actually running. // These are display fields, so an edit keeps the record's moderation status and // its id (and therefore its history); only a full resubmission re-enters // moderation. A rename must not collide with ANY other record's name on that // network, including the editor's own other records, hence the excludeId scan. async function slugTakenByOther(network, slug, excludeId) { for (const r of await store.listSubmissions()) { if (r.id === excludeId || r.network !== network) continue; if (slugify(r.name) === slug || r.id === `${network}-${slug}`) { return `the name is already used by ${r.paynym || "another"}'s ${r.status} record`; } } for (const n of await seedNodes()) { if (n.network !== network || n.id === excludeId) continue; if (slugify(n.name) === slug || n.id === `${network}-${slug}`) return "the name is reserved by a curated seed node"; } return null; } async function applyEdit(rec, body, res) { const name = String(body.name || "").trim().slice(0, 40); const slug = slugify(name); if (!slug) return json(res, 400, { error: "name is required (letters, digits and hyphens)" }); const taken = await slugTakenByOther(rec.network, slug, rec.id); if (taken) return json(res, 409, { error: `name "${name}" is taken on ${rec.network}: ${taken}` }); rec.name = name; rec.hardware = String(body.hardware || "").trim().slice(0, 120) || null; rec.updated_at = new Date().toISOString(); await store.putSubmission(rec); const out = rec.status === "approved" ? await tryRebuild() : null; // approved edits publish immediately json(res, 200, { ok: true, submission: rec, rebuild: out }); } // 9) edit display fields on one of my records route("POST", /^\/api\/dojo\/edit$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } const rec = await store.getSubmission(body.id); if (!rec || !owns(rec, s.paymentCode)) return json(res, 404, { error: "not found" }); await applyEdit(rec, body, res); }); // 9b) update the pairing details of a record you already own. // // The onion of a Dojo can change, an apikey can be rotated, an operator can // start exposing an Electrum indexer. None of that changes WHO runs the node, // and approval here binds to the payment code rather than to a particular // address: a maintainer approving a listing is approving the operator, and // leaves visitors to judge the node. So a pairing update keeps the record's // moderation status, its id and therefore its reliability history, and only // ever writes the payload and its signature. // // The same gates as a submission still apply, because they protect the reader // rather than gatekeep the operator: the payload must be well-formed, the new // onion must answer over Tor right now (which catches a mistyped address before // it replaces a working one), and a signature, if supplied, must verify against // the payment code signed in. route("POST", /^\/api\/dojo\/pairing$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } const rec = await store.getSubmission(body.id); if (!rec || !owns(rec, s.paymentCode)) return json(res, 404, { error: "not found" }); // Taken from the RECORD, not from the request: an edit cannot change which // network a listing is on, so the new endpoint is judged against the network // the listing already has. const network = rec.network === "testnet" ? "testnet" : "mainnet"; const payloadErr = validatePayload(body.payload, network); if (payloadErr) return json(res, 400, { error: payloadErr }); body.signed = cleanSigned(body.signed); // Required here for the same reason as at submission, and for one more: this // endpoint assigns rec.signed unconditionally, so an edit that omitted the // block used to replace a verified signature with null and quietly turn a // checkable listing into an unattested one. New pairing details need a new // signature over them; the old one covers the old details and would be a lie // about the new. if (!body.signed) { return json(res, 400, { error: "signature gate: paste a signed block covering the NEW pairing details. " + "Your existing signature covers the details you are replacing, so it cannot carry over. " + "Your listing is unchanged." }); } { const repaired = repairSignedBlock(body.signed); if (repaired) body.signed = repaired.block; const sig = verifySignedPayload({ signedText: body.signed, expectedMessage: canonicalPairing(body.payload), expectedAddress: notificationAddresses(s.paymentCode), }); if (!sig.ok) return json(res, 400, { error: "signature gate: " + sig.error }); } const check = await probe(body.payload.pairing.url, { ...PROBE_CFG, apikey: body.payload.pairing.apikey, network, }); if (!check.up) { return json(res, 422, { error: "connection gate: that node is unreachable or not serving block data over Tor (" + (check.reason || "no response") + "). Your listing is unchanged.", probe: check, }); } // Exactly the two keys the signature covers, and nothing the operator posted // alongside them. canonicalPairing is what was verified above, so anything // else in body.payload is unattested and must not be stored, let alone // published: dojos.json publishes payload wholesale for visitors to pair // with. rec.payload = { pairing: body.payload.pairing, explorer: body.payload.explorer }; rec.signed = body.signed || null; rec.last_probe = check; rec.updated_at = new Date().toISOString(); await store.putSubmission(rec); // Republish straight away when the record is live, so a moved onion is // corrected on the cards without waiting for the next probe cycle. const rebuilt = rec.status === "approved" ? await tryRebuild() : null; json(res, 200, { ok: true, submission: rec, rebuild: rebuilt, note: "Pairing details updated. Your listing keeps its place and its history." }); }); // 10) admin: edit display fields on any record route("POST", /^\/api\/admin\/edit$/, async (req, res) => { const s = await adminFrom(req, res); if (!s) return; let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } const rec = await store.getSubmission(body.id); if (!rec) return json(res, 404, { error: "not found" }); await applyEdit(rec, body, res); }); // 12) admin: apply an update. // // Upstream Dojo Bay self-updates by fetching a source archive and swapping it // onto disk under a systemd unit it then restarts. None of that applies to a // container Archipelago builds and versions itself — there is no systemd unit // here to restart, and overwriting the image's own files under a read-only // root would not survive the next container recreate even if it worked. So // self-update.mjs is not shipped in this image, and this route explains that // instead of trying it: updating this app means updating the Dojo Bay app // from the Archipelago App Store, the same as any other app. let UPDATE_JOB = null; // kept for the status route's shape; never set in this image route("POST", /^\/api\/admin\/update$/, async (req, res) => { if (!(await adminFrom(req, res))) return; json(res, 409, { error: "Self-update is not available in this build. This instance is an Archipelago app: " + "update it from the App Store like any other app, not from here.", }); }); // 12b) admin: import listings from another Dojo Bay. // // The same operation the installer performs at setup, offered to a running // instance. It is a background job for the same reason the self-update is: the // three documents come from another onion over Tor, which is seconds at best, // and holding a request open for that is worse than polling. // // It runs IN this process rather than by spawning the script, because the // backend holds the store in memory as its single writer. A second process // writing store.json while this one has it loaded is the bug the maintenance // tools all refuse to risk. // // Two phases on purpose. The premise of importing from another directory is // that you do not trust it, so an operator sees the plan (what would be // imported, what is already listed here under another id, what is refused and // why) before anything is written. That is the same dry run the command line // defaults to. // // Imported records arrive PENDING. At install, approved is right, because // choosing to bootstrap is the decision to trust that list wholesale. In a // running instance with a moderation queue, a listing that appeared on the site // without passing through it would be the other directory publishing here. let IMPORT_JOB = null; // { id, phase, log[], done, ok, error, apply, onion, result } route("POST", /^\/api\/admin\/import$/, async (req, res) => { if (!(await adminFrom(req, res))) return; if (IMPORT_JOB && !IMPORT_JOB.done) return json(res, 409, { error: "an import is already in progress" }); let body; try { body = JSON.parse(await readBody(req)); } catch { body = {}; } const onionHost = String(body.onion || "").replace(/^https?:\/\//, "").replace(/\/.*$/, ""); if (!/^[a-z2-7]{56}\.onion$/.test(onionHost)) { return json(res, 400, { error: "a valid .onion address is required" }); } // The peer's payment code is what the operator binding is checked against, // and it is the operator's own statement of who they are choosing to trust. // Without it there is nothing to compare the remote signature to, so the // import would verify only that the remote signed something. const trustedCode = String(body.code || "").trim(); if (!/^(PM8T|PM8[A-Za-z0-9])/.test(trustedCode) || trustedCode.length < 100) { return json(res, 400, { error: "the payment code of the instance you are importing from is required" }); } const id = Date.now().toString(36); const job = IMPORT_JOB = { id, phase: "starting", log: [], done: false, ok: false, error: null, apply: body.apply === true, onion: onionHost, result: null }; const log = (line: string) => { job.log.push(String(line)); if (job.log.length > 400) job.log.shift(); }; (async () => { try { job.phase = job.apply ? "importing" : "planning"; const { bootstrapImport } = await import("../scripts/bootstrap-import.mjs"); job.result = await bootstrapImport({ onionHost, trustedCode, dryRun: !job.apply, log, dataDir: process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), status: "pending", }); // Rebuild after an apply. The new records are pending and publish // nothing, but an import also carries history onto listings this instance // already has and adds domain claims, and both of those do reach the // published file. A planning run writes nothing and needs none. if (job.apply) { job.phase = "rebuilding"; await tryRebuild(); } // Then probe, for the same reason the installer runs a cycle before it // declares success. A pending record's live status comes from // pending-probe.json, which only the update cycle writes, so until one // runs an imported listing has no status at all and the moderation queue // shows a wall of nodes reading inactive. They are not inactive; nothing // has asked them yet, and a moderator deciding whether to approve is // exactly the person who needs the answer. // // Skipped when a cycle is already running, since there is no lock and two // at once would race to write the same files. systemd knows, and reading // that needs no privilege. if (job.apply && (job.result?.imported ?? 0) > 0) { job.phase = "probing"; const running = await execFileP("systemctl", ["is-active", "--quiet", "dojobay-update.service"]) .then(() => true, () => false); if (running) { log("a probe cycle is already running; the imported nodes will get their status from it"); } else { log("probing the imported nodes over Tor (this takes a minute)"); try { await execFileP(process.execPath, [path.join(ROOT, "scripts", "update.mjs")], { cwd: ROOT, timeout: 5 * 60 * 1000 }); log("probe cycle complete: the imported nodes show their own status"); } catch (e: any) { // Non-fatal. The records are imported and the timer comes round // within ten minutes; what is lost is the status being right // immediately, not the import. log("! probe cycle failed: " + (e.message || String(e))); log(" the import is complete; the update timer will fill in statuses within ten minutes"); } } } job.ok = true; job.done = true; job.phase = "done"; } catch (e: any) { job.error = e.message; job.ok = false; job.done = true; job.phase = "failed"; log("✗ " + e.message); } })(); json(res, 202, { started: true, id, apply: job.apply }); }); route("GET", /^\/api\/admin\/import\/status$/, async (req, res) => { if (!(await adminFrom(req, res))) return; json(res, 200, { job: IMPORT_JOB }); }); route("GET", /^\/api\/admin\/update\/status$/, async (req, res) => { if (!(await adminFrom(req, res))) return; // After a successful apply the service restarts; on the way back up the // helper leaves data/updates/last-result.json, which we surface so the panel // can confirm completion across the restart. let lastResult = null; try { lastResult = JSON.parse(await readFile(path.join(process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"), "updates", "last-result.json"), "utf8")); } catch {} json(res, 200, { job: UPDATE_JOB, lastResult }); }); // The restart permission self-update needs cannot be checked from here. // // Two attempts, both wrong, and the second wrong in a way that took a live // instance to find. systemctl restart --dry-run returns before any bus call, so // it reported success whatever the account could do. pkcheck asks polkit the // right question, but polkit refuses CheckAuthorization() WITH DETAILS from any // caller that is not uid 0 or the action's owner, and the rule keys on the unit // and the verb, so without details it cannot match and the answer would be a // false no. The rules directory is 750 root:polkitd, so reading the file is // closed off too. // // So this instance says nothing about whether the permission is present. What // it can do is report the one thing it has evidence for: an update that // installed and did not restart, which lastResult already records, and which is // exactly the symptom a missing permission produces. route("GET", /^\/api\/admin\/updates$/, async (req, res) => { if (!(await adminFrom(req, res))) return; // Six hours is right for an unattended check over Tor, where GitHub rate // limits shared exit nodes. It is wrong for somebody who has just signed in // to look, which is why the login discards it, and wrong for somebody who has // just pushed while already signed in, which is what ?refresh=1 is for. // // The floor is what stops that button being a way to hammer GitHub from an // exit node shared with every other Tor user. A forced check inside the floor // is answered from the cache with the wait attached, rather than refused: // the operator asked what the state is, and the honest answer is the last one // known plus how stale it is. const forced = /[?&]refresh=1(&|$)/.test(req.url || ""); const decision = updateCacheDecision({ cachedAt: UPDATES_CACHE ? UPDATES_CACHE.at : null, forced, forcedAt: FORCED_UPDATE_AT, floorMs: FORCED_UPDATE_FLOOR }); if (decision.serveCached) { return json(res, 200, decision.waitS ? { ...UPDATES_CACHE.result, refresh_wait_s: decision.waitS } : UPDATES_CACHE.result); } if (forced) FORCED_UPDATE_AT = Date.now(); try { // The account this process runs as, so the panel can print a command that // works rather than a placeholder. The two machines differ: one built by the // installer runs as dojobay, one set up by hand may not. const result = { available: true, serviceUser: (() => { try { return osMod.userInfo().username; } catch { return null; } })(), // Absolute, because the remedy the panel prints used to be a relative // path with nowhere stated to run it. An operator reading "cp // deploy/polkit-restart.rules.example" has to work out both the directory // and that .example is the literal filename rather than a placeholder. ruleSource: path.join(ROOT, "deploy/polkit-restart.rules.example"), rulePath: "/etc/polkit-1/rules.d/49-dojobay-restart.rules", ...(await checkUpdates({ cfg: { proxyHost: PROBE_CFG.proxyHost, proxyPort: PROBE_CFG.proxyPort } })) }; UPDATES_CACHE = { at: Date.now(), result }; json(res, 200, result); } catch (e) { json(res, 200, { available: false, error: e.message }); } }); // 11) reliability export: the full 24h check series and 90-day rollups in one // document, optionally filtered to a single node. Not linked anywhere on // the front end; the raw files also remain at /data/history.json and // /data/history-daily.json. route("GET", /^\/api\/history\/export$/, async (req, res) => { const u = new URL(req.url, "http://x"); const id = u.searchParams.get("id"); const dataDir = process.env.PUBLIC_DATA_DIR || path.join(ROOT, "data"); const read = async (f, fb) => { try { return JSON.parse(await readFile(path.join(dataDir, f), "utf8")); } catch { return fb; } }; const hist = await read("history.json", { nodes: {} }); const daily = await read("history-daily.json", { nodes: {} }); const ids = id ? [id] : [...new Set([...Object.keys(hist.nodes || {}), ...Object.keys(daily.nodes || {})])].sort(); const nodes = {}; for (const k of ids) { const h = (hist.nodes || {})[k], d = (daily.nodes || {})[k]; if (!h && !d) continue; nodes[k] = { checks: (h && h.checks) || [], days: (d && d.days) || [] }; const retired = (h && h.retired) || (d && d.retired); if (retired) nodes[k].retired = retired; } if (id && !nodes[id]) return json(res, 404, { error: "no history for that id" }); json(res, 200, { generated_at: new Date().toISOString(), interval_minutes: hist.interval_minutes || 10, window_checks: hist.window_checks || 144, nodes, }); }); // 8) delete one of my records route("POST", /^\/api\/dojo\/delete$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } const rec = await store.getSubmission(body.id); if (!rec || !owns(rec, s.paymentCode)) return json(res, 404, { error: "not found" }); await store.deleteSubmission(body.id); json(res, 200, { ok: true }); }); // A card link is only publishable on the operator's verified domain. This is the // constraint that replaces a freeform URL field: "link to my own site" survives, // an unverifiable social profile does not. // ---- verified operator domains --------------------------------------------- // An operator proves control of one clearnet domain: the domain names their // payment code in a TXT record, and they sign a statement naming the domain. // The badge on their cards, and the card-title link, both depend on it. const domainCfg = () => ({ proxyHost: PROBE_CFG.proxyHost, proxyPort: PROBE_CFG.proxyPort }); // What the operator has, plus exactly what to publish and sign. Returning the // instructions from the server keeps them identical to what verification checks. route("GET", /^\/api\/domain$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); const claim = await store.getDomain(s.paymentCode); json(res, 200, { claim: claim ? { domain: claim.domain, verified: !!claim.verified, verified_at: claim.verified_at || null, last_check: claim.last_check || null, last_result: claim.last_result || null, failing_since: claim.fail_since || null, grace_days: GRACE_DAYS, } : null, txt_host: txtHost(), txt_prefix: txtName(""), txt_value: txtValue(s.paymentCode), signing_hint: "Sign under PayNym → Sign message, which uses your PayNym's notification address.", }); }); // Instructions for a specific domain, so the console can show the exact record // and text before the operator has signed anything. route("POST", /^\/api\/domain\/prepare$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } const norm = normaliseDomain(body?.domain); if (!norm.ok) return json(res, 400, { error: norm.error }); json(res, 200, { domain: norm.domain, punycode: !!norm.punycode, // Two forms on purpose: most panels (Namecheap, Cloudflare, Route 53) want // the label relative to the zone, a few want the fully-qualified name. // Handing over only the latter produces _dojobay.example.com.example.com. txt_host: txtHost(), txt_name: txtName(norm.domain), txt_value: txtValue(s.paymentCode), sign_text: signingText(norm.domain, s.paymentCode), }); }); route("POST", /^\/api\/domain$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } const norm = normaliseDomain(body?.domain); if (!norm.ok) return json(res, 400, { error: norm.error }); let signed = String(body?.signed || "").trim(); if (!signed) return json(res, 400, { error: "paste the signed block" }); // Same paste hazard as the submission gate: restore the blank line the // signature covers, when a reconstruction verifies cryptographically. const repairedClaim = repairSignedBlock(signed); if (repairedClaim) signed = repairedClaim.block; // A domain already verified by a different operator is not fatal (a host may // run several operators' nodes) but it is worth surfacing to an admin. const clash = (await store.listDomains()) .find((c) => c && c.verified && c.domain === norm.domain && c.paymentCode !== s.paymentCode); const r = await verifyClaim({ domain: norm.domain, paymentCode: s.paymentCode, signed }, domainCfg()); const now = new Date().toISOString(); const prev = await store.getDomain(s.paymentCode); // A bad signature is the operator's to fix and nothing is stored. A missing // TXT record is usually propagation, so the claim is SAVED unverified and the // sweep keeps looking: the operator does not have to sign again, and an // unverified claim confers nothing (no badge, and no card link, because // checkNameUrl requires a verified domain). if (!r.ok && r.stage === "signature") { return json(res, 400, { error: r.error, stage: r.stage }); } await store.putDomain({ paymentCode: s.paymentCode, domain: norm.domain, signed, verified: !!r.ok, verified_at: r.ok ? now : null, last_check: r.ok ? now : null, // null so the sweep retries immediately last_result: r.ok ? "ok" : (r.error || "awaiting the TXT record"), fail_since: null, created_at: (prev && prev.created_at) || now, also_claimed_by: clash ? clash.paymentCode : null, }); if (!r.ok) { const rebuiltPending = await tryRebuild(); return json(res, 202, { ok: false, pending: true, domain: norm.domain, error: r.error, hint: r.hint || null, inconclusive: !!r.inconclusive, note: "Your signature is verified and saved. We could not see the TXT record yet, " + "which usually means DNS has not propagated. This is retried automatically; " + "you do not need to sign again.", rebuild: rebuiltPending, }); } // A changed domain can invalidate an existing card link, so republish. const rebuilt = await tryRebuild(); json(res, 200, { ok: true, domain: norm.domain, resolvers_agreed: r.agreed, rebuild: rebuilt }); }); // Re-check on demand, using the signature already stored. The operator has just // published a TXT record and wants an answer now rather than at the next sweep; // they should not have to sign again, and the GET deliberately does not hand // their signed block back to the browser. route("POST", /^\/api\/domain\/recheck$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); const claim = await store.getDomain(s.paymentCode); if (!claim) return json(res, 404, { error: "no domain claim to re-check" }); const r = await verifyClaim({ domain: claim.domain, paymentCode: claim.paymentCode, signed: claim.signed }, domainCfg()); const now = new Date().toISOString(); const next = { ...claim, last_check: now, verified: !!r.ok, verified_at: r.ok ? (claim.verified_at || now) : claim.verified_at, last_result: r.ok ? "ok" : (r.error || "no matching TXT record"), fail_since: r.ok ? null : claim.fail_since }; await store.putDomain(next); const rebuilt = await tryRebuild(); json(res, r.ok ? 200 : 202, { ok: !!r.ok, pending: !r.ok, domain: claim.domain, error: r.ok ? undefined : r.error, hint: r.ok ? undefined : (r.hint || null), inconclusive: !!r.inconclusive, rebuild: rebuilt, }); }); route("DELETE", /^\/api\/domain$/, async (req, res) => { const s = await sessionFrom(req); if (!s) return json(res, 401, { error: "not authenticated" }); await store.deleteDomain(s.paymentCode); const rebuilt = await tryRebuild(); json(res, 200, { ok: true, rebuild: rebuilt }); }); // Admin revocation. A badge attests to control, not to trustworthiness, so // there must be a way to remove one from a lookalike or abusive domain. route("POST", /^\/api\/admin\/domain\/revoke$/, async (req, res) => { if (!(await adminFrom(req, res))) return; let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: "invalid JSON" }); } const code = String(body?.paymentCode || ""); const claim = await store.getDomain(code); if (!claim) return json(res, 404, { error: "no domain claim for that payment code" }); await store.putDomain({ ...claim, verified: false, revoked: true, last_result: "revoked by admin", last_check: new Date().toISOString() }); const rebuilt = await tryRebuild(); json(res, 200, { ok: true, rebuild: rebuilt }); }); route("GET", /^\/api\/admin\/domains$/, async (req, res) => { if (!(await adminFrom(req, res))) return; const list = (await store.listDomains()).map((c) => ({ paymentCode: c.paymentCode, domain: c.domain, verified: !!c.verified, revoked: !!c.revoked, verified_at: c.verified_at || null, last_check: c.last_check || null, last_result: c.last_result || null, failing_since: c.fail_since || null, also_claimed_by: c.also_claimed_by || null, })); json(res, 200, { domains: list, grace_days: GRACE_DAYS }); }); // Periodic re-check. This lives in the backend rather than the ten-minute // updater because the store is owned by the backend's user; the updater runs as // a different user and must not write it. DNS changes slowly, so the sweep is // daily per claim, and it never lets an unreachable resolver strip a badge. // Rejected submissions are not kept indefinitely. The window exists so a // maintainer can undo a mistaken rejection; after it, the operator's payment // code, pairing payload, apikey and signature are removed. Defaults to the same // grace period the retired history uses. const REJECTED_RETENTION_DAYS = +(process.env.REJECTED_RETENTION_DAYS || process.env.HISTORY_GRACE_DAYS || 14); async function sweepRejected() { try { const gone = await store.pruneRejected(REJECTED_RETENTION_DAYS); if (gone.length) { console.log(`[retention] removed ${gone.length} rejected submission(s) older than ` + `${REJECTED_RETENTION_DAYS} days: ${gone.join(", ")}`); } return gone; } catch (e) { console.error("[retention] sweep failed:", (e as Error).message); return []; } } async function sweepDomains() { let changed = false; for (const claim of await store.listDomains()) { if (claim.revoked || !isDue(claim)) continue; let result; try { result = await recheckClaim(claim, domainCfg()); } catch (e) { result = { ok: false, inconclusive: true, error: e.message }; } const next = applyRecheck(claim, result); if (JSON.stringify(next) !== JSON.stringify(claim)) { await store.putDomain(next); if (next.verified !== claim.verified) changed = true; } } if (changed) await tryRebuild(); return changed; } if (process.env.DOMAIN_SWEEP !== "0") { const every = +(process.env.DOMAIN_SWEEP_MINUTES || 30) * 60 * 1000; const t = setInterval(() => { sweepDomains().catch(() => {}); sweepRejected().catch(() => {}); }, every); t.unref?.(); // Also once at startup, so a long-dead instance does not wait for the first tick. sweepRejected().catch(() => {}); } // ---- server ---------------------------------------------------------------- const server = http.createServer(async (req, res) => { try { const path = new URL(req.url, "http://x").pathname; for (const r of routes) { if (r.method === req.method && r.re.test(path)) return await r.fn(req, res); } json(res, 404, { error: "not found" }); } catch (e) { json(res, 500, { error: "server error", detail: e.message }); } }); server.listen(PORT, "127.0.0.1", () => console.log(`dojobay backend on 127.0.0.1:${PORT}` + (CONFIGURED_BASE_URL ? ` (base ${CONFIGURED_BASE_URL})` : " (base: derived per-request from Host)"))); export { server, routes };