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

The terminal's Sessions screen makes an API token with `m`: name and scope asked, the secret shown once #8105

Merged opened this

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

Merged

scout/forge:scout/8092-the-terminal-s-sessions-screen-makes-an → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/8092-the-terminal-s-sessions-screen-makes-an was merged into main as dcb2eb7.

Review

Sign in to review this change.
2 files changed +351 −5
src/tui/screens/signins.ts +112 −5
@@ -6,20 +6,26 @@
66 * `endAccountSession`, so the gate is the action's: your own account only.
77 * `d` on a token revokes it after a confirm, through the action `deleteToken`
88 * as Settings and the API do (your own only, and `token.deleted` is emitted).
9 * `m` makes a token through the action `createToken` (what Settings uses, so

The 'no bootstrapping' claim describes a guard that does not exist (the session cannot tell). Remove it or enforce it.

10 * `token.created` is emitted and `ADMIN_TOKEN_WORDS` is the refusal): a name,
11 * a scope, and for admin a second explicit `y`. The secret is shown once on a
12 * screen of its own and forgotten when that screen is left. Only a person
13 * minting from a terminal session may: an agent is not offered `m`.
914 * This terminal is held by its key, not a browser session, and cannot be
1015 * ended from here.
1116 */
1217
1318 import { accountSignIns, endAccountSession } from "../../actions/signins.ts";
14 import { deleteToken } from "../../actions/users.ts";
19 import { createToken, deleteToken } from "../../actions/users.ts";
1520 import { listKeys } from "../../core/keys.ts";
21 import { ADMIN_TOKEN_WORDS, type TokenScope } from "../../core/tokens.ts";
1622 import type { Ctx, Screen } from "../app/app.ts";
1723 import { binding } from "../app/keys.ts";
1824 import { style } from "../term/style.ts";
1925 import { truncate } from "../term/width.ts";
2026 import type { Session } from "../session.ts";
2127 import { fill, len, split } from "../ui/layout.ts";
22 import { ListState, ago, confirmScreen, drawList, type ListRow } from "./common.ts";
28 import { ListState, ago, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts";
2329
2430 interface SignIn {
2531 readonly kind: "terminal" | "browser" | "token";
@@ -70,6 +76,38 @@ export function signInsOf(f: Session): SignIn[] {
7076 ];
7177 }
7278
79 /** The secret, alone on a screen; nothing keeps it once this is popped. */
80 function secretScreen(ctx: Ctx, secret: string): Screen {
81 let copied = false;
82 return {
outdated

Truncation hides part of the secret on a narrow terminal; wrap it or say that it is cut and that y copies it whole. q also pops this screen but is not in keys.

83 title: "New token",
84 keys: [binding(["y"], "copy it"), binding(["esc", "q"], "done")],
85 render(area, s) {
86 const c = ctx.theme.c;
87 s.text(area.x, area.y, truncate("copy it now, it is never shown again.", area.w), style(c.mutedFg, c.bg), area.x + area.w);
88 // Wrapped, never cut: a hand-copied secret must be the whole secret.
89 const width = Math.max(1, area.w);
90 const lines: string[] = [];
91 for (let i = 0; i < secret.length; i += width) lines.push(secret.slice(i, i + width));
92 lines.forEach((line, i) => s.text(area.x, area.y + 2 + i, line, style(c.fg, c.bg), area.x + area.w));
93 const foot = copied ? "Copied." : "y copies it, esc is done.";
94 s.text(area.x, area.y + 3 + lines.length, truncate(foot, area.w), style(c.mutedFg, c.bg), area.x + area.w);
95 if (lines.length > 1)
96 s.text(area.x, area.y + 4 + lines.length, truncate("It wraps; y copies it whole.", area.w), style(c.mutedFg, c.bg), area.x + area.w);
97 },
98 key(e) {
99 if (e.ctrl || e.alt) return true;
100 if (e.key === "y") {
101 ctx.copy(secret);
102 copied = true;
103 } else if (e.key === "esc" || e.key === "q") {
104 ctx.pop();
105 }
106 return true;
107 },
108 };
109 }
110
73111 export function signInsScreen(f: Session, ctx: Ctx): Screen {
74112 const state = new ListState();
75113 let note = "";
@@ -120,9 +158,73 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
120158 );
121159 };
outdated

Lift the nested name and scope prompts out of make into named functions; the four-deep onSubmit chain is hard to review. Add tests for empty and over-long names, an unknown scope, and guest or agent pressing m.

122160
161 /** Pushes the secret's screen; call it once the modal has popped. */
162 const mint = (name: string, scope: TokenScope): void => {
163 const user = f.user;
164 const made = createToken(f.app, user, name, scope);
165 if (!made.ok) {
166 note = made.error === "forbidden" ? ADMIN_TOKEN_WORDS : "Sign in to make a token.";
167 ctx.invalidate();
168 return;
169 }
170 note = `Made ${name} (${scope}).`;
171 ctx.push(secretScreen(ctx, made.value.token));
172 };
173
174 /** Asks admin to say it twice; a plain y mints. */
175 const confirmAdmin = (name: string) =>
176 later(ctx, () =>
177 confirmScreen(ctx, screen, {
178 title: "Make an admin token",
179 body: [`${name} will act as admin: y to make it, n to cancel.`],
180 actions: [{ label: "Make it", variant: "danger" }, { label: "Cancel", variant: "ghost" }],
181 yesNo: true,
182 selected: 1,
183 width: 60,
184 onChoose: (i) => {
185 if (i === 0) mint(name, "admin");
186 },
187 }),
188 );
189
190 const askScope = (name: string, isAdmin: boolean) =>
191 later(ctx, () =>
192 promptScreen(ctx, screen, {
193 title: "Make a token",
194 label: isAdmin ? "Scope: ro, rw or admin" : "Scope: ro or rw",
195 value: "ro",
196 onSubmit: (word) => {
197 const scope = word.toLowerCase();
198 if (scope === "admin" && !isAdmin) return ADMIN_TOKEN_WORDS;
199 if (scope !== "ro" && scope !== "rw" && scope !== "admin") return isAdmin ? "Type ro, rw or admin." : "Type ro or rw.";
200 if (scope === "admin") return confirmAdmin(name);
201 return Promise.resolve().then(() => mint(name, scope));
202 },
203 }),
204 );
205
206 const askName = (isAdmin: boolean) =>
207 promptScreen(ctx, screen, {
208 title: "Make a token",
209 label: "Name",
210 value: "token",
211 onSubmit: (name) => {
212 if (name === "") return "A token needs a name.";
213 if ([...name].length > 64) return "Keep the name to 64 characters.";
214 return askScope(name, isAdmin);
215 },
216 });
217
218 const make = (): void => {
219 const user = f.user;
220 // An agent holds no keys to mint with; a guest has no account.
221 if (user === undefined || user.kind === "agent") return;
222 ctx.push(askName(user.isAdmin));
223 };
224
123225 const screen: Screen = {
124226 title: "Signed in",
125 keys: [binding(["j", "k"], "move cursor"), binding(["d"], "sign a browser out or revoke a token")],
227 keys: [binding(["j", "k"], "move cursor"), binding(["d"], "sign a browser out or revoke a token"), binding(["m"], "make a token")],
126228 render(area, s) {
127229 const c = ctx.theme.c;
128230 const all = signInsOf(f);
@@ -132,7 +234,7 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
132234 ? note
133235 : f.user === undefined
134236 ? "Sign in to see where you are signed in."
135 : "Where you are signed in. d signs a browser out or revokes a token.";
237 : "Where you are signed in. d signs a browser out or revokes a token, m makes a token.";
136238 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
137239 drawList(s, body!, c, state, rows(all));
138240 },
@@ -140,7 +242,12 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
140242 note = "";
141243 const all = signInsOf(f);
142244 if (state.key(e, all.length)) return true;
143 if (e.ctrl || e.alt || e.key !== "d") return false;
245 if (e.ctrl || e.alt) return false;
246 if (e.key === "m") {
247 make();
248 return true;
249 }
250 if (e.key !== "d") return false;
144251 const picked = all[state.index];
145252 if (picked === undefined) return false;
146253 end(picked);
test/tui-token-make.test.ts added +239
@@ -0,0 +1,239 @@
1 /**
2 * Making a token in the terminal (#8092): `m` on the Signed-in screen asks a
3 * name and a scope, mints through `createToken` (so `token.created` is
4 * emitted), shows the secret once on a screen of its own, and forgets it.
5 * Admin is offered only to an admin and needs a second `y`.
6 */
7 import { afterAll, beforeAll, describe, 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 { createApp, type App as Forge } from "../src/app.ts";
12 import { listEvents } from "../src/core/events.ts";
13 import { listTokens } from "../src/core/tokens.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import { signInsScreen } from "../src/tui/screens/signins.ts";
17 import { Session } from "../src/tui/session.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 describe("making a token in the terminal", () => {
25 let dataDir: string;
26 let forge: Forge;
27
28 function boot(username: string) {
29 const terminal = headlessTerminal(140, 32);
30 const user = getUserByName(forge.db, username)!;
31 const { app } = createForgeApp(
32 forge,
33 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
34 { terminal, exitHooks: false },
35 );
36 app.start();
37 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
38 return {
39 text: () => app.screen.toText(),
40 out: () => terminal.output(),
41 press: async (...keys: string[]) => {
42 keys.forEach((k) => key(k));
43 await settleApp(app);
44 },
45 type: async (t: string) => {
46 for (const ch of t) key(ch);
47 await settleApp(app);
48 },
49 open: async () => {
50 key("k", true);
51 await settleApp(app);
52 for (const ch of "Signed in") key(ch);
53 await settleApp(app);
54 key("enter");
55 await settleApp(app);
56 },
57 stop: () => app.stop(),
58 };
59 }
60
61 beforeAll(async () => {
62 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-token-make-"));
63 forge = createApp({ dataDir, port: 0 });
64 for (const [username, adminIfFirst] of [["boss", true], ["carol", false], ["bot1", false]] as const) {
65 const made = await createUser(forge.db, {
66 origin: "seed",
67 trusted: true,
68 username,
69 email: `${username}@forge.local`,
70 password: "a-good-passphrase",
71 adminIfFirst,
72 });
73 if (!made.ok) throw new Error(made.error);
74 }
75 forge.db.query("UPDATE user SET is_agent = 1 WHERE username = 'bot1'").run();
76 });
77 afterAll(async () => {
78 forge.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 test("m mints an rw token, shows the secret once, and the list shows it used", async () => {
83 const h = boot("carol");
84 await h.open();
85 await h.press("m");
86 expect(h.text()).toContain("Name");
87 await h.press("backspace", "backspace", "backspace", "backspace", "backspace");
88 await h.type("ci");
89 await h.press("enter");
90 expect(h.text()).toContain("Scope: ro or rw");
91 await h.press("backspace", "backspace");
92 await h.type("rw");
93 await h.press("enter");
94 const text = h.text();
95 expect(text).toContain("copy it now, it is never shown again.");
96 const carol = getUserByName(forge.db, "carol")!;
97 expect(listTokens(forge.db, carol.id).some((t) => t.name === "ci" && t.scope === "rw")).toBe(true);
98 const secret = text.match(/forge_[A-Za-z0-9_-]+/)?.[0];
99 expect(secret).toBeDefined();
100 expect(listEvents(forge.db, { kind: "token.created" }).length).toBeGreaterThan(0);
101 await h.press("esc");
102 const after = h.text();
103 expect(after).not.toContain(secret!);
104 expect(after).toContain("ci · rw");
105 expect(after).toMatch(/ci · rw[\s\S]*last seen never/);
106 h.stop();
107 });
108
109 test("a non-admin is never offered admin and nothing is made", async () => {
110 const h = boot("carol");
111 await h.open();
112 const before = listTokens(forge.db, getUserByName(forge.db, "carol")!.id).length;
113 await h.press("m", "enter");
114 expect(h.text()).not.toContain("admin");
115 await h.press("backspace", "backspace");
116 await h.type("admin");
117 await h.press("enter");
118 expect(h.text()).toContain("Only an admin can make a token");
119 expect(listTokens(forge.db, getUserByName(forge.db, "carol")!.id).length).toBe(before);
120 h.stop();
121 });
122
123 test("an admin needs a second y for admin scope", async () => {
124 const h = boot("boss");
125 await h.open();
126 await h.press("m", "enter");
127 expect(h.text()).toContain("ro, rw or admin");
128 await h.press("backspace", "backspace");
129 await h.type("admin");
130 await h.press("enter");
131 expect(h.text()).toContain("will act as admin");
132 const root = getUserByName(forge.db, "boss")!;
133 await h.press("n");
134 expect(listTokens(forge.db, root.id).length).toBe(0);
135 await h.press("m", "enter");
136 await h.press("backspace", "backspace");
137 await h.type("admin");
138 await h.press("enter");
139 await h.press("y");
140 expect(h.text()).toContain("copy it now");
141 expect(listTokens(forge.db, root.id).some((t) => t.scope === "admin")).toBe(true);
142 h.stop();
143 });
144
145 const count = (name: string) => listTokens(forge.db, getUserByName(forge.db, name)!.id).length;
146
147 test("an empty name and a name over 64 characters are refused and nothing is made", async () => {
148 const h = boot("carol");
149 await h.open();
150 const before = count("carol");
151 await h.press("m", "backspace", "backspace", "backspace", "backspace", "backspace", "enter");
152 expect(h.text()).toContain("A token needs a name.");
153 await h.type("x".repeat(65));
154 await h.press("enter");
155 expect(h.text()).toContain("Keep the name to 64 characters.");
156 expect(h.text()).not.toContain("Scope: ro or rw");
157 expect(count("carol")).toBe(before);
158 h.stop();
159 });
160
161 test("an unknown scope word is refused and nothing is made", async () => {
162 const h = boot("carol");
163 await h.open();
164 const before = count("carol");
165 await h.press("m", "enter");
166 await h.press("backspace", "backspace");
167 await h.type("root");
168 await h.press("enter");
169 expect(h.text()).toContain("Type ro or rw.");
170 expect(count("carol")).toBe(before);
171 h.stop();
172 });
173
174 test("a guest and an agent pressing m get nothing and no prompt opens", () => {
175 const pushed: unknown[] = [];
176 const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {} } as never;
177 const press = (session: Session) =>
178 signInsScreen(session, ctx).key?.({ type: "key", key: "m", ctrl: false, alt: false, shift: false } as never);
179 press(new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1" }));
180 const bot = getUserByName(forge.db, "bot1")!;
181 press(new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: bot }));
182 expect(pushed).toEqual([]);
183 expect(count("bot1")).toBe(0);
184 });
185
186 test("y on the secret screen copies the whole secret to the clipboard", async () => {
187 const h = boot("carol");
188 await h.open();
189 await h.press("m", "enter");
190 await h.press("enter");
191 const secret = h.text().match(/forge_[A-Za-z0-9_-]+/)?.[0];
192 expect(secret).toBeDefined();
193 await h.press("y");
194 expect(h.text()).toContain("Copied.");
195 expect(h.out()).toContain(`\x1b]52;c;${Buffer.from(secret!, "utf8").toString("base64")}\x07`);
196 h.stop();
197 });
198
199 test("esc from the admin confirm leaves no token", async () => {
200 const h = boot("boss");
201 await h.open();
202 const before = count("boss");
203 await h.press("m", "enter");
204 await h.press("backspace", "backspace");
205 await h.type("admin");
206 await h.press("enter");
207 expect(h.text()).toContain("will act as admin");
208 await h.press("esc");
209 expect(h.text()).not.toContain("copy it now");
210 expect(count("boss")).toBe(before);
211 h.stop();
212 });
213
214 test("a narrow terminal wraps the secret whole and says y copies it", async () => {
215 const terminal = headlessTerminal(40, 24);
216 const user = getUserByName(forge.db, "carol")!;
217 const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false });
218 app.start();
219 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
220 key("k", true);
221 await settleApp(app);
222 for (const ch of "Signed in") key(ch);
223 await settleApp(app);
224 key("enter");
225 await settleApp(app);
226 key("m");
227 await settleApp(app);
228 key("enter");
229 await settleApp(app);
230 key("enter");
231 await settleApp(app);
232 const rows = app.screen.toText().split("\n").map((l) => l.trim());
233 const joined = rows.join("");
234 const secret = joined.match(/forge_[A-Za-z0-9_-]+/)?.[0] ?? "";
235 expect(secret.length).toBeGreaterThan(40);
236 expect(joined).toContain("y copies it whole");
237 app.stop();
238 });
239 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs