Skip to content

refactor(core,express)!: split result error into errorCode and errorBody - #124

Merged
Bccorb merged 1 commit into
mainfrom
refactor/unify-result-failure-contract
Jul 29, 2026
Merged

refactor(core,express)!: split result error into errorCode and errorBody#124
Bccorb merged 1 commit into
mainfrom
refactor/unify-result-failure-contract

Conversation

@Bccorb

@Bccorb Bccorb commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Step 0 of #72. Unblocks the applyResult and proxyRequest work, which cannot be written until a failure has one meaning.

Problem

result.error meant two different things depending on which handler produced it:

Meaning Rendered as Sites
the auth API's whole failure body res.json(result.error) 12
a short code chosen by this package res.json({ error: result.error }) 8

FinishLoginResult declared error?: string while assigning the whole parsed body. Harmless today only because Express happens to render that one with the raw call.

Nothing in the type says which rendering applies, and that is exactly the trap a second adapter walks into: read the type, see error?: string, wrap it as { error }, and silently change the response shape for every auth failure on 12 routes.

Change

Failures now go through ResultFailure, exported from @seamless-auth/core:

  • errorCode?: string is a code this package chose. Adapters render it as { error, details }.
  • errorBody?: unknown is the auth API's own failure body. Adapters forward it unchanged.

The Express errorBody() helper is renamed failureResponse() so it no longer collides with the new field name.

Why the body cannot simply be normalized

Two tidier-looking options were tested against the real SDK and rejected on evidence.

Wrapping everything as { error: <body> } breaks every error message: extractMessage in seamless-auth-react requires error to be a string, so an object falls through to the generic fallback. All 7 real API bodies regressed.

Normalizing through the existing readUpstreamFailure looked perfect, and was message-identical on all 7 bodies, but it moves sibling keys under details:

TODAY -> getOAuthErrorCode: "oauth_email_not_verified"
normalized -> getOAuthErrorCode: undefined

The API's OAuth failure schema is { error, message?, code? }, and the SDK reads that top-level code to drive user-actionable OAuth messaging. Hence errorBody is forwarded verbatim, and the reason is recorded in the type's doc comment so it does not get "cleaned up" later.

The HTTP responses do not change

Verified, not assumed. The adapter's failure responses were captured on main and on this branch across 6 upstream body shapes (plain code, code with a sibling field, OAuth code, Zod validation body, message-only rate limit, empty body) and 3 route kinds (passthrough, the OAuth union, proxy). All 18 are byte-identical, and all 18 produce identical results through extractMessage and getOAuthErrorCode from seamless-auth-react.

All 131 pre-existing Express tests, which assert real HTTP responses through supertest, passed without modification.

No adopter application, SDK, or dashboard needs to change.

New regression test

packages/express/tests/failureWireFormat.test.js locks these shapes, since PRs 2 and 3 of this epic will touch rendering again. It is not vacuous: reintroducing the old wrapping bug on a single route trips 7 of its assertions.

Breaking change

For code reading result.error off a handler imported from @seamless-auth/core/handlers/*. Read errorBody for the auth-flow handlers and errorCode for the rest; the OAuth union check "error" in result becomes "errorBody" in result. Major changeset included, with the same guidance.

Checks

pnpm build clean. pnpm test passes: 44 suites, 216 tests (up 11).

Note for the epic

Two things surfaced that are out of scope here and worth their own issues:

  1. A 4xx from upstream with an empty body produces an empty response on passthrough routes, so the SDK falls back to a generic message. Pre-existing and unchanged by this PR.
  2. getOAuthErrorCode depending on a top-level sibling key is what forces errorBody to stay verbatim. Teaching the SDK to read code from either location would free future cleanup here.

The handler result `error` field meant two different things. On 12 sites it
held the auth API's whole failure body, forwarded to the caller unchanged.
On 8 sites it held a short code the adapter wrapped as `{ error }`. The
declared types could not describe either honestly, and FinishLoginResult
declared `error?: string` while assigning the whole body. Nothing in the
type told an adapter which rendering applied, which is the first thing a
second adapter has to get right.

Failures now go through ResultFailure, exported from core: `errorCode` for a
code this package chose, which adapters render as `{ error, details }`, and
`errorBody` for the auth API's own body, which adapters forward untouched.
Callers read fields off that body directly, so it must not be reshaped.

The HTTP responses do not change. Failure responses were captured on both
revisions across 6 upstream body shapes and 3 route kinds, and all 18 are
byte-identical. They were also run through extractMessage and
getOAuthErrorCode from seamless-auth-react with identical results.

A new failureWireFormat test locks these shapes so later work on this epic
cannot move them silently.

BREAKING: code reading `result.error` off @seamless-auth/core/handlers/*
reads `errorBody` or `errorCode` instead, and the OAuth union check
`"error" in result` becomes `"errorBody" in result`.

Refs #72
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