Files
vmall/openspec/changes/replace-mock-api-wave-2/design.md
T
james 0ceb4a2b25 feat(mall): authenticate against the live API
Wave 2 of replacing the fixed-data mock adapter: the auth domain joins the live
list, so credentials, roles and tokens belong to the real user.

- session: validate a restored token through /auth/me instead of trusting
  localStorage, clearing it on 401/403 but keeping it when the API is merely
  unreachable; the route guard now uses the validated session
- login: report a 401 as invalid credentials rather than a generic failure, and
  drop the 6-character client rule so the API owns the password policy
- register: raise the rule to the API's 8 characters, remove the
  verification-code field (its button only counted down and the value was never
  sent), and report a duplicate email (409) distinctly
- TopBar and the user profile render their session-dependent branch client-only:
  validating the session before hydration made those localStorage-backed
  branches report hydration mismatches the previous code did not

Verified against the running backend: wrong password rejected, real JWT issued,
/user reachable, a short password refused with no network call, duplicate email
reported, a tampered token cleared and bounced to sign-in, a stale token kept
when the API is down, and the fixed-data rollback still signs in with the
backend stopped.

Also re-cuts docs/TBD-migrate-wave.md: auth is done, and cart, orders,
shipments and invoices must move together, because the mock adapter keeps their
state in one shared object and a partial flip fails at checkout.

OpenSpec change: openspec/changes/replace-mock-api-wave-2
2026-09-17 16:13:16 +00:00

4.2 KiB

Design

Context

See proposal.md — Why. After wave 1 the mall selects adapters per domain through liveDomains (apps/mall/plugins/api.ts); auth, cart, orders, shipments and invoices are what remain on fixed data.

Session truth today lives entirely in localStorage: vmall.token is read by the plugin's getToken, vmall.user is read by middleware/auth.ts, and both are written by stores/session.ts. The fixed-data me() returns a constant MOCK_USER, so nothing ever proves a stored token is still valid.

The live contract this wave targets (apps/api/src/routes/auth.rs):

Endpoint Behaviour
POST /auth/register 400 on invalid email, password under 8 characters, or blank display name; 409 on duplicate email
POST /auth/login 401 on bad credentials; returns { token, user }
GET /auth/me requires a bearer token; returns the User

ApiError already carries status and code (packages/shared/src/api.ts:26-34).

Goals / Non-Goals

Goals:

  • Credentials, roles and tokens become the real user's, with failures reported distinctly.
  • The flip stays confined to the auth domain, so every other domain keeps working.

Non-Goals:

  • No refresh-token or cookie/session-server move: the JWT stays in localStorage.
  • No password-reset backend; /forgot-password stays presentational, which is a known gap rather than something this wave fixes.
  • No change to what the buyer center displays — its data is still fixed.

Decisions

1. Validate a restored token once per load through me(), and only clear the session on 401/403. middleware/auth.ts currently trusts localStorage, so a stale or forged token reaches protected pages and fails later, further from the cause. A single me() call on hydrate puts the check where the session is established. Alternatives: a 401 interceptor inside the shared request() helper (touches all three apps — a shared-package change outside this wave); validating in every guarded page (one call per navigation). A network failure is deliberately not treated as a rejected token, so an unreachable API does not silently log the shopper out.

2. The 8-character rule lives in the panel and mirrors the API. The API is the authority; the client check exists only to avoid a pointless round trip and a generic error. Relaxing the API to accept 6 would weaken the backend and needs a change outside this wave.

3. Errors are mapped on ApiError.status/code, never on message text. Matching the API's prose would break whenever its wording changes. New strings go in apps/mall/locales-extra.ts, because AGENTS.md reserves the shared locale bundle for contract changes.

4. The register panel drops the verification-code field. It is a mandatory no-op: the button only starts a countdown, the value is never sent, and no endpoint issues a code. Keeping it would demand input the API never checks.

5. liveDomains gains auth; rollback stays a one-string edit. The fixed-data adapter keeps serving auth, so removing the entry restores the previous behaviour with no data migration.

Risks / Trade-offs

  • [A 401 from a still-mocked domain could be mistaken for a session failure] → only me() drives validation; cart, orders and invoices stay fixed-data and never answer 401.
  • [Validation adds a round trip before the session is usable] → it runs only when a stored token exists, and it replaces blind trust rather than adding a second source of truth.
  • [The demo loses "any password works"] → intended and marked BREAKING; the seeded customer@vmall.local account still demonstrates sign-in.
  • [The register panel becomes shorter than the backend's own validation] → the panel mirrors the API rule, and the API still enforces it independently.

Migration Plan

  1. Add auth to liveDomains and land the panel, session and middleware changes in the same commit, so no user can submit the old 6-character rule against the new API.
  2. Verify against the running backend with the seeded customer, then a wrong password, then a tampered token.
  3. Rollback: drop auth from liveDomains. The fixed-data adapter still serves the domain, and no stored data needs reverting.