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

The terminal says who may sign up — open, invite or closed — and a forge admin changes it after a plan, as MCP `signup` does #7292

Merged opened this

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

Merged

scout/forge:scout/7286-the-terminal-says-who-may-sign-up-open-i → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/7286-the-terminal-says-who-may-sign-up-open-i was merged into main as e988bf1.

Review

Sign in to review this change.
5 files changed +174 −5
README.md +4
@@ -132,6 +132,10 @@ refuses every form a browser posts to `localhost`: run it with
132132 `env -u FORGE_URL bun run src/main.ts`, or set `FORGE_URL` to wherever you
133133 will actually browse it.
134134
135 Who may sign up (open, invite or closed) is shown on the terminal's Invite
136 links screen, and a forge admin who is a person changes it there with `s`,
137 which shows from → to and changes it only after a confirm (as MCP `signup` does).
138
135139 No sign-up makes the admin, not even the forge's first, and that first one is
136140 held to the signup setting and Turnstile like any other. Accounts sign up with
137141 a passkey and an email address (there is no password signup), and the browser
docs/CREW.md +1 −1
@@ -305,7 +305,7 @@ one the provider rests hands its runs to the other until the reset.
305305
306306 **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.
307307
308 **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.
308 **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.
309309
310310 **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.
311311 **Sandbox.** An agent's shell is held to a policy — the network is the forge
docs/MCP.md +1
@@ -229,6 +229,7 @@ enforces it); `dryRun` reads `false to apply` on every tool that takes it. The
229229 longer sentences:
230230
231231 - `dryRun`: plans unless it is `false`; anything else changes nothing. `limb` applying takes an admin token.
232 - `signup`: the terminal's Invite links screen does the same: `s` picks a mode, shows the plan (from → to) and changes it after a confirm, for a forge admin who is a person.
232233 - `tell.from`: a path on this forge the issue links to, like `/alice/notes/pulls/3`. `tell.context`: where this was told from, for the builder.
233234 - `spawn.reveal`, `claim_link.reveal`: put the live claim link in the answer (default false); it is a secret, only when its person reads it. `spawn.fresh`: a fresh app beside the copied ones. `spawn.followMode`: `tell-me` (default) tells the owner in their inbox when this forge's code moves; `take-updates` opens a pull with the new code, and a person merges it. `spawn.size`: recorded only, not enforced. `spawn.keepAccess`: stay an owner once claimed.
234235 - `commits.ref`: branch, tag or commit; the default branch when absent. `report.page`: the http(s) address where the problem was seen. `plan.issues.pillar`: the pillar served, as `"P0"` or `"P0 Trust"`; `later: true` means no pillar and needs `why`.
src/tui/screens/invites.ts +54 −4
@@ -4,11 +4,15 @@
44 * shows them. `m` mints one from a number of days (1 to 30, 7 by default) and
55 * prints the link once, on this screen, until the next key of any kind; `x` revokes the
66 * selected open one. Both go through the actions, so the gate is theirs: an
7 * admin who is a person; anyone else sees nothing and mints nothing.
7 * admin who is a person; anyone else sees nothing and mints nothing. The top
8 * line says who may sign up now, as MCP `signup` does; `s` picks open, invite
9 * or closed, shows from → to and changes it only after a confirm.
810 */
911
1012 import { inviteLink, list, mint, revoke } from "../../actions/invites.ts";
1113 import { INVITE_NOT_OPEN } from "../../actions/sever_invite_words.ts";
14 import { changeSignup, readSignup } from "../../actions/signup.ts";
15 import { SIGNUP_MODES, type SignupMode } from "../../settings/signup.ts";
1216 import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts";
1317 import type { Ctx, Screen } from "../app/app.ts";
1418 import { binding } from "../app/keys.ts";
@@ -16,7 +20,7 @@ import { style } from "../term/style.ts";
1620 import { truncate } from "../term/width.ts";
1721 import type { Session } from "../session.ts";
1822 import { fill, len, split } from "../ui/layout.ts";
19 import { ListState, drawList, promptScreen, type ListRow } from "./common.ts";
23 import { ListState, confirmScreen, drawList, pickerScreen, promptScreen, type ListRow } from "./common.ts";
2024
2125 export function invitesScreen(f: Session, ctx: Ctx): Screen {
2226 const state = new ListState();
@@ -41,6 +45,27 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen {
4145 return undefined;
4246 };
4347
48 const signup = () => readSignup(f.app, f.user);
49
50 const plan = (to: SignupMode): void => {
51 const now = signup();
52 if (!now.ok || now.value.signup === to) return;
53 const from = now.value.signup;
54 ctx.push(
55 confirmScreen(ctx, screen, {
56 title: "Sign-ups",
57 body: [`Would set signup from ${from} to ${to}.`],
58 actions: [{ label: "Change" }, { label: "Cancel" }],
59 onChoose: (i) => {
60 if (i !== 0) return;
61 const changed = changeSignup(f.app, f.user, to);
62 note = changed.ok ? `signup: ${from} → ${changed.value}.` : "You cannot change sign-ups.";
63 ctx.invalidate();
64 },
65 }),
66 );
67 };
68
4469 const rows = (): ListRow[] =>
4570 items().map((i) => ({
4671 title: i.id,
@@ -50,11 +75,22 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen {
5075
5176 const screen: Screen = {
5277 title: "Invite links",
53 keys: [binding(["j", "k"], "move cursor"), binding(["m"], "make a link"), binding(["x"], "revoke")],
78 // A getter: the help reads it each time, and `s` is listed only when it works.
outdated

The keys list always includes s. Build it conditionally on signup().ok, so guests and agents are not offered a dead key.

79 get keys() {
80 return [
81 binding(["j", "k"], "move cursor"),
82 binding(["m"], "make a link"),
83 binding(["x"], "revoke"),
84 ...(signup().ok ? [binding(["s"], "who may sign up")] : []),
85 ];
86 },
5487 render(area, s) {
5588 const c = ctx.theme.c;
5689 const r = read();
57 const [head, body] = split(area, "v", [len(1), fill(1)]);
90 const sg = signup();
91 const [top, head, body] = split(area, "v", [len(sg.ok ? 1 : 0), len(1), fill(1)]);
92 if (sg.ok)
93 s.text(top!.x, top!.y, truncate(`signup: ${sg.value.signup}. s changes it.`, top!.w), style(c.fg, c.bg), top!.x + top!.w);
5894 const text =
5995 note !== ""
6096 ? note
@@ -85,6 +121,20 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen {
85121 if (row === undefined) return false;
86122 note = revoke(f.app, f.user, row.id).ok ? `Revoked ${row.id}.` : INVITE_NOT_OPEN;
87123 ctx.invalidate();
124 } else if (e.key === "s") {
125 const now = signup();
126 if (!now.ok) return false;
127 ctx.push(
128 pickerScreen(ctx, screen, {
129 title: "Who may sign up",
130 items: SIGNUP_MODES.map((m) => ({
131 id: m,
132 label: m,
133 ...(m === now.value.signup ? { hint: "now" } : {}),
134 })),
135 onPick: ([m]) => plan(m as SignupMode),
136 }),
137 );
88138 } else return false;
89139 return true;
90140 },
test/tui-signup.test.ts added +114
@@ -0,0 +1,114 @@
1 /**
2 * Who may sign up, in the terminal (#7292): the Invite links screen says it, `s`
outdated

This cites #7286; the pull is #7292.

3 * picks a mode, shows from → to and changes it only after a confirm; esc
4 * changes nothing; a non-admin and an agent are never offered it.
5 */
6 import { afterAll, beforeAll, 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 as Forge } from "../src/app.ts";
11 import { signupClosed } from "../src/actions/users.ts";
12 import { createUser, getUserByName } from "../src/core/users.ts";
13 import { readSignup } from "../src/actions/signup.ts";
14 import { Session } from "../src/tui/session.ts";
15 import { invitesScreen } from "../src/tui/screens/invites.ts";
16 import { getSignup, setSignup } from "../src/settings/signup.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import type { TuiIdentity } from "../src/tui/session.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp } from "./helpers/tui.ts";
22
23 quietLogs();
24
25 let dir: string;
26 let forge: Forge;
27
28 function boot(username: string) {
29 const user = getUserByName(forge.db, username)!;
30 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
31 const { app } = createForgeApp(forge, identity, { terminal: headlessTerminal(140, 32), exitHooks: false });
32 app.start();
33 const k = (key: string, ctrl = false) => app.dispatch({ type: "key", key, ctrl, alt: false, shift: false });
34 return {
35 text: () => app.screen.toText(),
36 press: async (...keys: string[]) => {
37 keys.forEach((x) => k(x));
38 await settleApp(app);
39 },
40 open: async () => {
41 k("k", true);
42 for (const ch of "Invite links") k(ch);
43 k("enter");
44 await settleApp(app);
45 },
46 stop: () => app.stop(),
47 };
48 }
49
50 beforeAll(async () => {
51 dir = await mkdtemp(join(tmpdir(), "forge-tui-signup-"));
52 forge = createApp({ dataDir: dir, port: 0 });
53 for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) {
54 const made = await createUser(forge.db, {
55 origin: "seed",
56 trusted: true,
57 username,
58 email: `${username}@forge.local`,
59 password: "a-good-passphrase",
60 adminIfFirst,
61 });
62 if (!made.ok) throw new Error(made.error);
63 }
64 forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run();
65 setSignup(forge.db, "open", getUserByName(forge.db, "alice")!.id);
66 });
67 afterAll(async () => {
68 forge.close();
69 await rm(dir, { recursive: true, force: true });
70 });
71
72 test("reads open, esc changes nothing, a confirm closes the door", async () => {
73 const h = boot("alice");
74 await h.open();
75 expect(h.text()).toContain("signup: open");
76 // picker, closed, then esc on the plan
77 await h.press("s", "j", "j", "enter");
78 expect(h.text()).toContain("from open to closed");
79 await h.press("esc");
80 expect(getSignup(forge.db)).toBe("open");
81 // esc on the picker too
82 await h.press("s", "esc");
83 expect(getSignup(forge.db)).toBe("open");
84 await h.press("s", "j", "j", "enter", "enter");
85 expect(getSignup(forge.db)).toBe("closed");
86 expect(signupClosed({ db: forge.db } as never)).toBe(true);
87 expect(h.text()).toContain("signup: closed");
88 // what MCP `signup` and the admin settings read sees it, with the change kept
89 // (setSignup emits no event; it writes forge_setting_change)
90 const alice = getUserByName(forge.db, "alice")!;
91 const seen = readSignup(forge as never, alice);
92 expect(seen.ok && seen.value.signup).toBe("closed");
93 expect(seen.ok && seen.value.changes[0]).toMatchObject({ from: "open", to: "closed" });
94 h.stop();
95 setSignup(forge.db, "open", getUserByName(forge.db, "alice")!.id);
96 });
97
98 test("a non-admin and an admin agent are never offered s", async () => {
99 for (const who of ["bob", "carol"]) {
100 const h = boot(who);
101 await h.open();
102 expect(h.text()).not.toContain("signup:");
103 await h.press("s", "j", "j", "enter", "enter");
104 expect(getSignup(forge.db)).toBe("open");
105 h.stop();
106 const user = getUserByName(forge.db, who)!;
107 const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user });
108 const screen = invitesScreen(session, { push() {}, invalidate() {} } as never);
109 expect(screen.keys.flatMap((b) => b.keys ?? [])).not.toContain("s");
110 }
111 const admin = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "alice")! });
112 const own = invitesScreen(admin, { push() {}, invalidate() {} } as never);
113 expect(JSON.stringify(own.keys)).toContain('"s"');
114 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs