Public: anyone can read this; the crew can change it.
An operator can see the app's state: /api/health and a boot line that names what is missing #22
Check · Review · Land · Live done — Landed and live in production.
hearth/quorum:hearth/14-an-operator-can-see-the-app-s-state-api → main 1 commit fork
9 files
changed +353
−11
README.md +16 −2
| @@ -23,7 +23,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 23 | 23 | |
| 24 | 24 | | file | what it is | |
| 25 | 25 | | ---------------------------------- | ------------------------------------------------------------ | |
| 26 | | `src/actions.ts` | propose, list, read, withdraw — and who may | | |
| 26 | | `src/actions.ts` | propose, list, read, withdraw, health — and who may | | |
| 27 | 27 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 28 | 28 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 29 | 29 | | `src/live.ts` | the in-process bus and `GET /events` | |
| @@ -67,15 +67,29 @@ curl https://<app>/api/proposals/1 # one, with its options in order | ||
| 67 | 67 | curl -X POST https://<app>/api/proposals -H 'content-type: application/json' \ |
| 68 | 68 | -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"]}' |
| 69 | 69 | curl -X POST https://<app>/api/proposals/1/withdraw |
| 70 | curl https://<app>/api/health # 200 while sound, 503 when it is not | |
| 70 | 71 | |
| 71 | 72 | |
| 73 | `GET /api/health` is the route a probe can hit forever: `{ok, env, commit, | |
| 74 | schemaVersion, uptimeSeconds}` for anyone, no write, no count of anybody's | |
| 75 | data, well under a millisecond. `ok` is false — and the status 503 — when the | |
| 76 | schema in the file is not the one this code knows, when `FORGE_APP_DATA` has | |
| 77 | stopped taking writes, or when SQLite reports the file corrupt (a | |
| 78 | `quick_check`, remembered for a minute so a probe stays cheap). An admin | |
| 79 | asking gets `{identity, dataDir, databaseBytes, writable, terminal}` on top: | |
| 80 | `identity` is `verified` when `FORGE_APP_IDENTITY_KEY` is set and `disabled` | |
| 81 | when it is not, and `terminal` is `FORGE_APP_SSH` exactly as the forge gave | |
| 82 | it, or `off`. At boot the app prints one line per misconfiguration — the name | |
| 83 | of the variable and what it costs, never its value. | |
| 84 | ||
| 72 | 85 | A proposal is a title (1–120 characters), a paragraph (1–2000) and two to |
| 73 | 86 | eight options, each labelled, each different. Its author or an admin can |
| 74 | 87 | withdraw it while it is open, once. `rule` says how it will be decided and |
| 75 | 88 | is `majority` for now. |
| 76 | 89 | |
| 77 | 90 | As an MCP server, for Claude Code or Claude Desktop — `list_proposals`, |
| 78 | `get_proposal`, `propose`, `withdraw_proposal`: | |
| 91 | `get_proposal`, `propose`, `withdraw_proposal`, `health` (the same action | |
| 92 | behind `/api/health`, gated the same way): | |
| 79 | 93 | |
| 80 | 94 | sh |
| 81 | 95 | claude mcp add --transport http __TITLE__ https://<app>/mcp |
src/actions.test.ts +52 −1
| @@ -5,11 +5,13 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { |
| 6 | 6 | canWithdraw, |
| 7 | 7 | getProposal, |
| 8 | health, | |
| 8 | 9 | listProposals, |
| 9 | 10 | propose, |
| 10 | 11 | withdrawProposal, |
| 12 | type AdminHealth, | |
| 11 | 13 | } from "./actions.ts"; |
| 12 | import { openDb } from "./db.ts"; | |
| 14 | import { migrations, openDb } from "./db.ts"; | |
| 13 | 15 | import type { Who } from "./who.ts"; |
| 14 | 16 | |
| 15 | 17 | const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); |
| @@ -157,3 +159,52 @@ test("a state that is not a state is refused, and a limit is bounded", () => { | ||
| 157 | 159 | const capped = listProposals(db, anon, { limit: 1 }); |
| 158 | 160 | expect(capped.ok && capped.value.length).toBe(1); |
| 159 | 161 | }); |
| 162 | ||
| 163 | const PUBLIC_HEALTH = ["commit", "env", "ok", "schemaVersion", "uptimeSeconds"]; | |
| 164 | ||
| 165 | test("health tells anyone how the app is and nobody else where it lives", () => { | |
| 166 | for (const who of [anon, alice]) { | |
| 167 | const r = health(db, who); | |
| 168 | expect(r.ok).toBe(true); | |
| 169 | if (!r.ok) throw new Error(r.message); | |
| 170 | expect(Object.keys(r.value).sort()).toEqual(PUBLIC_HEALTH); | |
| 171 | expect(r.value.ok).toBe(true); | |
| 172 | expect(r.value.schemaVersion).toBe(migrations.length); | |
| 173 | expect(r.value.uptimeSeconds).toBeGreaterThanOrEqual(0); | |
| 174 | } | |
| 175 | ||
| 176 | const mine = health(db, root); | |
| 177 | if (!mine.ok) throw new Error(mine.message); | |
| 178 | expect(Object.keys(mine.value).sort()).toEqual( | |
| 179 | [ | |
| 180 | ...PUBLIC_HEALTH, | |
| 181 | "dataDir", | |
| 182 | "databaseBytes", | |
| 183 | "identity", | |
| 184 | "terminal", | |
| 185 | "writable", | |
| 186 | ].sort(), | |
| 187 | ); | |
| 188 | const seen = mine.value as AdminHealth; | |
| 189 | expect(seen.writable).toBe(true); | |
| 190 | expect(seen.databaseBytes).toBeGreaterThan(0); | |
| 191 | // The terminal is the forge's own command, echoed exactly, or the word off. | |
| 192 | expect(seen.terminal).toBe(process.env.FORGE_APP_SSH || "off"); | |
| 193 | for (const identity of ["verified", "disabled"] as const) { | |
| 194 | const answer = health(db, root, { identity }); | |
| 195 | expect(answer.ok && (answer.value as AdminHealth).identity).toBe(identity); | |
| 196 | } | |
| 197 | }); | |
| 198 | ||
| 199 | test("a schema behind the migration list is not ok", () => { | |
| 200 | const behind = openDb(mkdtempSync(join(tmpdir(), "app-behind-"))); | |
| 201 | behind | |
| 202 | .query("DELETE FROM schema_version WHERE version = ?1") | |
| 203 | .run(migrations.length); | |
| 204 | const r = health(behind, anon); | |
| 205 | expect(r.ok).toBe(true); | |
| 206 | if (!r.ok) return; | |
| 207 | expect(r.value.ok).toBe(false); | |
| 208 | expect(r.value.schemaVersion).toBe(migrations.length - 1); | |
| 209 | behind.close(); | |
| 210 | }); |
src/actions.ts +91
| @@ -7,6 +7,10 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { accessSync, constants, statSync } from "node:fs"; | |
| 11 | import { dirname } from "node:path"; | |
| 12 | import { migrations, quickCheck, schemaVersion } from "./db.ts"; | |
| 13 | import { commit, env, identityState, ssh } from "./env.ts"; | |
| 10 | 14 | import { emit } from "./live.ts"; |
| 11 | 15 | import type { Who } from "./who.ts"; |
| 12 | 16 | |
| @@ -209,6 +213,93 @@ export function withdrawProposal( | ||
| 209 | 213 | return ok(withdrawn); |
| 210 | 214 | } |
| 211 | 215 | |
| 216 | /** What anyone may know about this process: nothing about anybody's data. */ | |
| 217 | export interface Health { | |
| 218 | readonly ok: boolean; | |
| 219 | readonly env: string; | |
| 220 | readonly commit: string; | |
| 221 | readonly schemaVersion: number; | |
| 222 | readonly uptimeSeconds: number; | |
| 223 | } | |
| 224 | ||
| 225 | /** What an admin may know on top: where the state is, and what is switched on. */ | |
| 226 | export interface AdminHealth extends Health { | |
| 227 | readonly identity: "verified" | "disabled"; | |
| 228 | readonly dataDir: string; | |
| 229 | readonly databaseBytes: number; | |
| 230 | readonly writable: boolean; | |
| 231 | /** The command that opens the terminal, exactly as the forge gave it, or `off`. */ | |
| 232 | readonly terminal: string; | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * How this process is — the answer a probe hits forever, so it writes | |
| 237 | * nothing, counts nothing and walks nothing on the common request: a version | |
| 238 | * read, two stats, and a corruption check at most once a minute. The extra | |
| 239 | * fields are gated here rather than at the door, so the API, MCP and the | |
| 240 | * terminal all show a guest exactly the same five facts. | |
| 241 | */ | |
| 242 | export function health( | |
| 243 | db: Database, | |
| 244 | who: Who, | |
| 245 | input: { identity?: "verified" | "disabled" | undefined } = {}, | |
| 246 | ): Result<Health | AdminHealth> { | |
| 247 | const version = schemaVersion(db); | |
| 248 | const dir = dirname(db.filename); | |
| 249 | const writable = canWrite(dir); | |
| 250 | // The version the file carries is the version this code knows, or the two | |
| 251 | // have drifted and no request can be trusted to mean what it says. | |
| 252 | const sound = | |
| 253 | version === migrations.length && writable && soundness(db) === "ok"; | |
| 254 | const state: Health = { | |
| 255 | ok: sound, | |
| 256 | env, | |
| 257 | commit, | |
| 258 | schemaVersion: version, | |
| 259 | uptimeSeconds: Math.floor(process.uptime()), | |
| 260 | }; | |
| 261 | if (who.role !== "admin") return ok(state); | |
| 262 | return ok({ | |
| 263 | ...state, | |
| 264 | identity: input.identity ?? identityState(), | |
| 265 | dataDir: dir, | |
| 266 | databaseBytes: bytes(db.filename) + bytes(`${db.filename}-wal`), | |
| 267 | writable, | |
| 268 | terminal: ssh === undefined || ssh === "" ? "off" : ssh, | |
| 269 | }); | |
| 270 | } | |
| 271 | ||
| 272 | /** Whether the data directory takes writes, asked of the filesystem, never by writing. */ | |
| 273 | const canWrite = (dir: string): boolean => { | |
| 274 | try { | |
| 275 | accessSync(dir, constants.W_OK); | |
| 276 | return true; | |
| 277 | } catch { | |
| 278 | return false; | |
| 279 | } | |
| 280 | }; | |
| 281 | ||
| 282 | const bytes = (path: string): number => { | |
| 283 | try { | |
| 284 | return statSync(path).size; | |
| 285 | } catch { | |
| 286 | return 0; | |
| 287 | } | |
| 288 | }; | |
| 289 | ||
| 290 | // Corruption does not come and go, and reading every page on every probe is | |
| 291 | // exactly the cost this route must not have: the verdict is remembered for a | |
| 292 | // minute per database. | |
| 293 | const SOUNDNESS_TTL = 60_000; | |
| 294 | const soundnessSeen = new WeakMap<Database, { at: number; verdict: string }>(); | |
| 295 | function soundness(db: Database, now = Date.now()): string { | |
| 296 | const seen = soundnessSeen.get(db); | |
| 297 | if (seen !== undefined && now - seen.at < SOUNDNESS_TTL) return seen.verdict; | |
| 298 | const verdict = quickCheck(db); | |
| 299 | soundnessSeen.set(db, { at: now, verdict }); | |
| 300 | return verdict; | |
| 301 | } | |
| 302 | ||
| 212 | 303 | /** |
| 213 | 304 | * Whether `who` may withdraw `proposal` at all. The state is the action's |
| 214 | 305 | * own check; the page and the terminal ask this and the state together, so |
src/db.ts +16 −3
| @@ -83,13 +83,26 @@ export function migrate(db: Database): number { | ||
| 83 | 83 | })(); |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | export function check(db: Database): { version: number; integrity: string } { | |
| 87 | const version = db | |
| 86 | /** The version recorded in the file. Zero before a migration has ever run. */ | |
| 87 | export const schemaVersion = (db: Database): number => | |
| 88 | db | |
| 88 | 89 | .query< |
| 89 | 90 | { version: number }, |
| 90 | 91 | [] |
| 91 | >("SELECT max(version) AS version FROM schema_version") | |
| 92 | >("SELECT coalesce(max(version), 0) AS version FROM schema_version") | |
| 92 | 93 | .get()!.version; |
| 94 | ||
| 95 | /** | |
| 96 | * A cheap corruption check: `quick_check` reads the pages without the index | |
| 97 | * cross-checks `integrity_check` does, and stops at the first fault. This is | |
| 98 | * what a health probe can afford; `--check` below does the thorough one. | |
| 99 | */ | |
| 100 | export const quickCheck = (db: Database): string => | |
| 101 | db.query<{ quick_check: string }, []>("PRAGMA quick_check(1)").get()! | |
| 102 | .quick_check; | |
| 103 | ||
| 104 | export function check(db: Database): { version: number; integrity: string } { | |
| 105 | const version = schemaVersion(db); | |
| 93 | 106 | const integrity = db |
| 94 | 107 | .query<{ integrity_check: string }, []>("PRAGMA integrity_check") |
| 95 | 108 | .get()!.integrity_check; |
src/env.ts +38 −1
| @@ -18,9 +18,46 @@ export const where: string = | ||
| 18 | 18 | : env; |
| 19 | 19 | /** The exact command that opens this environment's terminal. Print it verbatim, never a guess. */ |
| 20 | 20 | export const ssh: string | undefined = process.env.FORGE_APP_SSH; |
| 21 | /** The origin a `FORGE_APP_URL` falls back to, and the sign it was never set. */ | |
| 22 | const DEFAULT_ORIGIN = "http://localhost"; | |
| 21 | 23 | /** The address the forge serves us at — what a browser sees, never the socket's. */ |
| 22 | 24 | export const publicOrigin: string = new URL( |
| 23 | process.env.FORGE_APP_URL ?? "http://localhost", | |
| 25 | process.env.FORGE_APP_URL ?? DEFAULT_ORIGIN, | |
| 24 | 26 | ).origin; |
| 25 | 27 | /** The key the forge signs identities with (`who.ts`). Unset, everyone is anonymous. */ |
| 26 | 28 | export const identityKey: string = process.env.FORGE_APP_IDENTITY_KEY ?? ""; |
| 29 | ||
| 30 | /** Whether identities are checked at all — the fact about the key, never the key. */ | |
| 31 | export const identityState = ( | |
| 32 | key: string = identityKey, | |
| 33 | ): "verified" | "disabled" => (key === "" ? "disabled" : "verified"); | |
| 34 | ||
| 35 | /** | |
| 36 | * What is misconfigured about this process, one line per real problem, each | |
| 37 | * naming the variable and what it costs. Nothing is a warning on its own: | |
| 38 | * an empty list is a healthy boot. A value is never printed — an operator | |
| 39 | * needs the name of the variable, not what is in it. | |
| 40 | */ | |
| 41 | export function warnings( | |
| 42 | e: Record<string, string | undefined> = process.env, | |
| 43 | ): string[] { | |
| 44 | const lines: string[] = []; | |
| 45 | if ((e.FORGE_APP_IDENTITY_KEY ?? "") === "") | |
| 46 | lines.push( | |
| 47 | "FORGE_APP_IDENTITY_KEY is empty: every request is anonymous, nobody can sign in and nobody can write. Set it under Settings → App → Environment.", | |
| 48 | ); | |
| 49 | if (originOf(e.FORGE_APP_URL) === DEFAULT_ORIGIN) | |
| 50 | lines.push( | |
| 51 | `FORGE_APP_URL is unset or ${DEFAULT_ORIGIN}: cookies will not be Secure and same-origin checks compare against ${DEFAULT_ORIGIN}.`, | |
| 52 | ); | |
| 53 | return lines; | |
| 54 | } | |
| 55 | ||
| 56 | /** An origin from whatever the variable held; the default when it held nothing usable. */ | |
| 57 | function originOf(url: string | undefined): string { | |
| 58 | try { | |
| 59 | return new URL(url ?? DEFAULT_ORIGIN).origin; | |
| 60 | } catch { | |
| 61 | return DEFAULT_ORIGIN; | |
| 62 | } | |
| 63 | } |
src/mcp.ts +12 −2
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { |
| 11 | 11 | getProposal, |
| 12 | health, | |
| 12 | 13 | listProposals, |
| 13 | 14 | propose, |
| 14 | 15 | withdrawProposal, |
| @@ -21,7 +22,7 @@ import { | ||
| 21 | 22 | TITLE_MAX, |
| 22 | 23 | type Result, |
| 23 | 24 | } from "./actions.ts"; |
| 24 | import { title } from "./env.ts"; | |
| 25 | import { identityState, title } from "./env.ts"; | |
| 25 | 26 | import { fields } from "./http.ts"; |
| 26 | 27 | import type { Who } from "./who.ts"; |
| 27 | 28 | |
| @@ -33,6 +34,7 @@ interface Tool { | ||
| 33 | 34 | db: Database, |
| 34 | 35 | who: Who, |
| 35 | 36 | args: Record<string, unknown>, |
| 37 | identity: "verified" | "disabled", | |
| 36 | 38 | ) => Result<unknown>; |
| 37 | 39 | } |
| 38 | 40 | |
| @@ -116,6 +118,13 @@ export const tools: readonly Tool[] = [ | ||
| 116 | 118 | }, |
| 117 | 119 | run: (db, who, args) => withdrawProposal(db, who, { id: Number(args.id) }), |
| 118 | 120 | }, |
| 121 | { | |
| 122 | name: "health", | |
| 123 | description: | |
| 124 | "How this app is: ok, environment, commit, schema version and uptime. An admin also sees where the state lives, how big it is and how to reach the terminal.", | |
| 125 | inputSchema: { type: "object", properties: {} }, | |
| 126 | run: (db, who, _args, identity) => health(db, who, { identity }), | |
| 127 | }, | |
| 119 | 128 | ]; |
| 120 | 129 | |
| 121 | 130 | /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */ |
| @@ -123,6 +132,7 @@ export function rpc( | ||
| 123 | 132 | db: Database, |
| 124 | 133 | who: Who, |
| 125 | 134 | message: unknown, |
| 135 | identity: "verified" | "disabled" = identityState(), | |
| 126 | 136 | ): { status: number; body?: unknown } { |
| 127 | 137 | const m = fields(message); |
| 128 | 138 | const id = m.id ?? null; |
| @@ -159,7 +169,7 @@ export function rpc( | ||
| 159 | 169 | const tool = tools.find((t) => t.name === params.name); |
| 160 | 170 | if (tool === undefined) |
| 161 | 171 | return refuse(-32602, `no tool named ${String(params.name)}`); |
| 162 | const result = tool.run(db, who, fields(params.arguments)); | |
| 172 | const result = tool.run(db, who, fields(params.arguments), identity); | |
| 163 | 173 | return reply( |
| 164 | 174 | result.ok |
| 165 | 175 | ? { content: [{ type: "text", text: JSON.stringify(result.value) }] } |
src/routes.ts +15 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { |
| 9 | 9 | getProposal, |
| 10 | health, | |
| 10 | 11 | listProposals, |
| 11 | 12 | propose, |
| 12 | 13 | withdrawProposal, |
| @@ -37,6 +38,8 @@ export interface Context { | ||
| 37 | 38 | readonly db: Database; |
| 38 | 39 | readonly who: Who; |
| 39 | 40 | readonly params: Record<string, string>; |
| 41 | /** Whether this server checks identities at all — the fact, never the key. */ | |
| 42 | readonly identity: "verified" | "disabled"; | |
| 40 | 43 | } |
| 41 | 44 | |
| 42 | 45 | export interface Route { |
| @@ -186,6 +189,17 @@ export const routes: readonly Route[] = [ | ||
| 186 | 189 | answer(withdrawProposal(c.db, c.who, { id: Number(c.params.id) })), |
| 187 | 190 | }, |
| 188 | 191 | |
| 192 | // What a probe asks, forever: 200 while the app is sound, 503 when it is | |
| 193 | // not, and never a word about anybody's data. | |
| 194 | { | |
| 195 | method: "GET", | |
| 196 | path: "/api/health", | |
| 197 | handle: (c) => { | |
| 198 | const state = health(c.db, c.who, { identity: c.identity }); | |
| 199 | return answer(state, state.ok && state.value.ok ? 200 : 503); | |
| 200 | }, | |
| 201 | }, | |
| 202 | ||
| 189 | 203 | // Live events, and the MCP server (the tools are the actions too). |
| 190 | 204 | { method: "GET", path: "/events", handle: (c) => events(c.request) }, |
| 191 | 205 | { |
| @@ -200,7 +214,7 @@ export const routes: readonly Route[] = [ | ||
| 200 | 214 | id: null, |
| 201 | 215 | error: { code: -32700, message: "parse error" }, |
| 202 | 216 | }); |
| 203 | const { status, body } = rpc(c.db, c.who, message); | |
| 217 | const { status, body } = rpc(c.db, c.who, message, c.identity); | |
| 204 | 218 | return body === undefined |
| 205 | 219 | ? new Response(null, { status }) |
| 206 | 220 | : json(body, status); |
src/server.test.ts +98
| @@ -1,7 +1,10 @@ | ||
| 1 | 1 | import { afterAll, expect, test } from "bun:test"; |
| 2 | import { Database } from "bun:sqlite"; | |
| 2 | 3 | import { mkdtempSync } from "node:fs"; |
| 3 | 4 | import { tmpdir } from "node:os"; |
| 4 | 5 | import { join } from "node:path"; |
| 6 | import { migrations } from "./db.ts"; | |
| 7 | import { warnings } from "./env.ts"; | |
| 5 | 8 | import { serve } from "./server.ts"; |
| 6 | 9 | import { sign } from "./who.ts"; |
| 7 | 10 | |
| @@ -239,6 +242,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 239 | 242 | "get_proposal", |
| 240 | 243 | "propose", |
| 241 | 244 | "withdraw_proposal", |
| 245 | "health", | |
| 242 | 246 | ]); |
| 243 | 247 | const call = (name: string, args: unknown, who = {}) => |
| 244 | 248 | rpc( |
| @@ -277,6 +281,100 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 277 | 281 | expect(notified.status).toBe(202); |
| 278 | 282 | }); |
| 279 | 283 | |
| 284 | test("/api/health answers a probe, and only an admin sees the environment", async () => { | |
| 285 | const PUBLIC = ["commit", "env", "ok", "schemaVersion", "uptimeSeconds"]; | |
| 286 | for (const who of [{}, alice]) { | |
| 287 | const response = await api("/api/health", {}, who); | |
| 288 | expect(response.status).toBe(200); | |
| 289 | expect(response.headers.get("content-security-policy")).toContain( | |
| 290 | "default-src 'self'", | |
| 291 | ); | |
| 292 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 293 | const body = (await response.json()) as Record<string, unknown>; | |
| 294 | expect(Object.keys(body).sort()).toEqual(PUBLIC); | |
| 295 | expect(body.ok).toBe(true); | |
| 296 | expect(body.schemaVersion).toBe(migrations.length); | |
| 297 | } | |
| 298 | ||
| 299 | const seen = (await (await api("/api/health", {}, root)).json()) as Record< | |
| 300 | string, | |
| 301 | unknown | |
| 302 | >; | |
| 303 | expect(Object.keys(seen).sort()).toEqual( | |
| 304 | [ | |
| 305 | ...PUBLIC, | |
| 306 | "dataDir", | |
| 307 | "databaseBytes", | |
| 308 | "identity", | |
| 309 | "terminal", | |
| 310 | "writable", | |
| 311 | ].sort(), | |
| 312 | ); | |
| 313 | // This server verifies signatures, so it says so; the key itself is nowhere. | |
| 314 | expect(seen.identity).toBe("verified"); | |
| 315 | expect(seen.writable).toBe(true); | |
| 316 | expect(seen.databaseBytes).toBeGreaterThan(0); | |
| 317 | expect(seen.terminal).toBe(process.env.FORGE_APP_SSH || "off"); | |
| 318 | expect(JSON.stringify(seen)).not.toContain(KEY); | |
| 319 | ||
| 320 | // The same shape through the same action, over MCP. | |
| 321 | const called = await rpc( | |
| 322 | { | |
| 323 | jsonrpc: "2.0", | |
| 324 | id: 9, | |
| 325 | method: "tools/call", | |
| 326 | params: { name: "health", arguments: {} }, | |
| 327 | }, | |
| 328 | root, | |
| 329 | ); | |
| 330 | const tool = JSON.parse(called.result.content[0].text) as Record< | |
| 331 | string, | |
| 332 | unknown | |
| 333 | >; | |
| 334 | expect(Object.keys(tool).sort()).toEqual(Object.keys(seen).sort()); | |
| 335 | expect(tool.identity).toBe("verified"); | |
| 336 | }); | |
| 337 | ||
| 338 | test("a schema behind the migration list answers 503", async () => { | |
| 339 | const dir = mkdtempSync(join(tmpdir(), "app-behind-")); | |
| 340 | const behind = serve({ port: 0, dataDir: dir, identityKey: KEY }); | |
| 341 | // The file is the one the server holds open: take the last migration off it. | |
| 342 | const file = new Database(join(dir, "app.db")); | |
| 343 | file | |
| 344 | .query("DELETE FROM schema_version WHERE version = ?1") | |
| 345 | .run(migrations.length); | |
| 346 | file.close(); | |
| 347 | const response = await fetch( | |
| 348 | `http://${behind.hostname}:${behind.port}/api/health`, | |
| 349 | ); | |
| 350 | expect(response.status).toBe(503); | |
| 351 | expect(((await response.json()) as { ok: boolean }).ok).toBe(false); | |
| 352 | behind.stop(true); | |
| 353 | }); | |
| 354 | ||
| 355 | test("boot names what is missing, and never what is in it", () => { | |
| 356 | expect( | |
| 357 | warnings({ | |
| 358 | FORGE_APP_IDENTITY_KEY: KEY, | |
| 359 | FORGE_APP_URL: "https://quorum.example", | |
| 360 | }), | |
| 361 | ).toEqual([]); | |
| 362 | ||
| 363 | const bare = warnings({}); | |
| 364 | expect(bare.length).toBe(2); | |
| 365 | expect(bare[0]).toContain("FORGE_APP_IDENTITY_KEY"); | |
| 366 | expect(bare[0]).toContain("nobody can write"); | |
| 367 | expect(bare[1]).toContain("FORGE_APP_URL"); | |
| 368 | expect(bare[1]).toContain("http://localhost"); | |
| 369 | ||
| 370 | // The key is named, never quoted — not whole and not in part. | |
| 371 | const quiet = warnings({ FORGE_APP_IDENTITY_KEY: KEY }); | |
| 372 | expect(quiet.length).toBe(1); | |
| 373 | expect(quiet.join(" ")).not.toContain(KEY); | |
| 374 | expect(quiet.join(" ")).not.toContain(KEY.slice(0, 6)); | |
| 375 | expect(quiet[0]).toContain("FORGE_APP_URL"); | |
| 376 | }); | |
| 377 | ||
| 280 | 378 | test("a change is on the event stream, and a late client hears what it missed", async () => { |
| 281 | 379 | const stream = await fetch(base + "/events"); |
| 282 | 380 | expect(stream.headers.get("content-type")).toBe("text/event-stream"); |
src/server.ts +15 −1
| @@ -7,7 +7,14 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { openDb } from "./db.ts"; |
| 10 | import { dataDir, identityKey, title, where } from "./env.ts"; | |
| 10 | import { | |
| 11 | dataDir, | |
| 12 | identityKey, | |
| 13 | identityState, | |
| 14 | title, | |
| 15 | warnings, | |
| 16 | where, | |
| 17 | } from "./env.ts"; | |
| 11 | 18 | import { allow, html, sameOrigin, text } from "./http.ts"; |
| 12 | 19 | import { match } from "./routes.ts"; |
| 13 | 20 | import { who } from "./who.ts"; |
| @@ -21,6 +28,9 @@ export interface ServeOptions { | ||
| 21 | 28 | |
| 22 | 29 | export function serve(options: ServeOptions) { |
| 23 | 30 | const db = openDb(options.dataDir); |
| 31 | // The fact that identities are checked travels with the request; the key | |
| 32 | // itself stays here, where `who` reads it and nothing else can. | |
| 33 | const identity = identityState(options.identityKey); | |
| 24 | 34 | return Bun.serve({ |
| 25 | 35 | port: options.port, |
| 26 | 36 | hostname: options.hostname ?? "127.0.0.1", |
| @@ -52,6 +62,7 @@ export function serve(options: ServeOptions) { | ||
| 52 | 62 | db, |
| 53 | 63 | who: me, |
| 54 | 64 | params: found.params, |
| 65 | identity, | |
| 55 | 66 | }); |
| 56 | 67 | // HEAD is GET without the body: a health check sees the app up. |
| 57 | 68 | return request.method === "HEAD" |
| @@ -81,4 +92,7 @@ if (import.meta.main) { | ||
| 81 | 92 | console.log( |
| 82 | 93 | `${title} listening on http://${server.hostname}:${server.port} (${where})`, |
| 83 | 94 | ); |
| 95 | // One line per real problem, naming the variable and what it costs. A | |
| 96 | // healthy boot says nothing more than the line above. | |
| 97 | for (const warning of warnings()) console.warn(`warning: ${warning}`); | |
| 84 | 98 | } |