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

The admin's accounts, people and invites lists answer `{ items, next_cursor }` #5052

Merged opened this

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

Merged

scout/forge:scout/5042-the-admin-s-accounts-people-and-invites → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/5042-the-admin-s-accounts-people-and-invites was merged into main as e4e4082.

Review

Sign in to review this change.
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
14021402 one the provider rests hands its runs to the other until the reset.
14031403 **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.
14041404
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).
14061406
14071407 **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.
14081408
src/contract/openapi.ts +6 −3
@@ -53,8 +53,9 @@ export const ROUTES: readonly Route[] = [
5353 method: "get",
5454 path: "/api/v1/admin/accounts",
5555 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",
5757 auth: true,
58 query: { limit: "integer", cursor: "string" },
5859 },
5960 {
6061 method: "delete",
@@ -126,8 +127,9 @@ export const ROUTES: readonly Route[] = [
126127 {
127128 method: "get",
128129 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",
130131 auth: true,
132 query: { limit: "integer", cursor: "string" },
131133 },
132134 {
133135 method: "post",
@@ -213,8 +215,9 @@ export const ROUTES: readonly Route[] = [
213215 method: "get",
214216 path: "/api/v1/admin/people",
215217 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",
217219 auth: true,
220 query: { limit: "integer", cursor: "string" },
218221 },
219222 { 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" } },
220223 { 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 {
178178 SIGNUP_CLOSED_MESSAGE,
179179 updateProfile,
180180 } from "../actions/users.ts";
181 import { OFFSET_LIMIT_MAX, offsetPage, offsetQuery } from "./offset_page.ts";
181182 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts";
182183 import { audienceFor } from "../core/audience.ts";
183184 import {
@@ -1044,6 +1045,12 @@ function listQuery(ctx: Ctx) {
10441045 };
10451046 }
10461047
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
10471054 /**
10481055 * `?limit=&cursor=` for a list paged by a name: limit 1..200 (default 50),
10491056 * 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 {
18251832 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
18261833 const denied = accountAdmin(ctx, "ro");
18271834 if (denied) return denied;
1835 const query = offsetQuery(ctx.url.searchParams);
1836 if ("error" in query) return offsetRefused(query.error);
18281837 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();
18301839 });
18311840
18321841 router.add("DELETE", "/api/v1/admin/invites/:id", (ctx) => {
@@ -1840,8 +1849,10 @@ export function apiRoutes(router: Router<Handler>): void {
18401849 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
18411850 const denied = accountAdmin(ctx, "ro");
18421851 if (denied) return denied;
1852 const query = offsetQuery(ctx.url.searchParams);
1853 if ("error" in query) return offsetRefused(query.error);
18431854 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();
18451856 });
18461857
18471858 router.add("POST", "/api/v1/admin/accounts/:username/suspend", async (ctx) => {
@@ -1882,8 +1893,10 @@ export function apiRoutes(router: Router<Handler>): void {
18821893 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
18831894 const denied = accountAdmin(ctx, "admin");
18841895 if (denied) return denied;
1896 const query = offsetQuery(ctx.url.searchParams);
1897 if ("error" in query) return offsetRefused(query.error);
18851898 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)));
18871900 });
18881901
18891902 // 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" };

Nit: 0 and -1 are refused here, but the test checks only cursor=nope. One more case would keep this bound from loosening unnoticed.

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 keyedPage.

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", () => {
159159 const ok = await send(tok, "member", 3);
160160 expect(await ok.json()).toEqual({ username: "bob", role: "member", usdPerMonth: 3 });
161161 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 }[];
163163 expect(people.find((p) => p.username === "bob")?.role).toBe("member");
164164 expect(people.find((p) => p.username === "alice")?.role).toBe("admin");
165165 expect((await send(tok, "guest")).status).toBe(200);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs