Public: anyone can read this; the crew can change it.
Invites, part 4: an MCP tool mints an invite link for an admin #2361
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2338-invites-part-4-an-mcp-tool-mints-an-invi → main 1 commit fork 1 thread · 1 unresolved
5 files
changed +44
−3
README.md +1 −1
| @@ -749,7 +749,7 @@ one the provider rests hands its runs to the other until the reset. | ||
| 749 | 749 | |
| 750 | 750 | **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. For now roles are shown and recorded; nothing is refused because of one. 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` 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; in the terminal it is the **What I can do** command (read only, agents included). |
| 751 | 751 | |
| 752 | **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 and revoking are events (`invite.minted`, `invite.revoked`). Signup does not read invites yet. | |
| 752 | **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 and revoking are events (`invite.minted`, `invite.revoked`). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link. Signup does not read invites yet. | |
| 753 | 753 | |
| 754 | 754 | **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. |
| 755 | 755 | **Sandbox.** An agent's shell is held to a policy — the network is the forge |
src/actions/invites.ts +4
| @@ -13,6 +13,10 @@ type Error = "not_found" | "invalid_days"; | ||
| 13 | 13 | const admin = (actor: User | undefined): actor is User => |
| 14 | 14 | actor !== undefined && actor.isAdmin && actor.kind === "user"; |
| 15 | 15 | |
| 16 | /** The link an admin hands over: the signup page with the token in it. */ | |
| 17 | export const inviteLink = (origin: string, token: string): string => | |
| 18 | `${origin.replace(/\/$/, "")}/signup?invite=${token}`; | |
| 19 | ||
| 16 | 20 | export function mint(app: App, actor: User | undefined, days: unknown): Result<{ invite: Invite; token: string }, Error> { |
| 17 | 21 | if (!admin(actor)) return err("not_found"); |
| 18 | 22 | const n = parseDays(days); |
src/http/api.ts +2 −2
| @@ -341,7 +341,7 @@ import { readRunLog, subscribeRunLog } from "../runs/log.ts"; | ||
| 341 | 341 | import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; |
| 342 | 342 | import { repoDir } from "../git/repos.ts"; |
| 343 | 343 | import type { Result } from "../kernel/result.ts"; |
| 344 | import { list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../actions/invites.ts"; | |
| 344 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../actions/invites.ts"; | |
| 345 | 345 | import { originForRepo, type OriginRepoRow } from "../core/origins.ts"; |
| 346 | 346 | import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts"; |
| 347 | 347 | import type { Origin } from "../origins/types.ts"; |
| @@ -1578,7 +1578,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1578 | 1578 | : notFound(); |
| 1579 | 1579 | const origin = ctx.app.config.url ?? ctx.url.origin; |
| 1580 | 1580 | return json( |
| 1581 | { ...made.value.invite, token: made.value.token, link: `${origin.replace(/\/$/, "")}/signup?invite=${made.value.token}` }, | |
| 1581 | { ...made.value.invite, token: made.value.token, link: inviteLink(origin, made.value.token) }, | |
| 1582 | 1582 | 201, |
| 1583 | 1583 | ); |
| 1584 | 1584 | }); |
src/http/mcp.ts +22
| @@ -62,6 +62,7 @@ import { | ||
| 62 | 62 | cardFor, |
| 63 | 63 | } from "../web/studio/cards.ts"; |
| 64 | 64 | import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts"; |
| 65 | import { inviteLink, mint as mintInviteLink } from "../actions/invites.ts"; | |
| 65 | 66 | import { setRole } from "../actions/roles.ts"; |
| 66 | 67 | import { ABILITIES, roleOf, rowOf } from "../core/roles.ts"; |
| 67 | 68 | import { keepThought } from "../actions/thought.ts"; |
| @@ -1015,6 +1016,27 @@ const TOOLS: readonly Tool[] = [ | ||
| 1015 | 1016 | return good({ username: user.username, role, can: rowOf(role), abilities: ABILITIES }); |
| 1016 | 1017 | }, |
| 1017 | 1018 | }, |
| 1019 | { | |
| 1020 | name: "mint_invite", | |
| 1021 | description: | |
| 1022 | "Mint a single-use invite link for someone to sign up with: it expires in `days` (1 to 30, 7 by default) and the link, shown once, is in the answer. A forge admin who is a person, with an admin token, mints it. Each mint is recorded in the audit; the token is not.", | |
| 1023 | inputSchema: { | |
| 1024 | type: "object", | |
| 1025 | properties: { | |
| 1026 | days: { type: "integer", minimum: 1, maximum: 30, description: "Days the link lasts; 7 by default." }, | |
| 1027 | }, | |
| 1028 | }, | |
| 1029 | writes: true, | |
| 1030 | run(ctx, user, args) { | |
| 1031 | if (!mayBoards(user)) return bad("forbidden", "an invite is a forge admin's to mint"); | |
| 1032 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 1033 | return bad("forbidden", "an invite needs an admin token"); | |
| 1034 | const made = mintInviteLink(ctx.app, user, args["days"]); | |
| 1035 | if (!made.ok) return bad("invalid", "days is a whole number from 1 to 30"); | |
| 1036 | const origin = ctx.app.config.url ?? ctx.url.origin; | |
| 1037 | return good({ ...made.value.invite, link: inviteLink(origin, made.value.token) }); | |
| 1038 | }, | |
| 1039 | }, | |
| 1018 | 1040 | { |
| 1019 | 1041 | name: "account_role", |
| 1020 | 1042 | description: |
test/mcp.test.ts +15
| @@ -183,6 +183,7 @@ describe("the forge over MCP", () => { | ||
| 183 | 183 | "audit", |
| 184 | 184 | "model_grant", |
| 185 | 185 | "whoami_can", |
| 186 | "mint_invite", | |
| 186 | 187 | "account_role", |
| 187 | 188 | "model_ceiling", |
| 188 | 189 | "outsiders", |
| @@ -946,6 +947,20 @@ describe("the forge over MCP", () => { | ||
| 946 | 947 | expect(admin.can.administer).toBe(true); |
| 947 | 948 | }); |
| 948 | 949 | |
| 950 | test("mint_invite: an admin person with an admin token gets a link; anyone else is refused", async () => { | |
| 951 | const admin = createToken(app.db, alice.id, "admin", "admin").token; | |
| 952 | expect((await tool(bobRw, "mint_invite")).error).toContain("forbidden"); | |
| 953 | expect((await tool(rw, "mint_invite")).error).toContain("admin token"); | |
| 954 | expect((await tool(admin, "mint_invite", { days: 0 })).error).toContain("invalid"); | |
| 955 | const made = (await tool(admin, "mint_invite", { days: 3 })).value; | |
| 956 | expect(made.link).toMatch(/\/signup\?invite=[\w-]+$/); | |
| 957 | const token = made.link.split("invite=")[1]; | |
| 958 | expect(made.token).toBeUndefined(); | |
| 959 | expect(token.length).toBeGreaterThan(20); | |
| 960 | const listed = await fetch(`${base}/api/v1/admin/invites`, { headers: { authorization: `Bearer ${admin}` } }); | |
| 961 | expect(JSON.stringify(await listed.json())).toContain(made.id); | |
| 962 | }); | |
| 963 | ||
| 949 | 964 | test("model_ceiling: dry by default, an admin person with an admin token sets and clears it", async () => { |
| 950 | 965 | const admin = createToken(app.db, alice.id, "admin", "admin").token; |
| 951 | 966 | const readOnly = createToken(app.db, alice.id, "ro", "ro").token; |
new above
offline
Fine as it is. Once
mayBoardsand the scope check pass,invalid_daysis the only errormintcan return, so mapping every failure toinvalidis correct today. Ifmintever gains another error, map it explicitly here.