Feature: Typed API client and session store with global 401, unverified-403, 429 and offline handling As the maintainer of RenewCMS I want useApi to expose one generic authenticated request method that every future screen call site builds on, that turns a mid-session 401 into a redirect to /login with the expired reason and clears the session, turns a 403 from a known-unverified session into a redirect to /register/pending, surfaces the Retry-After seconds from a 429 so screens can show a countdown, and marks the app offline on a network failure So that later screen slices (login, articles, editor, ...) get consistent global session and connectivity handling instead of each reimplementing 401/403/429/offline handling ad hoc Background: Given useApi exports a generic request(path, options) method, used by getMe and by every future endpoint call, that always sends credentials: 'include' and parses a JSON response body on success And a non-2xx response makes request(...) throw an ApiError carrying the numeric status; a 429 response also carries retryAfter, the integer number of seconds parsed from the response's Retry-After header, or undefined if the header is absent or not a plain integer And a fetch that rejects instead of resolving (no connectivity, DNS failure, the request never reaches a server) makes request(...) throw a NetworkError instead of an ApiError, so callers can tell "the server answered with an error" apart from "the request never got an answer" with instanceof And useApi exports a shared reactive isOffline ref, module-scoped so every caller of useApi() and every component observes the same value: a NetworkError sets it true; it is also true whenever navigator.onLine is false; a request that completes (2xx or a non-network ApiError) while navigator.onLine is true sets it back to false And getMe() calls request('/v1/auth/me', { onUnauthorized: 'passthrough' }): a 401 from this one call still throws a plain ApiError with status 401, exactly as before, and does not trigger the redirect below, because this call only ever runs as part of session.check() during router navigation (features/nuxt_ui_shell_foundation.feature), and the router's own guard already redirects to /login?redirect=&reason=expired for a "no session" boot result And every other call to request(...) uses the default onUnauthorized: 'redirect': on a 401, before the ApiError reaches the caller, useApi calls the session store's clear() action (session = null, status stays 'ready') and navigates the router to /login?redirect=&reason=expired, where is router.currentRoute.value.fullPath at the moment the 401 is received And on a 403, useApi reads the session store: if it holds a session whose verified is false, this is the global "unverified account" case from docs/design/routes.md and states.md, so useApi navigates the router to /register/pending; the ApiError still reaches the caller as a plain 403, and the session store is left untouched, since it already correctly records verified: false; any other 403 (no session, or a session whose verified is true) is not the global case: useApi does nothing extra and the ApiError propagates to the caller unchanged, for the screen to interpret (wrong-credentials at login, already-verified at cancel-registration, an expired verify or reset link, and registration-closed are all screen-level, out of scope here) And the session store gains a clear() action (session = null, status = 'ready', independent of check()/retry()) used by the global 401 handling above Scenario: A successful authenticated request returns the parsed body Given a mocked fetch that resolves 200 with JSON body {"id": 5} When useApi().request('/v1/article/5') is called Then the returned value is {"id": 5} And isOffline is false Scenario: A 401 received mid-session redirects to login with the expired reason and clears the session Given the session store holds session {"verified": true} and status "ready" And the current route is /article/12/edit And a mocked fetch that resolves 401 When useApi().request('/v1/article/12') is called Then the call rejects with an ApiError whose status is 401 And the session store's session becomes null and its status stays "ready" And the router navigates to "/login?redirect=/article/12/edit&reason=expired" Scenario: The boot call to getMe does not trigger the global 401 redirect Given the session store holds session null and status "checking" And a mocked fetch that resolves 401 When useApi().getMe() is called directly, outside of router navigation Then the call rejects with an ApiError whose status is 401 And the router does not navigate anywhere Scenario: A 403 while the session is known to be unverified redirects to the pending page Given the session store holds session {"verified": false} and status "ready" And a mocked fetch that resolves 403 When useApi().request('/v1/article/') is called Then the call rejects with an ApiError whose status is 403 And the router navigates to "/register/pending" And the session store's session is still {"verified": false} Scenario Outline: A 403 that is not the global unverified case passes through untouched Given the session store holds session and status "ready" And a mocked fetch that resolves 403 When useApi().request('/v1/some/endpoint') is called Then the call rejects with an ApiError whose status is 403 And the router does not navigate anywhere And the session store is unchanged Examples: | session | | null | | {"verified": true} | Scenario: A 429 on a rate-limited endpoint exposes the Retry-After seconds Given a mocked fetch that resolves 429 with a Retry-After header of "30" When useApi().request('/v1/auth/login', { method: 'POST' }) is called Then the call rejects with an ApiError whose status is 429 and whose retryAfter is 30 Scenario: A 429 with no Retry-After header exposes an undefined retryAfter Given a mocked fetch that resolves 429 with no Retry-After header When useApi().request('/v1/auth/password-reset', { method: 'POST' }) is called Then the call rejects with an ApiError whose status is 429 and whose retryAfter is undefined Scenario: A network failure throws a NetworkError and marks the app offline Given navigator.onLine is true And a mocked fetch that rejects with a TypeError, as a real fetch does when the network is unreachable When useApi().request('/v1/article/') is called Then the call rejects with a NetworkError, not an ApiError And isOffline becomes true Scenario: A later successful request clears the offline flag Given isOffline is true from a previous network failure And navigator.onLine is true And a mocked fetch that resolves 200 with JSON body {} When useApi().request('/v1/article/') is called Then isOffline becomes false Scenario: The browser's own online/offline events also drive the shared flag Given isOffline is false When the browser fires a "offline" event on window Then isOffline becomes true When the browser fires a "online" event on window Then isOffline becomes false