Overview
Six decisions that define the storefront. Each has context, choice, why, and trade-off — verified against next.config.ts, lib/api/*, features/auth/session-context.tsx, and components/guards.tsx.
ADR-001: The storefront proxies the API at the edge
Status: Accepted
Context: Browser → separate API origin means CORS configuration and third-party cookie risks, and every environment would need a distinct allowlist.
Decision: next.config.ts rewrites /api/v1/:path* → ${API_ORIGIN}/api/v1/:path* (API_ORIGIN trimmed and trailing-slash stripped). The browser talks to one origin; session cookies stay first-party. NEXT_PUBLIC_API_BASE_URL remains relative (/api/v1) so the Axios instance works identically locally (3001 → 3000) and on Vercel (→ Render).
Why: CORS disappears, cookies behave identically across environments, and the backend can move hosts without a client change.
Trade-off: Requests hop through the frontend host — negligible latency, one more moving part in deployment.
Alternatives: Direct browser-to-API with CORS_ORIGIN allowlist + SameSite=None; Secure cookies — correct but leaks CORS surface and requires per-environment tuning.
ADR-002: Opaque session cookies, probe-based role visibility
Status: Accepted
Context: Sessions expose no role payload; the UI still needs to decide which navigation to show.
Decision: SessionProvider probes staff tiers: GET /admin/products?page=1&limit=1 (admin vs non-admin, 200 vs 403) and, when admin, GET /admin/audit?page=1&limit=1 (super-admin vs admin). Results are cached 10 minutes (staleTime, no window-focus refetch) and drive isAdmin / isSuperAdmin. Guards (AdminGate, SuperAdminGate) render ForbiddenCard on failure; the API independently enforces authorization on every /admin/* path.
Why: UI visibility is responsive without a privileged token in JavaScript.
Trade-off: An extra probe per staff session — acceptable, and it avoids duplicating role issuance in the cookie.
ADR-003: One Axios instance, one CSRF flow
Status: Accepted
Context: Every cookie-authenticated write requires a double-submit CSRF token; N concurrent writes must not fan out to N token fetches.
Decision: A single axios instance (lib/api/axios-instance.ts, withCredentials: true) is shared; lib/api/csrf.ts shares one inflight fetchCsrfToken() promise; a request interceptor attaches x-csrf-token on writes, and a response interceptor on 403 with a CSRF error code retries the original request once after refreshing the token. Unknown 401s emit session-events.ts and clear the QueryClient.
Why: Callers never implement CSRF handling; concurrency is correct by construction.
Alternatives: Per-feature Axios instances (duplicated interceptors) or disabling CSRF for the storefront (weaker security posture).
Trade-off: A failed CSRF refresh that resolves to 401/403 clears cookies and broadcasts expiry — visible but correct, the user is forced to re-authenticate.
ADR-004: TanStack Query with a centralized key factory
Status: Accepted
Context: Listing params (page, limit, search, sort, status) must produce distinct, predictable cache entries — and must not be mirrored into local stores.
Decision: All server state lives in TanStack Query (app/providers.tsx, staleTime: 30s, disabled for 400/401/403/404/409/422, capped retries). Keys are built by a single factory (lib/api/queryKeys.ts): qk.products(params), qk.cart, qk.admin.orders(params), etc., with full params in the key.
Why: Adds a screen with zero cache ambiguity — any developer can invalidate or prefetch by key shape alone.
Alternatives: Zustand-mirrored server state (duplication, drift) or ad-hoc string keys (collisions, typo-prone invalidation).
Trade-off: Slightly larger key space, but it eliminates the "stale mirror" problem entirely.
ADR-005: Typed pagination envelope handling
Status: Accepted
Context: The API's paginated envelope carries both data and pagination at top level; naive consumers typing list calls as bare T[] compile yet explode at runtime.
Decision: lib/api/client.ts:unwrap normalizes both pagination locations, reconstitutes { ...body, pagination } or { ...body.data, pagination }, and callers type lists as Paginated<T> ({ data, pagination: { page, limit, total, hasNext, hasPrev } }). params.ts helpers (sortParam) derive sort strings from { field, desc } objects. Components read data and pagination together.
Why: A single unwrapping rule covers both pagination dialects; the Paginated<T> convention documents the pitfall that once caused a shipped bug (76a6de0).
Trade-off: One extra generic layer on every list type — cheap and self-documenting.
ADR-006: Direct-to-ImageKit signed uploads
Status: Accepted (mirrors backend ADR-0001)
Context: Product and review images are large; proxying bytes through the API wastes server resources while the CDN already serves them.
Decision: The client requests short-lived HMAC params from GET /uploads/imagekit-auth or GET /admin/products/uploads/imagekit-auth, uploads directly to ik.imagekit.io / *.imagekit.io, then registers the returned HTTPS URL via admin image endpoints. next.config.ts explicitly allows only ImageKit remotePatterns; persisted URLs are re-validated server-side for host, folder allowlist, and extension.
Why: The API never touches image bytes yet stays the security boundary.
Trade-off: A two-step flow (sign → upload → register) and an allowlist that must be maintained — but no bandwidth cost on the API.
Common thread
Security into the boundary (proxy + cookies + CSRF), correctness into the contract (typed envelopes, probe-based guards), and media onto the CDN — so application code stays boring on purpose.