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

The terminal lists who can do what in each app, read from its contract.json, as /audit and MCP `audit` do #7946

Merged opened this

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

Merged

scout/forge:scout/7935-the-terminal-lists-who-can-do-what-in-ea → main 1 commit fork

  • Merged. scout/7935-the-terminal-lists-who-can-do-what-in-ea was merged into main as ae5a1d7.

Review

Sign in to review this change.
4 files changed +226 −0
src/tui/screens/audit.ts added +117
@@ -0,0 +1,117 @@
1 /**
2 * "Who can do what" in the terminal, as /audit and MCP `audit` have it: one
3 * row per app the viewer can read, read from its `contract.json` at the
4 * default branch through the same `audit()`. Under the list, the selected
5 * app's doors — resource.verb, then yes, own or no for each identity — and
6 * whether outsiders may direct its crew. An app without a contract is listed
7 * as "unread", never hidden. Enter opens the app's repository.
8 */
9
10 import { appsForRepos } from "../../core/apps.ts";
11 import { listReposVisibleTo } from "../../core/repos.ts";
12 import { audit, type Audited } from "../../contract/audit.ts";
13 import { VERBS } from "../../contract/can.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import type { Session } from "../session.ts";
17 import { emptyState } from "../ui/widgets.ts";
18 import { ListState, drawFacts, drawList, hitList, metaOf, plural, runAsync, type ListRow } from "./common.ts";
19 import type { PaletteEntry } from "./entries.ts";
20 import { repoScreen } from "./repo.ts";
21 import { local } from "./where.ts";
22
23 /** As many as the page reads. */
24 const AUDIT_LIMIT = 200;
25
26 /** The apps the viewer can read, each read from its contract — the page's set. */
27 export async function auditedFor(f: Session): Promise<Audited[]> {
28 const repos = listReposVisibleTo(f.db, f.user, { limit: AUDIT_LIMIT });
29 const records = appsForRepos(f.db, repos.map((r) => r.id));
30 return Promise.all(repos.filter((r) => records.has(r.id)).map((repo) => audit(f.app.reposRoot, repo)));
31 }
32
33 const crewWords = (a: Audited): string =>
34 a.repo.crewOutsiders ? "outsiders may direct its crew" : "only members direct its crew";
35
36 /** One line per app: its name, its standing in a word or two, and who directs its crew. */
37 export function auditRows(audited: readonly Audited[]): ListRow[] {
38 return audited.map((a) => {
39 const st = a.standing;
40 const standing =
41 st.kind === "unread"
42 ? "unread"
43 : st.kind === "unsound"
44 ? `unsound: ${plural(st.problems.length, "problem")}`
45 : st.open.length === 0
46 ? "closed to strangers"
47 : `${plural(st.open.length, "door")} open to anyone`;
48 return {
49 title: `${a.repo.ownerName}/${a.repo.name}`,
50 tone: st.kind === "sound" ? (st.open.length === 0 ? "success" : "attn") : st.kind === "unsound" ? "danger" : "secondary",
51 meta: metaOf(standing, crewWords(a)),
52 };
53 });
54 }
55
56 /** The selected app's doors: [resource.verb, "anon no · member own · owner yes"], or why there are none. */
57 export function doorLines(a: Audited): [string, string][] {
58 const st = a.standing;
59 const crew: [string, string] = ["crew", crewWords(a)];
60 if (st.kind === "unread") return [["unread", "no contract.json: its doors are written by hand"], crew];
61 if (st.kind === "unsound") return [...st.problems.map((p): [string, string] => ["unsound", p]), crew];
62 const lines: [string, string][] = [];
63 for (const resource of Object.keys(st.contract.resources))
64 for (const verb of VERBS)
65 lines.push([
66 `${resource}.${verb}`,
67 st.identities
68 .map((as) => `${as} ${st.cells.find((c) => c.resource === resource && c.verb === verb && c.as === as)?.answer ?? "no"}`)
69 .join(" · "),
70 ]);
71 return [crew, ...lines];
72 }
73
74 export function auditScreen(f: Session, ctx: Ctx, audited: readonly Audited[]): Screen {
75 const state = new ListState();
76 const rows = auditRows(audited);
77 const open = (index: number) => {
78 const a = audited[index];
79 if (a !== undefined) ctx.push(repoScreen(f, ctx, local(f, a.repo)));
80 };
81 return {
82 title: "Who can do what",
83 keys: [binding(["enter"], "open repository"), binding(["esc", "q"], "close")],
84 render(area, s) {
85 const c = ctx.theme.c;
86 if (audited.length === 0) {
87 emptyState(s, area, c, { title: "No apps to read", body: "No app you can read runs on this forge." });
88 return;
89 }
90 const listH = Math.max(1, Math.min(rows.length, area.h >> 1));
91 const list = { x: area.x, y: area.y, w: area.w, h: listH };
92 drawList(s, list, c, state, rows);
93 hitList(ctx, list, state, audited.length, { open });
94 const selected = audited[state.index];
95 if (selected === undefined) return;
96 drawFacts(s, { x: area.x + 1, y: area.y + listH + 1, w: Math.max(0, area.w - 1), h: Math.max(0, area.h - listH - 1) }, c, doorLines(selected));
97 },
98 key(e) {
99 if (e.key === "esc" || e.key === "q") ctx.pop();
100 else if (e.key === "enter") open(state.index);
101 else state.key(e, audited.length);
102 return true;
103 },
104 };
105 }
106
107 /** The audit, as /audit lists it: anyone, a stranger seeing the public apps. */
108 export const entry: PaletteEntry = {
109 id: "audit",
110 title: "Who can do what",
111 hint: "each app's doors from its contract.json: yes, own or no",
112 offered: () => true,
113 run: (session, ctx) =>
114 runAsync(ctx, async () => {
115 ctx.push(auditScreen(session, ctx, await auditedFor(session)));
116 }),
117 };
src/tui/screens/entries.ts +2
@@ -10,6 +10,7 @@ import type { Command } from "../app/palette.ts";
1010 import type { Session } from "../session.ts";
1111 import { entry as access } from "./access.ts";
1212 import { entry as agents } from "./agents.ts";
13 import { entry as audit } from "./audit.ts";
1314 import { entry as boards } from "./boards.ts";
1415 import { entry as cloud } from "./cloud.ts";
1516 import { entry as crewHandover } from "./crewhandover.ts";
@@ -55,6 +56,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
5556 telemetry,
5657 resilience,
5758 access,
59 audit,
5860 secrets,
5961 crewSplits,
6062 crewHandover,
test/palette-roster.test.ts +4
@@ -101,6 +101,7 @@ test("a site-admin person is offered the whole roster", () => {
101101 ["agents", "Crew agents", "each agent's model, budget and brief"],
102102 ["invites", "Invite links", "make a single-use link, see which are open, revoke one"],
103103 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
104 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
104105 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
105106 ["domains", "Failure domains", "failure domains and their last drill"],
106107 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
@@ -148,6 +149,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
148149 ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"],
149150 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
150151 ["can", "What I can do", "your role and what it allows here"],
152 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
151153 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
152154 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
153155 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
@@ -188,6 +190,7 @@ test("a member person is offered the signed-in roster without the admin entries"
188190 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
189191 ["model", "Model budget", "what your crew may spend, and add your own subscription"],
190192 ["can", "What I can do", "your role and what it allows here"],
193 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
191194 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
192195 ["family", "Family", "the forge that sent this one, and the forges it sent"],
193196 ["chain", "Chain", "the forges this forge's code descends from, root first"],
@@ -211,6 +214,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
211214 ["signin", "Sign in", undefined],
212215 ["api", "API", "the forge's endpoints; run a GET"],
213216 ["changelog", "Changelog", "the last 30 releases and their pulls"],
217 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
214218 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
215219 ["family", "Family", "the forge that sent this one, and the forges it sent"],
216220 ["chain", "Chain", "the forges this forge's code descends from, root first"],
test/tui-audit.test.ts added +103
@@ -0,0 +1,103 @@
1 /**
2 * "Who can do what" in the terminal reads the page's `audit()` over the
3 * page's visibility: a reader sees their app's doors, a stranger only the
4 * public apps, an app with no contract reads "unread", and Enter opens the
5 * app's repository.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { enableApp, newApp } from "../src/actions/apps.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import type { Ctx } from "../src/tui/app/app.ts";
16 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
17 import { auditedFor, auditRows, doorLines } from "../src/tui/screens/audit.ts";
18 import { headlessTerminal } from "../src/tui/term/terminal.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { settleApp } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 let dir: string;
25 let app: App;
26 let alice: User;
27 const stranger = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" };
28 const as = (user: User) => ({ sshUsername: "t", remoteAddress: "127.0.0.1", user });
29
30 beforeAll(async () => {
31 dir = await mkdtemp(join(tmpdir(), "forge-tui-audit-"));
32 app = createApp({ dataDir: dir, port: 0 });
33 const made = await createUser(app.db, {
34 origin: "seed",
35 trusted: true,
36 username: "alice",
37 email: "[email protected]",
38 password: "a-good-passphrase",
39 adminIfFirst: true,
40 });
41 if (!made.ok) throw new Error(made.error);
42 alice = made.value;
43 const notes = await newApp(app, alice, { name: "notes", visibility: "public" });
44 if (!notes.ok) throw new Error(String(notes.error));
45 const secret = await newApp(app, alice, { name: "secret", visibility: "private" });
46 if (!secret.ok) throw new Error(String(secret.error));
47 const bare = await createRepo(app, alice, { name: "bare", visibility: "public" });
48 if (!bare.ok) throw new Error(String(bare.error));
49 const seeded = await commitToBranch(app, alice, bare.value, {
50 message: "no contract",
51 files: [{ path: "README.md", content: "# bare\n" }],
52 });
53 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
54 expect(enableApp(app, alice, bare.value).ok).toBe(true);
55 }, 60_000);
56
57 afterAll(async () => {
58 app.close();
59 await rm(dir, { recursive: true, force: true });
60 });
61
62 const names = (rows: { title: string }[]) => rows.map((r) => r.title).sort();
63
64 test("a reader sees every app they can read, with its doors; no contract reads unread", async () => {
65 const audited = await auditedFor(new Session(app, as(alice)));
66 expect(names(auditRows(audited))).toEqual(["alice/bare", "alice/notes", "alice/secret"]);
67 const notes = audited.find((a) => a.repo.name === "notes")!;
68 const doors = doorLines(notes);
69 expect(doors.some(([k, v]) => /^\w+\.read$/.test(k) && /\b(yes|own|no)\b/.test(v))).toBe(true);
70 expect(doors).toContainEqual(["crew", "only members direct its crew"]);
71 const bare = audited.find((a) => a.repo.name === "bare")!;
72 expect(auditRows([bare])[0]!.meta).toContain("unread");
73 expect(doorLines(bare)[0]![0]).toBe("unread");
74 });
75
76 test("a stranger sees only the public apps", async () => {
77 const audited = await auditedFor(new Session(app, stranger));
78 expect(names(auditRows(audited))).toEqual(["alice/bare", "alice/notes"]);
79 });
80
81 test("the palette offers it, the screen paints it, and Enter opens the repository", async () => {
82 expect(forgeCommands(new Session(app, stranger))({} as Ctx).some((c) => c.id === "audit")).toBe(true);
83 const { app: tui } = createForgeApp(app, as(alice), { terminal: headlessTerminal(140, 36), exitHooks: false });
84 tui.start();
85 const key = async (k: string, ctrl = false) => {
86 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
87 await settleApp(tui);
88 };
89 await key("k", true);
90 for (const ch of "Who can do what") await key(ch);
91 await key("enter");
92 const text = tui.screen.toText();
93 expect(text).toContain("alice/notes");
94 expect(text).toContain("alice/bare");
95 expect(text).toMatch(/unread/);
96 expect(text).toMatch(/direct its crew/);
97 const first = auditRows(await auditedFor(new Session(app, as(alice))))[0]!.title;
98 await key("enter");
99 const opened = tui.screen.toText();
100 expect(opened).toContain(first.split("/")[1]!);
101 expect(opened).not.toMatch(/direct its crew/);
102 tui.stop();
103 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs