docs: update hoover.md, audit residual storage reads, verify

This commit is contained in:
2026-08-15 08:25:40 +00:00
parent 4f74192302
commit 1980043afd
2 changed files with 82 additions and 5 deletions
+79 -2
View File
@@ -12,7 +12,8 @@ Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It p
| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
| Model | `model.js` | **Central** reactive store per subsystem, fetch, WS invalidation, loading states |
| WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh |
| Auth model | `auth_model.js` | Token/session lifecycle model: storage, refresh scheduling, session validation, login/logout transitions |
| WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh, `disconnect()` (terminal-auth socket teardown) |
| API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing |
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts |
@@ -246,6 +247,72 @@ render(state) {
Returns `{ loading, refreshing, error }` derived from the union of all passed models.
## Auth model
`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.
Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()`
(requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on
`getAuthToken()` afterwards, never on promise rejection), `getAuthData()` (whole data object).
**State:** `data.token`, `data.refresh`, `data.session_id`, `data.user`, `data.permissions`,
`data.ttl` (ms), plus the standard `loading`/`refreshing`/`error` model flags and
`onSuccess`/`onFailure` lifecycle hooks. `fetch(signal, param)` takes a param object
`{ action, payload? }``check`, `refresh`, `login`, `logout` (param-less calls are treated as
`check`). Any fetch result without a token (`null`, or the all-nulls logout shape) is **terminal**:
storage cleared, refresh timer cancelled, redirect to `#/login` if not already there, and an
`auth:logout` window event.
**Lifecycle:**
```
app bootstrap → modelFetch('auth', { action: 'check' })
→ stores verified user/permissions + stored tokens → schedules refresh
(no auth:login — initApp() calls fetchInitialData()/connect() directly)
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
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)
→ app.js listener (deferred to macrotask) → fetchInitialData() + connect()
logout → modelFetch('auth', { action: 'logout' }) → onSuccess clears + redirects
any terminal no-token result → onSuccess dispatches auth:logout
→ app.js listener → disconnect() closes the WS socket
```
**Invariants:**
- **Silent topic** — the subsystem topic is `'auth'` and the daemon never broadcasts it
(collectors in `lib/state.py` cover `firewall, dnsmasq, nginx, acme, wireguard, networkd,
system` only), so `refreshByTopic()` never fetches the auth model. Auth refresh is driven
exclusively by the TTL timer, `apiFetch` 401, and WS fail×3.
- **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`.
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection.
- **Single storage writer** — all `vw:*` sessionStorage keys are read/written through the
model's internal helpers only.
- **Event gating** — `auth:login` fires only for the `login` action (the `param.action` gate in
`onSuccess`); the bootstrap `check` and silent TTL `refresh`es must not re-fire it, or the
app.js listener would re-run `fetchInitialData()`/`connect()` on top of `initApp`'s direct
calls. `auth:logout` fires on every terminal (no-token) transition; its only listener
(app.js) calls `disconnect()` from `websocket.js`. The model never imports `websocket.js`
(would cycle) — the event inverts the dependency.
- **Session binding rotation** — the server mints a new `session_id` on every refresh; any
post-refresh request (the `apiFetch` 401 retry, the WS handshake) must re-read **both**
`Authorization` and `X-Session-Id` from `getAuthData()`.
- **Concurrent refresh guard** — `modelFetch`'s in-flight dedup (distinct key per param object:
`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.
- **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).
## Virtual DOM
### `h(tag, props, ...children)`
@@ -479,6 +546,15 @@ Link({ path: '/zones', class: 'active', children: ['Zones'] })
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Set `window.__WS_URL__` to override. Auto-reconnects with exponential backoff (max 15s).
The JWT is read from the auth model and sent in the WebSocket subprotocol header (`Bearer <token>`). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise.
### `disconnect()`
Close the WS socket (terminal auth transition — logout, failed session check, failed refresh,
or the 401 session-death path). The daemon validates the WS token only at handshake, so the
socket must be closed explicitly on a terminal transition; `app.js` listens for the
`auth:logout` event and calls `disconnect()`.
### WS Message Types
| Type | Fields | Effect |
@@ -526,7 +602,8 @@ const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
- Automatically sets `Accept: application/json`.
- If `body` is a plain object (not `FormData`), stringifies it and sets `Content-Type: application/json`.
- On HTTP 401, reloads the page (session expired).
- When authenticated, injects `Authorization: Bearer <token>` and `X-Session-Id` headers from the auth model. Caller-passed `options.headers` are merged under the injected values — they can never override them.
- On HTTP 401 (with a token present), triggers a model-driven token refresh via the auth model, then retries the request with the rotated `Authorization` and `X-Session-Id` (the session binding rotates on every refresh). If the retry still 401s (session dead) or the refresh fails, the model is driven to the terminal state: storage is cleared and the user is redirected to `#/login`.
- On non-2xx, returns `{ ok: false, error: "message", status }`.
- On network error, returns `{ ok: false, error: "Network error", status: 0 }`.
- Passes `credentials: 'same-origin'` by default.
+3 -3
View File
@@ -5,7 +5,7 @@
* Requires auth: rw permission.
*/
import { h, definePage, reactive, html, PageHeader, Table, Badge, ConfirmDelete, openModal, closeModal, formModal, apiFetch, toast, esc } from '/static/hoover/index.js';
import { h, definePage, reactive, html, PageHeader, Table, Badge, ConfirmDelete, openModal, closeModal, formModal, apiFetch, toast, esc, getAuthData } from '/static/hoover/index.js';
const BUILTIN_ADMIN = 'admin';
@@ -22,12 +22,12 @@ const SUBSYSTEMS = [
];
function currentUser() {
const u = JSON.parse(sessionStorage.getItem('vw:user') || 'null');
const u = getAuthData()?.user;
return u ? u.username : '';
}
function hasAuthAdmin() {
const perms = JSON.parse(sessionStorage.getItem('vw:permissions') || 'null');
const perms = getAuthData()?.permissions;
return perms && perms.auth === 'rw';
}