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
+39 -4
View File
@@ -73,14 +73,17 @@ export function createAuthModel() {
const json = await r.json();
if (!json.ok || !json.data?.user) return null;
// Server returns ONLY { user, permissions } — merge verified identity
// onto the stored token state.
// onto the stored token state. TTL is the token's REMAINING
// lifetime (exp claim), not the full issued TTL — the in-memory
// timer must fire before the actual expiry even when the session
// was restored mid-life (page reload/restore).
return {
token: stored.access,
refresh: stored.refresh,
session_id: stored.session_id,
user: json.data.user,
permissions: json.data.permissions,
ttl: stored.ttl || 900 * 1000,
ttl: tokenRemainingTtlMs(stored.access, stored.ttl || 900 * 1000),
};
}
@@ -99,7 +102,10 @@ export function createAuthModel() {
session_id: payload.tokens.session_id,
user: payload.user,
permissions: payload.permissions,
ttl: (payload.access_ttl || 900) * 1000,
ttl: tokenRemainingTtlMs(
payload.tokens.access_token,
(payload.access_ttl || 900) * 1000
),
};
}
@@ -185,10 +191,39 @@ async function _doRefresh() {
session_id: t.session_id,
user: json.data.user ?? prev?.user,
permissions: json.data.permissions ?? prev?.permissions,
ttl: json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000),
ttl: tokenRemainingTtlMs(
t.access_token,
json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000)
),
};
}
/**
* Remaining lifetime (ms) of an access token from its unverified `exp` claim.
* The payload is decoded WITHOUT signature verification — this mirrors the
* server's own unverified-payload extraction (lib/auth.py) and is used only
* to schedule the refresh timer, never to trust the claim. Returns the
* fallback when the token is malformed, undecodable, or already expired.
* @param {string} token - JWT access token
* @param {number} fallbackMs - TTL in ms when the exp claim is unusable
* @returns {number} remaining ms (> 0) or fallbackMs
*/
function tokenRemainingTtlMs(token, fallbackMs) {
try {
const payloadB64 = String(token).split('.')[1];
if (!payloadB64) return fallbackMs;
const padded = payloadB64 + '===='.slice(0, (4 - (payloadB64.length % 4)) % 4);
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
if (payload && typeof payload.exp === 'number') {
const remaining = payload.exp * 1000 - Date.now();
if (remaining > 0) return remaining;
}
} catch {
/* malformed token — fall back to the configured TTL */
}
return fallbackMs;
}
/**
* Read stored token state from sessionStorage.
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}