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
+45 -26
View File
@@ -61,6 +61,14 @@ export function createAuthModel() {
},
credentials: 'same-origin',
});
if (!r.ok && stored.refresh) {
// Stale access token (e.g. page reload/restore: the in-memory
// TTL timer is gone and the token may have expired server-side,
// while the 7-day refresh token is still in sessionStorage) —
// attempt exactly one refresh before treating the session
// as dead. A failed refresh falls through to the terminal path.
return _doRefresh();
}
if (!r.ok) return null;
const json = await r.json();
if (!json.ok || !json.data?.user) return null;
@@ -77,32 +85,7 @@ export function createAuthModel() {
}
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),
};
return _doRefresh();
}
if (action === 'login') {
@@ -170,6 +153,42 @@ export function createAuthModel() {
};
}
/**
* Rotate the token pair via POST /api/auth/refresh using the stored refresh
* token. Shared by the 'refresh' action and the 'check' 401 fallback.
* @returns {Promise<{token, refresh, session_id, user, permissions, ttl}|null>}
* The rotated token state, or null when the refresh token is missing,
* invalid, expired, or blacklisted.
*/
async function _doRefresh() {
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),
};
}
/**
* Read stored token state from sessionStorage.
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}