diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index 91d2b6d..12078d4 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -148,9 +148,32 @@ export async function registerTld({ tld: input, userId, ownerEmail = null, owner const logAction = (tld, userId, action) => run(`INSERT INTO moshpit_tld_log (tld, user_id, action, at) VALUES (?,?,?,?)`, [tld, userId, action, Date.now()]); -/** The append-only allocation log -- the answer to "who claimed it first". */ -export async function tldLog(limit = 500) { - return all(`SELECT seq, tld, user_id, action, at FROM moshpit_tld_log ORDER BY seq ASC LIMIT ?`, [limit]); +/** + * The append-only allocation log -- the answer to "who claimed it first". + * + * `since` is a seq and it is exclusive, because that is the only piece of state + * a mirror actually has: the last entry it stored. "Everything after 41" is the + * whole of the question, and answering it needs the server to remember nothing + * about who is asking -- which is what makes the log mirrorable by anyone + * rather than by whoever we have set up as a replica. + * + * Ordering is by seq and never by `at`. Two entries can share a millisecond, + * and a reader that sorts on the clock would put them in a different order than + * the writer did -- at which point two mirrors of the same log disagree about + * who claimed a name first, which is the one thing this table exists to settle. + */ +export async function tldLog({ since = 0, limit = 500 } = {}) { + const after = Number.isInteger(since) && since > 0 ? since : 0; + return all( + `SELECT seq, tld, user_id, action, at FROM moshpit_tld_log WHERE seq > ? ORDER BY seq ASC LIMIT ?`, + [after, limit], + ); +} + +/** How many entries the log holds -- so a page of it can say that it is one. */ +export async function countTldLog() { + const row = await get(`SELECT COUNT(*) AS n FROM moshpit_tld_log`); + return Number(row?.n ?? 0); } /* ---- aliases ---- */ diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index b567d43..78ef623 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -1,6 +1,7 @@ // The Moshpit namespace: claim `.`, alias it, exempt names from the // alias, and resolve. // +// GET /api/moshpit/log?since=&limit= the allocation log, in order, to anyone // GET /api/moshpit/tlds the public registry (`?mine=1` for yours) // POST /api/moshpit/tlds claim `.` // GET /api/moshpit/tlds/:tld availability lookup, no auth @@ -17,6 +18,7 @@ // GET /pit the human page // GET /pit/records the DNS records on the names you hold // GET /pit/dns how to reach these names from a machine +import { createHash } from "node:crypto"; import { Router } from "express"; import { page, footer, appBar, esc } from "../lib/html.mjs"; import { requireAuth, csrfInput } from "../lib/session.mjs"; @@ -35,6 +37,7 @@ import { clearExempt, countNames, countRecordNames, + countTldLog, countTlds, countTldsForUser, countSearchTlds, @@ -87,6 +90,7 @@ import { shortCount, suggestedLabels, summarizeBulkClaim, + tldLog, tldRejection, zoneLine, } from "../moshpit.mjs"; @@ -205,6 +209,67 @@ function pageParams(query) { return { limit, offset }; } +/** + * The allocation log, in order, to anyone who asks. + * + * `moshpit_tlds` is a cache; this table is the record. That distinction was + * written into the model months ago and then went nowhere, because nothing + * could read it: no route, no export, no way for a second copy of this registry + * to exist. "The directory can be mirrored and served by anyone" was true of + * the schema and false of the product. + * + * Unauthenticated, because a log only one party can read settles nothing. The + * point of publishing it is that a claim can be checked against the order it + * was made in, by someone who does not trust us -- and a reader who has to ask + * us for permission first is trusting us again. + * + * `?since=` is a seq, exclusive. A mirror stores the last seq it saw and asks + * for what came after; catching up and staying caught up are the same call. + * + * The owning account is a digest rather than the user id. Ownership is already + * public -- who claimed `.eggs` first is the whole point -- but the account + * behind it is not, which is the same line /api/moshpit/tlds/:tld draws when it + * answers "already registered" without saying by whom. The digest is stable and + * derived only from the id, so two entries by one owner are still visibly one + * owner, and every mirror computes the same value for them. + */ +moshpitRouter.get("/api/moshpit/log", async (req, res) => { + const sinceRaw = Number.parseInt(req.query.since, 10); + const since = Number.isInteger(sinceRaw) && sinceRaw > 0 ? sinceRaw : 0; + + const limitRaw = Number.parseInt(req.query.limit, 10); + const limit = Number.isInteger(limitRaw) && limitRaw > 0 ? Math.min(MAX_PAGE, limitRaw) : DEFAULT_LOG_PAGE; + + // One more than asked for, so "is there another page" is answered by what + // came back rather than by a second count that could disagree with it. + const rows = await tldLog({ since, limit: limit + 1 }); + const more = rows.length > limit; + const entries = more ? rows.slice(0, limit) : rows; + + res.json({ + total: await countTldLog(), + since, + limit, + // The seq to pass back as `?since=`. Null means caught up -- not "start + // again", which is what an absent cursor would otherwise be read as. + next: more ? entries[entries.length - 1].seq : null, + entries: entries.map((e) => ({ + seq: e.seq, + tld: e.tld, + action: e.action, + owner: ownerDigest(e.user_id), + at: e.at, + })), + }); +}); + +// 500 rather than the 200 the endings list uses: these rows are small, and a +// mirror catching up from empty is the normal case rather than the exception. +const DEFAULT_LOG_PAGE = 500; + +const ownerDigest = (userId) => + (userId ? createHash("sha256").update(`moshpit:owner:${userId}`).digest("hex").slice(0, 16) : null); + moshpitRouter.post("/api/moshpit/tlds", async (req, res) => { if (!req.user) return unauthorized(res); const result = await registerTld({ diff --git a/apps/pwa/test/moshpit-log.test.mjs b/apps/pwa/test/moshpit-log.test.mjs new file mode 100644 index 0000000..fb8f7ac --- /dev/null +++ b/apps/pwa/test/moshpit-log.test.mjs @@ -0,0 +1,197 @@ +// Publishing the allocation log. +// +// `moshpit_tlds` is a cache and `moshpit_tld_log` is the record — that was +// decided when the table was written, and then nothing could read it. There was +// no route, so "the directory can be mirrored and served by anyone" described +// the schema and not the product: the only copy of the order in which names +// were allocated lived in one database, behind one login. +// +// What is tested here is the part a mirror depends on. Not that the endpoint +// answers, but that following `?since=` from zero to caught-up yields every +// entry exactly once, in the order the writer wrote them — because a mirror +// that drops one entry or reorders two disagrees with the registry about who +// claimed a name first, which is the single fact this table exists to settle. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express"), cookieParser: require("cookie-parser") }; +} catch { + deps = null; +} + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-log-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; + +const ALICE = "user-alice"; +const BOB = "user-bob"; + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, db } = await import("../src/db.mjs"); + const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs"); + const { moshpitRouter } = await import("../src/routes/moshpit.mjs"); + const m = await import("../src/moshpit.mjs"); + + for (const [id, email] of [[ALICE, "alice@example.com"], [BOB, "bob@example.com"]]) { + await run(`INSERT OR IGNORE INTO users (id, email, created_at) VALUES (?,?,?)`, [id, email, Date.now()]); + } + + // Written through the model rather than straight into the table, so what is + // being paged is a log the registry actually produced. + await m.registerTld({ tld: "eggs", userId: ALICE, ownerEmail: "alice@example.com" }); + await m.registerTld({ tld: "chicken", userId: BOB, ownerEmail: "bob@example.com" }); + for (let i = 0; i < 30; i++) { + await m.registerName({ tld: "eggs", label: `n${String(i).padStart(3, "0")}`, userId: ALICE }); + } + + const app = deps.express(); + app.use(deps.express.json()); + app.use(deps.express.urlencoded({ extended: false })); + app.use(deps.cookieParser()); + app.use(sessionMiddleware); + app.use(csrfGuard); + app.use(moshpitRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + + // No credentials anywhere in here, on purpose. Every call this file makes is + // the call a stranger's mirror makes. + const call = async (p) => { + const res = await fetch(`${base}${p}`); + return { status: res.status, json: await res.json() }; + }; + + return { server, db, call }; +} + +let booted = null; +const app = () => (booted ||= boot()); + +test.after(() => { + if (!booted) return; + booted.then(({ server, db }) => { server.close(); db.close?.(); }) + .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } }); +}); + +const skip = { skip: !deps && "apps/pwa deps not installed" }; + +test("the log is readable by someone with no account", skip, async () => { + const { call } = await app(); + + // A log only the operator can read settles nothing: the reason to publish the + // order is so a claim can be checked by someone who does not trust us. + const res = await call("/api/moshpit/log"); + assert.equal(res.status, 200); + assert.equal(res.json.total, 32, "two endings and thirty names"); + assert.equal(res.json.entries.length, 32); + assert.equal(res.json.since, 0); + assert.equal(res.json.next, null, "a page that holds everything is caught up"); +}); + +test("entries arrive in seq order, and say what happened", skip, async () => { + const { call } = await app(); + const { entries } = (await call("/api/moshpit/log")).json; + + const seqs = entries.map((e) => e.seq); + assert.deepEqual(seqs, [...seqs].sort((a, b) => a - b), "order is the whole product"); + assert.equal(new Set(seqs).size, seqs.length); + + assert.deepEqual( + entries.slice(0, 2).map((e) => [e.tld, e.action]), + [["eggs", "register"], ["chicken", "register"]], + "the first two claims, in the order they were made", + ); + assert.ok(entries.some((e) => e.action === "name:n000"), "mints are in the log too, not only claims"); + assert.ok(entries.every((e) => Number.isInteger(e.at))); +}); + +test("since is exclusive, so a mirror can resume from what it stored", skip, async () => { + const { call } = await app(); + const all = (await call("/api/moshpit/log")).json.entries; + + const after = await call(`/api/moshpit/log?since=${all[4].seq}`); + assert.equal(after.json.since, all[4].seq); + assert.equal(after.json.entries[0].seq, all[5].seq, "exclusive: the entry you already have is not resent"); + assert.deepEqual(after.json.entries.map((e) => e.seq), all.slice(5).map((e) => e.seq)); + + // Past the end is empty rather than an error, and stays caught up. + const beyond = await call(`/api/moshpit/log?since=${all[all.length - 1].seq}`); + assert.equal(beyond.status, 200); + assert.deepEqual(beyond.json.entries, []); + assert.equal(beyond.json.next, null); +}); + +test("following next from zero replays the log exactly once", skip, async () => { + const { call } = await app(); + + // This is the mirror, written out. If it can drop or duplicate an entry, two + // copies of the registry can disagree about who was first. + const seen = []; + let since = 0; + for (let guard = 0; guard < 50; guard++) { + const res = await call(`/api/moshpit/log?since=${since}&limit=7`); + seen.push(...res.json.entries); + if (res.json.next === null) break; + assert.ok(res.json.next > since, "a cursor that does not advance is an infinite loop"); + since = res.json.next; + } + + assert.equal(seen.length, 32); + assert.equal(new Set(seen.map((e) => e.seq)).size, 32, "no entry replayed"); + const direct = (await call("/api/moshpit/log")).json.entries; + assert.deepEqual(seen.map((e) => e.seq), direct.map((e) => e.seq), "paged and whole agree on order"); +}); + +test("next is a cursor, not a page number", skip, async () => { + const { call } = await app(); + + const res = await call("/api/moshpit/log?limit=7"); + assert.equal(res.json.entries.length, 7); + assert.equal(res.json.limit, 7); + assert.equal(res.json.next, res.json.entries[6].seq, "resume from the last entry handed over"); + assert.equal(res.json.total, 32, "a page says how much log there is, not how much it holds"); +}); + +test("the owning account is linkable but not identified", skip, async () => { + const { call } = await app(); + const { entries } = (await call("/api/moshpit/log")).json; + + const body = JSON.stringify(entries); + assert.ok(!body.includes(ALICE) && !body.includes(BOB), "ownership is public; the account behind it is not"); + assert.ok(!body.includes("alice@example.com")); + + const eggs = entries.find((e) => e.tld === "eggs"); + const chicken = entries.find((e) => e.tld === "chicken"); + assert.match(eggs.owner, /^[0-9a-f]{16}$/); + assert.notEqual(eggs.owner, chicken.owner, "two owners have to be distinguishable"); + + // Stable, or "these forty names are held by one account" stops being checkable + // from the log alone. + assert.equal(entries.filter((e) => e.owner === eggs.owner).length, 31); +}); + +test("paging arguments are capped and nonsense falls back", skip, async () => { + const { call } = await app(); + + const huge = await call("/api/moshpit/log?limit=999999"); + assert.equal(huge.json.limit, 1000, "without a ceiling, ?limit= asks for the whole table"); + + for (const q of ["?limit=abc", "?limit=0", "?limit=-5", "?since=-1", "?since=abc", "?limit=&since="]) { + const res = await call(`/api/moshpit/log${q}`); + assert.equal(res.status, 200, q); + assert.equal(res.json.entries.length, 32, `${q} should fall back to the default page`); + assert.equal(res.json.since, 0, q); + } +});