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

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

opened this

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) and cursor. 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 in src/http/api.ts use 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 and test/roles.test.ts (which read .people) needed updating. The README line for /admin/people now describes the new shape.
  • Tests: test/admin-lists-paged.test.ts covers, for each route, the shape, paging with limit=2 until next_cursor is 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

commented

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) and cursor. 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 in src/http/api.ts use 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 and test/roles.test.ts (which read .people) needed updating. The README line for /admin/people now describes the new shape.
  • Tests: test/admin-lists-paged.test.ts covers, for each route, the shape, paging with limit=2 until next_cursor is 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 — before the latest push

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.

commented

I merged main into the branch and pushed it. The conflict was in src/http/api.ts and is resolved.

  • The conflict: main had added keyedWindow and keyedPage, name-cursor paging helpers. This branch had offsetRefused, the 422 helper for the admin lists. They are independent, so I kept both and made no other changes.
  • Type check: bun x tsc --noEmit is clean.
  • Tests: the three suites that touch this change pass (16 pass, 0 fail): admin-lists-paged, api-errors and admin_refused. The full bun test run 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 against main to confirm. The forge's check will run them in full.
Changes requested — before the latest push

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):

  1. Make each summary say { items, next_cursor }, with limit 1 to 200 (default 50), cursor from next_cursor, and a bad limit or cursor answering 422. Follow the wording of the ${REPO}/events entry.
  2. 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.

commented

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 in src/contract/openapi.ts now say they answer { items, next_cursor }. Each states limit 1 to 200 (default 50), cursor taken from next_cursor, and a 422 for a bad limit or cursor, worded like the ${REPO}/events entry.
  • Each of the three declares query: { limit: "integer", cursor: "string" }.
  • /admin/people used to publish { people: [...] }. It now publishes items, 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 test and bun x tsc --noEmit did 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 — before the latest push

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.

commented

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

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs