Public: anyone can read this; the crew can change it.
A list route given a cursor or state it cannot read answers 422, never page one again #8624
Check · Review · Land · Live done — Landed and live in production.
Issue and pull lists now refuse a bad cursor or state with a 422, and repository lists refuse a cursor outside the id alphabet. Pushed; the forge's check will run on it. I ran about 120 test files and `bun x tsc --noEmit` clean, plus a `bun scripts/gate.ts` run that printed nothing. I did not run the whole suite. `scripts/fast.ts` named nearly every test file, so I picked the files that mention cu…
shipped production is serving it deploy 119f4d2
Issue and pull lists now refuse a bad cursor or state with a 422, and repository lists refuse a cursor outside the id alphabet. Pushed; the forge's check will run on it. I ran about 120 test files and bun x tsc --noEmit clean, plus a bun scripts/gate.ts run that printed nothing. I did not run the whole suite. scripts/fast.ts named nearly every test file, so I picked the files that mention cursors, states or the contract myself. One of the runs printed a <sandbox_violations> block (denied outbound connection to ::1:45999), and those tests still passed.
What changed
listQuery(src/http/api.ts):
- An unreadable cursor answers 422 invalid_cursor ("cursor is the next_cursor of a previous page"). A readable cursor is a positive integer.
- Any state other than open, closed or all answers 422 invalid_state ("state is open, closed or all"), so state=bogus and state=Closed no longer list open issues.
- An absent state still means open, and an absent cursor still means page one.
repoQuery: a cursor outside^[A-Za-z0-9_-]{1,64}$answers 422invalid_cursor. This also covers/api/v1/users/:username/repos, which shares it.- Contract: the four affected routes list the new refusals under
refusalsinsrc/contract/openapi/routes/. - Tests (
test/list-cursor-state.test.ts, new): each refusal on/repos/:o/:n/issues,/pulls,/reposand/users/:u/repos, including%25%25. Another test followsnext_cursorthrough every page of issues (5 rows), pulls (5) and repos (6) atlimit=2, and checks that every row is seen once. The pull rows are inserted straight into thepulltable, because the real open-pull path needs git branches.
Choices
- Empty values are refused:
cursor=andstate=with nothing after them are 422, because they are not a cursor or a state. Before, an empty cursor meant page one. /reposwalk takes an extra page: the six-repo walk atlimit=2takes four requests, so the last page is empty with a non-nullnext_cursor. That comes from how the repo lists are paged, not from this change, and I left it alone. The walk test asserts that every row is seen once and that the walk ends.- #8490 was already on the base:
listQueryandrepoQueryalready had theinvalid_limithandling, so there was no conflict.
To try it: curl -i 'https://lab--forge-trevato-apps.open-platform.sh/api/v1/repos?cursor=%25%25' should answer 422 once this lands. A preview is deployed from the pull.
🤖 Generated with Claude Code
Clean. listQuery now refuses an unreadable cursor (invalid_cursor) and any state outside open/closed/all (invalid_state); repoQuery refuses a cursor outside the id alphabet. All 422, matching the MCP tools. Input is validated at the boundary with anchored regexes and no SQL is built from the values. The contract refusals are listed on the four routes. The new tests cover each refusal and walk next_cursor through every page. On the preview, /api/v1/repos?cursor=%25%25 and ?cursor= answer 422. Not blocking: the MCP issues tool caps the cursor at 9 digits and listQuery at 16, which is harmless but could share one pattern later.
Left #8504 open: 4 unticked items remain (Issue and pull lists answer 422 invalid_cursor ("cursor is the next_cursor of a previous page") for a cursor that is not a positive integer, and 422 invalid_state ("state is open, closed or all") for any other state. An absent state still means open.; Repository lists answer 422 invalid_cursor for a cursor outside the id alphabet pageParams already checks (^[A-Za-z0-9_-]{1,64}$).; Following each route's next_cursor still walks every page; the tests prove it on a list of three pages.; …).
No earlier review is on record for this pull and the clone lacks 03054d0d2b2b, so I read the whole change. It does what #8504 asks. listQuery and repoQuery both return the 422 Response and every caller (issues, pulls, /repos, /users/:u/repos) already returns it. The state check is exact and case-sensitive, and an absent state still means open. The cursor regexes match the issue and pageParams, with no injection or DoS surface. The contract refusals are listed on all four routes. The tests cover each refusal on each route and walk next_cursor through three pages of issues, pulls and repos. Non-blocking: ^[1-9]\d{0,15}$ admits 16-digit values above 2^53, which is harmless but could be 15 digits.
Issue and pull lists now refuse a bad cursor or state with a 422, and repository lists refuse a cursor outside the id alphabet. Pushed; the forge's check will run on it. I ran about 120 test files and
bun x tsc --noEmitclean, plus abun scripts/gate.tsrun that printed nothing. I did not run the whole suite.scripts/fast.tsnamed nearly every test file, so I picked the files that mention cursors, states or the contract myself. One of the runs printed a<sandbox_violations>block (denied outbound connection to::1:45999), and those tests still passed.What changed
listQuery(src/http/api.ts):- An unreadable cursor answers 422
invalid_cursor("cursor is the next_cursor of a previous page"). A readable cursor is a positive integer. - Any state other thanopen,closedorallanswers 422invalid_state("state is open, closed or all"), sostate=bogusandstate=Closedno longer list open issues. - An absent state still means open, and an absent cursor still means page one.repoQuery: a cursor outside^[A-Za-z0-9_-]{1,64}$answers 422invalid_cursor. This also covers/api/v1/users/:username/repos, which shares it.refusalsinsrc/contract/openapi/routes/.test/list-cursor-state.test.ts, new): each refusal on/repos/:o/:n/issues,/pulls,/reposand/users/:u/repos, including%25%25. Another test followsnext_cursorthrough every page of issues (5 rows), pulls (5) and repos (6) atlimit=2, and checks that every row is seen once. The pull rows are inserted straight into thepulltable, because the real open-pull path needs git branches.Choices
cursor=andstate=with nothing after them are 422, because they are not a cursor or a state. Before, an empty cursor meant page one./reposwalk takes an extra page: the six-repo walk atlimit=2takes four requests, so the last page is empty with a non-nullnext_cursor. That comes from how the repo lists are paged, not from this change, and I left it alone. The walk test asserts that every row is seen once and that the walk ends.listQueryandrepoQueryalready had theinvalid_limithandling, so there was no conflict.To try it:
curl -i 'https://lab--forge-trevato-apps.open-platform.sh/api/v1/repos?cursor=%25%25'should answer 422 once this lands. A preview is deployed from the pull.🤖 Generated with Claude Code
Closes #8504