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

Granting an app a private destination in the terminal asks first: `g` names the app and destination, `y` grants #7966

Merged opened this

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

Merged

scout/forge:scout/7961-granting-an-app-a-private-destination-in → main 1 commit fork

  • Merged. scout/7961-granting-an-app-a-private-destination-in was merged into main as 227c234.

Review

Sign in to review this change.
4 files changed +63 −15
src/tui/screens/common.ts +3
@@ -398,6 +398,8 @@ export function confirmScreen(
398398 selected?: number;
399399 /** `y` chooses the first action and `n` dismisses, for a plain yes/no question. */
400400 yesNo?: boolean;
401 /** Columns of the dialog, for a body line longer than the default fits. */
402 width?: number;
401403 },
402404 ): Screen {
403405 let selected = o.selected ?? 0;
@@ -415,6 +417,7 @@ export function confirmScreen(
415417 body: o.body,
416418 actions: o.actions,
417419 selected,
420 ...(o.width !== undefined ? { width: o.width } : {}),
418421 });
419422 // Clicking a button *is* choosing it: a dialog has no browsing to do,
420423 // so the two-click rule that protects a list would only be in the way.
src/tui/screens/egress.ts +28 −15
@@ -1,7 +1,7 @@
11 /**
22 * An app's private destinations in the terminal, a site admin's: the list the
3 * settings page shows, with `g` to grant the one under the cursor and `x` to
4 * revoke it. The actions decide who may; anyone else is offered nothing.
3 * settings page shows, with `g` to grant the one under the cursor once `y`
4 * confirms it (a grant widens the boundary) and `x` to revoke it. The actions decide who may; anyone else is offered nothing.
55 */
66
77 import {
@@ -19,7 +19,7 @@ import { style } from "../term/style.ts";
1919 import { truncate } from "../term/width.ts";
2020 import { fill, len, split } from "../ui/layout.ts";
2121 import type { Session } from "../session.ts";
22 import { ListState, drawList, type ListRow } from "./common.ts";
22 import { ListState, confirmScreen, drawList, runAsync, type ListRow } from "./common.ts";
2323
2424 export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
2525 const state = new ListState();
@@ -35,7 +35,7 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
3535 state.clamp(rows.length);
3636 ctx.invalidate();
3737 };
38 void reload();
38 runAsync(ctx, reload);
3939 const lines = (): ListRow[] =>
4040 rows.map((r) => ({
4141 title: r.destination,
@@ -43,15 +43,14 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4343 ? `granted by ${r.grantedBy} ${(r.grantedAt ?? "").slice(0, 16).replace("T", " ")} UTC`
4444 : "asked for, not granted",
4545 }));
46 const change = async (
47 done: Promise<{ ok: boolean }> | { ok: boolean },
48 said: string,
49 ): Promise<void> => {
50 const result = await done;
51 await reload();
52 if (result.ok) ctx.toast(said, "success");
53 else ctx.toast("that could not be changed", "danger");
54 };
46 // Tracked, so the app counts the write and the reload behind it as busy.
47 const change = (done: Promise<{ ok: boolean }> | { ok: boolean }, said: string): void =>
48 runAsync(ctx, async () => {
49 const result = await done;
50 await reload();
51 if (result.ok) ctx.toast(said, "success");
52 else ctx.toast("that could not be changed", "danger");
53 });
5554
5655 const screen: Screen = {
5756 title: "Private egress",
@@ -74,11 +73,25 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7473 key(e) {
7574 const row = canChange ? rows[state.index] : undefined;
7675 if (e.key === "g" && row !== undefined) {
77 void change(grantAppEgress(f.app, f.user, repo, row.destination), `${row.destination} is granted.`);
76 if (row.granted) return true;
77 const { destination } = row;
78 ctx.push(
79 confirmScreen(ctx, screen, {
80 title: `Let ${repo.ownerName}/${repo.name} reach ${destination}?`,
81 body: ["The app's outbound traffic to it is allowed until you revoke it with x."],
82 actions: [{ label: "Grant" }, { label: "Cancel", variant: "ghost" }],
83 yesNo: true,
84 width: 78,
85 onChoose: (choice) => {
86 if (choice !== 0) return;
87 change(grantAppEgress(f.app, f.user, repo, destination), `${destination} is granted.`);
88 },
89 }),
90 );
7891 return true;
7992 }
8093 if (e.key === "x" && row !== undefined) {
81 void change(revokeAppEgress(f.app, f.user, repo, row.destination), `${row.destination} is revoked.`);
94 change(revokeAppEgress(f.app, f.user, repo, row.destination), `${row.destination} is revoked.`);
8295 return true;
8396 }
8497 return state.key(e, rows.length);
test/egress-admin.test.ts +3
@@ -87,6 +87,9 @@ describe("egress grants in the terminal", () => {
8787 await h.press("e");
8888 await settleUntil(h.tui, "asked for, not granted");
8989 await h.press("g");
90 await settleUntil(h.tui, "reach");
91 expect(grants()).toEqual([]);
92 await h.press("y");
9093 await settleUntil(h.tui, "granted by ada");
9194 expect(grants()).toEqual([ARM]);
9295 await h.press("x");
test/tui-egress.test.ts +29
@@ -134,7 +134,14 @@ describe("private destinations in the terminal", () => {
134134 expect(h.text()).toContain(CAMERA);
135135 expect(h.text()).toContain("asked for, not granted");
136136 await h.press("g");
137 expect(h.text()).toContain(`Let acme/arm reach ${ARM}?`);
138 expect(grants()).toEqual([]);
139 await h.press("y");
137140 expect(grants()).toEqual([ARM]);
141 expect(h.text()).toContain(`${ARM} is granted.`);
142 // Already granted: `g` asks nothing and grants nothing again.
143 await h.press("g");
144 expect(h.text()).not.toContain(`Let acme/arm reach ${ARM}?`);
138145 expect(h.text()).toContain("granted by ada");
139146 expect(changes().map((e) => e.kind)).toEqual(["app.egress.granted"]);
140147 await h.press("x");
@@ -143,6 +150,28 @@ describe("private destinations in the terminal", () => {
143150 h.stop();
144151 });
145152
153 test("a site admin who presses g then n grants nothing", async () => {
154 const before = changes().length;
155 const h = boot(ada);
156 await h.toAbout();
157 await h.press("e");
158 await h.press("g");
159 expect(h.text()).toContain(`Let acme/arm reach ${ARM}?`);
160 expect(h.text()).toContain("The app's outbound traffic to it is allowed until you revoke it with x.");
161 await h.press("n");
162 expect(h.text()).not.toContain(`Let acme/arm reach ${ARM}?`);
163 expect(grants()).toEqual([]);
164 expect(changes().length).toBe(before);
165 const view = await egressView(forge, ada, repo);
166 expect(view.ok && view.value.rows.find((r) => r.destination === ARM)?.granted).toBe(false);
167 await h.press("g");
168 expect(h.text()).toContain(`Let acme/arm reach ${ARM}?`);
169 await h.press("esc");
170 expect(h.text()).not.toContain(`Let acme/arm reach ${ARM}?`);
171 expect(grants()).toEqual([]);
172 h.stop();
173 });
174
146175 test("a repository admin who is no site admin mounts the screen and gets no rows and no keys", async () => {
147176 // The action sees a repository admin, so only `canChange` stands between
148177 // them and the list and the keys.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs