# Consolidate AuthContext bootstrap onto POST refresh + /users/me chain

**Task**: AZ-510_auth_bootstrap_consolidation
**Name**: Auth bootstrap refresh consolidation
**Description**: Replace the broken `GET /api/admin/auth/refresh` bootstrap path in `AuthContext.tsx` with the same `POST /api/admin/auth/refresh` (credentials-included) path the 401-retry already uses, chaining `GET /api/admin/users/me` to fetch the user shape. Closes the long-standing Finding B3 logged against Architecture Vision principle P3.
**Complexity**: 3 points
**Dependencies**: None (POST refresh path already lives in `api/client.ts:88` and is exercised by tests)
**Component**: 02_auth (primary); 03_shared-ui (Header.test.tsx MSW handlers); 01_api-transport (no source change, but tests reference `api/client.ts`)
**Tracker**: AZ-510
**Epic**: AZ-509

## Problem

The SPA has two refresh-token paths and they disagree:

- **Bootstrap (broken)** — `src/auth/AuthContext.tsx:24` issues `GET /api/admin/auth/refresh` WITHOUT `credentials: 'include'`. The `Secure HttpOnly` refresh cookie set by `POST /api/admin/auth/login` is therefore never sent on the bootstrap call; the server cannot recognise the session; the request fails; the `.catch(() => {})` swallows the error; `setLoading(false)` resolves to "no user"; `ProtectedRoute` redirects to `/login`. A returning user with a perfectly valid refresh cookie is silently bounced to login on every page load.

- **401-retry (works)** — `src/api/client.ts:88` issues `POST /api/admin/auth/refresh` WITH `credentials: 'include'`. This path runs only when a subsequent authenticated request hits a 401; it does NOT run on bootstrap because line 73's `if (res.status === 401 && accessToken)` short-circuits when `accessToken` is null (which it always is on cold boot).

The broken path was flagged in the architecture documentation review (Architecture Vision principle P3 — "bearer in memory, refresh in HttpOnly cookie") and again in `_docs/02_document/architecture_compliance_baseline.md` as downstream item B3. Step 4 (Testability) chose to leave it for a behaviour cycle because the fix changes the bootstrap response handling, not just hardcoded strings — outside the testability-revision allowed-changes list.

Observable failure mode today: every page reload by an authenticated user shows a brief `/login` redirect followed by a forced re-login. Operators have learned to ignore it; the behaviour normalises a UX regression that violates P3.

## Outcome

- A returning user with a valid refresh cookie loads any URL (`/`, `/flights`, `/dataset`, …) and lands on the intended route without redirecting through `/login`.
- A returning user with an expired/invalid refresh cookie sees `/login` exactly once — no flash of the protected shell, no infinite redirect loop.
- The `GET /api/admin/auth/refresh` request disappears from network traces in the bootstrap window.
- `POST /api/admin/auth/refresh` (with credentials) followed by `GET /api/admin/users/me` (with bearer) appears in network traces on every successful bootstrap.
- Existing MSW tests pass against the new code path; no test handler relies on the deprecated GET bootstrap.

## Scope

### Included

