Skip to content

fix(api): answer a validation failure with the documented error body - #223

Merged
Bccorb merged 1 commit into
mainfrom
fix/validation-error-shape
Aug 30, 2026
Merged

fix(api): answer a validation failure with the documented error body#223
Bccorb merged 1 commit into
mainfrom
fix/validation-error-shape

Conversation

@Bccorb

@Bccorb Bccorb commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Closes #222.

The bug

Request validation in defineRoute refused a malformed request with
res.status(400).json(error), handing the ZodError straight to the serializer.
Confirmed against the pinned zod@4.3.6, that produces:

{ "name": "ZodError", "message": "[\n  {\n    \"code\": \"invalid_value\", ... }]" }

No error key at all, and the issues JSON-encoded inside message. Every route
declares 400: ErrorSchema, where error is required, so the response violated
the contract the route published for itself and left a client nothing stable to
branch on.

Nothing caught it. The response-schema check is installed by wrappedHandler,
and validate is pushed onto the middleware stack ahead of it, so on a
validation failure that wrapper never runs and the mismatch was not even logged.

For a consumer the effect was worse than a missing code. The React SDK's
extractMessage reads error, then falls back to message. With no error
present it takes message, so registerPasskey() surfaced the entire encoded
issue list as error.message, ready to be rendered by an app doing the
documented thing with an unrecognised failure. Verified against the SDK's own
toSeamlessAuthError, not inferred.

Not specific to attachment: validate is shared, so every route with a
params, query or body schema answered a malformed request this way.

The fix

{
  "error": "invalid_request",
  "message": "Request failed schema validation.",
  "details": { "issues": [{ "path": ["attachment"], "code": "invalid_value", "message": "..." }] }
}

details follows the reasoning already recorded on AdminValidationErrorSchema,
which exists because a plain error schema would strip that list before it reached
the caller. Issues are mapped field by field rather than passed through, so a
refusal names which field was wrong without echoing back the value that was sent.
There is a test for that specifically.

defineRoute declares ValidationErrorSchema as the 400 for any route that
validates a request, so openapi.json documents the response validation actually
returns. A route that already declares a richer 400, such as
AdminValidationErrorSchema, keeps it.

A throw that is not a ZodError now goes to the error handler rather than being
reported as a bad request, since it means a server fault rather than a malformed
request.

Compatibility

error stays required on every failure response and details is additive, so a
consumer reading only error is unaffected and errorShapeCoverage.spec.ts
still passes. The only callers that could regress are ones parsing the raw
ZodError shape, which was undocumented, contradicted the declared schema, and
had no error field to key off.

The openapi.json and src/generated/api.ts diff is large but purely additive:
no 400 was removed, and the changed entries are the plain { error, message }
shape being widened with the optional details. Regenerated with
npm run generate:api.

Verification

npm run build, npm run lint, npm run format:check, and npm run test:run
(1152 passing, 1 skipped) all pass.

New coverage: the documented body on a validation failure, that a rejected value
is not echoed back, that a non-ZodError throw is forwarded to next, the
declared 400 on a validating route, that a richer declared 400 is left alone,
that a route validating nothing gets no validation 400, and an integration test
for the reported case (GET /webauthn/register/start?attachment=bogus).

Closes #222.

Request validation in defineRoute refused a malformed request with
res.status(400).json(error), handing the ZodError straight to the serializer.
That produced { name, message } with the issues JSON encoded inside message and
no error key at all. Every route declares 400: ErrorSchema, where error is
required, so the response violated the contract the route published for itself
and left a client nothing stable to branch on.

Nothing caught it: the response schema check is installed by the handler
wrapper, and validation fails before that wrapper runs, so the mismatch was
never even logged.

The effect on a consumer was worse than a missing code. The React SDK reads
error and falls back to message, so with no error key the whole encoded issue
list became error.message, ready to be rendered to a user by an app doing the
documented thing with an unrecognised failure.

Validation now answers { error: 'invalid_request', message, details.issues },
and defineRoute declares ValidationErrorSchema as the 400 for any route that
validates a request, so openapi.json documents what validation actually returns.
A route that already declares a richer 400, such as AdminValidationErrorSchema,
keeps it. error stays required everywhere and details is additive, so a consumer
reading only error is unaffected.

Issues are mapped field by field rather than passed through, so a refusal names
which field was wrong without echoing back the value that was sent. A throw that
is not a ZodError now goes to the error handler rather than being reported as a
bad request, since it means a server fault rather than a malformed one.

Verified with npm run build, npm run lint, npm run format:check, and
npm run test:run (1152 passing).
@Bccorb
Bccorb merged commit c60c3e3 into main Aug 30, 2026
4 checks passed
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.

[Bug]: Request validation answers 400 with a raw ZodError, violating the route's declared ErrorSchema

1 participant