diff --git a/web/board/scripts/archive-stray-boards.mjs b/web/board/scripts/archive-stray-boards.mjs new file mode 100644 index 0000000..beaa2db --- /dev/null +++ b/web/board/scripts/archive-stray-boards.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +// archive-stray-boards.mjs — ready-153's sweep for boards this repo's own tests +// left in the owner's portfolio. +// +// WHAT CHANGED IN THE CLOSING ROUND, and why both changes are the same defect: +// +// 1. THE WALK IS NO LONGER RE-IMPLEMENTED HERE. This file used to carry its +// own copy of the relay walk, and that copy's REQ helper resolved with a +// partial page on its 45s timeout instead of rejecting. A relay that +// answered nothing therefore printed "0 unarchived stray(s) … nothing to +// archive" and exited 0 — a clean bill of health from a dead relay, the +// exact silent-relay defect throwaway-board.mjs's `wsReq` was fixed for. +// Two copies of a walk means one of them is always the unfixed one, so now +// there is one: `fetchBoardEvents`/`latestBoardsOwnedBy` are imported. +// +// 2. THE STRAY SET IS DERIVED FROM THE TREE, not typed here. It used to be a +// five-prefix list — the files that round's diff had touched. Walked live +// on 2026-07-30 that list covered 7 of 17 strays and missed 10 +// `ready-livetest-*`, which come from a Go live-relay test nobody had +// looked at. See stray-boards.mjs. +// +// Both are the same mistake: scoping a hygiene check to what the author already +// knew about, so it reports clean for the wrong reason. +// +// DRY RUN BY DEFAULT. Pass --archive to actually publish the archive events. +// Every board this key owns is printed with its classification and, for a +// stray, the source line that claims it — so the archive list is auditable +// before anything is written. `rd board unarchive ` reverses any of it. +// +// Usage: node scripts/archive-stray-boards.mjs [--relay wss://…] [--archive] + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { archiveBoard, fetchBoardEvents, latestBoardsOwnedBy } from "./throwaway-board.mjs"; +import { classify, strayPatterns } from "./stray-boards.mjs"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); +const argv = process.argv.slice(2); +const RELAY = argv.includes("--relay") ? argv[argv.indexOf("--relay") + 1] : "wss://relay.3dl.network"; +const DO_ARCHIVE = argv.includes("--archive"); + +const log = (...a) => console.log(...a); + +function rdHome() { + if (process.env.RD_HOME) return process.env.RD_HOME; + const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(xdg, "rd"); +} + +/** + * protectedBoardDs are board-ds this sweep will never call a stray, whatever a + * derived pattern says. `pkg/sync/nostroutbound.go` names this repo's own + * production board in `reservedProductionBoardD`, and several live-relay tests + * write that same literal into a `BoardD:` field — so the derivation legitimately + * produces `^ready$`, and without this shield the sweep's first act would be to + * archive the board it is being run to clean up. + */ +export function protectedBoardDs(repoRoot) { + const src = readFileSync(path.join(repoRoot, "pkg/sync/nostroutbound.go"), "utf8"); + const m = /reservedProductionBoardD\s*=\s*"([^"]+)"/.exec(src); + if (!m) throw new Error("pkg/sync/nostroutbound.go: could not read reservedProductionBoardD — refusing to sweep blind"); + return [m[1]]; +} + +async function main() { + const idPath = path.join(rdHome(), "nostr-identity.json"); + const identity = JSON.parse(readFileSync(idPath, "utf8")); + const owner = identity.pubkey_hex; + + const patterns = strayPatterns(REPO_ROOT).filter((p) => p.usable); + const shielded = protectedBoardDs(REPO_ROOT); + log(`${patterns.length} board-d pattern(s) derived from the tree; protected: ${shielded.join(", ")}`); + + log(`\nwalking ${RELAY} for every kind-30301 board owned by ${owner}`); + // fetchBoardEvents REJECTS on a relay that never sends EOSE — a silent relay + // must never read as "this key owns nothing". + const owned = latestBoardsOwnedBy(await fetchBoardEvents(RELAY), owner); + const unarchived = [...owned.values()].filter((b) => !b.archived).sort((a, b) => a.boardD.localeCompare(b.boardD)); + log(`${owned.size} board(s) for this key, ${unarchived.length} unarchived`); + + const { strays, unclaimed } = classify(unarchived, patterns, shielded); + + log(`\n${unclaimed.length} unarchived board(s) NOT claimed by any harness in this tree — left alone:`); + for (const b of unclaimed) log(` ${b.boardD}${b.protected ? " (protected)" : ""}`); + + log(`\n${strays.length} unarchived stray(s) claimed by a harness in this tree:`); + for (const b of strays) log(` ${b.boardD} <- ${b.claimedBy.map((p) => p.source).join(", ")}`); + + if (strays.length === 0) { + log("\nnothing to archive."); + return; + } + if (!DO_ARCHIVE) { + log("\ndry run — pass --archive to publish the archive events."); + return; + } + + // A real nostr-native project directory is required to run `rd board + // archive` (it supplies the signing key, local durability log, and + // configured relays) but need NOT pin any of the stray boards' own + // coordinates — the command takes its target as an explicit argument, and + // the archive event for a foreign board-d lands harmlessly in this + // scratch project's own local log (see board_archive.go's header comment). + const tmp = mkdtempSync(path.join(os.tmpdir(), "rd-archive-strays-")); + try { + const rdBin = path.join(tmp, "rd"); + execFileSync("go", ["build", "-o", rdBin, "./cmd/rd"], { cwd: REPO_ROOT, stdio: "inherit" }); + + const home = path.join(tmp, "home"); + mkdirSync(home, { recursive: true }); + writeFileSync(path.join(home, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); + + const dir = path.join(tmp, "scratch"); + mkdirSync(path.join(dir, ".ready"), { recursive: true }); + writeFileSync( + path.join(dir, ".ready", "config.json"), + JSON.stringify({ project_name: "archive-strays-scratch", board: strays[0].coord, public: true }, null, 2), + ); + + let failures = 0; + for (const b of strays) { + try { + archiveBoard({ rdBin, cwd: dir, home, relay: RELAY, coord: b.coord }); + log(` archived ${b.coord}`); + } catch (err) { + failures++; + console.error(` FAILED to archive ${b.coord}: ${err.message}`); + } + } + + // READ THE MARKERS BACK. `rd board archive` exiting 0 says the command ran, + // not that the relay took the event — the same read-back the per-run guard + // does, for the same reason. + const after = latestBoardsOwnedBy(await fetchBoardEvents(RELAY), owner); + const stillLive = strays.filter((b) => !after.get(b.coord)?.archived); + for (const b of stillLive) console.error(` NOT ARCHIVED ON THE RELAY: ${b.coord}`); + failures += stillLive.length; + + const nowUnarchived = [...after.values()].filter((b) => !b.archived).length; + log(`\nowner's unarchived board count: ${unarchived.length} before, ${nowUnarchived} after`); + if (failures > 0) { + console.error(`${failures} archive(s) failed`); + process.exit(1); + } + log(`archived ${strays.length}/${strays.length} stray board(s)`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error(err.stack ?? err); + process.exit(1); +}); diff --git a/web/board/scripts/harness-cleanup.test.mjs b/web/board/scripts/harness-cleanup.test.mjs new file mode 100644 index 0000000..a8d10d6 --- /dev/null +++ b/web/board/scripts/harness-cleanup.test.mjs @@ -0,0 +1,123 @@ +/** + * harness-cleanup.test.mjs — ready-153's "EVERY live harness" clause, as a + * check that runs on every PR. + * + * throwaway-board.test.mjs proves the cleanup contract WORKS. This file proves + * every harness is BOUND to it. Both are needed, and the second is the one the + * previous two attempts lacked: live-stranger-walk.mjs merged (2640e40) with a + * `rd init` at line 590 and a `finally` clause that archived nothing, three + * days after the cleanup was "added to every harness". Nothing anywhere + * noticed, because the invariant lived in a commit message. + * + * The harness list is DERIVED FROM THE TREE, never from a list maintained + * here — a new live-*.mjs that provisions a board is covered the moment it is + * written, which is the failure mode this file exists for. + * + * These are source-shape assertions, deliberately. A live harness needs + * Chromium, a Go toolchain, the owner's signing key and a real relay; it can + * never run in CI. What CAN run in CI is "this file creates a board and does + * not hand it to the guard", and that is exactly the regression that shipped. + */ + +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +const SCRIPTS = path.resolve(import.meta.dirname); + +const harnesses = readdirSync(SCRIPTS) + .filter((f) => f.startsWith("live-") && f.endsWith(".mjs")) + .sort() + .map((f) => ({ name: f, src: readFileSync(path.join(SCRIPTS, f), "utf8") })); + +/** A harness provisions a throwaway board iff it runs `rd init` — that is the + * only command in the CLI that publishes a new kind-30301 board definition. */ +const createsBoard = (h) => /(^|[[,]\s*)"init"/m.test(h.src); + +/** Everything after the LAST `finally {` in the file: a cleanup that is not in + * the finally clause does not run when the harness fails partway, which is + * precisely the run that leaks. */ +function finallyClause(src) { + const i = src.lastIndexOf("} finally {"); + return i === -1 ? "" : src.slice(i); +} + +test("the harness list is derived from the tree and is not empty", () => { + expect(harnesses.map((h) => h.name)).toContain("live-stranger-walk.mjs"); + expect(harnesses.length).toBeGreaterThanOrEqual(5); +}); + +/** Every harness known to provision a board. Deliberately a FLOOR, not an + * exact list: a harness added tomorrow is covered by the describe.each below + * the moment it is written, but a harness that DROPS out of `createsBoard` + * because the detector drifted must fail here rather than quietly stop being + * checked. */ +const KNOWN_BOARD_CREATORS = [ + "live-cache.mjs", + "live-roundtrip-both-ways.mjs", + "live-stranger-walk.mjs", + "live-write-roundtrip.mjs", +]; + +test("every harness known to provision a board is still detected as one", () => { + const detected = harnesses.filter(createsBoard).map((h) => h.name); + for (const name of KNOWN_BOARD_CREATORS) expect(detected).toContain(name); +}); + +/** + * A SECOND, INDEPENDENT DETECTOR — because the first one was scoped to what its + * author already knew about. `createsBoard` looks for the `rd init` call; + * `namesABoard` looks for the `BOARD_D` the harness gives that board, which is + * the same fact reached by a different shape and is what stray-boards.mjs reads + * to recognise the strays on the relay. + * + * They must agree. A harness that names a board but never inits it (or the + * reverse) means one of the two detectors has drifted, and a drifted detector + * is how "EVERY live harness is covered" comes to be true by definition: the + * set shrinks to the files that still match, and the check goes green over a + * harness it stopped looking at. + */ +const namesABoard = (h) => /\bBOARD_D\s*=/.test(h.src); + +test("the `rd init` detector and the BOARD_D detector name the same harnesses", () => { + expect(harnesses.filter(namesABoard).map((h) => h.name)).toEqual(harnesses.filter(createsBoard).map((h) => h.name)); +}); + +describe.each(harnesses.filter(createsBoard).map((h) => [h.name, h]))( + "%s runs `rd init`, so it is bound to the ready-153 cleanup contract", + (_name, h) => { + test("imports the shared guard rather than hand-rolling cleanup", () => { + expect(h.src).toMatch(/import\s*{[^}]*openThrowawayBoardGuard[^}]*}\s*from\s*"\.\/throwaway-board\.mjs"/); + }); + + test("opens the guard, which takes the BEFORE board count", () => { + expect(h.src).toMatch(/await openThrowawayBoardGuard\(/); + }); + + test("registers its board-d BEFORE `rd init` runs, so a crashed init still cleans up", () => { + const register = h.src.search(/guard\.expect\(/); + const init = h.src.search(/(^|[[,]\s*)"init"/m); + expect(register).toBeGreaterThan(-1); + expect(init).toBeGreaterThan(-1); + expect(register).toBeLessThan(init); + }); + + test("closes the guard inside the `finally` clause, not on the happy path", () => { + expect(finallyClause(h.src)).toMatch(/guard\.close\(/); + }); + + test("counts the guard's failures toward its own exit code", () => { + // reportCleanup returns the failure count; a harness that ignores it + // exits 0 with a stray on the relay, which is the whole bug. + expect(finallyClause(h.src)).toMatch(/failures \+= reportCleanup\(/); + expect(h.src).toMatch(/process\.exit\(failures === 0 \? 0 : 1\)/); + }); + + test("has no leftover hand-rolled archive call that could drift from the contract", () => { + // The single `rd board archive` invocation lives in throwaway-board.mjs + // (archiveBoard). A second copy here is how the three harnesses' + // cleanups diverged in the first place. + expect(h.src).not.toMatch(/"board",\s*"archive"/); + }); + }, +); diff --git a/web/board/scripts/live-cache.mjs b/web/board/scripts/live-cache.mjs index 5dc6a70..0680b08 100644 --- a/web/board/scripts/live-cache.mjs +++ b/web/board/scripts/live-cache.mjs @@ -59,6 +59,12 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; +// ready-153: this harness provisions a throwaway board (PART B below runs `rd +// init`), so it is bound to the shared cleanup contract like every other live +// harness that does. See throwaway-board.mjs; the binding itself is asserted +// for every live-*.mjs in the tree by harness-cleanup.test.mjs. +import { openThrowawayBoardGuard, reportCleanup } from "./throwaway-board.mjs"; + const BOARD_DIR = path.resolve(import.meta.dirname, ".."); const REPO_ROOT = path.resolve(BOARD_DIR, "../.."); const CHROME = @@ -381,13 +387,25 @@ async function main() { const tmp = mkdtempSync(path.join(os.tmpdir(), "rd-fe4-cache-")); const cleanup = []; + // ready-153. The BEFORE board count, taken before anything is published: the + // guard is opened here so the count is honest even for `--only a`, which + // provisions no board at all. Nothing is registered with it yet — + // `guard.expect(BOARD_D)` happens in PART B, immediately before the `rd init` + // that publishes it, so a run that never reaches PART B has nothing to clean + // up and the guard says so rather than reporting a board that was never made. + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: identity.pubkey_hex, log }); + let failures = 0; + let rdBin = null; + let projectDir = null; + let writerHome = null; + try { log(`relay: ${RELAY}`); log(`viewer: ${identity.pubkey_hex}`); log(`scratch: ${tmp}`); step("build rd from this tree"); - const rdBin = path.join(tmp, "rd"); + rdBin = path.join(tmp, "rd"); execFileSync("go", ["build", "-o", rdBin, "./cmd/rd"], { cwd: REPO_ROOT, stdio: "inherit" }); step("build the shipped bundle and serve it"); @@ -546,11 +564,15 @@ async function main() { step("PART B — condition 4, by the method the condition names: the rd CLI mutates, the OPEN page converges"); log("\n provision a throwaway PUBLIC board (nothing here touches a real project board)"); - const writerHome = path.join(tmp, "writer-home"); + writerHome = path.join(tmp, "writer-home"); mkdirSync(writerHome, { recursive: true }); writeFileSync(path.join(writerHome, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); - const projectDir = path.join(tmp, BOARD_D); + projectDir = path.join(tmp, BOARD_D); mkdirSync(projectDir, { recursive: true }); + // ready-153: register BEFORE `rd init`, so a run that dies inside init — + // the run with a published board and no coordinate to hand — still cleans + // up. The guard derives what to archive from the relay, not from initOut. + guard.expect(BOARD_D); const initOut = JSON.parse( rd(rdBin, projectDir, writerHome, [ "init", @@ -680,8 +702,15 @@ async function main() { for (const r of results) log(` ${r.ok ? "PASS" : "FAIL"} ${r.name}${r.detail ? ` — ${r.detail}` : ""}`); const bad = results.filter((r) => !r.ok).length; log(`\n${results.length - bad}/${results.length} assertions held`); - process.exitCode = bad === 0 ? 0 : 1; + failures += bad; } finally { + // ready-153: archive whatever this run put on the relay and prove the + // owner's board count is back where it started, BEFORE the scratch dir goes + // away — `rd board archive` needs the project dir and writer home that live + // in it. Its failures count toward this run's exit code; a cleanup that + // only warns exits 0 with a permanent stray in the owner's portfolio. + failures += reportCleanup(await guard.close({ rdBin, cwd: projectDir, home: writerHome }), log); + for (const fn of cleanup.reverse()) { try { fn(); @@ -692,6 +721,7 @@ async function main() { if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } + process.exit(failures === 0 ? 0 : 1); } /** titleOf is what the LIVE DOM says this item's title is, right now. */ diff --git a/web/board/scripts/live-roundtrip-both-ways.mjs b/web/board/scripts/live-roundtrip-both-ways.mjs index 9dbd23d..216933e 100644 --- a/web/board/scripts/live-roundtrip-both-ways.mjs +++ b/web/board/scripts/live-roundtrip-both-ways.mjs @@ -83,6 +83,11 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; import { createServer } from "vite"; +// ready-153: the throwaway board this run provisions must not survive it. The +// contract — archive, prove the marker landed on the relay, and bracket the +// run with the owner's unarchived board count — lives in one module that +// scripts/throwaway-board.test.mjs exercises hermetically in CI. +import { openThrowawayBoardGuard, reportCleanup } from "./throwaway-board.mjs"; const BOARD_DIR = path.resolve(import.meta.dirname, ".."); const REPO_ROOT = path.resolve(BOARD_DIR, "../.."); @@ -401,10 +406,28 @@ async function main() { const tmp = mkdtempSync(path.join(os.tmpdir(), "rd-4359-")); const cleanup = []; + // Hoisted out of the try block so the `finally` clause below can archive the + // throwaway board EVEN WHEN THE SCRIPT FAILS PARTWAY — a red run must not + // leave a permanent stray in the owner's portfolio (ready-153). + let rdBin, projectDir, writerHome, coord; + + const idPath = path.join( + process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), + "nostr-identity.json", + ); + const identity = JSON.parse(readFileSync(idPath, "utf8")); + + // ready-153: opened BEFORE anything is created, so it holds the owner's + // unarchived board count as it was before this process existed, and BOARD_D + // is registered before `rd init` runs — a run that dies inside `rd init` has + // no coordinate to hand back but may already have published the board. + step("ready-153: count this key's unarchived boards BEFORE the run"); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: identity.pubkey_hex, log }); + guard.expect(BOARD_D); try { step("build rd from this tree"); - const rdBin = path.join(tmp, "rd"); + rdBin = path.join(tmp, "rd"); execFileSync("go", ["build", "-o", rdBin, "./cmd/rd"], { cwd: REPO_ROOT, stdio: "inherit" }); step("prepare the injected signer (REAL secp256k1 — see this file's header)"); @@ -420,16 +443,11 @@ async function main() { const esbuild = (await import("esbuild")).default ?? (await import("esbuild")); step(`provision the throwaway board (owner key, ${CONFIDENTIAL ? "CONFIDENTIAL" : "PUBLIC"}, fresh board-d)`); - const writerHome = path.join(tmp, "writer-home"); + writerHome = path.join(tmp, "writer-home"); mkdirSync(writerHome, { recursive: true }); - const idPath = path.join( - process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), - "nostr-identity.json", - ); - const identity = JSON.parse(readFileSync(idPath, "utf8")); writeFileSync(path.join(writerHome, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); - const projectDir = path.join(tmp, BOARD_D); + projectDir = path.join(tmp, BOARD_D); mkdirSync(projectDir, { recursive: true }); const initOut = JSON.parse( rd(rdBin, projectDir, writerHome, [ @@ -443,7 +461,7 @@ async function main() { "--json", ]), ); - const coord = initOut.board; + coord = initOut.board; const owner = initOut.owner; log(` board ${coord}`); @@ -729,6 +747,21 @@ async function main() { /* best effort */ } } + // ready-153: this board exists ONLY to be thrown away. Cleaned up here, in + // `finally` rather than after a happy-path return, so a run that fails + // partway (or even before Chromium ever opens) still does not leave a + // permanent stray in the owner's portfolio — and BEFORE the rmSync below, + // because `rd board archive` runs inside the project dir under tmp. + // + // guard.close() archives whatever the RELAY says this run published, + // reads the archived marker back off the relay (an `rd board archive` + // that exits 0 without the event landing is not success), and re-checks + // the owner's unarchived board count against the one taken before the + // run. Its failures count toward this script's exit code — an archive + // problem that is only logged lets the process exit 0 with the stray + // still in the portfolio. + step("ready-153: archive the throwaway board, and prove the owner's board count is unchanged"); + failures += reportCleanup(await guard.close({ rdBin, cwd: projectDir, home: writerHome }), log); if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } diff --git a/web/board/scripts/live-stranger-walk.mjs b/web/board/scripts/live-stranger-walk.mjs index f2e829a..52f4790 100644 --- a/web/board/scripts/live-stranger-walk.mjs +++ b/web/board/scripts/live-stranger-walk.mjs @@ -87,6 +87,11 @@ import https from "node:https"; import os from "node:os"; import path from "node:path"; import { createServer } from "vite"; +// ready-153: the throwaway board this run provisions must not survive it. The +// contract — archive, prove the marker landed on the relay, and bracket the +// run with the owner's unarchived board count — lives in one module that +// scripts/throwaway-board.test.mjs exercises hermetically in CI. +import { openThrowawayBoardGuard, reportCleanup } from "./throwaway-board.mjs"; const BOARD_DIR = path.resolve(import.meta.dirname, ".."); const REPO_ROOT = path.resolve(BOARD_DIR, "../.."); @@ -552,10 +557,30 @@ async function main() { const tmp = mkdtempSync(path.join(os.tmpdir(), "rd-48f-")); const cleanup = []; + // Hoisted out of the try block so the `finally` clause below can archive the + // throwaway board EVEN WHEN THE SCRIPT FAILS PARTWAY — a red run must not + // leave a permanent stray in the owner's portfolio (ready-153). + let rdBin, projectDir, writerHome; + + const idPath = path.join( + process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), + "nostr-identity.json", + ); + const identity = JSON.parse(readFileSync(idPath, "utf8")); + + // ready-153: opened BEFORE anything is created, so it holds the owner's + // unarchived board count as it was before this process existed, and BOARD_D + // is registered before `rd init` runs — a run that dies inside `rd init` has + // no coordinate to hand back but may already have published the board. + // Until this landed, every run of this script added one permanent node to + // the owner's portfolio and nothing removed it. + step("ready-153: count this key's unarchived boards BEFORE the run"); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: identity.pubkey_hex, log }); + guard.expect(BOARD_D); try { step("build rd from this tree"); - const rdBin = path.join(tmp, "rd"); + rdBin = path.join(tmp, "rd"); execFileSync("go", ["build", "-o", rdBin, "./cmd/rd"], { cwd: REPO_ROOT, stdio: "inherit" }); const vite = await createServer({ @@ -574,16 +599,11 @@ async function main() { log(` ${ext.name} ${ext.version} @ ${NOS2X_COMMIT.slice(0, 12)} -> ${ext.dir}`); step("provision the throwaway CONFIDENTIAL board (owner key, fresh board-d)"); - const writerHome = path.join(tmp, "writer-home"); + writerHome = path.join(tmp, "writer-home"); mkdirSync(writerHome, { recursive: true }); - const idPath = path.join( - process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), - "nostr-identity.json", - ); - const identity = JSON.parse(readFileSync(idPath, "utf8")); writeFileSync(path.join(writerHome, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); - const projectDir = path.join(tmp, BOARD_D); + projectDir = path.join(tmp, BOARD_D); mkdirSync(projectDir, { recursive: true }); const initOut = JSON.parse( rd(rdBin, projectDir, writerHome, [ @@ -907,6 +927,21 @@ async function main() { /* best effort */ } } + // ready-153: this board exists ONLY to be thrown away. Cleaned up here, in + // `finally` rather than after a happy-path return, so a run that fails + // partway (or even before Chromium ever opens) still does not leave a + // permanent stray in the owner's portfolio — and BEFORE the rmSync below, + // because `rd board archive` runs inside the project dir under tmp. + // + // guard.close() archives whatever the RELAY says this run published, + // reads the archived marker back off the relay (an `rd board archive` + // that exits 0 without the event landing is not success), and re-checks + // the owner's unarchived board count against the one taken before the + // run. Its failures count toward this script's exit code — an archive + // problem that is only logged lets the process exit 0 with the stray + // still in the portfolio. + step("ready-153: archive the throwaway board, and prove the owner's board count is unchanged"); + failures += reportCleanup(await guard.close({ rdBin, cwd: projectDir, home: writerHome }), log); if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } diff --git a/web/board/scripts/live-write-roundtrip.mjs b/web/board/scripts/live-write-roundtrip.mjs index 107569d..ae347af 100644 --- a/web/board/scripts/live-write-roundtrip.mjs +++ b/web/board/scripts/live-write-roundtrip.mjs @@ -173,6 +173,11 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; import { createServer } from "vite"; +// ready-153: the throwaway board this run provisions must not survive it. The +// contract — archive, prove the marker landed on the relay, and bracket the +// run with the owner's unarchived board count — lives in one module that +// scripts/throwaway-board.test.mjs exercises hermetically in CI. +import { openThrowawayBoardGuard, reportCleanup } from "./throwaway-board.mjs"; const BOARD_DIR = path.resolve(import.meta.dirname, ".."); const REPO_ROOT = path.resolve(BOARD_DIR, "../.."); @@ -769,10 +774,28 @@ async function main() { const tmp = mkdtempSync(path.join(os.tmpdir(), "rd-b2b-")); const cleanup = []; let failures = 0; + // Hoisted out of the try block so the `finally` clause below can archive the + // throwaway board EVEN WHEN THE SCRIPT FAILS PARTWAY — a red run must not + // leave a permanent stray in the owner's portfolio (ready-153). + let rdBin, projectDir, writerHome, coord; + + const idPath = path.join( + process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), + "nostr-identity.json", + ); + const identity = JSON.parse(readFileSync(idPath, "utf8")); + + // ready-153: opened BEFORE anything is created, so it holds the owner's + // unarchived board count as it was before this process existed, and BOARD_D + // is registered before `rd init` runs — a run that dies inside `rd init` has + // no coordinate to hand back but may already have published the board. + step("ready-153: count this key's unarchived boards BEFORE the run"); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: identity.pubkey_hex, log }); + guard.expect(BOARD_D); try { step("build rd from this tree"); - const rdBin = path.join(tmp, "rd"); + rdBin = path.join(tmp, "rd"); execFileSync("go", ["build", "-o", rdBin, "./cmd/rd"], { cwd: REPO_ROOT, stdio: "inherit" }); // Stood up BEFORE the board is provisioned (ready-191) because mintKey draws @@ -791,16 +814,11 @@ async function main() { const esbuild = (await import("esbuild")).default ?? (await import("esbuild")); step(`provision the throwaway board (owner key, ${CONFIDENTIAL ? "CONFIDENTIAL" : "PUBLIC"}, fresh board-d)`); - const writerHome = path.join(tmp, "writer-home"); + writerHome = path.join(tmp, "writer-home"); mkdirSync(writerHome, { recursive: true }); - const idPath = path.join( - process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), - "nostr-identity.json", - ); - const identity = JSON.parse(readFileSync(idPath, "utf8")); writeFileSync(path.join(writerHome, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); - const projectDir = path.join(tmp, BOARD_D); + projectDir = path.join(tmp, BOARD_D); mkdirSync(projectDir, { recursive: true }); const initOut = JSON.parse( rd(rdBin, projectDir, writerHome, [ @@ -817,7 +835,7 @@ async function main() { "--json", ]), ); - const coord = initOut.board; + coord = initOut.board; const owner = initOut.owner; log(` board ${coord}`); @@ -1954,6 +1972,21 @@ async function main() { /* best effort */ } } + // ready-153: this board exists ONLY to be thrown away. Cleaned up here, in + // `finally` rather than after a happy-path return, so a run that fails + // partway (or even before Chromium ever opens) still does not leave a + // permanent stray in the owner's portfolio — and BEFORE the rmSync below, + // because `rd board archive` runs inside the project dir under tmp. + // + // guard.close() archives whatever the RELAY says this run published, + // reads the archived marker back off the relay (an `rd board archive` + // that exits 0 without the event landing is not success), and re-checks + // the owner's unarchived board count against the one taken before the + // run. Its failures count toward this script's exit code — an archive + // problem that is only logged lets the process exit 0 with the stray + // still in the portfolio. + step("ready-153: archive the throwaway board, and prove the owner's board count is unchanged"); + failures += reportCleanup(await guard.close({ rdBin, cwd: projectDir, home: writerHome }), log); if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } diff --git a/web/board/scripts/stray-boards.mjs b/web/board/scripts/stray-boards.mjs new file mode 100644 index 0000000..d6997d3 --- /dev/null +++ b/web/board/scripts/stray-boards.mjs @@ -0,0 +1,225 @@ +/** + * stray-boards.mjs — "which of the boards this key owns were made by a test?", + * answered from the TREE rather than from a list somebody typed (ready-153). + * + * WHY THIS EXISTS. The first version of the stray sweep carried a hand-written + * prefix list — `["b2blive", "b4359", "c191live", "c4359", "s48f"]` — and that + * list was exactly the set of files that round's diff had touched. Measured + * against the live relay on 2026-07-30 it covered 7 of the owner's 17 stray + * boards and missed 10 `ready-livetest-*` outright, because those come from a + * Go live-relay test (pkg/sync/live_relay_key_test.go's `liveTestBoardD`) and + * no one had thought about the Go side. A sweep whose scope is "the files I + * edited" reports a clean portfolio and leaves the strays where they are. + * + * So the patterns are DERIVED. Every source file in this repo that can publish + * a board to a live relay is found by shape, its board-d expression is read out + * of the source, and the run-varying parts become wildcards: + * + * web/board/scripts/live-*.mjs `const BOARD_D = `fe4${RUN}`;` -> ^fe4.*$ + * *_test.go w/ RD_NOSTR_LIVE_RELAY `fmt.Sprintf("ready-7ec-live-%d"` -> ^ready-7ec-live-.*$ + * + * A harness added tomorrow is covered the day it is written, and a harness + * whose board-d nobody here knows about still shows up — as an UNCLAIMED board, + * printed rather than silently skipped, which is the only honest answer for a + * board this repo cannot account for. + * + * NOTHING HERE ARCHIVES ANYTHING. This module only classifies; the caller + * decides. See archive-stray-boards.mjs. + */ + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +/** + * A pattern must have this much literal text before its first wildcard to be + * usable. `fmt.Sprintf("%s-live-%d")` would otherwise derive `^.*-live-.*$`, + * which is not evidence about any particular board — it is a wildcard wearing a + * pattern's clothes, and it would match real project boards. + */ +export const MIN_LITERAL_PREFIX = 3; + +/** Source files that can put a board on a live relay, found by shape. */ +export function harnessSources(repoRoot) { + const out = []; + + const scripts = path.join(repoRoot, "web/board/scripts"); + for (const f of safeReaddir(scripts)) { + if (!f.startsWith("live-") || !f.endsWith(".mjs")) continue; + out.push({ file: path.relative(repoRoot, path.join(scripts, f)), src: readFileSync(path.join(scripts, f), "utf8") }); + } + + // A Go test only reaches a real relay if it is gated on RD_NOSTR_LIVE_RELAY — + // that env var IS the live-test convention in this repo, so "contains it" is + // the shape, not a list of directories. + for (const p of walkGoTests(repoRoot)) { + const src = readFileSync(p, "utf8"); + if (!src.includes("RD_NOSTR_LIVE_RELAY")) continue; + out.push({ file: path.relative(repoRoot, p), src }); + } + + return out.sort((a, b) => a.file.localeCompare(b.file)); +} + +function safeReaddir(dir) { + try { + return readdirSync(dir).sort(); + } catch { + return []; + } +} + +function* walkGoTests(dir, depth = 0) { + if (depth > 6) return; + for (const name of safeReaddir(dir)) { + if (name === "node_modules" || name === ".git" || name === ".claude" || name === "dist") continue; + const p = path.join(dir, name); + let st; + try { + st = statSync(p); + } catch { + continue; + } + if (st.isDirectory()) yield* walkGoTests(p, depth + 1); + else if (name.endsWith("_test.go")) yield p; + } +} + +/** + * A `${...}` hole in a JS template literal expands to the string literals it + * contains, and to a bare wildcard when it contains none: + * + * `${CONFIDENTIAL ? "c191live" : "b2blive"}${RUN}` -> c191live.* AND b2blive.* + * + * Two separate patterns, not one alternation, because a pattern's usability is + * judged on the literal text before its first wildcard, and `(c191live|b2blive).*` + * has none. Collapsing the ternary to `.*` instead would derive `^.*.*$` — a + * pattern that matches every board this key owns, the owner's real projects + * included. That is the difference between a derived pattern and a dangerous + * one. + */ +function holeOptions(inner) { + const lits = [...inner.matchAll(/"([^"]*)"/g)].map((m) => m[1]).filter(Boolean); + if (lits.length === 0) return [".*"]; + return lits.map(escapeRe); +} + +const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** A Go format verb (%d, %s, %v, %02d…) is a run-varying hole. */ +function goFormatToRegex(lit) { + let out = ""; + let i = 0; + while (i < lit.length) { + const m = /^%[-+ #0]*[\d.]*[a-zA-Z]/.exec(lit.slice(i)); + if (m) { + out += ".*"; + i += m[0].length; + } else { + out += escapeRe(lit[i]); + i += 1; + } + } + return out; +} + +/** jsTemplateToRegexes returns one regex body per combination of hole choices. */ +function jsTemplateToRegexes(tpl) { + let outs = [""]; + let i = 0; + const append = (opts) => { + outs = outs.flatMap((o) => opts.map((x) => o + x)); + }; + while (i < tpl.length) { + if (tpl.startsWith("${", i)) { + let depth = 1; + let j = i + 2; + for (; j < tpl.length && depth > 0; j++) { + if (tpl[j] === "{") depth++; + else if (tpl[j] === "}") depth--; + } + append(holeOptions(tpl.slice(i + 2, j - 1))); + i = j; + } else { + append([escapeRe(tpl[i])]); + i += 1; + } + } + return outs; +} + +const lineOf = (src, index) => src.slice(0, index).split("\n").length; + +/** + * boardDPatterns reads every board-d expression out of one harness source. + * + * Returns `{ pattern, body, source, raw }` records. `pattern` is anchored; + * `body` is the un-anchored regex text, kept so a caller (and the test suite) + * can see what was derived. + */ +export function boardDPatterns({ file, src }) { + const found = []; + const add = (bodies, raw, index) => { + for (const body of bodies) { + const prefix = /^((?:\\.|[^\\.([])*)/.exec(body)?.[1] ?? ""; + const literalPrefixLen = prefix.replace(/\\(.)/g, "$1").length; + found.push({ + body, + pattern: new RegExp(`^${body}$`), + raw, + source: `${file}:${lineOf(src, index)}`, + usable: literalPrefixLen >= MIN_LITERAL_PREFIX, + literalPrefixLen, + exact: !body.includes(".*"), + }); + } + }; + + // JS live harness: `const BOARD_D = `...`;` or `const BOARD_D = "...";` + for (const m of src.matchAll(/\bBOARD_D\s*=\s*`([^`]*)`/g)) add(jsTemplateToRegexes(m[1]), m[0], m.index); + for (const m of src.matchAll(/\bBOARD_D\s*=\s*"([^"]*)"/g)) add([escapeRe(m[1])], m[0], m.index); + + // Go live test: a board-d built with fmt.Sprintf, or written as a literal on + // a BoardD field / boardD variable. + for (const m of src.matchAll(/\b(?:boardD|BoardD|board|d)\s*(?::=|=|:)\s*fmt\.Sprintf\("([^"]+)"/g)) + add([goFormatToRegex(m[1])], m[0], m.index); + for (const m of src.matchAll(/\b(?:boardD|BoardD)\s*(?::=|=|:)\s*"([^"]+)"/g)) add([escapeRe(m[1])], m[0], m.index); + + return found; +} + +/** + * strayPatterns is every usable board-d pattern this repo can produce, with the + * source line each one came from — provenance, so a board about to be archived + * can be traced back to the test that made it. + */ +export function strayPatterns(repoRoot) { + const out = []; + for (const s of harnessSources(repoRoot)) out.push(...boardDPatterns(s)); + return out; +} + +/** + * classify splits the boards a key owns into `strays` (a tree-derived pattern + * claims it, with the source that claims it) and `unclaimed` (nothing in the + * tree accounts for it — the owner's real projects, and any harness this repo + * does not contain). + * + * `protectedDs` are board-ds that are NEVER strays whatever the patterns say. + * A live test that writes `BoardD: "ready"` in a comparison would otherwise + * derive `^ready$` and put this repo's own production board on the list. + */ +export function classify(boards, patterns, protectedDs = []) { + const shielded = new Set(protectedDs); + const strays = []; + const unclaimed = []; + for (const b of boards) { + if (shielded.has(b.boardD)) { + unclaimed.push({ ...b, protected: true }); + continue; + } + const claimedBy = patterns.filter((p) => p.usable && p.pattern.test(b.boardD)); + if (claimedBy.length > 0) strays.push({ ...b, claimedBy }); + else unclaimed.push({ ...b, protected: false }); + } + return { strays, unclaimed }; +} diff --git a/web/board/scripts/stray-boards.test.mjs b/web/board/scripts/stray-boards.test.mjs new file mode 100644 index 0000000..832e9c4 --- /dev/null +++ b/web/board/scripts/stray-boards.test.mjs @@ -0,0 +1,163 @@ +/** + * stray-boards.test.mjs — the sweep's classifier, checked against the boards + * that were ACTUALLY on wss://relay.3dl.network (ready-153). + * + * WHY THE FIXTURE IS A REAL WALK. The previous version of the sweep carried a + * hand-typed prefix list and no test at all, and it "worked" — it archived + * every board it had been told about and reported the portfolio clean, while + * 17 strays sat in it, 10 of them from a Go live test the list's author had + * never looked at. A classifier tested only against examples its author + * invented reproduces exactly that blind spot. + * + * So the fixture below is the verbatim output of a live paged kind-30301 walk + * of the owner's key on 2026-07-30 (42 unarchived boards), and the expected + * split is the one an operator independently walked and reported. The patterns + * are NOT in the fixture: they are re-derived from this repo's tree on every + * run, so a harness whose board-d naming changes, or a new live test that + * starts publishing boards, is exercised against real board names rather than + * against a story about them. + */ + +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { boardDPatterns, classify, harnessSources, strayPatterns, MIN_LITERAL_PREFIX } from "./stray-boards.mjs"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); +const OWNER = "a9f766ae56bbf466d2d361e5b1788b7cd689fd8e3b418e35b002b313f478db25"; + +/** The owner's 25 real project boards, as walked live on 2026-07-30. */ +const REAL_PROJECTS = [ + "3dl", + "3dlbooks", + "agenticinternet", + "agenticinternetops", + "analyst0", + "augur", + "automataisland", + "dontguess", + "enterpriseaiframework", + "forge", + "galtrader", + "mainframe", + "mallcoppro", + "nalu", + "nostrrelay", + "olmo3dl", + "os", + "pcjsvax", + "producer", + "proj", + "ready", + "resonant", + "social", + "vat", + "website", +]; + +/** The 17 test-generated boards in the same walk. */ +const STRAYS = [ + "fe4ms7w3eif", + "fe4ms7xd7ir", + "fe4ms7z4rp7", + "ready-7ec-live-1785435017380425687", + "ready-7ec-live-1785435017380425687-other", + "ready-82c-live", + "ready-866-live-1785435016895244229", + "ready-livetest-1785434740827693994", + "ready-livetest-1785434742353373698", + "ready-livetest-1785434896387861513", + "ready-livetest-1785435020678889082", + "ready-livetest-1785435023904464777", + "ready-livetest-1785435030107422940", + "ready-livetest-1785435031736030172", + "ready-livetest-1785435055151740338", + "ready-livetest-1785435329509598406", + "ready-livetest-1785435332073358502", +]; + +const board = (d) => ({ boardD: d, coord: `30301:${OWNER}:${d}`, archived: false }); +const patterns = () => strayPatterns(REPO_ROOT).filter((p) => p.usable); +const run = () => classify([...REAL_PROJECTS, ...STRAYS].map(board), patterns(), ["ready"]); + +describe("the stray set is derived from the tree, against a real live walk", () => { + test("every one of the 17 boards the live walk found is claimed by a source line in this tree", () => { + const { strays } = run(); + expect(strays.map((b) => b.boardD).sort()).toEqual([...STRAYS].sort()); + }); + + test("each stray names the file and line that made it, so an archive is auditable", () => { + for (const b of run().strays) { + expect(b.claimedBy.length).toBeGreaterThan(0); + for (const c of b.claimedBy) expect(c.source).toMatch(/^[\w./-]+:\d+$/); + } + }); + + test("no real project board is classified as a stray", () => { + const { strays, unclaimed } = run(); + expect(strays.map((b) => b.boardD).filter((d) => REAL_PROJECTS.includes(d))).toEqual([]); + expect(unclaimed.map((b) => b.boardD).sort()).toEqual([...REAL_PROJECTS].sort()); + }); + + test("the Go live tests are in scope, not just the .mjs harnesses this item's diff touched", () => { + // The whole failure this file exists for: 10 of the 17 came from + // pkg/sync/live_relay_key_test.go, and the previous prefix list — scoped to + // web/board/scripts — did not mention it. + const sources = new Set(run().strays.flatMap((b) => b.claimedBy.map((c) => c.source.split(":")[0]))); + expect([...sources].some((f) => f.endsWith("_test.go"))).toBe(true); + expect([...sources].some((f) => f.startsWith("web/board/scripts/"))).toBe(true); + }); +}); + +describe("the shield, which is load-bearing and not decorative", () => { + test("this repo's own production board-d IS derived as a pattern — the shield is what stops it", () => { + // Several live-relay tests write `BoardD: "ready"`, so the derivation + // legitimately produces ^ready$. Without the protected list, the sweep's + // first act would be to archive the board it is run to clean up. + expect(patterns().some((p) => p.pattern.test("ready"))).toBe(true); + expect(classify([board("ready")], patterns(), ["ready"]).strays).toEqual([]); + expect(classify([board("ready")], patterns(), []).strays.map((b) => b.boardD)).toEqual(["ready"]); + }); +}); + +describe("pattern derivation", () => { + test("a ternary board-d expands to one pattern per branch, never to a bare wildcard", () => { + const found = boardDPatterns({ + file: "web/board/scripts/live-x.mjs", + src: 'const BOARD_D = `${CONFIDENTIAL ? "c191live" : "b2blive"}${RUN}`;', + }); + expect(found.map((p) => p.body).sort()).toEqual(["b2blive.*", "c191live.*"]); + expect(found.every((p) => p.usable)).toBe(true); + // The dangerous derivation this replaced: collapsing the ternary to `.*` + // too would give ^.*.*$, which matches every board the key owns. + expect(found.some((p) => p.pattern.test("galtrader"))).toBe(false); + }); + + test("a Go format verb becomes the wildcard, and the literal before it is kept", () => { + const found = boardDPatterns({ + file: "pkg/sync/x_test.go", + src: '\tboardD := fmt.Sprintf("ready-7ec-live-%d", run)\n', + }); + expect(found.map((p) => p.body)).toEqual(["ready-7ec-live-.*"]); + expect(found[0].pattern.test("ready-7ec-live-1785435017380425687")).toBe(true); + expect(found[0].pattern.test("ready")).toBe(false); + }); + + test("a pattern with too little literal text to be evidence is not usable", () => { + const found = boardDPatterns({ + file: "pkg/sync/x_test.go", + src: '\tboardD := fmt.Sprintf("%s-live-%d", who, run)\n', + }); + expect(found[0].usable).toBe(false); + expect(MIN_LITERAL_PREFIX).toBeGreaterThan(0); + }); + + test("harness sources are found by shape: live-*.mjs, and Go tests gated on RD_NOSTR_LIVE_RELAY", () => { + const files = harnessSources(REPO_ROOT).map((s) => s.file); + expect(files).toContain("web/board/scripts/live-cache.mjs"); + expect(files).toContain("pkg/sync/live_relay_key_test.go"); + // A Go test that never touches a live relay must not contribute patterns — + // hermetic tests use invented board names freely. + expect(files.some((f) => f.endsWith("pkg/sync/boardarchive_test.go"))).toBe(false); + }); +}); diff --git a/web/board/scripts/throwaway-board.mjs b/web/board/scripts/throwaway-board.mjs new file mode 100644 index 0000000..263fe89 --- /dev/null +++ b/web/board/scripts/throwaway-board.mjs @@ -0,0 +1,468 @@ +/** + * throwaway-board.mjs — the cleanup contract every live harness that runs + * `rd init` is bound to (ready-153). + * + * THE PROBLEM. A live harness provisions a fresh kind-30301 board per run so it + * has something disposable to write to. Before this module, those boards were + * never removed: 44 of the 75 nodes in the owner's portfolio were per-run + * throwaway boards published by the test suite itself, and every future run + * added another, permanently, to the product's primary view. + * + * WHY THIS IS A MODULE AND NOT A LINE IN EACH HARNESS. The first two attempts + * at this fix were a `finally`-block one-liner per script. Both were untestable + * in CI (a live harness needs Chromium, a real relay and a real key), both + * drifted — one harness merged with no cleanup at all — and both had the same + * three silent leak paths: + * + * 1. a failed `rd board archive` was logged as a WARNING and the run still + * exited 0, leaving the stray behind exactly as if no cleanup existed; + * 2. nothing read the archived marker back, so an `rd board archive` that + * exits 0 without the event reaching the relay looked like success; + * 3. the cleanup was guarded on a local `coord` variable assigned from + * `JSON.parse(rd init --json)`, so any failure at or before that parse — + * the exact case a `finally` block exists for — skipped the archive while + * the board itself was already published and permanent. + * + * All three are closed here, in ONE place, by deriving everything from the + * RELAY rather than from the harness's local variables: + * + * - what to archive is whatever the relay says this key owns under the + * board-ds this run registered — no local `coord` required, so a run that + * dies mid-`rd init` still cleans up (leak 3) — and it is RE-DERIVED ON + * EVERY POLL, so a board the relay had not propagated yet at the first + * read is archived when it does appear; + * - `rd board archive` failing is a recorded failure, never a warning + * (leak 1); + * - the archived marker is READ BACK off the relay after the command, and + * the run fails if it is not there, whatever the command's exit code + * said (leak 2); + * - and the whole run is bracketed by a count of this key's unarchived + * boards taken before it starts and after cleanup finishes. They must + * agree. That is the item's done condition as an executable assertion. + * + * THE COUNT INVARIANT MUST NOT BE ABLE TO PASS FOR THE WRONG REASON. Two ways + * it could, both closed here and both asserted in the test suite, because an + * invariant that passes vacuously is the same defect class it was filed to + * catch: + * + * - a relay that answers nothing would otherwise read as a key that owns no + * boards: 0 before, 0 after, equal, green, on a dead relay. `wsReq` + * therefore REJECTS when no EOSE arrives rather than resolving with a + * partial page (see below), and a failed count read is a failed run; + * - the count cannot see a board the relay is withholding, so cleanup also + * requires positive sight of every board-d the run registered, and fails + * closed when the relay never serves one. + * + * Every one of those behaviours is covered hermetically by + * throwaway-board.test.mjs (vitest, in CI via board-ci.yml) against a fake + * relay and a fake `rd`. Delete the `exec(...)` call in archiveBoard below and + * that suite goes red — which is the point: before this module, deleting the + * cleanup turned nothing red anywhere. + * + * MEASURED, NOT ASSERTED (2026-07-30). Every live harness in web/board/scripts + * was run against wss://relay.3dl.network with this guard in place — the four + * that provision a board (live-write-roundtrip public and confidential, + * live-roundtrip-both-ways public and confidential, live-stranger-walk, + * live-cache and live-cache --only a) and the two that do not (live-portfolio, + * live-portfolio-timing). Every run printed `board count unchanged, 25 before + * and 25 after`, and the owner's portfolio measured 25 unarchived boards before + * the sweep and 25 after it. Two of those runs FAILED partway (an unrelated + * rail defect in live-write-roundtrip) and cleaned up anyway, which is the path + * a `finally` clause exists for and the one the earlier attempts never proved. + * + * WHAT THIS MODULE DOES NOT COVER, stated here because a reader will otherwise + * assume it does. Only the JS harnesses under web/board/scripts are bound to + * it. The Go live-relay tests (`RD_NOSTR_LIVE_RELAY=1`) publish their own + * per-run boards and have no cleanup: 11 of the 17 strays swept on 2026-07-30 + * came from pkg/sync's live tests, and their next run will leave more. + * `archive-stray-boards.mjs` recognises and sweeps them — that is cleanup, not + * prevention. See ready-153's follow-up item. + * + * RELAY MEASUREMENT DISCIPLINE. The walk below is kind-only, paged backwards + * with `until` at limit 500, and NEVER uses an `authors` filter: an `authors` + * filter silently under-returns on wss://relay.3dl.network (measured 42/56 vs + * 56/56 for the same walk, ready-5c5). Ownership is filtered client-side after + * the walk instead, and the test suite asserts every filter this module sends + * carries no `authors` key. + */ + +import { execFileSync } from "node:child_process"; + +export const KIND_BOARD = 30301; + +/** PAGE_LIMIT/MAX_PAGES: 500 per page (the relay's practical cap) walked + * backwards by `until`; 40 pages is 20k board events, far past this key's + * real count, and bounds a pathological relay. */ +export const PAGE_LIMIT = 500; +export const MAX_PAGES = 40; + +/** + * READ_BACK_TIMEOUT_MS — how long `close()` waits for the relay to start + * serving a board this run published, before reporting it as never served. + * + * THIS NUMBER IS MEASURED, NOT ASSUMED. It decides a real verdict — past it, a + * board the relay has not shown fails the run — so "30s feels like enough" + * written in a comment is not support for it. Measured against + * wss://relay.3dl.network on 2026-07-30, three trials, by + * `rd init` + first write + polling the same walk this module uses: + * + * board coordinate served after the run's first write: 139ms 157ms 170ms + * archived marker served after `rd board archive`: 148ms 153ms 193ms + * + * (The same run also re-confirmed that `rd init` plus `rd relay flush` puts + * NOTHING on the relay — the board first appears on the run's first write. All + * three trials: `after rd init alone, relay serves the board: false`.) + * + * Worst observed is 193ms, and a whole walk costs ~250ms, so both halves are + * one poll. 30000ms is ~150x the worst measurement — chosen as a margin over a + * measured figure rather than as a guess, and large enough that reaching it + * means the relay is not going to serve the board, not that it is slow today. + * If the relay's real behaviour ever moves, re-measure and move this with it; + * do not raise it to make a run pass. + */ +export const READ_BACK_TIMEOUT_MS = 30000; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * wsReq issues ONE REQ against a relay and resolves with everything received + * up to EOSE. This is the only part of the walk that touches a socket, and it + * is injectable (`deps.req`) so the paging, dedupe, latest-wins and + * archived-marker logic above it can be exercised without a network. + * + * EOSE OR NOTHING. A relay that never answers REJECTS; it does not resolve + * with whatever partial page arrived. This is load-bearing for the board-count + * invariant above: if a silent relay resolved to `[]`, then "this key owns no + * boards" and "nothing came back" would be the same value, `ownedBoardCount` + * would report a clean portfolio, and the bracket would read 0 before and 0 + * after and call an unresponsive relay a passing run. Only EOSE means the + * relay has finished answering; anything else is an error the caller must see. + */ +export async function wsReq(relay, filter, { timeoutMs = 45000 } = {}) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(relay); + const out = []; + const sub = `tbg${Math.random().toString(36).slice(2, 10)}`; + const t = setTimeout(() => { + try { + ws.close(); + } catch { + /* already closed */ + } + reject( + new Error( + `relay ${relay}: no EOSE within ${timeoutMs}ms (${out.length} event(s) received) — ` + + `a silent relay is not an empty one, and must not be read as "this key owns no boards"`, + ), + ); + }, timeoutMs); + ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, filter])); + ws.onmessage = (m) => { + const f = JSON.parse(m.data); + if (f[0] === "EVENT" && f[1] === sub) out.push(f[2]); + else if (f[0] === "EOSE" && f[1] === sub) { + clearTimeout(t); + try { + ws.send(JSON.stringify(["CLOSE", sub])); + ws.close(); + } catch { + /* already closed */ + } + resolve(out); + } + }; + ws.onerror = () => { + clearTimeout(t); + reject(new Error(`relay ${relay}: connection failed`)); + }; + }); +} + +/** + * fetchBoardEvents walks every kind-30301 event the relay will serve, paging + * backwards with `until`. Kind-only by design — see the header's relay + * measurement discipline note. + */ +export async function fetchBoardEvents(relay, { req = wsReq } = {}) { + const seen = new Map(); + let until; + for (let page = 0; page < MAX_PAGES; page++) { + const filter = { kinds: [KIND_BOARD], limit: PAGE_LIMIT }; + if (until !== undefined) filter.until = until; + const got = await req(relay, filter); + let added = 0; + let oldest = until; + for (const e of got) { + if (!seen.has(e.id)) { + seen.set(e.id, e); + added++; + } + if (oldest === undefined || e.created_at < oldest) oldest = e.created_at; + } + if (added === 0 || got.length < PAGE_LIMIT) break; + if (oldest === undefined) break; + until = oldest - 1; + } + return [...seen.values()]; +} + +/** + * latestBoardsOwnedBy applies the two rules the board page itself applies to a + * kind-30301 stream: latest-wins per coordinate (they are replaceable events, + * and an archive is a REPUBLISH of the same coordinate), then read the + * `archived` marker off that winner. Returns a Map coord -> descriptor. + */ +export function latestBoardsOwnedBy(events, ownerPubkey) { + const byCoord = new Map(); + for (const e of events) { + if (e.pubkey !== ownerPubkey) continue; + const d = (e.tags ?? []).find((t) => t[0] === "d")?.[1]; + if (!d) continue; + const coord = `${KIND_BOARD}:${e.pubkey}:${d}`; + const prev = byCoord.get(coord); + if (!prev || e.created_at > prev.createdAt) { + byCoord.set(coord, { + coord, + boardD: d, + createdAt: e.created_at, + archived: ((e.tags ?? []).find((t) => t[0] === "archived")?.[1] ?? "") !== "", + }); + } + } + return byCoord; +} + +/** ownedBoards: the live relay's answer to "which boards does this key own, + * and which of them are archived, right now". */ +export async function ownedBoards(relay, ownerPubkey, deps = {}) { + return latestBoardsOwnedBy(await fetchBoardEvents(relay, deps), ownerPubkey); +} + +/** ownedBoardCount: how many of them are still UNARCHIVED — i.e. how many + * nodes the owner's portfolio shows. This is the number ready-153's done + * condition is written in terms of. */ +export async function ownedBoardCount(relay, ownerPubkey, deps = {}) { + let n = 0; + for (const b of (await ownedBoards(relay, ownerPubkey, deps)).values()) if (!b.archived) n++; + return n; +} + +/** + * archiveBoard runs the REAL `rd board archive` against a throwaway board. + * + * This one call is the entire cleanup action, deliberately in a module a + * hermetic test can drive: delete the `exec(...)` line and every read-back and + * board-count assertion in throwaway-board.test.mjs goes red. + * + * `rd board archive` republishes only the board's own kind-30301 definition + * with an `archived` tag — every card and status event already written to the + * board is untouched. + */ +export function archiveBoard({ rdBin, cwd, home, relay, coord, exec = execFileSync }) { + if (!rdBin) throw new Error("no rd binary available to archive with"); + return exec(rdBin, ["board", "archive", coord], { + cwd, + env: { ...process.env, RD_HOME: home, RD_NOSTR_RELAY_URL: relay }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +/** + * openThrowawayBoardGuard takes the BEFORE measurement and hands back the + * handle a harness closes in its `finally` clause. + * + * Usage, and the shape scripts_cleanup.test.mjs enforces on every live + * harness that runs `rd init`: + * + * const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey, log }); + * guard.expect(BOARD_D); // BEFORE `rd init` runs + * try { ...the run... } finally { + * failures += (await guard.close({ rdBin, cwd: projectDir, home: writerHome })).failures.length; + * } + * + * `expect` takes the board-d, not the coordinate, precisely so it can be + * called before `rd init` — the run that dies inside `rd init` is the one that + * leaks, and it is the one with no coordinate to hand. + * + * The BEFORE count is not defended against: if the relay will not answer, + * this REJECTS and the harness dies here, before `rd init` has published + * anything. That is the correct end for a run whose cleanup could never have + * been verified — and it is why `wsReq` must reject on silence rather than + * hand back an empty page that would open the guard with a made-up count. + */ +export async function openThrowawayBoardGuard({ relay, ownerPubkey, log = () => {}, boardDs = [], deps = {} }) { + const expected = new Set(boardDs); + const before = await ownedBoardCount(relay, ownerPubkey, deps); + log(` ready-153: ${before} unarchived board(s) owned by this key, before this run`); + + return { + before, + expected, + expect(boardD) { + expected.add(boardD); + return boardD; + }, + + /** + * close archives every board this run put on the relay, PROVES each one + * carries the archived marker by reading it back, and re-checks the + * owner's unarchived board count against the before measurement. + * + * Deriving the work from the relay is also what makes "the run died early" + * correct rather than merely safe. `rd init` appends the kind-30301 board + * event to the LOCAL log only — measured 2026-07-30: init plus `rd relay + * flush` leaves nothing on the relay, and the board first appears there on + * the run's first write. So a run that died before writing anything has no + * board in the owner's portfolio to clean up, and this archives nothing + * instead of publishing an archive for a board that never existed — but it + * does NOT call that case clean, because from the relay's answers it is + * identical to a board that has simply not propagated yet. It waits the + * read-back window out and then reports the board as never served. + * + * Returns { ok, before, after, archived, failures } — `failures` is a list + * of human-readable reasons, and a harness MUST add its length to its own + * failure count. Nothing here throws for a cleanup problem: the caller is + * a `finally` clause and must not lose the original error. + */ + async close({ + rdBin, + cwd, + home, + exec = execFileSync, + readBackTimeoutMs = READ_BACK_TIMEOUT_MS, + readBackIntervalMs = 2000, + now = () => Date.now(), + wait = sleep, + } = {}) { + const failures = []; + const archived = []; + /** coords `rd board archive` has already been run for, so a board that + * takes several polls to prove is not archived once per poll. */ + const attempted = new Set(); + /** boardD -> coord, for every registered board the relay has ACTUALLY + * served at least once. Sticky: once seen, a board stays this run's + * responsibility even if a later read stops showing it. */ + const seen = new Map(); + const coordOf = (boardD) => `${KIND_BOARD}:${ownerPubkey}:${boardD}`; + + // ONE POLLED LOOP, and the ownership read is INSIDE it. An earlier + // version took the "what do I have to archive" snapshot once, before the + // loop, and only re-read to confirm the marker. That is wrong against + // any relay that is not instantaneous: a board this run published but + // that had not propagated by the first read was never in the snapshot, + // so it was never archived — and then the read-back had nothing to + // prove and the count invariant, which also cannot see a board the relay + // is withholding, agreed with itself. Green run, permanent stray. The + // work is therefore re-derived from the relay on every poll: + // + // - WHAT TO ARCHIVE comes from the relay, never from a local `coord` + // variable: a run that failed inside `rd init` has no coordinate but + // may very well have published the board (leak 3 in this file's + // header) — and a board that shows up on poll 3 is archived on + // poll 3; + // - THE MARKER IS READ BACK. `rd board archive` exiting 0 says the + // command ran, not that the relay took the event; + // - EVERY REGISTERED BOARD MUST BE SEEN. A board the relay never + // serves is not evidence of a board that was never published — it is + // the absence of evidence either way, and it fails closed. + // + // Polled rather than slept: a relay takes an event when it takes it, and + // a fixed sleep is either flaky or slow. The loop exits the moment every + // condition holds, and gives up at the deadline. + const deadline = now() + readBackTimeoutMs; + let after; + let unproven = []; + let unseen = [...expected]; + for (;;) { + let owned; + try { + owned = await ownedBoards(relay, ownerPubkey, deps); + } catch (err) { + failures.push(`could not read this key's boards back off ${relay}: ${err.message}`); + break; + } + + for (const b of owned.values()) if (expected.has(b.boardD)) seen.set(b.boardD, b.coord); + + for (const coord of seen.values()) { + const b = owned.get(coord); + if (!b || b.archived || attempted.has(coord)) continue; + attempted.add(coord); + try { + archiveBoard({ rdBin, cwd, home, relay, coord, exec }); + archived.push(coord); + log(` ready-153: archived throwaway board ${coord}`); + } catch (err) { + // A FAILURE, not a warning. An error that is only logged lets the + // process exit 0 with the stray still in the owner's portfolio — + // the exact outcome this guard exists to prevent. + failures.push(`could not archive throwaway board ${coord}: ${err.message}`); + } + } + + unproven = [...seen.values()].filter((coord) => !owned.get(coord)?.archived); + unseen = [...expected].filter((d) => !seen.has(d)); + after = 0; + for (const b of owned.values()) if (!b.archived) after++; + if (unproven.length === 0 && unseen.length === 0 && after === before) break; + if (now() >= deadline) break; + await wait(readBackIntervalMs); + } + + for (const coord of unproven) { + failures.push( + `the archived marker for ${coord} is NOT on ${relay} — the board is still in the owner's portfolio`, + ); + } + + // FAIL CLOSED ON A BOARD THAT NEVER APPEARED. The measured behaviour is + // that `rd init` writes the kind-30301 board to the LOCAL log only and + // the board reaches the relay on the run's first write, so a run that + // died before writing anything genuinely has nothing to clean up. But + // from here that run is indistinguishable from one whose board the relay + // simply has not served yet, and the two have opposite consequences: one + // is clean, the other is a permanent stray in the owner's portfolio. The + // guard waits the full read-back window for the board to appear and then + // reports it, deliberately preferring a loud line on a run that has + // already failed for another reason over a silent stray. + for (const boardD of unseen) { + failures.push( + `${relay} never served ${coordOf(boardD)}, the board this run registered — ` + + `cleanup cannot establish whether it was published, and a board it cannot see it cannot archive`, + ); + } + + // THE DONE CONDITION, AS AN ASSERTION: a full run leaves the owner's + // portfolio with the board count it started with. It is deliberately the + // WHOLE portfolio's count and not just this run's board — a cleanup that + // only checks its own coordinate cannot see a board it forgot to + // register. The corollary is that these harnesses must be run one at a + // time on a given key: a concurrent run's board is, correctly, a stray + // from this run's point of view. + if (after === undefined) { + /* the read-back never completed; the failure is already recorded */ + } else if (unseen.length > 0) { + /* the count agrees, but only because the relay is not showing a board + * this run registered. An invariant cannot see what it is not shown; + * the unseen failure above is the honest report. */ + } else if (after !== before) { + failures.push( + `the run changed the owner's unarchived board count: ${before} before, ${after} after — it left a stray behind`, + ); + } else { + log(` ready-153: board count unchanged, ${before} before and ${after} after`); + } + + return { ok: failures.length === 0, before, after, archived, failures }; + }, + }; +} + +/** reportCleanup prints a guard result the way the harnesses' own summaries + * print theirs, and returns the number to add to `failures`. */ +export function reportCleanup(result, log = console.log) { + log(` ${result.ok ? "PASS" : "FAIL"} ready-153: the run left the owner's board count exactly where it started`); + for (const f of result.failures) console.error(`FAILURE: ${f}`); + return result.failures.length; +} diff --git a/web/board/scripts/throwaway-board.test.mjs b/web/board/scripts/throwaway-board.test.mjs new file mode 100644 index 0000000..08474a8 --- /dev/null +++ b/web/board/scripts/throwaway-board.test.mjs @@ -0,0 +1,652 @@ +/** + * throwaway-board.test.mjs — the CI-executable half of ready-153. + * + * The item's done condition is "a full run of every live harness leaves the + * owner's portfolio with the same board count it started with". Twice that was + * argued in a commit message from a manual before/after count. A number in a + * commit message is not a test: it does not run again, and nothing goes red + * when the cleanup stops running. This suite is the assertion. + * + * WHAT IS REAL HERE. The code under test — the relay walk's paging and dedupe, + * latest-wins per coordinate, the archived-marker read, which boards get + * archived, what argv `rd board archive` is invoked with, whether a failure is + * a failure, and the before/after board-count bracket — is the real + * throwaway-board.mjs, unmodified. + * + * WHAT IS FAKED, AND WHY THAT IS NOT FAKING THE THING UNDER TEST. Two + * collaborators only: + * + * - the relay socket (`deps.req`), replaced by an in-memory relay that + * applies REAL nostr semantics: it honours `kinds`, `limit` and `until`, + * serves newest-first, stores an archive as a REPUBLISH of the same + * coordinate at a later created_at — which is exactly what makes + * latest-wins load-bearing — and can WITHHOLD an event it has accepted + * for the next few reads, because a relay that answers a just-published + * board with "no such board" is the ordinary case cleanup exists for, and + * a fake that always serves the run's own board on the very next read can + * never exercise the path where the archive does not happen. + * - `wsReq`'s socket itself, in the last describe block, replaced by a fake + * `WebSocket` — so the EOSE-versus-silence distinction that keeps the + * board-count invariant from passing on a dead relay is tested on the + * real `wsReq`, not on a stand-in for it. + * - the `rd` binary (`exec`), replaced by a fake that behaves like the real + * one: given ["board","archive",coord] it publishes the archived + * republish to the fake relay. Each failure mode this suite cares about is + * produced by making that fake behave the way a broken `rd` would (throw, + * or exit 0 without publishing) — the failure is never simulated at the + * level of the module's own return value. + * + * A live relay and a real signing key cannot run in CI; the harnesses that use + * them need Chromium, a Go toolchain and the owner's key. The contract those + * harnesses depend on is what runs here, on every PR. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + KIND_BOARD, + PAGE_LIMIT, + archiveBoard, + fetchBoardEvents, + latestBoardsOwnedBy, + openThrowawayBoardGuard, + ownedBoardCount, + reportCleanup, + wsReq, +} from "./throwaway-board.mjs"; + +const OWNER = "a".repeat(64); +const STRANGER = "b".repeat(64); +const RELAY = "wss://relay.example.invalid"; + +/** + * fakeRelay is an in-memory nostr relay that implements the parts of NIP-01 + * this walk depends on: a stored event list, `kinds`/`limit`/`until` + * filtering, and newest-first delivery. It records every filter it is asked + * for, so a test can assert what was (and was not) sent. + */ +function fakeRelay(events = []) { + const store = [...events]; + const filters = []; + const withheld = new Map(); + let nextTs = 2_000_000; + return { + store, + filters, + /** + * withhold makes this relay answer the next `reads` REQs as though it had + * never heard of board-d `d`, while still holding the event — a relay that + * has ACCEPTED a write and has not propagated it yet. Real, ordinary, and + * the reason cleanup cannot take a single snapshot of what to archive. + * + * `reads` is counted in REQs; every walk in these tests fits in one page, + * so one read = one walk = one `ownedBoards` call. Pass `Infinity` for a + * relay that never serves the board at all. + */ + withhold(d, reads) { + withheld.set(d, reads); + return d; + }, + publish(e) { + store.push(e); + return e; + }, + /** board() mints a kind-30301 board event, as `rd init` publishes one. */ + board({ pubkey = OWNER, d, archived = false, created_at = nextTs++ } = {}) { + const tags = [["d", d]]; + if (archived) tags.push(["archived", "2026-07-30T00:00:00Z"]); + return this.publish({ id: `${pubkey}:${d}:${created_at}`, pubkey, kind: KIND_BOARD, created_at, tags }); + }, + /** archiveOnRelay is what a WORKING `rd board archive` does: republish the + * same coordinate, later, with the archived tag. */ + archiveOnRelay(coord) { + const [, pubkey, d] = coord.split(":"); + return this.board({ pubkey, d, archived: true, created_at: nextTs++ }); + }, + req(_relay, filter) { + filters.push(structuredClone(filter)); + const hidden = new Set(); + for (const [d, reads] of withheld) { + if (reads > 0) hidden.add(d); + withheld.set(d, reads - 1); + } + const matched = store + .filter((e) => !hidden.has(e.tags.find((t) => t[0] === "d")?.[1])) + .filter((e) => (filter.kinds ? filter.kinds.includes(e.kind) : true)) + .filter((e) => (filter.until === undefined ? true : e.created_at <= filter.until)) + .sort((a, b) => b.created_at - a.created_at); + return Promise.resolve(matched.slice(0, filter.limit ?? matched.length)); + }, + }; +} + +/** fakeRd stands in for the compiled `rd` binary. `onArchive` decides how it + * behaves; the default is a correct one that publishes the archive. */ +function fakeRd(relay, onArchive) { + const calls = []; + const exec = (bin, args) => { + calls.push({ bin, args }); + if (args[0] === "board" && args[1] === "archive") { + return (onArchive ?? ((coord) => relay.archiveOnRelay(coord)))(args[2]); + } + throw new Error(`fake rd: unexpected argv ${args.join(" ")}`); + }; + return { calls, exec }; +} + +/** + * polls() gives the read-back loop a VIRTUAL clock: `wait` advances time + * instead of spending it, so a test that has to let the whole read-back window + * expire (a relay that never serves the board) costs a handful of iterations + * rather than 30 real seconds — and costs exactly the same number of them + * every run, which a wall clock would not. + * + * PROOF that the budget is real and not a way of dodging the poll loop: the + * "a relay that never serves the run's board" test below asserts the walk was + * retried, and "the marker read-back is polled" runs on the real clock. + */ +const POLLS = 5; +function polls({ intervalMs = 1000 } = {}) { + let t = 1_000_000; + return { + readBackTimeoutMs: intervalMs * POLLS, + readBackIntervalMs: intervalMs, + now: () => t, + wait: async (ms) => { + t += ms; + }, + }; +} + +const RD = { rdBin: "/tmp/rd", cwd: "/tmp/proj", home: "/tmp/home" }; + +describe("the relay walk (ready-153 / relay measurement discipline)", () => { + test("pages backwards with `until` and never sends an `authors` filter", async () => { + const relay = fakeRelay(); + // 1,200 boards is well past one page, so the walk MUST page to see them + // all — the shape that made a one-page walk under-report. + for (let i = 0; i < 1200; i++) relay.board({ d: `board-${i}` }); + + const events = await fetchBoardEvents(RELAY, { req: relay.req.bind(relay) }); + + expect(events.length).toBe(1200); + expect(relay.filters.length).toBeGreaterThan(1); + for (const f of relay.filters) { + // An `authors` filter silently under-returns on wss://relay.3dl.network + // (42/56 vs 56/56 for the same walk, ready-5c5). Ownership is filtered + // client-side, after the walk. + expect(f).not.toHaveProperty("authors"); + expect(f.kinds).toEqual([KIND_BOARD]); + expect(f.limit).toBeGreaterThanOrEqual(PAGE_LIMIT); + } + expect(relay.filters.slice(1).every((f) => typeof f.until === "number")).toBe(true); + }); + + test("latest-wins per coordinate: an archive republish beats the original", () => { + const relay = fakeRelay(); + relay.board({ d: "b4359x", created_at: 100 }); + relay.board({ d: "b4359x", archived: true, created_at: 200 }); + + const boards = latestBoardsOwnedBy(relay.store, OWNER); + + expect(boards.size).toBe(1); + expect(boards.get(`${KIND_BOARD}:${OWNER}:b4359x`).archived).toBe(true); + }); + + test("an older archive republish does NOT un-archive a newer live board", () => { + const relay = fakeRelay(); + relay.board({ d: "b4359x", archived: true, created_at: 100 }); + relay.board({ d: "b4359x", created_at: 200 }); + + const boards = latestBoardsOwnedBy(relay.store, OWNER); + + expect(boards.get(`${KIND_BOARD}:${OWNER}:b4359x`).archived).toBe(false); + }); + + test("counts only this key's unarchived boards", async () => { + const relay = fakeRelay(); + relay.board({ d: "live-1" }); + relay.board({ d: "live-2" }); + relay.board({ d: "gone", archived: true }); + relay.board({ pubkey: STRANGER, d: "someone-elses" }); + + expect(await ownedBoardCount(RELAY, OWNER, { req: relay.req.bind(relay) })).toBe(2); + }); +}); + +describe("archiveBoard runs the real `rd board archive`", () => { + test("invokes the binary with exactly ['board','archive',coord] in the project dir", () => { + const relay = fakeRelay(); + const rd = fakeRd(relay); + + archiveBoard({ ...RD, relay: RELAY, coord: `${KIND_BOARD}:${OWNER}:b4359x`, exec: rd.exec }); + + expect(rd.calls).toEqual([{ bin: "/tmp/rd", args: ["board", "archive", `${KIND_BOARD}:${OWNER}:b4359x`] }]); + }); + + test("a missing rd binary is an error, not a silent skip", () => { + expect(() => archiveBoard({ ...RD, rdBin: undefined, relay: RELAY, coord: "c", exec: () => {} })).toThrow( + /no rd binary/, + ); + }); +}); + +describe("the guard's cleanup contract", () => { + /** run models a harness: open the guard, register the board-d, let `create` + * decide what the run actually publishes, then close. */ + async function run({ relay, create, onArchive, rdBin = RD.rdBin, boardD = "b4359run" }) { + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect(boardD); + create?.(boardD); + const rd = fakeRd(relay, onArchive); + const result = await guard.close({ ...RD, rdBin, exec: rd.exec, ...polls() }); + return { guard, rd, result }; + } + + test("the happy path: the run's board is archived and the count comes back level", async () => { + const relay = fakeRelay(); + relay.board({ d: "unrelated-real-board" }); + + const { rd, result } = await run({ relay, create: (d) => relay.board({ d }) }); + + expect(result.failures).toEqual([]); + expect(result.ok).toBe(true); + expect(result.before).toBe(1); + expect(result.after).toBe(1); + expect(result.archived).toEqual([`${KIND_BOARD}:${OWNER}:b4359run`]); + expect(rd.calls.map((c) => c.args[0] + " " + c.args[1])).toEqual(["board archive"]); + }); + + // THIS IS THE ONE THE REVIEW ASKED FOR: delete the archive call and this + // goes red. `onArchive: () => ""` is a `rd board archive` that runs and + // publishes nothing — indistinguishable, from the harness's point of view, + // from the call never having been made at all. + test("cleanup that does not actually archive turns the run RED", async () => { + const relay = fakeRelay(); + + const { result } = await run({ relay, create: (d) => relay.board({ d }), onArchive: () => "" }); + + expect(result.ok).toBe(false); + expect(result.before).toBe(0); + expect(result.after).toBe(1); + expect(result.failures.join("\n")).toMatch(/archived marker for 30301:a+:b4359run is NOT on/); + expect(result.failures.join("\n")).toMatch(/0 before, 1 after — it left a stray behind/); + }); + + test("`rd board archive` exiting 0 without the marker landing is NOT success", async () => { + const relay = fakeRelay(); + // A `rd` that returns cleanly — the exit code says everything worked — but + // whose event never reaches the relay. Only a read-back can tell. + const { rd, result } = await run({ relay, create: (d) => relay.board({ d }), onArchive: () => "archived\n" }); + + expect(rd.calls.length).toBe(1); // the command DID run and DID succeed + expect(result.ok).toBe(false); + expect(result.failures.join("\n")).toMatch(/archived marker for .* is NOT on/); + }); + + test("a failing `rd board archive` is a failure, not a warning", async () => { + const relay = fakeRelay(); + + const { result } = await run({ + relay, + create: (d) => relay.board({ d }), + onArchive: () => { + throw new Error("relay refused: rate-limited"); + }, + }); + + expect(result.ok).toBe(false); + expect(result.failures.join("\n")).toMatch(/could not archive throwaway board .*rate-limited/); + expect(result.failures.join("\n")).toMatch(/it left a stray behind/); + }); + + // Leak 3 from throwaway-board.mjs's header: the previous cleanup was guarded + // on `if (coord && rdBin)`, and `coord` only ever got a value from + // JSON.parse(rd init --json). A board published by an `rd init` that then + // failed leaked forever — the exact case the finally block exists for. + test("a board published by an `rd init` that then FAILED is still cleaned up", async () => { + const relay = fakeRelay(); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + + // The harness registers the board-d BEFORE running `rd init`... + guard.expect("s48fcrash"); + // ...`rd init` publishes the board and then dies. No coordinate is ever + // returned to the harness, no local variable is assigned. + relay.board({ d: "s48fcrash" }); + + const rd = fakeRd(relay); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); + + expect(result.archived).toEqual([`${KIND_BOARD}:${OWNER}:s48fcrash`]); + expect(result.failures).toEqual([]); + expect(result.after).toBe(result.before); + }); + + // ── the relay is not instantaneous ────────────────────────────────────── + // + // A relay that serves this run's board on the very next read is a relay + // that can never make the cleanup fail, and a fake that only models that + // relay tests nothing about the case cleanup exists for. These three drive + // the fake's `withhold`: a board it has accepted and is not serving yet. + + test("a board the relay is not serving YET is archived when it appears", async () => { + const relay = fakeRelay(); + relay.board({ d: "unrelated-real-board" }); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359lag"); + + // The run publishes its board and the relay takes it — but the next two + // reads answer as though it were not there. A cleanup that decides what to + // archive from a single snapshot archives NOTHING here, and then agrees + // with itself: the marker read-back has nothing to prove and the count + // invariant cannot see the board either. 1 before, 1 after, green, and a + // permanent stray the moment the relay catches up. + relay.board({ d: "b4359lag" }); + relay.withhold("b4359lag", 2); + + const rd = fakeRd(relay); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); + + expect(result.archived).toEqual([`${KIND_BOARD}:${OWNER}:b4359lag`]); + expect(rd.calls.map((c) => c.args)).toEqual([["board", "archive", `${KIND_BOARD}:${OWNER}:b4359lag`]]); + expect(result.failures).toEqual([]); + expect(result.ok).toBe(true); + expect(result.after).toBe(result.before); + }); + + test("a board that appears late is archived ONCE, not once per poll", async () => { + const relay = fakeRelay(); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359once"); + relay.board({ d: "b4359once" }); + relay.withhold("b4359once", 1); + + // The archive republish is withheld for a poll too, so the loop keeps + // going after the command has already run. Re-issuing `rd board archive` + // every poll would spam the relay with republishes of a board it is simply + // slow to serve. + const rd = fakeRd(relay, (coord) => { + relay.archiveOnRelay(coord); + relay.withhold(coord.split(":")[2], 1); + return ""; + }); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); + + expect(rd.calls.length).toBe(1); + expect(result.ok).toBe(true); + }); + + test("a relay that NEVER serves the run's board fails the run rather than passing it clean", async () => { + const relay = fakeRelay(); + relay.board({ d: "unrelated-real-board" }); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359dark"); + + // Published, accepted, and never served back inside the read-back window. + // From here this is indistinguishable from a run that died before it ever + // published — and the two have opposite consequences, so the guard is not + // allowed to guess. The count invariant AGREES (1 before, 1 after): it + // cannot see a board the relay will not show it, which is exactly why the + // count alone is not enough to call a run clean. + relay.board({ d: "b4359dark" }); + relay.withhold("b4359dark", Infinity); + + const readsBefore = relay.filters.length; + const rd = fakeRd(relay); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); + + expect(relay.filters.length - readsBefore).toBe(POLLS + 1); // it really waited the window out + expect(rd.calls).toEqual([]); + expect(result.after).toBe(result.before); // the invariant passes... + expect(result.ok).toBe(false); // ...and the run is red anyway + expect(result.failures.join("\n")).toMatch(/never served 30301:a+:b4359dark, the board this run registered/); + }); + + test("a run that published nothing is reported, not quietly passed", async () => { + const relay = fakeRelay(); + relay.board({ d: "unrelated-real-board" }); + + // The honest cost of the rule above: a run that died before `rd init` + // published anything looks the same from the relay and is reported the + // same way. That run has already failed for its own reason; one more loud + // line on a red run is the deliberate trade against a silent stray. + const { rd, result } = await run({ relay, create: undefined, rdBin: null }); + + expect(rd.calls).toEqual([]); + expect(result.after).toBe(result.before); + expect(result.ok).toBe(false); + expect(result.failures.join("\n")).toMatch(/never served .* the board this run registered/); + }); + + test("no rd binary but a board WAS published is a failure, not a pass", async () => { + const relay = fakeRelay(); + + const { result } = await run({ relay, create: (d) => relay.board({ d }), rdBin: null }); + + expect(result.ok).toBe(false); + expect(result.failures.join("\n")).toMatch(/no rd binary available to archive with/); + }); + + test("an already-archived board is left alone rather than archived twice", async () => { + const relay = fakeRelay(); + + const { rd, result } = await run({ relay, create: (d) => relay.board({ d, archived: true }) }); + + expect(rd.calls).toEqual([]); + expect(result.ok).toBe(true); + }); + + test("every registered board is archived, and one failure does not skip the rest", async () => { + const relay = fakeRelay(); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359a"); + guard.expect("b4359b"); + relay.board({ d: "b4359a" }); + relay.board({ d: "b4359b" }); + + const rd = fakeRd(relay, (coord) => { + if (coord.endsWith("b4359a")) throw new Error("boom"); + return relay.archiveOnRelay(coord); + }); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); + + expect(rd.calls.length).toBe(2); + expect(result.archived).toEqual([`${KIND_BOARD}:${OWNER}:b4359b`]); + expect(result.failures.join("\n")).toMatch(/b4359a/); + }); + + test("a board the run did NOT create is never archived", async () => { + const relay = fakeRelay(); + relay.board({ d: "the-owners-real-project" }); + + const { rd, result } = await run({ relay, create: (d) => relay.board({ d }) }); + + expect(rd.calls.map((c) => c.args[2])).toEqual([`${KIND_BOARD}:${OWNER}:b4359run`]); + expect(result.ok).toBe(true); + }); + + test("the marker read-back is polled, so a relay that is slow to serve it still passes", async () => { + const relay = fakeRelay(); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359slow"); + relay.board({ d: "b4359slow" }); + + // The archive lands on the relay two poll cycles after the command + // returns — a real relay's propagation, not an error. + let pending; + const rd = fakeRd(relay, (coord) => { + pending = coord; + return ""; + }); + let waits = 0; + const result = await guard.close({ + ...RD, + exec: rd.exec, + readBackTimeoutMs: 10_000, + readBackIntervalMs: 1, + wait: async () => { + if (++waits === 2 && pending) relay.archiveOnRelay(pending); + }, + }); + + expect(waits).toBe(2); + expect(result.ok).toBe(true); + }); + + test("a relay that goes away before cleanup fails the run rather than passing it", async () => { + const relay = fakeRelay(); + let live = true; + const req = (r, f) => (live ? relay.req(r, f) : Promise.reject(new Error("connection failed"))); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359gone"); + relay.board({ d: "b4359gone" }); + + // The board is published, then the relay stops answering — cleanup cannot + // even establish what needs archiving. Fail closed: an unverifiable + // cleanup is not a clean one. + live = false; + const rd = fakeRd(relay); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); + + expect(rd.calls).toEqual([]); + expect(result.ok).toBe(false); + expect(result.failures.join("\n")).toMatch(/could not read this key's boards back off/); + }); + + test("reportCleanup returns the number a harness must add to its failure count", () => { + const lines = []; + expect(reportCleanup({ ok: true, failures: [] }, (l) => lines.push(l))).toBe(0); + expect(reportCleanup({ ok: false, failures: ["a", "b"] }, (l) => lines.push(l))).toBe(2); + expect(lines[0]).toMatch(/PASS/); + expect(lines[1]).toMatch(/FAIL/); + }); + + test("a dead relay cannot open the guard at all, so the count is never invented", async () => { + // The BEFORE measurement is where a silent relay does its worst damage: it + // would hand back 0, the run would publish a board, cleanup would read 0 + // again, and 0 === 0 would call it clean. The read must propagate. + const dead = { req: () => Promise.reject(new Error("no EOSE within 45000ms")) }; + await expect(openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: dead })).rejects.toThrow( + /no EOSE/, + ); + + // ...and the difference that has to survive: a LIVE relay this key simply + // owns nothing on opens the guard and reports a real 0. + const empty = fakeRelay(); + empty.board({ pubkey: STRANGER, d: "someone-elses" }); + const guard = await openThrowawayBoardGuard({ + relay: RELAY, + ownerPubkey: OWNER, + deps: { req: empty.req.bind(empty) }, + }); + expect(guard.before).toBe(0); + }); +}); + +/** + * wsReq is the one function in the module that touches a socket, so the fake + * here is the `WebSocket` class itself and the code under test is the real + * `wsReq` — its timeout, its EOSE handling, its CLOSE. + * + * WHY THIS BLOCK EXISTS. `wsReq` used to `resolve(out)` on timeout. Every + * caller above then read an unresponsive relay as an answer: `fetchBoardEvents` + * returned a short page, `ownedBoardCount` returned a number, and the guard's + * whole board-count bracket read 0 before and 0 after and reported a clean + * portfolio for a relay that had said nothing at all. An invariant that passes + * when the measurement fails is the same defect class this item was filed to + * catch, so the distinction is asserted rather than assumed. + */ +describe("wsReq tells an empty answer from no answer", () => { + afterEach(() => vi.unstubAllGlobals()); + + /** fakeSocket installs a `WebSocket` whose behaviour on REQ is `script`. */ + function fakeSocket(script) { + const sockets = []; + class FakeWebSocket { + constructor(url) { + this.url = url; + this.sent = []; + this.closed = false; + sockets.push(this); + // A MICROTASK, not a 0ms timer. `wsReq` assigns `onopen` after this + // constructor returns, so the open cannot be synchronous — but a timer + // would queue behind the timeout timer's macrotask if the event loop + // were blocked, and the "events arrived, EOSE did not" test would then + // reject for the wrong reason on a loaded runner. A microtask always + // runs before any timer. + queueMicrotask(() => this.onopen?.()); + } + send(raw) { + this.sent.push(raw); + const [verb, sub, filter] = JSON.parse(raw); + // Only a REQ produces an answer. A real relay does not re-serve the + // subscription when the client CLOSEs it. + if (verb === "REQ") script(this, sub, filter); + } + close() { + this.closed = true; + } + } + vi.stubGlobal("WebSocket", FakeWebSocket); + return sockets; + } + + const deliver = (ws, sub, e) => ws.onmessage({ data: JSON.stringify(["EVENT", sub, e]) }); + const eose = (ws, sub) => ws.onmessage({ data: JSON.stringify(["EOSE", sub]) }); + + test("a live relay with nothing to say resolves empty, and closes the subscription", async () => { + const sockets = fakeSocket((ws, sub) => eose(ws, sub)); + + await expect(wsReq(RELAY, { kinds: [KIND_BOARD], limit: PAGE_LIMIT })).resolves.toEqual([]); + expect(JSON.parse(sockets[0].sent[0])[0]).toBe("REQ"); + expect(JSON.parse(sockets[0].sent[1])[0]).toBe("CLOSE"); + }); + + test("a live relay's events are returned once EOSE says it has finished", async () => { + const sockets = fakeSocket((ws, sub) => { + deliver(ws, sub, { id: "e1" }); + deliver(ws, sub, { id: "e2" }); + eose(ws, sub); + }); + + await expect(wsReq(RELAY, { kinds: [KIND_BOARD] })).resolves.toEqual([{ id: "e1" }, { id: "e2" }]); + expect(sockets[0].closed).toBe(true); + }); + + test("a relay that never answers REJECTS — it is not a key that owns no boards", async () => { + const sockets = fakeSocket(() => { + /* accepts the REQ and says nothing, ever */ + }); + + await expect(wsReq(RELAY, { kinds: [KIND_BOARD] }, { timeoutMs: 20 })).rejects.toThrow(/no EOSE within 20ms/); + expect(sockets[0].closed).toBe(true); + }); + + test("a page that arrives without EOSE is rejected, not resolved as if complete", async () => { + // The nastier half: some events DID arrive. Resolving the partial page + // would under-report the walk, and an under-reported walk is a stray the + // cleanup never sees. + fakeSocket((ws, sub) => { + deliver(ws, sub, { id: "e1" }); + deliver(ws, sub, { id: "e2" }); + }); + + await expect(wsReq(RELAY, { kinds: [KIND_BOARD] }, { timeoutMs: 20 })).rejects.toThrow(/2 event\(s\) received/); + }); + + test("the rejection propagates all the way to the board count, which never returns a number", async () => { + fakeSocket(() => {}); + + // fetchBoardEvents/ownedBoardCount default to the real wsReq: a dead relay + // must not be able to produce a count at all. + await expect(ownedBoardCount(RELAY, OWNER, { req: (r, f) => wsReq(r, f, { timeoutMs: 20 }) })).rejects.toThrow( + /no EOSE/, + ); + }); +}); diff --git a/web/board/vitest.config.ts b/web/board/vitest.config.ts index 26e21f7..c3cd425 100644 --- a/web/board/vitest.config.ts +++ b/web/board/vitest.config.ts @@ -9,6 +9,14 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["src/**/*.test.ts"], + // scripts/**: ready-153. The live harnesses under scripts/ can never run + // in CI (Chromium, a Go toolchain, the owner's signing key, a real relay), + // but the cleanup contract they depend on — throwaway-board.mjs — is pure + // logic over an injectable relay and an injectable `rd`, and the check + // that every board-creating harness is actually bound to that contract is + // a source-shape assertion. Both run here, on every PR. Before this, the + // only evidence a harness cleaned up after itself was prose in a commit + // message. + include: ["src/**/*.test.ts", "scripts/**/*.test.mjs"], }, });