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

Invites, part 1: an admin mints, lists and revokes single-use, expiring invite links through the API #2353

Merged opened this

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

Merged

scout/forge:scout/2335-invites-part-1-an-admin-mints-lists-and → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2335-invites-part-1-an-admin-mints-lists-and was merged into main as c8e94e9.

Review

Sign in to review this change.
6 files changed +271 −0
README.md +2
@@ -749,6 +749,8 @@ 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).
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.
753
752754 **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.
753755 **Sandbox.** An agent's shell is held to a policy — the network is the forge
754756 and the package registry, writes stay in the clone, the forge's data and the
src/actions/invites.ts added +39
@@ -0,0 +1,39 @@
1 /**
2 * An admin mints, lists and revokes invite links (#2335). Each mint and
3 * revoke is an event, which is the audit; the token is in none of them.
4 */
5
6 import type { App } from "../app.ts";
7 import { listInvites, mintInvite, parseDays, revokeInvite, type Invite } from "../core/invites.ts";
8 import { err, ok, type Result } from "../kernel/result.ts";
9 import type { User } from "../core/users.ts";
10
11 type Error = "not_found" | "invalid_days";
12
13 const admin = (actor: User | undefined): actor is User =>
14 actor !== undefined && actor.isAdmin && actor.kind === "user";
15
16 export function mint(app: App, actor: User | undefined, days: unknown): Result<{ invite: Invite; token: string }, Error> {
17 if (!admin(actor)) return err("not_found");
18 const n = parseDays(days);
19 if (!n.ok) return n;
20 const made = mintInvite(app.db, actor.id, n.value);
21 app.bus.append({
22 kind: "invite.minted",
23 actorId: actor.id,
24 payload: { id: made.invite.id, expiresAt: made.invite.expiresAt },
25 });
26 return ok(made);
27 }
28
29 export function list(app: App, actor: User | undefined): Result<Invite[], "not_found"> {
30 return admin(actor) ? ok(listInvites(app.db)) : err("not_found");
31 }
32
33 export function revoke(app: App, actor: User | undefined, inviteId: string): Result<Invite, "not_found"> {
34 if (!admin(actor)) return err("not_found");
35 const done = revokeInvite(app.db, inviteId);
36 if (!done.ok) return done;
37 app.bus.append({ kind: "invite.revoked", actorId: actor.id, payload: { id: inviteId } });
38 return done;
39 }
src/core/invites.ts added +87
@@ -0,0 +1,87 @@
1 /**
2 * Invite links: a secret handed out once, good for one signup, gone after a
3 * deadline. The database holds only the token's hash, so a list can never
4 * give a link back. Signup does not read these yet (#2335).
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { sha256 } from "../kernel/hash.ts";
9 import { id, secret } from "../kernel/id.ts";
10 import { err, ok, type Result } from "../kernel/result.ts";
11
12 export const DEFAULT_DAYS = 7;
13 export const MAX_DAYS = 30;
14 const DAY = 86_400_000;
15
16 export type InviteState = "open" | "used" | "revoked" | "expired";
17
18 export interface Invite {
19 readonly id: string;
20 readonly createdBy: string;
21 readonly createdAt: string;
22 readonly expiresAt: string;
23 readonly state: InviteState;
24 }
25
26 interface Row {
27 id: string;
28 created_by: string;
29 created_at: string;
30 expires_at: string;
31 revoked_at: string | null;
32 used_at: string | null;
33 }
34
35 const stateOf = (r: Row, now: number): InviteState =>
36 r.revoked_at !== null ? "revoked" : r.used_at !== null ? "used" : Date.parse(r.expires_at) <= now ? "expired" : "open";
37
38 const view = (r: Row, now: number): Invite => ({
39 id: r.id,
40 createdBy: r.created_by,
41 createdAt: r.created_at,
42 expiresAt: r.expires_at,
43 state: stateOf(r, now),
44 });
45
46 /** A whole number of days from 1 to 30, or the default when absent. */
47 export function parseDays(raw: unknown): Result<number, "invalid_days"> {
48 if (raw === undefined || raw === null) return ok(DEFAULT_DAYS);
49 return typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= MAX_DAYS ? ok(raw) : err("invalid_days");
50 }
51
52 /** The token is returned here and nowhere else. */
53 export function mintInvite(
54 db: Database,
55 createdBy: string,
56 days: number = DEFAULT_DAYS,
57 now: number = Date.now(),
58 ): { invite: Invite; token: string } {
59 const token = `invite_${secret()}`;
60 const inviteId = id(now);
61 db.query(
62 "INSERT INTO invite (id, token_hash, created_by, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)",
63 ).run(inviteId, sha256(token), createdBy, new Date(now).toISOString(), new Date(now + days * DAY).toISOString());
64 const row = db.query<Row, [string]>("SELECT * FROM invite WHERE id = ?1").get(inviteId)!;
65 return { invite: view(row, now), token };
66 }
67
68 /** Every invite, newest first, never with its token. */
69 export function listInvites(db: Database, now: number = Date.now()): Invite[] {
70 return db
71 .query<Row, []>("SELECT * FROM invite ORDER BY id DESC LIMIT 500")
72 .all()
73 .map((r) => view(r, now));
74 }
75
76 /** Revoke an open invite. A used, revoked or unknown one is `not_found`. */
77 export function revokeInvite(
78 db: Database,
79 inviteId: string,
80 now: number = Date.now(),
81 ): Result<Invite, "not_found"> {
82 const changed = db
83 .query("UPDATE invite SET revoked_at = ?2 WHERE id = ?1 AND revoked_at IS NULL AND used_at IS NULL")
84 .run(inviteId, new Date(now).toISOString()).changes;
85 if (changed === 0) return err("not_found");
86 return ok(view(db.query<Row, [string]>("SELECT * FROM invite WHERE id = ?1").get(inviteId)!, now));
87 }
src/core/migrations/20261016200000-invite.ts added +13
@@ -0,0 +1,13 @@
1 // A single-use, expiring invite link (#2335). Only the hash of the token is stored.
2 export default `
3 CREATE TABLE invite (
4 id TEXT PRIMARY KEY,
5 token_hash TEXT NOT NULL UNIQUE,
6 created_by TEXT NOT NULL,
7 created_at TEXT NOT NULL,
8 expires_at TEXT NOT NULL,
9 revoked_at TEXT,
10 used_at TEXT,
11 used_by TEXT
12 ) WITHOUT ROWID;
13 `;
src/http/api.ts +32
@@ -341,6 +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";
344345 import { originForRepo, type OriginRepoRow } from "../core/origins.ts";
345346 import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts";
346347 import type { Origin } from "../origins/types.ts";
@@ -1565,6 +1566,37 @@ export function apiRoutes(router: Router<Handler>): void {
15651566 );
15661567 });
15671568
1569 // Invite links (#2335): minted once, listed without tokens, revoked by id.
1570 router.add("POST", "/api/v1/admin/invites", async (ctx) => {
1571 const denied = accountAdmin(ctx, "admin");
1572 if (denied) return denied;
1573 const input = await body(ctx.request);
1574 const made = mintInviteLink(ctx.app, ctx.auth.user, input.days);
1575 if (!made.ok)
1576 return made.error === "invalid_days"
1577 ? apiError("invalid", 422, "days is a whole number from 1 to 30")
1578 : notFound();
1579 const origin = ctx.app.config.url ?? ctx.url.origin;

Minor: with no config.url, this origin comes from the client's Host header. That is fine for a link only the minting admin sees, but once signup redeems invites, build the link from configuration only.

1580 return json(
1581 { ...made.value.invite, token: made.value.token, link: `${origin.replace(/\/$/, "")}/signup?invite=${made.value.token}` },
1582 201,
1583 );
1584 });
1585
1586 router.add("GET", "/api/v1/admin/invites", (ctx) => {
1587 const denied = accountAdmin(ctx, "ro");
1588 if (denied) return denied;
1589 const listed = listInviteLinks(ctx.app, ctx.auth.user);
1590 return listed.ok ? json({ items: listed.value }) : notFound();
1591 });
1592
1593 router.add("DELETE", "/api/v1/admin/invites/:id", (ctx) => {
1594 const denied = accountAdmin(ctx, "admin");
1595 if (denied) return denied;
1596 const done = revokeInviteLink(ctx.app, ctx.auth.user, ctx.params.id!);
1597 return done.ok ? json(done.value) : notFound();
1598 });
1599
15681600 router.add("GET", "/api/v1/admin/accounts", (ctx) => {
15691601 const denied = accountAdmin(ctx, "ro");
15701602 if (denied) return denied;
test/invites.test.ts added +98
@@ -0,0 +1,98 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { signUp } from "../src/actions/users.ts";
7 import { listEvents } from "../src/core/events.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { listInvites, mintInvite, revokeInvite } from "../src/core/invites.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { proveAll } from "./helpers/verified.ts";
12
13 describe("invites: an admin mints, lists and revokes", () => {
14 let dir: string;
15 let app: App;
16 let servers: Servers;
17 let admin: string;
18 let guest: string;
19
20 const api = (path: string, token: string, init: RequestInit = {}) =>
21 fetch(`${servers.url}${path}`, {
22 ...init,
23 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
24 });
25
26 beforeAll(async () => {
27 dir = await mkdtemp(join(tmpdir(), "forge-invites-"));
28 app = createApp({ dataDir: dir, port: 0, url: "https://forge.example" });
29 servers = await serve(app);
30 const a = await signUp(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" });
31 const b = await signUp(app, { username: "ada", email: "[email protected]", password: "guest-passphrase" });
32 if (!a.ok || !b.ok) throw new Error("signup");
33 proveAll(app);
34 admin = createToken(app.db, a.value.id, "t", "admin").token;
35 guest = createToken(app.db, b.value.id, "t", "admin").token;
36 });
37 afterAll(async () => {
38 servers.stop();
39 app.close();
40 await rm(dir, { recursive: true, force: true });
41 });
42
43 test("mint, list, revoke; the token shows once and is never stored", async () => {
44 const res = await api("/api/v1/admin/invites", admin, { method: "POST", body: "{}" });
45 expect(res.status).toBe(201);
46 const made = (await res.json()) as { id: string; token: string; link: string; expiresAt: string; state: string };
47 expect(made.link).toBe(`https://forge.example/signup?invite=${made.token}`);
48 expect(made.state).toBe("open");
49 const days = (Date.parse(made.expiresAt) - Date.now()) / 86_400_000;
50 expect(days).toBeGreaterThan(6.99);
51 expect(days).toBeLessThan(7.01);
52
53 const rows = app.db.query("SELECT * FROM invite").all();
54 expect(JSON.stringify(rows)).not.toContain(made.token);
55
56 const listed = await (await api("/api/v1/admin/invites", admin)).json();
57 expect(JSON.stringify(listed)).not.toContain(made.token);
58 expect((listed as { items: { id: string }[] }).items.map((i) => i.id)).toContain(made.id);
59
60 const gone = await api(`/api/v1/admin/invites/${made.id}`, admin, { method: "DELETE" });
61 expect(gone.status).toBe(200);
62 expect(((await gone.json()) as { state: string }).state).toBe("revoked");
63 expect((await api(`/api/v1/admin/invites/${made.id}`, admin, { method: "DELETE" })).status).toBe(404);
64
65 const kinds = listEvents(app.db, {}).map((e) => e.kind);
66 expect(kinds).toContain("invite.minted");
67 expect(kinds).toContain("invite.revoked");
68 expect(JSON.stringify(listEvents(app.db, {}))).not.toContain(made.token);
69 });
70
71 test("days is bounded to 1..30", async () => {
72 const post = (days: unknown) =>
73 api("/api/v1/admin/invites", admin, { method: "POST", body: JSON.stringify({ days }) });
74 expect((await post(30)).status).toBe(201);
75 for (const bad of [0, 31, 1.5, "7", -1]) expect((await post(bad)).status).toBe(422);
76 });
77
78 test("a non-admin and a stranger are refused on all three routes", async () => {
79 const made = mintInvite(app.db, "x");
80 for (const token of [guest, "forge_nope"]) {
81 const post = await api("/api/v1/admin/invites", token, { method: "POST", body: "{}" });
82 expect([401, 404]).toContain(post.status);
83 expect([401, 404]).toContain((await api("/api/v1/admin/invites", token)).status);
84 expect([401, 404]).toContain(
85 (await api(`/api/v1/admin/invites/${made.invite.id}`, token, { method: "DELETE" })).status,
86 );
87 }
88 expect(app.db.query("SELECT revoked_at FROM invite WHERE id = ?1").get(made.invite.id)).toEqual({ revoked_at: null });
89 });
90
91 test("an expired invite reads as expired; a used one cannot be revoked", () => {
92 const past = mintInvite(app.db, "x", 1, Date.now() - 2 * 86_400_000);
93 expect(listInvites(app.db).find((i) => i.id === past.invite.id)?.state).toBe("expired");
94 const used = mintInvite(app.db, "x");
95 app.db.query("UPDATE invite SET used_at = ?2 WHERE id = ?1").run(used.invite.id, "2026-01-01T00:00:00Z");
96 expect(revokeInvite(app.db, used.invite.id).ok).toBe(false);
97 });
98 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs