Public: anyone can read this; the crew can change it.
The terminal reads the access log (Settings → Access has no src/tui screen) #1900
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1768-the-terminal-reads-the-access-log-settin → main 3 commits fork 3 threads · 3 unresolved
5 files
changed +274
−0
README.md +2
| @@ -528,6 +528,8 @@ mechanical rather than advisory. | ||
| 528 | 528 | `docs/FLOW.md` is where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 529 | 529 | Where the whole forge waits is a forge admin's: `/flow` shows each stage from ready to live with what waits, the oldest wait and its capacity, under one sentence naming the bottleneck, and beside each what it let out in the last hour with a 24-hour sparkline, under a line of landings and the median ask→live — every count links to the items it counts. The terminal's Flow command and the MCP `flow` tool read the same: the sentence, the landings line, and each stage's count out in the last hour and in 24 h. |
| 530 | 530 | |
| 531 | The terminal also has an Access command (`:` → Access), a site admin's: it reads the same last-day non-browser requests as Settings → Access and the `access` MCP tool, newest first, and filters them with `s` (since), `u` (user agent) and `p` (path), `c` to clear. | |
| 532 | ||
| 531 | 533 | `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and |
| 532 | 534 | what "ready now" means to a person and to the crew. The map also shows each |
| 533 | 535 | pillar's ask-to-live: the median hours from an issue opening to its change live |
scripts/gate.ts +1
| @@ -56,6 +56,7 @@ export const GATE: readonly string[] = [ | ||
| 56 | 56 | "test/protection.test.ts", |
| 57 | 57 | "test/tui-limbs.test.ts", |
| 58 | 58 | "test/tui-onyou.test.ts", |
| 59 | "test/tui-access.test.ts", | |
| 59 | 60 | "test/claim-link.test.ts", |
| 60 | 61 | "test/og-card.test.ts", |
| 61 | 62 | "test/og-route.test.ts", |
src/tui/forge.ts +9
| @@ -34,6 +34,7 @@ import { issueScreen } from "./screens/issue.ts"; | ||
| 34 | 34 | import { pullScreen } from "./screens/pull.ts"; |
| 35 | 35 | import { blobScreen } from "./screens/repo.ts"; |
| 36 | 36 | import { dashboardScreen } from "./screens/dashboard.ts"; |
| 37 | import { accessScreen } from "./screens/access.ts"; | |
| 37 | 38 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| 38 | 39 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| 39 | 40 | import { limbsScreen } from "./screens/limbs.ts"; |
| @@ -195,6 +196,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 195 | 196 | hint: "the pipeline's bottleneck and throughput", |
| 196 | 197 | run: () => ctx.push(flowScreen(ctx, flowLines(session.db))), |
| 197 | 198 | }); |
| 199 | // What outside agents tried, as Settings → Access says it: a site admin's (#1768). | |
| 200 | if (session.user?.isAdmin === true && session.user.kind !== "agent") | |
| 201 | commands.push({ | |
| 202 | id: "access", | |
| 203 | title: "Access", | |
| 204 | hint: "what outside agents tried", | |
| 205 | run: () => ctx.push(accessScreen(session, ctx)), | |
| 206 | }); | |
| 198 | 207 | commands.push({ |
| 199 | 208 | id: "family", |
| 200 | 209 | title: "Family", |
src/tui/screens/access.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | /** | |
| 2 | * The access log in the terminal, a forge admin's (#1768): the last day's | |
| 3 | * non-browser requests to public paths, newest first — `listAccess`, the | |
| 4 | * action the settings page and the `access` MCP tool read. Filters are the | |
| 5 | * same three: `s` since (a time), `u` user agent contains, `p` path starts | |
| 6 | * with; `c` clears them. Who may read is the action's call, not this | |
| 7 | * screen's: anyone else gets an empty screen that says nothing exists. | |
| 8 | */ | |
| 9 | ||
| 10 | import { listAccess } from "../../actions/access_log.ts"; | |
| 11 | import type { AccessLine, AccessQuery } from "../../http/access_log.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import { fill, len, split } from "../ui/layout.ts"; | |
| 15 | import { style } from "../term/style.ts"; | |
| 16 | import { truncate } from "../term/width.ts"; | |
| 17 | import type { Session } from "../session.ts"; | |
| 18 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 19 | ||
| 20 | type Filter = "since" | "ua" | "path"; | |
| 21 | ||
| 22 | const FILTERS: { key: string; filter: Filter; title: string; label: string }[] = [ | |
| 23 | { key: "s", filter: "since", title: "Since", label: "ISO time, e.g. 2026-10-14T09:00" }, | |
| 24 | { key: "u", filter: "ua", title: "User agent", label: "contains" }, | |
| 25 | { key: "p", filter: "path", title: "Path", label: "starts with, e.g. /api/" }, | |
| 26 | ]; | |
| 27 | ||
| 28 | const MAX = { since: 40, ua: 100, path: 200 } as const; | |
| 29 | ||
| 30 | export function accessScreen(f: Session, ctx: Ctx): Screen { | |
| 31 | const state = new ListState(); | |
| 32 | const filters: Partial<Record<Filter, string>> = {}; | |
| 33 | let lines: AccessLine[] = []; | |
| 34 | let loaded = false; | |
| 35 | ||
| 36 | const query = (): AccessQuery => ({ ...filters }); | |
| 37 | ||
| 38 | const reload = (): void => { | |
| 39 | const listed = listAccess(f.app, f.user, query()); | |
| 40 | lines = listed.ok ? listed.value : []; | |
| 41 | loaded = true; | |
| 42 | state.clamp(lines.length); | |
| 43 | ctx.invalidate(); | |
| 44 | }; | |
| 45 | ||
| 46 | const ask = (which: (typeof FILTERS)[number]): void => { | |
| 47 | ctx.push( | |
| 48 | promptScreen(ctx, screen, { | |
| 49 | title: which.title, | |
| 50 | label: which.label, | |
| 51 | value: filters[which.filter] ?? "", | |
| 52 | onSubmit: (raw) => { | |
| 53 | const value = raw.trim(); | |
| 54 | if (value.length > MAX[which.filter]) return "too long"; | |
| 55 | if (which.filter === "since" && value !== "" && Number.isNaN(Date.parse(value))) | |
| 56 | return "not a time"; | |
| 57 | if (value === "") delete filters[which.filter]; | |
| 58 | else filters[which.filter] = value; | |
| 59 | reload(); | |
| 60 | return undefined; | |
| 61 | }, | |
| 62 | }), | |
| 63 | ); | |
| 64 | }; | |
| 65 | ||
| 66 | const rows = (): ListRow[] => | |
| 67 | lines.map((l) => ({ | |
| 68 | title: `${l.method} ${l.path}`, | |
| 69 | secondary: String(l.status), | |
| 70 | meta: `${l.t.slice(11, 19)} · ${l.ms} ms · ${l.auth ? "signed in" : "anonymous"} · ${l.who} · ${l.ua === "" ? "no user agent" : l.ua}`, | |
| 71 | })); | |
| 72 | ||
| 73 | const screen: Screen = { | |
| 74 | title: "Access", | |
| 75 | keys: [ | |
| 76 | binding(["j", "k"], "move cursor"), | |
| 77 | binding(["s"], "since"), | |
| 78 | binding(["u"], "user agent"), | |
| 79 | binding(["p"], "path"), | |
| 80 | binding(["c"], "clear filters"), | |
| 81 | ], | |
| 82 | load() { | |
| 83 | reload(); | |
| 84 | return Promise.resolve(); | |
| 85 | }, | |
| 86 | render(area, s) { | |
| 87 | const c = ctx.theme.c; | |
| 88 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 89 | const active = Object.entries(filters) | |
| 90 | .map(([k, v]) => `${k}=${v}`) | |
| 91 | .join(" "); | |
| 92 | const note = !loaded | |
| 93 | ? "Loading…" | |
| 94 | : lines.length === 0 | |
| 95 | ? "Nothing in the last day." | |
| 96 | : `${lines.length} outside request${lines.length === 1 ? "" : "s"}, newest first${active === "" ? "" : ` · ${active}`}`; | |
| 97 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 98 | drawList(s, list!, c, state, rows()); | |
| 99 | }, | |
| 100 | key(e) { | |
| 101 | if (state.key(e, lines.length)) return true; | |
| 102 | if (e.ctrl || e.alt) return false; | |
| 103 | const which = FILTERS.find((x) => x.key === e.key); | |
| 104 | if (which !== undefined) ask(which); | |
| 105 | else if (e.key === "c") { | |
| 106 | for (const k of Object.keys(filters)) delete filters[k as Filter]; | |
| 107 | reload(); | |
| 108 | } else return false; | |
| 109 | return true; | |
| 110 | }, | |
| 111 | }; | |
| 112 | return screen; | |
| 113 | } |
test/tui-access.test.ts added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | /** | |
| 2 | * The access log in the terminal (#1768): a forge admin's screen over | |
| 3 | * `readAccess` with its since / user agent / path filters; anyone else has | |
The header names |
||
| 4 | * no such command and, if they reach the action, are told nothing exists. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { flushAccess, recordAccess } from "../src/http/access_log.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | describe("the access log in the terminal", () => { | |
| 22 | let dataDir: string; | |
| 23 | let forge: Forge; | |
| 24 | ||
| 25 | const seen = (path: string, ua: string) => ({ | |
| 26 | request: new Request(`http://forge.test${path}`, { headers: { "user-agent": ua } }), | |
| 27 | status: 200, | |
| 28 | ms: 3, | |
| 29 | client: "203.0.113.9", | |
| 30 | }); | |
| 31 | ||
| 32 | function boot(username: string) { | |
| 33 | const terminal = headlessTerminal(140, 32); | |
| 34 | const user = getUserByName(forge.db, username)!; | |
| 35 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 36 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 37 | app.start(); | |
| 38 | const key = (k: string) => | |
| 39 | app.dispatch({ type: "key", key: k, ctrl: k.startsWith("ctrl+"), alt: false, shift: false }); | |
| 40 | const press = async (...keys: string[]) => { | |
| 41 | for (const k of keys) key(k.startsWith("ctrl+") ? k.slice(5) : k); | |
| 42 | await settleApp(app); | |
| 43 | }; | |
| 44 | const ctrl = async (k: string) => { | |
| 45 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 46 | await settleApp(app); | |
| 47 | }; | |
| 48 | const type = async (text: string) => { | |
| 49 | for (const ch of text) key(ch); | |
| 50 | await settleApp(app); | |
| 51 | }; | |
| 52 | return { text: () => app.screen.toText(), press, ctrl, type, stop: () => app.stop() }; | |
| 53 | } | |
| 54 | ||
| 55 | beforeAll(async () => { | |
| 56 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-access-")); | |
| 57 | forge = createApp({ dataDir, port: 0 }); | |
| 58 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) { | |
| 59 | const made = await createUser(forge.db, { | |
| 60 | trusted: true, | |
| 61 | username, | |
| 62 | email: `${username}@forge.local`, | |
| 63 | password: "a-good-passphrase", | |
| 64 | adminIfFirst, | |
| 65 | }); | |
| 66 | if (!made.ok) throw new Error(made.error); | |
| 67 | } | |
| 68 | forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run(); | |
| 69 | recordAccess(dataDir, seen("/api/v1/repos", "curl/8.0")); | |
| 70 | recordAccess(dataDir, seen("/llms.txt", "GPTBot/1.0")); | |
| 71 | flushAccess(); | |
| 72 | }); | |
| 73 | ||
| 74 | afterAll(async () => { | |
| 75 | forge.close(); | |
| 76 | await rm(dataDir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | const open = async (h: ReturnType<typeof boot>) => { | |
| 80 | await h.ctrl("k"); | |
| 81 | await h.type("Access"); | |
| 82 | await h.press("enter"); | |
| 83 | }; | |
| 84 | ||
| 85 | test("an admin reads the log and filters it by user agent and path", async () => { | |
| 86 | const h = boot("alice"); | |
| 87 | await open(h); | |
| 88 | expect(h.text()).toContain("GET /api/v1/repos"); | |
| 89 | expect(h.text()).toContain("GET /llms.txt"); | |
| 90 | ||
| 91 | await h.press("u"); | |
| 92 | await h.type("gptbot"); | |
| 93 | await h.press("enter"); | |
| 94 | expect(h.text()).toContain("GET /llms.txt"); | |
| 95 | expect(h.text()).not.toContain("GET /api/v1/repos"); | |
| 96 | ||
| 97 | await h.press("c", "p"); | |
| 98 | await h.type("/api/"); | |
| 99 | await h.press("enter"); | |
| 100 | expect(h.text()).toContain("GET /api/v1/repos"); | |
| 101 | expect(h.text()).not.toContain("GET /llms.txt"); | |
| 102 | ||
| 103 | // A time that is not one is refused and the prompt stays. | |
| 104 | await h.press("s"); | |
| 105 | await h.type("yesterday-ish"); | |
| 106 | await h.press("enter"); | |
| 107 | expect(h.text()).toContain("not a time"); | |
| 108 | h.stop(); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a since in the future shows nothing", async () => { | |
| 112 | const h = boot("alice"); | |
| 113 | await open(h); | |
| 114 | await h.press("s"); | |
| 115 | await h.type("2999-01-01T00:00"); | |
| 116 | await h.press("enter"); | |
| 117 | expect(h.text()).toContain("Nothing in the last day."); | |
| 118 | h.stop(); | |
| 119 | }); | |
| 120 | ||
| 121 | const palette = async (h: ReturnType<typeof boot>) => { | |
outdated
This passes even when the Access command is registered for non-admins, because the action returns nothing either way. Assert the palette lacks the entry, e.g. not.toContain("what outside agents tried"). |
||
| 122 | await h.ctrl("k"); | |
| 123 | await h.type("Access"); | |
| 124 | return h.text(); | |
| 125 | }; | |
| 126 | ||
| 127 | test("an admin's palette offers Access", async () => { | |
| 128 | const h = boot("alice"); | |
| 129 | expect(await palette(h)).toContain("what outside agents tried"); | |
| 130 | h.stop(); | |
| 131 | }); | |
| 132 | ||
| 133 | test("a person who is not an admin has no Access command", async () => { | |
| 134 | const h = boot("bob"); | |
| 135 | expect(await palette(h)).not.toContain("what outside agents tried"); | |
| 136 | await h.press("enter"); | |
| 137 | expect(h.text()).not.toContain("GET /api/v1/repos"); | |
| 138 | h.stop(); | |
| 139 | }); | |
| 140 | ||
| 141 | test("an admin who is an agent has no Access command", async () => { | |
| 142 | expect(getUserByName(forge.db, "carol")!.kind).toBe("agent"); | |
| 143 | const h = boot("carol"); | |
| 144 | expect(await palette(h)).not.toContain("what outside agents tried"); | |
| 145 | await h.press("enter"); | |
| 146 | expect(h.text()).not.toContain("GET /api/v1/repos"); | |
| 147 | h.stop(); | |
| 148 | }); | |
| 149 | }); |
new above
offline
A since shorthand such as
1hor30mwould help a thumb-typed SSH session more than an ISO timestamp.