ADR-0019: SPA–BFF integration architecture for the frontend build-out
Status: Accepted · Date: 2026-07-20 · Amended: 2026-09-05 (decision 3), and by ADR-0023 (decision 6) · Decision Makers: Vladislav Aleshaev
Context
Before this decision the SPA was a complete mock prototype: its RTK Query endpoints had no consumer, its auth was a bearer token behind a DEV_BYPASS_AUTH flag, the inverse of the BFF's cookie, and its hand-written types disagreed with the real contract. The backend contract has sharp edges the client must encode structurally: two envelope exceptions, a step-up 403 that is not ProblemDetails, two PIN models, a five-state idempotency protocol that fingerprints raw body bytes, an authenticated 401 (INVALID_PIN) and three sources of 429.
Decision
- One data client, one error channel. RTK Query only:
problemBaseQueryturns every failure into a typedApiProblemrouted onerrorCode, so that the contract's edges are encoded once (VALIDATION_ERRORsynthesised for a 400 witherrors, the step-up 403 recognised fromX-Auth-Level-Requiredbefore the body,retryAfterSecondsfrom the body first). Retry is structural: queries only, transport and gateway failures only, never mutations. - No API token ever reaches the browser. The access and refresh tokens stay in the BFF's session; transport auth is the session cookie, an opaque session id (ADR-0018): its name is
__Host-AzureBank.Sessionoutside Development and.AzureBank.Sessionin Development. Client auth state isunknown | anonymous | authenticated | expired, resolved by the one bootstrap probe (GET /bff/auth/me) and kept by string matchers on the RTK Query action shape, because the wire shape is the stable contract. - The global 401 rule routes on
errorCode, never on endpoint identity (D3). The codes ofIN_FLOW_401_CODESinsessionMiddleware.tsstay with the surface that asked:INVALID_PINin the calling form,INVALID_CREDENTIALSon login, and ADR-0042'sAUTHORIZATION_REQUIRED,AUTHORIZATION_EXPIREDandAUTHORIZATION_INVALIDin the transfer, because those are thrown after the cookie was accepted. The boot probe's 401 resolves toanonymous, with no banner. Every other 401 dispatchessessionExpired()and resets the RTK Query cache, because financial data must not outlive the session it was fetched under. - No polling, explicit keep-alive (D6/D14).
session-statusis the safe probe, because the BFF excludes it from activity (ADR-0018);/bff/auth/meis the deliberate refresher behind the "Stay signed in" warning, two minutes before the end, driven by a client mirror of LastActivity. No financial intent is ever parked in Web Storage: a session loss loses unsubmitted form state. DEV_BYPASS_AUTHis deleted, a one-way door (D20), because the auth behind it inverted the BFF's cookie architecture. A run with no backend returns only as the labelled MSW-worker mode. Development runs against the real BFF through the Vite proxy (D18); production is the BFF serving the built SPA (ADR-0054).- Superseded by ADR-0023. BFF response shapes are Zod schemas in
src/api/bffSchemas.ts, validated at runtime, andbffTypes.tsisz.inferof them. Hand-written mirrors ofBffResponses.csare not restored, because that would delete a live runtime guard on the weakest boundary in the system. Request bodies reuse the generated API types: the BFF forwards them. - One form system. react-hook-form and zod with Fluent
FieldandInputand theregister()spread, with Zod schemas that mirror the backend's validation contract.
Rejected
- Rejected:
endpoints.X.matchFulfilled, because it couples the auth slice to the api-slice module. - Rejected: routing a 401 by its endpoint, because some 401s come after the cookie was accepted.
- Rejected: a hand-rolled
FormFieldor a Controller adapter, because the pages bind natively.
Consequences
- Built on this contract: the route guard with
returnTo, registration's dual-path banner (D15) and a countdown for each source of 429 (D13). - The session cookie is HttpOnly: no script reads it, and a reload is answered on the cookie alone.
- Not covered: the BFF's own responses are not in the OpenAPI document (decision 6).
- Not covered: a draft that was not submitted is lost with the session (decision 4).
Verified by
frontend/src/features/auth/auth.test.tsx: the probe, the 401 rule, the guard, registration.frontend/src/api/policies.test.tsx: the data-layer policies.
Related
ADR-0018, ADR-0023, ADR-0042, ADR-0054.