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

The terminal lists where you are signed in and ends one session with a confirm, as MCP `sessions` does #7246

Merged opened this

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

Merged

mason/forge:mason/7237-the-terminal-lists-where-you-are-signed → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/7237-the-terminal-lists-where-you-are-signed was merged into main as b1c23a4.

Review

Sign in to review this change.
6 files changed +316 −0
README.md +2
@@ -405,4 +405,6 @@ This page is the front door. Everything else is a file of its own under
405405 - [`docs/DESIGN.md`](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`.
406406 - [`docs/SPIKE-CLOUDFLARE.md`](docs/SPIKE-CLOUDFLARE.md), [`docs/AUDIT-MANY-HANDS.md`](docs/AUDIT-MANY-HANDS.md), [`docs/BACKLOG.md`](docs/BACKLOG.md) and [`docs/research/`](docs/research/) — a spike, an audit, a backlog and research notes, kept as written.
407407
408 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").
409
408410 `/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 (`docs/features/changelog.md`).
docs/CREW.md +2
@@ -303,6 +303,8 @@ one the provider rests hands its runs to the other until the reset.
303303
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`); 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 (read only, agents included).
305305
306 **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
306308 **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). 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.
307309
308310 **Spend ceilings.** Apart from the grant, a forge admin who is a person may set any account's — a person's, an agent's or an organization's — daily (UTC) and per-run ceilings in dollars: on the account's page (`/settings/accounts/<name>`, an organization's too, by that address; an agent and an admin's own account have no such page), the terminal's **Model ceiling** command, `POST /api/v1/admin/accounts/:name/ceiling` (`{"dailyUsd": 20, "perRunUsd": 5}`) or the MCP `model_ceiling` tool; each change is in the audit. Both empty on the page, `none` in the terminal (its prompt shows the current ceilings, and an empty answer changes nothing), or `null` for both on the API and MCP takes them back. An organization's page is linked from its own page for an admin. An account has no ceiling until one is set, so this forge's own crew is never stopped by a default nobody chose. A run on an account with a ceiling does not start when the day's is spent or the per-run one is too small, is capped at the smaller of the per-run ceiling and what is left of the day, and stops mid-run when the day's is spent.
docs/TOUR.md +3
@@ -153,6 +153,9 @@ to `/recover`: give the email on your account and, if it is verified, a link
153153 that lasts 30 minutes and works once registers a new passkey and signs you in.
154154 The relying party is the address the browser used (or
155155 `FORGE_URL`), so a passkey does not follow a forge to another hostname.
156 Where you are signed in, and signing a browser out, is on `/settings`, MCP
157 `sessions` and the terminal's **Signed in** command (`d` asks, then signs the
158 selected browser out); see `docs/CREW.md`, "Where you are signed in".
156159 Sign-up is a username and a password of at least 12 characters (`/signup`, `POST /api/v1/users` with `{username, password, email?}`, the terminal), hashed with argon2id; the email is optional and, when given, gets the verify link (an account with none has nothing to prove). A passkey (`src/web/pages/signup-passkey.ts`, which does take an email) is offered beside the password. Nothing in the path touches an edge, parent or Forgejo account. An operator can still refuse password sign-ups with `passkeyOnlySignup`. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) takes the same: a username and a password with the email optional, or a passkey (which needs an email; challenges from `POST /claim/options`), and a claim with neither a long enough password nor a passkey is refused without spending the link. An admin's everyday account (`web/pages/everyday.ts`) still makes its account with a passkey only (`web/passkey-ceremony.ts`, challenges from `POST /settings/everyday/options`): it reads no password, and without JavaScript the page says a passkey needs it.
157160
158161 Password checks are bounded (`src/core/hash-gate.ts`): at most 4 argon2 verifications run at once, and a password over 1024 bytes is never hashed and cannot be set (sign-up, claim and change-password refuse it with "Use 1024 bytes or fewer."). A check over the cap is refused at once, not queued: the caller sees the same `throttled` answer as a full sign-in window (429 with `Retry-After: 1` on HTTP Basic, the API and the sign-in page, "try again" in the terminal; an over-long Basic password is a plain 401), and the attempt is given back to the sign-in windows. The 4-hash cap is a deliberate trade-off: an attacker who keeps 4 hashes busy can make real sign-ins get "try again in 1s"; the per-source sign-in window is what limits how long they can.
src/tui/forge.ts +8
@@ -59,6 +59,7 @@ import { peopleScreen } from "./screens/people.ts";
5959 import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts";
6060 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
6161 import { invitesScreen } from "./screens/invites.ts";
62 import { signInsScreen } from "./screens/signins.ts";
6263 import { quotasScreen } from "./screens/quotas.ts";
6364 import { teamsScreen } from "./screens/teams.ts";
6465 import { domainsScreen } from "./screens/domains.ts";
@@ -248,6 +249,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
248249 title: "Account settings",
249250 run: () => openOnWeb(session, ctx, "/settings", "Account settings"),
250251 },
252 // Settings → Sessions and MCP `sessions`, in the terminal (#7237).

The command is signed-in only, but no test pins that a guest does not get it. Add one. Document the command in docs/CREW.md and the README.

253 {
254 id: "signins",
255 title: "Signed in",
256 hint: "your browsers and tokens; d signs a browser out",
257 run: () => ctx.push(signInsScreen(session, ctx)),
258 },
251259 );
252260 // The forge's own faults, as the board's strip shows them: a site admin's.
253261 if (session.user?.isAdmin === true)
src/tui/screens/signins.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * Where you are signed in, in the terminal (#7237): this terminal's key,
3 * your browsers and your tokens, each with its kind, where and when it was
4 * last seen, as Settings → Sessions and MCP `sessions` list them — never a
5 * secret. `d` signs the selected browser out after a confirm, through
6 * `endAccountSession`, so the gate is the action's: your own account only.
7 * 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.
9 */
10
11 import { accountSignIns, endAccountSession } from "../../actions/signins.ts";
12 import { listKeys } from "../../core/keys.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import type { Session } from "../session.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import { ListState, ago, confirmScreen, drawList, type ListRow } from "./common.ts";
20
21 interface SignIn {
22 readonly kind: "terminal" | "browser" | "token";
23 readonly id: string;
24 readonly where: string;
25 readonly seen: string | null;
26 readonly since: string;
27 readonly here: boolean;
28 }
29
30 /** The tail of an id: two browsers signed in the same day still read apart. */
31 const shortId = (id: string): string => id.slice(-6).toLowerCase();
32
33 /** The rows, this terminal first; empty for a guest. */
34 export function signInsOf(f: Session): SignIn[] {
35 const user = f.user;
36 if (user === undefined) return [];
37 // Without `current`: the terminal holds no browser cookie, so no browser
38 // row is ever "this one" here; this terminal's key is.
39 const seen = accountSignIns(f.app, user, user.username);
40 if (!seen.ok) return [];
41 const key = f.keyId === undefined ? undefined : listKeys(f.db, user.id).find((k) => k.id === f.keyId);
42 return [
43 ...(key === undefined
44 ? []
45 : [{ kind: "terminal" as const, id: key.id, where: key.name, seen: key.lastUsedAt, since: key.createdAt, here: true }]),
46 ...seen.value.sessions.map((s) => ({
47 kind: "browser" as const,
48 id: s.id,
49 where: `web browser ${shortId(s.id)}`,
50 seen: s.lastSeenAt,
51 since: s.createdAt,
52 here: s.current,
53 })),
54 ...seen.value.tokens.map((t) => ({
55 kind: "token" as const,
56 id: t.id,
57 where: `${t.name} · ${t.scope}`,
58 seen: t.lastUsedAt,
59 since: t.createdAt,
60 here: false,
61 })),
62 ];
63 }
64
65 export function signInsScreen(f: Session, ctx: Ctx): Screen {
66 const state = new ListState();
67 let note = "";
68
69 const rows = (all: readonly SignIn[]): ListRow[] =>
70 all.map((s) => ({
71 title: s.here ? `${s.where} · this one` : s.where,
72 secondary: s.kind,
73 meta: `last seen ${s.seen === null ? "never" : ago(s.seen)} · since ${s.since.slice(0, 10)}`,
74 }));
75
76 const end = (picked: SignIn): void => {
77 if (picked.kind === "terminal") {
78 note = "This terminal cannot be ended from here: disconnect, or remove its key in Settings → SSH keys.";
outdated

Browser rows are indistinguishable: both read 'web browser'. Add the since date or a short id to the title so the person can tell which row d ends.

79 ctx.invalidate();
80 return;
81 }
82 if (picked.kind === "token") {
83 note = "A token is revoked from Settings → Personal access tokens.";
84 ctx.invalidate();
85 return;
86 }
87 ctx.push(
88 confirmScreen(ctx, screen, {
89 title: "Sign this browser out",
90 body: [`${picked.where}: last seen ${picked.seen === null ? "never" : ago(picked.seen)}, signed in ${picked.since.slice(0, 10)}.`],
91 actions: [{ label: "Keep", variant: "ghost" }, { label: "Sign out", variant: "danger" }],
92 onChoose: (i) => {
93 if (i < 1) return;
94 const user = f.user;
95 const done = user !== undefined && endAccountSession(f.app, user, user.username, picked.id).ok;
96 note = done ? "Signed that browser out." : "That browser is already signed out.";
97 ctx.invalidate();
98 },
99 }),
100 );
101 };
102
103 const screen: Screen = {
104 title: "Signed in",
105 keys: [binding(["j", "k"], "move cursor"), binding(["d"], "sign a browser out")],
106 render(area, s) {
107 const c = ctx.theme.c;
108 const all = signInsOf(f);
109 const [head, body] = split(area, "v", [len(1), fill(1)]);
110 const text =
111 note !== ""
112 ? note
113 : f.user === undefined
114 ? "Sign in to see where you are signed in."
115 : "Where you are signed in. d signs the selected browser out.";
116 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
117 drawList(s, body!, c, state, rows(all));
118 },
119 key(e) {
outdated

Redundant invalidate: end() already invalidates on its own paths. Keep one.

120 note = "";
121 const all = signInsOf(f);
122 if (state.key(e, all.length)) return true;
123 if (e.ctrl || e.alt || e.key !== "d") return false;
124 const picked = all[state.index];
125 if (picked === undefined) return false;
126 end(picked);
127 return true;
128 },
129 };
130 return screen;
131 }
test/tui-signins.test.ts added +170
@@ -0,0 +1,170 @@
1 /**
2 * Where you are signed in, in the terminal (#7237): the palette's "Signed in"
3 * lists your browsers and tokens and this terminal's key, never a secret; `d`
4 * signs a browser out after a confirm, after which its cookie reads as signed
5 * out; this terminal cannot be ended from here; another person's sign-ins are
6 * never listed.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App as Forge } from "../src/app.ts";
13 import { addKey, parseAuthorizedKey } from "../src/core/keys.ts";
14 import { createSession, sessionOf } from "../src/core/sessions.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser, getUserByName } from "../src/core/users.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import { signInsOf, signInsScreen } from "../src/tui/screens/signins.ts";
19 import { Session } from "../src/tui/session.ts";
20 import { headlessTerminal } from "../src/tui/term/terminal.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { settleApp } from "./helpers/tui.ts";
23
24 quietLogs();
25
26 const KEY =
27 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo terminal-test";
28
29 describe("where you are signed in, in the terminal", () => {
30 let dataDir: string;
31 let forge: Forge;
32 let keyId: string;
33 let older: string;
34 let newer: string;
35 let bobs: string;
36 let token: string;
37
38 function boot(username: string | undefined) {
39 const terminal = headlessTerminal(140, 32);
40 const user = username === undefined ? undefined : getUserByName(forge.db, username)!;
41 const { app } = createForgeApp(
42 forge,
43 { sshUsername: "t", remoteAddress: "127.0.0.1", user, ...(username === "alice" ? { keyId } : {}) },
44 { terminal, exitHooks: false },
45 );
46 app.start();
47 const key = (k: string, ctrl = false) =>
48 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
49 return {
50 text: () => app.screen.toText(),
51 press: async (...keys: string[]) => {
52 keys.forEach((k) => key(k));
53 await settleApp(app);
54 },
55 open: async () => {
56 key("k", true);
57 await settleApp(app);
58 for (const ch of "Signed in") key(ch);
59 await settleApp(app);
60 key("enter");
61 await settleApp(app);
62 },
63 stop: () => app.stop(),
64 };
65 }
66
67 beforeAll(async () => {
68 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-signins-"));
69 forge = createApp({ dataDir, port: 0 });
70 for (const username of ["alice", "bob"]) {
71 const made = await createUser(forge.db, {
72 origin: "seed",
73 trusted: true,
74 username,
75 email: `${username}@forge.local`,
76 password: "a-good-passphrase",
77 adminIfFirst: false,
78 });
79 if (!made.ok) throw new Error(made.error);
80 }
81 const alice = getUserByName(forge.db, "alice")!;
82 const bob = getUserByName(forge.db, "bob")!;
83 const parsed = parseAuthorizedKey(KEY);
84 if (!parsed.ok) throw new Error(parsed.error);
85 const added = addKey(forge.db, alice.id, "alice-laptop-key", parsed.value);
86 if (!added.ok) throw new Error(added.error);
87 keyId = added.value.id;
88 older = createSession(forge.db, alice.id).token;
89 newer = createSession(forge.db, alice.id).token;
90 bobs = createSession(forge.db, bob.id).token;
91 token = createToken(forge.db, alice.id, "alice-ci", "ro").token;
92 createToken(forge.db, bob.id, "bob-secret-box", "ro");
93 });
94 afterAll(async () => {
95 forge.close();
96 await rm(dataDir, { recursive: true, force: true });
97 });
98
99 test("lists two browsers, a token and this terminal, and nothing of bob's", async () => {
100 const h = boot("alice");
101 await h.open();
102 const text = h.text();
103 expect(text).toContain("alice-laptop-key · this one");
104 expect(text.match(/web browser/g)?.length).toBe(2);
105 expect(text).toContain("alice-ci · ro");
106 expect(text).not.toContain("bob-secret-box");
107 expect(text).not.toContain(token);
108 expect(text).not.toContain(older);
109 h.stop();
110 });
111
112 test("this terminal cannot be ended from here, and says so", async () => {
113 const h = boot("alice");
114 await h.open();
115 await h.press("d");
116 expect(h.text()).toContain("This terminal cannot be ended from here");
117 expect(h.text()).toContain("alice-laptop-key");
118 h.stop();
119 });
120
121 test("d with a confirm signs one browser out; its cookie reads as signed out", async () => {
122 const h = boot("alice");
123 await h.open();
124 const browsers = signInsOf(new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "alice")! }))
125 .filter((s) => s.kind === "browser")
126 .map((s) => s.where);
127 // Two browsers signed in the same minute still read apart.
128 expect(new Set(browsers).size).toBe(2);
129 await h.press("j", "d");
130 expect(h.text()).toContain("Sign this browser out");
131 expect(h.text()).toContain(`${browsers[0]}: last seen`);
132 // Keep is the default: enter on it ends nothing.
133 await h.press("enter");
134 expect(sessionOf(forge.db, older).ok && sessionOf(forge.db, newer).ok).toBe(true);
135 await h.press("d", "right", "enter");
136 expect(h.text()).toContain("Signed that browser out.");
137 expect(h.text().match(/web browser/g)?.length).toBe(1);
138 const left = [older, newer].filter((t) => sessionOf(forge.db, t).ok);
139 expect(left.length).toBe(1);
140 expect(sessionOf(forge.db, bobs).ok).toBe(true);
141 h.stop();
142 });
143
144 test("bob sees only his own", async () => {
145 const h = boot("bob");
146 await h.open();
147 const text = h.text();
148 expect(text.match(/web browser/g)?.length).toBe(1);
149 expect(text).toContain("bob-secret-box");
150 expect(text).not.toContain("alice-ci");
151 expect(text).not.toContain("alice-laptop-key");
152 h.stop();
153 });
154
155 test("a guest has no Signed in command, and d on its empty screen does nothing", async () => {
156 const h = boot(undefined);
157 await h.open();
158 expect(h.text()).not.toContain("Sign in to see where you are signed in");
159 expect(h.text()).not.toContain("web browser");
160 h.stop();
161 const guest = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1" });
162 expect(signInsOf(guest)).toEqual([]);
163 const pushed: unknown[] = [];
164 const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {} } as never;
165 const screen = signInsScreen(guest, ctx);
166 const handled = screen.key?.({ type: "key", key: "d", ctrl: false, alt: false, shift: false } as never);
167 expect(handled).toBe(false);
168 expect(pushed).toEqual([]);
169 });
170 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs