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

The terminal reads the access log (Settings → Access has no src/tui screen) #1900

Merged opened this

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

Merged

scout/forge:scout/1768-the-terminal-reads-the-access-log-settin → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/1768-the-terminal-reads-the-access-log-settin was merged into main as 1e6b918.

Review

Sign in to review this change.
5 files changed +274 −0
README.md +2
@@ -528,6 +528,8 @@ mechanical rather than advisory.
528528 `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.
529529 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.
530530
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
531533 `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and
532534 what "ready now" means to a person and to the crew. The map also shows each
533535 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[] = [
5656 "test/protection.test.ts",
5757 "test/tui-limbs.test.ts",
5858 "test/tui-onyou.test.ts",
59 "test/tui-access.test.ts",
5960 "test/claim-link.test.ts",
6061 "test/og-card.test.ts",
6162 "test/og-route.test.ts",
src/tui/forge.ts +9
@@ -34,6 +34,7 @@ import { issueScreen } from "./screens/issue.ts";
3434 import { pullScreen } from "./screens/pull.ts";
3535 import { blobScreen } from "./screens/repo.ts";
3636 import { dashboardScreen } from "./screens/dashboard.ts";
37 import { accessScreen } from "./screens/access.ts";
3738 import { flowLines, flowScreen } from "./screens/flow.ts";
3839 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
3940 import { limbsScreen } from "./screens/limbs.ts";
@@ -195,6 +196,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
195196 hint: "the pipeline's bottleneck and throughput",
196197 run: () => ctx.push(flowScreen(ctx, flowLines(session.db))),
197198 });
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 });
198207 commands.push({
199208 id: "family",
200209 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

A since shorthand such as 1h or 30m would help a thumb-typed SSH session more than an ISO timestamp.

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 readAccess, but the screen calls listAccess. Fix the name.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs