Skip to content

fix(api): return 400 for a malformed list cursor instead of page one - #222

Merged
stewartjarod merged 1 commit into
mainfrom
fix/api-templates-cursor-validation
Oct 28, 2026
Merged

stewartjarod merged 1 commit into
mainfrom
fix/api-templates-cursor-validation

Conversation

@stewartjarod

Copy link
Copy Markdown
Contributor

Summary

A malformed cursor on the list routes returned 200 with page one again, so a client following a corrupted cursor looped silently. decodeCursor returned null and callers treated null as "no cursor"; it was also lenient (new Date("5") parses, so base64("5|abc") was accepted).

GET /v1/templates/pull | /v1/templates | /v1/email/logs
  parseCursorParam(query.cursor)        # apps/api/src/lib/cursor.ts (new)
    absent        -> null (first page)
    valid         -> decoded position
    invalid       -> 400 BAD_REQUEST
  decodeCursor accepts only a value that re-encodes to exactly the input

On /pull the parse runs before the unpaginated branch, so a bad cursor is a 400 with or without limit.

Evidence

  • Before: six malformed cursors (not base64, %%%, empty, base64 of JSON, 5|abc, non-canonical timestamp) -> 200 with page one.
    After: all 400. Pagination still walks every page with limit=1, no row repeated or dropped.
@wraps/api test    195 files / 1997 tests pass
packages/db        25 files / 259 tests pass (unit)
test:baseline      151 pass, scan clean
api typecheck, biome on changed files: clean

The no-limit /pull test passes but was added after the fix and not run against the old code. The CLI needs no change: apiFailure maps 400 to VALIDATION_FAILED and prints the API message (that mapping lands with #219).

Merge Danger

Door: two-way

?cursor= (empty) used to count as absent and now returns 400. Any cursor the API did not issue is rejected, including hand-edited ones.

Blast Radius: API clients that paginate

GET /v1/templates and GET /v1/email/logs share the decoder and change too. apps/web/src/lib/aws/ses-suppression.ts has its own lenient decodeCursor, left alone.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GxJAQNUP4FKZHL1yzPDsGd

decodeCursor returned null for an undecodable cursor and the callers treated
null as "no cursor", so GET /v1/templates, GET /v1/templates/pull and
GET /v1/email/logs answered 200 with the first page. A client following a
stale or corrupted cursor looped over page one with no error.

- decodeCursor (packages/db) now accepts only a value encodeCursor could
  produce: the decoded date must re-encode to the input. This rejects
  lookalikes such as "5|abc", which new Date() parses as a valid date.
- apps/api/src/lib/cursor.ts parseCursorParam: absent cursor is the first
  page; a present but invalid cursor sets 400 and throws, which the standard
  error handler reports as { error, code: "BAD_REQUEST" }.
- The three cursor-paginated routes use it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GxJAQNUP4FKZHL1yzPDsGd
@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
wraps-web Ready Ready Preview Oct 28, 2026 6:34pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
wraps-website Skipped Skipped Oct 28, 2026 6:34pm UTC

Request Review

@vercel
vercel Bot temporarily deployed to Preview – wraps-website October 28, 2026 18:32 Inactive
@stewartjarod
stewartjarod merged commit 2d00a01 into main Oct 28, 2026
17 of 20 checks passed
@stewartjarod
stewartjarod deleted the fix/api-templates-cursor-validation branch October 28, 2026 19:45

This branch was successfully deployed

1 active and 1 inactive deployments
Preview – wraps-web — 6064b814 Deployed Oct 28, 2026 by vercel[bot]
Preview – wraps-website — 6064b814 Deployed Oct 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant