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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8092-the-terminal-s-sessions-screen-makes-an → main 2 commits fork 3 threads · 3 unresolved
2 files
changed +351
−5
src/tui/screens/signins.ts +112 −5
| @@ -6,20 +6,26 @@ | ||
| 6 | 6 | * `endAccountSession`, so the gate is the action's: your own account only. |
| 7 | 7 | * `d` on a token revokes it after a confirm, through the action `deleteToken` |
| 8 | 8 | * 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 | |
| 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`. | |
| 9 | 14 | * This terminal is held by its key, not a browser session, and cannot be |
| 10 | 15 | * ended from here. |
| 11 | 16 | */ |
| 12 | 17 | |
| 13 | 18 | import { accountSignIns, endAccountSession } from "../../actions/signins.ts"; |
| 14 | import { deleteToken } from "../../actions/users.ts"; | |
| 19 | import { createToken, deleteToken } from "../../actions/users.ts"; | |
| 15 | 20 | import { listKeys } from "../../core/keys.ts"; |
| 21 | import { ADMIN_TOKEN_WORDS, type TokenScope } from "../../core/tokens.ts"; | |
| 16 | 22 | import type { Ctx, Screen } from "../app/app.ts"; |
| 17 | 23 | import { binding } from "../app/keys.ts"; |
| 18 | 24 | import { style } from "../term/style.ts"; |
| 19 | 25 | import { truncate } from "../term/width.ts"; |
| 20 | 26 | import type { Session } from "../session.ts"; |
| 21 | 27 | 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"; | |
| 23 | 29 | |
| 24 | 30 | interface SignIn { |
| 25 | 31 | readonly kind: "terminal" | "browser" | "token"; |
| @@ -70,6 +76,38 @@ export function signInsOf(f: Session): SignIn[] { | ||
| 70 | 76 | ]; |
| 71 | 77 | } |
| 72 | 78 | |
| 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 |
||
| 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 | ||
| 73 | 111 | export function signInsScreen(f: Session, ctx: Ctx): Screen { |
| 74 | 112 | const state = new ListState(); |
| 75 | 113 | let note = ""; |
| @@ -120,9 +158,73 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 120 | 158 | ); |
| 121 | 159 | }; |
outdated
Lift the nested name and scope prompts out of |
||
| 122 | 160 | |
| 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 | ||
| 123 | 225 | const screen: Screen = { |
| 124 | 226 | 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")], | |
| 126 | 228 | render(area, s) { |
| 127 | 229 | const c = ctx.theme.c; |
| 128 | 230 | const all = signInsOf(f); |
| @@ -132,7 +234,7 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 132 | 234 | ? note |
| 133 | 235 | : f.user === undefined |
| 134 | 236 | ? "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."; | |
| 136 | 238 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 137 | 239 | drawList(s, body!, c, state, rows(all)); |
| 138 | 240 | }, |
| @@ -140,7 +242,12 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 140 | 242 | note = ""; |
| 141 | 243 | const all = signInsOf(f); |
| 142 | 244 | 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; | |
| 144 | 251 | const picked = all[state.index]; |
| 145 | 252 | if (picked === undefined) return false; |
| 146 | 253 | 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 | }); |
new above
offline
The 'no bootstrapping' claim describes a guard that does not exist (the session cannot tell). Remove it or enforce it.