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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2301-roles-part-1-an-account-is-a-guest-a-mem → main 2 commits fork 3 threads · 3 unresolved
README.md +2
| @@ -733,6 +733,8 @@ the work repositories and nothing else. Two on one grant take turns, and | ||
| 733 | 733 | one the provider rests hands its runs to the other until the reset. |
| 734 | 734 | **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. |
| 735 | 735 | |
| 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 | ||
| 736 | 738 | **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. |
| 737 | 739 | **Sandbox.** An agent's shell is held to a policy — the network is the forge |
| 738 | 740 | 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()); | |
| 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 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { setRole } from "../actions/roles.ts"; | |
| 12 | import { listPeople } from "../core/roles.ts"; | |
| 11 | 13 | import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts"; |
| 12 | 14 | import { storageLimitMessage } from "../core/storage.ts"; |
| 13 | 15 | import { voteVariant } from "../actions/variants.ts"; |
| @@ -1609,6 +1611,30 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1609 | 1611 | : notFound(); |
| 1610 | 1612 | }); |
| 1611 | 1613 | |
| 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 | ||
| 1612 | 1638 | // An account's daily and per-run model spend ceilings, dollars (any account, orgs too). |
| 1613 | 1639 | router.add("POST", "/api/v1/admin/accounts/:username/ceiling", async (ctx) => { |
| 1614 | 1640 | const denied = accountAdmin(ctx, "admin"); |
src/http/mcp.ts +47
| @@ -62,6 +62,7 @@ import { | ||
| 62 | 62 | cardFor, |
| 63 | 63 | } from "../web/studio/cards.ts"; |
| 64 | 64 | import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts"; |
| 65 | import { setRole } from "../actions/roles.ts"; | |
| 65 | 66 | import { keepThought } from "../actions/thought.ts"; |
| 66 | 67 | import { |
| 67 | 68 | FROM_WORDS, |
| @@ -1001,6 +1002,52 @@ const TOOLS: readonly Tool[] = [ | ||
| 1001 | 1002 | return good(set.value); |
| 1002 | 1003 | }, |
| 1003 | 1004 | }, |
| 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 | }, | |
| 1004 | 1051 | { |
| 1005 | 1052 | name: "model_ceiling", |
| 1006 | 1053 | description: |
src/http/server.ts +3
| @@ -156,6 +156,7 @@ import { | ||
| 156 | 156 | subscriptionHeadroom, |
| 157 | 157 | subscriptionWake, |
| 158 | 158 | } from "../web/pages/subscriptions.ts"; |
| 159 | import { peoplePage, roleAct } from "../web/pages/people.ts"; | |
| 159 | 160 | import { ceilingAct, grantAct, modelAdd, modelPage } from "../web/pages/model.ts"; |
| 160 | 161 | import { |
| 161 | 162 | changePassword, |
| @@ -804,6 +805,8 @@ export function buildRouter(): Router<Handler> { | ||
| 804 | 805 | router.add("POST", "/settings/backups/mirror/clear", backupsMirrorClear); |
| 805 | 806 | router.add("POST", "/settings/backups/mirror/now", backupsMirrorNow); |
| 806 | 807 | router.add("GET", "/settings/accounts", accountsPage); |
| 808 | router.add("GET", "/settings/people", peoplePage); | |
| 809 | router.add("POST", "/settings/people/:username/role", roleAct); | |
| 807 | 810 | router.add("GET", "/settings/model", modelPage); |
| 808 | 811 | router.add("POST", "/settings/model", modelAdd); |
| 809 | 812 | router.add("POST", "/settings/accounts/:username/model", grantAct); |
src/tui/forge.ts +9
| @@ -47,6 +47,7 @@ import { accessScreen } from "./screens/access.ts"; | ||
| 47 | 47 | import { flagsScreen } from "./screens/flags.ts"; |
| 48 | 48 | import { cloudScreen } from "./screens/cloud.ts"; |
| 49 | 49 | import { usageScreen } from "./screens/usage.ts"; |
| 50 | import { peopleScreen } from "./screens/people.ts"; | |
| 50 | 51 | import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts"; |
| 51 | 52 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 52 | 53 | import { quotasScreen } from "./screens/quotas.ts"; |
| @@ -394,6 +395,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 394 | 395 | hint: "set an account's dollars a day and a run", |
| 395 | 396 | run: () => ctx.push(modelCeilingPrompt(session, ctx, blank("Model ceiling"))), |
| 396 | 397 | }); |
| 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 | }); | |
| 397 | 406 | // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191). |
| 398 | 407 | if (session.user?.isAdmin === true && session.user.kind !== "agent") |
| 399 | 408 | 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 = | ||
| 79 | 79 | | "usage" |
| 80 | 80 | | "crew" |
| 81 | 81 | | "accounts" |
| 82 | | "people" | |
| 82 | 83 | | "subscriptions" |
| 83 | 84 | | "backups" |
| 84 | 85 | | "secrets" |
| @@ -109,6 +110,12 @@ export function accountSections( | ||
| 109 | 110 | icon: "users" as const, |
| 110 | 111 | current: current === "accounts", |
| 111 | 112 | }, |
| 113 | { | |
| 114 | label: "People", | |
| 115 | href: "/settings/people", | |
| 116 | icon: "users" as const, | |
| 117 | current: current === "people", | |
| 118 | }, | |
| 112 | 119 | { |
| 113 | 120 | label: "Crew", |
| 114 | 121 | 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 |
||
| 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
|
||
| 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", () => { | ||
| 181 | 181 | "health", |
| 182 | 182 | "audit", |
| 183 | 183 | "model_grant", |
| 184 | "account_role", | |
| 184 | 185 | "model_ceiling", |
| 185 | 186 | "outsiders", |
| 186 | 187 | "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 | }); |
This bypasses
setAccountGrant, so noaccount.model_grantedevent 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.