Public: anyone can read this; the crew can change it.
Boards, step 6c: a security source and the Security starting board #2474
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2460-boards-step-6c-a-security-source-and-the → main 1 commit fork 2 threads · 2 unresolved
6 files
changed +68
−8
README.md +1 −1
| @@ -1247,7 +1247,7 @@ work here. | ||
| 1247 | 1247 | `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit |
| 1248 | 1248 | by the admin who made it, and a file that does not validate is not shown. Make, rename, |
| 1249 | 1249 | 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. | |
| 1251 | 1251 | A board with a refresh redraws its grid on that beat through the live-region swap |
| 1252 | 1252 | (`/boards/:slug/live`, etag'd). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug` |
| 1253 | 1253 | (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>> = { | ||
| 118 | 118 | n: "requests in that hour with that pattern, method, status class, role and surface", |
| 119 | 119 | }, |
| 120 | 120 | }, |
| 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 | }, | |
| 121 | 146 | // What the machine used (`boards/sampler.ts`, kept by `boards/ring.ts`): |
| 122 | 147 | // 15-s points for the last day, 5-min points before that, never both |
| 123 | 148 | // for one moment. |
src/boards/starting.ts +21 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 3 | * boards in `forge/boards` once written, so a person edits or deletes them |
| 4 | 4 | * like any other; `.offered` (a JSON list of slugs, committed with the |
| 5 | 5 | * 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) => ({ | ||
| 21 | 21 | source: "machine", aggregate: "avg", column, groupBy: MINUTE, filter: [{ column: "target", op, value }], |
| 22 | 22 | }); |
| 23 | 23 | |
| 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", | |
| 27 | filter: [{ column: "pattern", op: "eq", value: "/signin" }, { column: "method", op: "eq", value: "POST" }, { column: "status", op: "eq", value: "4xx" }], | |
| 28 | }; | |
| 29 | ||
| 24 | 30 | export const STARTING_BOARDS: Readonly<Record<string, Board>> = { |
| 25 | 31 | usage: { |
| 26 | 32 | title: "Usage", |
| @@ -47,6 +53,20 @@ export const STARTING_BOARDS: Readonly<Record<string, Board>> = { | ||
| 47 | 53 | panel("Spend per hour (USD)", "line", { source: "spend", aggregate: "sum", column: "cost_usd", groupBy: HOUR }, 2), |
| 48 | 54 | ], |
| 49 | 55 | }, |
| 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 | }, | |
| 50 | 70 | }; |
| 51 | 71 | |
| 52 | 72 | /** 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( | ||
| 354 | 354 | // node, every 15 s into a ring the `machine` board source reads. |
| 355 | 355 | const sampler = startSampler(app); |
| 356 | 356 | 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). | |
| 358 | 358 | void offerStartingBoards(app) |
| 359 | 359 | .then((slugs) => slugs.length > 0 && app.log.info("starting boards offered", { slugs: slugs.join(", ") })) |
| 360 | 360 | .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", () => { | ||
| 317 | 317 | } |
| 318 | 318 | }); |
| 319 | 319 | |
| 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 | ||
| 320 | 335 | test("signed out is 401", async () => { |
| 321 | 336 | expect((await call("/api/v1/boards/sources", null)).status).toBe(401); |
| 322 | 337 | expect((await q(null, { source: "runs", aggregate: "count" })).status).toBe(401); |
test/boards-starting.test.ts +5 −5
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 3 | * edit survives the next boot, and a deletion is not undone. |
| 4 | 4 | */ |
| 5 | 5 | 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 () => { | ||
| 33 | 33 | expect(await offerStartingBoards(bare)).toEqual([]); |
| 34 | 34 | }); |
| 35 | 35 | |
| 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"]); | |
| 38 | 38 | 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"]); | |
| 40 | 40 | let panels = 0; |
| 41 | 41 | for (const { board } of boards) { |
| 42 | 42 | for (const p of board.panels) { |
| @@ -45,7 +45,7 @@ test("a fresh boot writes Usage and Crew, and every panel runs", async () => { | ||
| 45 | 45 | panels++; |
| 46 | 46 | } |
| 47 | 47 | } |
| 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); | |
| 49 | 49 | }); |
| 50 | 50 | |
| 51 | 51 | test("a second boot leaves an edit in place", async () => { |
new above
offline
Only /signin is counted; /signin/passkey failures are not. Name the gap in the panel title or include that pattern.