Public: anyone can read this; the crew can change it.

The record survives: an admin can export the decisions and restore them #34

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

hearth/quorum:hearth/10-the-record-survives-an-admin-can-export → main 1 commit fork 1 thread · 1 unresolved

  • Merged. hearth/10-the-record-survives-an-admin-can-export was merged into main as efd4096.

Review

Sign in to review this change.
9 files changed +948 −6
README.md +25 −1
@@ -29,6 +29,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2929 | `src/who.ts` | who is asking, from the forge's signed headers |
3030 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
3131 | `src/live.ts` | the event log, the bus, and `GET /events` |
32 | `src/backup.ts` | a copy of the record, and putting one back |
3233 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
3334 | `src/routes.ts` | every path, in one table |
3435 | `src/pages/` | the HTML, rendered on the server: one layout, three pages |
@@ -49,7 +50,8 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers:
4950 and deletes what they said; on a proposal they opened, they can accept a
5051 counter.
5152 - **admin** — able to write this repository. Withdraws any proposal, deletes
52 any remark, accepts a counter on any proposal.
53 any remark, accepts a counter on any proposal, and takes a copy of the
54 whole record.
5355
5456 "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is
5557 `/__forge/signout`; the forge answers both on the app's own host.
@@ -111,6 +113,28 @@ when it is not, and `terminal` is `FORGE_APP_SSH` exactly as the forge gave
111113 it, or `off`. At boot the app prints one line per misconfiguration — the name
112114 of the variable and what it costs, never its value.
113115
116 ## The record, as a file
117
118 Everything decided here is one SQLite file under `FORGE_APP_DATA`, and an
119 admin can take it away and put it back. Taking a copy is a read; putting one
120 back is not on the web at all.
121
122 ```sh
123 curl -OJ https://<app>/api/backup.db # a copy, admin only, VACUUM INTO at that moment
124 curl https://<app>/api/backup.json # the same rows as JSON, every table, admin only
125 FORGE_APP_DATA=./data bun run src/backup.ts --snapshot # a copy beside the database
126 FORGE_APP_DATA=./data bun run src/backup.ts --restore <file> # put one back, at the terminal
127 ```
128
129 A restore moves the current file aside as `app-before-<moment>.db` — with its
130 WAL, which would otherwise be replayed onto the file that replaced it —
131 rather than deleting it, and refuses a file whose schema version is newer
132 than this build's migration list. A preview and production each have their
133 own `FORGE_APP_DATA`, so a copy taken from one is a copy of that one; restore
134 it where it came from. `/about` shows an admin the file's size, its schema
135 version, the last snapshot kept beside it and both download links, and shows
136 nobody else anything.
137
114138 A proposal is a title (1–120 characters), a paragraph (1–2000) and two to
115139 eight options, each labelled, each different. Its author or an admin can
116140 withdraw it while it is open, once.
src/actions.ts +85 −1
@@ -9,8 +9,15 @@
99 import type { Database } from "bun:sqlite";
1010 import { accessSync, constants, statSync } from "node:fs";
1111 import { dirname } from "node:path";
12 import {
13 downloadName,
14 exportBytes,
15 exportJson,
16 snapshots,
17 type Snapshot,
18 } from "./backup.ts";
1219 import { migrations, quickCheck, schemaVersion } from "./db.ts";
13 import { commit, env, identityState, ssh } from "./env.ts";
20 import { commit, env, identityState, ssh, title } from "./env.ts";
1421 import { emit } from "./live.ts";
1522 import { parseRule, RULE_FORMS, type Rule } from "./quorum.ts";
1623 import type { Who } from "./who.ts";
@@ -436,6 +443,83 @@ export function acceptCounter(
436443 return ok(done);
437444 }
438445
446 /**
447 * The whole record, as a file or as JSON. A backup is every decision this
448 * group ever made in one download, so the check that it is an admin asking
449 * lives here, in front of the only two functions that take one — never at
450 * the route, where the next door would have to remember it.
451 */
452 export function exportSnapshot(
453 db: Database,
454 who: Who,
455 ): Result<{ filename: string; bytes: Uint8Array }> {
456 const refusal = onlyAdmin(who, "download the record");
457 if (refusal !== undefined) return refusal;
458 return ok({ filename: downloadName("db"), bytes: exportBytes(db) });
459 }
460
461 export function exportRecord(
462 db: Database,
463 who: Who,
464 ): Result<{
465 filename: string;
466 app: string;
467 takenAt: string;
468 schemaVersion: number;
469 tables: Record<string, unknown[]>;
470 }> {
471 const refusal = onlyAdmin(who, "download the record");
472 if (refusal !== undefined) return refusal;
473 return ok({
474 filename: downloadName("json"),
475 app: title,
476 takenAt: new Date().toISOString(),
477 schemaVersion: schemaVersion(db),
478 tables: exportJson(db),
479 });
480 }
481
482 /** What the panel says about the record: sizes, version, copies on disk. */
483 export interface RecordState {
484 readonly dataDir: string;
485 readonly databaseBytes: number;
486 readonly schemaVersion: number;
487 readonly migrationsKnown: number;
488 readonly writable: boolean;
489 /** Every copy kept beside the database, newest first. */
490 readonly snapshots: readonly Snapshot[];
491 readonly lastSnapshotAt: string | null;
492 }
493
494 /**
495 * Where the record stands, for the admin section of a page. It reads the
496 * directory and nothing else — no copy is taken by looking.
497 */
498 export function recordState(db: Database, who: Who): Result<RecordState> {
499 const refusal = onlyAdmin(who, "see where the record is kept");
500 if (refusal !== undefined) return refusal;
501 const dir = dirname(db.filename);
502 const kept = snapshots(dir);
503 return ok({
504 dataDir: dir,
505 databaseBytes: bytes(db.filename) + bytes(`${db.filename}-wal`),
506 schemaVersion: schemaVersion(db),
507 migrationsKnown: migrations.length,
508 writable: canWrite(dir),
509 snapshots: kept,
510 lastSnapshotAt: kept[0]?.takenAt ?? null,
511 });
512 }
513
514 /** Signed out is one answer, signed in and not an admin is another. */
515 const onlyAdmin = (who: Who, doing: string): Result<never> | undefined => {
516 if (who.role === "anon")
517 return fail("signin_required", `Sign in to ${doing}.`);
518 if (who.role !== "admin")
519 return fail("forbidden", `Only an admin can ${doing}.`);
520 return undefined;
521 };
522
439523 /** Whether a proposal with this id is there at all. */
440524 const exists = (db: Database, id: number): boolean =>
441525 db
src/backup.test.ts added +346
@@ -0,0 +1,346 @@
1 import { Database } from "bun:sqlite";
2 import { afterAll, expect, test } from "bun:test";
3 import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join, resolve } from "node:path";
6 import { recordState } from "./actions.ts";
7 import {
8 downloadName,
9 exportBytes,
10 exportJson,
11 restore,
12 snapshot,
13 snapshots,
14 sweep,
15 } from "./backup.ts";
16 import { migrations, openDb, schemaVersion } from "./db.ts";
17 import { serve } from "./server.ts";
18 import { sign } from "./who.ts";
19
20 const dir = () => mkdtempSync(join(tmpdir(), "app-backup-"));
21
22 /** A database with one proposal on it, so a copy has something to carry. */
23 const withRows = (at: string = dir(), title = "Where do we meet"): Database => {
24 const db = openDb(at);
25 db.query(
26 "INSERT INTO proposal (title, body, author_id, author_name) VALUES (?1, 'One room.', 'u1', 'alice')",
27 ).run(title);
28 return db;
29 };
30
31 const titles = (db: Database): string[] =>
32 db
33 .query<{ title: string }, []>("SELECT title FROM proposal ORDER BY id")
34 .all()
35 .map((row) => row.title);
36
37 test("a snapshot is a copy of the record, and it lands under the data directory", () => {
38 const at = dir();
39 const db = withRows(at);
40 const file = snapshot(db);
41
42 expect(file.path.startsWith(at)).toBe(true);
43 expect(file.bytes).toBeGreaterThan(0);
44 const copy = new Database(file.path, { readonly: true });
45 expect(titles(copy)).toEqual(["Where do we meet"]);
46 expect(schemaVersion(copy)).toBe(migrations.length);
47 copy.close();
48
49 // A write after the copy was taken is not in it: a snapshot is a moment.
50 db.query(
51 "INSERT INTO proposal (title, body, author_id, author_name) VALUES ('Later', 'After the copy.', 'u1', 'alice')",
52 ).run();
53 const again = new Database(file.path, { readonly: true });
54 expect(titles(again)).toEqual(["Where do we meet"]);
55 again.close();
56
57 // And it is listed as one kept beside the database.
58 expect(snapshots(at).map((s) => s.name)).toEqual([file.name]);
59 });
60
61 test("two snapshots in the same moment do not collide", () => {
62 const at = dir();
63 const db = withRows(at);
64 const one = snapshot(db);
65 const two = snapshot(db);
66 expect(one.path).not.toBe(two.path);
67 expect(snapshots(at)).toHaveLength(2);
68 });
69
70 test("a copy for a download leaves nothing behind", () => {
71 const at = dir();
72 const db = withRows(at);
73 const bytes = exportBytes(db);
74 expect(new TextDecoder().decode(bytes.slice(0, 15))).toBe("SQLite format 3");
75 // Nothing kept, and no temp file: the only .db in the directory is the app's.
76 expect(snapshots(at)).toEqual([]);
77 expect(readdirSync(at).filter((n) => n.startsWith(".tmp-"))).toEqual([]);
78
79 // A copy a dead run left behind is swept once it is stale, and a kept
80 // snapshot never is.
81 const kept = snapshot(db);
82 const stale = join(at, ".tmp-left-behind.db");
83 writeFileSync(stale, "not much");
84 expect(sweep(at, Date.now() + 2 * 60 * 60 * 1000)).toEqual([stale]);
85 expect(existsSync(stale)).toBe(false);
86 expect(existsSync(kept.path)).toBe(true);
87 });
88
89 test("exportJson reads the schema, so a table a later migration adds is in it", () => {
90 const db = withRows();
91 const before = exportJson(db);
92 expect(Object.keys(before)).toContain("proposal");
93 expect(Object.keys(before)).toContain("schema_version");
94 expect(before.proposal).toHaveLength(1);
95 expect((before.proposal?.[0] as { title: string }).title).toBe(
96 "Where do we meet",
97 );
98 // SQLite's own bookkeeping is not the record.
99 expect(Object.keys(before).some((name) => name.startsWith("sqlite_"))).toBe(
100 false,
101 );
102
103 // What a later migration will do, done by hand: `backup.ts` is not
104 // edited, and the table is in the next export.
105 db.exec("CREATE TABLE ballot (id INTEGER PRIMARY KEY, voter TEXT NOT NULL)");
106 db.query("INSERT INTO ballot (voter) VALUES ('alice')").run();
107 const after = exportJson(db);
108 expect(after.ballot).toEqual([{ id: 1, voter: "alice" }]);
109 });
110
111 test("a download is named for the day it was taken", () => {
112 expect(downloadName("db", new Date("2026-09-18T11:22:33Z"))).toBe(
113 "quorum-2026-09-18.db",
114 );
115 expect(downloadName("json", new Date("2026-09-18T11:22:33Z"))).toBe(
116 "quorum-2026-09-18.json",
117 );
118 });
119
120 test("a file from a newer build is refused, and the record is untouched", () => {
121 const at = dir();
122 const db = withRows(at, "The one that was here");
123 const ahead = snapshot(db);
124 // A copy written by a build with one migration more than this one.
125 const tampered = new Database(ahead.path);
126 tampered
127 .query("INSERT INTO schema_version (version) VALUES (?1)")
128 .run(migrations.length + 1);
129 tampered.close();
130
131 const refused = restore(ahead.path, at);
132 expect(refused.ok).toBe(false);
133 if (!refused.ok) {
134 expect(refused.message).toContain(`${migrations.length + 1}`);
135 expect(refused.message).toContain("Nothing was changed");
136 }
137 // The database is where it was, with what it had, and nothing was moved.
138 expect(titles(openDb(at))).toEqual(["The one that was here"]);
139 expect(readdirSync(at).filter((n) => n.startsWith("app-before-"))).toEqual(
140 [],
141 );
142 });
143
144 test("a file that is not a database is refused", () => {
145 const at = dir();
146 withRows(at);
147 const notOne = join(at, "notes.txt");
148 writeFileSync(notOne, "dear diary, I am not a database\n");
149 const refused = restore(notOne, at);
150 expect(refused.ok).toBe(false);
151 if (!refused.ok) expect(refused.message).toContain("not a SQLite database");
152
153 const missing = restore(join(at, "nothing-here.db"), at);
154 expect(missing.ok).toBe(false);
155 if (!missing.ok) expect(missing.message).toContain("is not a file");
156
157 expect(restore(join(at, "app.db"), at).ok).toBe(false);
158 });
159
160 test("a restore moves the old file aside and the copy's data is reachable", () => {
161 const from = dir();
162 const taken = snapshot(withRows(from, "What the copy remembers"));
163
164 const at = dir();
165 const live = withRows(at, "What was here before");
166 live.close();
167
168 const done = restore(taken.path, at);
169 expect(done.ok).toBe(true);
170 if (!done.ok) return;
171 expect(done.to).toBe(join(at, "app.db"));
172 expect(done.version).toBe(migrations.length);
173 expect(done.movedAside.length).toBeGreaterThan(0);
174 // A write-ahead log of the file that left never stays beside the one that
175 // arrived: it would be replayed onto it.
176 expect(existsSync(join(at, "app.db-wal"))).toBe(false);
177
178 // The record is the one that was restored...
179 expect(titles(openDb(at))).toEqual(["What the copy remembers"]);
180 // ...and the one it replaced is still on disk, still readable.
181 const aside = done.movedAside.find((path) => path.endsWith(".db"))!;
182 expect(existsSync(aside)).toBe(true);
183 const old = new Database(aside, { readonly: true });
184 expect(titles(old)).toEqual(["What was here before"]);
185 old.close();
186 });
187
188 test("a restore carries the write-ahead log of the file it replaces aside with it", () => {
189 const at = dir();
190 // A database left open, as a running app leaves it: rows live in the WAL.
191 const live = withRows(at, "In the log");
192 expect(existsSync(join(at, "app.db-wal"))).toBe(true);
193 const taken = snapshot(withRows(dir(), "The copy"));
194
195 const done = restore(taken.path, at);
196 expect(done.ok).toBe(true);
197 if (!done.ok) return;
198 expect(done.movedAside.some((path) => path.endsWith(".db-wal"))).toBe(true);
199 expect(existsSync(join(at, "app.db-wal"))).toBe(false);
200 // And the file it brought in is the database now, not a temp file left
201 // lying beside it.
202 expect(readdirSync(at).filter((n) => n.startsWith(".tmp-"))).toEqual([]);
203 live.close();
204 });
205
206 test("the terminal takes a snapshot and prints where it went", () => {
207 const at = dir();
208 withRows(at).close();
209 const ran = Bun.spawnSync(["bun", "run", "src/backup.ts", "--snapshot"], {
210 env: { ...process.env, FORGE_APP_DATA: at },
211 });
212 const out = ran.stdout.toString();
213 expect(ran.exitCode).toBe(0);
214 expect(out).toContain("snapshot ");
215 const path = out.split("\n")[0]!.slice("snapshot ".length);
216 expect(existsSync(path)).toBe(true);
217 expect(snapshots(at).map((s) => resolve(s.path))).toEqual([path]);
218 expect(out).toContain(`schema version ${migrations.length}`);
219 });
220
221 // The doors. The admin check is in the action, so it holds at both of them
222 // and on the page that links them.
223 const KEY = "a-key-only-the-forge-holds";
224 const served = dir();
225 const server = serve({ port: 0, dataDir: served, identityKey: KEY });
226 const base = `http://${server.hostname}:${server.port}`;
227 afterAll(() => server.stop(true));
228
229 const as = (
230 id: string,
231 name: string,
232 role: "user" | "admin" = "user",
233 ): Record<string, string> => {
234 const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`;
235 return {
236 "x-forge-identity": identity,
237 "x-forge-signature": sign(identity, KEY),
238 };
239 };
240 const alice = as("u1", "alice");
241 const root = as("u0", "root", "admin");
242 const get = (path: string, who: Record<string, string> = {}) =>
243 fetch(base + path, { headers: who });
244
245 for (const path of ["/api/backup.db", "/api/backup.json"]) {
246 test(`${path} is an admin's, and nobody else's`, async () => {
247 const guest = await get(path);
248 expect(guest.status).toBe(401);
249 expect(await guest.json()).toMatchObject({ error: "signin_required" });
250 expect(guest.headers.get("content-disposition")).toBeNull();
251
252 const user = await get(path, alice);
253 expect(user.status).toBe(403);
254 expect(await user.json()).toMatchObject({ error: "forbidden" });
255 expect(user.headers.get("content-disposition")).toBeNull();
256
257 const admin = await get(path, root);
258 expect(admin.status).toBe(200);
259 expect(admin.headers.get("content-disposition")).toBe(
260 `attachment; filename="${downloadName(path.endsWith(".db") ? "db" : "json")}"`,
261 );
262 expect(admin.headers.get("cache-control")).toBe("no-store");
263 // The floor is on a download like it is on everything else.
264 expect(admin.headers.get("x-content-type-options")).toBe("nosniff");
265 expect(admin.headers.get("referrer-policy")).toBe(
266 "strict-origin-when-cross-origin",
267 );
268 expect(admin.headers.get("content-security-policy")).toContain(
269 "default-src 'self'",
270 );
271 });
272 }
273
274 test("the downloaded database opens, and the JSON carries the same rows", async () => {
275 await fetch(base + "/api/proposals", {
276 method: "POST",
277 headers: { "content-type": "application/json", ...root },
278 body: JSON.stringify({
279 title: "Adopt the four-day week",
280 body: "A trial for one quarter.",
281 options: ["Yes", "No"],
282 }),
283 });
284
285 const file = new Uint8Array(await (await get("/api/backup.db", root)).arrayBuffer());
286 const path = join(served, "downloaded.db");
287 writeFileSync(path, file);
288 const copy = new Database(path, { readonly: true });
289 expect(titles(copy)).toContain("Adopt the four-day week");
290 copy.close();
291
292 const record = (await (await get("/api/backup.json", root)).json()) as {
293 app: string;
294 schemaVersion: number;
295 tables: Record<string, { title?: string }[]>;
296 };
297 expect(record.app).toBe("quorum");
298 expect(record.schemaVersion).toBe(migrations.length);
299 expect(record.tables.proposal?.map((row) => row.title)).toContain(
300 "Adopt the four-day week",
301 );
302 expect(record.tables.option).toBeDefined();
303 });
304
305 test("/about shows an admin where the record is, and says nothing to anyone else", async () => {
306 const guest = await (await get("/about")).text();
307 expect(guest).not.toContain("The record");
308 expect(guest).not.toContain("/api/backup.db");
309
310 const user = await (await get("/about", alice)).text();
311 expect(user).not.toContain("/api/backup.db");
312
313 const admin = await (await get("/about", root)).text();
314 expect(admin).toContain("The record");
315 expect(admin).toContain('href="/api/backup.db"');
316 expect(admin).toContain('href="/api/backup.json"');
317 expect(admin).toContain(`Schema version</dt><dd>${migrations.length}`);
318 expect(admin).toContain("--restore");
319 expect(admin).toContain("none kept beside the database");
320 // How to reach the keyboard that restores one — off, where it is off.
321 expect(admin).toContain("The terminal is");
322 });
323
324 test("recordState is an admin's too, and counts the copies beside the file", () => {
325 const at = dir();
326 const db = withRows(at);
327 const anon = recordState(db, { role: "anon" });
328 expect(anon.ok).toBe(false);
329 if (!anon.ok) expect(anon.error).toBe("signin_required");
330 const user = recordState(db, { role: "user", id: "u1", name: "alice" });
331 expect(user.ok).toBe(false);
332 if (!user.ok) expect(user.error).toBe("forbidden");
333
334 const admin = recordState(db, { role: "admin", id: "u0", name: "root" });
335 expect(admin.ok).toBe(true);
336 if (!admin.ok) return;
337 expect(admin.value.dataDir).toBe(at);
338 expect(admin.value.databaseBytes).toBeGreaterThan(0);
339 expect(admin.value.schemaVersion).toBe(migrations.length);
340 expect(admin.value.lastSnapshotAt).toBeNull();
341
342 const file = snapshot(db);
343 const after = recordState(db, { role: "admin", id: "u0", name: "root" });
344 expect(after.ok && after.value.snapshots[0]?.name).toBe(file.name);
345 expect(after.ok && after.value.lastSnapshotAt).toBe(file.takenAt);
346 });
src/backup.ts added +328
@@ -0,0 +1,328 @@
1 /**
2 * The record as a file. Everything this app is for lives in one SQLite
3 * file under `FORGE_APP_DATA`; this module takes a copy of it and puts one
4 * back, and nothing here is reachable over HTTP except the two reads
5 * (`exportSnapshot` and `exportRecord` in `actions.ts`, admin only).
6 *
7 * bun run src/backup.ts --snapshot a copy beside the database
8 * bun run src/backup.ts --restore <file> put one back, deliberately
9 *
10 * A copy is taken with SQLite's own `VACUUM INTO`, which reads one
11 * consistent view of the file: a write in flight cannot tear it. Every
12 * path this module writes is under the data directory, temp files
13 * included — `VACUUM INTO` fails if its target exists, so each one is a
14 * fresh name, and the one the download uses is unlinked as soon as it has
15 * been read.
16 */
17
18 import { Database } from "bun:sqlite";
19 import {
20 closeSync,
21 copyFileSync,
22 existsSync,
23 openSync,
24 readdirSync,
25 readFileSync,
26 readSync,
27 renameSync,
28 statSync,
29 unlinkSync,
30 } from "node:fs";
31 import { basename, dirname, join, resolve } from "node:path";
32 import { migrations, openDb, schemaVersion } from "./db.ts";
33 import { title } from "./env.ts";
34
35 /** The first sixteen bytes of every SQLite database, and nothing else. */
36 const MAGIC = "SQLite format 3\0";
37
38 /** What a snapshot kept beside the database is called. */
39 const KEPT = `${title}-`;
40 /**
41 * What every short-lived file this module writes is called. A dotfile
42 * under the data directory, so a run that dies leaves something `sweep`
43 * recognises as its own and nothing else ever matches.
44 */
45 const TEMP = ".tmp-";
46 const EXPORTING = `${TEMP}export-`;
47 const RESTORING = `${TEMP}restore-`;
48
49 /** A file on disk that is a copy of the record. */
50 export interface Snapshot {
51 readonly name: string;
52 readonly path: string;
53 readonly bytes: number;
54 readonly takenAt: string;
55 }
56
57 /** The data directory this database lives in. Every write here goes there. */
58 export const dirOf = (db: Database): string => dirname(db.filename);
59
60 /**
61 * A consistent copy of `db`, written under its own directory and never
62 * anywhere else. `VACUUM INTO` refuses a target that exists, so the name
63 * carries the moment it was taken and a counter for the unlikely tie.
64 */
65 export function snapshot(
66 db: Database,
67 dir: string = dirOf(db),
68 prefix: string = KEPT,
69 ): Snapshot {
70 const path = fresh(dir, prefix, stamp());
71 db.query("VACUUM INTO ?1").run(path);
72 return describe(path);
73 }
74
75 /** Every snapshot kept beside the database, newest first. */
76 export function snapshots(dir: string): Snapshot[] {
77 let names: string[];
78 try {
79 names = readdirSync(dir);
80 } catch {
81 return [];
82 }
83 return names
84 .filter((name) => name.startsWith(KEPT) && name.endsWith(".db"))
85 .map((name) => describe(join(dir, name)))
86 .sort((a, b) => b.takenAt.localeCompare(a.takenAt));
87 }
88
89 /**
90 * Every table's rows, read from `sqlite_master` rather than from a list
91 * written here: a table a later migration adds is in the export the day it
92 * exists, without this file being edited. SQLite's own tables are not the
93 * record and are left out.
94 */
95 export function exportJson(db: Database): Record<string, unknown[]> {
96 const tables = db
97 .query<
98 { name: string },
99 []
100 >("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
101 .all();
102 const out: Record<string, unknown[]> = {};
103 for (const { name } of tables)
104 // The name came from the schema itself, and is quoted as an identifier
105 // on the way back in: no request ever reaches this string.
106 out[name] = db.query(`SELECT * FROM "${name.replace(/"/g, '""')}"`).all();
107 return out;
108 }
109
110 /** A copy taken for a download: the bytes, and the temp file already gone. */
111 export function exportBytes(db: Database): Uint8Array {
112 const dir = dirOf(db);
113 sweep(dir);
114 const file = snapshot(db, dir, EXPORTING);
115 try {
116 return new Uint8Array(readFileSync(file.path));
117 } finally {
118 try {
119 unlinkSync(file.path);
120 } catch {
121 // A copy we could not remove is noise rather than a failure: it is a
122 // dotfile under the data directory, and `sweep` takes it next time.
123 }
124 }
125 }
126
127 /** How long a copy taken for a download may linger before it is litter. */
128 const STALE = 60 * 60 * 1000;
129
130 /**
131 * The temp files of a run that died mid-copy. Kept honest by the prefix:
132 * only files this module writes for itself are ever removed, and never a
133 * snapshot somebody asked to keep.
134 */
135 export function sweep(dir: string, now: number = Date.now()): string[] {
136 const gone: string[] = [];
137 let names: string[];
138 try {
139 names = readdirSync(dir);
140 } catch {
141 return gone;
142 }
143 for (const name of names) {
144 if (!name.startsWith(TEMP) || !name.endsWith(".db")) continue;
145 const path = join(dir, name);
146 try {
147 if (now - statSync(path).mtimeMs < STALE) continue;
148 unlinkSync(path);
149 gone.push(path);
150 } catch {
151 // Another process got there first, which is the outcome we wanted.
152 }
153 }
154 return gone;
155 }
156
157 /** What a download is called: `quorum-2026-09-18.db`. */
158 export const downloadName = (extension: string, at = new Date()): string =>
159 `${title}-${at.toISOString().slice(0, 10)}.${extension}`;
160
161 /** The version a candidate file carries, or why it is not one of ours. */
162 export type Reading =
163 | { ok: true; version: number; bytes: number }
164 | { ok: false; message: string };
165
166 /**
167 * What a file on disk is: a SQLite database, and the schema version it
168 * holds. Opened read-only, so looking at it changes nothing.
169 */
170 export function readCandidate(path: string): Reading {
171 if (!existsSync(path) || !statSync(path).isFile())
172 return { ok: false, message: `${path} is not a file.` };
173 const bytes = statSync(path).size;
174 if (bytes < MAGIC.length)
175 return { ok: false, message: `${path} is too small to be a database.` };
176 // Sixteen bytes, not the whole file: what this asks is what the file
177 // claims to be, and a database can be large.
178 const head = Buffer.alloc(MAGIC.length);
179 const handle = openSync(path, "r");
180 try {
181 readSync(handle, head, 0, MAGIC.length, 0);
182 } finally {
183 closeSync(handle);
184 }
185 if (head.toString("binary") !== MAGIC)
186 return { ok: false, message: `${path} is not a SQLite database.` };
187 let db: Database | undefined;
188 try {
189 db = new Database(path, { readonly: true });
190 db.exec("PRAGMA temp_store = MEMORY");
191 const version = schemaVersion(db);
192 return { ok: true, version, bytes };
193 } catch (error) {
194 return {
195 ok: false,
196 message: `${path} does not open as a database (${(error as Error).message}).`,
197 };
198 } finally {
199 db?.close();
200 }
201 }
202
203 export type Restore =
204 | {
205 ok: true;
206 from: string;
207 to: string;
208 movedAside: string[];
209 version: number;
210 bytes: number;
211 }
212 | { ok: false; message: string };
213
214 /**
215 * Put a copy back. The old file is never deleted: it is moved aside with
216 * the moment in its name, next to the database, along with its WAL — a
217 * write-ahead log left behind would be replayed onto the file that
218 * replaced it. A version ahead of this build's migration list is refused
219 * rather than guessed at: forward-only migrations cannot walk it back.
220 */
221 export function restore(
222 file: string,
223 dir: string,
224 now: Date = new Date(),
225 ): Restore {
226 const from = resolve(file);
227 const to = join(resolve(dir), "app.db");
228 if (from === to)
229 return { ok: false, message: "That file is the database itself." };
230 const reading = readCandidate(from);
231 if (!reading.ok) return reading;
232 if (reading.version > migrations.length)
233 return {
234 ok: false,
235 message: `That file is at schema version ${reading.version}; this build knows ${migrations.length}. Deploy the code that wrote it, then restore. Nothing was changed.`,
236 };
237 const at = stamp(now);
238 const dataDir = dirname(to);
239 // The new file is brought in beside the old one first and put in its
240 // place last: a copy that runs out of disk leaves the record exactly
241 // where it was, still the one the app is serving.
242 const landing = fresh(dataDir, RESTORING, at);
243 copyFileSync(from, landing);
244 const movedAside: string[] = [];
245 // The database and its sidecars move together, or the WAL of the file
246 // that left would be applied to the file that arrived.
247 for (const suffix of ["", "-wal", "-shm"]) {
248 const path = `${to}${suffix}`;
249 if (!existsSync(path)) continue;
250 const aside = join(dataDir, `app-before-${at}.db${suffix}`);
251 renameSync(path, aside);
252 movedAside.push(aside);
253 }
254 renameSync(landing, to);

probe

Changes requested. (The stray probe comment on this line is mine — this API wants an anchor and there is no delete; ignore it.)

The export half is solid: the admin check lives in the action so both routes and the page inherit it, exportJson reads sqlite_master so a table a later migration adds is in the file for free, VACUUM INTO gives a copy a write in flight cannot tear, temp copies are dotfiles under FORGE_APP_DATA with a sweep behind them, a version-ahead file is refused with the original untouched, download() sanitises the filename and carries the floor. On the preview a guest gets signin_required, a signed-in user forbidden, /about shows a guest nothing, and / answers in 89 ms. 91 tests green, no new dependency. Four findings, two high.

1 (high) A restore against the running app silently does not take effect, and the writes made after it are lost. restore() renames app.db, -wal and -shm out from under the process that has them open (this line, src/backup.ts:246-254). Measured at this head, with a server serving that directory while the restore runs:

post before restore:  201
before:               [ "BEFORE the restore" ]
restore: ok
server still serves:  [ "BEFORE the restore" ]   <- not the copy
post after restore:   201 "WRITTEN after the restore"
what the file holds:  [ "FROM the copy" ]        <- that write went to app-before-*.db

The operator sees restored ... (n bytes), the page keeps serving the old record, new proposals are accepted with 201 into the file that was just moved aside and vanish at the next boot — while any second process on that directory (the tty, which opens app.db fresh) is already on the restored file. Two doors, two different databases, no error anywhere. That is the one failure this feature exists to prevent.

Fix this first: refuse the restore while another process holds the database — open it and take BEGIN EXCLUSIVE, or treat a live app.db-shm as occupied — exit non-zero with "stop the app first", and say in the command's output, on /about and in the README that the app must be restarted before anyone writes again.

2 (high) The terminal is not a door for this. forge.json's tty is bun run src/tty.ts; FORGE_APP_SSH lands a person in that TUI, which has no key for a snapshot or a restore and no prompt to type a command at. /about (src/pages/about.ts:135-153) tells an admin to run bun run src/backup.ts --restore <file> "at the terminal" and then prints FORGE_APP_SSH as that terminal — the door the page names cannot run the command the page gives. README.md:125-126 repeats it. What a person can do on the page they must be able to do in src/tty.ts: put the keys there, gated on whoAtTty().role === "admin" exactly as the page gates on who, listing the snapshots beside the database for a restore, and keep src/backup.ts as the module plus the operator CLI. Related: the import.meta.main block (src/backup.ts:290) checks nobody at all — harmless only while no ssh session can reach it, which is precisely what fixing this changes.

3 (medium) GET /api/backup.db is an unbounded full-database VACUUM plus a full in-memory buffer, per request. allow() rations non-reads only (src/server.ts:49-59), so this route has no ceiling: 30 concurrent downloads went through in 70 ms here, each writing a whole copy under FORGE_APP_DATA and holding another in memory (src/backup.ts:116). An admin's browser can be driven to issue those cross-site (<img src="/api/backup.db">) — the bytes stay unreadable, the disk and memory work does not. Ration this route on user:${who.id} with the bucket that already exists, and stream the temp file instead of readFileSync into a Uint8Array.

4 (minor) README.md:123 documents a command that cannot work. curl -OJ https://<app>/api/backup.db gets {"error":"signin_required"} — identity is the forge's signed headers, not something a shell can present; that is exactly what the preview returned to me. Say it is a link you follow in a browser while signed in as an admin.

One test to add while you are in there: the --restore command itself is uncovered — restore() has tests, the CLI that calls it does not, and that CLI is where #1 gets fixed.

255 return {
256 ok: true,
257 from,
258 to,
259 movedAside,
260 version: reading.version,
261 bytes: reading.bytes,
262 };
263 }
264
265 /** A path nothing occupies yet: `VACUUM INTO` will not write over a file. */
266 function fresh(dir: string, prefix: string, at: string): string {
267 for (let n = 0; n < 1000; n++) {
268 const path = join(dir, `${prefix}${at}${n === 0 ? "" : `-${n}`}.db`);
269 if (!existsSync(path)) return path;
270 }
271 throw new Error(`no free name for a copy in ${dir}`);
272 }
273
274 /** The moment, in a name a filesystem is happy with. */
275 const stamp = (at: Date = new Date()): string =>
276 at.toISOString().replace(/[:.]/g, "-");
277
278 const describe = (path: string): Snapshot => {
279 const stat = statSync(path);
280 return {
281 name: basename(path),
282 path,
283 bytes: stat.size,
284 takenAt: new Date(stat.mtimeMs).toISOString(),
285 };
286 };
287
288 // The terminal, and only the terminal: a restore replaces live data, so it
289 // is a person at a keyboard who does it, and every step is printed.
290 if (import.meta.main) {
291 const argv = process.argv.slice(2);
292 const restoring = argv.indexOf("--restore");
293 if (argv.includes("--snapshot")) {
294 const db = openDb();
295 const file = snapshot(db);
296 console.log(`snapshot ${resolve(file.path)}`);
297 console.log(`${file.bytes} bytes, schema version ${schemaVersion(db)}`);
298 db.close();
299 } else if (restoring !== -1) {
300 const file = argv[restoring + 1];
301 if (file === undefined || file.startsWith("--")) {
302 console.error("usage: bun run src/backup.ts --restore <file>");
303 process.exit(2);
304 }
305 // The database is opened once first, so a restore onto a directory
306 // that has never run this app still lands beside a WAL-mode file.
307 const db = openDb();
308 const dir = dirOf(db);
309 db.close();
310 const done = restore(file, dir);
311 if (!done.ok) {
312 console.error(`refused: ${done.message}`);
313 process.exit(1);
314 }
315 for (const aside of done.movedAside) console.log(`moved aside ${aside}`);
316 console.log(`restored ${done.from} to ${done.to} (${done.bytes} bytes)`);
317 console.log(
318 done.version === migrations.length
319 ? `schema version ${done.version}, which is this build's`
320 : `schema version ${done.version}; this build knows ${migrations.length} and will migrate it forward on the next boot`,
321 );
322 } else {
323 console.error(
324 "usage: bun run src/backup.ts --snapshot | --restore <file>",
325 );
326 process.exit(2);
327 }
328 }
src/db.ts +4
@@ -82,6 +82,10 @@ export function openDb(dir = dataDir): Database {
8282 db.exec("PRAGMA journal_mode = WAL");
8383 db.exec("PRAGMA foreign_keys = ON");
8484 db.exec("PRAGMA busy_timeout = 5000");
85 // SQLite's own scratch space stays in memory, so the only thing this
86 // process ever writes to disk is the file under FORGE_APP_DATA — a
87 // `VACUUM INTO` for a backup included (`backup.ts`).
88 db.exec("PRAGMA temp_store = MEMORY");
8589 migrate(db);
8690 return db;
8791 }
src/http.ts +20
@@ -58,6 +58,26 @@ export const text = (body: string, status = 200): Response =>
5858 headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY },
5959 });
6060
61 /**
62 * A file to save rather than a page to look at. The name is reduced to
63 * letters, digits, dots and dashes before it goes in the header — a
64 * filename is a place quoting mistakes hide — and nothing about a download
65 * is ever cached, by a browser or by anything between.
66 */
67 export const download = (
68 body: Uint8Array | string,
69 filename: string,
70 type: string,
71 ): Response =>
72 new Response(body as BodyInit, {
73 headers: {
74 "content-type": type,
75 "content-disposition": `attachment; filename="${filename.replace(/[^A-Za-z0-9._-]/g, "-")}"`,
76 "cache-control": "no-store",
77 ...SECURITY,
78 },
79 });
80
6181 /**
6282 * Back to a page of this app, with a word for it to show once
6383 * (`readNotice`). The location is the app's own — a path a route built, never
src/pages/about.ts +71 −3
@@ -5,11 +5,12 @@
55 * `src/env.ts`, so a page that rots is a module that changed under it.
66 */
77
8 import type { RecordState } from "../actions.ts";
89 import { ssh, where } from "../env.ts";
910 import { escape } from "../http.ts";
1011 import { RULES, RULE_FORMS } from "../quorum.ts";
1112 import type { Who } from "../who.ts";
12 import { layout } from "./layout.ts";
13 import { ago, layout } from "./layout.ts";
1314
1415 /** One rule as a page prints it: whatever `RULES` holds, never a copy of it. */
1516 interface RuleEntry {
@@ -28,6 +29,7 @@ const WORKS: readonly string[] = [
2829 "Accept a counter on a proposal you opened: the option it names is added to it, and every ballot already cast still stands.",
2930 "Withdraw a proposal you opened while it is open; an admin withdraws any.",
3031 "Watch the list stay current as other people write, without reloading.",
32 "Take the whole record away as a file: an admin downloads the database or its JSON, and puts one back at the terminal.",
3133 ];
3234
3335 /**
@@ -42,7 +44,10 @@ const NOT_YET: readonly string[] = [
4244 "Bindings. A decision is a record here and does nothing elsewhere — no webhook, no issue, no file.",
4345 ];
4446
45 export function about(input: { who: Who }): string {
47 export function about(input: {
48 who: Who;
49 record?: RecordState | undefined;
50 }): string {
4651 const { who } = input;
4752 return layout(
4853 `<section class="card prose">
@@ -90,12 +95,75 @@ export function about(input: { who: Who }): string {
9095 <p class="muted small">Whichever door you come through, a rule holds the
9196 same: the doors read and write, they decide nothing on their own. This is
9297 ${escape(where)}.</p>
93 </section>`,
98 </section>
99 ${record(input.record)}`,
94100 who,
95101 "/about",
96102 );
97103 }
98104
105 /**
106 * The record as a file, for an admin. Nothing is drawn for anyone else:
107 * the caller has no state to draw with, because `recordState` refused it.
108 */
109 export const record = (state: RecordState | undefined): string => {
110 if (state === undefined) return "";
111 const kept = state.snapshots[0];
112 return `<section class="card prose" id="record">
113 <h2>The record</h2>
114 <p>Everything this group has decided is one SQLite file under
115 <code>${escape(state.dataDir)}</code>. This is ${escape(where)}, and it
116 has its own: a preview and production never share a file, so a copy taken
117 here is a copy of what is here.</p>
118 <dl class="facts">
119 <dt>Database</dt><dd>${escape(size(state.databaseBytes))}${state.writable ? "" : " · <b>not writable</b>"}</dd>
120 <dt>Schema version</dt><dd>${state.schemaVersion}${
121 state.schemaVersion === state.migrationsKnown
122 ? ", which is this build’s"
123 : ` · this build knows ${state.migrationsKnown}`
124 }</dd>
125 <dt>Last snapshot</dt><dd>${
126 kept === undefined
127 ? "none kept beside the database"
128 : `${escape(ago(kept.takenAt))} · ${escape(size(kept.bytes))} · <code>${escape(kept.name)}</code>`
129 }</dd>
130 </dl>
131 <p class="row wrap">
132 <a class="button" href="/api/backup.db">Download the database</a>
133 <a class="button button-quiet" href="/api/backup.json">Download it as JSON</a>
134 </p>
135 <p class="muted small">A download is taken with <code>VACUUM INTO</code>
136 at the moment you ask, so a write in flight cannot tear it. Putting one
137 back is deliberate and off the web —
138 <code>bun run src/backup.ts --restore &lt;file&gt;</code> at the terminal,
139 which moves the current file aside rather than deleting it, and refuses a
140 file whose schema is newer than this build.
141 <code>bun run src/backup.ts --snapshot</code> keeps a copy beside the
142 database before anything risky. ${terminalHere()}</p>
143 </section>`;
144 };
145
146 /**
147 * How to reach the keyboard those commands are typed at — the exact
148 * command the forge gave this process, never a guess at one.
149 */
150 const terminalHere = (): string =>
151 ssh === undefined || ssh === ""
152 ? "The terminal is off in this environment, so a copy taken here can only be downloaded."
153 : `The terminal is <code>${escape(ssh)}</code>.`;
154
155 /** Bytes, for a person: never more than three significant figures. */
156 const size = (bytes: number): string => {
157 const units = ["B", "KB", "MB", "GB"];
158 let n = bytes;
159 let unit = 0;
160 while (n >= 1024 && unit < units.length - 1) {
161 n /= 1024;
162 unit += 1;
163 }
164 return `${unit === 0 ? n : n.toFixed(n < 10 ? 1 : 0)} ${units[unit]}`;
165 };
166
99167 /**
100168 * The three rules as the quorum module spells them. A caller passes nothing
101169 * and gets `RULES`; a test passes its own list and sees it printed, which is
src/routes.ts +48 −1
@@ -9,17 +9,21 @@ import {
99 acceptCounter,
1010 addRemark,
1111 deleteRemark,
12 exportRecord,
13 exportSnapshot,
1214 getProposal,
1315 health,
1416 listProposals,
1517 listRemarks,
1618 propose,
19 recordState,
1720 withdrawProposal,
1821 type Failure,
1922 type Result,
2023 } from "./actions.ts";
2124 import {
2225 back,
26 download,
2327 fields,
2428 html,
2529 json,
@@ -116,7 +120,18 @@ export const routes: readonly Route[] = [
116120 },
117121 // What this app is, for a first visit: prose, and every fact in it read
118122 // from the module that owns it.
119 { method: "GET", path: "/about", handle: (c) => html(about({ who: c.who })) },
123 {
124 method: "GET",
125 path: "/about",
126 handle: (c) => {
127 // The action decides who sees the record's state; a guest and a user
128 // get a refusal here, and the page draws nothing at all from it.
129 const record = recordState(c.db, c.who);
130 return html(
131 about({ who: c.who, record: record.ok ? record.value : undefined }),
132 );
133 },
134 },
120135 {
121136 method: "GET",
122137 path: "/p/:id",
@@ -304,6 +319,38 @@ export const routes: readonly Route[] = [
304319 },
305320 },
306321
322 // The record, as a file to keep. Both doors are the same action, which is
323 // where the admin check lives; a restore is not here and never will be —
324 // it is `bun run src/backup.ts --restore <file>`, at the terminal.
325 {
326 method: "GET",
327 path: "/api/backup.db",
328 handle: (c) => {
329 const taken = exportSnapshot(c.db, c.who);
330 return taken.ok
331 ? download(
332 taken.value.bytes,
333 taken.value.filename,
334 "application/vnd.sqlite3",
335 )
336 : answer(taken);
337 },
338 },
339 {
340 method: "GET",
341 path: "/api/backup.json",
342 handle: (c) => {
343 const taken = exportRecord(c.db, c.who);
344 if (!taken.ok) return answer(taken);
345 const { filename, ...record } = taken.value;
346 return download(
347 JSON.stringify(record),
348 filename,
349 "application/json; charset=utf-8",
350 );
351 },
352 },
353
307354 // Live events, and the MCP server (the tools are the actions too).
308355 { method: "GET", path: "/events", handle: (c) => events(c.request) },
309356 {
static/app.css +21
@@ -306,6 +306,27 @@ code,
306306 gap: var(--s4);
307307 grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
308308 }
309 /* The admin's panel on that page: a fact per row, two columns where there
310 is room and one where there is not — and controls that wrap rather than
311 push the page sideways on a phone. */
312 .facts {
313 margin: 0;
314 display: grid;
315 gap: var(--s2) var(--s4);
316 grid-template-columns: auto 1fr;
317 }
318 .facts dt {
319 color: var(--muted);
320 font-size: var(--text-sm);
321 }
322 .facts dd {
323 margin: 0;
324 overflow-wrap: anywhere;
325 }
326 .wrap {
327 flex-wrap: wrap;
328 row-gap: var(--s2);
329 }
309330 footer {
310331 margin-top: var(--s6);
311332 font-size: var(--text-sm);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs