fix(api): answer a validation failure with the documented error body - #223
Merged
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #222.
The bug
Request validation in
defineRouterefused a malformed request withres.status(400).json(error), handing theZodErrorstraight to the serializer.Confirmed against the pinned
zod@4.3.6, that produces:{ "name": "ZodError", "message": "[\n {\n \"code\": \"invalid_value\", ... }]" }No
errorkey at all, and the issues JSON-encoded insidemessage. Every routedeclares
400: ErrorSchema, whereerroris required, so the response violatedthe 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
validateis pushed onto the middleware stack ahead of it, so on avalidation 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
extractMessagereadserror, then falls back tomessage. With noerrorpresent it takes
message, soregisterPasskey()surfaced the entire encodedissue list as
error.message, ready to be rendered by an app doing thedocumented thing with an unrecognised failure. Verified against the SDK's own
toSeamlessAuthError, not inferred.Not specific to
attachment:validateis shared, so every route with aparams,queryorbodyschema answered a malformed request this way.The fix
{ "error": "invalid_request", "message": "Request failed schema validation.", "details": { "issues": [{ "path": ["attachment"], "code": "invalid_value", "message": "..." }] } }detailsfollows the reasoning already recorded onAdminValidationErrorSchema,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.
defineRoutedeclaresValidationErrorSchemaas the400for any route thatvalidates a request, so
openapi.jsondocuments the response validation actuallyreturns. A route that already declares a richer
400, such asAdminValidationErrorSchema, keeps it.A throw that is not a
ZodErrornow goes to the error handler rather than beingreported as a bad request, since it means a server fault rather than a malformed
request.
Compatibility
errorstays required on every failure response anddetailsis additive, so aconsumer reading only
erroris unaffected anderrorShapeCoverage.spec.tsstill passes. The only callers that could regress are ones parsing the raw
ZodErrorshape, which was undocumented, contradicted the declared schema, andhad no
errorfield to key off.The
openapi.jsonandsrc/generated/api.tsdiff is large but purely additive:no
400was removed, and the changed entries are the plain{ error, message }shape being widened with the optional
details. Regenerated withnpm run generate:api.Verification
npm run build,npm run lint,npm run format:check, andnpm 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-
ZodErrorthrow is forwarded tonext, thedeclared
400on a validating route, that a richer declared400is left alone,that a route validating nothing gets no validation
400, and an integration testfor the reported case (
GET /webauthn/register/start?attachment=bogus).