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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7935-the-terminal-lists-who-can-do-what-in-ea → main 1 commit fork
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"; | ||
| 10 | 10 | import type { Session } from "../session.ts"; |
| 11 | 11 | import { entry as access } from "./access.ts"; |
| 12 | 12 | import { entry as agents } from "./agents.ts"; |
| 13 | import { entry as audit } from "./audit.ts"; | |
| 13 | 14 | import { entry as boards } from "./boards.ts"; |
| 14 | 15 | import { entry as cloud } from "./cloud.ts"; |
| 15 | 16 | import { entry as crewHandover } from "./crewhandover.ts"; |
| @@ -55,6 +56,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 55 | 56 | telemetry, |
| 56 | 57 | resilience, |
| 57 | 58 | access, |
| 59 | audit, | |
| 58 | 60 | secrets, |
| 59 | 61 | crewSplits, |
| 60 | 62 | crewHandover, |
test/palette-roster.test.ts +4
| @@ -101,6 +101,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 101 | 101 | ["agents", "Crew agents", "each agent's model, budget and brief"], |
| 102 | 102 | ["invites", "Invite links", "make a single-use link, see which are open, revoke one"], |
| 103 | 103 | ["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"], | |
| 104 | 105 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 105 | 106 | ["domains", "Failure domains", "failure domains and their last drill"], |
| 106 | 107 | ["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", | ||
| 148 | 149 | ["scorecard", "Crew scorecard per agent", "each agent's pulls landed, review rounds, red checks, reverts, time to land"], |
| 149 | 150 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 150 | 151 | ["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"], | |
| 151 | 153 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 152 | 154 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 153 | 155 | ["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" | ||
| 188 | 190 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 189 | 191 | ["model", "Model budget", "what your crew may spend, and add your own subscription"], |
| 190 | 192 | ["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"], | |
| 191 | 194 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 192 | 195 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 193 | 196 | ["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", () => { | ||
| 211 | 214 | ["signin", "Sign in", undefined], |
| 212 | 215 | ["api", "API", "the forge's endpoints; run a GET"], |
| 213 | 216 | ["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"], | |
| 214 | 218 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 215 | 219 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 216 | 220 | ["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 | }); |
new above
offline