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
This commit is contained in:
2026-09-17 16:13:16 +00:00
parent e0e833d0e5
commit 0ceb4a2b25
15 changed files with 280 additions and 94 deletions
+38 -25
View File
@@ -1,46 +1,58 @@
# TBD — migrate the mall off the mock API (waves 2+)
# TBD — migrate the mall off the mock API (waves 3+)
Wave 1 is planned in the OpenSpec change `openspec/changes/replace-mock-api-wave-1/`
(catalog + currency). This file tracks everything after it.
Waves 1 and 2 are captured in `openspec/changes/replace-mock-api-wave-1/` (catalog +
currency) and `replace-mock-api-wave-2/` (auth). This file tracks what remains.
**How to use:** check a box only once the behaviour is implemented *and* verified against
the live backend (`cargo run -p vmall-api`, `node scripts/seed-demo.mjs`). Wave 1 leaves a
per-domain switch in `apps/mall/plugins/api.ts`, so most of these are adding a domain name
to the live list — each still needs its own verification below.
the live backend (`cargo run -p vmall-api`, `node scripts/seed-demo.mjs`). The per-domain
switch in `apps/mall/plugins/api.ts` means most of these are adding a domain name to the
live list — each still needs its own verification below.
**Delete this file** once every box in Waves 2 and 3 is checked. Wave 4 is optional: if you
decide against it, delete this file anyway and record that decision wherever you like.
**Delete this file** once every box in Wave 3 is checked. Wave 4 is optional: if you decide
against it, delete this file anyway and record that decision wherever you like.
---
## Wave 2 — auth + cart
## Wave 2 — auth (done)
- [ ] Flip `auth` to live and verify `login` / `register` / `me` against `:8080` using the seeded `customer@vmall.local` / `customer123`.
- [ ] Confirm bad credentials now produce a real 401 — the mock accepted any input (`apps/mall/mock/api.ts:125`), so this is a deliberate UX change.
- [ ] Confirm the JWT round-trips through the `vmall.token` localStorage key, shared with shop-admin and admin, and that logout clears it.
- [ ] Flip `cart` to live. Cart requires a token (`AuthUser` on all four handlers in `apps/api/src/routes/cart.rs`) and real SKU ids from Wave 1 — both are prerequisites, not optional.
- [ ] Remove cart's mock display coupling: `apps/mall/pages/cart.vue:4,34,41,56,60` uses `productById` / `storeById` for shop name, image and stock. The live `CartView` already returns `product_name`, `image` and `unit_price_minor` (`apps/api/src/cart.rs:44-53`).
- [ ] Verify: add / qty update / remove / empty cart for a logged-out user, and the out-of-stock 409 path.
Captured in `openspec/changes/replace-mock-api-wave-2/`; auth is live, with the session
validated through `/auth/me` rather than trusted from `localStorage`.
## Wave 3 — orders + shipments + invoices
- [x] Flip `auth` to live and verify `login` / `register` / `me` against `:8080` using the seeded `customer@vmall.local` / `customer123`.
- [x] Confirm bad credentials now produce a real 401 — the mock accepted any input (`apps/mall/mock/api.ts:125`), so this is a deliberate UX change.
- [x] Confirm the JWT round-trips through the `vmall.token` localStorage key, shared with shop-admin and admin, and that logout clears it.
- [ ] Flip `orders` to live and verify checkout splits one cart into per-shop orders with a price snapshot and stock decrement.
- [ ] Checkout still sources `shipping_address` from `MOCK_ADDRESSES` (`apps/mall/pages/checkout/index.vue:4,23,37,127`) — that is intentional; see the out-of-scope note below.
- [ ] Verify cancel restores stock and `payOrder` only accepts `pending_payment` (both are enforced live; the mock only mimicked them).
- [ ] Flip `shipments` to live and verify `confirmDelivered` moves the order to `completed`.
- [ ] Flip `invoices` to live and verify a company invoice requires a tax number and that one order can hold only one active invoice.
## Wave 3 — the transaction chain: cart + orders + shipments + invoices
These move **together**, not one at a time. The mock adapter keeps
`state.cart -> state.orders -> state.shipments` in a single shared state, so a partial flip
leaves the mock half reading state the live half never populates:
- live cart + mock `checkout()` fails with `EMPTY_CART` (`apps/mall/mock/api.ts:194`),
- mock `requestInvoice()` 404s on any live order id (`mock/api.ts:285`),
- mock `listMyShipments()` returns shipments whose `order_id` matches no live order, so the
shipment block is silently empty (`mock/api.ts:282`, `pages/user/orders/[id].vue:25`).
- [ ] Flip `cart`, `orders`, `shipments` and `invoices` in the same change, then verify one purchase end to end: add to cart, check out into per-shop orders, pay, ship, confirm delivery, request an invoice.
- [ ] Verify cancel restores stock and `payOrder` only accepts `pending_payment` (both enforced live; the mock only mimicked them).
- [ ] Verify a company invoice requires a tax number, and that one order can hold only one active invoice.
- [ ] Remove cart's mock display coupling: `apps/mall/pages/cart.vue:4,34,41,56,60` uses `productById` / `storeById` for shop name, image and stock. The live `CartView` already returns `product_name`, `image` and `unit_price_minor` (`apps/api/src/cart.rs:44-53`) — but **not** the shop, so decide whether to add a shop field to `CartItem` in the shared contract or accept ungrouped lines until the public store read exists.
- [ ] Decide what caps cart quantity: `CartItem` carries no `stock`, so `maxFor` falls back to 999 (`pages/cart.vue:60`). The live cart does not enforce stock either — only checkout does (409). Either add `stock` to `CartItem` or keep the cap at checkout and say so.
- [ ] Gate add-to-cart for anonymous shoppers: a live `addCartItem` on the public product page returns 401, and `pages/goods/[id].vue` is not behind the auth middleware.
- [ ] Checkout keeps sourcing `shipping_address` from `MOCK_ADDRESSES` (`pages/checkout/index.vue:4,23,37,127`) — intentional; see the out-of-scope note below.
- [ ] Fix contract debt so the TS types stop lying: `Shipment.items` is required in `packages/shared/src/types.ts` but the live struct has no `items` field (`apps/api/src/models.rs:172-182`), and `Invoice.invoice_no` is nullable live (`models.rs:194`) but non-null `string` in TS.
- [ ] Remove the remaining `storeById` mock usage on the order pages (`apps/mall/pages/user/orders/index.vue:4`).
- [ ] Confirm the mall still renders when the live API is down (mock mode remains the fallback for local UI work).
- [ ] Confirm the mall still renders when the live API is down, with every domain configured to fixed data.
## Wave 4 — optional new backend capabilities
## Wave 4 — optional, mostly new backend capabilities
Only if you want more of the storefront backed by real data. Each is a new capability, not a flip.
- [ ] **Storefront content** — banners, promos, quick links, home floors and floor advert art. Needs real tables, admin CRUD and i18n JSONB. Do this first if you want the home page fully live; it is the most visible remaining mock surface.
- [ ] **Public store read** — a buyer-facing shop endpoint so `stores/index` and `stores/[id]` leave mock. Small: products already carry `shop_id`, and the public catalog already joins shops for the active check.
- [ ] **Storefront content** — banners, promos, quick links and floor advert art. Needs real tables, admin CRUD and i18n JSONB. Do this first if you want the home page fully live; it is the most visible remaining mock surface.
- [ ] **Public store read** — a buyer-facing shop endpoint so `stores/index`, `stores/[id]` and the cart's shop grouping leave mock. Small: products already carry `shop_id`, and the public catalog already joins shops for the active check.
- [ ] **Brand model + sales/comments sorts** — restores the brand facet and the sorts removed in Wave 1. Needs a `brands` table (`products.brand_id` + i18n) plus sales and comments data, neither of which exists today.
- [ ] Extend the `ORDER BY` whitelist if more sorts are wanted beyond the `sort=price` added in Wave 1.
- [ ] *(adjacent, not part of the migration)* Move the session token to a cookie so SSR knows whether anyone is signed in. Today a full page load of a guarded route renders the page and then redirects on the client, which logs a hydration mismatch; it is pre-existing (verified identical before Wave 2) and harmless, but it is the real fix for the `ClientOnly` workarounds in `components/shell/TopBar.vue` and `pages/user.vue`.
---
@@ -57,3 +69,4 @@ These have no API contract and no backend model. Leaving them on `~/mock/data` i
- Category filtering is by **subtree**; the backend exact-match filter was the bug (fixed in Wave 1).
- A migration wave must not change what the UI claims: facets without a backing model are removed rather than left matching nothing.
- The mall is the last mock holdout; `shop-admin` and `admin` already run live against the same backend.
- Auth flips independently, but the transaction domains do not: the mock's shared cart/order/shipment state makes any partial flip fail loudly.