phase: 11_admin_login
**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.
**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).
**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.
**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.
**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.
**Next pending phase**: `12_nut_self_test_verdict`.
This commit is contained in:
@@ -33,6 +33,11 @@ file — plus an overview page that says which of the four you need:
|
||||
History is a first-class feature, not a sparkline afterthought: every number worth graphing
|
||||
is stored, rolled up (raw → 1m → 1h), and served from `/api/*/history`.
|
||||
|
||||
None of it is public. One admin signs in at `/login` (the only page an anonymous visitor
|
||||
reaches), and `/settings` — `⚙ Account` in the masthead — is where that session is read, ended,
|
||||
and re-credentialled. Every page above, `/settings`, and every `/api` route but the four named
|
||||
in invariant 12 require the session.
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
These are the things that make the numbers trustworthy. Breaking one is a bug even if the
|
||||
@@ -43,7 +48,10 @@ page still renders.
|
||||
(`NUT_ALLOW_ACTIONS`, not `DASH_READ_ONLY`, the upsd.users account actually having the
|
||||
command, and a UI confirmation — `DANGER` for anything that can cut power) and the server
|
||||
re-checks every gate on every request. Hiding a button is not a safety control.
|
||||
Commands that would cut output are refused outright while the UPS is already on battery.
|
||||
Commands that would cut output are refused outright while the UPS is already on battery. Since
|
||||
phase 11 there are two more in front of it — a session, and this session's CSRF token for a
|
||||
cookie write (invariant 12) — and they are additive: `tests/test_auth_api.py` proves the
|
||||
`DANGER` requirement and the wire send still behave exactly as they did without them.
|
||||
2. **Never invent a number.** A value the upstream does not publish renders as `n/a`, never
|
||||
as `0`. Every derived value carries the `source` string it came from and `derived: true`
|
||||
so the UI can print *how* it was obtained (`computed:Δtokens/Δt`, `Δ10.0s`). Aggregating
|
||||
@@ -75,7 +83,14 @@ page still renders.
|
||||
`config.strip_userinfo`-redacted engine URLs, and every URL inside an `UpstreamError` message
|
||||
is redacted too — those strings go into the health record *and* the `event` table, which
|
||||
`/api/events` serves to the browser and `page-system.js` prints. No token, password, or
|
||||
personal hostname goes into tracked files, fixtures, or the browser.
|
||||
personal hostname goes into tracked files, fixtures, or the browser. **The admin's own
|
||||
credential is the same case** (phase 11): `auth.py` records nothing and answers
|
||||
`False`/`None` to every rejection, because the *reason* a login was refused is a verifier, a
|
||||
salt, or a session token — and this app turns reasons into health records and `event` rows
|
||||
that a browser reads. Its one `logging` call and its two `raise` sites (the fail-closed
|
||||
startup and the empty-new-password refusal) carry **fixed** operator text with nothing
|
||||
interpolated, not even a prefix of a hash; `tests/test_auth.py` pins that structurally, so
|
||||
the day someone adds `logger.warning(f"bad verifier {v}")` a test fails instead of a log line.
|
||||
7. **Verify against the deployed upstream; never guess a metric name.** Field maps in
|
||||
`sources/llm.py` and `sources/nut.py` were captured from the running engines and NUT
|
||||
2.8.5, and are pinned by fixtures in `tests/fixtures/`. Re-capture with
|
||||
@@ -91,6 +106,49 @@ page still renders.
|
||||
10. **The dev server stays up while work happens** (`./scripts/dev-server.sh restart`,
|
||||
http://127.0.0.1:8090), and UI changes are verified in a real browser with a screenshot
|
||||
before they are called done. `data/logs/*.png` is where those land.
|
||||
11. **The dashboard never starts open, and the stored password beats the environment**
|
||||
(phase 11). The admin verifier and the session-signing key are durable rows in the SQLite
|
||||
`meta` table (`auth.verifier`, `auth.session_secret`) and are the only place either secret
|
||||
lives; `DASH_ADMIN_PASSWORD` is a **seed** that hashes into the row on the first boot of a
|
||||
database and is ignored thereafter (one warning line, value never quoted), so a password is
|
||||
changeable without a redeploy and no container env can roll it back. A server with neither a
|
||||
stored verifier nor the seed **refuses to start** — a `RuntimeError` out of the lifespan, a
|
||||
non-zero exit, and a message naming the variable, the meta key, and the
|
||||
`sqlite3 <db> "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"`
|
||||
reset command. "Start open with a warning" is not an option: the domain is public and the
|
||||
fleet's hostnames and UPS controls are behind that login. `testpass` is the local-testing
|
||||
password (dev config and the hermetic suite) and never a production fallback. Invariant 12 is
|
||||
the request-time half; this one is why there is always a credential to check against.
|
||||
12. **The gate is default-deny and its public surface is four entries** (phase 11). `app.py`'s
|
||||
`PUBLIC_PATHS` (`/login`, `/api/auth/login`, `/api/health`) plus `PUBLIC_PREFIXES`
|
||||
(`/static/`) are the only things an anonymous caller can reach — every page, every `/api`
|
||||
data route, and `/api/auth/status`/`logout` require a session read from the
|
||||
`dash_session` cookie or an `Authorization: Bearer` token. `/api/health` is the one data
|
||||
route that stays open on purpose (the Containerfile `HEALTHCHECK` and
|
||||
`./scripts/dev-server.sh status` poll it unsigned, and it leaks nothing); its shape is now a
|
||||
contract with deployment, which means **nothing may be added to it without re-deciding that**
|
||||
— a new field there is a field the whole internet reads. Adding a path to that list is a
|
||||
security decision, and
|
||||
`tests/test_auth_api.py::test_every_route_that_is_not_public_requires_a_session` walks the
|
||||
**served** route table so a route that forgets the gate fails a test instead of shipping —
|
||||
the middleware guards by default, which is why no new route has to remember to. Rules the
|
||||
gate keeps and each has a test: a page `GET` gets `303` to `/login?next=…` (query string
|
||||
included) and everything else the one JSON refusal, because a redirect a script cannot render
|
||||
is not an answer; every answer carries `no-store`; `?next=` passes `auth.safe_next` and is
|
||||
never followed raw (an open redirect on a login page is a phishing kit with a homelab theme);
|
||||
a write additionally needs a matching `Origin`/`Sec-Fetch-Site` and — when it came in on the
|
||||
**cookie** — this session's `X-CSRF-Token`, because a bearer caller is something no other site
|
||||
can forge. A process whose lifespan never produced `ctx.auth` answers `503` for **everything,
|
||||
public paths included**: "healthy" from a server that cannot say who its admin is is the
|
||||
invariant-4 failure mode, and a login form that cannot verify a password is a worse promise
|
||||
than no form. `POST /api/ups/command` keeps all four of its own gates behind this one (the
|
||||
`DANGER` word is still what cuts power), and `POST /api/auth/logout` is gated like any other
|
||||
write because a sign-you-out link is a CSRF. `POST /api/auth/password` (task 04) is the third
|
||||
gated write and the app's only revocation: guesses at the current password spend their own
|
||||
`password:<address>` bucket, and a right one replaces `auth.verifier` **and**
|
||||
`auth.session_secret` so every browser that is not in front of the form dies on its next
|
||||
request — which is why the response is minted from the new state and never the one being
|
||||
replaced.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -133,7 +191,14 @@ Responsibilities, one line each:
|
||||
is single-flight and keeps the old cache on failure, so a bad pass retries on the next
|
||||
expiry instead of hammering the disk.
|
||||
- `config.py` — env only, no config file format, no CLI flags. `.env` is a *seed*; a real
|
||||
env var always wins. Defaults are safe except the sources, which default to unset.
|
||||
env var always wins. Defaults are safe except the sources, which default to unset. The auth
|
||||
block (phase 11) is `DASH_ADMIN_PASSWORD` (the **seed**, and `_env()` strips, so `VAR=` means
|
||||
*unset* rather than "the password is empty"), `DASH_SESSION_HOURS` (720 = 30 days: a dashboard
|
||||
that logs the operator out mid-outage gets bypassed), `DASH_COOKIE_SECURE` (off, because dev is
|
||||
`http://127.0.0.1`; task 03 also treats `X-Forwarded-Proto: https` as secure), and
|
||||
`DASH_PBKDF2_ITERATIONS` (default `auth.PBKDF2_ITERATIONS`, imported so the primitive and the
|
||||
setting cannot drift) — that knob exists so the hermetic suite hashes in microseconds instead
|
||||
of 0.3 s per client and is not a production tuning parameter.
|
||||
- `poller.py` — job loop, health records, stale/last-good semantics, transition events.
|
||||
`/disks` is a **source of its own** (`disks:<host>`, `DISKS_INTERVAL_SECONDS` 300 s,
|
||||
`DISKS_TIMEOUT_SECONDS` 30 s, events under the dashboard name `disks`) reading the same
|
||||
@@ -208,6 +273,44 @@ Responsibilities, one line each:
|
||||
monitor through it, so the assembly and the model cannot disagree about what is enabled.
|
||||
- `prom.py` — Prometheus text-exposition parser (in-house; only for engines we don't own).
|
||||
- `rates.py` — Δ/window estimator, keyed by resolved series name.
|
||||
- `auth.py` — every authentication decision as a pure function, stdlib only (phase 11):
|
||||
`hash_password`/`verify_password`/`needs_rehash` over a self-describing
|
||||
`pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>` verifier (so the cost can rise without a
|
||||
migration), `mint_session`/`read_session` for an HMAC-signed stateless cookie (no session
|
||||
table and nothing to prune; rotating the stored secret signs every cookie ever issued out,
|
||||
which is how changing the password evicts every other browser), `mint_csrf`/`verify_csrf`
|
||||
bound to the session token instead of stored, `LoginThrottle` (in-process exponential
|
||||
backoff), and `origin_matches`/`sec_fetch_site_allowed` as string functions so the gate is
|
||||
testable without a `Request`. The primitives **never raise and record nothing**: a malformed
|
||||
verifier, a tampered cookie, and a rotated secret all answer `False`/`None`, because the
|
||||
reason an input was rejected is the credential (invariant 6). No HTTP, no SQLite driver, no
|
||||
new dependency — `app.py`'s middleware and `api.py`'s `/api/auth/*` are the callers, so a
|
||||
decision made here is made once and is unit-testable without a `Request`. It also carries the
|
||||
two strings the gate and the pages share (`LOGIN_PATH`, `LOGIN_NEXT`) and `safe_next()`, the
|
||||
only judge of a `?next=` value: kept verbatim (query string and all, because the range chips
|
||||
live there) when it stays on this app, dropped otherwise — scheme-prefixed, scheme-relative,
|
||||
the percent-encoded spellings of both, anything with a control character (not a path but a
|
||||
second `Location` header), and anything without a leading `/`.
|
||||
The **verifier bootstrap** (task 02) is the one deliberate exception to "never raises":
|
||||
`load_auth_state(store, admin_password=…, iterations=…, session_hours=…, db_path=…)` reads the
|
||||
`meta` rows `auth.verifier` / `auth.session_secret` through a three-method `MetaStore`
|
||||
`Protocol` (so this module still imports nothing in the package, and its tests use a dict)
|
||||
and returns a frozen `AuthState` — the environment **seeds** a password once and the stored
|
||||
verifier wins thereafter (`env_ignored` and one fixed-line warning when both are present, so
|
||||
`/settings` is the real answer and no redeploy can roll a changed password back); no row and
|
||||
no seed raises a `RuntimeError` naming `DASH_ADMIN_PASSWORD`, the meta key, and the
|
||||
`sqlite3 … DELETE FROM meta …` reset command, because "start open with a warning" is the
|
||||
failure this phase exists to prevent; a stored secret that is not 256 bits of hex is
|
||||
regenerated and persisted (the effect is one login prompt, never a lockout). Nothing here
|
||||
reads a clock, so the state carries no expiry — a session's is inside its signed payload.
|
||||
`update_verifier(store, state, new_password)` writes the new verifier **and** a fresh secret
|
||||
(secret first, so a crash cannot leave sessions outliving the password they were issued
|
||||
under) and refuses an empty/whitespace password; it is what `POST /api/auth/password` calls,
|
||||
because rotating the secret **is** this app's revocation list — a stateless cookie has no row to
|
||||
delete, so without it a stolen session would outlive the password that "revoked" it for another
|
||||
`DASH_SESSION_HOURS`. Its two raises, like the loader's, carry
|
||||
fixed text — `tests/test_auth.py` pins the file's raise sites and its one logging call
|
||||
structurally, so no future edit can interpolate a credential into either.
|
||||
- `severity.py` — the only place thresholds live: `THRESHOLDS`/`INVERTED` per metric,
|
||||
`classify()` → `ok`/`warn`/`fail`/`None`, plus `attach()`/`with_severity()` to put the verdict
|
||||
beside a value. No measurement, no verdict: `None` in → `None` out, never a green pass for
|
||||
@@ -248,8 +351,45 @@ Responsibilities, one line each:
|
||||
grading a drive here would be the bug, not the fix (phase 09). Its `HREF` table does carry
|
||||
`/disks`, so the one alert a disk job *can* raise — SMART failing while its host answers —
|
||||
links to the page that names the drive.
|
||||
- `store.py` — `sample(source,host,metric,res,ts)` + `event` + `meta`, rollups, pruning.
|
||||
- `api.py` — GETs everywhere plus the one POST. `/api/config` is redaction boundary.
|
||||
- `store.py` — `sample(source,host,metric,res,ts)` + `event` + `meta`, rollups, pruning. The
|
||||
`meta` table is the only durable key/value state in the app and is reached through three
|
||||
named helpers — `get_meta`/`set_meta`/`delete_meta` (the last returns its rowcount, because
|
||||
the password-reset procedure has to be able to say whether a row was there, and one key per
|
||||
call, because a reset that deletes a row it did not name deletes the operator's credential).
|
||||
`stats()` reads `last_maintain` through `get_meta()` too — outside its own lock, which
|
||||
`get_meta` takes itself and `threading.Lock` is not reentrant — while `maintain()`'s write
|
||||
stays inline inside that method's transaction.
|
||||
- `api.py` — GETs everywhere plus the one POST that dials an upstream, and the `auth_router()`
|
||||
that creates, describes, **re-credentials**, and ends a session (`POST /api/auth/login` public;
|
||||
`GET /api/auth/status`, `POST /api/auth/logout` and `POST /api/auth/password` protected). Those
|
||||
three are the app's only other writes and they touch this process and its `meta` table and dial
|
||||
nothing — invariant 1 is about the fleet, and `tests/test_api.py` pins the two lists separately
|
||||
rather than widening the one that names `/api/ups/command`.
|
||||
The login route is where a *sign-in* password is compared: it
|
||||
throttles before it hashes, refuses an absent/empty/non-object body with `422` so `""` never
|
||||
reaches `verify_password`, answers every failure with the one sentence `wrong password`, and
|
||||
answers `429` + `Retry-After` while the per-address backoff runs (the only extra fact it adds,
|
||||
because a wait is something the caller has to act on). After a good sign-in it also upgrades a
|
||||
verifier minted at a lower `DASH_PBKDF2_ITERATIONS` (`auth.needs_rehash`), keeping the signing
|
||||
secret — that knob has to actually buy something, and a login is the one moment the plaintext is
|
||||
in hand. A refused upgrade write is swallowed with one fixed-text `logging` line (this module's
|
||||
only one, carrying no credential: invariant 6) because `database is locked` on a bookkeeping row
|
||||
must not turn a correct password into no login at all. `POST /api/auth/password` is task 04: it
|
||||
validates `new` first (`MIN_PASSWORD_LENGTH = 8`, non-empty once stripped, different from
|
||||
`current` — length is the whole policy, because composition mandates produce sticky notes),
|
||||
throttles the *current* password on its own key `password:<address>`, refuses a wrong one with
|
||||
`403` + a `password-denied` event, and on the right one calls `auth.update_verifier` and answers
|
||||
from the **new** state with a cookie and CSRF token minted from the rotated secret. Building that
|
||||
response from the old `ctx.auth` is the bug the order exists to prevent: the operator would be
|
||||
signed out by their own form.
|
||||
`AppContext` carries `auth` and the one
|
||||
`LoginThrottle` per process — a throttle built per request is a throttle that never trips. A
|
||||
refused sign-in writes a `login-denied` **event** and nothing else, and so do the two password
|
||||
outcomes (`password-denied`, `password-changed`): `alerts.py` turns only
|
||||
`kind: "unreachable"` into an overview row and every feed filters by `source`, so
|
||||
`/api/events?source=auth` is the audit trail and the overview stays about outages (do not
|
||||
"fix" that by hiding the event). Neither fact appears on `/api/health` — which is public — or
|
||||
`/api/config`. `/api/config` is a redaction boundary.
|
||||
`/api/disks/*` (phase 09) is the drive-health pair: `/snapshot` answers one entry per
|
||||
configured host (disk rows + `smart` + `smartctl_available` + per-row `severity` + a
|
||||
`fleet` block summed by the API, with a configured-but-never-answered host as an
|
||||
@@ -275,10 +415,61 @@ Responsibilities, one line each:
|
||||
job with no payload yet is listed as a stub (`unreachable: true`, every value `null`) in
|
||||
`system.hosts`/`llm.engines` exactly as `/api/system/snapshot` does, so `hosts_total`
|
||||
counts the machines there are and `hosts_live` the ones answering.
|
||||
- `app.py` — assembly; `create_app()` takes injectable settings/store so tests build an app
|
||||
with an in-memory DB and no targets.
|
||||
- `web/` — five static pages (`/`, `/system`, `/disks`, `/llm`, `/ups`), one stylesheet,
|
||||
`charts.js`. No framework. `app.js`'s masthead status dots are one per dashboard job —
|
||||
- `app.py` — assembly, and the **session gate** (`session_gate` middleware, invariant 12) whose
|
||||
`PUBLIC_PATHS`/`PUBLIC_PREFIXES` are the whole anonymous surface. It answers in this order and
|
||||
the order is the design: no `AuthState` → `503` for everything; no session and no such route →
|
||||
the router's `404` (a typo is not a sign-in problem, and "sign in required" would otherwise be
|
||||
the only answer anyone outside ever gets for a wrong URL); public → through, except that the
|
||||
origin check still applies to a public write (a cross-site login post spends the throttle of
|
||||
whoever is typing) and `GET /login` for someone already signed in goes to the dashboard, not
|
||||
back to the form; no session → `303` to `/login?next=…` for a page and the JSON refusal for
|
||||
everything else; a write → origin, then this session's CSRF token when it arrived on the
|
||||
cookie. `_own_origin` reads `X-Forwarded-Proto` for the same reason the cookie's `Secure` flag
|
||||
does, so behind nginx a real same-origin write is not a cross-site refusal — the bug that only
|
||||
appears in production, which is why it has a test. `create_app()` takes injectable settings/store so tests build an app
|
||||
with an in-memory DB and no targets. The lifespan builds `ctx.auth`
|
||||
(`auth.load_auth_state`) after the context exists and **before** the first poll, for the
|
||||
owned-store and injected-store branches alike — one call site, because two copies drift — and
|
||||
a `RuntimeError` from it leaves the process non-zero with the operator's fix in the output.
|
||||
That failure is never caught here: a dashboard that cannot say who its admin is does not start
|
||||
the loops either (`__main__.py` passes `SETTINGS` to `create_app()` explicitly so the served
|
||||
app is configured by the same object the bind address came from).
|
||||
- `web/` — five dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the login page
|
||||
(`/login`, `pages/login.html` + `static/page-login.js`), one stylesheet, `charts.js`. No
|
||||
framework. `/login` renders the masthead's **brand link and theme toggle** and nothing else
|
||||
from it: no tabs (every tab bounces straight back here, and a nav of links that all redirect is
|
||||
a lie about what is available), no status dots and no clock (there is nothing signed in for them
|
||||
to say). It posts through `Dash.fetchJSON` like every other script, which is only safe because
|
||||
`bounceToLogin()` does nothing when the browser is already on `LOGIN_PATH` — without that guard
|
||||
a `401` would reload the form instead of printing `Wrong password.`, and the page that exists to
|
||||
end a bounce loop would be one. So the form branches on `err.status`: `401` is the inline
|
||||
sentence plus `field.select()` (fixing a typo without retyping is the whole UX here), `429`
|
||||
shows the server's `detail` and **counts down its `Retry-After`** with the button disabled until
|
||||
the wait is over — `fetchJSON` therefore carries `response.headers` on the `Error` it throws,
|
||||
because a wait this page made up would be an invented number (rule 2) — and anything else prints
|
||||
the server's `detail` verbatim. Every refusal goes out through `textContent`, never
|
||||
`innerHTML`: this is the one page a stranger reaches first. Its own copy of `auth.safe_next`
|
||||
guards the one redirect followed on client strength (already-signed-in `?next=`), and
|
||||
`Dash.ensureAuth()` up front stops a flash of the form for a browser that is already in. The
|
||||
login page is audited for orphan `id`s and CDN references too (`tests/test_auth_api.py`), fetched
|
||||
unsigned because a signed-in `GET /login` is a `303` and would audit `/`'s markup under the wrong
|
||||
name. `/settings` (`pages/settings.html` + `static/page-settings.js`) is a **page and not a
|
||||
sixth dashboard**: same `<head>` and masthead, the five tabs with **no** `aria-current` (it is
|
||||
not one of them), no status dots, and it is reached from the `⚙ Account` link in
|
||||
`.masthead-right` of every dashboard — outside `.tabs`, so the pinned tab structure is untouched
|
||||
(`tests/test_api.py::test_every_dashboard_page_links_to_settings`). It prints
|
||||
`/api/auth/status` (name, expiry as a local timestamp *and* as time left), signs out through
|
||||
`POST /api/auth/logout`, and changes the password through `POST /api/auth/password` after
|
||||
checking locally what it can judge itself (non-empty current, ≥ 8 new characters —
|
||||
`tests/test_api.py` pins that number against `api.MIN_PASSWORD_LENGTH` so the two cannot drift —
|
||||
new matching confirm), printing `403`/`422`/`429` through `textContent` as the server words them,
|
||||
then re-reads the status so the panel cannot show a session the rotation ended. Its static
|
||||
sub-line is the precedence rule (env seed → stored verifier wins → reset by deleting
|
||||
`auth.verifier`), because that sentence is what stops the "I edited the env file and nothing
|
||||
happened" call. So `app.js` exports **`resetAuth()`**: it clears the memoised `authStatus` and its
|
||||
in-flight promise, and must be called after a password change (the secret rotated, so the cached
|
||||
`csrf` is now invalid — every later write, the UPS buttons included, 403s until a reload) and on
|
||||
any `401` bounce. `app.js`'s masthead status dots are one per dashboard job —
|
||||
including `disks`, because the same agent can answer the 10-second utilisation poll and
|
||||
have a wedged SMART pass, and one dot for both would be right about neither. `app.js` owns the
|
||||
colour vocabulary: `Dash.severityFrom(cellOrSeverity)` turns a payload verdict into an
|
||||
@@ -320,27 +511,33 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
neither `.env.dev` nor `.env.dev.example` declares a forwarded port — dev dials the same
|
||||
addresses the deployed dashboard does, and `scripts/capture-fixtures.sh` reads
|
||||
`HOST_TARGETS` from `.env.dev` instead of naming hosts of its own
|
||||
(`tests/test_deploy_scripts.py` fails the day any tunnel name or port returns). Current state: `gateway` runs its installed service, bound to the address
|
||||
`gateway.reeselink.com` resolves to, and answers `/health` + `/snapshot` **on the host**
|
||||
(re-verified 2026-10-18, and the token in `.env.dev` matches `/etc/dashboards-agent.token`),
|
||||
but the laptop cannot reach its `:9120` — and that is now proven to be nobody's firewall here:
|
||||
opening `9120/tcp` in that host's own firewalld (a runtime rule, added and reverted in the
|
||||
phase-10 final pass; runtime and permanent port lists are back to their original set) changed
|
||||
nothing, because the SYN never arrives — `ss -tan sport = :9120` on the host shows only the
|
||||
`LISTEN` — while the same laptop reaches `:22` and `:80` on the same address in milliseconds.
|
||||
The drop is the port-selective inter-VLAN filter on the lab gateway `10.2.0.1`, the same one
|
||||
that eats 3051/3493. On `driveripper` and `deskwork` the ad-hoc `~/lab-agent/host_agent.py` agents
|
||||
are gone (stopped with the old tunnel sessions, nothing replaced them, `:9120` is empty on
|
||||
both) because their `deploy/agent-sudoers` grant is not installed yet, so the installer
|
||||
cannot run there. Every `system`/`disks` source is therefore `DOWN` with its real reason
|
||||
and renders as an `unreachable` stub row — the designed degradation, recorded rather than
|
||||
hidden — which is also why 6 of the `live` tests fail on transport while both engines pass.
|
||||
The per-host record, the exact root commands and the one firewall line each host still
|
||||
needs are in `data/logs/NOTES-agent-install.md`, how far the NUT wire reaches from each
|
||||
(`tests/test_deploy_scripts.py` fails the day any tunnel name or port returns).
|
||||
**Fleet transport, re-verified from the laptop on 2026-10-19.** The port-selective inter-VLAN
|
||||
drop phase 10 recorded on the lab gateway `10.2.0.1` is **gone**: `gateway` (`10.1.1.27:9120`)
|
||||
and `deskwork` (`10.1.0.51:9120`) both take a plain TCP connect and answer `/snapshot`, and
|
||||
`driveripper`'s upsd answers `LIST UPS` on `10.1.0.126:3051` from this same laptop. Two hosts
|
||||
run the **installed** service — `systemctl is-active dashboards-agent` is `active` on both,
|
||||
with `/usr/local/lib/dashboards-agent/host_agent.py` and a `--token-file` unit bound to the
|
||||
address the dashboard dials. `driveripper` is the one host with no agent at all (unit
|
||||
`inactive`, no installed file, nothing listening on `:9120` → `ConnectionRefused`), and it
|
||||
renders as an `unreachable` stub row: the designed degradation, recorded rather than hidden.
|
||||
`/api/health` today reads `host:deskwork ok`, `host:gateway ok`, `disks:deskwork ok`,
|
||||
`disks:gateway ok`, `nut ok`, `llm:gufo ok`, `llm:vllm ok` with the two `driveripper` jobs
|
||||
`DOWN`, and the `live` suite is 8 passed / 3 failed with every failure that one transport.
|
||||
Phase 10's proof that the old drop was not this repo's firewall stands as history in
|
||||
`data/logs/NOTES-verify.md` — do not re-add a tunnel on the strength of it, and re-run the
|
||||
probe rather than trusting either note.
|
||||
The per-host record and the exact root commands are in `data/logs/NOTES-agent-install.md`
|
||||
(its firewall lines are the phase-10 state, not today's), how far the NUT wire reaches from each
|
||||
network is in `data/logs/NOTES-upsd-acl.md`, and the end-to-end direct-dial verification
|
||||
(health table, no-tunnel-port proof, live-suite tally, screenshots, and the probes that put
|
||||
the block on the lab gateway's port allow-list rather than in this app) is in
|
||||
`data/logs/NOTES-verify.md`.
|
||||
`data/logs/NOTES-verify.md`, and the phase-11 login verification (the `curl` refusal codes, the
|
||||
six screenshots, the two `meta` keys the database actually holds, and the operator's
|
||||
deployment-repo actions) is in `data/logs/NOTES-auth.md`. Those notes are local evidence, not
|
||||
tracked files: `data/` is gitignored (invariant 10's `data/logs/*.png` included), so a fresh
|
||||
clone has the conclusions in the prose above and the captures live on the box that ran them —
|
||||
re-run the probe rather than trusting either.
|
||||
- Engines are reached directly: gufo `deskwork.reeselink.com:8731`, vLLM `driveripper.reeselink.com:8000`.
|
||||
- NUT 2.8.5 on driveripper: **configs live in `/etc/ups/`, not `/etc/nut/`**, and **upsd is
|
||||
on port 3051, not 3493**. Nothing listens on 3493 at all, so a default client reports
|
||||
@@ -352,14 +549,15 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
2.8 does not refuse non-local clients the way older docs suggest: `LISTEN 0.0.0.0 3051` +
|
||||
`LISTEN :: 3051` with `3051/tcp` in the `FedoraServer` zone answers any client routed to it
|
||||
(reads are unauthenticated; `SET`/`INSTCMD` still need an `upsd.users` account). The host
|
||||
range `10.1.0.0/21` does reach it today (verified from `deskwork`); the laptop's
|
||||
`10.2.0.0/22` does **not** — the lab gateway `10.2.0.1` forwards 22/1234/8000-8004 and
|
||||
silently drops 3051/3493 in both families, which no sudo on a fleet host can change, so the
|
||||
laptop cannot yet dial `NUT_PORT=3051` (probe evidence and the operator's one rule:
|
||||
`data/logs/NOTES-upsd-acl.md`).
|
||||
Two UPSes are exported over USB and dialled directly as `NUT_HOST` + `NUT_PORT=3051` (from the
|
||||
laptop that is still the blocked path above; from the host itself, and from the deployed
|
||||
dashboard, it reads live):
|
||||
range `10.1.0.0/21` reaches it, and **since 2026-10-19 so does the laptop**: a raw
|
||||
`LIST UPS` to `10.1.0.126:3051` from the dev box answers `BEGIN LIST UPS`, which is why
|
||||
`nut` reads `ok` in `./scripts/dev-server.sh status` with two real UPSes and no
|
||||
`NUT_SIMULATE`. Phase 10 recorded `10.2.0.1` dropping 3051/3493 for this laptop; that is
|
||||
history now, and the probes behind both statements are in `data/logs/NOTES-upsd-acl.md`.
|
||||
Two UPSes are exported over USB and dialled directly as `NUT_HOST` + `NUT_PORT=3051`, and that
|
||||
reads live from every vantage the dashboard has ever used — the host itself, the deployed
|
||||
dashboard, and since 2026-10-19 the laptop (`/ups` shows both boxes at 100 % charge, `OL`,
|
||||
with no `SIMULATED` badge anywhere):
|
||||
`apc` = APC Back-UPS RS 1500MS, `cyberpower` = CyberPower PR1500LCDRT2U (that is
|
||||
`ups.description`, the operator's label — the driver's own `identity.model` answers
|
||||
`GX1500U`, so the description is what identifies the box).
|
||||
@@ -379,21 +577,22 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
`upsc`, `udevadm`, the `/etc/ups` reads, and the `tee /etc/ups/upsd.users|upsd.conf` +
|
||||
`systemctl restart|reload nut-server` pair documented in `deploy/nut-sudoers`); `deskwork`
|
||||
grants an unrelated set (`btrfs scrub`, `snapper`, `setfacl /srv/*`, `osbuild-auto.sh`).
|
||||
Neither `driveripper` nor `deskwork` can run the installer or `visudo`, so those two installs
|
||||
— and the `firewall-cmd` line each still needs — stay with the operator.
|
||||
- **SMART is captured from real hardware; the live agents still cannot read it.** `smartctl`
|
||||
7.5 is installed on deskwork and driveripper. The `ops` account (which runs the dev agents)
|
||||
is in no `disk` group; driveripper grants it `(root) NOPASSWD: /usr/sbin/smartctl*` and
|
||||
deskwork grants nothing, so the agent's own unprivileged `smartctl -i -A -H` — it shells the
|
||||
binary and never calls sudo — answers *Permission denied* (exit 2) on every host and `smart`
|
||||
stays `null` end to end: the designed degradation, not a bug. The agents deployed on
|
||||
driveripper and deskwork also predate the SMART pass, so the `disks` source today yields one row
|
||||
per physical disk (16 on driveripper, 1 on deskwork, `vda` on gateway) with `smart: null`,
|
||||
`smart_errors: []` and `smartctl_available: null` — "nobody told us either way", which is why
|
||||
that flag is three-way and not a boolean. `gateway`'s agent is the one exception as of phase 10:
|
||||
it is the installed systemd service, which does know about the SMART pass and answers
|
||||
`smartctl_available: false` there because smartmontools is not installed — the *install it*
|
||||
half of the three-way flag, from a host the dashboard cannot yet reach (its firewall).
|
||||
Neither host grants `ops` the installer or `visudo` (re-checked 2026-10-19), so every install
|
||||
that has happened on those two went through root by the operator — including the one still
|
||||
outstanding, `driveripper`'s.
|
||||
- **SMART: real captures, and one live host finally reads them.** `smartctl` 7.5 is installed on
|
||||
deskwork and driveripper. The `ops` account (which is who runs a hand-started dev agent) is in
|
||||
no `disk` group, driveripper grants it `(root) NOPASSWD: /usr/sbin/smartctl*` and deskwork
|
||||
grants nothing, and `host_agent.py` shells the binary **as itself** — it never calls sudo — so
|
||||
an agent run as `ops` still answers *Permission denied* (exit 2) and reports `smart: null`.
|
||||
What changed by 2026-10-19 is that `deskwork`'s **installed** service runs as
|
||||
`dashboards-agent`, which is in the `disk` group (`getent group disk` → `disk:dashboards-agent`)
|
||||
on an agent installed after the SMART pass: `/disks` shows real numbers there —
|
||||
`smartctl_available: true`, `nvme0n1` healthy at 52 °C, 19 158 power-on hours, 11 % of rated
|
||||
wear — so the wear bar and the alarm chips are drawn from live hardware, not only fixtures.
|
||||
`gateway` answers `smartctl_available: false` (no smartmontools in its VM: the *install it*
|
||||
half of the three-way flag), and `driveripper` answers nothing at all until its agent exists,
|
||||
which is where `smartctl_available: null` — "nobody told us either way" — still comes from.
|
||||
**Real data is in the repo now:** `tests/fixtures/smartctl_driveripper_<dev>.txt` is all 16
|
||||
drives (8 NVMe, 8 spinning) read through that sudo rule, and `tests/test_agent.py` parses
|
||||
every one — which is what found the parser's one real bug: this binary annotates the
|
||||
@@ -403,15 +602,16 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
honest look at this fleet's drives: `nvme5n1` reports 13 media-and-data-integrity errors and
|
||||
`nvme1n1` is at 58 % of its rated wear, so the alarm chip and the amber wear bar on `/disks`
|
||||
are real features with real data behind them, not demo furniture.
|
||||
**Lighting up the live page is two operator actions that travel together:** give the agent
|
||||
user device read access — the `disk` group (`sudo usermod -aG disk <agent-user>`, which is
|
||||
what `deploy/install-agent.sh` does on install), *not* a sudo rule, because the agent must
|
||||
read the device itself — and redeploy `agent/host_agent.py` (never redeploy without the
|
||||
grant: 16 permission-denied lines would age nothing but are pure noise, while with the grant
|
||||
they are 16 drives' worth of temperature, hours and sector counters). Until then `/disks`
|
||||
**Lighting up the rest of it is the same two operator actions that travel together:** install
|
||||
the agent on `driveripper`, and give its agent user device read access — the `disk` group
|
||||
(`sudo usermod -aG disk <agent-user>`, which is what `deploy/install-agent.sh` does on install),
|
||||
*not* a sudo rule, because the agent must read the device itself — on an `agent/host_agent.py`
|
||||
from phase 09 or later (never redeploy without the grant: 16 permission-denied lines would age
|
||||
nothing but are pure noise, while with the grant they are 16 drives' worth of temperature,
|
||||
hours and sector counters). `gateway` needs smartmontools first and will still read no SMART
|
||||
after that, being a VM with no physical disk behind `vda`. A host that cannot read its drives
|
||||
renders the designed degradation: one dimmed card per physical drive, every number `n/a`, and
|
||||
the one-line why — which is also what gateway shows permanently, being a VM with no physical
|
||||
disk to read and no smartmontools at all.
|
||||
the one-line why.
|
||||
`tests/fixtures/smartctl_reference_*.txt` stay as what they are: **reference shapes** (labels
|
||||
verbatim from the deployed binary, values synthetic), kept beside the real captures so the
|
||||
synthetic edge cases stay pinned. Re-record the real ones with
|
||||
@@ -425,16 +625,19 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
|
||||
```bash
|
||||
uv sync --python 3.14 # deps + editable install
|
||||
cp .env.example .env # production-shaped config
|
||||
cp .env.example .env # production-shaped config (DASH_ADMIN_PASSWORD seeds
|
||||
# the login on the first boot of a database — with no
|
||||
# seed and no stored verifier the server exits non-zero)
|
||||
cp .env.dev.example .env.dev # dev-only: the real hosts, tokens in URL userinfo
|
||||
|
||||
set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no tunnels
|
||||
./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up
|
||||
./scripts/dev-server.sh status # per-source ok/DOWN
|
||||
./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up; sign in with the
|
||||
# DASH_ADMIN_PASSWORD from .env.dev (`testpass`)
|
||||
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
|
||||
./scripts/dev-server.sh logs
|
||||
|
||||
uv run pytest # hermetic: 793 passed + 11 live-skipped, no network
|
||||
uv run pytest --cov # coverage gate (98.33%; floor 94%)
|
||||
uv run pytest # hermetic: 1140 passed + 11 live-skipped, no network
|
||||
uv run pytest --cov # coverage gate (98.6%; floor 94%)
|
||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||
uv run ruff check src tests agent && uv run pyright
|
||||
./scripts/capture-fixtures.sh # re-record ground truth (agents + UPS + gufo /slots)
|
||||
@@ -512,6 +715,42 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
|
||||
test on hardware something is plugged into is not a test.
|
||||
- The agent's real-`/proc` test (`tests/test_agent.py`) touches only the box running pytest,
|
||||
so it stays in the default run and is *not* marked `live`.
|
||||
- `tests/auth_contract.py` is why the HTTP suite can start a server at all: every app in a test
|
||||
is seeded with `admin_password="testpass"` (the local-testing password — a server with no
|
||||
stored verifier and no seed refuses to start, so an app under test needs a credential exactly
|
||||
like a deployment does) and hashed at `pbkdf2_iterations=1000`, because the real 600 000 costs
|
||||
~0.3 s per client and the suite starts a hundred-odd of them. The real figure stays pinned in
|
||||
`test_auth.py` and `test_config.py`, so the cheap cost here cannot become the default there.
|
||||
- **The perimeter is its own file, and it is paranoid in two specific ways.**
|
||||
`tests/auth_client.py::sign_in(client)` is the one way a test gets a session: it posts
|
||||
`testpass`, asserts the sign-in worked (so a suite cannot silently start from a 401 and fail
|
||||
three lines later on something that looks like an unrelated bug), and puts the returned CSRF
|
||||
token on the client's default headers — which is what let the ~200 pre-existing HTTP assertions
|
||||
in `tests/test_api.py` stay untouched, asserting UPS behaviour rather than the new gate.
|
||||
`tests/test_auth_api.py` then runs the gate through a real ASGI request with
|
||||
`follow_redirects=False` on every anonymous call: `TestClient` follows redirects by default, so
|
||||
an unsigned `GET /api/config` would fetch the login page and report `200` — the way a gated
|
||||
suite ends up passing while testing nothing. Two of its tests are the ones to keep if you keep
|
||||
none: `test_every_route_that_is_not_public_requires_a_session` walks the **served** route table
|
||||
(`walk_routes` flattens pages, `include_router` wrappers and the `/static` mount, and raises on
|
||||
anything it does not recognise, so the audit cannot silently shrink) and demands `401`/`303` for
|
||||
every path outside `PUBLIC_PATHS`, GET and POST — the test that protects the next phase rather
|
||||
than this one; and the UPS pair prove the CSRF refusal on **the wire** (a scripted
|
||||
`nut.NutClient` records nothing for a tokenless write and does record it once the header is
|
||||
there, and `driver.killpower` still needs `DANGER` behind the session), because 403 after
|
||||
`instcmd` would be a power cut with an error message attached.
|
||||
- **The setup surface has its own pins, because prose rots quietly.**
|
||||
`tests/test_auth_docs.py` reads the artefacts an operator actually holds when a dashboard will
|
||||
not come up: both tracked env templates (every `DASH_*` auth setting declared; every
|
||||
secret-shaped *assignment* a placeholder — the same allow-list `.agents/validate.sh` 2b uses,
|
||||
so `uv run pytest` catches a committed password on the working tree and not only in the gate;
|
||||
the local-testing word allowed in prose and forbidden as a value), `scripts/dev-server.sh` (its
|
||||
probe and its `status` ride `/api/health`, no `dash_session`/`/api/auth/`/`Authorization:`
|
||||
anywhere, the password named but never assigned), and `README.md`/`AGENTS.md` (the *Signing in*
|
||||
section before *Configuration*, the four public entries, the reset `DELETE FROM meta …` command
|
||||
verbatim, the four config rows, the `DASH_COOKIE_SECURE=1` deployment note, the three auth
|
||||
gaps). No server starts and no request is made: a doc that stops naming the reset command is a
|
||||
locked-out admin, and that is worth a test even though it asserts nothing about Python.
|
||||
- Coverage gate: 80% is the repo floor, the phase gate in `.agents/validate.sh` enforces
|
||||
94% (`--cov-fail-under`), and ~98% is where we are; keep it there. New/modified Python
|
||||
still has to clear >90% on its own. API tests use `create_app()` with an in-memory DB,
|
||||
@@ -878,16 +1117,65 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
field map is written.
|
||||
3. **UPS history depth.** Events and the numeric series are stored; long-window battery
|
||||
health (capacity fade over months) has the rollups but no dedicated view.
|
||||
4. **The dev DB is big enough to refuse a write now and then.** The dev store (~610 MB, WAL
|
||||
grown past 140 MB) logs `store-error: OperationalError: database is locked` a few times an
|
||||
4. **The dev DB is big enough to refuse a write now and then.** The dev store (~640 MB on
|
||||
2026-10-19, its WAL grown past 140 MB at its worst) logs `store-error: OperationalError:
|
||||
database is locked` a few times an
|
||||
hour across `system`/`llm`/`ups`; the poller handles it exactly as invariant 4 requires —
|
||||
the source goes red with the real reason, ages, and writes `recording` when the next poll
|
||||
lands — so nothing freezes, but those polls' samples are lost. The one cause this repo can
|
||||
prove is two servers on one file, which is why `scripts/dev-server.sh stop` waits for the
|
||||
old pid to die (its comment records the incident). A controlled 25 s external read
|
||||
transaction during this pass produced none, so checkpoint starvation is not yet shown.
|
||||
transaction during this pass produced none, and neither did the dev-server log window open on
|
||||
2026-10-19 — the absence of a sample, not the absence of the pressure, since the file only
|
||||
keeps growing.
|
||||
Retention/`wal_checkpoint` tuning for a long-running store is its own phase, not a
|
||||
phase-09 follow-up.
|
||||
5. **The login throttle keys on the socket peer address** (phase 11), and the one
|
||||
`LoginThrottle` instance now holds two keys per address: the bare one for
|
||||
`POST /api/auth/login` and `password:<address>` for `POST /api/auth/password`. Behind nginx
|
||||
every login shares one bucket, so anyone who can reach the login form can lock the operator out
|
||||
of the *dashboard* — not the UPS, not the data, not a host — for at most
|
||||
`LoginThrottle.max_seconds` (1 h by default) at a time, and a process restart clears the
|
||||
counters. The two keys are separate on purpose, so neither form can be hammered at the other's
|
||||
rate and a stolen cookie plus the change form is not an unthrottled oracle for the old
|
||||
password; the cost of that choice is that one address can be blocked twice over. Accepted for a
|
||||
single-admin box rather than becoming an `X-Forwarded-For` knob:
|
||||
that alternative is spoofable by anyone the moment somebody forgets to configure which proxy
|
||||
to trust, which would hand the attacker the throttle key instead of leaving it on the
|
||||
network layer. PBKDF2-HMAC-SHA256 at `PBKDF2_ITERATIONS` (600 000) stays the real cost of
|
||||
every guess; the throttle only makes hammering the form slower than useless.
|
||||
6. **One admin, one factor — no second factor and no per-user accounts, by design** (phase 11).
|
||||
The operator asked for a single admin, and that is what the model is: one verifier row, one
|
||||
username in every signed session, one name in every `password-changed` event, no read-only
|
||||
role and no per-viewer view. The consequence is worth stating out loud, because it is the
|
||||
whole security budget of a public domain: the PBKDF2 cost plus the backoff is the only thing
|
||||
between a stranger and the fleet's hostnames and the UPS command buttons, so there is no
|
||||
defence in depth behind it — no TOTP, no WebAuthn, no per-IP allow-list, no "this login is
|
||||
from somewhere new" notice. Adding a second human means more than a second row: the gate has
|
||||
no notion of scope, so a colleague would be able to cut power unless somebody designs a
|
||||
read-only session first. That is a phase, not an edit.
|
||||
7. **The throttle is per-process, so a second uvicorn worker multiplies it** (phase 11).
|
||||
`LoginThrottle` is a dict in one process's memory: `uvicorn --workers 4` hands anyone who
|
||||
reaches the form four independent buckets and four times the attempts before a `429`, and a
|
||||
restart clears all of them (the gap-5 note above). Nothing else in the auth design has that
|
||||
problem — the session is an HMAC-signed cookie and the CSRF token derives from it, so both
|
||||
are worker-safe by construction with no store to share and nothing to prune. The reason this
|
||||
stays unfixed is that the app is single-process by nature anyway: every worker would run its
|
||||
own poller loops against the same SQLite file (AGENTS.md invariant 4, and the `database is
|
||||
locked` incident in gap 4 is what two processes on one store already looks like). If this is
|
||||
ever revisited, the fix is a shared counter (the `meta` table would do, at login rates), not
|
||||
a bigger number in one process.
|
||||
8. **A logout ends this browser's cookie, not a copy of it** (phase 11). The session is an
|
||||
HMAC-signed cookie with no server-side record — that is the design invariant 12 and gap 7 rest
|
||||
on — so `POST /api/auth/logout` can only tell *this* browser to drop the cookie (verified
|
||||
against a running server: a replayed token from a logged-out jar still authenticates until it
|
||||
expires). The same is true of an `Authorization: Bearer` token, which was never in a cookie to
|
||||
begin with. The only revocation this app has is rotating `auth.session_secret`, which
|
||||
`POST /api/auth/password` does on purpose, and the reset `DELETE FROM meta …` does as the
|
||||
lockout escape hatch. Accepted for a single-admin box on a domain nobody else signs in to —
|
||||
the operator's own machine is the threat model here, not a shared kiosk — and the reason a
|
||||
lost laptop is a password change and not a sign-out. A real revocation list would be a row per
|
||||
session with a prune rule, which is the thing this design traded away.
|
||||
|
||||
## Locked decisions (and why)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user