Public: anyone can read this; the crew can change it.
The terminal registers, rotates and revokes an app too #67
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/30-the-terminal-registers-rotates-and-revok → main 2 commits fork 2 threads · 2 unresolved
4 files
changed +604
−54
README.md +26 −5
| @@ -76,9 +76,9 @@ because a derived key stays computable forever and the check that matters | ||
| 76 | 76 | is whether it's still current, not whether the row exists. `relay` itself |
| 77 | 77 | is a reserved id — nothing can register an app under it — because relay is |
| 78 | 78 | a poster on its own timeline (below), and its `source` must stay |
| 79 | unforgeable. No route, page or terminal command reaches these actions yet: | |
| 80 | only an admin working directly against `src/actions.ts` (or a future door) | |
| 81 | can register, rotate or revoke. | |
| 79 | unforgeable. The terminal (`src/tty.ts`, below) reaches all three; no page | |
| 80 | or JSON route does yet (issue #71), so today only the terminal or a script | |
| 81 | working directly against `src/actions.ts` can register, rotate or revoke. | |
| 82 | 82 | |
| 83 | 83 | ## relay posts its own events |
| 84 | 84 | |
| @@ -168,8 +168,29 @@ without a reload. Without JavaScript it is the same page, one reload behind. | ||
| 168 | 168 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 169 | 169 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge |
| 170 | 170 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on |
| 171 | a real pty over the same data, as the account at the keyboard: j/k move | |
| 172 | through the timeline, q leaves. | |
| 171 | a real pty over the same data, as the account at the keyboard: j/k move, | |
| 172 | q leaves, p switches between the timeline and the app registry — any key | |
| 173 | redraws with fresh data, so there is no separate refresh key. In the | |
| 174 | registry an admin presses n to register (id, then name, the same two | |
| 175 | fields any door would ask for), r to rotate the app under the cursor, and | |
| 176 | x to revoke it — the same `registerApp`, `rotateAppKey` and `revokeApp` as | |
| 177 | any other door, refusing a guest or a plain user exactly as they would | |
| 178 | anywhere else, immediately and without opening a prompt for a key the | |
| 179 | footer does not even offer them. Rotating and revoking ask first, naming | |
| 180 | the app (`Revoke Beacon (beacon)? y/n`), because both are one-way on a | |
| 181 | single keypress; the app under the cursor is tracked by id, not by row | |
| 182 | position, so registering a new one — which sorts to the top — never | |
| 183 | silently moves the cursor onto a different app. A freshly issued key | |
| 184 | prints once, its own screen, and is gone the moment anything else is | |
| 185 | drawn — it is never held in the state the status line redraws. | |
| 186 | ||
| 187 | The terminal is its own process, so a write made there does not reach an | |
| 188 | open page over `/events` the way a write from another process's own door | |
| 189 | would (`src/live.ts`); it shows up the next time that page loads, until | |
| 190 | issue #65 gives every process a database watcher instead of an in-process | |
| 191 | bus. Issue #71 tracks the other side of the asymmetry this leaves: the | |
| 192 | page and the JSON API still cannot register, rotate or revoke — only the | |
| 193 | terminal (and a script working directly against `src/actions.ts`) can. | |
| 173 | 194 | |
| 174 | 195 | The footer prints that command verbatim, quoted, never a guessed form. |
| 175 | 196 | The forge sits behind a proxy that rewrites anything shaped like an email |
src/actions.ts +4
| @@ -305,6 +305,10 @@ export interface IssuedKey { | ||
| 305 | 305 | readonly key: string; |
| 306 | 306 | } |
| 307 | 307 | |
| 308 | /** The sentence every door shows beside a freshly issued key, word for word. */ | |
| 309 | export const KEY_SHOWN_ONCE = | |
| 310 | "This key is shown once, right now, and never again — copy it before you do anything else."; | |
| 311 | ||
| 308 | 312 | type AdminGate = |
| 309 | 313 | | { ok: true; id: string; name: string } |
| 310 | 314 | | { ok: false; error: Failure; message: string }; |
src/tty.test.ts added +213
| @@ -0,0 +1,213 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { appSecret } from "./apps.ts"; | |
| 6 | import { openDb } from "./db.ts"; | |
| 7 | import { createHandler } from "./tty.ts"; | |
| 8 | import type { Who } from "./who.ts"; | |
| 9 | ||
| 10 | const KEY = "a-secret-only-relay-holds"; | |
| 11 | const anon: Who = { role: "anon" }; | |
| 12 | const alice: Who = { role: "user", id: "u1", name: "alice" }; | |
| 13 | const root: Who = { role: "admin", id: "u0", name: "root" }; | |
| 14 | ||
| 15 | const fresh = (): ReturnType<typeof openDb> => openDb(mkdtempSync(join(tmpdir(), "app-tty-"))); | |
| 16 | ||
| 17 | /** Types a string into the current draft field, one keystroke per character, then enter. */ | |
| 18 | function type(handle: (input: string) => void, text: string): void { | |
| 19 | for (const ch of text) handle(ch); | |
| 20 | handle("\r"); | |
| 21 | } | |
| 22 | ||
| 23 | test("a non-admin's register keypress refuses immediately, without opening the two-field prompt", () => { | |
| 24 | const db = fresh(); | |
| 25 | const screens: string[] = []; | |
| 26 | const handle = createHandler(db, alice, KEY, (s) => screens.push(s)); | |
| 27 | handle(""); // initial draw | |
| 28 | handle("p"); // switch to the apps view | |
| 29 | handle("n"); // alice cannot even see the "n" key, but pressing it must still refuse | |
| 30 | const last = screens[screens.length - 1]!; | |
| 31 | expect(last).toContain("Only an admin may manage apps."); | |
| 32 | // No prompt was opened: typing more does not accumulate into an id field. | |
| 33 | expect(last).not.toContain("Register app"); | |
| 34 | }); | |
| 35 | ||
| 36 | test("an admin registers an app; the key prints once", () => { | |
| 37 | const db = fresh(); | |
| 38 | const screens: string[] = []; | |
| 39 | const handle = createHandler(db, root, KEY, (s) => screens.push(s)); | |
| 40 | handle(""); | |
| 41 | handle("p"); | |
| 42 | handle("n"); | |
| 43 | type(handle, "beacon"); | |
| 44 | type(handle, "Beacon"); | |
| 45 | ||
| 46 | const keyScreen = screens[screens.length - 1]!; | |
| 47 | const expectedKey = appSecret("beacon", 1, KEY); | |
| 48 | expect(keyScreen).toContain(expectedKey); | |
| 49 | expect(keyScreen).toContain("Registered"); | |
| 50 | expect(keyScreen).toContain("shown once"); | |
| 51 | ||
| 52 | // Any key dismisses the once-only screen back to the normal view. | |
| 53 | handle("z"); | |
| 54 | const after = screens[screens.length - 1]!; | |
| 55 | expect(after).not.toContain(expectedKey); | |
| 56 | expect(after).toContain("Registered Beacon."); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a resize (a bare redraw) while the key is shown does not erase or repeat it", () => { | |
| 60 | const db = fresh(); | |
| 61 | const screens: string[] = []; | |
| 62 | const handle = createHandler(db, root, KEY, (s) => screens.push(s)); | |
| 63 | handle(""); | |
| 64 | handle("p"); | |
| 65 | handle("n"); | |
| 66 | type(handle, "spindle"); | |
| 67 | type(handle, "Spindle"); | |
| 68 | const keyScreen = screens[screens.length - 1]!; | |
| 69 | const expectedKey = appSecret("spindle", 1, KEY); | |
| 70 | expect(keyScreen).toContain(expectedKey); | |
| 71 | ||
| 72 | handle(""); // a bare redraw, as startup and resize send | |
| 73 | expect(screens[screens.length - 1]).toBe(keyScreen); | |
| 74 | ||
| 75 | handle("z"); // dismiss | |
| 76 | const dismissed = screens[screens.length - 1]!; | |
| 77 | expect(dismissed).not.toContain(expectedKey); | |
| 78 | handle("j"); // a further keystroke must never bring the key back | |
| 79 | expect(screens[screens.length - 1]).not.toContain(expectedKey); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the selection follows the app, not the row: registering a new app does not slide the cursor", () => { | |
| 83 | const db = fresh(); | |
| 84 | const screens: string[] = []; | |
| 85 | const handle = createHandler(db, root, KEY, (s) => screens.push(s)); | |
| 86 | handle(""); | |
| 87 | handle("p"); | |
| 88 | ||
| 89 | handle("n"); | |
| 90 | type(handle, "alpha"); | |
| 91 | type(handle, "Alpha"); | |
| 92 | handle("z"); // Alpha is the only app: it is selected with nothing to move onto it. | |
| 93 | expect(screens[screens.length - 1]).toContain(`▸ ${"\x1b[1mAlpha\x1b[0m"}`); | |
| 94 | ||
| 95 | handle("n"); | |
| 96 | type(handle, "beta"); | |
| 97 | type(handle, "Beta"); | |
| 98 | handle("z"); // Beta lands above Alpha (newest first) — Alpha is still there, still selected. | |
| 99 | expect(screens[screens.length - 1]).toContain(`▸ ${"\x1b[1mAlpha\x1b[0m"}`); | |
| 100 | ||
| 101 | handle("n"); | |
| 102 | type(handle, "gamma"); | |
| 103 | type(handle, "Gamma"); | |
| 104 | handle("z"); // Gamma pushes both rows down again; the selection must not slide onto Beta. | |
| 105 | expect(screens[screens.length - 1]).toContain(`▸ ${"\x1b[1mAlpha\x1b[0m"}`); | |
| 106 | ||
| 107 | // Revoking now must revoke Alpha — the app that was selected all along — | |
| 108 | // never Beta or Gamma, whatever row Alpha now sits in. | |
| 109 | handle("x"); // opens the confirm prompt, naming the app | |
| 110 | expect(screens[screens.length - 1]).toContain("Revoke Alpha (alpha)? y/n"); | |
| 111 | handle("y"); | |
| 112 | expect(screens[screens.length - 1]).toContain("Revoked Alpha."); | |
| 113 | ||
| 114 | const alpha = db.query("SELECT revoked_at FROM app WHERE id = 'alpha'").get() as { | |
| 115 | revoked_at: string | null; | |
| 116 | }; | |
| 117 | expect(alpha.revoked_at).not.toBeNull(); | |
| 118 | const beta = db.query("SELECT revoked_at FROM app WHERE id = 'beta'").get() as { | |
| 119 | revoked_at: string | null; | |
| 120 | }; | |
| 121 | expect(beta.revoked_at).toBeNull(); | |
| 122 | const gamma = db.query("SELECT revoked_at FROM app WHERE id = 'gamma'").get() as { | |
| 123 | revoked_at: string | null; | |
| 124 | }; | |
| 125 | expect(gamma.revoked_at).toBeNull(); | |
| 126 | }); | |
| 127 | ||
| 128 | test("rotate and revoke ask first, naming the app, and only act on y", () => { | |
| 129 | const db = fresh(); | |
| 130 | const screens: string[] = []; | |
| 131 | const handle = createHandler(db, root, KEY, (s) => screens.push(s)); | |
| 132 | handle(""); | |
| 133 | handle("p"); | |
| 134 | handle("n"); | |
| 135 | type(handle, "torque"); | |
| 136 | type(handle, "Torque"); | |
| 137 | handle("z"); | |
| 138 | ||
| 139 | handle("r"); // asks first | |
| 140 | expect(screens[screens.length - 1]).toContain("Rotate Torque (torque)? y/n"); | |
| 141 | handle("n"); // decline | |
| 142 | expect(screens[screens.length - 1]).toContain("Rotate cancelled."); | |
| 143 | ||
| 144 | handle("r"); | |
| 145 | handle("y"); // confirm | |
| 146 | const rotated = screens[screens.length - 1]!; | |
| 147 | const rotatedKey = appSecret("torque", 2, KEY); | |
| 148 | expect(rotated).toContain(rotatedKey); | |
| 149 | handle("z"); | |
| 150 | ||
| 151 | handle("x"); | |
| 152 | expect(screens[screens.length - 1]).toContain("Revoke Torque (torque)? y/n"); | |
| 153 | handle("y"); | |
| 154 | const revoked = screens[screens.length - 1]!; | |
| 155 | expect(revoked).toContain("Revoked Torque."); | |
| 156 | expect(revoked).not.toContain(rotatedKey); | |
| 157 | }); | |
| 158 | ||
| 159 | test("a non-admin pressing rotate or revoke sees the action's own refusal, no prompt", () => { | |
| 160 | const db = fresh(); | |
| 161 | const asRoot: string[] = []; | |
| 162 | const rootHandle = createHandler(db, root, KEY, (s) => asRoot.push(s)); | |
| 163 | rootHandle(""); | |
| 164 | rootHandle("p"); | |
| 165 | rootHandle("n"); | |
| 166 | type(rootHandle, "torque"); | |
| 167 | type(rootHandle, "Torque"); | |
| 168 | rootHandle("z"); | |
| 169 | ||
| 170 | const screens: string[] = []; | |
| 171 | const handle = createHandler(db, alice, KEY, (s) => screens.push(s)); | |
| 172 | handle(""); | |
| 173 | handle("p"); | |
| 174 | handle("r"); | |
| 175 | expect(screens[screens.length - 1]).toContain("Only an admin may manage apps."); | |
| 176 | handle("x"); | |
| 177 | expect(screens[screens.length - 1]).toContain("Only an admin may manage apps."); | |
| 178 | }); | |
| 179 | ||
| 180 | test("a pasted burst is not dropped: id and name arrive in one chunk each", () => { | |
| 181 | const db = fresh(); | |
| 182 | const screens: string[] = []; | |
| 183 | const handle = createHandler(db, root, KEY, (s) => screens.push(s)); | |
| 184 | handle(""); | |
| 185 | handle("p"); | |
| 186 | handle("n"); | |
| 187 | handle("beacon\r"); // a paste, one data event, not one keystroke per character | |
| 188 | handle("Beacon\r"); | |
| 189 | const keyScreen = screens[screens.length - 1]!; | |
| 190 | expect(keyScreen).toContain(appSecret("beacon", 1, KEY)); | |
| 191 | }); | |
| 192 | ||
| 193 | test("with no identity key, registering shows the action's own unavailable message, never a key", () => { | |
| 194 | const db = fresh(); | |
| 195 | const screens: string[] = []; | |
| 196 | const handle = createHandler(db, root, "", (s) => screens.push(s)); | |
| 197 | handle(""); | |
| 198 | handle("p"); | |
| 199 | handle("n"); | |
| 200 | type(handle, "beacon"); | |
| 201 | type(handle, "Beacon"); | |
| 202 | const last = screens[screens.length - 1]!; | |
| 203 | expect(last).toContain("No identity key is configured"); | |
| 204 | }); | |
| 205 | ||
| 206 | test("anon reading the apps view sees signin_required, not a list", () => { | |
| 207 | const db = fresh(); | |
| 208 | const screens: string[] = []; | |
| 209 | const handle = createHandler(db, anon, KEY, (s) => screens.push(s)); | |
| 210 | handle(""); | |
| 211 | handle("p"); | |
| 212 | expect(screens[screens.length - 1]).toContain("Sign in to manage apps."); | |
| 213 | }); |
src/tty.ts +361 −49
| @@ -1,63 +1,375 @@ | ||
| 1 | 1 | /** |
| 2 | * The app in a terminal. The forge runs this on a pty when someone | |
| 3 | * `ssh`es to the app, over the same data as the page and through the same | |
| 4 | * actions, as the account at the keyboard. j/k move, r refreshes, q leaves. | |
| 5 | * Plain ANSI, no dependencies. | |
| 2 | * The app in a terminal. The forge runs this on a pty when someone `ssh`es | |
| 3 | * to the app, over the same data and the same actions as the page, as the | |
| 4 | * account at the keyboard. `p` switches between the timeline and the app | |
| 5 | * registry; in the registry an admin can register, rotate and revoke — | |
| 6 | * `registerApp`, `rotateAppKey` and `revokeApp` decide everything, this | |
| 7 | * module only prompts and reports. A guest or a plain user pressing a | |
| 8 | * write key sees that action's own refusal on the status line immediately, | |
| 9 | * the same way a non-admin sees `listApps` refuse the registry itself — | |
| 10 | * neither role is offered the two-step prompts below, which are only for | |
| 11 | * the keys the footer actually shows them. | |
| 12 | * | |
| 13 | * The registry tracks the app under the cursor by **id**, not by row | |
| 14 | * index: `listApps` orders newest first, so registering a third app while | |
| 15 | * the cursor sits on the first would otherwise silently slide the cursor | |
| 16 | * onto the second. Rotating and revoking are also one-way and immediate on | |
| 17 | * a real key, so both go through a `y`/`n` prompt that names the app | |
| 18 | * before touching it. | |
| 19 | * | |
| 20 | * `createHandler` is the seam: it takes the database, the identity at the | |
| 21 | * keyboard and the identity key, and returns the function that handles a | |
| 22 | * chunk of terminal input (one keystroke, or a pasted burst of several), | |
| 23 | * so a test can drive it without a real pty. Plain ANSI, no dependencies. | |
| 6 | 24 | */ |
| 7 | 25 | |
| 8 | import { listEvents, type Event } from "./actions.ts"; | |
| 26 | import type { Database } from "bun:sqlite"; | |
| 27 | import { | |
| 28 | KEY_SHOWN_ONCE, | |
| 29 | listApps, | |
| 30 | listEvents, | |
| 31 | registerApp, | |
| 32 | revokeApp, | |
| 33 | rotateAppKey, | |
| 34 | type AppSummary, | |
| 35 | type IssuedKey, | |
| 36 | } from "./actions.ts"; | |
| 9 | 37 | import { openDb } from "./db.ts"; |
| 10 | import { ssh, title, where } from "./env.ts"; | |
| 11 | import { whoAtTty } from "./who.ts"; | |
| 38 | import { identityKey as envIdentityKey, ssh, title, where } from "./env.ts"; | |
| 39 | import { whoAtTty, type Who } from "./who.ts"; | |
| 12 | 40 | |
| 13 | const db = openDb(); | |
| 14 | const me = whoAtTty(); | |
| 15 | 41 | const esc = "\x1b["; |
| 16 | 42 | const dim = (s: string): string => `${esc}2m${s}${esc}0m`; |
| 17 | 43 | const bold = (s: string): string => `${esc}1m${s}${esc}0m`; |
| 18 | const out = (s: string): void => { | |
| 19 | process.stdout.write(s); | |
| 20 | }; | |
| 44 | const clear = `${esc}2J${esc}H`; | |
| 21 | 45 | |
| 22 | let cursor = 0; | |
| 46 | type View = "timeline" | "apps"; | |
| 47 | type Draft = { readonly field: "id" | "name"; readonly id: string; readonly name: string }; | |
| 48 | type Confirm = { readonly action: "rotate" | "revoke"; readonly id: string; readonly name: string }; | |
| 23 | 49 | |
| 24 | const events = (): Event[] => { | |
| 25 | const listed = listEvents(db, me); | |
| 26 | return listed.ok ? listed.value : []; | |
| 27 | }; | |
| 50 | /** | |
| 51 | * One session's state, closed over by the handler it returns. `awaitingDismiss` | |
| 52 | * and the key print themselves once, straight to `write`, and are never | |
| 53 | * stored anywhere `draw` could redraw them. | |
| 54 | */ | |
| 55 | export function createHandler( | |
| 56 | db: Database, | |
| 57 | who: Who, | |
| 58 | key: string, | |
| 59 | write: (s: string) => void = (s) => { | |
| 60 | process.stdout.write(s); | |
| 61 | }, | |
| 62 | ): (input: string) => void { | |
| 63 | let view: View = "timeline"; | |
| 64 | let timelineCursor = 0; | |
| 65 | /** The app under the cursor in the registry, by id — stable across a reorder. */ | |
| 66 | let selectedId: string | undefined; | |
| 67 | let said = ""; | |
| 68 | let draft: Draft | undefined; | |
| 69 | let confirm: Confirm | undefined; | |
| 70 | let awaitingDismiss = false; | |
| 28 | 71 | |
| 29 | function draw(): void { | |
| 30 | const rows = events(); | |
| 31 | const lines = [ | |
| 32 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`, | |
| 33 | ssh === undefined ? "" : dim(`connect: ${ssh}`), | |
| 34 | "", | |
| 72 | const roleLabel = (): string => | |
| 73 | who.role === "anon" ? "guest" : `${who.name}${who.role === "admin" ? " (admin)" : ""}`; | |
| 74 | ||
| 75 | const timelineRows = (): { readonly subject: string; readonly summary: string; readonly meta: string }[] => { | |
| 76 | const listed = listEvents(db, who); | |
| 77 | return listed.ok | |
| 78 | ? listed.value.map((event) => ({ | |
| 79 | subject: event.subject, | |
| 80 | summary: event.summary, | |
| 81 | meta: `${event.source} · ${event.kind} · ${event.occurred_at.slice(0, 16).replace("T", " ")}`, | |
| 82 | })) | |
| 83 | : []; | |
| 84 | }; | |
| 85 | ||
| 86 | const timelineLines = (): string[] => { | |
| 87 | const rows = timelineRows(); | |
| 88 | if (rows.length === 0) return [dim("No events yet.")]; | |
| 89 | return rows.map((row, i) => { | |
| 90 | const subject = i === timelineCursor ? bold(row.subject) : row.subject; | |
| 91 | return `${i === timelineCursor ? "▸" : " "} ${subject} — ${row.summary} ${dim(row.meta)}`; | |
| 92 | }); | |
| 93 | }; | |
| 94 | ||
| 95 | const currentApps = (): AppSummary[] => { | |
| 96 | const listed = listApps(db, who); | |
| 97 | return listed.ok ? listed.value : []; | |
| 98 | }; | |
| 99 | ||
| 100 | const appsLines = (): string[] => { | |
| 101 | const listed = listApps(db, who); | |
| 102 | if (!listed.ok) return [dim(listed.message)]; | |
| 103 | if (listed.value.length === 0) return [dim("No apps registered.")]; | |
| 104 | return listed.value.map((app) => { | |
| 105 | const here = app.id === selectedId; | |
| 106 | const name = here ? bold(app.name) : app.name; | |
| 107 | const revoked = app.revoked ? dim(" · revoked") : ""; | |
| 108 | return `${here ? "▸" : " "} ${name} (${app.id}) ${dim(`generation ${app.generation}`)}${revoked}`; | |
| 109 | }); | |
| 110 | }; | |
| 111 | ||
| 112 | const legend = (): string => { | |
| 113 | if (view === "timeline") return "j/k move · p apps · q quit (any key redraws — there is no separate refresh)"; | |
| 114 | return who.role === "admin" | |
| 115 | ? "j/k move · n register · r rotate · x revoke · p timeline · q quit" | |
| 116 | : "j/k move · p timeline · q quit"; | |
| 117 | }; | |
| 118 | ||
| 119 | const draftLines = (d: Draft): string[] => { | |
| 120 | const lines = [`Register app — id: ${d.id}${d.field === "id" ? "▌" : ""}`]; | |
| 121 | if (d.field === "name") lines.push(`Register app — name: ${d.name}▌`); | |
| 122 | lines.push(""); | |
| 123 | lines.push(dim("enter continue · esc cancel")); | |
| 124 | return lines; | |
| 125 | }; | |
| 126 | ||
| 127 | const confirmLines = (c: Confirm): string[] => [ | |
| 128 | `${c.action === "rotate" ? "Rotate" : "Revoke"} ${c.name} (${c.id})? y/n`, | |
| 35 | 129 | ]; |
| 36 | if (rows.length === 0) lines.push(dim("No events yet.")); | |
| 37 | rows.forEach((event, i) => { | |
| 38 | const subject = i === cursor ? bold(event.subject) : event.subject; | |
| 39 | lines.push( | |
| 40 | `${i === cursor ? "▸" : " "} ${subject} — ${event.summary} ${dim(`${event.source} · ${event.kind} · ${event.occurred_at.slice(0, 16).replace("T", " ")}`)}`, | |
| 41 | ); | |
| 42 | }); | |
| 43 | lines.push(""); | |
| 44 | lines.push(dim("j/k move · r refresh · q quit")); | |
| 45 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); | |
| 130 | ||
| 131 | function draw(): void { | |
| 132 | const lines = [ | |
| 133 | `${bold(title)} ${dim(where)} ${dim(roleLabel())}`, | |
| 134 | ssh === undefined ? "" : dim(`connect: ${ssh}`), | |
| 135 | "", | |
| 136 | ...(view === "timeline" ? timelineLines() : appsLines()), | |
| 137 | "", | |
| 138 | ]; | |
| 139 | if (draft !== undefined) lines.push(...draftLines(draft)); | |
| 140 | else if (confirm !== undefined) lines.push(...confirmLines(confirm)); | |
| 141 | else { | |
| 142 | if (said !== "") lines.push(said); | |
| 143 | lines.push(dim(legend())); | |
| 144 | } | |
| 145 | write(`${clear}${lines.join("\r\n")}`); | |
| 146 | } | |
| 147 | ||
| 148 | /** Prints once, straight to the terminal, and is gone the moment anything else is drawn. */ | |
| 149 | function showKey(issued: IssuedKey, verb: string): void { | |
| 150 | const lines = [ | |
| 151 | `${bold(title)} ${dim(where)}`, | |
| 152 | "", | |
| 153 | `${verb} ${bold(issued.name)} (generation ${issued.generation}).`, | |
| 154 | "", | |
| 155 | bold(KEY_SHOWN_ONCE), | |
| 156 | issued.key, | |
| 157 | "", | |
| 158 | dim("press any key to continue"), | |
| 159 | ]; | |
| 160 | write(`${clear}${lines.join("\r\n")}`); | |
| 161 | awaitingDismiss = true; | |
| 162 | } | |
| 163 | ||
| 164 | function submitRegister(id: string, name: string): void { | |
| 165 | const result = registerApp(db, who, { id, name }, key); | |
| 166 | draft = undefined; | |
| 167 | if (!result.ok) { | |
| 168 | said = result.message; | |
| 169 | draw(); | |
| 170 | return; | |
| 171 | } | |
| 172 | ensureSelection(); | |
| 173 | said = `Registered ${result.value.name}.`; | |
| 174 | showKey(result.value, "Registered"); | |
| 175 | } | |
| 176 | ||
| 177 | /** Processes a whole chunk character by character, so a paste is never dropped. */ | |
| 178 | function handleDraftKey(input: string): void { | |
| 179 | for (const ch of input) { | |
| 180 | if (draft === undefined) return; // submitted or cancelled mid-chunk | |
| 181 | if (ch === "\x03") { | |
| 182 | write(clear); | |
| 183 | process.exit(0); | |
| 184 | } | |
| 185 | if (ch === "\x1b") { | |
| 186 | draft = undefined; | |
| 187 | said = "Register cancelled."; | |
| 188 | draw(); | |
| 189 | return; | |
| 190 | } | |
| 191 | if (ch === "\r" || ch === "\n") { | |
| 192 | if (draft.field === "id") { | |
| 193 | draft = { field: "name", id: draft.id, name: draft.name }; | |
| 194 | continue; | |
| 195 | } | |
| 196 | submitRegister(draft.id, draft.name); | |
| 197 | return; | |
| 198 | } | |
| 199 | if (ch === "\x7f" || ch === "\b") { | |
| 200 | draft = | |
| 201 | draft.field === "id" | |
| 202 | ? { ...draft, id: draft.id.slice(0, -1) } | |
| 203 | : { ...draft, name: draft.name.slice(0, -1) }; | |
| 204 | continue; | |
| 205 | } | |
| 206 | if (ch >= " " && ch <= "\x7e") { | |
| 207 | draft = draft.field === "id" ? { ...draft, id: draft.id + ch } : { ...draft, name: draft.name + ch }; | |
| 208 | } | |
| 209 | } | |
| 210 | draw(); | |
| 211 | } | |
| 212 | ||
| 213 | function handleConfirmKey(input: string, current: Confirm): void { | |
| 214 | if (input === "\x03") { | |
| 215 | write(clear); | |
| 216 | process.exit(0); | |
| 217 | } | |
| 218 | confirm = undefined; | |
| 219 | if (input !== "y") { | |
| 220 | said = `${current.action === "rotate" ? "Rotate" : "Revoke"} cancelled.`; | |
| 221 | draw(); | |
| 222 | return; | |
| 223 | } | |
| 224 | if (current.action === "rotate") { | |
| 225 | const result = rotateAppKey(db, who, { id: current.id }, key); | |
| 226 | if (!result.ok) { | |
| 227 | said = result.message; | |
| 228 | draw(); | |
| 229 | return; | |
| 230 | } | |
| 231 | said = `Rotated ${result.value.name} to generation ${result.value.generation}.`; | |
| 232 | showKey(result.value, "Rotated"); | |
| 233 | return; | |
| 234 | } | |
| 235 | const result = revokeApp(db, who, { id: current.id }); | |
| 236 | said = result.ok ? `Revoked ${current.name}.` : result.message; | |
| 237 | draw(); | |
| 238 | } | |
| 239 | ||
| 240 | /** Moves the selection to the next or previous row in the current (id-ordered) list. */ | |
| 241 | function moveSelection(direction: 1 | -1): void { | |
| 242 | const rows = currentApps(); | |
| 243 | if (rows.length === 0) { | |
| 244 | selectedId = undefined; | |
| 245 | return; | |
| 246 | } | |
| 247 | const index = rows.findIndex((a) => a.id === selectedId); | |
| 248 | const next = Math.min(Math.max((index === -1 ? 0 : index) + direction, 0), rows.length - 1); | |
| 249 | selectedId = rows[next]?.id; | |
| 250 | } | |
| 251 | ||
| 252 | /** | |
| 253 | * Keeps the selection valid without ever moving it off an app that is | |
| 254 | * still there: only defaults to the first row when nothing was selected, | |
| 255 | * or the selected id no longer appears (never on a mere reorder). | |
| 256 | */ | |
| 257 | function ensureSelection(): void { | |
| 258 | const rows = currentApps(); | |
| 259 | if (rows.length === 0) { | |
| 260 | selectedId = undefined; | |
| 261 | return; | |
| 262 | } | |
| 263 | if (selectedId === undefined || !rows.some((a) => a.id === selectedId)) selectedId = rows[0]?.id; | |
| 264 | } | |
outdated
probe |
||
| 265 | ||
| 266 | /** `n`/`r`/`x` are only prompted for the role the footer actually shows them to; anyone | |
| 267 | * else still reaches the action directly, and sees exactly the refusal it returns. */ | |
| 268 | function startRegister(): void { | |
| 269 | if (who.role === "admin") { | |
| 270 | draft = { field: "id", id: "", name: "" }; | |
| 271 | said = ""; | |
| 272 | draw(); | |
| 273 | return; | |
| 274 | } | |
| 275 | const result = registerApp(db, who, { id: "", name: "" }, key); | |
| 276 | said = result.ok ? `Registered ${result.value.name}.` : result.message; | |
| 277 | draw(); | |
| 278 | } | |
| 279 | ||
| 280 | function startRotate(): void { | |
| 281 | const app = currentApps().find((a) => a.id === selectedId); | |
| 282 | if (who.role === "admin" && app !== undefined) { | |
| 283 | confirm = { action: "rotate", id: app.id, name: app.name }; | |
| 284 | said = ""; | |
| 285 | draw(); | |
| 286 | return; | |
| 287 | } | |
| 288 | const result = rotateAppKey(db, who, { id: app?.id ?? selectedId ?? "" }, key); | |
| 289 | said = result.ok | |
| 290 | ? `Rotated ${result.value.name} to generation ${result.value.generation}.` | |
| 291 | : result.message; | |
| 292 | draw(); | |
| 293 | } | |
| 294 | ||
| 295 | function startRevoke(): void { | |
| 296 | const app = currentApps().find((a) => a.id === selectedId); | |
| 297 | if (who.role === "admin" && app !== undefined) { | |
| 298 | confirm = { action: "revoke", id: app.id, name: app.name }; | |
| 299 | said = ""; | |
| 300 | draw(); | |
| 301 | return; | |
| 302 | } | |
| 303 | const result = revokeApp(db, who, { id: app?.id ?? selectedId ?? "" }); | |
| 304 | said = result.ok ? `Revoked ${app?.name ?? "app"}.` : result.message; | |
| 305 | draw(); | |
| 306 | } | |
| 307 | ||
| 308 | return function handleKey(input: string): void { | |
| 309 | // An empty input is a bare redraw (startup, resize), never a keystroke: | |
| 310 | // it must not dismiss the once-only key screen or feed a draft field. | |
| 311 | if (input === "") { | |
| 312 | if (!awaitingDismiss) draw(); | |
| 313 | return; | |
| 314 | } | |
| 315 | if (awaitingDismiss) { | |
| 316 | awaitingDismiss = false; | |
| 317 | draw(); | |
| 318 | return; | |
| 319 | } | |
| 320 | if (draft !== undefined) { | |
| 321 | handleDraftKey(input); | |
| 322 | return; | |
| 323 | } | |
| 324 | if (confirm !== undefined) { | |
| 325 | handleConfirmKey(input, confirm); | |
| 326 | return; | |
| 327 | } | |
| 328 | if (input === "q" || input === "\x03") { | |
| 329 | write(clear); | |
| 330 | process.exit(0); | |
| 331 | } | |
| 332 | if (input === "p") { | |
| 333 | view = view === "timeline" ? "apps" : "timeline"; | |
| 334 | said = ""; | |
| 335 | if (view === "apps") ensureSelection(); | |
| 336 | draw(); | |
| 337 | return; | |
| 338 | } | |
| 339 | if (input === "j" || input === "\x1b[B") { | |
| 340 | if (view === "timeline") timelineCursor = Math.min(timelineCursor + 1, Math.max(0, timelineRows().length - 1)); | |
| 341 | else moveSelection(1); | |
| 342 | draw(); | |
| 343 | return; | |
| 344 | } | |
| 345 | if (input === "k" || input === "\x1b[A") { | |
| 346 | if (view === "timeline") timelineCursor = Math.max(timelineCursor - 1, 0); | |
| 347 | else moveSelection(-1); | |
| 348 | draw(); | |
| 349 | return; | |
| 350 | } | |
| 351 | if (view === "apps" && input === "n") { | |
| 352 | startRegister(); | |
| 353 | return; | |
| 354 | } | |
| 355 | if (view === "apps" && input === "r") { | |
| 356 | startRotate(); | |
| 357 | return; | |
| 358 | } | |
| 359 | if (view === "apps" && input === "x") { | |
| 360 | startRevoke(); | |
| 361 | return; | |
| 362 | } | |
| 363 | draw(); | |
| 364 | }; | |
| 46 | 365 | } |
| 47 | 366 | |
| 48 | process.stdin.setRawMode?.(true); | |
| 49 | process.stdin.resume(); | |
| 50 | process.stdin.setEncoding("utf8"); | |
| 51 | draw(); | |
| 52 | process.stdin.on("data", (key: string) => { | |
| 53 | if (key === "q" || key === "\x03") { | |
| 54 | out(`${esc}2J${esc}H`); | |
| 55 | process.exit(0); | |
| 56 | } | |
| 57 | const rows = events(); | |
| 58 | if (key === "j" || key === "\x1b[B") | |
| 59 | cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); | |
| 60 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 61 | draw(); | |
| 62 | }); | |
| 63 | process.stdout.on("resize", draw); | |
| 367 | if (import.meta.main) { | |
| 368 | const handleKey = createHandler(openDb(), whoAtTty(), envIdentityKey); | |
| 369 | process.stdin.setRawMode?.(true); | |
| 370 | process.stdin.resume(); | |
| 371 | process.stdin.setEncoding("utf8"); | |
| 372 | handleKey(""); | |
| 373 | process.stdin.on("data", handleKey); | |
| 374 | process.stdout.on("resize", () => handleKey("")); | |
| 375 | } |
Changes requested.
Read the whole diff against
main, ranbun testat this head (138 pass, 0 fail), drovecreateHandlerthrough real sessions, and checked the preview (page unchanged,/200 in ~0.4s, footer still printsFORGE_APP_SSHverbatim). The shape is right: the three actions decide everything,whoAtTtyis the only identity, the issued key goes straight to the terminal and is never held in the statedrawredraws (the resize test is a good one), and there is no secret, no shell and no string-built SQL. Two things stop it landing.1. Fix first — the cursor is an index into a list that reorders under it, and
xis an unconfirmed, irreversible keystroke.listAppsorders bycreated_at DESC, so registering an app pushes every existing row down one whilecursorstays where it was. Driven for real against this head: with Beta/Alpha listed and the cursor on Alpha, pressingnand registering Gamma leaves the cursor on Beta, and a singlexthen revokes Beta — an app the operator never selected, one-way, no confirmation, and the prompt never names what it is about to act on.rhas the same shape and invalidates a live app's current key on one keypress; worse,rmeant refresh in this very terminal until this diff, so old muscle memory now rotates a key. Fix: hold the selection as an app id and recompute the row index after every list change, and putrandxbehind a confirm that names the app (Revoke Beacon (beacon)? y/n) — the draft machinery is already there for it. The test that would have caught it: register two apps, move the cursor, register a third, pressx, assert the app revoked is the one that was under the cursor before the register.2. A write made in the terminal does not reach an open page. This is the first pull to put a write in
src/tty.ts, andAGENTS.mdasks that a write made there reach web viewers live like any other. All three actions callpostRelayEvent, so each one posts a realapp.registered/app.rotated/app.revokedevent onto the public timeline — exactly what/streams over/eventswithdata-live="event.posted". From the tty process that bus has no subscribers, so someone watching the page sees nothing until they reload. The README documents the gap instead of closing it. Close it (a durable nudge the server process picks up, soemitfrom any process fans out), or, if that is honestly its own change, open an issue for it and link it here rather than leaving a paragraph in the README as the answer.Smaller, worth doing while you are in the file:
handleDraftKeyonly acceptsinput.length === 1, but a pty delivers a paste — and often fast typing — as one chunk. Pastingbeaconinto the id field types nothing and you getid is lowercase letters, digits and hyphens…over an empty field. Iterate the chunk's characters through the same branch instead of switching on the whole chunk, and test it withhandle("beacon").ras refresh vanished with no word about it. The old legend advertised it and the new one does not mention refresh at all. Say in the README and the legend that any key redraws, or keep an explicit refresh key that is not the rotate key.type View = "notes" | "apps"and the legend'sp notes, in an app whose concept is events, and whose README calls the same thing the timeline. Name ittimelinein all three.ndraft and type both fields beforeregisterApprefuses. The refusal is correct and tested; the prompt still should not open for someone the legend already hides the key from.One door only, for the record: the page, the JSON API and MCP still have no app registry, so the terminal now does more than the page rather than the same. That is what the issue asked for, so I am not blocking on it — but it inverts
AGENTS.md's "and nothing more", and it wants a follow-up issue for the page/API door so the asymmetry sits on the plan instead of in a README sentence.(Housekeeping: two stray comments of mine on this pull,
probeonsrc/tty.ts:264andprobe-verdict-test, are artefacts of finding the shape this API wants —POST …/pulls/67/reviewsrefuses anything without apath/lineanchor. There is no delete route; ignore them.)