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

Roles, part 1: an account is a guest, a member or an admin, shown in one table, and the owner promotes guest to member in one action #2307

Merged opened this

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

Merged

scout/forge:scout/2301-roles-part-1-an-account-is-a-guest-a-mem → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/2301-roles-part-1-an-account-is-a-guest-a-mem was merged into main as e5e83b1.

Review

Sign in to review this change.
14 files changed +634 −0
README.md +2
@@ -733,6 +733,8 @@ the work repositories and nothing else. Two on one grant take turns, and
733733 one the provider rests hands its runs to the other until the reset.
734734 **Model budget.** A new account has none: its repositories' crew does not run, spends nothing, and says "This repository has no model account". A person adds their own subscription at Settings → Model (or the terminal's **Model budget** command, `a`); it is sealed, granted to their own repositories only, and one per account. A forge admin who is a person gives an account a capped grant, in dollars a month spent on the forge's own credential, from the account's page, the terminal's **Model grant** command, `POST /api/v1/admin/accounts/:name/model-grant` (`{"usdPerMonth": 20}`, `null` takes it back) or the MCP `model_grant` tool; each change is in the audit. The cap is the UTC month's spend in the usage ledger, checked when a run starts and on every spend tick: a run past it stops, and its page says the grant is spent. Two runs that start together each begin with the whole remainder; the tick check ends both once the sum crosses the cap, so the overshoot is at most what they spend between ticks.
735735
736 **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).
737
736738 **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.
737739 **Sandbox.** An agent's shell is held to a policy — the network is the forge
738740 and the package registry, writes stay in the clone, the forge's data and the
docs/ROLES.md added +24
@@ -0,0 +1,24 @@
1 # Roles
2
3 An account is a **guest**, a **member** or an **admin**. Admin is the
4 `is_admin` flag; a member is a row in the `member` table; everyone else is a
5 guest. `src/core/roles.ts` holds the one table below, and `test/roles.test.ts`
6 fails if this copy differs from it.
7
8 Part 1 (#2301) shows and records roles. Nothing is refused because of a role
9 yet: the table says what each role is meant to do once later parts enforce it.
10
11 On `/settings/people` an admin promotes a guest to member, with a starter
12 model allowance (dollars a month, default none, through the grant of #1145),
13 or demotes a member to guest. Demoting leaves any allowance as it is; take it
14 back on the account's page.
15
16 <!-- roles-table -->
17 | Ability | guest | member | admin |
18 | --- | --- | --- | --- |
19 | Read what is public | yes | yes | yes |
20 | Comment and open issues | yes | yes | yes |
21 | Make an app their own | no | yes | yes |
22 | Run a crew on a model allowance | no | yes | yes |
23 | Administer the forge | no | no | yes |
24 <!-- /roles-table -->
src/actions/roles.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * Promote a guest to member, or demote a member, with a starter model
3 * allowance on the way up. A forge admin who is a person only; the change is
4 * an event on the outbox. Records the role; refuses nothing because of it.
5 */
6
7 import type { App } from "../app.ts";
8 import { setModelGrant, validGrant } from "../core/model_grant.ts";
9 import { roleOf, setMember } from "../core/roles.ts";
10 import { getUserByName, type User } from "../core/users.ts";
11 import { err, ok, type Result } from "../kernel/result.ts";
12 import { requireUser } from "./access.ts";
13
14 export type RoleError = "not_found" | "not_allowed" | "invalid_amount";
15
16 export function setRole(
17 app: App,
18 actor: User | undefined,
19 username: string,
20 role: "member" | "guest",
21 usd: number | null = null,
22 ): Result<{ username: string; role: "member" | "guest"; usdPerMonth: number | null }, RoleError> {
23 const admin = requireUser(actor);
24 if (!admin.ok || !admin.value.isAdmin || admin.value.kind !== "user") return err("not_found");
25 const user = getUserByName(app.db, username);
26 if (user === undefined || user.kind === "org") return err("not_found");
27 if (user.kind === "agent" || user.isAdmin) return err("not_allowed");
28 if (role === "member" && usd !== null && !validGrant(usd)) return err("invalid_amount");
29 const was = roleOf(app.db, user);
30 const grant = role === "member" && usd !== null ? Math.round(usd * 100) / 100 : null;
31 app.db.transaction(() => {
32 setMember(app.db, user.id, role === "member", admin.value.id, new Date());
33 if (grant !== null && was === "guest") setModelGrant(app.db, user.id, grant, admin.value.id, new Date());

This bypasses setAccountGrant, so no account.model_granted event is appended, and the grant audit misses every allowance given on promotion. Emit that event here (with the rounded amount) and add a test for it.

34 })();
35 const usdPerMonth = was === "guest" ? grant : null;
36 // The grant is audited as `setAccountGrant` audits it.
37 if (usdPerMonth !== null)
38 app.bus.append({
39 kind: "account.model_granted",
40 actorId: admin.value.id,
41 payload: { username: user.username, usdPerMonth },
42 });
43 if (was !== role)
44 app.bus.append({
45 kind: "account.role_set",
46 actorId: admin.value.id,
47 payload: { username: user.username, role, usdPerMonth },
48 });
49 return ok({ username: user.username, role, usdPerMonth });
50 }
src/core/migrations/20261016190000-member.ts added +9
@@ -0,0 +1,9 @@
1 // An account is a guest, a member or an admin (#2301). Admin is `user.is_admin`;
2 // a member is a row here; everyone else is a guest.
3 export default `
4 CREATE TABLE member (
5 user_id TEXT PRIMARY KEY REFERENCES user(id) ON DELETE CASCADE,
6 promoted_by TEXT,
7 promoted_at TEXT NOT NULL
8 ) WITHOUT ROWID;
9 `;
src/core/roles.ts added +82
@@ -0,0 +1,82 @@
1 /**
2 * Roles: an account is a guest, a member or an admin. Admin is the `is_admin`
3 * flag, a member is a row in `member`, everyone else is a guest. `CAN` is the
4 * one table of what each role may do; `docs/ROLES.md` repeats it and a test
5 * fails when they differ. Part 1 records and shows roles — nothing is
6 * refused because of one yet. Pure data; who may promote is the action's.
7 */
8
9 import type { Database } from "bun:sqlite";
10
11 export const ROLES = ["guest", "member", "admin"] as const;
12 export type Role = (typeof ROLES)[number];
13
14 export const ABILITIES = {
15 read: "Read what is public",
16 comment: "Comment and open issues",
17 make_it_yours: "Make an app their own",
18 run_crew: "Run a crew on a model allowance",
19 administer: "Administer the forge",
20 } as const;
21 export type Ability = keyof typeof ABILITIES;
22
23 /** What each role can do. */
24 export const CAN: Readonly<Record<Role, readonly Ability[]>> = {
25 guest: ["read", "comment"],
26 member: ["read", "comment", "make_it_yours", "run_crew"],
27 admin: ["read", "comment", "make_it_yours", "run_crew", "administer"],
28 };
29
30 export const can = (role: Role, ability: Ability): boolean => CAN[role].includes(ability);
31
32 /** The table as `docs/ROLES.md` prints it. */
33 export function rolesTable(): string {
34 const rows = (Object.keys(ABILITIES) as Ability[]).map(
35 (a) => `| ${ABILITIES[a]} | ${ROLES.map((r) => (can(r, a) ? "yes" : "no")).join(" | ")} |`,
36 );
37 return [`| Ability | ${ROLES.join(" | ")} |`, `| --- | ${ROLES.map(() => "---").join(" | ")} |`, ...rows].join("\n");
38 }
39
40 export function roleOf(db: Database, user: { id: string; isAdmin: boolean }): Role {
41 if (user.isAdmin) return "admin";
42 const row = db.query<{ n: number }, [string]>("SELECT 1 AS n FROM member WHERE user_id = ?1").get(user.id);
43 return row ? "member" : "guest";
44 }
45
46 /** Make an account a member (true) or a guest again (false). */
47 export function setMember(db: Database, userId: string, member: boolean, by: string, now: Date): void {
48 if (!member) {
49 db.query("DELETE FROM member WHERE user_id = ?1").run(userId);
50 return;
51 }
52 db.query("INSERT OR IGNORE INTO member (user_id, promoted_by, promoted_at) VALUES (?1, ?2, ?3)").run(
53 userId,
54 by,
55 now.toISOString(),
56 );
57 }
58
59 export interface Person {
60 readonly username: string;
61 readonly role: Role;
62 readonly usdPerMonth: number | null;
63 }
64
65 /** Every person (not an agent, not an organization) with role and model grant, newest first, in one query. */
66 export function listPeople(db: Database): Person[] {
67 return db
68 .query<{ username: string; is_admin: number; member: number | null; usd: number | null }, []>(
69 `SELECT u.username, u.is_admin, m.user_id AS member, g.usd_per_month AS usd
70 FROM user u
71 LEFT JOIN member m ON m.user_id = u.id
72 LEFT JOIN model_grant g ON g.user_id = u.id
73 WHERE u.kind = 'user' AND u.is_agent = 0 AND u.removed_at IS NULL
74 ORDER BY u.created_at DESC, u.username`,
75 )
76 .all()
77 .map((r) => ({
78 username: r.username,
79 role: r.is_admin === 1 ? "admin" : r.member !== null ? "member" : "guest",
80 usdPerMonth: r.usd,
81 }));
82 }
src/http/api.ts +26
@@ -8,6 +8,8 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { setRole } from "../actions/roles.ts";
12 import { listPeople } from "../core/roles.ts";
1113 import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts";
1214 import { storageLimitMessage } from "../core/storage.ts";
1315 import { voteVariant } from "../actions/variants.ts";
@@ -1609,6 +1611,30 @@ export function apiRoutes(router: Router<Handler>): void {
16091611 : notFound();
16101612 });
16111613
1614 // Every person and their role.
1615 router.add("GET", "/api/v1/admin/people", (ctx) => {
1616 const denied = accountAdmin(ctx, "admin");
1617 if (denied) return denied;
1618 return json({ people: listPeople(ctx.app.db) });
1619 });
1620
1621 // Make a person a member (with an optional starter allowance, dollars a month) or a guest again.
1622 router.add("POST", "/api/v1/admin/accounts/:username/role", async (ctx) => {
1623 const denied = accountAdmin(ctx, "admin");
1624 if (denied) return denied;
1625 const input = await body(ctx.request);
1626 const { role, usdPerMonth } = input;
1627 if ((role !== "member" && role !== "guest") || (usdPerMonth !== undefined && usdPerMonth !== null && (typeof usdPerMonth !== "number" || !Number.isFinite(usdPerMonth))))
1628 return apiError("invalid", 422, 'role is "member" or "guest"; usdPerMonth is dollars a month, or null');
1629 const done = setRole(ctx.app, ctx.auth.user, ctx.params.username!, role, typeof usdPerMonth === "number" ? usdPerMonth : null);
1630 if (done.ok) return json(done.value);
1631 return done.error === "invalid_amount"
1632 ? apiError("invalid", 422, "usdPerMonth is from 0.01 to 10000 dollars")
1633 : done.error === "not_allowed"
1634 ? apiError("not_allowed", 422, "a role is for a person who is not an admin")
1635 : notFound();
1636 });
1637
16121638 // An account's daily and per-run model spend ceilings, dollars (any account, orgs too).
16131639 router.add("POST", "/api/v1/admin/accounts/:username/ceiling", async (ctx) => {
16141640 const denied = accountAdmin(ctx, "admin");
src/http/mcp.ts +47
@@ -62,6 +62,7 @@ import {
6262 cardFor,
6363 } from "../web/studio/cards.ts";
6464 import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts";
65 import { setRole } from "../actions/roles.ts";
6566 import { keepThought } from "../actions/thought.ts";
6667 import {
6768 FROM_WORDS,
@@ -1001,6 +1002,52 @@ const TOOLS: readonly Tool[] = [
10011002 return good(set.value);
10021003 },
10031004 },
1005 {
1006 name: "account_role",
1007 description:
1008 "Make a person a member, or a guest again, with an optional starter model allowance (`usdPerMonth`, dollars a month, only when promoting; none by default). Roles are recorded and shown on /settings/people; nothing is refused because of one yet. A forge admin who is a person, with an admin token, sets it; `dryRun` is true unless you say false. Each change, and any allowance, is recorded in the audit.",
1009 inputSchema: {
1010 type: "object",
1011 properties: {
1012 username: { type: "string" },
1013 role: { type: "string", enum: ["member", "guest"] },
1014 usdPerMonth: { type: ["number", "null"], description: "0.01 to 10000; omit or null for none." },
1015 dryRun: { type: "boolean", description: "Plan only (the default). Send false to set it." },
1016 },
1017 required: ["username", "role"],
1018 },
1019 writes: true,
1020 dryByDefault: true,
1021 run(ctx, user, args) {
1022 const { username, role, usdPerMonth } = args;
1023 if (
1024 typeof username !== "string" ||
1025 (role !== "member" && role !== "guest") ||
1026 !(usdPerMonth === undefined || usdPerMonth === null || (typeof usdPerMonth === "number" && Number.isFinite(usdPerMonth)))
1027 )
1028 return bad("invalid", 'send a username and a role, "member" or "guest"');
1029 if (!mayBoards(user)) return bad("forbidden", "a role is a forge admin's to set");
1030 const usd = typeof usdPerMonth === "number" ? usdPerMonth : null;
1031 if (args["dryRun"] !== false)
1032 return good({
1033 username,
1034 role,
1035 usdPerMonth: usd,
1036 says: "This is the plan; nothing changed. Send dryRun: false to set it.",
1037 });
1038 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
1039 return bad("forbidden", "a role needs an admin token");
1040 const set = setRole(ctx.app, user, username, role, usd);
1041 if (!set.ok)
1042 return bad(
1043 set.error === "invalid_amount" ? "invalid" : "forbidden",
1044 set.error === "invalid_amount"
1045 ? "usdPerMonth is from 0.01 to 10000 dollars"
1046 : "no such person, or not one a role is for",
1047 );
1048 return good(set.value);
1049 },
1050 },
10041051 {
10051052 name: "model_ceiling",
10061053 description:
src/http/server.ts +3
@@ -156,6 +156,7 @@ import {
156156 subscriptionHeadroom,
157157 subscriptionWake,
158158 } from "../web/pages/subscriptions.ts";
159 import { peoplePage, roleAct } from "../web/pages/people.ts";
159160 import { ceilingAct, grantAct, modelAdd, modelPage } from "../web/pages/model.ts";
160161 import {
161162 changePassword,
@@ -804,6 +805,8 @@ export function buildRouter(): Router<Handler> {
804805 router.add("POST", "/settings/backups/mirror/clear", backupsMirrorClear);
805806 router.add("POST", "/settings/backups/mirror/now", backupsMirrorNow);
806807 router.add("GET", "/settings/accounts", accountsPage);
808 router.add("GET", "/settings/people", peoplePage);
809 router.add("POST", "/settings/people/:username/role", roleAct);
807810 router.add("GET", "/settings/model", modelPage);
808811 router.add("POST", "/settings/model", modelAdd);
809812 router.add("POST", "/settings/accounts/:username/model", grantAct);
src/tui/forge.ts +9
@@ -47,6 +47,7 @@ import { accessScreen } from "./screens/access.ts";
4747 import { flagsScreen } from "./screens/flags.ts";
4848 import { cloudScreen } from "./screens/cloud.ts";
4949 import { usageScreen } from "./screens/usage.ts";
50 import { peopleScreen } from "./screens/people.ts";
5051 import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts";
5152 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
5253 import { quotasScreen } from "./screens/quotas.ts";
@@ -394,6 +395,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
394395 hint: "set an account's dollars a day and a run",
395396 run: () => ctx.push(modelCeilingPrompt(session, ctx, blank("Model ceiling"))),
396397 });
398 // Guests, members and admins, as /settings/people has them (#2301).
399 if (session.user?.isAdmin === true && session.user.kind === "user")
400 commands.push({
401 id: "people",
402 title: "People",
403 hint: "who is a guest, a member or an admin; promote or demote",
404 run: () => ctx.push(peopleScreen(session, ctx)),
405 });
397406 // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191).
398407 if (session.user?.isAdmin === true && session.user.kind !== "agent")
399408 commands.push({
src/tui/screens/people.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * People in the terminal, as `/settings/people` has them: every person with
3 * a role and allowance, and `r` to make one a member (`member 5` gives a
4 * starter $5 a month) or a guest. A forge admin who is a person; every rule
5 * is the action's (`actions/roles.ts`).
6 */
7
8 import { setRole } from "../../actions/roles.ts";
9 import { listPeople } from "../../core/roles.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import type { Session } from "../session.ts";
13 import { style } from "../term/style.ts";
14 import { truncate } from "../term/width.ts";
15 import { later, promptScreen } from "./common.ts";
16
17 export function peopleScreen(f: Session, ctx: Ctx): Screen {
18 const screen: Screen = {
19 title: "People",
20 keys: [binding(["r"], "change a role"), binding(["esc"], "back")],
21 render(area, s) {
22 const c = ctx.theme.c;
23 listPeople(f.app.db)
24 .slice(0, Math.max(0, area.h))
25 .forEach((p, i) => {
26 const allowance = p.usdPerMonth === null ? "" : ` $${p.usdPerMonth.toFixed(2)} a month`;
27 s.text(area.x, area.y + i, truncate(`${p.username} ${p.role}${allowance}`, area.w), style(c.mutedFg, c.bg), area.x + area.w);
28 });
29 },
30 key(e) {
31 if (e.ctrl || e.alt || e.key !== "r") return false;
32 ctx.push(
33 promptScreen(ctx, screen, {
34 title: "Change a role",
35 label: "account name",
36 onSubmit: (name) =>
37 later(ctx, () =>
38 promptScreen(ctx, screen, {
39 title: `Role for ${name.trim()}`,
40 label: "guest, member, or member 5 (dollars a month)",
41 onSubmit: (raw) => {
42 const [role, amount, ...rest] = raw.trim().toLowerCase().split(/\s+/);
43 if ((role !== "member" && role !== "guest") || rest.length > 0 || (amount !== undefined && role !== "member"))
44 return "guest, member, or member 5.";
45 const usd = amount === undefined ? null : Number(amount);
46 if (usd !== null && !Number.isFinite(usd)) return "Dollars a month, like 5.";
47 const done = setRole(f.app, f.user, name.trim(), role, usd);
48 if (!done.ok) return done.error === "invalid_amount" ? "That amount will not do." : "No such account, or not one a role is for.";
49 ctx.toast(`${name.trim()} is a ${role}.`);
50 return undefined;
51 },
52 }),
53 ),
54 }),
55 );
56 return true;
57 },
58 };
59 return screen;
60 }
src/web/pages/origins.ts +7
@@ -79,6 +79,7 @@ export type AccountSection =
7979 | "usage"
8080 | "crew"
8181 | "accounts"
82 | "people"
8283 | "subscriptions"
8384 | "backups"
8485 | "secrets"
@@ -109,6 +110,12 @@ export function accountSections(
109110 icon: "users" as const,
110111 current: current === "accounts",
111112 },
113 {
114 label: "People",
115 href: "/settings/people",
116 icon: "users" as const,
117 current: current === "people",
118 },
112119 {
113120 label: "Crew",
114121 href: "/settings/crew",
src/web/pages/people.ts added +103
@@ -0,0 +1,103 @@
1 /**
2 * People — every account and its role in one table: guest, member or admin.
3 * An admin promotes a guest to member (with a starter model allowance, none
4 * by default) or demotes a member, in one same-origin form post. Roles are
5 * shown and recorded; nothing is refused because of one yet (`docs/ROLES.md`).
6 */
7
8 import { setRole } from "../../actions/roles.ts";
9 import { GRANT_MAX_USD } from "../../core/model_grant.ts";
10 import { listPeople, type Person } from "../../core/roles.ts";
11 import { getUserByName, type User } from "../../core/users.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
14 import { field, readForm } from "../form.ts";
15 import { html } from "../html.ts";
16 import { flashTo, page } from "../layout.ts";
17 import { badge, btn, card, empty, userLink } from "../ui.ts";
18 import { accountSections } from "./origins.ts";
19
20 // GET /settings/people
21 export function peoplePage(ctx: Ctx): Response {
22 if (!ctx.auth.user) return signInFirst(ctx);
23 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);

Low: this page gates on isAdmin alone. The terminal command and setRole also require kind === "user", so an admin agent with a web session could read the people list here, though it cannot change anything. Match the other doors: if (!ctx.auth.user.isAdmin || ctx.auth.user.kind !== "user") return notFoundPage(ctx);, here and in roleAct.

24 const rows = listPeople(ctx.app.db);
25 const line = (p: Person) => {
26 const role = p.role;
27 const at = `/settings/people/${encodeURIComponent(p.username)}/role`;
28 const act =
29 role === "admin"
30 ? null
31 : role === "guest"
32 ? html`<form class="row-actions" method="post" action="${at}">
33 <input type="hidden" name="role" value="member" />
34 <input class="input" name="usd" inputmode="decimal" autocomplete="off"
35 aria-label="Starter allowance, dollars a month" placeholder="$ a month, none" size="12" />
36 ${btn("Make member", { type: "submit", size: "sm", variant: "primary" })}
37 </form>`
38 : html`<form class="row-actions" method="post" action="${at}">
39 <input type="hidden" name="role" value="guest" />
outdated

roleOf + getModelGrant per row is 2N queries on every render. Fold the role and grant into the SELECT above with LEFT JOINs.

40 ${btn("Make guest", { type: "submit", size: "sm" })}
41 </form>`;
42 return html`<tr>
43 <td>${userLink(p.username)}</td>
44 <td>${badge(role, { tone: role === "guest" ? "default" : "success" })}</td>
45 <td>${p.usdPerMonth === null ? "none" : `$${p.usdPerMonth.toFixed(2)} a month`}</td>
46 <td>${act}</td>
47 </tr>`;
48 };
49 return page({
50 ctx,
51 title: "People",
52 body: html`<div class="page-head"><h1>People</h1></div>
53 ${settingsLayout(
54 accountSections("people", { admin: true }),
55 card(
56 rows.length === 0
57 ? empty({ icon: "users", title: "Nobody has joined yet." })
58 : html`<table class="table" id="people">
59 <thead><tr><th>Account</th><th>Role</th><th>Allowance</th><th></th></tr></thead>
60 <tbody>${rows.map(line)}</tbody>
61 </table>`,
62 {
63 head: html`<h2>Guests, members and admins</h2>
64 <p class="muted">A member may be given a starter model allowance (up to
65 $${GRANT_MAX_USD} a month). Roles are recorded here; nothing is refused
66 because of one yet.</p>`,
67 },
68 ),
69 "Account settings",
70 )}`,
71 });
72 }
73
74 // POST /settings/people/:username/role
75 export async function roleAct(ctx: Ctx): Promise<Response> {
76 if (!ctx.auth.user) return signInFirst(ctx);
77 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
78 const form = await readForm(ctx, "admin");
79 if (form instanceof Response) return form;
80 const username = ctx.params.username ?? "";
81 const role = field(form, "role");
82 if (role !== "member" && role !== "guest") return notFoundPage(ctx);
83 const raw = field(form, "usd").trim().replace(/^\$/, "");
84 const usd = raw === "" || Number(raw) === 0 ? null : /^\d{1,6}(\.\d{1,2})?$/.test(raw) ? Number(raw) : NaN;
85 const done = setRole(ctx.app, ctx.auth.user as User, username, role, usd);
86 const back = "/settings/people";
87 if (!done.ok) {
88 if (done.error === "not_found" || getUserByName(ctx.app.db, username) === undefined) return notFoundPage(ctx);
89 return flashTo(ctx, back, {
90 tone: "danger",
91 message:
92 done.error === "invalid_amount"
93 ? `An allowance is dollars a month, from 0.01 to ${GRANT_MAX_USD}.`
94 : "You cannot change an admin's or an agent's role.",
95 });
96 }
97 return flashTo(ctx, back, {
98 message:
99 done.value.role === "member"
100 ? `${username} is a member${done.value.usdPerMonth === null ? "" : ` with $${done.value.usdPerMonth.toFixed(2)} a month`}.`
101 : `${username} is a guest.`,
102 });
103 }
test/mcp.test.ts +1
@@ -181,6 +181,7 @@ describe("the forge over MCP", () => {
181181 "health",
182182 "audit",
183183 "model_grant",
184 "account_role",
184185 "model_ceiling",
185186 "outsiders",
186187 "decisions_by",
test/roles.test.ts added +211
@@ -0,0 +1,211 @@
1 /** Roles, part 1 (#2301): read from the data, shown on one page, promoted in one action; nothing refused. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, readFile, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { getModelGrant } from "../src/core/model_grant.ts";
9 import { ABILITIES, CAN, ROLES, can, roleOf, rolesTable } from "../src/core/roles.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import { settleApp } from "./helpers/tui.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 const PASS = "a-long-roles-passphrase";
21
22 describe("the table", () => {
23 test("docs/ROLES.md repeats CAN exactly", async () => {
24 const doc = await readFile(join(import.meta.dir, "../docs/ROLES.md"), "utf8");
25 const m = doc.match(/<!-- roles-table -->\n([\s\S]*?)\n<!-- \/roles-table -->/);
26 expect(m?.[1]).toBe(rolesTable());
27 });
28
29 test("every role is in the table, and each ability is known", () => {
30 expect(Object.keys(CAN).sort()).toEqual([...ROLES].sort());
31 for (const r of ROLES) for (const a of CAN[r]) expect(a in ABILITIES).toBe(true);
32 expect(can("guest", "make_it_yours")).toBe(false);
33 expect(can("member", "make_it_yours")).toBe(true);
34 expect(can("member", "administer")).toBe(false);
35 });
36 });
37
38 describe("roles on /settings/people", () => {
39 let dataDir = "";
40 let app: App;
41 let servers: Servers;
42
43 const signIn = async (username: string) => {
44 const res = await fetch(`${servers.url}/signin`, {
45 method: "POST",
46 body: new URLSearchParams({ username, password: PASS }),
47 redirect: "manual",
48 });
49 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
50 };
51 const post = (cookie: string, user: string, body: Record<string, string>, origin = servers.url) =>
52 fetch(`${servers.url}/settings/people/${user}/role`, {
53 method: "POST",
54 headers: { cookie, origin },
55 body: new URLSearchParams(body),
56 redirect: "manual",
57 });
58 const roleNow = (name: string) => roleOf(app.db, getUserByName(app.db, name)!);
59
60 beforeAll(async () => {
61 dataDir = await mkdtemp(join(tmpdir(), "forge-roles-"));
62 app = createApp({ dataDir, port: 0 });
63 servers = await serve(app);
64 for (const [username, isAdmin] of [["alice", true], ["bob", false], ["carol", false]] as const) {
65 const made = await createUser(app.db, { trusted: true, username, email: `${username}@x.test`, password: PASS, isAdmin });
66 if (!made.ok) throw new Error(username);
67 }
68 }, 30_000);
69
70 afterAll(async () => {
71 servers.stop();
72 app.close();
73 await rm(dataDir, { recursive: true, force: true });
74 });
75
76 test("roles are read: admin from the flag, member from the table, else guest", () => {
77 expect(roleNow("alice")).toBe("admin");
78 expect(roleNow("bob")).toBe("guest");
79 });
80
81 test("the page lists everyone with a role, for an admin only", async () => {
82 const alice = await signIn("alice");
83 const html = await (await fetch(`${servers.url}/settings/people`, { headers: { cookie: alice } })).text();
84 expect(html).toContain("alice");
85 expect(html).toContain("bob");
86 expect(html).toContain("Make member");
87 const bob = await signIn("bob");
88 expect((await fetch(`${servers.url}/settings/people`, { headers: { cookie: bob } })).status).toBe(404);
89 });
90
91 test("a guest is promoted with no allowance by default, and demoted", async () => {
92 const alice = await signIn("alice");
93 expect((await post(alice, "bob", { role: "member" })).status).toBe(303);
94 expect(roleNow("bob")).toBe("member");
95 expect(getModelGrant(app.db, getUserByName(app.db, "bob")!.id)).toBeUndefined();
96 const ev = app.db.query<{ payload: string }, []>("SELECT payload FROM event WHERE kind = 'account.role_set' ORDER BY id DESC LIMIT 1").get();
97 expect(JSON.parse(ev!.payload)).toMatchObject({ username: "bob", role: "member" });
98 await post(alice, "bob", { role: "guest" });
99 expect(roleNow("bob")).toBe("guest");
100 });
101
102 test("a starter allowance goes through the model grant; a bad one changes nothing", async () => {
103 const alice = await signIn("alice");
104 await post(alice, "carol", { role: "member", usd: "nonsense" });
105 expect(roleNow("carol")).toBe("guest");
106 await post(alice, "carol", { role: "member", usd: "5" });
107 expect(roleNow("carol")).toBe("member");
108 expect(getModelGrant(app.db, getUserByName(app.db, "carol")!.id)?.usdPerMonth).toBe(5);
109 });
110
111 test("a non-admin, a foreign origin, an admin target and an unknown role are refused", async () => {
112 const bob = await signIn("bob");
113 expect((await post(bob, "bob", { role: "member" })).status).toBe(404);
114 expect(roleNow("bob")).toBe("guest");
115 const alice = await signIn("alice");
116 expect((await post(alice, "bob", { role: "member" }, "https://evil.example")).status).toBeGreaterThanOrEqual(400);
117 expect(roleNow("bob")).toBe("guest");
118 await post(alice, "alice", { role: "guest" });
119 expect(roleNow("alice")).toBe("admin");
120 expect((await post(alice, "bob", { role: "admin" })).status).toBe(404);
121 expect(roleNow("bob")).toBe("guest");
122 });
123
124 test("a starter allowance is audited as a model grant", () => {
125 const ev = app.db
126 .query<{ payload: string; actor_id: string }, []>("SELECT payload, actor_id FROM event WHERE kind = 'account.model_granted' ORDER BY id DESC LIMIT 1")
127 .get();
128 expect(JSON.parse(ev!.payload)).toEqual({ username: "carol", usdPerMonth: 5 });
129 expect(ev!.actor_id).toBe(getUserByName(app.db, "alice")!.id);
130 });
131
132 test("the API lists people and sets a role for an admin token only", async () => {
133 const tok = createToken(app.db, getUserByName(app.db, "alice")!.id, "t", "admin").token;
134 const bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "t", "admin").token;
135 const send = (token: string, role: unknown, usd?: unknown) =>
136 fetch(`${servers.url}/api/v1/admin/accounts/bob/role`, {
137 method: "POST",
138 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
139 body: JSON.stringify({ role, usdPerMonth: usd }),
140 });
141 expect((await send(bobTok, "member")).status).toBe(404);
142 expect((await send(tok, "admin")).status).toBe(422);
143 expect((await send(tok, "member", -1)).status).toBe(422);
144 expect(roleNow("bob")).toBe("guest");
145 const ok = await send(tok, "member", 3);
146 expect(await ok.json()).toEqual({ username: "bob", role: "member", usdPerMonth: 3 });
147 const list = await fetch(`${servers.url}/api/v1/admin/people`, { headers: { authorization: `Bearer ${tok}` } });
148 const people = ((await list.json()) as any).people as { username: string; role: string }[];
149 expect(people.find((p) => p.username === "bob")?.role).toBe("member");
150 expect(people.find((p) => p.username === "alice")?.role).toBe("admin");
151 expect((await send(tok, "guest")).status).toBe(200);
152 expect(roleNow("bob")).toBe("guest");
153 });
154
155 test("MCP account_role plans by default and sets with dryRun false, for an admin token", async () => {
156 const mcp = async (token: string, args: unknown) => {
157 const res = await fetch(`${servers.url}/mcp`, {
158 method: "POST",
159 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
160 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "account_role", arguments: args } }),
161 });
162 const body = (await res.json()) as any;
163 return { error: body.result.isError === true, text: body.result.content[0].text as string };
164 };
165 const tok = createToken(app.db, getUserByName(app.db, "alice")!.id, "t", "admin").token;
166 const rw = createToken(app.db, getUserByName(app.db, "alice")!.id, "t", "rw").token;
167 const bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "t", "admin").token;
168 expect((await mcp(tok, { username: "bob", role: "member" })).text).toContain("nothing changed");
169 expect(roleNow("bob")).toBe("guest");
170 expect((await mcp(rw, { username: "bob", role: "member", dryRun: false })).error).toBe(true);
171 expect((await mcp(bobTok, { username: "bob", role: "member", dryRun: false })).error).toBe(true);
172 expect((await mcp(tok, { username: "bob", role: "boss", dryRun: false })).error).toBe(true);
173 expect(roleNow("bob")).toBe("guest");
174 expect((await mcp(tok, { username: "bob", role: "member", dryRun: false })).error).toBe(false);
175 expect(roleNow("bob")).toBe("member");
176 await mcp(tok, { username: "bob", role: "guest", dryRun: false });
177 });
178
179 test("the terminal lists people and changes a role; a person has no such command", async () => {
180 const boot = (username: string) => {
181 const terminal = headlessTerminal(140, 32);
182 const user = getUserByName(app.db, username)!;
183 const { app: tui } = createForgeApp(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false });
184 tui.start();
185 const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
186 return {
187 text: () => tui.screen.toText(),
188 press: async (...ks: string[]) => { ks.forEach((k) => key(k)); await settleApp(tui); },
189 palette: async (title: string) => { key("k", true); await settleApp(tui); for (const ch of title) key(ch); await settleApp(tui); },
190 type: async (t: string) => { for (const ch of t) key(ch); await settleApp(tui); },
191 stop: () => tui.stop(),
192 };
193 };
194 const h = boot("alice");
195 await h.palette("People");
196 await h.press("enter");
197 expect(h.text()).toContain("bob guest");
198 await h.press("r");
199 await h.type("bob");
200 await h.press("enter");
201 await h.type("member 4");
202 await h.press("enter");
203 expect(roleNow("bob")).toBe("member");
204 expect(getModelGrant(app.db, getUserByName(app.db, "bob")!.id)?.usdPerMonth).toBe(4);
205 h.stop();
206 const p = boot("bob");
207 await p.palette("People");
208 expect(p.text()).not.toContain("promote or demote");
209 p.stop();
210 });
211 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs