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