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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7286-the-terminal-says-who-may-sign-up-open-i → main 4 commits fork 2 threads · 2 unresolved
5 files
changed +174
−5
README.md +4
| @@ -132,6 +132,10 @@ refuses every form a browser posts to `localhost`: run it with | ||
| 132 | 132 | `env -u FORGE_URL bun run src/main.ts`, or set `FORGE_URL` to wherever you |
| 133 | 133 | will actually browse it. |
| 134 | 134 | |
| 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 | ||
| 135 | 139 | No sign-up makes the admin, not even the forge's first, and that first one is |
| 136 | 140 | held to the signup setting and Turnstile like any other. Accounts sign up with |
| 137 | 141 | 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. | ||
| 305 | 305 | |
| 306 | 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 | 307 | |
| 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. | |
| 309 | 309 | |
| 310 | 310 | **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. |
| 311 | 311 | **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 | ||
| 229 | 229 | longer sentences: |
| 230 | 230 | |
| 231 | 231 | - `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. | |
| 232 | 233 | - `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. |
| 233 | 234 | - `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. |
| 234 | 235 | - `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 @@ | ||
| 4 | 4 | * shows them. `m` mints one from a number of days (1 to 30, 7 by default) and |
| 5 | 5 | * prints the link once, on this screen, until the next key of any kind; `x` revokes the |
| 6 | 6 | * 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. | |
| 8 | 10 | */ |
| 9 | 11 | |
| 10 | 12 | import { inviteLink, list, mint, revoke } from "../../actions/invites.ts"; |
| 11 | 13 | 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"; | |
| 12 | 16 | import { DEFAULT_DAYS, MAX_DAYS } from "../../core/invites.ts"; |
| 13 | 17 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 18 | import { binding } from "../app/keys.ts"; |
| @@ -16,7 +20,7 @@ import { style } from "../term/style.ts"; | ||
| 16 | 20 | import { truncate } from "../term/width.ts"; |
| 17 | 21 | import type { Session } from "../session.ts"; |
| 18 | 22 | 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"; | |
| 20 | 24 | |
| 21 | 25 | export function invitesScreen(f: Session, ctx: Ctx): Screen { |
| 22 | 26 | const state = new ListState(); |
| @@ -41,6 +45,27 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen { | ||
| 41 | 45 | return undefined; |
| 42 | 46 | }; |
| 43 | 47 | |
| 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 | ||
| 44 | 69 | const rows = (): ListRow[] => |
| 45 | 70 | items().map((i) => ({ |
| 46 | 71 | title: i.id, |
| @@ -50,11 +75,22 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen { | ||
| 50 | 75 | |
| 51 | 76 | const screen: Screen = { |
| 52 | 77 | 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. | |
| 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 | }, | |
| 54 | 87 | render(area, s) { |
| 55 | 88 | const c = ctx.theme.c; |
| 56 | 89 | 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); | |
| 58 | 94 | const text = |
| 59 | 95 | note !== "" |
| 60 | 96 | ? note |
| @@ -85,6 +121,20 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen { | ||
| 85 | 121 | if (row === undefined) return false; |
| 86 | 122 | note = revoke(f.app, f.user, row.id).ok ? `Revoked ${row.id}.` : INVITE_NOT_OPEN; |
| 87 | 123 | 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 | ); | |
| 88 | 138 | } else return false; |
| 89 | 139 | return true; |
| 90 | 140 | }, |
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 | }); |
new above
offline
The keys list always includes
s. Build it conditionally onsignup().ok, so guests and agents are not offered a dead key.