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

Boards, step 6c: a security source and the Security starting board #2474

Merged opened this

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

Merged

scout/forge:scout/2460-boards-step-6c-a-security-source-and-the → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/2460-boards-step-6c-a-security-source-and-the was merged into main as 1fe0439.

Review

Sign in to review this change.
6 files changed +68 −8
README.md +1 −1
@@ -1247,7 +1247,7 @@ work here.
12471247 `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit
12481248 by the admin who made it, and a file that does not validate is not shown. Make, rename,
12491249 duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`.
1250 The first boot also writes two starting boards, **Usage** and **Crew**, once each: `.offered` in that repo records them, so an edit stays and a deletion sticks.
1250 The first boot also writes three starting boards, **Usage**, **Crew** and **Security** (sign-ins, failed sign-ins, tokens by scope, admin actions, from the admin-only `signins`, `tokens` and `adminActions` sources and `usage`; no token text, hash, payload or IP), once each: `.offered` in that repo records them, so an edit stays and a deletion sticks.
12511251 A board with a refresh redraws its grid on that beat through the live-region swap
12521252 (`/boards/:slug/live`, etag'd). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug`
12531253 (an agent or anyone but an admin is 403; a panel whose query cannot run is refused with 422). The same four doors are MCP tools (`boards`, `board_write`, `board_delete`; `boards` with a `slug` reads one) and, for the same person, a **Boards** screen in the terminal that lists boards and shows a board's panels as text. No dependency was added.
src/boards/sources.ts +25
@@ -118,6 +118,31 @@ export const SOURCES: Readonly<Record<string, Source>> = {
118118 n: "requests in that hour with that pattern, method, status class, role and surface",
119119 },
120120 },
121 // Browser sign-ins: one row per session. The token hash and the session
122 // id are not listed. Failed sign-ins are not a row anywhere; they are the
123 // `usage` rows for POST /signin with a 4xx status.
124 signins: {
125 table: "session",
126 owner: "admin",
127 time: "created_at",
128 columns: { user_id: "text", created_at: "time", expires_at: "time", last_seen_at: "time" },
129 },
130 // API tokens: scope, owner and age. The token text and its hash and name are not listed.
131 tokens: {
132 table: "token",
133 owner: "admin",
134 time: "created_at",
135 columns: { id: "text", user_id: "text", scope: "text", created_at: "time", last_used_at: "time", expires_at: "time" },
136 docs: { scope: "ro, rw or admin" },
137 },
138 // What admins did: the events an admin account appended. The payload is not listed.
139 adminActions: {
140 table: "event",
141 owner: "admin",
142 where: "actor_id IN (SELECT id FROM user WHERE is_admin = 1)",
143 time: "created_at",
144 columns: { id: "text", kind: "text", repo_id: "text", actor_id: "text", created_at: "time" },
145 },
121146 // What the machine used (`boards/sampler.ts`, kept by `boards/ring.ts`):
122147 // 15-s points for the last day, 5-min points before that, never both
123148 // for one moment.
src/boards/starting.ts +21 −1
@@ -1,5 +1,5 @@
11 /**
2 * The boards the forge offers a new install: Usage and Crew. They are plain
2 * The boards the forge offers a new install: Usage, Crew and Security. They are plain
33 * boards in `forge/boards` once written, so a person edits or deletes them
44 * like any other; `.offered` (a JSON list of slugs, committed with the
55 * boards) records what was offered so a deletion sticks and an edit is
@@ -21,6 +21,12 @@ const machine = (op: "eq" | "starts", value: string, column: string) => ({
2121 source: "machine", aggregate: "avg", column, groupBy: MINUTE, filter: [{ column: "target", op, value }],
2222 });
2323
24 // A failed sign-in answers 422 (wrong password) or 429 (rate-limited); the usage count keeps no more.
25 const failed = {
26 source: "usage", aggregate: "sum", column: "n",

Only /signin is counted; /signin/passkey failures are not. Name the gap in the panel title or include that pattern.

27 filter: [{ column: "pattern", op: "eq", value: "/signin" }, { column: "method", op: "eq", value: "POST" }, { column: "status", op: "eq", value: "4xx" }],
28 };
29
2430 export const STARTING_BOARDS: Readonly<Record<string, Board>> = {
2531 usage: {
2632 title: "Usage",
@@ -47,6 +53,20 @@ export const STARTING_BOARDS: Readonly<Record<string, Board>> = {
4753 panel("Spend per hour (USD)", "line", { source: "spend", aggregate: "sum", column: "cost_usd", groupBy: HOUR }, 2),
4854 ],
4955 },
56 security: {
57 title: "Security",
58 range: "7d",
59 refresh: "1m",
60 panels: [

series: "status" on a query already filtered to status 4xx gives one series. Remove it.

61 panel("Sign-ins per hour", "bar", { source: "signins", aggregate: "count", groupBy: HOUR }, 2),
62 panel("Failed sign-ins per hour", "bar", { ...failed, groupBy: HOUR, series: "status" }, 2),
63 panel("Failed sign-ins (total)", "number", failed),
64 panel("Tokens by scope", "bar", { source: "tokens", aggregate: "count", groupBy: "scope" }),
65 panel("Tokens made per day", "bar", { source: "tokens", aggregate: "count", groupBy: { bucket: "day" } }),
66 panel("Admin actions per hour", "bar", { source: "adminActions", aggregate: "count", groupBy: HOUR }, 2),
67 panel("Admin actions by kind", "bar", { source: "adminActions", aggregate: "count", groupBy: "kind" }, 2),
68 ],
69 },
5070 };
5171
5272 /** The slugs already offered, from the repo; none when it has no such file yet. */
src/main.ts +1 −1
@@ -354,7 +354,7 @@ const snapshots = startSnapshots(
354354 // node, every 15 s into a ring the `machine` board source reads.
355355 const sampler = startSampler(app);
356356 const memorySampler = startMemorySampler(app);
357 // The Usage and Crew starting boards, once each (actions/boards.ts).
357 // The Usage, Crew and Security starting boards, once each (actions/boards.ts).
358358 void offerStartingBoards(app)
359359 .then((slugs) => slugs.length > 0 && app.log.info("starting boards offered", { slugs: slugs.join(", ") }))
360360 .catch((error) => app.log.warn("starting boards not offered", { error: String(error) }));
test/boards-query.test.ts +15
@@ -317,6 +317,21 @@ describe("boards API", () => {
317317 }
318318 });
319319
320 test("the security sources are admin only: a non-admin gets 403, a repo scope gets nothing", async () => {
321 for (const source of ["signins", "tokens", "adminActions"]) {
322 expect((await q("bob", { source, aggregate: "count" })).status).toBe(403);
323 expect((await q("agent", { source, aggregate: "count" })).status).toBe(403);
324 expect((await q("admin", { source, aggregate: "count" })).status).toBe(200);
325 expect(run(app.db, { source, aggregate: "count" }, { repoIds: ["r1"] })).toEqual({ ok: true, rows: [] });
326 }
327 });
328
329 test("the security sources list no secret column", () => {
330 for (const source of ["signins", "tokens", "adminActions"]) {
331 for (const c of Object.keys(SOURCES[source]!.columns)) expect(c).not.toMatch(/hash|token|secret|payload|name/);
332 }
333 });
334
320335 test("signed out is 401", async () => {
321336 expect((await call("/api/v1/boards/sources", null)).status).toBe(401);
322337 expect((await q(null, { source: "runs", aggregate: "count" })).status).toBe(401);
test/boards-starting.test.ts +5 −5
@@ -1,5 +1,5 @@
11 /**
2 * The forge offers the Usage and Crew boards on boot: every panel runs, an
2 * The forge offers the Usage, Crew and Security boards on boot: every panel runs, an
33 * edit survives the next boot, and a deletion is not undone.
44 */
55 import { afterAll, beforeAll, expect, test } from "bun:test";
@@ -33,10 +33,10 @@ test("a forge with no person to found the repo writes nothing", async () => {
3333 expect(await offerStartingBoards(bare)).toEqual([]);
3434 });
3535
36 test("a fresh boot writes Usage and Crew, and every panel runs", async () => {
37 expect((await offerStartingBoards(app)).sort()).toEqual(["crew", "usage"]);
36 test("a fresh boot writes Usage, Crew and Security, and every panel runs", async () => {
37 expect((await offerStartingBoards(app)).sort()).toEqual(["crew", "security", "usage"]);
3838 const boards = await listBoards(app.reposRoot);
39 expect(boards.map((b) => b.slug)).toEqual(["crew", "usage"]);
39 expect(boards.map((b) => b.slug)).toEqual(["crew", "security", "usage"]);
4040 let panels = 0;
4141 for (const { board } of boards) {
4242 for (const p of board.panels) {
@@ -45,7 +45,7 @@ test("a fresh boot writes Usage and Crew, and every panel runs", async () => {
4545 panels++;
4646 }
4747 }
48 expect(panels).toBe(STARTING_BOARDS.usage!.panels.length + STARTING_BOARDS.crew!.panels.length);
48 expect(panels).toBe(STARTING_BOARDS.usage!.panels.length + STARTING_BOARDS.crew!.panels.length + STARTING_BOARDS.security!.panels.length);
4949 });
5050
5151 test("a second boot leaves an edit in place", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs