// Tiny JSON-file store for the backend. Single-writer (one server process), // atomic writes, no external database. Holds submissions, live sessions and // outstanding Auth47 nonces. import { readFile, writeFile, rename, mkdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomBytes } from "node:crypto"; import type { StoreRecord, DomainClaim } from "../types.js"; /** A short-lived, single-use Auth47 nonce. */ export interface Nonce { expires: number; [k: string]: unknown } /** A signed-in operator's session, keyed by a random cookie id. */ export interface Session { paymentCode: string; expires: number; [k: string]: unknown } interface StoreShape { submissions: Record; sessions: Record; nonces: Record; domains: Record; } const DIR = process.env.SERVER_DATA_DIR || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "data"); const FILE = path.join(DIR, "store.json"); const EMPTY: StoreShape = { submissions: {}, sessions: {}, nonces: {}, domains: {} }; let cache: StoreShape | null = null; // Whether a record carries a signed pairing block at all. A shape check, not a // verification: the submit gate decided whether the block verifies against the // operator's own payment code, and re-deriving that at every read would mean // the store and the rebuild silently dropping listings over a cryptographic // judgement made elsewhere. This asks only what a caller is entitled to ask // here, which is whether there is anything for a visitor to check. // server/audit-signed.mjs is the tool that re-runs the real verification over // the whole store. It lives in this file rather than beside the verifier so // that store.ts stays on node builtins alone: remove-listing.ts and the // migration scripts import the store, and should not have to pull in secp256k1 // to ask a question about a string. export function hasSignedBlock(rec: { signed?: string | null } | null | undefined): boolean { const signed = typeof rec?.signed === "string" ? rec.signed.trim() : ""; return signed.includes("BEGIN BITCOIN SIGNED MESSAGE") && signed.includes("BEGIN BITCOIN SIGNATURE"); } // A submission's ownership is a paymentCodes ARRAY, because one PayNym often // carries two BIP47 codes (segwit and legacy variants) and the wallet may sign // Auth47 with either. Records written before this schema carried a scalar // paymentCode; normalise those on read so old store files keep working. function normaliseSubmission(rec: T): T { if (!rec || typeof rec !== "object") return rec; const r = rec as { paymentCodes?: unknown; paymentCode?: string }; if (!Array.isArray(r.paymentCodes)) { r.paymentCodes = r.paymentCode ? [r.paymentCode] : []; } r.paymentCodes = [...new Set((r.paymentCodes as unknown[]).filter((c): c is string => typeof c === "string" && !!c))]; delete r.paymentCode; return rec; } async function load(): Promise { if (cache) return cache; await mkdir(DIR, { recursive: true }); try { cache = { ...EMPTY, ...JSON.parse(await readFile(FILE, "utf8")) }; } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; cache = structuredClone(EMPTY); } for (const rec of Object.values(cache.submissions)) normaliseSubmission(rec); return cache; } // A temporary name no other writer can take; see build-public.ts. The store has // a single writer by design, but the backend and a maintenance script can both // be pointed at it, and that is precisely when a shared temporary name bites. let tmpSeq = 0; async function persist() { const tmp = `${FILE}.${process.pid}.${(tmpSeq = (tmpSeq + 1) % 1e6)}.tmp`; await writeFile(tmp, JSON.stringify(cache, null, 2) + "\n"); await rename(tmp, FILE); } export const store = { async get() { return load(); }, async save() { await persist(); }, // --- nonces (single-use, short lived) --- async putNonce(nonce: string, data: Nonce) { (await load()).nonces[nonce] = data; await persist(); }, async takeNonce(nonce: string): Promise { const s = await load(); const n = s.nonces[nonce]; if (n) { delete s.nonces[nonce]; await persist(); } return n || null; }, async gcNonces(now: number = Date.now()) { const s = await load(); let changed = false; for (const [k, v] of Object.entries(s.nonces)) { if (!v || v.expires < now) { delete s.nonces[k]; changed = true; } } if (changed) await persist(); }, // --- sessions --- async putSession(data: Session): Promise { const s = await load(); const id = randomBytes(32).toString("hex"); s.sessions[id] = data; await persist(); return id; }, async getSession(id: string | null | undefined): Promise { if (!id) return null; const s = await load(); const sess = s.sessions[id]; if (!sess) return null; if (sess.expires < Date.now()) { delete s.sessions[id]; await persist(); return null; } return sess; }, async dropSession(id: string) { const s = await load(); if (s.sessions[id]) { delete s.sessions[id]; await persist(); } }, // --- submissions (keyed by network + name slug; owned by paymentCodes[]) --- async listSubmissions(): Promise { return Object.values((await load()).submissions); }, async submissionsFor(paymentCode: string): Promise { return Object.values((await load()).submissions) .filter((r) => Array.isArray(r.paymentCodes) && r.paymentCodes.includes(paymentCode)); }, // Every record must carry at least one BIP47 payment code and a signed // pairing block. This is the single chokepoint through which every write to // the store passes, so enforcing both here is what makes an unowned or // unattested listing structurally impossible rather than merely discouraged: // the payment code is the identity the directory rests on, and the signature // is what lets a visitor check the pairing details against that identity // without trusting this site at all. A listing without one cannot be owned, // edited, verified or recognised by a visitor; a listing without the other // asks the visitor to take our word for an onion address and an API key, // which is the one thing this directory exists not to require. Historically a // few pre-Auth47 records existed without a code, and rather more predate the // signature gate; both doors are now closed. async putSubmission(rec: StoreRecord): Promise { const normalised = normaliseSubmission(rec); const codes = (normalised as StoreRecord).paymentCodes; // An emptiness guard, deliberately, not a validator: whether a code is a // real BIP47 payment code is settled at the gates that admit it — an Auth47 // session proves possession, and the signature checks derive its // notification address. What must be impossible HERE is a listing with no // owner at all. if (!Array.isArray(codes) || !codes.some((c) => typeof c === "string" && /^PM\w{6,}/.test(c.trim()))) { throw new Error(`refusing to store ${rec?.id}: a listing must carry a BIP47 payment code`); } // The same kind of guard for the signature: a shape check, not a // verification. Whether the block verifies against the record's own code is // settled at the submit and pairing-edit gates, which have the session and // the canonical message to hand and can say precisely what is wrong. What // must be impossible HERE is a record whose pairing details nobody has // attested to, however it was assembled — by an admin action, an import, a // migration or a future endpoint that has not been written yet. if (!hasSignedBlock(normalised)) { throw new Error(`refusing to store ${rec?.id}: a listing must carry a signed pairing block. ` + `Ask the operator to sign their pairing payload, or remove the listing with server/remove-listing.ts.`); } const s = await load(); s.submissions[rec.id] = normalised; await persist(); return rec; }, async getSubmission(id: string): Promise { const rec = (await load()).submissions[id] || null; return rec ? normaliseSubmission(rec) : null; }, // Retention: a rejected submission is kept briefly so a maintainer can reverse // a mistake, then deleted. Nothing else ever removed one, so the store // accumulated the payment code, pairing payload and signature of every // operator ever turned down — including the apikey, which is a live // credential to their Dojo, not merely metadata. Returns the ids removed. async pruneRejected(days: number, now: number = Date.now()): Promise { const s = await load(); const cutoff = now - days * 86400 * 1000; const gone: string[] = []; for (const [id, rec] of Object.entries(s.submissions)) { if (rec?.status !== "rejected") continue; const stamp = Date.parse(rec.updated_at || rec.created_at || ""); // A record with no usable timestamp is pruned rather than kept forever. if (Number.isFinite(stamp) && stamp > cutoff) continue; delete s.submissions[id]; gone.push(id); } if (gone.length) await persist(); return gone; }, async deleteSubmission(id: string) { const s = await load(); if (s.submissions[id]) { delete s.submissions[id]; await persist(); } }, // --- verified operator domains (keyed by payment code) --------------------- // One claim per code. A record is kept even after it stops verifying, so // restoring the TXT record restores the badge without a fresh signature. async listDomains(): Promise { return Object.values((await load()).domains || {}); }, async getDomain(paymentCode: string): Promise { return ((await load()).domains || {})[paymentCode] || null; }, async putDomain(claim: DomainClaim): Promise { const s = await load(); s.domains = s.domains || {}; s.domains[claim.paymentCode] = claim; await persist(); return claim; }, async deleteDomain(paymentCode: string) { const s = await load(); if (s.domains && s.domains[paymentCode]) { delete s.domains[paymentCode]; await persist(); } }, // Every verified domain, as a payment code -> domain map, for the rebuild. async verifiedDomainMap(): Promise> { const out = new Map(); for (const c of Object.values((await load()).domains || {})) { if (c && c.verified && c.domain) out.set(c.paymentCode, c.domain); } return out; }, };