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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2417-invites-part-2a-with-signup-invite-signu → main 2 commits fork 2 threads · 2 unresolved
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. | ||
| 885 | 885 | |
| 886 | 886 | **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). |
| 887 | 887 | |
| 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. | |
| 889 | 889 | |
| 890 | 890 | **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. |
| 891 | 891 | **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 { | ||
| 21 | 21 | deleteToken as revokeToken, |
| 22 | 22 | type TokenScope, |
| 23 | 23 | } from "../core/tokens.ts"; |
| 24 | import { inviteSpentBy } from "../core/invites.ts"; | |
| 24 | 25 | import { |
| 25 | 26 | changePassword as rehash, |
| 26 | 27 | cleanDisplayName, |
| @@ -122,6 +123,7 @@ export async function signUp( | ||
| 122 | 123 | ...(from.address === undefined ? {} : { address: from.address }), |
| 123 | 124 | }, |
| 124 | 125 | }); |
| 126 | auditInvite(app, created.value.id); | |
| 125 | 127 | // The address is proven by the link it is sent; until then nothing it |
| 126 | 128 | // does may create or cost. |
| 127 | 129 | await welcome(app, created.value); |
| @@ -178,10 +180,18 @@ export async function signUpWithPasskey( | ||
| 178 | 180 | ...(from.address === undefined ? {} : { address: from.address }), |
| 179 | 181 | }, |
| 180 | 182 | }); |
| 183 | auditInvite(app, created.value.id); | |
| 181 | 184 | await welcome(app, created.value); |
| 182 | 185 | return created; |
| 183 | 186 | } |
| 184 | 187 | |
| 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 | ||
| 185 | 195 | /** |
| 186 | 196 | * Mint a personal access token. Callers decide what credential may mint one |
| 187 | 197 | * (a session or the password, never another token — no privilege |
src/core/invites.ts +5
| @@ -120,3 +120,8 @@ export function revokeInvite( | ||
| 120 | 120 | if (changed === 0) return err("not_found"); |
| 121 | 121 | return ok(view(db.query<Row, [string]>("SELECT * FROM invite WHERE id = ?1").get(inviteId)!, now)); |
| 122 | 122 | } |
| 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; | |
|
||
| 127 | } |
test/invite-redeem.test.ts +7
| @@ -10,6 +10,7 @@ import { tmpdir } from "node:os"; | ||
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { listInvites, mintInvite, revokeInvite } from "../src/core/invites.ts"; |
| 13 | import { listEvents } from "../src/core/events.ts"; | |
| 13 | 14 | import { getUserByName } from "../src/core/users.ts"; |
| 14 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 15 | 16 | import { setLimit } from "../src/settings/limits.ts"; |
| @@ -79,6 +80,9 @@ describe("invite links redeem at signup", () => { | ||
| 79 | 80 | expect(stateOf(invite.id)).toBe("used"); |
| 80 | 81 | const row = app.db.query<{ used_by: string | null }, [string]>("SELECT used_by FROM invite WHERE id = ?1").get(invite.id); |
| 81 | 82 | 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); | |
| 82 | 86 | expect((await form("ivy2", token)).status).toBe(403); |
| 83 | 87 | expect(getUserByName(app.db, "ivy2")).toBeUndefined(); |
| 84 | 88 | }); |
| @@ -105,6 +109,9 @@ describe("invite links redeem at signup", () => { | ||
| 105 | 109 | return (await post("/signup/passkey", { username, email: `${username}@f.dev`, challenge, ...(await d.create(challenge)) })).status; |
| 106 | 110 | }; |
| 107 | 111 | 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); | |
| 108 | 115 | expect(stateOf(invite.id)).toBe("used"); |
| 109 | 116 | expect(await keyed("pia2")).toBe(403); |
| 110 | 117 | }); |
new above
offline
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.