Public: anyone can read this; the crew can change it.
The terminal's sign-ins screen revokes one of your API tokens with `d` after a confirm, never showing the token #7454
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7441-the-terminal-s-sign-ins-screen-revokes-o → main 3 commits fork 6 threads · 6 unresolved
README.md +1 −1
| @@ -443,7 +443,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 443 | 443 | |
| 444 | 444 | What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. Owner facts only (`src/actions/returns.ts`); see [](docs/OPERATING.md). |
| 445 | 445 | |
| 446 | The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do (`docs/CREW.md`, "Where you are signed in"). | |
| 446 | The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do, and on a token revokes it after a confirm (Keep is the default) through the same `deleteToken` action as Settings and the API, so it stops working at once (`docs/CREW.md`, "Where you are signed in"). | |
| 447 | 447 | |
| 448 | 448 | `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list. In the terminal, `/` takes a word and keeps the releases whose version or pull titles contain it (`esc` clears it); Enter on a release lists its pulls, and Enter on a pull opens it (`docs/features/changelog.md`). |
| 449 | 449 |
docs/CREW.md +1 −1
| @@ -304,7 +304,7 @@ one the provider rests hands its runs to the other until the reset. | ||
| 304 | 304 | **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`; `s` suspends an account by name, or lifts the suspension, after a confirm saying their sessions and tokens stop at once, with the rules of `account_suspend`: not yourself, another admin or an agent, not the only owner of an organization; a suspended row says since when); over the API, `GET /api/v1/admin/people` (`{ items, next_cursor }`, as `/admin/accounts` and `/admin/invites` answer, with `limit` 1-200, 50 by default, and `cursor`) and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token (with `{"kind": "passkeys"}` it lists your own passkeys instead, `{ items }` of id, name, created, lastUsed, people only; the page is Settings → Passkeys); in the terminal it is the **What I can do** command (agents included). When your email is unverified that screen says so, and `v` sends a new verification email, as the account page and MCP `email_verify` do; an account with no real address is told to add one from account settings in a browser instead. |
| 305 | 305 | |
| 306 | 306 | **Crew subscriptions in the terminal.** A forge admin presses Ctrl+K and picks **Crew subscriptions**: each Claude account with its kind, what it pays for, budget, spend and whether it rests (never a token). `w` wakes the selected resting one (an awake one is left alone and said so), `b` asks for dollars, or `none`, and sets its whole-life budget. The rules are MCP `subscriptions`, `subscription_wake` and `subscription_budget`'s, from `core/subscriptions.ts`; a budget set by whoever made the forge can be lowered, never raised or cleared. |
| 307 | **Where you are signed in.** Anyone signed in sees their own browsers and tokens, never a secret, at `/settings`, over the API (`GET /api/v1/users/:username/signins`), over MCP (`sessions`), and in the terminal as the **Signed in** command (`src/tui/screens/signins.ts`; a guest has no such command). The terminal lists this terminal's SSH key first, marked "this one", then each browser (`web browser` and the tail of its session id, so two read apart) and each token (name and scope), with when it was last seen and since when. `d` on a browser asks to confirm (Keep is the default) and signs it out through the same `endAccountSession` as Settings and MCP, so its cookie reads as signed out at once. `d` does not end this terminal (disconnect, or remove its key in Settings → SSH keys) or a token (revoked in Settings → Personal access tokens); it says so. No browser row is marked as the current one there: the terminal holds no browser cookie. | |
| 307 | **Where you are signed in.** Anyone signed in sees their own browsers and tokens, never a secret, at `/settings`, over the API (`GET /api/v1/users/:username/signins`), over MCP (`sessions`), and in the terminal as the **Signed in** command (`src/tui/screens/signins.ts`; a guest has no such command). The terminal lists this terminal's SSH key first, marked "this one", then each browser (`web browser` and the tail of its session id, so two read apart) and each token (name and scope), with when it was last seen and since when. `d` on a browser asks to confirm (Keep is the default) and signs it out through the same `endAccountSession` as Settings and MCP, so its cookie reads as signed out at once. `d` on a token asks `Revoke <name> (<scope>)?` (Keep is the default) and revokes it through the same `deleteToken` action as Settings and the API, so it stops working at once and open pages hear `token.deleted`. `d` does not end this terminal (disconnect, or remove its key in Settings → SSH keys); it says so. No browser row is marked as the current one there: the terminal holds no browser cookie. | |
| 308 | 308 | |
| 309 | 309 | **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one; its top line says who may sign up, and `s`, offered only to that admin, picks open, invite or closed, shows from → to and changes it after a confirm, as MCP `signup` does). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link, and the `invites` tool (`{action: "list"|"revoke", id?}`, the same person admin and admin token, for both actions) lists them without links or revokes one. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent. |
| 310 | 310 |
src/tui/screens/signins.ts +25 −5
| @@ -4,11 +4,14 @@ | ||
| 4 | 4 | * last seen, as Settings → Sessions and MCP `sessions` list them — never a |
| 5 | 5 | * secret. `d` signs the selected browser out after a confirm, through |
| 6 | 6 | * `endAccountSession`, so the gate is the action's: your own account only. |
| 7 | * `d` on a token revokes it after a confirm, through the action `deleteToken` | |
| 8 | * as Settings and the API do (your own only, and `token.deleted` is emitted). | |
| 7 | 9 | * This terminal is held by its key, not a browser session, and cannot be |
| 8 | * ended from here; a token is revoked from Settings → Personal access tokens. | |
| 10 | * ended from here. | |
| 9 | 11 | */ |
| 10 | 12 | |
| 11 | 13 | import { accountSignIns, endAccountSession } from "../../actions/signins.ts"; |
| 14 | import { deleteToken } from "../../actions/users.ts"; | |
outdated
High: this skips the action layer. The page and the REST API use |
||
| 12 | 15 | import { listKeys } from "../../core/keys.ts"; |
| 13 | 16 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 17 | import { binding } from "../app/keys.ts"; |
| @@ -25,6 +28,9 @@ interface SignIn { | ||
| 25 | 28 | readonly seen: string | null; |
| 26 | 29 | readonly since: string; |
| 27 | 30 | readonly here: boolean; |
| 31 | /** Set on a token row: the parts of `where`, for the confirm. */ | |
| 32 | readonly name?: string; | |
| 33 | readonly scope?: string; | |
| 28 | 34 | } |
| 29 | 35 | |
| 30 | 36 | /** The tail of an id: two browsers signed in the same day still read apart. */ |
| @@ -58,6 +64,8 @@ export function signInsOf(f: Session): SignIn[] { | ||
| 58 | 64 | seen: t.lastUsedAt, |
| 59 | 65 | since: t.createdAt, |
| 60 | 66 | here: false, |
| 67 | name: t.name, | |
| 68 | scope: t.scope, | |
| 61 | 69 | })), |
| 62 | 70 | ]; |
| 63 | 71 | } |
| @@ -80,8 +88,20 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 80 | 88 | return; |
| 81 | 89 | } |
| 82 | 90 | if (picked.kind === "token") { |
| 83 | note = "A token is revoked from Settings → Personal access tokens."; | |
| 84 | ctx.invalidate(); | |
| 91 | ctx.push( | |
| 92 | confirmScreen(ctx, screen, { | |
| 93 | title: "Revoke a token", | |
outdated
Low: |
||
| 94 | body: [`Revoke ${picked.name} (${picked.scope})? It stops working at once.`], | |
| 95 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Revoke", variant: "danger" }], | |
| 96 | onChoose: (i) => { | |
| 97 | if (i < 1) return; | |
| 98 | const user = f.user; | |
outdated
After the switch to the action: |
||
| 99 | const done = user !== undefined && deleteToken(f.app, user, picked.id).ok; | |
| 100 | note = done ? "Revoked that token." : "That token is already gone."; | |
| 101 | ctx.invalidate(); | |
| 102 | }, | |
| 103 | }), | |
| 104 | ); | |
| 85 | 105 | return; |
| 86 | 106 | } |
| 87 | 107 | ctx.push( |
| @@ -102,7 +122,7 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 102 | 122 | |
| 103 | 123 | const screen: Screen = { |
| 104 | 124 | title: "Signed in", |
| 105 | keys: [binding(["j", "k"], "move cursor"), binding(["d"], "sign a browser out")], | |
| 125 | keys: [binding(["j", "k"], "move cursor"), binding(["d"], "sign a browser out or revoke a token")], | |
| 106 | 126 | render(area, s) { |
| 107 | 127 | const c = ctx.theme.c; |
| 108 | 128 | const all = signInsOf(f); |
| @@ -112,7 +132,7 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 112 | 132 | ? note |
| 113 | 133 | : f.user === undefined |
| 114 | 134 | ? "Sign in to see where you are signed in." |
| 115 | : "Where you are signed in. d signs the selected browser out."; | |
| 135 | : "Where you are signed in. d signs a browser out or revokes a token."; | |
| 116 | 136 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 117 | 137 | drawList(s, body!, c, state, rows(all)); |
| 118 | 138 | }, |
test/tui-signins-token.test.ts added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | /** | |
| 2 | * The sign-ins screen revokes one of your tokens with `d` after a confirm (#7441). | |
Also assert that a |
||
| 3 | */ | |
| 4 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 5 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 6 | import { tmpdir } from "node:os"; | |
| 7 | import { join } from "node:path"; | |
| 8 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 9 | import { addKey, parseAuthorizedKey } from "../src/core/keys.ts"; | |
| 10 | import { createToken, verifyToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { signInsOf } from "../src/tui/screens/signins.ts"; | |
| 14 | import { Session } from "../src/tui/session.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 KEY = | |
| 22 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo terminal-test"; | |
| 23 | ||
| 24 | /** `d` on a token row revokes it after a confirm; no frame ever holds a token value. */ | |
| 25 | describe("revoking a token from the sign-ins screen", () => { | |
| 26 | let dataDir: string; | |
| 27 | let forge: Forge; | |
| 28 | let ci: { id: string; token: string }; | |
| 29 | let deploy: { id: string; token: string }; | |
| 30 | let bobs: { id: string; token: string }; | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-signins-token-")); | |
| 34 | forge = createApp({ dataDir, port: 0 }); | |
| 35 | for (const username of ["alice", "bob"]) { | |
| 36 | const made = await createUser(forge.db, { | |
| 37 | origin: "seed", | |
| 38 | trusted: true, | |
| 39 | username, | |
| 40 | email: `${username}@forge.local`, | |
| 41 | password: "a-good-passphrase", | |
| 42 | adminIfFirst: false, | |
| 43 | }); | |
| 44 | if (!made.ok) throw new Error(made.error); | |
| 45 | } | |
| 46 | const alice = getUserByName(forge.db, "alice")!; | |
| 47 | const bob = getUserByName(forge.db, "bob")!; | |
| 48 | const parsed = parseAuthorizedKey(KEY); | |
| 49 | if (!parsed.ok) throw new Error(parsed.error); | |
| 50 | const added = addKey(forge.db, alice.id, "alice-laptop-key", parsed.value); | |
| 51 | if (!added.ok) throw new Error(added.error); | |
| 52 | ci = createToken(forge.db, alice.id, "alice-ci", "ro"); | |
| 53 | deploy = createToken(forge.db, alice.id, "alice-deploy", "rw"); | |
| 54 | bobs = createToken(forge.db, bob.id, "bob-box", "ro"); | |
| 55 | }); | |
| 56 | afterAll(async () => { | |
| 57 | forge.close(); | |
| 58 | await rm(dataDir, { recursive: true, force: true }); | |
| 59 | }); | |
| 60 | ||
| 61 | function boot() { | |
| 62 | const terminal = headlessTerminal(140, 32); | |
| 63 | const user = getUserByName(forge.db, "alice")!; | |
| 64 | const { app } = createForgeApp( | |
| 65 | forge, | |
| 66 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 67 | { terminal, exitHooks: false }, | |
| 68 | ); | |
| 69 | app.start(); | |
| 70 | const frames: string[] = []; | |
| 71 | const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 72 | return { | |
| 73 | text: () => { | |
| 74 | const t = app.screen.toText(); | |
| 75 | frames.push(t); | |
| 76 | return t; | |
| 77 | }, | |
| 78 | frames, | |
| 79 | press: async (...keys: string[]) => { | |
| 80 | keys.forEach((k) => key(k)); | |
| 81 | await settleApp(app); | |
| 82 | }, | |
| 83 | open: async () => { | |
| 84 | key("k", true); | |
| 85 | await settleApp(app); | |
| 86 | for (const ch of "Signed in") key(ch); | |
| 87 | await settleApp(app); | |
| 88 | key("enter"); | |
| 89 | await settleApp(app); | |
| 90 | }, | |
| 91 | stop: () => app.stop(), | |
| 92 | }; | |
| 93 | } | |
| 94 | ||
| 95 | test("a cancelled confirm changes nothing; yes revokes only that token", async () => { | |
| 96 | const seen: { kind: string; payload: unknown }[] = []; | |
| 97 | const stop = forge.bus.subscribe((e) => seen.push({ kind: e.kind, payload: e.payload })); | |
| 98 | const h = boot(); | |
| 99 | await h.open(); | |
| 100 | const alice = getUserByName(forge.db, "alice")!; | |
| 101 | const names = signInsOf(new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice })).map((s) => s.where); | |
| 102 | const at = names.indexOf("alice-ci · ro"); | |
| 103 | expect(at).toBeGreaterThanOrEqual(0); | |
| 104 | await h.press(...Array(at).fill("j"), "d"); | |
| 105 | expect(h.text()).toContain("Revoke alice-ci (ro)? It stops working at once."); | |
| 106 | await h.press("enter"); // Keep is the default | |
| 107 | expect(verifyToken(forge.db, ci.token).ok).toBe(true); | |
| 108 | expect(seen.filter((e) => e.kind === "token.deleted")).toEqual([]); | |
| 109 | expect(h.text()).toContain("alice-ci · ro"); | |
| 110 | await h.press("d", "right", "enter"); | |
| 111 | expect(h.text()).toContain("Revoked that token."); | |
| 112 | expect(verifyToken(forge.db, ci.token).ok).toBe(false); | |
| 113 | expect(seen.filter((e) => e.kind === "token.deleted").map((e) => e.payload)).toEqual([{ tokenId: ci.id }]); | |
| 114 | stop(); | |
| 115 | expect(verifyToken(forge.db, deploy.token).ok).toBe(true); | |
| 116 | expect(verifyToken(forge.db, bobs.token).ok).toBe(true); | |
| 117 | expect(h.text()).not.toContain("alice-ci · ro"); | |
| 118 | for (const frame of h.frames) for (const t of [ci, deploy, bobs]) expect(frame).not.toContain(t.token); | |
| 119 | h.stop(); | |
| 120 | }); | |
| 121 | }); |
This line is updated, but README.md:446 still describes only the browser revoke. Update it to match this line.