Persiste proposal, specs delta (auth, api-client, access-requests, platform-shell, testing), design con 8 ADRs, tasks (27 items en 10 fases), verify-report (28 tests, 0 críticos, 0 warnings tras el cierre) y archive-report final. Cierra el ciclo SDD del cleanup de deuda técnica del console.
27 KiB
Design — console-tech-debt-cleanup
Phase: design (HOW at architectural level) Project: omnioilpersonal Surface:
services/frontend-consoleApproach: targeted refactor over the existing layered structure (features/<domain>/{api,pages,stores}+lib/api+app/{layouts,router}). No new architecture, no new layers — eliminate duplications, tighten contracts, install the safety net.
0. Architecture context (recap)
frontend-console already follows the standard React + Vite + Zustand + React Query layout:
src/
app/
layouts/PlatformLayout.tsx <- shell (sidebar + Outlet + Toaster)
router/{index.tsx, ProtectedRoute.tsx}
features/
auth/ (stores, api, schemas, pages)
access-requests/ (api, pages)
lib/
api/client.ts <- apiClient + SessionCoordinator (the choke point)
utils/cn.ts
components/ui/...
The runtime contract that apiClient talks to (Rust backend in services/backend-api) is the source of truth. We do NOT change the contract; we change how the frontend models it.
Backend login response shape (verified): services/backend-api/src/handlers/auth.rs:232-242 returns
{
"access_token": "...",
"token": "...", // legacy duplicate, comment "Compatibilidad con clientes legacy"
"role": "...",
"email": "...",
"projects": [...]
}
Same dual emission for refresh at auth.rs:500-509. The backend will keep emitting both access_token and token for the foreseeable future — that is a backend decision, not ours. Our job here is to stop the frontend from caring about token.
1. ADR-001 — Single auth token field: accessToken
Decision: drop the legacy token field from useAuthStore state. accessToken becomes the single source of truth. Components, route guards, and effects read accessToken only.
Rationale:
- Today
auth-store.ts:32,39,44stores bothaccessTokenANDtoken(mirrored). Consumers split:ProtectedRoute.tsx:9-29andLoginPage.tsx:16,24,29readstate.token, whileclient.ts:31readsstate.accessToken. Two fields, two readers, same value — pure duplication that drifted because nobody noticed they were equal. - The backend already keeps the dual emission for legacy clients. We do not need a second field on the frontend to "track legacy"; the fallback lives on the wire.
- Frontend-admin (the reference app) already uses only
accessTokenfor live state. Confirmed atservices/frontend-admin/src/lib/api/client.test.ts:124,146— it assertsstate.accessToken === 'fresh-token'and that on logoutaccessToken: null, refreshToken: null, token: null. Note: frontend-admin still keepstoken: nullin the store as a vestigial slot. We will go further and remove the slot entirely since console has no such legacy consumers outside the two files we control.
Migration plan (execution belongs to tasks, not design):
- Audit step before any rename — run a regex sweep:
useAuthStore.*\.token\bandstate\.tokenfor selector reads\['token'\]and\["token"\]for bracket-string reads (THE landmine)token:inside object literals destructured from the store
- Replace selectors:
state.token→state.accessToken. Update dependency arrays accordingly. - Remove the
tokenslot fromAuthState, theset({ token: ... })mirror writes, and the initialtoken: null. - TypeScript will catch every typed read site at build. Bracket-string reads are NOT caught by TS — that's why step 1 is mandatory before rename.
Rejected alternatives:
- Keep both fields, document
accessTokenas canonical: doesn't fix the bug, just adds a comment. Future refactors will keep splitting. - Rename to
tokeninstead ofaccessToken: contradicts the backend field name and the rest of the codebase (admin usesaccessToken).
Risk: A bracket-string read like useAuthStore.getState()['token'] would silently start returning undefined. Mitigation = step 1 audit. Acceptance criterion = grep returns zero matches before merge.
2. ADR-002 — LoginResponse type matches the runtime exactly
Decision: redefine LoginResponse in src/features/auth/api/auth-api.ts as
export interface LoginResponse {
access_token: string // required — backend always sends it
refresh_token?: string // optional in JSON body (backend prefers HttpOnly cookie)
role: string
email: string
projects?: Array<{ id: string; name: string }> // present in admin runtime; opaque to console
}
No token. No ?? fallback in LoginPage.onSubmit.
Runtime contract source: services/backend-api/src/handlers/auth.rs:232-242 (login) and :500-509 (refresh). The fields confirmed present: access_token, token (legacy mirror — we choose to ignore it), role, email, projects. refresh_token is NOT in the JSON body for login (it goes into the Set-Cookie header at line 223-225); it IS present in some refresh paths. Marking it optional in our type is honest.
Rationale:
- Today
auth-api.ts:4-9declarestoken: stringREQUIRED andaccess_token?OPTIONAL — the exact opposite of the runtime. The current code only works becauseLoginPage.tsx:45doesresponse.access_token ?? response.token, papering over the lying type. - A type that lies about the runtime is worse than no type. It actively misleads autocomplete and code review.
Where verified: services/backend-api/src/handlers/auth.rs:232-242. Documenting this in the spec so the next refactor doesn't have to re-derive it.
Rejected alternatives:
- Keep
tokenrequired for backward compat with the legacy emission: we already chose not to consume the legacy field (ADR-001). Keeping it in the type just to "match the wire" leaks legacy concerns into TS. - Make everything optional: then
LoginPagemust handle every undefined case at runtime. That's defensive coding against our own backend.
3. ADR-003 — Split apiClient: apiClient<T> + apiClientVoid
Decision: option (a) from the proposal. Two named exports:
export async function apiClient<T>(endpoint: string, options?: FetchOptions): Promise<T>
export async function apiClientVoid(endpoint: string, options?: FetchOptions): Promise<void>
apiClient<T> throws if the response is 204 (no body to parse — caller asked for a body). apiClientVoid returns void and ignores the body for 2xx, including 204.
Both share an internal executeWithRefresh(endpoint, options): Promise<Response> that owns the 401 → refresh → retry flow currently embedded in apiClient. The two public functions are thin wrappers that decide what to do with the final Response.
Rationale:
- Today
client.ts:96,111returnsnull as Tfor 204. Every caller that types<{ ok: boolean }>getsnullat runtime, which the type system happily lies about. The "fix" in the test (client.test.ts:144—.rejects.toThrow) only catches the rejection path, not the success-with-204 path. - Option (a) makes the void-vs-body decision explicit at the call site, where the caller already knows whether the endpoint returns a body.
- Mechanical migration: every existing call returns a body today (login, listAccessRequests, approve/reject/review). Zero call sites need to switch to
apiClientVoidin this change. We add the function for future mutation endpoints that return 204.
Rejected alternatives:
- (b) Generic with overload
apiClient<T | void>: TypeScript overloads on generics are fragile and confuse autocomplete. Caller still has to remember which overload they invoked. - (c) Always return
T | nulland let callers narrow: every caller writesif (data === null) ...for endpoints that NEVER return null. That's the worst-of-both-worlds — keeps the lie AND adds boilerplate. - Throw on 204 inside
apiClient<T>only, no second function: works but leaves no migration path for legitimate void endpoints; first call site that needs 204 will reintroduce thenull as Tcast.
Internal shape (illustrative — implementation lives in tasks/apply):
async function executeWithRefresh(endpoint, options): Promise<Response> {
const { response } = await executeRequest(endpoint, options)
if (response.status === 401 && !options._retry && !isBypassedEndpoint(endpoint)) {
const refreshed = await SessionCoordinator.refresh()
if (!refreshed) {
useAuthStore.getState().logout()
redirectToLogin()
throw new Error('Sesion expirada')
}
const { response: retried } = await executeRequest(endpoint, { ...options, token: refreshed, _retry: true })
if (!retried.ok) {
// same fail-open behavior as today
const err = await retried.json().catch(() => ({ error: 'Request failed' }))
throw new Error(err.error ?? `HTTP ${retried.status}`)
}
return retried
}
if (!response.ok) {
const err = await response.json().catch(() => ({ error: 'Request failed' }))
throw new Error(err.error ?? err.message ?? `HTTP ${response.status}`)
}
return response
}
export async function apiClient<T>(endpoint, options = {}): Promise<T> {
const r = await executeWithRefresh(endpoint, options)
if (r.status === 204) throw new Error(`apiClient: endpoint ${endpoint} returned 204; use apiClientVoid`)
return r.json() as Promise<T>
}
export async function apiClientVoid(endpoint, options = {}): Promise<void> {
await executeWithRefresh(endpoint, options)
}
The 204-throws-in-apiClient is a dev-time guard. No production endpoint we currently call returns 204; if one ever does, it's a contract violation we want to see loudly, not silently coerce to null.
4. ADR-004 — ProtectedRoute first-load: persisted user as the heuristic
Decision: drive the "should we attempt a refresh on cold load?" decision off the persisted user slot. Logic:
on mount:
if accessToken exists in memory: render <Outlet/> (or redirect on bad role) — done
else if persisted user exists (rehydrated from localStorage): show "Restaurando sesion" + call refresh()
else: render <Navigate to="/login"/> immediately — no refresh attempt, no flash
Today ProtectedRoute.tsx:9-34 ALWAYS attempts a refresh when there's no token, regardless of whether the user ever logged in. That produces the "Restaurando sesion..." flash for first-time visitors.
Rationale:
- The persist allowlist (
auth-store.ts:54) already savesuserto localStorage. Ifuserrehydrated → there was a prior session → refresh might restore it (cookie still valid). Ifuserdid not rehydrate → cold visitor → no point hitting/auth/refresh. - Zero changes to
partialize— the heuristic uses what's already persisted. - Eliminates the spurious refresh request AND the cosmetic flash in one move.
Implementation sketch:
const accessToken = useAuthStore(s => s.accessToken)
const user = useAuthStore(s => s.user)
const hasHydrated = useAuthStore.persist.hasHydrated()
// 1. wait for hydration before deciding
const [phase, setPhase] = useState<'pending'|'restoring'|'ready'>(hasHydrated ? 'ready' : 'pending')
useEffect(() => {
if (!hasHydrated) return
if (accessToken) { setPhase('ready'); return }
if (!user) { setPhase('ready'); return } // cold visitor — no refresh
setPhase('restoring')
let cancelled = false
SessionCoordinator.refresh().finally(() => { if (!cancelled) setPhase('ready') })
return () => { cancelled = true }
}, [hasHydrated, accessToken, user])
if (phase !== 'ready') return <RestoringSession />
if (!accessToken || !user) return <Navigate to="/login" replace />
if (!PLATFORM_ROLES.has(user.role)) return <Navigate to="/login" replace />
return <Outlet />
(Actual code goes in tasks; this sketch is the contract design enforces.)
Rejected alternatives:
- Persist
accessTokenin localStorage: leaks JWT to XSS. The whole point of the cookie-based refresh is that the access token lives in memory only. - Skip refresh attempt entirely on every cold load: breaks the legitimate "I closed the tab, came back, refresh cookie still valid" path.
- Add a
hasEverLoggedInboolean to the store: redundant —useralready serves that role.
5. ADR-005 — ErrorBoundary placement: inside PlatformLayout, around <Outlet />
Decision: a class-based error boundary lives inside PlatformLayout.tsx, wrapping ONLY the <Outlet />. Sidebar, logout button, and Toaster stay outside the boundary so they survive a page-level crash.
Rationale:
- A render error in
AccessRequestsPage(or any future child route) should NOT take down the shell. The user must still be able to navigate elsewhere or sign out. - Wrapping at the
RouterProviderlevel would crash the whole app — same regression as today. - The login route is a single form with no children; React Router's natural error handling is enough. No boundary on
/login.
Library choice: native React class component. No dependency added. Frontend-console's only state libs are React Query + Zustand — both have their own error surfaces (isError, error boundaries via ErrorBoundary is the standard React pattern). Adding react-error-boundary would be one more devdep for a 30-line class.
Shape:
class ShellErrorBoundary extends React.Component<{children: React.ReactNode}, {error: Error|null}> {
state = { error: null as Error | null }
static getDerivedStateFromError(error: Error) { return { error } }
componentDidCatch(error: Error, info: React.ErrorInfo) {
// log to console for now — we don't have Sentry/observability in console yet
console.error('[ShellErrorBoundary]', error, info)
}
render() {
if (this.state.error) {
return <FallbackPanel error={this.state.error} onReset={() => this.setState({ error: null })} />
}
return this.props.children
}
}
The fallback shows the error message + a "Reintentar" button that resets the boundary state. This forces a remount of the route subtree.
Rejected alternatives:
- Boundary at
RouterProviderlevel: kills the shell — rejected. - Per-route boundary: more flexible but premature. Console has 2 routes today.
- Add
react-error-boundary: extra dep for negligible ergonomic gain at this surface size.
6. ADR-006 — AccessRequestsPage error UX: inline card + soft-fail stat cards
Decision:
- When
requestsQuery.isErroris true, replace the table region (the<Card className="overflow-hidden">inAccessRequestsPage.tsx:176-213) with an inline error card containing the error message and a "Reintentar" button that callsrequestsQuery.refetch(). - When
allRequestsQuery.isErroris true, the fourStatCardcomponents show0(using?? 0ongetStatusCount). Stats failing must NOT block the table. - The detail panel (
RequestDetail) keeps rendering its empty state whenselectedis null — no change. - Loading state stays as today (centered spinner). Error state replaces what would otherwise be a silently-empty table.
Rationale:
- Today both queries fail invisibly (
AccessRequestsPage.tsx:71-72:requestsQuery.data ?? []). The user sees an empty table and no signal that the network failed. That's a correctness bug for a page that approves/rejects platform access. - Stats and the table are independent fetches with independent failure modes. Coupling them (e.g., refusing to render the table because stats failed) is the wrong UX.
refetchon the inline button is the canonical React Query recovery path. No need to invent retry logic.
Shape (illustrative):
{requestsQuery.isLoading ? (
<Spinner />
) : requestsQuery.isError ? (
<Card>
<CardContent className="space-y-3 py-10 text-center">
<p className="text-sm text-red-300">{(requestsQuery.error as Error).message}</p>
<Button variant="secondary" onClick={() => requestsQuery.refetch()}>Reintentar</Button>
</CardContent>
</Card>
) : (
<TableAndDetail ... />
)}
Rejected alternatives:
- Toast on every fetch error: users lose the recovery affordance the moment the toast dismisses. Also noisy.
- Use the global ErrorBoundary: the boundary is for render errors, not for query failures. Mixing them blurs the contract — users would see "something went wrong" for a transient network blip.
- Block the page on stat failure: stats are a side panel. Failing them should not break the primary workflow.
7. ADR-007 — Vitest scaffolding: mirror frontend-admin's inline vite.config.ts test block
Decision: do NOT add a separate vitest.config.ts. Mirror the frontend-admin pattern, which puts the test: block inside vite.config.ts (verified at services/frontend-admin/vite.config.ts:28-33). Add the test setup file at src/test/setup.ts (mirror of admin's services/frontend-admin/src/test/setup.ts).
Final config additions (delta against current state):
services/frontend-console/vite.config.ts — add test block:
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
restoreMocks: true,
clearMocks: true,
},
services/frontend-console/src/test/setup.ts — verbatim copy of admin's setup. Stubs localStorage/sessionStorage, sets IS_REACT_ACT_ENVIRONMENT, clears storage beforeEach, unstubs globals afterEach.
services/frontend-console/package.json — add:
"test": "vitest run"to scripts- devDeps:
vitest@^3.2.4,jsdom@^26.1.0(matching admin versions to avoid two majors in one repo) - The proposal also mentions
@testing-library/react,@testing-library/user-event,@vitest/coverage-v8. Frontend-admin does NOT have these — its tests are pure logic + RTL-free. Recommendation: only add what we will actually use in this change. The test priorities (next ADR) cover store + coordinator + apiClient + a page. The page test (AccessRequestsPage) needs RTL. Coverage is nice-to-have, not required to land tests.- Add now:
@testing-library/react@^16,@testing-library/dom@^10,@testing-library/user-event@^14 - Defer:
@vitest/coverage-v8until someone asks for coverage gating - Defer:
msw— for the page test we mock the api module directly (vi.mock('@/features/access-requests/api/access-requests-api')), simpler than network interception
- Add now:
Rejected alternatives:
- Separate
vitest.config.ts: divergent from admin without a reason. Two configs to maintain. - Add
mswupfront: heavier setup; the api modules in console are thin enough that module-level mocking is sufficient and faster. - Add coverage from day one: coverage is meaningless until there's a baseline of tests AND a target threshold. Premature.
Diff snapshot for tasks phase (so the breakdown is mechanical):
M services/frontend-console/package.json (scripts.test, devDependencies)
M services/frontend-console/vite.config.ts (+ test block)
+ services/frontend-console/src/test/setup.ts
+ services/frontend-console/src/features/auth/stores/auth-store.test.ts
+ services/frontend-console/src/lib/api/client.test.ts
+ services/frontend-console/src/features/access-requests/pages/AccessRequestsPage.test.tsx
8. ADR-008 — Test ordering (TDD-first sequencing)
Decision: tests land BEFORE the corresponding refactor, in this exact order:
auth-store.test.ts—setSessionwritesaccessToken,refreshToken,user;logoutclears all of them;isAuthenticated()reflects state. Pure unit, no I/O, no jsdom features beyond what setup.ts provides. This test must PASS against the current store (with the legacytokenfield still present). After ADR-001 lands, the test still passes — we only assert onaccessToken, never ontoken.SessionCoordinator.refreshdedupe — race test using mockedfetch: two concurrentrefresh()calls, assert one network call, both promises resolve to the same token. Mirror admin'sclient.test.ts:44-86. Must PASS against current code (the dedupe already works — this test pins the behavior so the apiClient split doesn't break it).apiClient401 retry path — mockfetchto return 401, then 200 on the refresh, then 200 on the retry. Assert headers, theAuthorization: Bearer fresh-tokenon retry, and final body. Mirror admin'sclient.test.ts:88-126. Also assert:apiClient<{ok:boolean}>on a 204 throws (this is the new contract from ADR-003 — test goes red until ADR-003 ships).AccessRequestsPage.test.tsx— happy path render with mocked api module: status filter changes, refetch button calls both queries, approve/reject mutations show success toast, AND the new error UI from ADR-006 renders when the query mock rejects. Some assertions go red until ADR-006 ships.
Rationale:
- Tests 1-2 pin existing correct behavior FIRST. They protect against accidental regressions during the rename in ADR-001.
- Tests 3-4 contain assertions that intentionally fail today and pass once the refactor lands. That is the TDD signal. Strict TDD mode is active for this project — this is non-negotiable.
- This ordering means the rename in ADR-001 has a green safety net before it runs, exactly as the proposal demands.
Rejected alternatives:
- All tests after the refactor: defeats the point of strict TDD. Also leaves the rename uncovered during the highest-risk window.
- Page test first: too much surface; would require the apiClient split AND the error UI AND mocks all at once. Bottom-up is faster.
9. Component & data-flow map (post-change)
┌─────────────────────────────────────────────────────────────────┐
│ RouterProvider (root) │
│ ┌───────────────────────────┐ ┌──────────────────────────┐ │
│ │ /login → LoginPage │ │ /app/platform/* tree │ │
│ │ reads: accessToken,user │ │ ProtectedRoute (gate) │ │
│ │ writes: setSession() │ │ reads: accessToken,user │ │
│ │ │ │ persist.hasHyd.. │ │
│ └───────────────────────────┘ │ decides: redirect / │ │
│ │ restoring / │ │
│ │ render Outlet │ │
│ │ ┌────────────────────┐ │ │
│ │ │ PlatformLayout │ │ │
│ │ │ sidebar + logout │ │ │
│ │ │ ┌──────────────┐ │ │ │
│ │ │ │ ErrorBoundary │ │ │ │
│ │ │ │ <Outlet/> │ │ │ │
│ │ │ └──────────────┘ │ │ │
│ │ └────────────────────┘ │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
useAuthStore (Zustand, persist:{ user })
▲ ▲ ▲ ▲
│ │ │ │
ProtectedR. LoginPage PlatformL. apiClient
(reads accessToken)
│
▼
┌──────────────────────────┐
│ apiClient<T> | apiClientVoid
│ ↓ executeWithRefresh │
│ ↓ 401 → SessionCoord. │
│ ↓ /auth/refresh│
└──────────────────────────┘
│
▼
backend-api (Rust)
Integration points (unchanged contracts):
POST /auth/login→ returns{access_token, role, email, projects, refresh_token?}(cookie also set)POST /auth/refresh→ returns{access_token, role, email}(rotated cookie)GET /platform/access-requests→AccessRequest[]POST /platform/access-requests/:id/{review,approve,reject}→AccessRequest
None of these contracts change in this refactor.
10. Architectural risks & assumptions
| ID | Risk / assumption | Severity | Mitigation |
|---|---|---|---|
| R1 | Bracket-string read of state['token'] exists somewhere TS can't see |
medium | Mandatory regex audit (ADR-001 step 1) before rename; CI grep guard optional |
| R2 | Backend stops sending access_token and only sends token |
low | Backend code (auth.rs:232-236) sends both today and the comment marks token as the legacy one; type contract aligned with what's primary |
| R3 | ProtectedRoute heuristic mis-detects "cold visitor" if user got persisted but cleared via direct localStorage manipulation |
low | Acceptable — reduces to current behavior (refresh attempt) |
| R4 | apiClient<T> 204 throw breaks an existing call we missed |
low | Grep: every existing call site expects a body. Add a smoke pass during apply |
| R5 | RTL test for AccessRequestsPage is flaky on async mutations |
medium | Use findBy* queries + waitFor; mock api at the module boundary, not the network |
| R6 | Vitest version drift between admin and console | low | Pin both to ^3.2.4 (admin's version) |
| R7 | ErrorBoundary swallows real bugs in dev | low | componentDidCatch logs to console; React's default red overlay still surfaces in dev mode |
Unresolved decisions: none. All eight items have a concrete decision above.
Assumptions requiring validation in the apply phase:
- A1: No bracket-string
'token'reads exist in the console source tree (validate via grep before rename). - A2: No current caller of
apiClient<T>hits a 204 endpoint (validate by inspecting all four call sites:loginApi,listAccessRequests,markAccessRequestInReview,approveAccessRequest,rejectAccessRequest,SessionCoordinator.performRefresh).
11. Out of scope (explicit non-goals for this design)
- Refresh token rotation rework
- Migrating frontend-admin or landing apps
- E2E tests
- Coverage gating
- Observability (Sentry, etc.) wiring into the ErrorBoundary
- Removing the legacy
tokenemission from the backend - Project/tenant selection state (admin has it; console does not need it yet)
These belong in future changes if/when the need is real.