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

Merged opened this

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

Merged

scout/forge:scout/3618-the-terminal-lists-grants-and-revokes-an → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/3618-the-terminal-lists-grants-and-revokes-an was merged into main as f8d4648.

Review

Sign in to review this change.
4 files changed +189 −4
.gitignore +3
@@ -40,3 +40,6 @@ typesafe-api-key
4040 # where a sandboxed `bun install` keeps its temp files and package cache
4141 # when the default temp directory is read-only (#2706)
4242 /.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
300300 destination forge.json does not name is refused, and each change is an
301301 `app.egress.granted` or `app.egress.revoked` event with who and when. In the terminal a site admin
302302 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`).
304306
305307 A release that turns egress on must not break an app whose writers have
306308 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";
2222 export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
2323 const state = new ListState();
2424 let rows: EgressRow[] = [];
25 // Whether the action says this viewer may change grants; it decides, not the screen.
26 let canChange = false;
2527 const reload = async (): Promise<void> => {
2628 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.

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.

32 rows = view.ok && view.value.canChange ? view.value.rows : [];
2833 state.clamp(rows.length);
2934 ctx.invalidate();
3035 };
@@ -48,7 +53,12 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4853
4954 const screen: Screen = {
5055 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 },
5262 render(area, s) {
5363 const c = ctx.theme.c;
5464 const [head, list] = split(area, "v", [len(1), fill(1)]);
@@ -60,7 +70,7 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6070 drawList(s, list!, c, state, lines());
6171 },
6272 key(e) {
63 const row = rows[state.index];
73 const row = canChange ? rows[state.index] : undefined;
6474 if (e.key === "g" && row !== undefined) {
6575 void change(grantAppEgress(f.app, f.user, repo, row.destination), `${row.destination} is granted.`);
6676 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs