Public: anyone can read this; the crew can change it.
The terminal lists your passkeys, with name, added and last used, beside SSH keys #8051
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8044-the-terminal-lists-your-passkeys-with-na → main 3 commits fork 1 thread · 1 unresolved
6 files
changed +176
−1
docs/CREW.md +1 −1
Diff not shown — a line 2,406 characters wide (limit 2,000). +1 −1
View filedocs/TOUR.md +2
| @@ -197,6 +197,8 @@ that account, as for anyone with a key. | ||
| 197 | 197 | |
| 198 | 198 | In the terminal the palette's `SSH keys` (ctrl+k) lists your own keys; `a` adds one (a title, then the authorized_keys line) and `d` removes the selected key after a confirm that warns when it is this session's key. Refusals read in MCP `ssh_keys`'s words (`keyWords`). |
| 199 | 199 | |
| 200 | Beside it the palette's `Passkeys` (ctrl+k, people only, not agents or guests) lists your own passkeys, read only: each row is the name, then `added YYYY-MM-DD, used YYYY-MM-DD` (or `used never`). The head line says it is read only and points at Settings › Passkeys to add or remove one; with none it reads "None yet. Add one on the web: Settings › Passkeys." (`src/tui/screens/passkeys.ts`, the same rows as MCP `whoami_can kind: passkeys`). | |
| 201 | ||
| 200 | 202 | ## Page-view beacon |
| 201 | 203 | |
| 202 | 204 | Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB, no dependency) that sends, once per page view with `sendBeacon`, the route's path, TTFB, load time, LCP, INP (the worst interaction) and CLS to `POST /_measure`. The route is same-origin only, validates every field, caps the body at 512 bytes and the rate per client, and answers 204. Reports land in `page_vital` (bun:sqlite) keyed by route *pattern*; no IP and no user agent string is stored. A unique visitor is a hash under a salt that rotates daily (yesterday's salt is deleted), rows are kept 90 days. Do-Not-Track and the `forge_measure=off` cookie send nothing, and the server stores nothing for them either. The CSP is unchanged: the script is nonce'd like the shell's. |
src/tui/forge.ts +9
| @@ -49,6 +49,7 @@ import { canScreen } from "./screens/can.ts"; | ||
| 49 | 49 | import { modelScreen } from "./screens/model.ts"; |
| 50 | 50 | import { notifyScreen } from "./screens/notify.ts"; |
| 51 | 51 | import { sshKeysScreen } from "./screens/sshkeys.ts"; |
| 52 | import { passkeysScreen } from "./screens/passkeys.ts"; | |
| 52 | 53 | import { confirmsScreen } from "./screens/confirms.ts"; |
| 53 | 54 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 54 | 55 | import { signInsScreen } from "./screens/signins.ts"; |
| @@ -272,6 +273,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 272 | 273 | hint: "list, add and remove the keys that sign in as you", |
| 273 | 274 | run: () => ctx.push(sshKeysScreen(session, ctx)), |
| 274 | 275 | }); |
| 276 | // Settings › Passkeys and MCP `whoami_can kind: passkeys`, read only (#8044). | |
| 277 | if (session.signedIn && session.user?.kind !== "agent") | |
| 278 | commands.push({ | |
| 279 | id: "passkeys", | |
| 280 | title: "Passkeys", | |
| 281 | hint: "the passkeys that sign in as you", | |
| 282 | run: () => ctx.push(passkeysScreen(session, ctx)), | |
| 283 | }); | |
| 275 | 284 | if (session.signedIn) |
| 276 | 285 | commands.push( |
| 277 | 286 | { |
src/tui/screens/passkeys.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | /** | |
| 2 | * Your passkeys in the terminal (#8044): the list, read only. A passkey is | |
| 3 | * made by a browser ceremony, so this screen never offers to add one; it | |
| 4 | * names where to, Settings › Passkeys. The list is your own passkeys, never | |
| 5 | * another account's, as MCP `whoami_can kind: passkeys` shows them. | |
| 6 | */ | |
| 7 | ||
| 8 | import { listPasskeys } from "../../core/passkeys.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import { style } from "../term/style.ts"; | |
| 12 | import { truncate } from "../term/width.ts"; | |
| 13 | import type { Session } from "../session.ts"; | |
| 14 | import { fill, len, split } from "../ui/layout.ts"; | |
| 15 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 16 | ||
| 17 | export function passkeysScreen(f: Session, ctx: Ctx): Screen { | |
| 18 | const state = new ListState(); | |
| 19 | const items = () => (f.user === undefined ? [] : listPasskeys(f.app.db, f.user.id)); | |
| 20 | ||
| 21 | const rows = (): ListRow[] => | |
| 22 | items().map((p) => ({ | |
| 23 | title: p.name, | |
| 24 | meta: `added ${p.createdAt.slice(0, 10)}, used ${p.lastUsedAt?.slice(0, 10) ?? "never"}`, | |
| 25 | })); | |
| 26 | ||
| 27 | return { | |
| 28 | title: "Passkeys", | |
| 29 | keys: [binding(["j", "k"], "move cursor")], | |
| 30 | render(area, s) { | |
| 31 | const c = ctx.theme.c; | |
| 32 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 33 | const text = | |
| 34 | items().length === 0 | |
| 35 | ? "None yet. Add one on the web: Settings › Passkeys." | |
| 36 | : "Passkeys that sign in as you. Read only: add or remove one on the web, Settings › Passkeys."; | |
| 37 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 38 | drawList(s, body!, c, state, rows()); | |
| 39 | }, | |
| 40 | key(e) { | |
| 41 | return state.key(e, items().length); | |
| 42 | }, | |
| 43 | }; | |
| 44 | } |
test/palette-roster.test.ts +2
| @@ -65,6 +65,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 65 | 65 | ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"], |
| 66 | 66 | ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"], |
| 67 | 67 | ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"], |
| 68 | ["passkeys", "Passkeys", "the passkeys that sign in as you"], | |
| 68 | 69 | ["account", "Account settings", undefined], |
| 69 | 70 | ["signins", "Signed in", "your browsers and tokens; d signs a browser out"], |
| 70 | 71 | ["sentinel", "Sentinel", "the forge's open faults"], |
| @@ -188,6 +189,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 188 | 189 | ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"], |
| 189 | 190 | ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"], |
| 190 | 191 | ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"], |
| 192 | ["passkeys", "Passkeys", "the passkeys that sign in as you"], | |
| 191 | 193 | ["account", "Account settings", undefined], |
| 192 | 194 | ["signins", "Signed in", "your browsers and tokens; d signs a browser out"], |
| 193 | 195 | ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"], |
test/tui-passkeys.test.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * Passkeys in the terminal (#8044): your own passkeys listed with added and | |
| 3 | * last used, never another account's; none yet points at the web; a person's | |
| 4 | * command, never an agent's. | |
| 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 } from "../src/app.ts"; | |
| 11 | import type { Credential } from "../src/auth/webauthn.ts"; | |
| 12 | import { addPasskey, notePasskeyUsed } from "../src/core/passkeys.ts"; | |
| 13 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 14 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const cred = (id: string): Credential => ({ | |
| 22 | id, | |
| 23 | alg: "ES256", | |
| 24 | key: { kty: "EC", crv: "P-256", x: "x", y: "y" }, | |
| 25 | signCount: 0, | |
| 26 | }); | |
| 27 | ||
| 28 | describe("passkeys in the terminal", () => { | |
| 29 | let dataDir: string; | |
| 30 | let app: App; | |
| 31 | ||
| 32 | const seed = (owner: string, name: string) => { | |
| 33 | const added = addPasskey(app.db, getUserByName(app.db, owner)!.id, name, cred(`${owner}-${name}`)); | |
| 34 | if (!added.ok) throw new Error(added.error); | |
| 35 | return added.value; | |
| 36 | }; | |
| 37 | ||
| 38 | function boot(username: string) { | |
| 39 | const terminal = headlessTerminal(140, 32); | |
| 40 | const user = getUserByName(app.db, username)!; | |
| 41 | const { app: tui } = createForgeApp( | |
| 42 | app, | |
| 43 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 44 | { terminal, exitHooks: false }, | |
| 45 | ); | |
| 46 | tui.start(); | |
| 47 | const key = (k: string, ctrl = false) => | |
| 48 | tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 49 | return { | |
| 50 | text: () => tui.screen.toText(), | |
| 51 | open: async () => { | |
| 52 | key("k", true); | |
| 53 | await settleApp(tui); | |
| 54 | for (const ch of "Passkeys") key(ch); | |
| 55 | await settleApp(tui); | |
| 56 | key("enter"); | |
| 57 | await settleApp(tui); | |
| 58 | }, | |
| 59 | stop: () => tui.stop(), | |
| 60 | }; | |
| 61 | } | |
| 62 | ||
| 63 | beforeAll(async () => { | |
| 64 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-passkeys-")); | |
| 65 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 66 | for (const [username, first] of [["alice", true], ["bob", false], ["carol", false]] as const) { | |
| 67 | const made = await createUser(app.db, { | |
| 68 | origin: "seed", | |
| 69 | trusted: true, | |
| 70 | username, | |
| 71 | email: `${username}@forge.local`, | |
| 72 | password: "a-good-passphrase", | |
| 73 | adminIfFirst: first, | |
| 74 | }); | |
| 75 | if (!made.ok) throw new Error(made.error); | |
| 76 | } | |
| 77 | notePasskeyUsed(app.db, seed("alice", "work laptop"), 1); | |
| 78 | seed("alice", "phone"); | |
| 79 | seed("bob", "bobs-yubikey"); | |
| 80 | }); | |
| 81 | ||
| 82 | afterAll(async () => { | |
| 83 | await rm(dataDir, { recursive: true, force: true }); | |
| 84 | }); | |
| 85 | ||
| 86 | test("lists your two passkeys with added and used, never another account's", async () => { | |
| 87 | const h = boot("alice"); | |
| 88 | await h.open(); | |
| 89 | const text = h.text(); | |
| 90 | expect(text).toContain("Passkeys that sign in as you. Read only: add or remove one on the web, Settings › Passkeys."); | |
| 91 | expect(text).toContain("work laptop"); | |
| 92 | expect(text).toContain("phone"); | |
| 93 | const today = new Date().toISOString().slice(0, 10); | |
| 94 | expect(text).toContain(`added ${today}, used ${today}`); | |
| 95 | expect(text).toContain(`added ${today}, used never`); | |
| 96 | expect(text).not.toContain("bobs-yubikey"); | |
| 97 | h.stop(); | |
| 98 | }); | |
| 99 | ||
| 100 | test("with none, the head line points at Settings › Passkeys", async () => { | |
| 101 | const h = boot("carol"); | |
| 102 | await h.open(); | |
| 103 | expect(h.text()).toContain("None yet. Add one on the web: Settings › Passkeys."); | |
| 104 | expect(h.text()).not.toContain("bobs-yubikey"); | |
| 105 | h.stop(); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the palette offers it to a signed-in person, never to an agent or a guest", () => { | |
| 109 | const alice = getUserByName(app.db, "alice")!; | |
| 110 | const ids = (u?: User) => | |
| 111 | forgeCommands(new Session(app, { sshUsername: u?.username ?? "git", remoteAddress: "127.0.0.1", user: u }))( | |
| 112 | {} as never, | |
| 113 | ).map((c) => c.id); | |
| 114 | expect(ids(alice)).toContain("passkeys"); | |
| 115 | expect(ids({ ...alice, kind: "agent" })).not.toContain("passkeys"); | |
| 116 | expect(ids()).not.toContain("passkeys"); | |
| 117 | }); | |
| 118 | }); |
new above
offline
Only j/k are bound, so
aandddo nothing and the screen never says why. Consider a one-line note in the keys list or head line that this screen is read only.