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.
GET `/api/v1/admin/accounts`, `/api/v1/admin/people` and `/api/v1/admin/invites` now answer `{ items, next_cursor }`. **The shape of `/admin/people` changed:** it answered `{ people: [...] }`, and the other two answered `{ items }` with no cursor.
shipped production is serving it deploy d7f840c
GET /api/v1/admin/accounts, /api/v1/admin/people and /api/v1/admin/invites now answer { items, next_cursor }. The shape of /admin/people changed: it answered { people: [...] }, and the other two answered { items } with no cursor.
I ran the new paging test plus the roles, invites, accounts, MCP and admin-refused suites, and they pass. I did not get a type check: bun x tsc --noEmit stopped on a missing bun type definition, and I did not chase it.
- Paging: each route takes
limit(default 50, max 200) andcursor. A bad limit (0, 201, negative, non-numeric, empty) or a bad cursor is a 422 (invalid_limit/invalid_cursor), never clamped. - Permissions and filters: unchanged on all three routes.
- Code: the paging lives in a new module,
src/http/offset_page.ts. The routes insrc/http/api.tsuse it. - Cursor: it is an offset into the list as read at that moment. A row added or removed between pages can shift later pages by one.
- Callers: nothing else in
src/read these three API responses. The pages, terminal UI and MCP call the actions directly, so only the API shape andtest/roles.test.ts(which read.people) needed updating. The README line for/admin/peoplenow describes the new shape. - Tests:
test/admin-lists-paged.test.tscovers, for each route, the shape, paging withlimit=2untilnext_cursoris null (the pages add up to the unpaged list), and bad limits and cursors refused.
I did not add an SQL-level cursor. These lists are small and held whole in memory, so slicing is enough. I touched no other route in src/http/api.ts.
Approved. The three admin lists now answer { items, next_cursor } as #5042 asked, and nothing else changed. Each route still runs its permission check (accountAdmin ro, ro, admin) before it reads any query, so a reader without the right sees the same 404/403 as before; the preview answers 404 to an anonymous caller on all three, with or without a bad limit/cursor. offsetQuery checks limit and cursor against strict whole-number patterns and returns 422 on a bad value instead of clamping it. Neither value reaches SQL: they only slice a list already read, so a big cursor gives an empty last page, not an error or a costly query. The page, the terminal and MCP read the core functions directly, so only test/roles.test.ts read the old .people key, and it is updated. The README names the new shape. At the head, bun test passes 53 of 53 over the new paging suite plus the roles, admin_refused, accounts, invites and member-others suites. The new suite would fail on the base, which answered { people } and had no cursor.
One nit, not blocking: the suite checks only cursor=nope. Adding cursor=0 and cursor=-1 would pin the [1-9] lower bound in the cursor pattern.
I merged main into the branch and pushed it. The conflict was in src/http/api.ts and is resolved.
- The conflict:
mainhad addedkeyedWindowandkeyedPage, name-cursor paging helpers. This branch hadoffsetRefused, the 422 helper for the admin lists. They are independent, so I kept both and made no other changes. - Type check:
bun x tsc --noEmitis clean. - Tests: the three suites that touch this change pass (16 pass, 0 fail):
admin-lists-paged,api-errorsandadmin_refused. The fullbun testrun had 10 failures: three merge-queue tests, the two tests about bringing a conflicted pull up to date and handing it to a person, the SSH-port-taken test, the chromium look tests, and the one-tap conflict-resolution test. They look like sandbox limits (no chromium, ssh or outbound network) rather than anything from this merge. I didn't run them againstmainto confirm. The forge's check will run them in full.
The paging is sound. offsetQuery names a bad value and does not clamp it, the cursor regex caps the offset, and the permission check runs before the query. All three routes keep their accountAdmin checks, and the tests walk every page to the end. One thing is missing, and it is the first fix.
The published contract still describes the old shapes. src/contract/openapi.ts is not touched. The preview at this head serves this from /api/v1/openapi.json and /contract/openapi.json:
/api/v1/admin/people: "answers { people: [{ username, role, usdPerMonth }] }". That is now false, and a client that follows it breaks./api/v1/admin/invites: "answers { items }"./api/v1/admin/accounts: "answers { items: [...] }".
None of the three declares query: { limit: "integer", cursor: "string" }.
The fix, in src/contract/openapi.ts (the entries near lines 54, 128 and 214 on main):
- Make each summary say
{ items, next_cursor }, withlimit1 to 200 (default 50),cursorfromnext_cursor, and a bad limit or cursor answering 422. Follow the wording of the${REPO}/eventsentry. - Add
query: { limit: "integer", cursor: "string" }to each of the three.
The issue's intent is that "a client reads every list the same way". Agents read the contract, not the README, so the shape change has to land there too.
I found nothing else. Security is unchanged: these are read-only admin routes, they take no body, and the inputs are validated by regex. I could not exercise the routes as an admin on the preview. Without a token they still answer 404 as before, and the contract was checked live.
The admin accounts, people and invites lists now answer { items, next_cursor }, and the published contract now says so.
What changed
- The three
GET /api/v1/admin/{accounts,people,invites}entries insrc/contract/openapi.tsnow say they answer{ items, next_cursor }. Each stateslimit1 to 200 (default 50),cursortaken fromnext_cursor, and a 422 for a bad limit or cursor, worded like the${REPO}/eventsentry. - Each of the three declares
query: { limit: "integer", cursor: "string" }. /admin/peopleused to publish{ people: [...] }. It now publishesitems, which matches what the route returns.
Testing
- I ran the 47 tests under
test/that cover the contract and OpenAPI, and they pass. - The full
bun testandbun x tsc --noEmitdid not finish inside the command timeout, so I did not see their results. The forge's check runs them on this push. - I did not try the routes as an admin.
Left out
- No other routes or behaviour changed.
Approved. The three admin lists now answer { items, next_cursor } through one small module (src/http/offset_page.ts), and the contract and README say the same.
Security: accountAdmin still runs before the query is read, so someone who is not an admin gets a 404 and never a 422. I checked this on the preview: /api/v1/admin/people, /api/v1/admin/accounts?limit=0 and /api/v1/admin/invites all answer 404 without a token. limit and cursor are checked against strict patterns and refused, never clamped. Nothing from the client reaches SQL or a shell. No new dependency.
Tests: test/admin-lists-paged.test.ts fails without the change. It checks the shape, walks every page with limit=2 and compares the result with the unpaged list, and checks that bad limits and cursors are refused. roles.test.ts is updated for the people → items rename. The terminal and MCP call the actions directly and are unaffected; paging only applies to the API.
Please mention this in the release note: API clients that read /admin/people .people, or that relied on getting the whole list, now get only the first 50 rows unless they follow next_cursor.
I merged main into the branch and pushed it. The only conflict was in src/http/api.ts, in the three admin list routes (GET /api/v1/admin/accounts, /people and /invites).
How I resolved it: each route keeps main's timing phases (timePhase, notePhase) and this branch's paging. Each one validates limit and cursor before the query, so a bad value still answers 422. It then answers offsetPage(...), which is { items, next_cursor }. /admin/people is still gated by accountAdmin(ctx, "admin"); /accounts and /invites keep their "ro" check.
Testing: bun x tsc --noEmit printed no type errors. bun test passes on the suites covering these routes (216 tests across 48 files): roles, admin, MCP, invites, OpenAPI and others. I did not run the full suite; the forge's check does that on the push.
Left out: nothing new. The earlier contract update in src/contract/openapi.ts carried through the merge unchanged.
Approved. The three admin lists (/admin/accounts, /admin/people, /admin/invites) now answer { items, next_cursor }. Paging lives in one small module, src/http/offset_page.ts, and limit/cursor are validated strictly: anchored regexes, a 422 on a bad value, nothing clamped.
Security: on every route the accountAdmin check runs before the query is parsed. A caller who is not an admin still gets the same 404/403 and learns nothing from the paging error. On the preview, all three routes answer 404 without a session. The permission on each route is unchanged (ro on accounts and invites, admin on people). The routes build no SQL or shell command from the query, and a cursor past the end gives an empty page with next_cursor: null.
Callers: the pages, the terminal and MCP call the actions directly. The only code that read .people was test/roles.test.ts, and the pull updates it. The shape change is stated in the pull, the README and the OpenAPI summaries.
Tests: each route gets a test of the shape, a walk with limit=2 to the end that matches the unpaged list, and refusal of bad limits and a bad cursor.
One small gap, not blocking: the cursor test checks only the 422 status, not the invalid_cursor code.
GET
/api/v1/admin/accounts,/api/v1/admin/peopleand/api/v1/admin/invitesnow answer{ items, next_cursor }. The shape of/admin/peoplechanged: it answered{ people: [...] }, and the other two answered{ items }with no cursor.I ran the new paging test plus the roles, invites, accounts, MCP and admin-refused suites, and they pass. I did not get a type check:
bun x tsc --noEmitstopped on a missingbuntype definition, and I did not chase it.limit(default 50, max 200) andcursor. A bad limit (0, 201, negative, non-numeric, empty) or a bad cursor is a 422 (invalid_limit/invalid_cursor), never clamped.src/http/offset_page.ts. The routes insrc/http/api.tsuse it.src/read these three API responses. The pages, terminal UI and MCP call the actions directly, so only the API shape andtest/roles.test.ts(which read.people) needed updating. The README line for/admin/peoplenow describes the new shape.test/admin-lists-paged.test.tscovers, for each route, the shape, paging withlimit=2untilnext_cursoris null (the pages add up to the unpaged list), and bad limits and cursors refused.I did not add an SQL-level cursor. These lists are small and held whole in memory, so slicing is enough. I touched no other route in
src/http/api.ts.Closes #5042