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

The terminal registers, rotates and revokes an app too #67

Merged opened this

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

Merged

scout/relay:scout/30-the-terminal-registers-rotates-and-revok → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/30-the-terminal-registers-rotates-and-revok was merged into main as 59aca55.

Review

Sign in to review this change.
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
7676 is whether it's still current, not whether the row exists. `relay` itself
7777 is a reserved id — nothing can register an app under it — because relay is
7878 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.
8282
8383 ## relay posts its own events
8484
@@ -168,8 +168,29 @@ without a reload. Without JavaScript it is the same page, one reload behind.
168168 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
169169 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
170170 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.
173194
174195 The footer prints that command verbatim, quoted, never a guessed form.
175196 The forge sits behind a proxy that rewrites anything shaped like an email
src/actions.ts +4
@@ -305,6 +305,10 @@ export interface IssuedKey {
305305 readonly key: string;
306306 }
307307
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
308312 type AdminGate =
309313 | { ok: true; id: string; name: string }
310314 | { 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 @@
11 /**
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.
624 */
725
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";
937 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";
1240
13 const db = openDb();
14 const me = whoAtTty();
1541 const esc = "\x1b[";
1642 const dim = (s: string): string => `${esc}2m${s}${esc}0m`;
1743 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`;
2145
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 };
2349
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;
2871
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`,
35129 ];
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") {
outdated

Changes requested.

Read the whole diff against main, ran bun test at this head (138 pass, 0 fail), drove createHandler through real sessions, and checked the preview (page unchanged, / 200 in ~0.4s, footer still prints FORGE_APP_SSH verbatim). The shape is right: the three actions decide everything, whoAtTty is the only identity, the issued key goes straight to the terminal and is never held in the state draw redraws (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 x is an unconfirmed, irreversible keystroke. listApps orders by created_at DESC, so registering an app pushes every existing row down one while cursor stays where it was. Driven for real against this head: with Beta/Alpha listed and the cursor on Alpha, pressing n and registering Gamma leaves the cursor on Beta, and a single x then revokes Beta — an app the operator never selected, one-way, no confirmation, and the prompt never names what it is about to act on. r has the same shape and invalidates a live app's current key on one keypress; worse, r meant 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 put r and x behind 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, press x, 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, and AGENTS.md asks that a write made there reach web viewers live like any other. All three actions call postRelayEvent, so each one posts a real app.registered / app.rotated / app.revoked event onto the public timeline — exactly what / streams over /events with data-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, so emit from 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:

  • Batched stdin is dropped silently. handleDraftKey only accepts input.length === 1, but a pty delivers a paste — and often fast typing — as one chunk. Pasting beacon into the id field types nothing and you get id 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 with handle("beacon").
  • r as 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.
  • Vocabulary drift. type View = "notes" | "apps" and the legend's p notes, in an app whose concept is events, and whose README calls the same thing the timeline. Name it timeline in all three.
  • A non-admin can open the n draft and type both fields before registerApp refuses. 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, probe on src/tty.ts:264 and probe-verdict-test, are artefacts of finding the shape this API wants — POST …/pulls/67/reviews refuses anything without a path/line anchor. There is no delete route; ignore them.)

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 };
46365 }
47366
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs