[AZ-510][AZ-511][AZ-512][AZ-513] Cycle 3 Steps 12-15 + admin prereq
ci/woodpecker/push/build-arm Pipeline failed

Wrap up cycle 3 across the autodev existing-code Phase B steps that
follow Implement (Steps 12-15), plus the cross-workspace prerequisite
ticket filed for AZ-512.

Step 12 - Test-Spec Sync:
- Un-quarantine FT-P-01 in traceability-matrix (closed by AZ-510)
- Add AZ-510 chained /users/me failure-path test reference under AC-23
- Note AZ-512 deferral status under O9 (P12 Phase B target)

Step 13 - Update Docs (task mode):
- Refresh src__auth__AuthContext module doc with AZ-510 wire shape
  (POST refresh + chained /users/me + bootstrapInflight guard)
- Add usersMe() to src__api__endpoints module doc + consumer note
- Rename src__features__annotations__classColors module doc to
  src__class-colors__classColors (matches AZ-511 git mv); refresh header
- Refresh src__components__DetectionClasses + src__features__annotations
  module group doc for the new class-colors barrel import path
- Update components/11_class-colors Module Inventory to point at the
  renamed module doc filename
- Rewrite system-flows.md Flow F2 (Bearer auto-refresh) with the AZ-510
  POST + chained /users/me sequence; close Finding B3 references
- Generate ripple_log_cycle3 documenting all changed source files,
  their reverse-dependency search results, and the docs touched

Step 14 - Security Audit (cycle-3 delta):
- Resume mode against cycle-2 baseline; cycle-2 artifacts untouched
- Re-run bun audit on both roots: clean (cycle-2 inline fix held)
- Re-rate OWASP A06: FAIL -> PASS; A07: PASS_WITH_KNOWN -> PASS (B3
  closed by AZ-510)
- New finding F-SAST-CY3-1 (LOW): __resetBootstrapInflightForTests
  exposed via src/auth public barrel; defer to hygiene cycle
- Verdict: FAIL -> PASS_WITH_WARNINGS; one HIGH (F-SAST-1
  mission-planner git-history key, unchanged) remains
- Add amendment banner to cycle-2 security_report.md

Step 15 - Performance Test:
- Static profile NFT-PERF-01 PASS (290 575 B gzipped vs 2 MB budget;
  ~14% of budget; no regression from AZ-510 surface additions)
- E2E profile SKIP (Playwright perf project still pending AZ-457..AZ-482);
  legitimate skip per test-run skill, gap acknowledged in report
- AZ-510 200ms p95 chain NFR verified at spec level only - no CI gate
  yet (covered by future AZ-457..AZ-482 work)

Cross-workspace prerequisite (AZ-513 just filed):
- Updated _docs/_process_leftovers/2026-05-13_az-512-admin-classes-prereq.md
  to reflect AZ-513 filing on admin/ workspace (parent epic AZ-509,
  Blocks link to AZ-512). Companion task spec added in admin/ repo
  (separate commit there, owned by admin/ workspace).

State file: advanced to Step 16 (Deploy) per autodev existing-code flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 03:58:21 +03:00
parent 6c7e29722f
commit 09449bda2c
14 changed files with 448 additions and 41 deletions
@@ -1,7 +1,8 @@
# Module: `src/auth/AuthContext.tsx`
> **Source**: `src/auth/AuthContext.tsx` (54 lines)
> **Source**: `src/auth/AuthContext.tsx` (~120 lines after AZ-510)
> **Topo batch**: B3 (depends on B2 leaves: `api/client`, `types/index`)
> **Last refresh**: 2026-05-13 — AZ-510 consolidated bootstrap onto POST refresh + chained `/users/me`; closes Vision P3 / Finding B3.
## Purpose
@@ -31,16 +32,30 @@ State:
- `user: AuthUser | null``null` when unauthenticated.
- `loading: boolean``true` until the initial refresh attempt resolves (success or failure). Renders should gate on this.
**Bootstrap effect (mount-only)**:
**Bootstrap effect (mount-only)** — AZ-510 wire shape:
```ts
api.get<{ user: AuthUser; token: string }>(endpoints.admin.authRefresh())
.then(data => { setToken(data.token); setUser(data.user) })
.catch(() => {})
.finally(() => setLoading(false))
async function runBootstrap(): Promise<AuthUser | null> {
const refreshRes = await fetch(getApiBase() + endpoints.admin.authRefresh(), {
method: 'POST',
credentials: 'include',
})
if (!refreshRes.ok) return null
const refreshData = (await refreshRes.json()) as { token: string }
setToken(refreshData.token)
try {
return await api.get<AuthUser>(endpoints.admin.usersMe())
} catch (err) {
console.error('[AuthContext] Refresh succeeded but /users/me failed:', err)
setToken(null)
return null
}
}
```
The refresh endpoint is invoked with `credentials: 'include'` only inside `client.ts`'s **internal** `refreshToken()` helper — but here we go through the public `api.get()` path, which does NOT include credentials. **This is a real divergence**: `client.ts`'s internal `refreshToken()` (used in the 401 retry) sends the cookie; the bootstrap call in `AuthContext` does not. The endpoint must therefore accept the refresh either via cookie (then bootstrap fails silently for non-cookie clients — which is everyone after a hard reload) **or** via some other mechanism (a refresh token in `localStorage`, etc.). **Flag for Step 4 verification** against the `admin/` service contract; this is likely a real bug masking by silent `.catch`. The path string itself is unaffected by AZ-486 — `endpoints.admin.authRefresh()` produces `'/api/admin/auth/refresh'` character-identically to the pre-refactor literal, so the divergence is structural, not URL-based.
A module-scoped `bootstrapInflight: Promise | null` guard is consulted before invoking `runBootstrap`, so two concurrent `useEffect` mounts (React 18+ StrictMode dev double-mount, or rapid re-mount in tests) share a single network round-trip and avoid racing the backend's refresh-cookie rotation. A test-only escape hatch `__resetBootstrapInflightForTests()` is exported via the `src/auth` barrel and called in `tests/setup.ts`'s `afterEach` to keep the module-scoped promise from leaking between tests.
The bootstrap and the existing 401-retry path in `api/client.ts:73` now share a single wire shape — both POST `/api/admin/auth/refresh` with `credentials:'include'` and rely on the HttpOnly refresh cookie. The chained `GET /api/admin/users/me` request fetches the user payload (the POST refresh response is `{ token }` only). On any failure path (refresh 401, refresh network error, refresh 200 → `/users/me` 401, refresh 200 → `/users/me` network error) the bootstrap clears the bearer first then sets `user: null` + `loading: false`, so an in-flight re-render never sees `(user: null, accessToken: <stale>)`. Closes Vision principle P3 ("bearer in memory, refresh in HttpOnly cookie") and Finding B3.
**`login(email, password)`**:
@@ -60,7 +75,7 @@ setToken(null); setUser(null)
Network failure on logout is silently swallowed because we want to clear local auth state regardless.
**`hasPermission(perm)`**: returns `user?.permissions.includes(perm) ?? false`. The permission strings are not constrained at the type level — any string passes. Backend-defined.
**`hasPermission(perm)`**: returns `user?.permissions?.includes(perm) ?? false`. Defensively handles legacy `/users/me` payloads that omit `permissions` (older backend builds; some test fixtures returning the bare `User` shape). Permission strings are not constrained at the type level — any string passes. Backend-defined; UI uses this only for affordance show/hide, never for security gates (the server is the authority — see `_docs/02_document/architecture.md` Vision P12 / O4).
## Dependencies
@@ -103,14 +118,11 @@ No env vars consumed directly — token storage policy is defined in `client.ts`
## Tests
None.
`src/auth/AuthContext.test.tsx` — un-quarantined `FT-P-01` (bootstrap POST + `credentials:'include'` + chained `/users/me` regression guard); `FT-P-03` (refresh transparency, child re-render delta ≤ 1); `NFT-SEC-01` (bearer never in localStorage / sessionStorage across the full bootstrap + 401-retry lifecycle); `NFT-SEC-02` (no refresh-prefixed cookie visible via `document.cookie`); `AC-4 (AZ-510)` — POST refresh 200 → `/users/me` 401 clears the bearer + logs a diagnostic console.error.
## Notes / open questions
- **Bootstrap-vs-refresh divergence** (above) — the highest-priority flag in this module. Either:
1. The refresh endpoint accepts an Authorization-less, cookie-bearing call → confirm the `admin/` service sets an HttpOnly cookie on `/login` and the cookie path matches `/api/admin/auth/refresh`. The `api.get()` path in `client.ts` does NOT send `credentials: 'include'`, so this currently CANNOT work. → **likely bug**.
2. Or the bootstrap should be calling the internal `refreshToken()` helper, which is currently not exported.
Either way, this needs a Step 4 fix (export `refreshToken()` and call it here, or change `api.get()` to allow per-call `credentials`).
- ~~**Bootstrap-vs-refresh divergence**~~ — **RESOLVED 2026-05-13 by AZ-510**. Bootstrap now uses POST + `credentials:'include'` + chained `/users/me`, sharing the same wire shape as the 401-retry path. `api.get()` is intentionally NOT used for the refresh itself because it does not thread `credentials:'include'`; the bootstrap calls `fetch()` directly with the same explicit-credentials pattern documented in `api/client.ts:88`. Finding B3 closed.
- **`AuthContext = createContext<AuthState>(null!)`**: the non-null assertion means `useAuth()` will throw at the destructuring site if it's used outside `AuthProvider`. Acceptable given `App.tsx` mounts `AuthProvider` at the top, but a guard `if (!ctx) throw new Error(...)` would be friendlier. Defer.
- The `loading` flag is never re-set to `true` after the initial bootstrap. `login` and `logout` complete synchronously from the React tree's perspective (the `await` is inside the callback). If a future requirement demands a "logging in…" indicator, it would need its own state. Note for Step 8.
- `useAuth` returns the raw context value (no memoisation wrapper). React 18+ behaviour means `<AuthProvider>` re-renders all `useAuth` consumers on every state update — fine here because there's no high-frequency state.