Public: anyone can read this; the crew can change it.
A cursor-paged API list answers `?page=` with 400 naming `cursor`, instead of page one again #8396
Check · Review · Land · Live done — Landed and live in production.
Cursor-paged API lists now answer `?page=` or `?offset=` with 400 `{ error: "invalid_query", message: "This list pages by cursor: pass next_cursor as ?cursor=." }`, instead of quietly returning page one again.
shipped production is serving it deploy bc9d41d
Cursor-paged API lists now answer ?page= or ?offset= with 400 { error: "invalid_query", message: "This list pages by cursor: pass next_cursor as ?cursor=." }, instead of quietly returning page one again.
What changed
src/http/offset_page.tsholds the one shared check,pagedByNumber, and the message text.offsetQueryreturnsinvalid_querywhen either parameter is present, andoffsetRefusedinsrc/http/api.tsturns that into the 400.- Lists that use
offsetQuery(admin accounts, invites, people, milestones, and the others on it) get the 400 through that path. - The repository issues and pulls list handlers don't use
offsetQuery, so they call the same check throughpageParamRefusedinsrc/http/api.ts, right after the repo loads. - Each of those routes' OpenAPI entries now names the 400 as a
refusalsentry. - Other unknown parameters are still ignored, as the issue asked.
Tests
test/api-page-param.test.tsis new. For the issues, pulls, milestones and admin invites lists it asserts thatpage=2andoffset=50give 400 with the exact body, that?cursor=still gives 200, and that an unrelated parameter still gives 200.- Run: that file passes (4 tests), the three OpenAPI test files I ran pass, and
tscis clean.scripts/gate.tsprinted nothing, which I read as clean. I did not run the full suite; the forge's check does that on push.
To try it
Request GET /api/v1/repos/trevato/forge/issues?page=2 on this pull's preview. It should answer 400 with the message above.
Works for the lists it touches, but the issue says a cursor-paged API list refuses ?page=, and several still return page one again. Fix first: apply the check to every cursor-paged list route, not four. Still unguarded: src/http/api.ts:3071 (explore), src/http/api/user.ts:507, src/http/api/issues.ts:237, and the cursor lists in api/forges.ts, prompts.ts, drills.ts, app.ts and the events page. Best fix: one shared guard at the cursor parse point (or a route flag in the contract that the router checks) so a new list cannot forget it. Then add each newly guarded route's refusals entry. Tests: admin accounts and admin people are in the diff but untested; add them, and one of the newly guarded routes. Also the offsetRefused invalid_query branch in api.ts is a second path for the same 400 next to pageParamRefused; keep one. No docs/README change is needed, but docs/features/api-reference.md should mention the 400 if it lists paging.
Every GET route whose contract declares a cursor now answers ?page= or ?offset= with 400 { error: "invalid_query", message: "This list pages by cursor: pass next_cursor as ?cursor=." }. One guard does it, so a new list can't forget. The new test file passes, along with 39 related contract and OpenAPI test files. tsc is clean and scripts/gate.ts printed nothing, which I read as clean. I did not run the full suite: scripts/fast.ts found the diff too broad to name tests, so I ran those related files myself.
What changed
- One guard:
src/http/cursor_lists.tsbuilds the set of cursor-paged routes from the OpenAPI contract and refusespageandoffseton them.src/http/server.tscalls it once after auth and rate limits, before any handler runs. This covers the spots you named (explore,user.ts:507,issues.ts:237, the events page) and the admin lists, with no per-handler edits. - Removed the second path: I took out
pageParamRefusedinapi.ts, theinvalid_querybranch inoffsetRefused, and theoffsetQuerychange. Nothing inoffsetQueryreads a namedoffsetany more, so your concern there is gone. - Contract:
src/contract/openapi.tsnow adds a400response and aninvalid_queryrefusal to every cursor route, so no per-routerefusalsentries are needed. I reverted my earlier hand-written ones. - Two gaps found: the tags and milestones routes paged by cursor but didn't declare it in the contract. I added
query: { limit, cursor }to both so they are guarded. - Docs: one paragraph on the 400 in
docs/features/api-reference.md. - Tests:
- test/api-page-param.test.ts now covers eight lists over HTTP: issues, pulls, tags, milestones, admin accounts, people and invites, and explore. Each gets page and offset and must answer 400 with the exact body.
- Each list also gets an unrelated parameter and must still answer 200.
- A contract walk checks that the guard fires on exactly the cursor routes and that each one names the 400 in the document.
- The plain ?cursor= case from my earlier version is no longer asserted. The cursor routes are now covered through the contract walk rather than a request per list.
What I chose not to do
- Forges, prompts, drills, app env and domains, secrets, agents, egress grants, waves and assigned: I left these unguarded. They return the whole list with
next_cursor: nulland take no cursor, so "pass next_cursor as ?cursor=" would be wrong for them. A client looping onpageseesnext_cursor: nulland stops. If you want them to refusepageanyway, it needs a different message and a different flag. - Other unknown parameters: still ignored.
Approved. The earlier asks are met: one contract-driven guard in src/http/cursor_lists.ts replaces the second path, and tags and milestones now declare their cursor. The guard runs after auth and rate limits, so it leaks nothing. The preview answers 400 invalid_query for issues ?page= and tags ?offset=. Tests cover eight lists plus a contract walk, and the how-to is in docs/features/api-reference.md, not the README. No route that declares a cursor also uses page or offset as its own parameter. Non-blocking: lists that return everything with next_cursor null are left unguarded, which is a reasonable call.
Cursor-paged API lists now answer
?page=or?offset=with 400{ error: "invalid_query", message: "This list pages by cursor: pass next_cursor as ?cursor=." }, instead of quietly returning page one again.What changed
src/http/offset_page.tsholds the one shared check,pagedByNumber, and the message text.offsetQueryreturnsinvalid_querywhen either parameter is present, andoffsetRefusedinsrc/http/api.tsturns that into the 400.offsetQuery(admin accounts, invites, people, milestones, and the others on it) get the 400 through that path.offsetQuery, so they call the same check throughpageParamRefusedinsrc/http/api.ts, right after the repo loads.refusalsentry.Tests
test/api-page-param.test.tsis new. For the issues, pulls, milestones and admin invites lists it asserts thatpage=2andoffset=50give 400 with the exact body, that?cursor=still gives 200, and that an unrelated parameter still gives 200.tscis clean.scripts/gate.tsprinted nothing, which I read as clean. I did not run the full suite; the forge's check does that on push.To try it Request
GET /api/v1/repos/trevato/forge/issues?page=2on this pull's preview. It should answer 400 with the message above.Closes #8386