Public: anyone can read this; the crew can change it.
The record survives: an admin can export the decisions and restore them #34
Check · Review · Land · Live done — Landed and live in production.
hearth/quorum:hearth/10-the-record-survives-an-admin-can-export → main 1 commit fork 1 thread · 1 unresolved
README.md +25 −1
| @@ -29,6 +29,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 29 | 29 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 30 | 30 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 31 | 31 | | `src/live.ts` | the event log, the bus, and `GET /events` | |
| 32 | | `src/backup.ts` | a copy of the record, and putting one back | | |
| 32 | 33 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | |
| 33 | 34 | | `src/routes.ts` | every path, in one table | |
| 34 | 35 | | `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: | ||
| 49 | 50 | and deletes what they said; on a proposal they opened, they can accept a |
| 50 | 51 | counter. |
| 51 | 52 | - **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. | |
| 53 | 55 | |
| 54 | 56 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 55 | 57 | `/__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 | ||
| 111 | 113 | it, or `off`. At boot the app prints one line per misconfiguration — the name |
| 112 | 114 | of the variable and what it costs, never its value. |
| 113 | 115 | |
| 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 | ||
| 114 | 138 | A proposal is a title (1–120 characters), a paragraph (1–2000) and two to |
| 115 | 139 | eight options, each labelled, each different. Its author or an admin can |
| 116 | 140 | withdraw it while it is open, once. |
src/actions.ts +85 −1
| @@ -9,8 +9,15 @@ | ||
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { accessSync, constants, statSync } from "node:fs"; |
| 11 | 11 | import { dirname } from "node:path"; |
| 12 | import { | |
| 13 | downloadName, | |
| 14 | exportBytes, | |
| 15 | exportJson, | |
| 16 | snapshots, | |
| 17 | type Snapshot, | |
| 18 | } from "./backup.ts"; | |
| 12 | 19 | 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"; | |
| 14 | 21 | import { emit } from "./live.ts"; |
| 15 | 22 | import { parseRule, RULE_FORMS, type Rule } from "./quorum.ts"; |
| 16 | 23 | import type { Who } from "./who.ts"; |
| @@ -436,6 +443,83 @@ export function acceptCounter( | ||
| 436 | 443 | return ok(done); |
| 437 | 444 | } |
| 438 | 445 | |
| 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 | ||
| 439 | 523 | /** Whether a proposal with this id is there at all. */ |
| 440 | 524 | const exists = (db: Database, id: number): boolean => |
| 441 | 525 | 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); | |
Changes requested. (The stray The export half is solid: the admin check lives in the action so both routes and the page inherit it, 1 (high) A restore against the running app silently does not take effect, and the writes made after it are lost.
The operator sees Fix this first: refuse the restore while another process holds the database — open it and take 2 (high) The terminal is not a door for this. 3 (medium) 4 (minor) One test to add while you are in there: the |
||
| 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 { | ||
| 82 | 82 | db.exec("PRAGMA journal_mode = WAL"); |
| 83 | 83 | db.exec("PRAGMA foreign_keys = ON"); |
| 84 | 84 | 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"); | |
| 85 | 89 | migrate(db); |
| 86 | 90 | return db; |
| 87 | 91 | } |
src/http.ts +20
| @@ -58,6 +58,26 @@ export const text = (body: string, status = 200): Response => | ||
| 58 | 58 | headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY }, |
| 59 | 59 | }); |
| 60 | 60 | |
| 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 | ||
| 61 | 81 | /** |
| 62 | 82 | * Back to a page of this app, with a word for it to show once |
| 63 | 83 | * (`readNotice`). The location is the app's own — a path a route built, never |
src/pages/about.ts +71 −3
| @@ -5,11 +5,12 @@ | ||
| 5 | 5 | * `src/env.ts`, so a page that rots is a module that changed under it. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import type { RecordState } from "../actions.ts"; | |
| 8 | 9 | import { ssh, where } from "../env.ts"; |
| 9 | 10 | import { escape } from "../http.ts"; |
| 10 | 11 | import { RULES, RULE_FORMS } from "../quorum.ts"; |
| 11 | 12 | import type { Who } from "../who.ts"; |
| 12 | import { layout } from "./layout.ts"; | |
| 13 | import { ago, layout } from "./layout.ts"; | |
| 13 | 14 | |
| 14 | 15 | /** One rule as a page prints it: whatever `RULES` holds, never a copy of it. */ |
| 15 | 16 | interface RuleEntry { |
| @@ -28,6 +29,7 @@ const WORKS: readonly string[] = [ | ||
| 28 | 29 | "Accept a counter on a proposal you opened: the option it names is added to it, and every ballot already cast still stands.", |
| 29 | 30 | "Withdraw a proposal you opened while it is open; an admin withdraws any.", |
| 30 | 31 | "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.", | |
| 31 | 33 | ]; |
| 32 | 34 | |
| 33 | 35 | /** |
| @@ -42,7 +44,10 @@ const NOT_YET: readonly string[] = [ | ||
| 42 | 44 | "Bindings. A decision is a record here and does nothing elsewhere — no webhook, no issue, no file.", |
| 43 | 45 | ]; |
| 44 | 46 | |
| 45 | export function about(input: { who: Who }): string { | |
| 47 | export function about(input: { | |
| 48 | who: Who; | |
| 49 | record?: RecordState | undefined; | |
| 50 | }): string { | |
| 46 | 51 | const { who } = input; |
| 47 | 52 | return layout( |
| 48 | 53 | `<section class="card prose"> |
| @@ -90,12 +95,75 @@ export function about(input: { who: Who }): string { | ||
| 90 | 95 | <p class="muted small">Whichever door you come through, a rule holds the |
| 91 | 96 | same: the doors read and write, they decide nothing on their own. This is |
| 92 | 97 | ${escape(where)}.</p> |
| 93 | </section>`, | |
| 98 | </section> | |
| 99 | ${record(input.record)}`, | |
| 94 | 100 | who, |
| 95 | 101 | "/about", |
| 96 | 102 | ); |
| 97 | 103 | } |
| 98 | 104 | |
| 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 <file></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 | ||
| 99 | 167 | /** |
| 100 | 168 | * The three rules as the quorum module spells them. A caller passes nothing |
| 101 | 169 | * 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 { | ||
| 9 | 9 | acceptCounter, |
| 10 | 10 | addRemark, |
| 11 | 11 | deleteRemark, |
| 12 | exportRecord, | |
| 13 | exportSnapshot, | |
| 12 | 14 | getProposal, |
| 13 | 15 | health, |
| 14 | 16 | listProposals, |
| 15 | 17 | listRemarks, |
| 16 | 18 | propose, |
| 19 | recordState, | |
| 17 | 20 | withdrawProposal, |
| 18 | 21 | type Failure, |
| 19 | 22 | type Result, |
| 20 | 23 | } from "./actions.ts"; |
| 21 | 24 | import { |
| 22 | 25 | back, |
| 26 | download, | |
| 23 | 27 | fields, |
| 24 | 28 | html, |
| 25 | 29 | json, |
| @@ -116,7 +120,18 @@ export const routes: readonly Route[] = [ | ||
| 116 | 120 | }, |
| 117 | 121 | // What this app is, for a first visit: prose, and every fact in it read |
| 118 | 122 | // 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 | }, | |
| 120 | 135 | { |
| 121 | 136 | method: "GET", |
| 122 | 137 | path: "/p/:id", |
| @@ -304,6 +319,38 @@ export const routes: readonly Route[] = [ | ||
| 304 | 319 | }, |
| 305 | 320 | }, |
| 306 | 321 | |
| 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 | ||
| 307 | 354 | // Live events, and the MCP server (the tools are the actions too). |
| 308 | 355 | { method: "GET", path: "/events", handle: (c) => events(c.request) }, |
| 309 | 356 | { |
static/app.css +21
| @@ -306,6 +306,27 @@ code, | ||
| 306 | 306 | gap: var(); |
| 307 | 307 | grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); |
| 308 | 308 | } |
| 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() var(); | |
| 316 | grid-template-columns: auto 1fr; | |
| 317 | } | |
| 318 | .facts dt { | |
| 319 | color: var(); | |
| 320 | font-size: var(); | |
| 321 | } | |
| 322 | .facts dd { | |
| 323 | margin: 0; | |
| 324 | overflow-wrap: anywhere; | |
| 325 | } | |
| 326 | .wrap { | |
| 327 | flex-wrap: wrap; | |
| 328 | row-gap: var(); | |
| 329 | } | |
| 309 | 330 | footer { |
| 310 | 331 | margin-top: var(); |
| 311 | 332 | font-size: var(); |
probe