fix: seed builtin admin only on empty DB; recover page-load sessions with one refresh

Auth seeding (last-resort guard)
- `_seed_builtin_admin()` in get_db() now skips when
  VACUUM_WALL_SEED_BUILTIN_ADMIN=0 or when the users table already
  contains any user — previously a fresh service start after a non-default
  bootstrap (e.g. --mgmt-user alice) seeded a hard-coded `admin` with an
  unrecoverable random password, shadowing the operator's account
- bootstrap_auth.py sets VACUUM_WALL_SEED_BUILTIN_ADMIN=0: bootstrap
  creates the operator user itself on a fresh install, so exactly one
  account exists and no seeded admin can appear

Frontend (session recovery)
- on page load/restore the in-memory TTL timer is gone, so a valid
  7-day refresh token could sit in sessionStorage while the access token
  is already expired server-side: the session `check` now attempts
  exactly one refresh (POST /api/auth/refresh with the stored refresh
  token) on 401 before treating the session as dead
- extract shared `_doRefresh()` used by both the `check` 401 fallback and
  the `refresh` action (removes the duplicated rotation logic)

Tests
- update seeding tests to the new any-user-present check; add
  test_seed_skipped_when_users_exist, test_seed_skipped_via_env,
  test_bootstrap_flow_creates_exactly_one_user, and the auth-model JS
  test suite (tests/test-auth-model.js)

Docs
- AGENTS.md: document VACUUM_WALL_SEED_BUILTIN_ADMIN
- architecture.md / hoover.md / security.md: describe the bootstrap
  check 401 → one-refresh fallback path
This commit is contained in:
2026-08-17 23:56:54 +00:00
parent 0ed275835d
commit 183904faad
9 changed files with 343 additions and 43 deletions
+7 -2
View File
@@ -270,7 +270,10 @@ storage cleared, refresh timer cancelled, redirect to `#/login` if not already t
```
app bootstrap → modelFetch('auth', { action: 'check' })
→ stores verified user/permissions + stored tokens → schedules refresh
200: stores verified user/permissions + stored tokens → schedules refresh
→ 401 with a stored refresh token (stale access token after page
reload/restore): exactly one refresh attempt, then the same
success or terminal path
(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
@@ -290,7 +293,9 @@ any terminal no-token result → onSuccess dispatches auth:logout
- **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.
by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` 401 fallback
(exactly one refresh when the stored access token is rejected at page load while a
refresh token is still present).
- **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.