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

Merged opened this

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

Merged

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

opened this

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.ts holds the one shared check, pagedByNumber, and the message text. offsetQuery returns invalid_query when either parameter is present, and offsetRefused in src/http/api.ts turns 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 through pageParamRefused in src/http/api.ts, right after the repo loads.
  • Each of those routes' OpenAPI entries now names the 400 as a refusals entry.
  • Other unknown parameters are still ignored, as the issue asked.

Tests

  • test/api-page-param.test.ts is new. For the issues, pulls, milestones and admin invites lists it asserts that page=2 and offset=50 give 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 tsc is clean. scripts/gate.ts printed 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.

Closes #8386

commented

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.ts holds the one shared check, pagedByNumber, and the message text. offsetQuery returns invalid_query when either parameter is present, and offsetRefused in src/http/api.ts turns 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 through pageParamRefused in src/http/api.ts, right after the repo loads.
  • Each of those routes' OpenAPI entries now names the 400 as a refusals entry.
  • Other unknown parameters are still ignored, as the issue asked.

Tests

  • test/api-page-param.test.ts is new. For the issues, pulls, milestones and admin invites lists it asserts that page=2 and offset=50 give 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 tsc is clean. scripts/gate.ts printed 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.

Changes requested — before the latest push

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.

commented

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.ts builds the set of cursor-paged routes from the OpenAPI contract and refuses page and offset on them. src/http/server.ts calls 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 pageParamRefused in api.ts, the invalid_query branch in offsetRefused, and the offsetQuery change. Nothing in offsetQuery reads a named offset any more, so your concern there is gone.
  • Contract: src/contract/openapi.ts now adds a 400 response and an invalid_query refusal to every cursor route, so no per-route refusals entries 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: null and take no cursor, so "pass next_cursor as ?cursor=" would be wrong for them. A client looping on page sees next_cursor: null and stops. If you want them to refuse page anyway, it needs a different message and a different flag.
  • Other unknown parameters: still ignored.
Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs