ui: per-container #comp lifecycle, exp-claim auth refresh TTL

- hoover: #comp registry + expanded-content cache now per render
  container; committing one root no longer unmounts/remounts
  components owned by another root (infinite load loop on pages
  whose load() re-mutates reactive state)
- auth_model: refresh timer scheduled from the token's remaining
  exp claim (unverified decode, mirrors lib/auth.py); falls back to
  the configured TTL for non-JWT/malformed/already-expired tokens
- docs: hoover.md documents both behaviors
- tests: exp-claim TTL cases in test-auth-model.js; new
  test-render-lifecycle.js regression suite
This commit is contained in:
2026-09-03 17:25:22 +00:00
parent fc478a016e
commit 2b7fe1f485
6 changed files with 466 additions and 33 deletions
+23 -4
View File
@@ -319,8 +319,9 @@ Returns `{ loading, refreshing, error }` derived from the union of all passed mo
`auth_model.js` is a first-class Hoover model (`modelRegister('auth', createAuthModel())`) promoted
to the single source of truth for the token/session lifecycle: token storage (sessionStorage via
internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (TTL 60s timer),
session validation, login/logout transitions, and WS reconnection coordination.
internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (remaining-TTL 60s
timer, driven by the token's `exp` claim), session validation, login/logout transitions, and WS
reconnection coordination.
Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()`
(requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on
@@ -338,7 +339,9 @@ storage cleared, refresh timer cancelled, redirect to `#/login` if not already t
```
app bootstrap → modelFetch('auth', { action: 'check' })
→ 200: stores verified user/permissions + stored tokens → schedules refresh
→ 200: stores verified user/permissions + stored tokens → schedules the
refresh at the token's REMAINING lifetime (exp claim, not the full issued
TTL) minus 60s
→ 401 with a stored refresh token (stale access token after page
reload/restore): exactly one refresh attempt, then the same
success or terminal path
@@ -346,7 +349,7 @@ app bootstrap → modelFetch('auth', { action: 'check' })
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
→ onSuccess stores rotated tokens (new session_id) or clears + redirects
(no auth:login dispatch)
timer fires (TTL 60s) → refreshAuth() → same path
timer fires (remaining TTL 60s) → refreshAuth() → same path
WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection)
login → modelFetch('auth', { action: 'login', payload: data })
→ onSuccess stores + schedules + fires auth:login (login action only)
@@ -382,6 +385,15 @@ any terminal no-token result → onSuccess dispatches auth:logout
`name + ':' + JSON.stringify(param)`) is the primary guard shared by all refresh paths
(timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant
secondary guard for the timer path.
- **Exp-claim TTL** — `data.ttl` is the access token's *remaining* lifetime, decoded
unverified from the JWT `exp` claim (`tokenRemainingTtlMs`, mirroring the server's own
unverified-payload extraction in `lib/auth.py`); the full issued TTL
(`payload.access_ttl` / stored `vw:access_ttl`) is only the fallback when the claim is
undecodable or the token is already expired. This keeps the in-memory refresh timer
correct on page restore: a session resumed mid-life schedules its refresh from the
actual expiry, not from the moment the model was (re)populated. An already-expired
stored token falls back to the stored TTL and is healed by the `check` 401 one-refresh
path or the first `apiFetch` 401.
- **Socket teardown necessity** — the daemon validates the WS token only at handshake, so
without the terminal `auth:logout``disconnect()` path the previous user's socket would
survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket).
@@ -564,6 +576,13 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
Create a VNode for a page component. The `key` determines lifecycle boundaries — the same key reuses the existing component instance (preserving state and in-flight loads).
The `#comp` lifecycle registry (and the expanded-content cache) is **per render container**: a
commit of one root (e.g. `#sidebar`) never unmounts or prunes components owned by another root
(e.g. `#main`'s page). Since `commitAll()` commits every root on each reactive update, a shared
global registry would make the sidebar's commit remount the page on every WS tick/toast/model
update — re-running `load()` and, for pages whose `load()` re-mutates reactive state, spinning
an infinite unmount/remount/load loop.
```javascript
// Router pattern — key is the path so navigation to a different page unmounts the old one
return hComp(page, this.state.path);