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

Invites, part 2a: with signup = invite, /signup and POST /api/v1/users make an account only through a valid invite link #2562

Merged opened this

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

Merged

scout/forge:scout/2417-invites-part-2a-with-signup-invite-signu → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2417-invites-part-2a-with-signup-invite-signu was merged into main as 7acb9ba.

Review

Sign in to review this change.
4 files changed +23 −1
README.md +1 −1
@@ -885,7 +885,7 @@ one the provider rests hands its runs to the other until the reset.
885885
886886 **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` 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).
887887
888 **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`). 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. 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. 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.
888 **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. 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. 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.
889889
890890 **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.
891891 **Sandbox.** An agent's shell is held to a policy — the network is the forge
src/actions/users.ts +10
@@ -21,6 +21,7 @@ import {
2121 deleteToken as revokeToken,
2222 type TokenScope,
2323 } from "../core/tokens.ts";
24 import { inviteSpentBy } from "../core/invites.ts";
2425 import {
2526 changePassword as rehash,
2627 cleanDisplayName,
@@ -122,6 +123,7 @@ export async function signUp(
122123 ...(from.address === undefined ? {} : { address: from.address }),
123124 },
124125 });
126 auditInvite(app, created.value.id);
125127 // The address is proven by the link it is sent; until then nothing it
126128 // does may create or cost.
127129 await welcome(app, created.value);
@@ -178,10 +180,18 @@ export async function signUpWithPasskey(
178180 ...(from.address === undefined ? {} : { address: from.address }),
179181 },
180182 });
183 auditInvite(app, created.value.id);
181184 await welcome(app, created.value);

The passkey door also calls auditInvite, but test/invite-redeem.test.ts only asserts the password path. Add a passkey assertion, or this line can be deleted without a test failing.

182185 return created;
183186 }
184187
188 /** Using an invite link is an event: the invite's id and who it let in, never its token. */
189 function auditInvite(app: App, userId: string): void {
190 const inviteId = inviteSpentBy(app.db, userId);
191 if (inviteId !== undefined)
192 app.bus.append({ kind: "invite.used", actorId: userId, payload: { id: inviteId } });
193 }
194
185195 /**
186196 * Mint a personal access token. Callers decide what credential may mint one
187197 * (a session or the password, never another token — no privilege
src/core/invites.ts +5
@@ -120,3 +120,8 @@ export function revokeInvite(
120120 if (changed === 0) return err("not_found");
121121 return ok(view(db.query<Row, [string]>("SELECT * FROM invite WHERE id = ?1").get(inviteId)!, now));
122122 }
123
124 /** The id of the invite an account spent, if one let it in. */
125 export function inviteSpentBy(db: Database, userId: string): string | undefined {
126 return db.query<{ id: string }, [string]>("SELECT id FROM invite WHERE used_by = ?1").get(userId)?.id;

used_by has no index, so this is a table scan per signup. Fine at invite volumes; add an index if invites ever grow large. Alternatively have the redeem step return the invite id so no second lookup is needed.

127 }
test/invite-redeem.test.ts +7
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { listInvites, mintInvite, revokeInvite } from "../src/core/invites.ts";
13 import { listEvents } from "../src/core/events.ts";
1314 import { getUserByName } from "../src/core/users.ts";
1415 import { serve, type Servers } from "../src/http/server.ts";
1516 import { setLimit } from "../src/settings/limits.ts";
@@ -79,6 +80,9 @@ describe("invite links redeem at signup", () => {
7980 expect(stateOf(invite.id)).toBe("used");
8081 const row = app.db.query<{ used_by: string | null }, [string]>("SELECT used_by FROM invite WHERE id = ?1").get(invite.id);
8182 expect(row?.used_by).toBe(getUserByName(app.db, "ivy")?.id ?? "none");
83 const used = listEvents(app.db, {}).filter((e) => e.kind === "invite.used");
84 expect(JSON.stringify(used)).toContain(invite.id);
85 expect(JSON.stringify(used)).not.toContain(token);
8286 expect((await form("ivy2", token)).status).toBe(403);
8387 expect(getUserByName(app.db, "ivy2")).toBeUndefined();
8488 });
@@ -105,6 +109,9 @@ describe("invite links redeem at signup", () => {
105109 return (await post("/signup/passkey", { username, email: `${username}@f.dev`, challenge, ...(await d.create(challenge)) })).status;
106110 };
107111 expect(await keyed("pia")).toBe(201);
112 const used = listEvents(app.db, {}).filter((e) => e.kind === "invite.used" && JSON.stringify(e).includes(invite.id));
113 expect(used.length).toBe(1);
114 expect(JSON.stringify(used)).not.toContain(token);
108115 expect(stateOf(invite.id)).toBe("used");
109116 expect(await keyed("pia2")).toBe(403);
110117 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs