Public: anyone can read this; the crew can change it.
The terminal lists a forge admin's sealed secrets by name and sets or removes one, never showing a value #7245
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7238-the-terminal-lists-a-forge-admin-s-seale → main 3 commits fork 5 threads · 5 unresolved
docs/OPERATING.md +4
| @@ -155,6 +155,10 @@ it falls back to a regex read of the served HTML). axe-core is a devDependency | ||
| 155 | 155 | of this check only, never loaded by the server: its rules need a rendered page |
| 156 | 156 | and an accessibility tree, which nothing dependency-free gives. |
| 157 | 157 | |
| 158 | ## Secrets in the terminal | |
| 159 | ||
| 160 | A forge admin opens the palette (`ctrl+k`), picks **Secrets**, and sees the sealed names at a place with when each was set and last read — names only, never a value. `a` sets one (place, name, then the value typed or pasted masked and kept verbatim, newlines included), `d` removes the one under the cursor after a confirm, `s` looks at another place (`forge`, `org NAME`, `repo OWNER/NAME`). Everything is `actions/secrets.ts`, as the page and MCP `secrets` use it; a member has no entry. | |
| 161 | ||
| 158 | 162 | ## Flags |
| 159 | 163 | |
| 160 | 164 | A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either. The MCP `flag_set` tool does the same with the same checks, for an admin token. The page and the terminal screen both list flags through one read, `readFlags` in `src/actions/flags_view.ts`. |
docs/TOUR.md +2
| @@ -32,6 +32,8 @@ username and password — which links that key to your account, so the next | ||
| 32 | 32 | connection is instant. `j`/`k` to move, `enter` to open, `esc` to go back, `/` |
| 33 | 33 | to filter, `?` for the keys, `:` for the command palette, `t` for the theme. |
| 34 | 34 | |
| 35 | **Secrets.** For a forge admin, `:` → *Secrets* lists sealed secret names (never values); `a` sets one, `d` removes one after a confirm, `s` changes the place. See [](OPERATING.md#secrets-in-the-terminal). | |
| 36 | ||
| 35 | 37 | **Milestones.** `:` → *Milestones in a repository* → `owner/name` lists a |
| 36 | 38 | repository's limbs in the order the crew takes ready work (`enter` opens a |
| 37 | 39 | limb's own limbs). A person who administers the repository presses `r` to |
src/tui/forge.ts +9
| @@ -49,6 +49,7 @@ import { accessScreen } from "./screens/access.ts"; | ||
| 49 | 49 | import { mayEditTrustedHosts } from "../origins/trusted-hosts.ts"; |
| 50 | 50 | import { trustedHostsPrompt } from "./screens/trustedhosts.ts"; |
| 51 | 51 | import { flagsScreen } from "./screens/flags.ts"; |
| 52 | import { secretsScreen } from "./screens/secrets.ts"; | |
| 52 | 53 | import { scorecardScreen } from "./screens/scorecard.ts"; |
| 53 | 54 | import { cloudScreen } from "./screens/cloud.ts"; |
| 54 | 55 | import { crewSplitsScreen } from "./screens/crewsplits.ts"; |
| @@ -393,6 +394,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 393 | 394 | hint: "declared flags and their live values", |
| 394 | 395 | run: () => ctx.push(flagsScreen(session, ctx)), |
| 395 | 396 | }); |
| 397 | // Settings → Secrets, as the page has it: a forge admin; names only, never a value. | |
| 398 | if (session.user?.isAdmin === true) | |
| 399 | commands.push({ | |
| 400 | id: "secrets", | |
| 401 | title: "Secrets", | |
| 402 | hint: "sealed secrets by name; set or remove one", | |
| 403 | run: () => ctx.push(secretsScreen(session, ctx)), | |
| 404 | }); | |
| 396 | 405 | // Settings → Origins' trusted internal hosts: the same admin gate as the page. |
| 397 | 406 | if (mayEditTrustedHosts(session.user)) |
| 398 | 407 | commands.push({ |
src/tui/screens/cloud.ts +2 −2
| @@ -102,7 +102,7 @@ export function cloudScreen(f: Session, ctx: Ctx): Screen { | ||
| 102 | 102 | title: `${LABEL[provider as (typeof PROVIDERS)[number]]} / ${name}`, |
| 103 | 103 | label: "the token (checked, then sealed, never shown)", |
| 104 | 104 | mask: true, |
| 105 | onSubmit: (value) => settle(addAccount(f.app, f.user, provider, name, value), () => `Sealed ${provider}/${name}.`), | |
| 105 | onSubmit: (value) => settle(addAccount(f.app, f.user, provider, name, value.trim()), () => `Sealed ${provider}/${name}.`), | |
| 106 | 106 | }), |
| 107 | 107 | ); |
| 108 | 108 | }, |
| @@ -160,7 +160,7 @@ export function cloudScreen(f: Session, ctx: Ctx): Screen { | ||
| 160 | 160 | title: `Rotate ${provider}/${name}`, |
| 161 | 161 | label: "the new token (checked, then sealed)", |
| 162 | 162 | mask: true, |
| 163 | onSubmit: (value) => settle(rotateAccount(f.app, f.user, provider, name, value), () => `Rotated ${provider}/${name}.`), | |
| 163 | onSubmit: (value) => settle(rotateAccount(f.app, f.user, provider, name, value.trim()), () => `Rotated ${provider}/${name}.`), | |
| 164 | 164 | }), |
| 165 | 165 | ); |
| 166 | 166 | else if (e.key === "n") |
src/tui/screens/common.ts +5 −3
| @@ -452,7 +452,8 @@ export function promptScreen( | ||
| 452 | 452 | /** |
| 453 | 453 | * Show the value as dots. A terminal is the one surface where somebody |
| 454 | 454 | * is routinely reading over your shoulder, so a secret typed into one |
| 455 | * must never be a plain string of cells. | |
| 455 | * must never be a plain string of cells. A masked value is also kept | |
| 456 | * verbatim: not trimmed, and a paste keeps its newlines. | |
| 456 | 457 | */ |
| 457 | 458 | mask?: boolean; |
| 458 | 459 | /** Return an error string to keep the prompt open with a message. */ |
| @@ -491,7 +492,8 @@ export function promptScreen( | ||
| 491 | 492 | ctx.setCursor(cursor); |
| 492 | 493 | }, |
| 493 | 494 | paste(text) { |
| 494 | value += text.replace(/\s+/g, " "); | |
| 495 | // A masked value is a credential: kept as pasted (a PEM key's lines), only CR/CRLF made LF. | |
| 496 | value += o.mask === true ? text.replace(/\r\n?/g, "\n") : text.replace(/\s+/g, " "); | |
| 495 | 497 | return true; |
| 496 | 498 | }, |
| 497 | 499 | key(e) { |
| @@ -500,7 +502,7 @@ export function promptScreen( | ||
| 500 | 502 | return true; |
| 501 | 503 | } |
| 502 | 504 | if (e.key === "enter") { |
| 503 | const outcome = o.onSubmit(value.trim()); | |
| 505 | const outcome = o.onSubmit(o.mask === true ? value : value.trim()); | |
This no longer trims any masked prompt, and the cloud add and rotate prompts ( |
||
| 504 | 506 | if (typeof outcome === "string") { |
| 505 | 507 | error = outcome; |
| 506 | 508 | return true; |
src/tui/screens/secrets.ts added +172
| @@ -0,0 +1,172 @@ | ||
| 1 | /** | |
| 2 | * Sealed secrets in the terminal, a forge admin's: the names at a scope with | |
| 3 | * when each was set and last opened, as `/settings/secrets` lists them. `s` | |
| 4 | * looks at another place, `a` sets one (place, name, then the value typed | |
| 5 | * masked), `d` removes the one under the cursor after a confirm. Every rule is | |
| 6 | * the action's (`actions/secrets.ts`); a value is typed once, handed to it and | |
| 7 | * never held, drawn or logged here. Anyone else has no command and, driving | |
| 8 | * the screen directly, sees and changes nothing. | |
| 9 | */ | |
| 10 | ||
| 11 | import { listSecrets, putSecret, removeSecret } from "../../actions/secrets.ts"; | |
| 12 | import { isSecretName, type SecretName } from "../../core/secret_store.ts"; | |
| 13 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 14 | import { binding } from "../app/keys.ts"; | |
| 15 | import { style } from "../term/style.ts"; | |
| 16 | import { truncate } from "../term/width.ts"; | |
| 17 | import type { Session } from "../session.ts"; | |
| 18 | import { fill, len, split } from "../ui/layout.ts"; | |
| 19 | import { confirmScreen, drawList, later, ListState, promptScreen } from "./common.ts"; | |
| 20 | ||
| 21 | /** The action's refusals in this screen's words. */ | |
| 22 | const ERRORS: Record<string, string> = { | |
| 23 | invalid_name: "A name is capital letters, digits and underscores, starting with a letter — like CLOUDFLARE_API_TOKEN.", | |
| 24 | invalid_value: "A value is never empty and stays under 64 KiB.", | |
| 25 | invalid_scope: "Place is forge, org NAME or repo OWNER/NAME.", | |
| 26 | not_found: "No such organization or repository.", | |
| 27 | no_such_secret: "No secret by that name there.", | |
| 28 | }; | |
| 29 | ||
| 30 | const say = (code: string): string => ERRORS[code] ?? "That could not be done."; | |
| 31 | ||
| 32 | interface Place { | |
| 33 | readonly scope: string; | |
| 34 | readonly ref: string; | |
| 35 | } | |
| 36 | ||
| 37 | /** `forge`, `org acme` or `repo alice/site` → a place; a string is the refusal. */ | |
| 38 | export function parsePlace(line: string): Place | string { | |
| 39 | const [scope = "", ref = "", ...rest] = line.trim().split(/\s+/); | |
| 40 | if (rest.length > 0 || (scope === "forge" && ref !== "")) return ERRORS.invalid_scope!; | |
| 41 | if (scope === "forge") return { scope, ref: "" }; | |
| 42 | if ((scope === "org" || scope === "repo") && ref !== "") return { scope, ref }; | |
| 43 | return ERRORS.invalid_scope!; | |
| 44 | } | |
| 45 | ||
| 46 | const showPlace = (p: Place): string => (p.scope === "forge" ? "forge" : `${p.scope} ${p.ref}`); | |
| 47 | ||
| 48 | export function secretsScreen(f: Session, ctx: Ctx): Screen { | |
| 49 | const state = new ListState(); | |
| 50 | const allowed = f.user?.isAdmin === true; | |
| 51 | let place: Place = { scope: "forge", ref: "" }; | |
| 52 | let note = ""; | |
| 53 | ||
| 54 | const names = (): readonly SecretName[] => { | |
| 55 | const listed = allowed ? listSecrets(f.app, f.user, place.scope, place.ref) : undefined; | |
| 56 | return listed?.ok ? listed.value : []; | |
| 57 | }; | |
| 58 | ||
| 59 | const screen: Screen = { | |
| 60 | title: "Secrets", | |
| 61 | keys: [ | |
| 62 | binding(["j", "k"], "move cursor"), | |
| 63 | binding(["a"], "set a secret"), | |
| 64 | binding(["d"], "remove"), | |
| 65 | binding(["s"], "other place"), | |
| 66 | binding(["esc"], "back"), | |
| 67 | ], | |
| 68 | render(area, s) { | |
| 69 | const c = ctx.theme.c; | |
| 70 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 71 | const rows = names(); | |
| 72 | const text = !allowed | |
| 73 | ? "Secrets are kept by a forge admin." | |
| 74 | : note !== "" | |
| 75 | ? note | |
| 76 | : rows.length === 0 | |
| 77 | ? `No secret at ${showPlace(place)}. a sets one.` | |
| 78 | : `Sealed secrets at ${showPlace(place)}; values are never shown.`; | |
| 79 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 80 | drawList( | |
| 81 | s, | |
| 82 | list!, | |
| 83 | c, | |
| 84 | state, | |
| 85 | rows.map((n) => ({ | |
| 86 | title: n.name, | |
| 87 | secondary: n.scope, | |
| 88 | meta: `set ${n.updatedAt} ${n.lastReadAt === null ? "never read" : `read ${n.lastReadAt}`}`, | |
| 89 | })), | |
| 90 | ); | |
| 91 | }, | |
| 92 | key(e) { | |
| 93 | if (state.key(e, names().length)) return true; | |
| 94 | if (e.ctrl || e.alt || !allowed) return false; | |
| 95 | if (e.key === "s") | |
| 96 | ctx.push( | |
| 97 | promptScreen(ctx, screen, { | |
| 98 | title: "Secrets: other place", | |
| 99 | label: "forge, org NAME or repo OWNER/NAME", | |
| 100 | value: showPlace(place), | |
| 101 | onSubmit: (raw) => { | |
| 102 | const next = parsePlace(raw); | |
Validate the place here as well as parse it: run |
||
| 103 | if (typeof next === "string") return next; | |
| 104 | const listed = listSecrets(f.app, f.user, next.scope, next.ref); | |
| 105 | if (!listed.ok) return say(listed.error); | |
| 106 | place = next; | |
| 107 | note = ""; | |
| 108 | state.index = 0; | |
| 109 | ctx.invalidate(); | |
| 110 | return undefined; | |
| 111 | }, | |
| 112 | }), | |
| 113 | ); | |
| 114 | else if (e.key === "a") | |
| 115 | ctx.push( | |
| 116 | promptScreen(ctx, screen, { | |
| 117 | title: "Set a secret", | |
| 118 | label: `place: forge, org NAME or repo OWNER/NAME (empty is ${showPlace(place)})`, | |
| 119 | onSubmit: (raw) => { | |
| 120 | const target = raw.trim() === "" ? place : parsePlace(raw); | |
| 121 | if (typeof target === "string") return target; | |
| 122 | return later(ctx, () => | |
| 123 | promptScreen(ctx, screen, { | |
| 124 | title: "Set a secret", | |
outdated
Nit: check the name here (same rule as |
||
| 125 | label: "name, like CLOUDFLARE_API_TOKEN", | |
| 126 | onSubmit: (name) => | |
| 127 | !isSecretName(name.trim()) ? say("invalid_name") : later(ctx, () => | |
| 128 | promptScreen(ctx, screen, { | |
| 129 | title: `Set ${name.trim()}`, | |
| 130 | label: "value (never shown again)", | |
outdated
High: |
||
| 131 | mask: true, | |
| 132 | onSubmit: (value) => { | |
| 133 | const set = putSecret(f.app, f.user, target.scope, target.ref, name.trim(), value); | |
| 134 | if (!set.ok) return say(set.error); | |
| 135 | place = target; | |
| 136 | note = `${name.trim()} is sealed at ${showPlace(target)}.`; | |
| 137 | ctx.invalidate(); | |
| 138 | return undefined; | |
| 139 | }, | |
| 140 | }), | |
| 141 | ), | |
| 142 | }), | |
| 143 | ); | |
| 144 | }, | |
| 145 | }), | |
| 146 | ); | |
| 147 | else if (e.key === "d") { | |
| 148 | const row = names()[state.index]; | |
| 149 | if (row === undefined) return true; | |
| 150 | ctx.push( | |
| 151 | confirmScreen(ctx, screen, { | |
| 152 | title: `Remove ${row.name}?`, | |
| 153 | body: [`It is taken from ${showPlace(place)} and cannot be read back.`], | |
| 154 | actions: [ | |
| 155 | { label: "Remove", variant: "danger" }, | |
| 156 | { label: "Cancel", variant: "ghost" }, | |
| 157 | ], | |
| 158 | selected: 1, | |
| 159 | onChoose: (choice) => { | |
| 160 | if (choice !== 0) return; | |
| 161 | const gone = removeSecret(f.app, f.user, place.scope, place.ref, row.name); | |
| 162 | note = gone.ok ? `${row.name} is removed.` : say(gone.error); | |
| 163 | ctx.invalidate(); | |
| 164 | }, | |
| 165 | }), | |
| 166 | ); | |
| 167 | } else return false; | |
| 168 | return true; | |
| 169 | }, | |
| 170 | }; | |
| 171 | return screen; | |
| 172 | } |
test/cloud-doors.test.ts +10 −3
| @@ -13,7 +13,7 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 13 | 13 | import { addAccount, net, renameAccount } from "../src/actions/cloud.ts"; |
| 14 | 14 | import { recordAudit } from "../src/core/cloud_audit.ts"; |
| 15 | 15 | import { getCloudAccount, listCloudAccounts } from "../src/core/cloud_account.ts"; |
| 16 | import { listSecretNames, setSecret } from "../src/core/secret_store.ts"; | |
| 16 | import { listSecretNames, resolveSecrets, setSecret } from "../src/core/secret_store.ts"; | |
| 17 | 17 | import { createToken } from "../src/core/tokens.ts"; |
| 18 | 18 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 19 | 19 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -90,6 +90,10 @@ describe("cloud accounts at the terminal and MCP doors", () => { | ||
| 90 | 90 | for (const ch of t) key(ch); |
| 91 | 91 | await settleApp(tui); |
| 92 | 92 | }, |
| 93 | paste: async (text: string) => { | |
| 94 | tui.dispatch({ type: "paste", text } as never); | |
| 95 | await settleApp(tui); | |
| 96 | }, | |
| 93 | 97 | palette: async (q: string) => { |
| 94 | 98 | tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); |
| 95 | 99 | await settleApp(tui); |
| @@ -109,10 +113,12 @@ describe("cloud accounts at the terminal and MCP doors", () => { | ||
| 109 | 113 | await h.press("a"); |
| 110 | 114 | await h.type("github/main"); |
| 111 | 115 | await h.press("enter"); |
| 112 | await h.type(T1); | |
| 116 | await h.paste(`${T1}\n`); // a pasted token keeps its newline in a masked prompt; the action gets it trimmed, as from the page | |
| 113 | 117 | expect(h.text()).not.toContain(T1); |
| 114 | 118 | await h.press("enter"); |
| 115 | 119 | expect(listCloudAccounts(app.db).map((a) => a.name)).toEqual(["main"]); |
| 120 | const sealed = (name: string) => resolveSecrets(app.db, app.config.dataDir, app.bus, [name])[name]; | |
| 121 | expect(sealed("CLOUD_GITHUB_MAIN")).toBe(T1); | |
| 116 | 122 | expect(listSecretNames(app.db, "forge").map((s) => s.name)).toEqual(["CLOUD_GITHUB_MAIN"]); |
| 117 | 123 | expect(h.text()).toContain("github/main"); |
| 118 | 124 | expect(h.text()).toContain("1 call today"); |
| @@ -134,10 +140,11 @@ describe("cloud accounts at the terminal and MCP doors", () => { | ||
| 134 | 140 | await h.press("esc"); |
| 135 | 141 | |
| 136 | 142 | await h.press("r"); |
| 137 | await h.type(T2); | |
| 143 | await h.paste(` ${T2}\n`); | |
| 138 | 144 | expect(h.text()).not.toContain(T2); |
| 139 | 145 | await h.press("enter"); |
| 140 | 146 | expect(h.text()).toContain("Rotated github/main"); |
| 147 | expect(sealed("CLOUD_GITHUB_MAIN")).toBe(T2); | |
| 141 | 148 | expect(h.text()).not.toContain(T2); |
| 142 | 149 | |
| 143 | 150 | await h.press("n"); |
test/tui-secrets.test.ts added +200
| @@ -0,0 +1,200 @@ | ||
| 1 | /** | |
| 2 | * Secrets in the terminal: a forge admin sets one (the value typed masked), | |
| 3 | * sees its name, removes it after a confirm; a member has no entry; and no | |
| 4 | * frame ever contains the value. | |
| 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 { createApp, type App as Forge } from "../src/app.ts"; | |
| 11 | import { resolveSecrets } from "../src/core/secret_store.ts"; | |
| 12 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import { secretsScreen } from "../src/tui/screens/secrets.ts"; | |
| 15 | import { Session, type TuiIdentity } from "../src/tui/session.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | const VALUE = "hunter2-very-secret-value"; | |
| 23 | ||
| 24 | describe("secrets 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 identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 32 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 33 | app.start(); | |
| 34 | const frames: string[] = []; | |
| 35 | const key = (k: string) => | |
| 36 | app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 37 | const seen = () => frames.push(app.screen.toText()); | |
| 38 | return { | |
| 39 | text: () => app.screen.toText(), | |
| 40 | frames, | |
| 41 | press: async (...keys: string[]) => { | |
| 42 | for (const k of keys) { | |
| 43 | key(k); | |
| 44 | await settleApp(app); | |
| 45 | seen(); | |
| 46 | } | |
| 47 | }, | |
| 48 | ctrl: async (k: string) => { | |
| 49 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 50 | await settleApp(app); | |
| 51 | seen(); | |
| 52 | }, | |
| 53 | type: async (t: string) => { | |
| 54 | for (const ch of t) { | |
| 55 | key(ch); | |
| 56 | seen(); | |
| 57 | } | |
| 58 | await settleApp(app); | |
| 59 | seen(); | |
| 60 | }, | |
| 61 | stop: () => app.stop(), | |
| 62 | }; | |
| 63 | } | |
| 64 | ||
| 65 | beforeAll(async () => { | |
| 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-secrets-")); | |
| 67 | forge = createApp({ dataDir, port: 0 }); | |
| 68 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 69 | const made = await createUser(forge.db, { | |
| 70 | origin: "seed", | |
| 71 | trusted: true, | |
| 72 | username, | |
| 73 | email: `${username}@forge.local`, | |
| 74 | password: "a-good-passphrase", | |
| 75 | adminIfFirst, | |
| 76 | }); | |
| 77 | if (!made.ok) throw new Error(made.error); | |
| 78 | } | |
| 79 | }); | |
| 80 | afterAll(async () => { | |
| 81 | forge.close(); | |
| 82 | await rm(dataDir, { recursive: true, force: true }); | |
| 83 | }); | |
| 84 | ||
| 85 | const open = async (h: ReturnType<typeof boot>) => { | |
| 86 | await h.ctrl("k"); | |
| 87 | await h.type("Secrets"); | |
| 88 | await h.press("enter"); | |
| 89 | }; | |
| 90 | ||
| 91 | test("an admin sets a secret, sees its name, and removes it after a confirm; no frame shows the value", async () => { | |
| 92 | const h = boot("alice"); | |
| 93 | await open(h); | |
| 94 | expect(h.text()).toContain("No secret at forge"); | |
| 95 | ||
| 96 | await h.press("a"); | |
| 97 | await h.press("enter"); // empty place: the one in view, forge | |
| 98 | await h.type("DEMO_TOKEN"); | |
| 99 | await h.press("enter"); | |
| 100 | await h.type(VALUE); | |
| 101 | await h.press("enter"); | |
| 102 | expect(h.text()).toContain("DEMO_TOKEN is sealed at forge."); | |
| 103 | expect(h.text()).toContain("DEMO_TOKEN"); | |
| 104 | expect(h.text()).toContain("never read"); | |
| 105 | ||
| 106 | await h.press("d"); | |
| 107 | expect(h.text()).toContain("Remove DEMO_TOKEN?"); | |
| 108 | await h.press("right", "enter"); // Cancel | |
| 109 | expect(h.text()).toContain("never read"); | |
| 110 | await h.press("d"); | |
| 111 | await h.press("left", "enter"); // Remove | |
| 112 | expect(h.text()).toContain("DEMO_TOKEN is removed."); | |
| 113 | expect(h.text()).not.toContain("never read"); | |
| 114 | ||
| 115 | expect(h.frames.length).toBeGreaterThan(10); | |
| 116 | for (const frame of h.frames) expect(frame).not.toContain(VALUE); | |
| 117 | expect(JSON.stringify(forge.db.query("SELECT * FROM event").all())).not.toContain(VALUE); | |
| 118 | h.stop(); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a refusal reads in the screen's words and sets nothing", async () => { | |
| 122 | const h = boot("alice"); | |
| 123 | await open(h); | |
| 124 | await h.press("a"); | |
| 125 | await h.type("org nosuchorg"); | |
| 126 | await h.press("enter"); | |
| 127 | await h.type("DEMO_TOKEN"); | |
| 128 | await h.press("enter"); | |
| 129 | await h.type(VALUE); | |
| 130 | await h.press("enter"); | |
| 131 | expect(h.text()).toContain("No such organization or repository."); | |
| 132 | await h.press("esc", "a", "enter"); | |
| 133 | await h.type("lower"); | |
| 134 | await h.press("enter"); | |
| 135 | expect(h.text()).toContain("A name is capital letters"); | |
| 136 | expect(forge.db.query("SELECT 1 FROM secret").all()).toEqual([]); | |
| 137 | for (const frame of h.frames) expect(frame).not.toContain(VALUE); | |
| 138 | h.stop(); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a pasted two-line value with edge whitespace is sealed verbatim", async () => { | |
| 142 | const pem = " -----BEGIN KEY-----\nabc123\n-----END KEY-----\n "; | |
| 143 | const terminal = headlessTerminal(140, 32); | |
| 144 | const user = getUserByName(forge.db, "alice")!; | |
| 145 | const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false }); | |
| 146 | app.start(); | |
| 147 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 148 | const typed = async (t: string) => { | |
| 149 | for (const ch of t) key(ch); | |
| 150 | await settleApp(app); | |
| 151 | }; | |
| 152 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 153 | await typed("Secrets"); | |
| 154 | key("enter"); | |
| 155 | await settleApp(app); | |
| 156 | key("a"); | |
| 157 | key("enter"); | |
| 158 | await settleApp(app); | |
| 159 | await typed("PEM_KEY"); | |
| 160 | key("enter"); | |
| 161 | await settleApp(app); | |
| 162 | app.dispatch({ type: "paste", text: pem } as never); | |
| 163 | await settleApp(app); | |
| 164 | key("enter"); | |
| 165 | await settleApp(app); | |
| 166 | expect(resolveSecrets(forge.db, forge.config.dataDir, forge.bus, ["PEM_KEY"])).toEqual({ PEM_KEY: pem }); | |
| 167 | app.stop(); | |
| 168 | }); | |
| 169 | ||
| 170 | test("s to an unknown org is refused, not shown as empty; a bad name is refused before the value", async () => { | |
| 171 | const h = boot("alice"); | |
| 172 | await open(h); | |
| 173 | await h.press("s"); | |
| 174 | await h.press("backspace", "backspace", "backspace", "backspace", "backspace"); | |
| 175 | await h.type("org nosuchorg"); | |
| 176 | await h.press("enter"); | |
| 177 | expect(h.text()).toContain("No such organization or repository."); | |
| 178 | await h.press("esc", "a", "enter"); | |
| 179 | await h.type("lower"); | |
| 180 | await h.press("enter"); | |
| 181 | expect(h.text()).toContain("A name is capital letters"); | |
| 182 | expect(h.text()).not.toContain("never shown again"); | |
| 183 | h.stop(); | |
| 184 | }); | |
| 185 | ||
| 186 | test("a member has no entry, and driving the screen shows and changes nothing", async () => { | |
| 187 | const h = boot("bob"); | |
| 188 | await h.ctrl("k"); | |
| 189 | await h.type("Secrets"); | |
| 190 | expect(h.text()).not.toContain("sealed secrets by name"); | |
| 191 | h.stop(); | |
| 192 | ||
| 193 | const user = getUserByName(forge.db, "bob")!; | |
| 194 | const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }); | |
| 195 | const pushed: unknown[] = []; | |
| 196 | const screen = secretsScreen(session, { push: (s: unknown) => pushed.push(s) } as never); | |
| 197 | expect(screen.key({ key: "a", ctrl: false, alt: false, shift: false } as never)).toBe(false); | |
| 198 | expect(pushed).toEqual([]); | |
| 199 | }); | |
| 200 | }); |
The new door needs a line in the README (or docs/TOUR.md, where the other palette screens are listed):
Secretsfor a forge admin,asets,dremoves,schanges the place, names only.