Public: anyone can read this; the crew can change it.
The terminal lists, grants and revokes an app's private destinations, through the same actions as the page #3882
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3618-the-terminal-lists-grants-and-revokes-an → main 5 commits fork 1 thread · 1 unresolved
4 files
changed +189
−4
.gitignore +3
| @@ -40,3 +40,6 @@ typesafe-api-key | ||
| 40 | 40 | # where a sandboxed `bun install` keeps its temp files and package cache |
| 41 | 41 | # when the default temp directory is read-only (#2706) |
| 42 | 42 | /.bun-tmp/ |
| 43 | ||
| 44 | # a sandbox's bun/bunx scratch directory; never source | |
| 45 | /.tmpx/ |
docs/APPS.md +3 −1
| @@ -300,7 +300,9 @@ else who administers the repository sees the list with no buttons. A | ||
| 300 | 300 | destination forge.json does not name is refused, and each change is an |
| 301 | 301 | `app.egress.granted` or `app.egress.revoked` event with who and when. In the terminal a site admin |
| 302 | 302 | opens the repository's About (`I`), then `e`: `g` grants and `x` revokes the |
| 303 | destination under the cursor. | |
| 303 | destination under the cursor. The terminal is stricter than the page on | |
| 304 | purpose: a repository admin who is no site admin sees the list read-only on the | |
| 305 | page, and nothing on the terminal screen (no rows, no `g`/`x`). | |
| 304 | 306 | |
| 305 | 307 | A release that turns egress on must not break an app whose writers have |
| 306 | 308 | not declared what it uses yet, so the operator can start it reported: |
src/tui/screens/egress.ts +13 −3
| @@ -22,9 +22,14 @@ import { ListState, drawList, type ListRow } from "./common.ts"; | ||
| 22 | 22 | export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 23 | 23 | const state = new ListState(); |
| 24 | 24 | let rows: EgressRow[] = []; |
| 25 | // Whether the action says this viewer may change grants; it decides, not the screen. | |
| 26 | let canChange = false; | |
| 25 | 27 | const reload = async (): Promise<void> => { |
| 26 | 28 | const view = await egressView(f.app, f.user, repo); |
| 27 | rows = view.ok ? view.value.rows : []; | |
| 29 | canChange = view.ok && view.value.canChange; | |
| 30 | // Deliberately stricter than the web page, which shows a repository admin the | |
| 31 | // list read-only: the terminal's list is a site admin's, as #3618 asks. | |
| 32 | rows = view.ok && view.value.canChange ? view.value.rows : []; | |
| 28 | 33 | state.clamp(rows.length); |
| 29 | 34 | ctx.invalidate(); |
| 30 | 35 | }; |
| @@ -48,7 +53,12 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 48 | 53 | |
| 49 | 54 | const screen: Screen = { |
| 50 | 55 | title: "Private egress", |
| 51 | keys: [binding(["g"], "grant"), binding(["x"], "revoke"), binding(["j", "k"], "move cursor")], | |
| 56 | get keys() { | |
| 57 | return [ | |
| 58 | ...(canChange ? [binding(["g"], "grant"), binding(["x"], "revoke")] : []), | |
| 59 | binding(["j", "k"], "move cursor"), | |
| 60 | ]; | |
| 61 | }, | |
| 52 | 62 | render(area, s) { |
| 53 | 63 | const c = ctx.theme.c; |
| 54 | 64 | const [head, list] = split(area, "v", [len(1), fill(1)]); |
| @@ -60,7 +70,7 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 60 | 70 | drawList(s, list!, c, state, lines()); |
| 61 | 71 | }, |
| 62 | 72 | key(e) { |
| 63 | const row = rows[state.index]; | |
| 73 | const row = canChange ? rows[state.index] : undefined; | |
| 64 | 74 | if (e.key === "g" && row !== undefined) { |
| 65 | 75 | void change(grantAppEgress(f.app, f.user, repo, row.destination), `${row.destination} is granted.`); |
| 66 | 76 | return true; |
test/tui-egress.test.ts added +170
| @@ -0,0 +1,170 @@ | ||
| 1 | /** | |
| 2 | * An app's private destinations in the terminal (#3618): a site admin lists, | |
| 3 | * grants and revokes them through the same actions as the settings page; a | |
| 4 | * writer is offered neither the list nor the keys, and nothing is written. | |
| 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 { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 12 | import { egressView } from "../src/actions/egress_grants.ts"; | |
| 13 | import { listEgressGrants } from "../src/core/egress_grants.ts"; | |
| 14 | import { listEvents } from "../src/core/events.ts"; | |
| 15 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { egressScreen } from "../src/tui/screens/egress.ts"; | |
| 19 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 20 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | import { promote } from "./helpers/members.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | const ARM = "100.89.95.68:8101"; | |
| 29 | const CAMERA = "100.89.95.68:8102"; | |
| 30 | ||
| 31 | describe("private destinations in the terminal", () => { | |
| 32 | let dataDir: string; | |
| 33 | let forge: Forge; | |
| 34 | let ada: User; // site admin | |
| 35 | let erin: User; // administers the repository through a team, is no site admin | |
| 36 | let dave: User; // writes to the repository, is no site admin | |
| 37 | let repo: Repo; | |
| 38 | ||
| 39 | const grants = () => listEgressGrants(forge.db, repo.id).map((g) => g.destination); | |
| 40 | const changes = () => | |
| 41 | listEvents(forge.db, { repoId: repo.id }).filter((e) => e.kind.startsWith("app.egress.")); | |
| 42 | ||
| 43 | function boot(user: User) { | |
| 44 | const terminal = headlessTerminal(120, 32); | |
| 45 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 46 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 47 | app.start(); | |
| 48 | const press = async (...keys: string[]) => { | |
| 49 | for (const key of keys) { | |
| 50 | const ctrl = key.startsWith("ctrl+"); | |
| 51 | app.dispatch({ type: "key", key: ctrl ? key.slice(5) : key, ctrl, alt: false, shift: false }); | |
| 52 | } | |
| 53 | await settleApp(app); | |
| 54 | }; | |
| 55 | const toAbout = async () => { | |
| 56 | await settleApp(app); | |
| 57 | await press("ctrl+k"); | |
| 58 | for (const ch of "Open a repository") | |
| 59 | app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 60 | await press("enter"); | |
| 61 | for (const ch of "acme/arm") | |
| 62 | app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 63 | await press("enter"); | |
| 64 | await press("I"); | |
| 65 | }; | |
| 66 | return { text: () => app.screen.toText(), press, toAbout, stop: () => app.stop() }; | |
| 67 | } | |
| 68 | ||
| 69 | beforeAll(async () => { | |
| 70 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-egress-")); | |
| 71 | forge = createApp({ dataDir, port: 0 }); | |
| 72 | const make = async (username: string, adminIfFirst: boolean): Promise<User> => { | |
| 73 | const made = await createUser(forge.db, { | |
| 74 | origin: "seed", | |
| 75 | trusted: true, | |
| 76 | username, | |
| 77 | email: `${username}@forge.local`, | |
| 78 | password: "a-good-passphrase", | |
| 79 | adminIfFirst, | |
| 80 | }); | |
| 81 | if (!made.ok) throw new Error(made.error); | |
| 82 | return made.value; | |
| 83 | }; | |
| 84 | ada = await make("ada", true); | |
| 85 | dave = await make("dave", false); | |
| 86 | erin = await make("erin", false); | |
| 87 | promote(forge.db, dave, erin); | |
| 88 | const org = createOrg(forge.db, "acme", ada.id); | |
| 89 | if (!org.ok) throw new Error("org"); | |
| 90 | const team = createTeam(forge.db, org.value.id, "writers", "write"); | |
| 91 | if (!team.ok) throw new Error("team"); | |
| 92 | addTeamMember(forge.db, team.value, dave.id); | |
| 93 | const stewards = createTeam(forge.db, org.value.id, "stewards", "admin"); | |
| 94 | if (!stewards.ok) throw new Error("team"); | |
| 95 | addTeamMember(forge.db, stewards.value, erin.id); | |
| 96 | const made = await createRepo(forge, ada, { name: "arm", owner: "acme", visibility: "public", description: "" }); | |
| 97 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 98 | repo = made.value; | |
| 99 | const committed = await commitToBranch(forge, ada, repo, { | |
| 100 | message: "Ask for the arm", | |
| 101 | files: [ | |
| 102 | { | |
| 103 | path: "forge.json", | |
| 104 | content: JSON.stringify({ app: { run: "bun run src/server.ts" }, egress: [ARM, CAMERA] }), | |
| 105 | }, | |
| 106 | ], | |
| 107 | }); | |
| 108 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 109 | }, 60_000); | |
| 110 | ||
| 111 | afterAll(async () => { | |
| 112 | forge?.close(); | |
| 113 | if (dataDir) await rm(dataDir, { recursive: true, force: true }); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a writer is offered neither the list nor the keys, and a press changes nothing", async () => { | |
| 117 | const h = boot(dave); | |
| 118 | await h.toAbout(); | |
| 119 | expect(h.text()).not.toContain("e egress"); | |
| 120 | await h.press("e", "g", "x"); | |
| 121 | expect(h.text()).not.toContain("Private egress"); | |
| 122 | expect(h.text()).not.toContain("asked for, not granted"); | |
| 123 | expect(grants()).toEqual([]); | |
| 124 | h.stop(); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a site admin lists, grants and revokes through the actions", async () => { | |
| 128 | const h = boot(ada); | |
| 129 | await h.toAbout(); | |
| 130 | expect(h.text()).toContain("e egress"); | |
| 131 | await h.press("e"); | |
| 132 | expect(h.text()).toContain("Private egress"); | |
| 133 | expect(h.text()).toContain(ARM); | |
| 134 | expect(h.text()).toContain(CAMERA); | |
| 135 | expect(h.text()).toContain("asked for, not granted"); | |
| 136 | await h.press("g"); | |
| 137 | expect(grants()).toEqual([ARM]); | |
| 138 | expect(h.text()).toContain("granted by ada"); | |
| 139 | expect(changes().map((e) => e.kind)).toEqual(["app.egress.granted"]); | |
| 140 | await h.press("x"); | |
| 141 | expect(grants()).toEqual([]); | |
| 142 | expect(changes().map((e) => e.kind).sort()).toEqual(["app.egress.granted", "app.egress.revoked"]); | |
| 143 | h.stop(); | |
| 144 | }); | |
| 145 | ||
| 146 | test("a repository admin who is no site admin mounts the screen and gets no rows and no keys", async () => { | |
| 147 | // The action sees a repository admin, so only `canChange` stands between | |
| 148 | // them and the list and the keys. | |
| 149 | const before = changes().length; | |
| 150 | const view = await egressView(forge, erin, repo); | |
| 151 | expect(view.ok && view.value.rows.length).toBe(2); | |
| 152 | const terminal = headlessTerminal(120, 32); | |
| 153 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: erin }; | |
| 154 | const { app, session } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 155 | app.start(); | |
| 156 | const screen = egressScreen(session, app.ctx, repo); | |
| 157 | app.ctx.push(screen); | |
| 158 | await settleApp(app); | |
| 159 | expect(app.screen.toText()).not.toContain(ARM); | |
| 160 | expect(app.screen.toText()).not.toContain(CAMERA); | |
| 161 | expect(screen.keys.flatMap((b) => b.keys)).not.toContain("g"); | |
| 162 | expect(screen.keys.flatMap((b) => b.keys)).not.toContain("x"); | |
| 163 | for (const key of ["g", "x"]) | |
| 164 | app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 165 | await settleApp(app); | |
| 166 | expect(grants()).toEqual([]); | |
| 167 | expect(changes().length).toBe(before); | |
| 168 | app.stop(); | |
| 169 | }); | |
| 170 | }); |
new above
offline
The terminal shows the list to site admins only, while the page shows it read-only to repository admins. If that difference is intended to last, say so in docs/APPS.md so nobody reads it as a bug.