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

The terminal's People screen suspends or unsuspends an account with `s` and a confirm, as MCP `account_suspend` does #7274

Merged opened this

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

Merged

scout/forge:scout/7267-the-terminal-s-people-screen-suspends-or → main 2 commits fork

  • Merged. scout/7267-the-terminal-s-people-screen-suspends-or was merged into main as 75b60db.

Review

Sign in to review this change.
4 files changed +196 −10
docs/CREW.md +1 −1
@@ -301,7 +301,7 @@ the work repositories and nothing else. Two on one grant take turns, and
301301 one the provider rests hands its runs to the other until the reset.
302302 **Model budget.** A new account has none: its repositories' crew does not run, spends nothing, and says "This repository has no model account". A person adds their own subscription at Settings → Model (or the terminal's **Model budget** command, `a`); it is sealed, granted to their own repositories only, and one per account. A forge admin who is a person gives an account a capped grant, in dollars a month spent on the forge's own credential, from the account's page, the terminal's **Model grant** command, `POST /api/v1/admin/accounts/:name/model-grant` (`{"usdPerMonth": 20}`, `null` takes it back) or the MCP `model_grant` tool; each change is in the audit. The cap is the UTC month's spend in the usage ledger, checked when a run starts and on every spend tick: a run past it stops, and its page says the grant is spent. Two runs that start together each begin with the whole remainder; the tick check ends both once the sum crosses the cap, so the overshoot is at most what they spend between ticks.
303303
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`); 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).
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 (read only, agents included).
305305
306306 **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.
307307
src/core/roles.ts +5 −2
@@ -99,13 +99,15 @@ export interface Person {
9999 readonly username: string;
100100 readonly role: Role;
101101 readonly usdPerMonth: number | null;
102 /** When the owner suspended the account; null while it is in good standing. */
103 readonly suspendedAt: string | null;
102104 }
103105
104106 /** Every person (not an agent, not an organization) with role and model grant, newest first, in one query. */
105107 export function listPeople(db: Database): Person[] {
106108 return db
107 .query<{ username: string; is_admin: number; member: number | null; usd: number | null }, []>(
108 `SELECT u.username, u.is_admin, m.user_id AS member, g.usd_per_month AS usd
109 .query<{ username: string; is_admin: number; member: number | null; usd: number | null; suspended_at: string | null }, []>(
110 `SELECT u.username, u.is_admin, m.user_id AS member, g.usd_per_month AS usd, u.suspended_at
109111 FROM user u
110112 LEFT JOIN member m ON m.user_id = u.id
111113 LEFT JOIN model_grant g ON g.user_id = u.id
@@ -117,5 +119,6 @@ export function listPeople(db: Database): Person[] {
117119 username: r.username,
118120 role: r.is_admin === 1 ? "admin" : r.member !== null ? "member" : "guest",
119121 usdPerMonth: r.usd,
122 suspendedAt: r.suspended_at,
120123 }));
121124 }
src/tui/screens/people.ts +64 −7
@@ -2,34 +2,91 @@
22 * People in the terminal, as `/settings/people` has them: every person with
33 * a role and allowance, and `r` to make one a member (`member 5` gives a
44 * starter $5 a month) or a guest. A forge admin who is a person; every rule
5 * is the action's (`actions/roles.ts`).
5 * is the action's (`actions/roles.ts`). `s` suspends or lifts a suspension
6 * after a confirm, with the actions and words MCP `account_suspend` uses.
67 */
78
9 import { suspendAccount, unsuspendAccount } from "../../actions/accounts.ts";
10 import { ADMIN_WORDS } from "../../actions/admin_words.ts";
11 import { lastOwnerSays } from "../../core/orgs.ts";
812 import { setRole } from "../../actions/roles.ts";
913 import { AMOUNT_REFUSED } from "../../actions/model_words.ts";
10 import { listPeople } from "../../core/roles.ts";
14 import { listPeople, type Person } from "../../core/roles.ts";
1115 import type { Ctx, Screen } from "../app/app.ts";
1216 import { binding } from "../app/keys.ts";
1317 import type { Session } from "../session.ts";
1418 import { style } from "../term/style.ts";
1519 import { truncate } from "../term/width.ts";
16 import { later, promptScreen } from "./common.ts";
20 import { confirmScreen, later, promptScreen, runAsync } from "./common.ts";
21
22 /** One person's row: name, role, allowance, and since when they are suspended. */
23 function rowOf(p: Person): string {
24 const allowance = p.usdPerMonth === null ? "" : ` $${p.usdPerMonth.toFixed(2)} a month`;
25 const suspended = p.suspendedAt === null ? "" : ` suspended ${p.suspendedAt.slice(0, 10)}`;
26 return `${p.username} ${p.role}${allowance}${suspended}`;
27 }
1728
1829 export function peopleScreen(f: Session, ctx: Ctx): Screen {
30 const askSuspend = (username: string, suspend: boolean): void => {
31 later(ctx, () =>
32 confirmScreen(ctx, screen, {
33 title: suspend ? `Suspend ${username}` : `Lift ${username}'s suspension`,
34 body: [
35 suspend
36 ? `${username}'s sessions and tokens stop at once. Suspend this account?`
37 : `${username} can sign in again, with a new session. Lift the suspension?`,
38 ],
39 actions: [suspend ? { label: "Suspend", variant: "danger" } : { label: "Lift" }, { label: "Cancel" }],
40 selected: 1,
41 onChoose: (choice) => {
42 if (choice !== 0) return;
43 runAsync(ctx, async () => {
44 const done = suspend ? await suspendAccount(f.app, f.user, username) : unsuspendAccount(f.app, f.user, username);
45 if (done.ok) return ctx.toast(suspend ? `${username} is suspended.` : `${username} is lifted.`);
46 const words =
47 done.error === "not_found"
48 ? "No such person."
49 : done.error === "last_owner"
50 ? lastOwnerSays(f.app.db, username)
51 : done.error === "not_allowed"
52 ? ADMIN_WORDS.selfSuspend
53 : "That did not work.";
54 ctx.toast(words, "danger");
55 });
56 },
57 }),
58 );
59 };
1960 const screen: Screen = {
2061 title: "People",
21 keys: [binding(["r"], "change a role"), binding(["esc"], "back")],
62 keys: [binding(["r"], "change a role"), binding(["s"], "suspend or lift"), binding(["esc"], "back")],
2263 render(area, s) {
2364 const c = ctx.theme.c;
2465 listPeople(f.app.db)
2566 .slice(0, Math.max(0, area.h))
2667 .forEach((p, i) => {
27 const allowance = p.usdPerMonth === null ? "" : ` $${p.usdPerMonth.toFixed(2)} a month`;
28 s.text(area.x, area.y + i, truncate(`${p.username} ${p.role}${allowance}`, area.w), style(c.mutedFg, c.bg), area.x + area.w);
68 s.text(area.x, area.y + i, truncate(rowOf(p), area.w), style(c.mutedFg, c.bg), area.x + area.w);
2969 });
3070 },
3171 key(e) {
32 if (e.ctrl || e.alt || e.key !== "r") return false;
72 if (e.ctrl || e.alt) return false;
73 if (e.key === "s") {
74 ctx.push(
75 promptScreen(ctx, screen, {
76 title: "Suspend or lift",
77 label: "account name",
78 onSubmit: (name) => {
79 const username = name.trim();
80 const person = listPeople(f.app.db).find((p) => p.username === username);
81 if (person === undefined) return "No such account, or not one a suspension is for.";
82 askSuspend(username, person.suspendedAt === null);
83 return undefined;
84 },
85 }),
86 );
87 return true;
88 }
89 if (e.key !== "r") return false;
3390 ctx.push(
3491 promptScreen(ctx, screen, {
3592 title: "Change a role",
test/tui-people-suspend.test.ts added +126
@@ -0,0 +1,126 @@
1 /** `s` on the terminal's People screen suspends or lifts, as MCP `account_suspend` does. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { ADMIN_WORDS } from "../src/actions/admin_words.ts";
9 import { createOrg, lastOwnerSays } from "../src/core/orgs.ts";
10 import { createSession, sessionUser } from "../src/core/sessions.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15 import { settleApp, settleUntil } from "./helpers/tui.ts";
16
17 quietLogs();
18
19 describe("suspending from the terminal's People screen", () => {
20 let dataDir = "";
21 let forge: App;
22
23 const suspendedAt = (name: string) => getUserByName(forge.db, name)!.suspendedAt;
24
25 function boot(username = "alice") {
26 const terminal = headlessTerminal(140, 32);
27 const user = getUserByName(forge.db, username)!;
28 const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false });
29 app.start();
30 const press = async (...keys: string[]) => {
31 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
32 await settleApp(app);
33 };
34 const type = async (text: string) => press(...text.split(""));
35 return { app, press, type, text: () => app.screen.toText() };
36 }
37
38 async function people(h: ReturnType<typeof boot>): Promise<void> {
39 h.app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
40 await settleApp(h.app);
41 await h.type("People");
42 await h.press("enter");
43 await settleUntil(h.app, "bob");
44 }
45
46 async function ask(h: ReturnType<typeof boot>, name: string): Promise<void> {
47 await h.press("s");
48 await h.type(name);
49 await h.press("enter");
50 }
51
52 beforeAll(async () => {
53 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-suspend-"));
54 forge = createApp({ dataDir, port: 0 });
55 for (const [username, isAdmin] of [["alice", true], ["dave", true], ["bob", false], ["carol", false]] as const) {
56 const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-suspend-passphrase", isAdmin });
57 if (!made.ok) throw new Error(username);
58 }
59 if (!createOrg(forge.db, "acme", getUserByName(forge.db, "carol")!.id).ok) throw new Error("org");
60 }, 30_000);
61
62 afterAll(async () => {
63 forge.close();
64 await rm(dataDir, { recursive: true, force: true });
65 });
66
67 test("a guest or a member is offered no People screen, and nobody changes", async () => {
68 for (const name of ["bob", "carol"]) {
69 const h = boot(name);
70 h.app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
71 await settleApp(h.app);
72 await h.type("People");
73 expect(h.text()).not.toContain("who is a guest, a member or an admin");
74 await h.press("enter", "s");
75 expect(h.text()).not.toContain("Suspend or lift");
76 expect(suspendedAt("bob")).toBeNull();
77 h.app.stop();
78 }
79 });
80
81 test("esc at the confirm changes nothing", async () => {
82 const h = boot();
83 await people(h);
84 await ask(h, "bob");
85 expect(h.text()).toContain("sessions and tokens stop at once");
86 await h.press("esc");
87 expect(suspendedAt("bob")).toBeNull();
88 h.app.stop();
89 });
90
91 test("a confirm suspends, signs the person out and marks the row; a second lifts it", async () => {
92 const token = createSession(forge.db, getUserByName(forge.db, "bob")!.id).token;
93 expect(sessionUser(forge.db, token).ok).toBe(true);
94 const h = boot();
95 await people(h);
96 await ask(h, "bob");
97 await h.press("left", "enter");
98 await settleUntil(h.app, "bob is suspended.");
99 expect(suspendedAt("bob")).not.toBeNull();
100 expect(sessionUser(forge.db, token).ok).toBe(false);
101 expect(h.text()).toMatch(/bob +guest +suspended 20\d\d-/);
102
103 await ask(h, "bob");
104 expect(h.text()).toContain("can sign in again");
105 await h.press("left", "enter");
106 await settleUntil(h.app, "bob is lifted.");
107 expect(suspendedAt("bob")).toBeNull();
108 h.app.stop();
109 });
110
111 test("yourself, another admin and the only owner of an organization are refused in MCP's words", async () => {
112 const h = boot();
113 await people(h);
114 for (const [name, words] of [
115 ["alice", ADMIN_WORDS.selfSuspend],
116 ["dave", ADMIN_WORDS.selfSuspend],
117 ["carol", lastOwnerSays(forge.db, "carol")],
118 ] as const) {
119 await ask(h, name);
120 await h.press("left", "enter");
121 await settleUntil(h.app, words.slice(0, 40));
122 expect(suspendedAt(name)).toBeNull();
123 }
124 h.app.stop();
125 });
126 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs