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

Merged opened this

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

Merged

hearth/quorum:hearth/14-an-operator-can-see-the-app-s-state-api → main 1 commit fork

  • Merged. hearth/14-an-operator-can-see-the-app-s-state-api was merged into main as 9b3a459.

Review

Sign in to review this change.
9 files changed +353 −11
README.md +16 −2
@@ -23,7 +23,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
2323
2424 | file | what it is |
2525 | ---------------------------------- | ------------------------------------------------------------ |
26 | `src/actions.ts` | propose, list, read, withdraw — and who may |
26 | `src/actions.ts` | propose, list, read, withdraw, health — and who may |
2727 | `src/who.ts` | who is asking, from the forge's signed headers |
2828 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
2929 | `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
6767 curl -X POST https://<app>/api/proposals -H 'content-type: application/json' \
6868 -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"]}'
6969 curl -X POST https://<app>/api/proposals/1/withdraw
70 curl https://<app>/api/health # 200 while sound, 503 when it is not
7071 ```
7172
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
7285 A proposal is a title (1–120 characters), a paragraph (1–2000) and two to
7386 eight options, each labelled, each different. Its author or an admin can
7487 withdraw it while it is open, once. `rule` says how it will be decided and
7588 is `majority` for now.
7689
7790 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):
7993
8094 ```sh
8195 claude mcp add --transport http __TITLE__ https://<app>/mcp
src/actions.test.ts +52 −1
@@ -5,11 +5,13 @@ import { join } from "node:path";
55 import {
66 canWithdraw,
77 getProposal,
8 health,
89 listProposals,
910 propose,
1011 withdrawProposal,
12 type AdminHealth,
1113 } from "./actions.ts";
12 import { openDb } from "./db.ts";
14 import { migrations, openDb } from "./db.ts";
1315 import type { Who } from "./who.ts";
1416
1517 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", () => {
157159 const capped = listProposals(db, anon, { limit: 1 });
158160 expect(capped.ok && capped.value.length).toBe(1);
159161 });
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 @@
77 */
88
99 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";
1014 import { emit } from "./live.ts";
1115 import type { Who } from "./who.ts";
1216
@@ -209,6 +213,93 @@ export function withdrawProposal(
209213 return ok(withdrawn);
210214 }
211215
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
212303 /**
213304 * Whether `who` may withdraw `proposal` at all. The state is the action's
214305 * 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 {
8383 })();
8484 }
8585
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
8889 .query<
8990 { version: number },
9091 []
91 >("SELECT max(version) AS version FROM schema_version")
92 >("SELECT coalesce(max(version), 0) AS version FROM schema_version")
9293 .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);
93106 const integrity = db
94107 .query<{ integrity_check: string }, []>("PRAGMA integrity_check")
95108 .get()!.integrity_check;
src/env.ts +38 −1
@@ -18,9 +18,46 @@ export const where: string =
1818 : env;
1919 /** The exact command that opens this environment's terminal. Print it verbatim, never a guess. */
2020 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";
2123 /** The address the forge serves us at — what a browser sees, never the socket's. */
2224 export const publicOrigin: string = new URL(
23 process.env.FORGE_APP_URL ?? "http://localhost",
25 process.env.FORGE_APP_URL ?? DEFAULT_ORIGIN,
2426 ).origin;
2527 /** The key the forge signs identities with (`who.ts`). Unset, everyone is anonymous. */
2628 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 @@
99 import type { Database } from "bun:sqlite";
1010 import {
1111 getProposal,
12 health,
1213 listProposals,
1314 propose,
1415 withdrawProposal,
@@ -21,7 +22,7 @@ import {
2122 TITLE_MAX,
2223 type Result,
2324 } from "./actions.ts";
24 import { title } from "./env.ts";
25 import { identityState, title } from "./env.ts";
2526 import { fields } from "./http.ts";
2627 import type { Who } from "./who.ts";
2728
@@ -33,6 +34,7 @@ interface Tool {
3334 db: Database,
3435 who: Who,
3536 args: Record<string, unknown>,
37 identity: "verified" | "disabled",
3638 ) => Result<unknown>;
3739 }
3840
@@ -116,6 +118,13 @@ export const tools: readonly Tool[] = [
116118 },
117119 run: (db, who, args) => withdrawProposal(db, who, { id: Number(args.id) }),
118120 },
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 },
119128 ];
120129
121130 /** 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(
123132 db: Database,
124133 who: Who,
125134 message: unknown,
135 identity: "verified" | "disabled" = identityState(),
126136 ): { status: number; body?: unknown } {
127137 const m = fields(message);
128138 const id = m.id ?? null;
@@ -159,7 +169,7 @@ export function rpc(
159169 const tool = tools.find((t) => t.name === params.name);
160170 if (tool === undefined)
161171 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);
163173 return reply(
164174 result.ok
165175 ? { content: [{ type: "text", text: JSON.stringify(result.value) }] }
src/routes.ts +15 −1
@@ -7,6 +7,7 @@
77 import type { Database } from "bun:sqlite";
88 import {
99 getProposal,
10 health,
1011 listProposals,
1112 propose,
1213 withdrawProposal,
@@ -37,6 +38,8 @@ export interface Context {
3738 readonly db: Database;
3839 readonly who: Who;
3940 readonly params: Record<string, string>;
41 /** Whether this server checks identities at all — the fact, never the key. */
42 readonly identity: "verified" | "disabled";
4043 }
4144
4245 export interface Route {
@@ -186,6 +189,17 @@ export const routes: readonly Route[] = [
186189 answer(withdrawProposal(c.db, c.who, { id: Number(c.params.id) })),
187190 },
188191
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
189203 // Live events, and the MCP server (the tools are the actions too).
190204 { method: "GET", path: "/events", handle: (c) => events(c.request) },
191205 {
@@ -200,7 +214,7 @@ export const routes: readonly Route[] = [
200214 id: null,
201215 error: { code: -32700, message: "parse error" },
202216 });
203 const { status, body } = rpc(c.db, c.who, message);
217 const { status, body } = rpc(c.db, c.who, message, c.identity);
204218 return body === undefined
205219 ? new Response(null, { status })
206220 : json(body, status);
src/server.test.ts +98
@@ -1,7 +1,10 @@
11 import { afterAll, expect, test } from "bun:test";
2 import { Database } from "bun:sqlite";
23 import { mkdtempSync } from "node:fs";
34 import { tmpdir } from "node:os";
45 import { join } from "node:path";
6 import { migrations } from "./db.ts";
7 import { warnings } from "./env.ts";
58 import { serve } from "./server.ts";
69 import { sign } from "./who.ts";
710
@@ -239,6 +242,7 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
239242 "get_proposal",
240243 "propose",
241244 "withdraw_proposal",
245 "health",
242246 ]);
243247 const call = (name: string, args: unknown, who = {}) =>
244248 rpc(
@@ -277,6 +281,100 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
277281 expect(notified.status).toBe(202);
278282 });
279283
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
280378 test("a change is on the event stream, and a late client hears what it missed", async () => {
281379 const stream = await fetch(base + "/events");
282380 expect(stream.headers.get("content-type")).toBe("text/event-stream");
src/server.ts +15 −1
@@ -7,7 +7,14 @@
77 */
88
99 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";
1118 import { allow, html, sameOrigin, text } from "./http.ts";
1219 import { match } from "./routes.ts";
1320 import { who } from "./who.ts";
@@ -21,6 +28,9 @@ export interface ServeOptions {
2128
2229 export function serve(options: ServeOptions) {
2330 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);
2434 return Bun.serve({
2535 port: options.port,
2636 hostname: options.hostname ?? "127.0.0.1",
@@ -52,6 +62,7 @@ export function serve(options: ServeOptions) {
5262 db,
5363 who: me,
5464 params: found.params,
65 identity,
5566 });
5667 // HEAD is GET without the body: a health check sees the app up.
5768 return request.method === "HEAD"
@@ -81,4 +92,7 @@ if (import.meta.main) {
8192 console.log(
8293 `${title} listening on http://${server.hostname}:${server.port} (${where})`,
8394 );
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}`);
8498 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs