Public: anyone can read this; the crew can change it.
The terminal lists your OAuth apps for connecting origins, adds one and removes one, never reading back a secret, as MCP `origin_apps` does #7860
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7291-the-terminal-lists-your-oauth-apps-for-c → main 2 commits fork
4 files
changed +243
−0
src/tui/forge.ts +8
| @@ -51,6 +51,7 @@ import { notifyScreen } from "./screens/notify.ts"; | ||
| 51 | 51 | import { sshKeysScreen } from "./screens/sshkeys.ts"; |
| 52 | 52 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 53 | 53 | import { signInsScreen } from "./screens/signins.ts"; |
| 54 | import { originAppsScreen } from "./screens/originapps.ts"; | |
| 54 | 55 | import { addEntries } from "./screens/entries.ts"; |
| 55 | 56 | import { capacityScreen, mayCapacity } from "./screens/capacity.ts"; |
| 56 | 57 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| @@ -275,6 +276,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 275 | 276 | hint: "your browsers and tokens; d signs a browser out", |
| 276 | 277 | run: () => ctx.push(signInsScreen(session, ctx)), |
| 277 | 278 | }, |
| 279 | // Settings → Origins' OAuth apps and MCP `origin_apps` (#7291). | |
| 280 | { | |
| 281 | id: "origin-apps", | |
| 282 | title: "Origin apps", | |
| 283 | hint: "your OAuth apps for connecting origins; a adds, d removes", | |
| 284 | run: () => ctx.push(originAppsScreen(session, ctx)), | |
| 285 | }, | |
| 278 | 286 | ); |
| 279 | 287 | // The forge's RSS beside its JS heap, as /system gates it (#2151). |
| 280 | 288 | if (canSeeMemory(session.user)) |
src/tui/screens/originapps.ts added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | /** | |
| 2 | * Your OAuth apps for connecting origins, in the terminal (#7291): the host | |
| 3 | * (or kind), client id and whether a secret is set, as Settings and MCP | |
| 4 | * `origin_apps` list them. `a` asks for a host (or github), a client id and a | |
| 5 | * client secret typed masked, and registers it through `registerOriginApp`, | |
| 6 | * refused in the API's words (`connectMessage`); `d` removes the one under the | |
| 7 | * cursor after a confirm. A secret is write-only: it is held in a closure from | |
| 8 | * the masked field to the action and is never drawn, toasted or logged. | |
| 9 | */ | |
| 10 | ||
| 11 | import { deleteOriginApp, listOriginApps, registerOriginApp } from "../../actions/origins.ts"; | |
| 12 | import { connectMessage } from "../../http/api.ts"; | |
| 13 | import type { AppRow } from "../../origins/oauth.ts"; | |
| 14 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 15 | import { binding } from "../app/keys.ts"; | |
| 16 | import { style } from "../term/style.ts"; | |
| 17 | import { wrap } from "../term/width.ts"; | |
| 18 | import { fill, len, split } from "../ui/layout.ts"; | |
| 19 | import type { Session } from "../session.ts"; | |
| 20 | import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts"; | |
| 21 | ||
| 22 | /** A row's name: the host of its web address, as the page shows it. */ | |
| 23 | const hostOf = (a: AppRow): string => a.webUrl.replace(/^https?:\/\//, ""); | |
| 24 | ||
| 25 | export function originAppsScreen(f: Session, ctx: Ctx): Screen { | |
| 26 | const state = new ListState(); | |
| 27 | let rows: AppRow[] = []; | |
| 28 | let note = ""; | |
| 29 | const reload = (): void => { | |
| 30 | rows = f.user === undefined ? [] : listOriginApps(f.app, f.user); | |
| 31 | state.clamp(rows.length); | |
| 32 | ctx.invalidate(); | |
| 33 | }; | |
| 34 | reload(); | |
| 35 | const say = (text: string): void => { | |
| 36 | note = text; | |
| 37 | reload(); | |
| 38 | }; | |
| 39 | ||
| 40 | const add = (): Screen => | |
| 41 | promptScreen(ctx, screen, { | |
| 42 | title: "Register an origin app", | |
| 43 | label: "host, or github", | |
| 44 | placeholder: "github", | |
| 45 | onSubmit: (host) => { | |
| 46 | if (host === "") return "An origin app needs a host, or github."; | |
| 47 | return later(ctx, () => | |
| 48 | promptScreen(ctx, screen, { | |
| 49 | title: host, | |
| 50 | label: "the OAuth app's client id", | |
| 51 | onSubmit: (clientId) => { | |
| 52 | if (clientId === "") return "`clientId` is required"; | |
| 53 | return later(ctx, () => | |
| 54 | promptScreen(ctx, screen, { | |
| 55 | title: host, | |
| 56 | label: "the client secret (sealed, never shown again)", | |
| 57 | mask: true, | |
| 58 | onSubmit: async (secret) => { | |
| 59 | const saved = await registerOriginApp(f.app, f.user, { | |
| 60 | host, | |
| 61 | ...(host.toLowerCase() === "github" ? { kind: "github" as const } : {}), | |
| 62 | clientId, | |
| 63 | ...(secret.trim() === "" ? {} : { clientSecret: secret }), | |
| 64 | }); | |
| 65 | say(saved.ok ? `${hostOf(saved.value)} is registered.` : connectMessage(saved.error)); | |
| 66 | }, | |
| 67 | }), | |
| 68 | ); | |
| 69 | }, | |
| 70 | }), | |
| 71 | ); | |
| 72 | }, | |
| 73 | }); | |
| 74 | ||
| 75 | const remove = (a: AppRow): Screen => | |
| 76 | confirmScreen(ctx, screen, { | |
| 77 | title: "Remove this origin app", | |
| 78 | body: [`${hostOf(a)}, client ${a.clientId}`, "Connecting that origin by OAuth stops working."], | |
| 79 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Remove", variant: "danger" }], | |
| 80 | onChoose: (i) => { | |
| 81 | if (i !== 1) return; | |
| 82 | const removed = deleteOriginApp(f.app, f.user, a.id); | |
| 83 | say(removed.ok ? `${hostOf(a)} is removed.` : "no such origin app"); | |
| 84 | }, | |
| 85 | }); | |
| 86 | ||
| 87 | const lines = (): ListRow[] => | |
| 88 | rows.map((a) => ({ | |
| 89 | title: hostOf(a), | |
| 90 | secondary: a.kind, | |
| 91 | meta: `client ${a.clientId} · ${a.hasSecret ? "secret set" : "no secret"}`, | |
| 92 | })); | |
| 93 | ||
| 94 | const screen: Screen = { | |
| 95 | title: "Origin apps", | |
| 96 | keys: [binding(["a"], "register one"), binding(["d"], "remove"), binding(["j", "k"], "move cursor")], | |
| 97 | render(area, s) { | |
| 98 | const c = ctx.theme.c; | |
| 99 | const text = | |
| 100 | note !== "" | |
| 101 | ? note | |
| 102 | : f.user === undefined | |
| 103 | ? "Sign in to see your origin apps." | |
| 104 | : rows.length === 0 | |
| 105 | ? "No origin app yet. Press a to register one." | |
| 106 | : `${rows.length} registered; a secret is never shown.`; | |
| 107 | // A refusal is the API's sentence, whole: it wraps rather than cuts. | |
| 108 | const said = wrap(text, Math.max(1, area.w)).slice(0, 3); | |
| 109 | const [head, list] = split(area, "v", [len(said.length), fill(1)]); | |
| 110 | said.forEach((line, i) => s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w)); | |
| 111 | drawList(s, list!, c, state, lines()); | |
| 112 | }, | |
| 113 | key(e) { | |
| 114 | if (state.key(e, rows.length)) return true; | |
| 115 | if (e.ctrl || e.alt || f.user === undefined) return false; | |
| 116 | if (e.key === "a") { | |
| 117 | note = ""; | |
| 118 | ctx.push(add()); | |
| 119 | return true; | |
| 120 | } | |
| 121 | const a = rows[state.index]; | |
| 122 | if (e.key === "d" && a !== undefined) { | |
| 123 | note = ""; | |
| 124 | ctx.push(remove(a)); | |
| 125 | return true; | |
| 126 | } | |
| 127 | return false; | |
| 128 | }, | |
| 129 | }; | |
| 130 | return screen; | |
| 131 | } |
test/palette-roster.test.ts +3
| @@ -68,6 +68,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 68 | 68 | ["signins", "Signed in", "your browsers and tokens; d signs a browser out"], |
| 69 | 69 | ["sentinel", "Sentinel", "the forge's open faults"], |
| 70 | 70 | ["boards", "Boards", "the forge's boards and their panels"], |
| 71 | ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"], | |
| 71 | 72 | ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"], |
| 72 | 73 | ["flow", "Flow", "the pipeline's bottleneck and throughput"], |
| 73 | 74 | ["system", "System", "the machine now: CPU, memory, disks, processes"], |
| @@ -133,6 +134,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 133 | 134 | ["signins", "Signed in", "your browsers and tokens; d signs a browser out"], |
| 134 | 135 | ["sentinel", "Sentinel", "the forge's open faults"], |
| 135 | 136 | ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"], |
| 137 | ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"], | |
| 136 | 138 | ["api", "API", "the forge's endpoints; run a GET"], |
| 137 | 139 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 138 | 140 | ["releases", "Releases", "the live release and the kept ones"], |
| @@ -177,6 +179,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 177 | 179 | ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"], |
| 178 | 180 | ["account", "Account settings", undefined], |
| 179 | 181 | ["signins", "Signed in", "your browsers and tokens; d signs a browser out"], |
| 182 | ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"], | |
| 180 | 183 | ["api", "API", "the forge's endpoints; run a GET"], |
| 181 | 184 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 182 | 185 | ["stalled", "Stalled steps", "pulls whose check is red or was never asked for; enter re-runs"], |
test/tui-originapps.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * The terminal's Origin apps screen (src/tui/screens/originapps.ts, #7291): | |
| 3 | * add, list without the secret, a bad host refused in the API's words, and | |
| 4 | * remove after a confirm. No frame and no log line holds the secret. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, spyOn, 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 { listOriginApps } from "../src/actions/origins.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import { connectMessage } from "../src/http/api.ts"; | |
| 14 | import { logLevel, setLogLevel, type LogLevel } from "../src/kernel/log.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | const SECRET = "s3cr3t_Qw9zXv"; | |
| 20 | const RIGHT = "\x1b[C"; | |
| 21 | const open = [":", "Origin apps", "\r"]; | |
| 22 | ||
| 23 | let dir: string; | |
| 24 | let app: App; | |
| 25 | let alice: User; | |
| 26 | let found: LogLevel; | |
| 27 | const frames: string[] = []; | |
| 28 | const logged: string[] = []; | |
| 29 | const spies: { mockRestore(): void }[] = []; | |
| 30 | ||
| 31 | async function drive(keys: string[]): Promise<string> { | |
| 32 | const terminal = headlessTerminal(120, 40); | |
| 33 | const { app: tui } = createForgeApp( | |
| 34 | app, | |
| 35 | { sshUsername: alice.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice }, | |
| 36 | { terminal, exitHooks: false }, | |
| 37 | ); | |
| 38 | tui.start(); | |
| 39 | await settleApp(tui); | |
| 40 | for (const k of keys) { | |
| 41 | terminal.feed(k); | |
| 42 | await settleApp(tui); | |
| 43 | frames.push(tui.screen.toText()); | |
| 44 | } | |
| 45 | const text = tui.screen.toText(); | |
| 46 | tui.stop?.(); | |
| 47 | return text; | |
| 48 | } | |
| 49 | ||
| 50 | beforeAll(async () => { | |
| 51 | found = logLevel(); | |
| 52 | setLogLevel("debug"); | |
| 53 | for (const m of ["log", "info", "warn", "error", "debug"] as const) { | |
| 54 | spies.push(spyOn(console, m).mockImplementation((...a: unknown[]) => void logged.push(a.join(" ")))); | |
| 55 | } | |
| 56 | dir = await mkdtemp(join(tmpdir(), "forge-tui-originapps-")); | |
| 57 | app = createApp({ dataDir: dir, port: 0 }); | |
| 58 | const r = await createUser(app.db, { | |
| 59 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 60 | password: "a-good-passphrase", adminIfFirst: true, | |
| 61 | }); | |
| 62 | if (!r.ok) throw new Error(r.error); | |
| 63 | alice = r.value; | |
| 64 | }); | |
| 65 | afterAll(async () => { | |
| 66 | for (const s of spies) s.mockRestore(); | |
| 67 | setLogLevel(found); | |
| 68 | app.db.close(); | |
| 69 | await rm(dir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | describe("the terminal's origin apps screen", () => { | |
| 73 | test("a refused host says MCP's words and registers nothing", async () => { | |
| 74 | const text = await drive([...open, "a", "not a host!", "\r", "cid", "\r", SECRET, "\r"]); | |
| 75 | expect(text).toContain(connectMessage("unknown_host")); | |
| 76 | expect(listOriginApps(app, alice)).toEqual([]); | |
| 77 | }); | |
| 78 | ||
| 79 | test("add: listed with its client id and hasSecret, the secret nowhere", async () => { | |
| 80 | const text = await drive([...open, "a", "github", "\r", "Iv1.abc123", "\r", SECRET, "\r"]); | |
| 81 | const rows = listOriginApps(app, alice); | |
| 82 | expect(rows.map((r) => [r.kind, r.clientId, r.hasSecret])).toEqual([["github", "Iv1.abc123", true]]); | |
| 83 | expect(text).toContain("github.com is registered."); | |
| 84 | expect(text).toContain("client Iv1.abc123 · secret set"); | |
| 85 | expect(frames.some((f) => f.includes("•".repeat(SECRET.length)))).toBe(true); | |
| 86 | for (const f of frames) expect(f).not.toContain(SECRET); | |
| 87 | expect(logged.join("\n")).not.toContain(SECRET); | |
| 88 | }); | |
| 89 | ||
| 90 | test("d asks first; keeping leaves it, confirming removes it", async () => { | |
| 91 | const asked = await drive([...open, "d"]); | |
| 92 | expect(asked).toContain("Remove this origin app"); | |
| 93 | expect(listOriginApps(app, alice).length).toBe(1); | |
| 94 | await drive([...open, "d", "\r"]); | |
| 95 | expect(listOriginApps(app, alice).length).toBe(1); | |
| 96 | const text = await drive([...open, "d", RIGHT, "\r"]); | |
| 97 | expect(listOriginApps(app, alice)).toEqual([]); | |
| 98 | expect(text).toContain("github.com is removed."); | |
| 99 | for (const f of frames) expect(f).not.toContain(SECRET); | |
| 100 | }); | |
| 101 | }); |
new above
offline