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

Merged opened this

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

Merged

scout/forge:scout/7441-the-terminal-s-sign-ins-screen-revokes-o → main 3 commits fork 6 threads · 6 unresolved

  • Merged. scout/7441-the-terminal-s-sign-ins-screen-revokes-o was merged into main as 6f5d201.

Review

Sign in to review this change.
4 files changed +148 −7
README.md +1 −1
@@ -443,7 +443,7 @@ This page is the front door. Everything else is a file of its own under
443443
444444 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`](docs/OPERATING.md).
445445
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").
447447
448448 `/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`).
449449
docs/CREW.md +1 −1
@@ -304,7 +304,7 @@ one the provider rests hands its runs to the other until the reset.
304304 **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.
305305
306306 **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.

This line is updated, but README.md:446 still describes only the browser revoke. Update it to match this line.

308308
309309 **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.
310310
src/tui/screens/signins.ts +25 −5
@@ -4,11 +4,14 @@
44 * last seen, as Settings → Sessions and MCP `sessions` list them — never a
55 * secret. `d` signs the selected browser out after a confirm, through
66 * `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).
79 * 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.
911 */
1012
1113 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 deleteToken from src/actions/users.ts, which gates on requireUser and appends token.deleted to the bus. Use that one (or revokeAccountToken) so the terminal behaves like the other doors and web viewers get the change live.

1215 import { listKeys } from "../../core/keys.ts";
1316 import type { Ctx, Screen } from "../app/app.ts";
1417 import { binding } from "../app/keys.ts";
@@ -25,6 +28,9 @@ interface SignIn {
2528 readonly seen: string | null;
2629 readonly since: string;
2730 readonly here: boolean;
31 /** Set on a token row: the parts of `where`, for the confirm. */
32 readonly name?: string;
33 readonly scope?: string;
2834 }
2935
3036 /** 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[] {
5864 seen: t.lastUsedAt,
5965 since: t.createdAt,
6066 here: false,
67 name: t.name,
68 scope: t.scope,
6169 })),
6270 ];
6371 }
@@ -80,8 +88,20 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
8088 return;
8189 }
8290 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: replace(" · ", " (") acts on the first separator, which can come from the token's own name. Build the sentence from the name and scope fields.

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: deleteToken(f.app, user, picked.id).ok.

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 );
85105 return;
86106 }
87107 ctx.push(
@@ -102,7 +122,7 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
102122
103123 const screen: Screen = {
104124 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")],
106126 render(area, s) {
107127 const c = ctx.theme.c;
108128 const all = signInsOf(f);
@@ -112,7 +132,7 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
112132 ? note
113133 : f.user === undefined
114134 ? "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.";
116136 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
117137 drawList(s, body!, c, state, rows(all));
118138 },
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 token.deleted event for ci.id was appended after the revoke. Today's code fails that, and it is what makes the change visible on the web.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs