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

Invites, part 4: an MCP tool mints an invite link for an admin #2361

Merged opened this

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

Merged

scout/forge:scout/2338-invites-part-4-an-mcp-tool-mints-an-invi → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2338-invites-part-4-an-mcp-tool-mints-an-invi was merged into main as 5849420.

Review

Sign in to review this change.
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.
749749
750750 **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).
751751
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.
753753
754754 **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.
755755 **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";
1313 const admin = (actor: User | undefined): actor is User =>
1414 actor !== undefined && actor.isAdmin && actor.kind === "user";
1515
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
1620 export function mint(app: App, actor: User | undefined, days: unknown): Result<{ invite: Invite; token: string }, Error> {
1721 if (!admin(actor)) return err("not_found");
1822 const n = parseDays(days);
src/http/api.ts +2 −2
@@ -341,7 +341,7 @@ import { readRunLog, subscribeRunLog } from "../runs/log.ts";
341341 import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts";
342342 import { repoDir } from "../git/repos.ts";
343343 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";
345345 import { originForRepo, type OriginRepoRow } from "../core/origins.ts";
346346 import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts";
347347 import type { Origin } from "../origins/types.ts";
@@ -1578,7 +1578,7 @@ export function apiRoutes(router: Router<Handler>): void {
15781578 : notFound();
15791579 const origin = ctx.app.config.url ?? ctx.url.origin;
15801580 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) },
15821582 201,
15831583 );
15841584 });
src/http/mcp.ts +22
@@ -62,6 +62,7 @@ import {
6262 cardFor,
6363 } from "../web/studio/cards.ts";
6464 import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts";
65 import { inviteLink, mint as mintInviteLink } from "../actions/invites.ts";
6566 import { setRole } from "../actions/roles.ts";
6667 import { ABILITIES, roleOf, rowOf } from "../core/roles.ts";
6768 import { keepThought } from "../actions/thought.ts";
@@ -1015,6 +1016,27 @@ const TOOLS: readonly Tool[] = [
10151016 return good({ username: user.username, role, can: rowOf(role), abilities: ABILITIES });
10161017 },
10171018 },
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");

Fine as it is. Once mayBoards and the scope check pass, invalid_days is the only error mint can return, so mapping every failure to invalid is correct today. If mint ever gains another error, map it explicitly here.

1036 const origin = ctx.app.config.url ?? ctx.url.origin;
1037 return good({ ...made.value.invite, link: inviteLink(origin, made.value.token) });
1038 },
1039 },
10181040 {
10191041 name: "account_role",
10201042 description:
test/mcp.test.ts +15
@@ -183,6 +183,7 @@ describe("the forge over MCP", () => {
183183 "audit",
184184 "model_grant",
185185 "whoami_can",
186 "mint_invite",
186187 "account_role",
187188 "model_ceiling",
188189 "outsiders",
@@ -946,6 +947,20 @@ describe("the forge over MCP", () => {
946947 expect(admin.can.administer).toBe(true);
947948 });
948949
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
949964 test("model_ceiling: dry by default, an admin person with an admin token sets and clears it", async () => {
950965 const admin = createToken(app.db, alice.id, "admin", "admin").token;
951966 const readOnly = createToken(app.db, alice.id, "ro", "ro").token;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs