Public: anyone can read this; the crew can change it.
The admin's accounts, people and invites lists answer `{ items, next_cursor }` #5052
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5042-the-admin-s-accounts-people-and-invites → main 4 commits fork 3 threads · 3 unresolved
6 files
changed +127
−8
README.md +1 −1
| @@ -1402,7 +1402,7 @@ the work repositories and nothing else. Two on one grant take turns, and | ||
| 1402 | 1402 | one the provider rests hands its runs to the other until the reset. |
| 1403 | 1403 | **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. |
| 1404 | 1404 | |
| 1405 | **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token; in the terminal it is the **What I can do** command (read only, agents included). | |
| 1405 | **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` (`{ items, next_cursor }`, as `/admin/accounts` and `/admin/invites` answer, with `limit` 1-200, 50 by default, and `cursor`) and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token; in the terminal it is the **What I can do** command (read only, agents included). | |
| 1406 | 1406 | |
| 1407 | 1407 | **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent. |
| 1408 | 1408 |
src/contract/openapi.ts +6 −3
| @@ -53,8 +53,9 @@ export const ROUTES: readonly Route[] = [ | ||
| 53 | 53 | method: "get", |
| 54 | 54 | path: "/api/v1/admin/accounts", |
| 55 | 55 | summary: |
| 56 | "List every person and agent, newest first (a forge admin's, read scope; anyone else gets 404); answers { items: [{ username, kind, displayName, isAdmin, createdAt, via, signsInThrough, address, repos, apps, spentUsd, suspendedAt, removedAt, verified }] }", | |
| 56 | "List every person and agent, newest first (a forge admin's, read scope; anyone else gets 404); answers { items: [{ username, kind, displayName, isAdmin, createdAt, via, signsInThrough, address, repos, apps, spentUsd, suspendedAt, removedAt, verified }], next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", | |
| 57 | 57 | auth: true, |
| 58 | query: { limit: "integer", cursor: "string" }, | |
| 58 | 59 | }, |
| 59 | 60 | { |
| 60 | 61 | method: "delete", |
| @@ -126,8 +127,9 @@ export const ROUTES: readonly Route[] = [ | ||
| 126 | 127 | { |
| 127 | 128 | method: "get", |
| 128 | 129 | path: "/api/v1/admin/invites", |
| 129 | summary: "List invite links without their tokens (an admin's, read scope); answers { items }", | |
| 130 | summary: "List invite links without their tokens (an admin's, read scope); answers { items, next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", | |
| 130 | 131 | auth: true, |
| 132 | query: { limit: "integer", cursor: "string" }, | |
| 131 | 133 | }, |
| 132 | 134 | { |
| 133 | 135 | method: "post", |
| @@ -213,8 +215,9 @@ export const ROUTES: readonly Route[] = [ | ||
| 213 | 215 | method: "get", |
| 214 | 216 | path: "/api/v1/admin/people", |
| 215 | 217 | summary: |
| 216 | "List every person and their role (admin scope); answers { people: [{ username, role, usdPerMonth }] }, role being admin, member or guest", | |
| 218 | "List every person and their role (admin scope); answers { items: [{ username, role, usdPerMonth }], next_cursor }, role being admin, member or guest; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", | |
| 217 | 219 | auth: true, |
| 220 | query: { limit: "integer", cursor: "string" }, | |
| 218 | 221 | }, |
| 219 | 222 | { method: "get", path: `${REPO}/issues`, summary: "List a repository's issues; fields=brief leaves out bodies and comments, any other fields value is a 422 invalid_fields", auth: false, query: { ...LIST_QUERY, fields: "string" } }, |
| 220 | 223 | { method: "get", path: `${REPO}/pulls`, summary: "List a repository's pulls", auth: false, query: LIST_QUERY }, |
src/http/api.ts +16 −3
| @@ -178,6 +178,7 @@ import { | ||
| 178 | 178 | SIGNUP_CLOSED_MESSAGE, |
| 179 | 179 | updateProfile, |
| 180 | 180 | } from "../actions/users.ts"; |
| 181 | import { OFFSET_LIMIT_MAX, offsetPage, offsetQuery } from "./offset_page.ts"; | |
| 181 | 182 | import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts"; |
| 182 | 183 | import { audienceFor } from "../core/audience.ts"; |
| 183 | 184 | import { |
| @@ -1044,6 +1045,12 @@ function listQuery(ctx: Ctx) { | ||
| 1044 | 1045 | }; |
| 1045 | 1046 | } |
| 1046 | 1047 | |
| 1048 | function offsetRefused(error: "invalid_limit" | "invalid_cursor"): Response { | |
| 1049 | return error === "invalid_limit" | |
| 1050 | ? apiError(error, 422, `limit is a whole number from 1 to ${OFFSET_LIMIT_MAX}`) | |
| 1051 | : apiError(error, 422, "cursor is the next_cursor of a page before"); | |
| 1052 | } | |
| 1053 | ||
| 1047 | 1054 | /** |
| 1048 | 1055 | * `?limit=&cursor=` for a list paged by a name: limit 1..200 (default 50), |
| 1049 | 1056 | * the cursor the last name of the page before. A bad one is a 422. |
| @@ -1825,8 +1832,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1825 | 1832 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 1826 | 1833 | const denied = accountAdmin(ctx, "ro"); |
| 1827 | 1834 | if (denied) return denied; |
| 1835 | const query = offsetQuery(ctx.url.searchParams); | |
| 1836 | if ("error" in query) return offsetRefused(query.error); | |
| 1828 | 1837 | const listed = timePhase(ctx.request, "invites", () => listInviteLinks(ctx.app, ctx.auth.user)); |
| 1829 | return listed.ok ? timePhase(ctx.request, "render", () => json({ items: listed.value })) : notFound(); | |
| 1838 | return listed.ok ? timePhase(ctx.request, "render", () => json(offsetPage(listed.value, query.limit, query.offset))) : notFound(); | |
| 1830 | 1839 | }); |
| 1831 | 1840 | |
| 1832 | 1841 | router.add("DELETE", "/api/v1/admin/invites/:id", (ctx) => { |
| @@ -1840,8 +1849,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1840 | 1849 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 1841 | 1850 | const denied = accountAdmin(ctx, "ro"); |
| 1842 | 1851 | if (denied) return denied; |
| 1852 | const query = offsetQuery(ctx.url.searchParams); | |
| 1853 | if ("error" in query) return offsetRefused(query.error); | |
| 1843 | 1854 | const listed = timePhase(ctx.request, "accounts", () => listAccounts(ctx.app, ctx.auth.user)); |
| 1844 | return listed.ok ? timePhase(ctx.request, "render", () => json({ items: listed.value })) : notFound(); | |
| 1855 | return listed.ok ? timePhase(ctx.request, "render", () => json(offsetPage(listed.value, query.limit, query.offset))) : notFound(); | |
| 1845 | 1856 | }); |
| 1846 | 1857 | |
| 1847 | 1858 | router.add("POST", "/api/v1/admin/accounts/:username/suspend", async (ctx) => { |
| @@ -1882,8 +1893,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1882 | 1893 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 1883 | 1894 | const denied = accountAdmin(ctx, "admin"); |
| 1884 | 1895 | if (denied) return denied; |
| 1896 | const query = offsetQuery(ctx.url.searchParams); | |
| 1897 | if ("error" in query) return offsetRefused(query.error); | |
| 1885 | 1898 | const people = timePhase(ctx.request, "people", () => listPeople(ctx.app.db)); |
| 1886 | return timePhase(ctx.request, "render", () => json({ people })); | |
| 1899 | return timePhase(ctx.request, "render", () => json(offsetPage(people, query.limit, query.offset))); | |
| 1887 | 1900 | }); |
| 1888 | 1901 | |
| 1889 | 1902 | // Make a person a member (with an optional starter allowance, dollars a month) or a guest again. |
src/http/offset_page.ts added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | /** A list held whole in memory, served a page at a time: `limit` (1-200, 50 by default) and an opaque `cursor`. */ | |
| 2 | ||
| 3 | export const OFFSET_LIMIT_DEFAULT = 50; | |
| 4 | export const OFFSET_LIMIT_MAX = 200; | |
| 5 | ||
| 6 | export type OffsetQuery = { limit: number; offset: number } | { error: "invalid_limit" | "invalid_cursor" }; | |
| 7 | ||
| 8 | /** Read `?limit=&cursor=`; a bad value is named, never clamped. */ | |
| 9 | export function offsetQuery(q: URLSearchParams): OffsetQuery { | |
| 10 | const rawLimit = q.get("limit"); | |
| 11 | const limit = rawLimit === null ? OFFSET_LIMIT_DEFAULT : /^\d{1,4}$/.test(rawLimit) ? Number(rawLimit) : 0; | |
| 12 | if (limit < 1 || limit > OFFSET_LIMIT_MAX) return { error: "invalid_limit" }; | |
| 13 | const rawCursor = q.get("cursor"); | |
| 14 | if (rawCursor === null) return { limit, offset: 0 }; | |
| 15 | if (!/^[1-9]\d{0,8}$/.test(rawCursor)) return { error: "invalid_cursor" }; | |
| 16 | return { limit, offset: Number(rawCursor) }; | |
| 17 | } | |
| 18 | ||
| 19 | export function offsetPage<T>(all: readonly T[], limit: number, offset: number): { items: T[]; next_cursor: string | null } { | |
Minor, not blocking: the offset cursor can skip or repeat a row when the list changes between pages (accounts and invites are newest first, so a new signup shifts every later page by one). That is acceptable for admin lists this small, and the pull says so. If these lists grow, switch to a keyed cursor like |
||
| 20 | const end = offset + limit; | |
| 21 | return { items: all.slice(offset, end), next_cursor: end < all.length ? String(end) : null }; | |
| 22 | } |
test/admin-lists-paged.test.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 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 { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { mintInvite } from "../src/core/invites.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { offsetPage } from "../src/http/offset_page.ts"; | |
| 11 | import { proveAll } from "./helpers/verified.ts"; | |
| 12 | import { fixtureSlot } from "./helpers/slot.ts"; | |
| 13 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 14 | ||
| 15 | describe("the admin's accounts, people and invites lists answer { items, next_cursor }", () => { | |
| 16 | let dir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let admin: string; | |
| 20 | ||
| 21 | const get = async (path: string) => | |
| 22 | fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${admin}` } }); | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dir = await mkdtemp(join(tmpdir(), "forge-admin-paged-")); | |
| 26 | app = createApp({ dataDir: dir, port: 0, url: "https://forge.example" }); | |
| 27 | servers = await serve(app); | |
| 28 | const a = await seedAdmin(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" }); | |
| 29 | if (!a.ok) throw new Error("seed"); | |
| 30 | for (const n of ["ada", "bea", "cy", "dee"]) { | |
| 31 | const made = await signUp(app, { username: n, email: `${n}@f.dev`, password: "guest-passphrase" }, { slot: fixtureSlot(app) }); | |
| 32 | if (!made.ok) throw new Error("signup"); | |
| 33 | } | |
| 34 | proveAll(app); | |
| 35 | admin = createToken(app.db, a.value.id, "t", "admin").token; | |
| 36 | for (let i = 0; i < 4; i++) mintInvite(app.db, a.value.id, 3); | |
| 37 | }); | |
| 38 | afterAll(async () => { | |
| 39 | servers.stop(); | |
| 40 | app.close(); | |
| 41 | await rm(dir, { recursive: true, force: true }); | |
| 42 | }); | |
| 43 | ||
| 44 | for (const path of ["/api/v1/admin/accounts", "/api/v1/admin/people", "/api/v1/admin/invites"]) { | |
| 45 | test(`${path}: the shape, paging to the end, a bad limit or cursor refused`, async () => { | |
| 46 | const whole = (await (await get(path)).json()) as { items: unknown[]; next_cursor: string | null }; | |
| 47 | expect(Array.isArray(whole.items)).toBe(true); | |
| 48 | expect(whole.next_cursor).toBeNull(); | |
| 49 | const total = whole.items.length; | |
| 50 | expect(total).toBeGreaterThanOrEqual(4); | |
| 51 | ||
| 52 | const seen: unknown[] = []; | |
| 53 | let cursor: string | null = null; | |
| 54 | let pages = 0; | |
| 55 | do { | |
| 56 | const res = await get(`${path}?limit=2${cursor ? `&cursor=${cursor}` : ""}`); | |
| 57 | expect(res.status).toBe(200); | |
| 58 | const page = (await res.json()) as { items: unknown[]; next_cursor: string | null }; | |
| 59 | expect(page.items.length).toBeLessThanOrEqual(2); | |
| 60 | seen.push(...page.items); | |
| 61 | cursor = page.next_cursor; | |
| 62 | pages++; | |
| 63 | } while (cursor !== null && pages < 50); | |
| 64 | expect(pages).toBe(Math.ceil(total / 2)); | |
| 65 | expect(seen).toEqual(whole.items); | |
| 66 | ||
| 67 | for (const bad of ["0", "201", "-1", "x", "1.5", ""]) { | |
| 68 | const res = await get(`${path}?limit=${bad}`); | |
| 69 | expect(res.status).toBe(422); | |
| 70 | expect(await res.text()).toContain("invalid_limit"); | |
| 71 | } | |
| 72 | expect((await get(`${path}?cursor=nope`)).status).toBe(422); | |
| 73 | }); | |
| 74 | } | |
| 75 | ||
| 76 | test("offsetPage stops at the end", () => { | |
| 77 | expect(offsetPage([1, 2, 3], 3, 0)).toEqual({ items: [1, 2, 3], next_cursor: null }); | |
| 78 | expect(offsetPage([1, 2, 3], 2, 0)).toEqual({ items: [1, 2], next_cursor: "2" }); | |
| 79 | expect(offsetPage([1, 2, 3], 2, 2)).toEqual({ items: [3], next_cursor: null }); | |
| 80 | }); | |
| 81 | }); |
test/roles.test.ts +1 −1
| @@ -159,7 +159,7 @@ describe("roles on /settings/people", () => { | ||
| 159 | 159 | const ok = await send(tok, "member", 3); |
| 160 | 160 | expect(await ok.json()).toEqual({ username: "bob", role: "member", usdPerMonth: 3 }); |
| 161 | 161 | const list = await fetch(`${servers.url}/api/v1/admin/people`, { headers: { authorization: `Bearer ${tok}` } }); |
| 162 | const people = ((await list.json()) as any).people as { username: string; role: string }[]; | |
| 162 | const people = ((await list.json()) as any).items as { username: string; role: string }[]; | |
| 163 | 163 | expect(people.find((p) => p.username === "bob")?.role).toBe("member"); |
| 164 | 164 | expect(people.find((p) => p.username === "alice")?.role).toBe("admin"); |
| 165 | 165 | expect((await send(tok, "guest")).status).toBe(200); |
new above
offline
Nit:
0and-1are refused here, but the test checks onlycursor=nope. One more case would keep this bound from loosening unnoticed.