auth: create auth model definition (hoover/auth_model.js)

This commit is contained in:
2026-08-14 23:42:56 +00:00
parent 61d95b99a4
commit 5d84710d1a
+256
View File
@@ -0,0 +1,256 @@
/**
* Hoover — auth_model.js
*
* First-class Hoover model for the token/session lifecycle. Single source
* of truth for:
* - token storage and retrieval (sessionStorage via internal helpers)
* - refresh scheduling (TTL timer) and execution
* - session validation ('check' action)
* - login/logout state transitions
* - WS reconnection coordination (refreshAuth for websocket.js)
*
* Registered at app bootstrap: modelRegister('auth', createAuthModel()).
*
* Invariants:
* - subsystem topic 'auth' is silent — the daemon only broadcasts topics
* for its collectors and never emits 'auth', so refreshByTopic() cannot
* touch this model. Refresh is timer/401/WS-fail driven only.
* - fetch() uses vanilla fetch() — never apiFetch — preventing recursion
* (apiFetch 401 → refreshAuth → auth fetch → apiFetch → 401 …).
* - This module never imports api.js or websocket.js (would cycle:
* websocket.js imports the auth model's exports).
* - modelFetch() never rejects — consumers branch on model state
* (getAuthToken / isAuthenticated), never on promise rejection.
*/
import { modelFetch, getModel } from './model.js';
/** Refresh timer handle. Not reactive — only set/cleared in lifecycle hooks. */
let _refreshTimer = null;
/** In-flight refresh guard — prevents concurrent refresh attempts (timer path). */
let _refreshing = false;
/** All-nulls data shape — returned by the 'logout' action and used as the canonical
* "not authenticated" state. */
const EMPTY = { token: null, refresh: null, session_id: null, user: null, permissions: null, ttl: null };
/**
* Return the model definition object for `modelRegister('auth', ...)`.
* @returns {object} Hoover model definition
*/
export function createAuthModel() {
return {
// Silent topic — the daemon never broadcasts 'auth', so refreshByTopic()
// will never fetch this model. Refresh is timer/401/WS-fail driven only.
subsystem: 'auth',
defaultData: { ...EMPTY },
async fetch(signal, param) {
// Param-less calls are treated as 'check' (defensive; refreshByTopic
// never reaches this model thanks to subsystem: 'auth').
const action = param?.action || 'check';
if (action === 'check') {
const stored = readStorage();
if (!stored.access) return null; // no stored session
const r = await fetch('/api/auth/session', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + stored.access,
'X-Session-Id': stored.session_id || '',
},
credentials: 'same-origin',
});
if (!r.ok) return null;
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.
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,
};
}
if (action === 'refresh') {
const stored = readStorage();
if (!stored.refresh) return null;
const r = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
refresh_token: stored.refresh,
session_id: stored.session_id,
}),
});
if (!r.ok) return null;
const json = await r.json();
if (!json.ok || !json.data?.tokens) return null;
const t = json.data.tokens;
const prev = getModel('auth').data; // fallback for any field the server omits
// NOTE: the server mints a NEW session_id on every refresh — the rotated
// binding must win over `prev`.
return {
token: t.access_token,
refresh: t.refresh_token,
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),
};
}
if (action === 'login') {
const payload = param.payload;
// Defensive: successful logins always carry tokens — a malformed payload
// is treated as terminal (null → clear storage + redirect).
if (!payload?.tokens?.access_token) return null;
return {
token: payload.tokens.access_token,
refresh: payload.tokens.refresh_token,
session_id: payload.tokens.session_id,
user: payload.user,
permissions: payload.permissions,
ttl: (payload.access_ttl || 900) * 1000,
};
}
if (action === 'logout') {
// Intentional logout: fresh all-nulls object so model.data ends up in
// the canonical "not authenticated" state (isAuthenticated() → false).
return { ...EMPTY };
}
return null;
},
onSuccess(name, data, param) {
if (!data || !data.token) {
// Logout, failed check, or failed refresh — all terminal:
// clear storage, cancel timer, redirect if needed.
clearStorage();
if (_refreshTimer) { clearTimeout(_refreshTimer); _refreshTimer = null; }
// location.hash includes the '#' — compare against '#/login', not '/login'.
if (name === 'auth' && document.location.hash !== '#/login') {
document.location.hash = '/login';
}
// Terminal transition — notify the app to tear down session-scoped resources
// (the WS socket; the daemon validates it only at handshake, so it would
// otherwise stay open and be reused by a same-tab relogin). The model never
// imports websocket.js (would cycle), so teardown is event-driven — the
// listener lives in app.js (see Phases 4 and 6).
window.dispatchEvent(new CustomEvent('auth:logout'));
return;
}
writeStorage(data);
scheduleRefresh(data.ttl);
// Transition event — fires ONLY for the 'login' action. Rationale:
// * 'check' (bootstrap): initApp() already calls fetchInitialData()
// and connect() on the authenticated branch — firing the event too
// would double the work.
// * 'refresh' (TTL timer, apiFetch 401, WS fail×3): the session is
// already established; re-firing would re-run fetchInitialData()
// on every ~14-minute silent refresh.
// The app.js listener defers its path check to a macrotask (see
// Phase 6), so it runs after doLogin's hashchange has landed.
if (param?.action === 'login') {
window.dispatchEvent(new CustomEvent('auth:login', {
detail: { permissions: data.permissions },
}));
}
},
onFailure(name, error) {
// Silent — data stays as-is. Only reachable on a real throw (network error
// inside fetch()). The no-token branch above covers the normal failure path.
},
};
}
/**
* Read stored token state from sessionStorage.
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}
*/
function readStorage() {
return {
access: sessionStorage.getItem('vw:access'),
refresh: sessionStorage.getItem('vw:refresh'),
session_id: sessionStorage.getItem('vw:session_id'),
ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || null,
};
}
/** Persist token state to sessionStorage. @param {object} data - auth model data */
function writeStorage(data) {
sessionStorage.setItem('vw:access', data.token);
sessionStorage.setItem('vw:refresh', data.refresh);
sessionStorage.setItem('vw:session_id', data.session_id);
sessionStorage.setItem('vw:access_ttl', String(data.ttl));
sessionStorage.setItem('vw:user', JSON.stringify(data.user));
sessionStorage.setItem('vw:permissions', JSON.stringify(data.permissions));
}
/** Remove all stored token state. */
function clearStorage() {
sessionStorage.removeItem('vw:access');
sessionStorage.removeItem('vw:refresh');
sessionStorage.removeItem('vw:session_id');
sessionStorage.removeItem('vw:access_ttl');
sessionStorage.removeItem('vw:user');
sessionStorage.removeItem('vw:permissions');
}
/**
* Schedule a silent refresh TTL 60s out (min 30s). Resets any pending timer.
* @param {number} [ttl] - access token TTL in ms
*/
function scheduleRefresh(ttl) {
if (_refreshTimer) clearTimeout(_refreshTimer);
const delay = Math.max((ttl || 900 * 1000) - 60000, 30000);
_refreshTimer = setTimeout(() => {
if (!_refreshing) {
_refreshing = true;
modelFetch('auth', { action: 'refresh' }).finally(() => { _refreshing = false; });
}
}, delay);
}
/**
* Current access token from the auth model.
* @returns {string|undefined}
*/
export function getAuthToken() {
return getModel('auth').data?.token;
}
/**
* Authenticated only when the model carries both a token and a user.
* @returns {boolean}
*/
export function isAuthenticated() {
const d = getModel('auth').data;
return !!(d && d.token && d.user);
}
/**
* Trigger a token refresh via the model.
* Resolves (never rejects) — callers branch on getAuthToken() afterwards.
* modelFetch() returns undefined for unregistered models — normalize to a
* resolved promise so the result is always a thenable (defensive: the auth
* model is registered at app bootstrap before any consumer can run).
* @returns {Promise<void>}
*/
export function refreshAuth() {
return Promise.resolve(modelFetch('auth', { action: 'refresh' }));
}
/**
* Whole auth model data object.
* @returns {{token, refresh, session_id, user, permissions, ttl}|null}
*/
export function getAuthData() {
return getModel('auth').data;
}