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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7961-granting-an-app-a-private-destination-in → main 1 commit fork
4 files
changed +63
−15
src/tui/screens/common.ts +3
| @@ -398,6 +398,8 @@ export function confirmScreen( | ||
| 398 | 398 | selected?: number; |
| 399 | 399 | /** `y` chooses the first action and `n` dismisses, for a plain yes/no question. */ |
| 400 | 400 | yesNo?: boolean; |
| 401 | /** Columns of the dialog, for a body line longer than the default fits. */ | |
| 402 | width?: number; | |
| 401 | 403 | }, |
| 402 | 404 | ): Screen { |
| 403 | 405 | let selected = o.selected ?? 0; |
| @@ -415,6 +417,7 @@ export function confirmScreen( | ||
| 415 | 417 | body: o.body, |
| 416 | 418 | actions: o.actions, |
| 417 | 419 | selected, |
| 420 | ...(o.width !== undefined ? { width: o.width } : {}), | |
| 418 | 421 | }); |
| 419 | 422 | // Clicking a button *is* choosing it: a dialog has no browsing to do, |
| 420 | 423 | // 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { |
| @@ -19,7 +19,7 @@ import { style } from "../term/style.ts"; | ||
| 19 | 19 | import { truncate } from "../term/width.ts"; |
| 20 | 20 | import { fill, len, split } from "../ui/layout.ts"; |
| 21 | 21 | 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"; | |
| 23 | 23 | |
| 24 | 24 | export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 25 | 25 | const state = new ListState(); |
| @@ -35,7 +35,7 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 35 | 35 | state.clamp(rows.length); |
| 36 | 36 | ctx.invalidate(); |
| 37 | 37 | }; |
| 38 | void reload(); | |
| 38 | runAsync(ctx, reload); | |
| 39 | 39 | const lines = (): ListRow[] => |
| 40 | 40 | rows.map((r) => ({ |
| 41 | 41 | title: r.destination, |
| @@ -43,15 +43,14 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 43 | 43 | ? `granted by ${r.grantedBy} ${(r.grantedAt ?? "").slice(0, 16).replace("T", " ")} UTC` |
| 44 | 44 | : "asked for, not granted", |
| 45 | 45 | })); |
| 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 | }); | |
| 55 | 54 | |
| 56 | 55 | const screen: Screen = { |
| 57 | 56 | title: "Private egress", |
| @@ -74,11 +73,25 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 74 | 73 | key(e) { |
| 75 | 74 | const row = canChange ? rows[state.index] : undefined; |
| 76 | 75 | 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 | ); | |
| 78 | 91 | return true; |
| 79 | 92 | } |
| 80 | 93 | 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.`); | |
| 82 | 95 | return true; |
| 83 | 96 | } |
| 84 | 97 | return state.key(e, rows.length); |
test/egress-admin.test.ts +3
| @@ -87,6 +87,9 @@ describe("egress grants in the terminal", () => { | ||
| 87 | 87 | await h.press("e"); |
| 88 | 88 | await settleUntil(h.tui, "asked for, not granted"); |
| 89 | 89 | await h.press("g"); |
| 90 | await settleUntil(h.tui, "reach"); | |
| 91 | expect(grants()).toEqual([]); | |
| 92 | await h.press("y"); | |
| 90 | 93 | await settleUntil(h.tui, "granted by ada"); |
| 91 | 94 | expect(grants()).toEqual([ARM]); |
| 92 | 95 | await h.press("x"); |
test/tui-egress.test.ts +29
| @@ -134,7 +134,14 @@ describe("private destinations in the terminal", () => { | ||
| 134 | 134 | expect(h.text()).toContain(CAMERA); |
| 135 | 135 | expect(h.text()).toContain("asked for, not granted"); |
| 136 | 136 | await h.press("g"); |
| 137 | expect(h.text()).toContain(`Let acme/arm reach ${ARM}?`); | |
| 138 | expect(grants()).toEqual([]); | |
| 139 | await h.press("y"); | |
| 137 | 140 | 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}?`); | |
| 138 | 145 | expect(h.text()).toContain("granted by ada"); |
| 139 | 146 | expect(changes().map((e) => e.kind)).toEqual(["app.egress.granted"]); |
| 140 | 147 | await h.press("x"); |
| @@ -143,6 +150,28 @@ describe("private destinations in the terminal", () => { | ||
| 143 | 150 | h.stop(); |
| 144 | 151 | }); |
| 145 | 152 | |
| 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 | ||
| 146 | 175 | test("a repository admin who is no site admin mounts the screen and gets no rows and no keys", async () => { |
| 147 | 176 | // The action sees a repository admin, so only `canChange` stands between |
| 148 | 177 | // them and the list and the keys. |
new above
offline