- `src/auth/AuthContext.tsx` — rewrite the `useEffect` mount handler to:
  1. `await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })` — direct call (not `api.post()`, because `api.post` does not carry `credentials: 'include'` and adding it there would change every callsite's CORS posture).
  2. On `!res.ok` → set `user: null` + `loading: false` + return.
  3. On success → `setToken(data.token)`, then `api.get<AuthUser>('/api/admin/users/me')` to fetch the user shape, `setUser(authUser)`, `setLoading(false)`.
  4. On the `/users/me` failure path → `setToken(null)`, `setUser(null)`, `setLoading(false)`. Do not throw silently — a 401 here is a genuine "refresh succeeded but the user record is gone" edge case worth surfacing through console.error.
- Tests (in-task; not deferred to a separate `test-spec sync` ticket):
  - `src/auth/AuthContext.test.tsx` — update bootstrap tests to assert `POST /api/admin/auth/refresh` then `GET /api/admin/users/me`. Drop GET-bootstrap expectations.
  - `src/auth/ProtectedRoute.test.tsx` — same MSW handler swap.
  - `src/components/Header.test.tsx` — same MSW handler swap (the test fires a full app render that exercises bootstrap).
- New i18n strings: NONE (the user-visible behaviour change is the absence of the spurious redirect, not new copy).
- A small note added to `_docs/02_document/components/02_auth/description.md` recording that bootstrap and 401-retry now share a single wire shape.

### Excluded

- Refresh-cookie rotation backend changes — server keeps its existing rotate-on-refresh policy unchanged.
- SSE bearer-rotation hardening (ADR-008 consequences) — separate ticket scope; the `?token=...` query-string refresh problem is not addressed here.
- Changing `api.post` to default `credentials: 'include'` — out of scope; would expand the test matrix to every POST callsite.
- Embedding the user payload in the POST refresh response — would be a backend wire-contract change; the chained `/users/me` GET is intentional and matches existing semantics.

## Acceptance Criteria

**AC-1: Bootstrap uses POST refresh with credentials**
Given a fresh app mount (no in-memory bearer)
When `AuthProvider` renders
Then exactly one outbound request is made to `POST /api/admin/auth/refresh` with `credentials: 'include'`; no `GET /api/admin/auth/refresh` request occurs.

**AC-2: Successful refresh chains to /users/me**
Given the POST refresh returns 200 with `{ token: '<bearer>' }`
When the response resolves
Then `setToken('<bearer>')` is called, then `GET /api/admin/users/me` is requested with `Authorization: Bearer <bearer>`; on its 200 response the returned `AuthUser` is exposed via `useAuth().user`; `loading` flips to `false`.

**AC-3: Failed refresh shows /login without flash**
Given the POST refresh returns 401 (no valid cookie) or a network error occurs
When the response is handled
Then `setUser(null)` + `setLoading(false)` are called; `ProtectedRoute` renders the spinner during the in-flight bootstrap and then renders `/login` exactly once; no protected route component renders even momentarily; no second redirect fires.

**AC-4: /users/me failure after refresh success clears the bearer**
Given the POST refresh returns 200 but the subsequent `GET /users/me` returns 401 or fails
When the failure is handled
Then `setToken(null)` is called, `setUser(null)` + `setLoading(false)` are called, the user lands on `/login`, and `console.error` carries a diagnostic message identifying the edge case (refresh OK / user GET failed).

**AC-5: Returning user is not bounced through /login**
Given a refresh cookie that the backend considers valid
When the user reloads any protected URL (e.g. `/flights`)
Then no `/login` route is rendered (verified via a Playwright e2e check or via the React-Router history not containing a `/login` entry); the user sees the protected route immediately after the bootstrap spinner.

**AC-6: No regression in the 401-retry path**
Given an authenticated session with an expired bearer (`accessToken` non-null but server-side expired)
When the user makes any API call from a feature page
Then the existing `api/client.ts:73` 401-retry path is unchanged, calls `POST /api/admin/auth/refresh` with credentials, rotates the bearer, and replays the original request — behaviour identical to today.

## Non-Functional Requirements

**Performance**: bootstrap latency added by the chained `/users/me` GET is observable but acceptable — both calls hit the same nginx, same auth, same machine in prod; budget: under 200 ms p95 for the chain on the suite dev compose stack.

**Compatibility**: no change to the backend contract. The chained `/users/me` GET already exists and is the only source of user shape today; tests prove it.

**Reliability**: every failure mode (refresh 401, refresh network error, refresh 200 + users/me 401, refresh 200 + users/me network error) must resolve `loading` to `false` and put the user on `/login`. No path may leave `loading: true` indefinitely.

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|--------------|------------------|
| AC-1 | `AuthContext` mount with no prior bearer | exactly one POST `/api/admin/auth/refresh` is made; no GET refresh |
| AC-2 | POST refresh 200 → users/me 200 | bearer set + user set + `loading: false` |
| AC-3 | POST refresh 401 | `setUser(null)` + `loading: false` + no further requests |
| AC-3 | POST refresh network error (MSW `HttpResponse.error()`) | same as 401 case |
| AC-4 | POST refresh 200 → users/me 401 | `setToken(null)` + `setUser(null)` + `loading: false`; console.error called |
| AC-6 | request → 401 → POST refresh 200 → replay → 200 | unchanged 401-retry behaviour (regression guard) |

## Blackbox Tests

| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|------------------------|--------------|-------------------|----------------|
| AC-1 | Browser with valid refresh cookie | Reload `/flights` | DevTools Network panel shows POST `/api/admin/auth/refresh` followed by GET `/users/me` — no GET refresh | — |
| AC-5 | Browser with valid refresh cookie | Reload `/flights` | `/flights` renders directly; no `/login` is visible at any point | — |
| AC-3 | Browser with expired refresh cookie | Reload `/` | Spinner briefly visible; then `/login`; no flash of the protected shell | Reliability |

## Constraints

- The `getApiBase()` helper is the ONLY source for the base URL — do not bypass it.
- The new bootstrap path must NOT use `api.post()` because that helper does not carry `credentials: 'include'`. Direct `fetch(..., { method: 'POST', credentials: 'include' })` is intentional; the comment in `api/client.ts:88` documents the same pattern.
- The MSW test handlers must run against the **production** code paths — no `vi.mock('api/client')` or equivalent allowed.
- `setToken(null)` must precede `setUser(null)` on every failure path so that an in-flight component re-render does not see a partial state where `user: null` but `accessToken: <stale-bearer>`.

## Risks & Mitigation

**Risk 1: POST refresh response shape varies across environments**
- *Risk*: The 401-retry path assumes `{ token }`; production may also return `{ token, user }` (unverified). If so, the chained `/users/me` GET is wasted work.
- *Mitigation*: Inspect the live response shape during implementation; if `user` is present, skip the chained GET. The contract is single-source in the backend Admin API spec — verify there first, not by guessing.

**Risk 2: Tests assume GET-bootstrap fail-soft behaviour**
- *Risk*: Some current tests may assert the broken behaviour as the expected outcome ("when bootstrap fails the user lands on /login"). Re-pointing those tests at the POST path may surface assertion bugs that have been masking real regressions.
- *Mitigation*: Read each test's assertions before swapping the handler; if the test was asserting the broken behaviour as a feature, replace the assertion with the AC-3 behaviour from this spec. Do not preserve a test that documents the bug.

**Risk 3: Bootstrap latency regression**
- *Risk*: Two sequential GETs on every page load is more network than one. For very slow refresh cookies (e.g., over slow links), the user perceives a longer spinner.
- *Mitigation*: NFR Performance budget (200 ms p95 on dev compose) is the gate. If a real-world deployment exceeds it, the next iteration may embed user in the POST refresh response (Excluded scope above).

**Risk 4: Concurrent `<StrictMode>` double-mount fires bootstrap twice**
- *Risk*: React 18+ StrictMode dev mode mounts effects twice; two concurrent POST refresh requests could race the cookie rotation (the backend rotates on every refresh).
- *Mitigation*: Add a module-scoped in-flight guard (a `Promise<void> | null` ref) so the second mount awaits the first. The guard is small enough to live inside `AuthContext.tsx` without a new helper.

## References

- `src/auth/AuthContext.tsx:23-31` — broken bootstrap path being replaced.
- `src/api/client.ts:88-98` — working POST refresh path that informs the new bootstrap.
- `_docs/02_document/components/02_auth/description.md` — component spec; F2 (two refresh paths) is the documented finding this task closes.
- `_docs/02_document/architecture_compliance_baseline.md` — downstream item B3 (will move to RESOLVED).
- `_docs/02_document/architecture.md` Architecture Vision P3 — "bearer in memory, refresh in HttpOnly cookie".
