chore(agent): add phase 11 — single-admin login for the dashboards

Eight tasks to put a session gate in front of every page and API route: the auth primitives
(PBKDF2 verifier, HMAC-signed stateless session, session-bound CSRF token, login throttle,
origin check) in a new `auth.py`, the verifier in the store plus fail-closed startup, the
default-deny middleware with an explicit public list, `POST /api/auth/password` with session
rotation, a login page, a `/settings` page, and the dev-config/docs/browser verification pass.

Stdlib hashlib/hmac only — no new dependency, so no PLAN.md anchor moves. `/api/health`
stays public on purpose (the Containerfile HEALTHCHECK and dev-server.sh depend on it), and
one test enumerates every route and demands a session.

LLM-Generated: true
This commit is contained in:
2026-10-17 12:27:40 -04:00
parent 71900d903a
commit 046dc57b26
8 changed files with 888 additions and 0 deletions
@@ -0,0 +1,110 @@
# Phase 11 — One admin, one password: the dashboards stop being public
**Source:** operator request in chat, 2026-10-17 — "password protect this dashboard for a single admin user… changeable once logged in, configured initially with an env var, not required to change… for local testing the default admin password should be `testpass`."
**Story:** n/a
**Context:** `src/dashboards/app.py` (route table, middleware, `PAGES`), `src/dashboards/api.py` (`AppContext`, `/api/config`, the one `POST /api/ups/command`), `src/dashboards/config.py` (env-only settings), `src/dashboards/store.py` (the existing `meta` table), `src/dashboards/web/static/app.js` (`fetchJSON`, `initChrome`), `.env.example` / `.env.dev.example`, `README.md` → *Configuration*, `AGENTS.md` → *Non-negotiable rules*
## Objective
Every page and every `/api` route except a small, explicit public list requires a signed-in
admin session; the admin password is seeded from `DASH_ADMIN_PASSWORD` on first start, is
changeable from a new `/settings` page, and does **not** have to be changed. The server refuses
to start when it has neither a stored password nor the env var — there is no way to run this
thing open by accident.
## Derived scope (from the operator, in chat)
- **Fail closed.** `DASH_ADMIN_PASSWORD` unset **and** no stored verifier → the process exits
with an operator-readable reason. `testpass` is the local-testing password (dev config +
the hermetic suite), never a production fallback.
- **`/api/health` stays public** on purpose — the Containerfile `HEALTHCHECK` and
`./scripts/dev-server.sh status` depend on it. Its shape must not change, and nothing new
may be added to it without treating it as a public endpoint.
- **Most secure, least likely to break** was the instruction for sessions/CSRF → stdlib
`hashlib`/`hmac` only: PBKDF2-HMAC-SHA256 verifier, HMAC-signed stateless session cookie,
a session-bound CSRF token on every write, and a default-deny gate. **No new dependency**,
so no `PLAN.md` anchor moves (`itsdangerous` + Starlette `SessionMiddleware` was the
alternative the operator declined by asking for "least likely to break").
## Dependencies
- `01_severity_language` … `05_ups_hero_battery_glyph` (complete) — the pages, the shared
`.masthead` chrome and `Dash.fetchJSON` that this phase wraps; nothing here may change what
they render once a session exists.
- `06_system_page_gpu_verdicts`, `07_llm_monitor_style_pages`, `08_events_paging_and_copy`,
`09_disks_smartctl` (todo) — **no** dependency in either direction. This phase touches
`app.py`, `config.py`, `store.py`, `app.js` and new files; those phases touch
`severity.py`, `sources/*`, page scripts. If they run first, re-read the `create_app(` call
sites in `tests/` before task 03 — that is the only overlap that can conflict.
- `10_dev_direct_dial` (todo) — **no functional dependency, four shared files**: it rewrites
`.env.dev.example`, edits `.env.example`'s NUT section, rewrites the local `.env.dev`, and
rewrites the dev sections of `README.md`/`AGENTS.md`. Run either order, with two rules: apply
this phase's `.env*` additions by **appending to whatever the file says at the time** (never
restore a `DEV_TUNNELS`/`DEV_FORWARDS` line that 10 deleted), and never delete a
`DASH_ADMIN_PASSWORD` line 10 touches. Whichever phase runs second re-checks the diff of the
other's doc edits rather than overwriting them.
## Tasks
1. `01_auth_primitives.md` — `src/dashboards/auth.py`: password hash/verify (self-describing PBKDF2), session mint/read, CSRF token, login throttle, origin check. Pure logic, no HTTP.
2. `02_verifier_config_fail_closed.md` — `store.get_meta`/`set_meta`/`delete_meta`, the four `DASH_*` auth settings, and `ensure_admin_verifier()` wired into the lifespan so a dashboard with no password anywhere refuses to start.
3. `03_session_gate_and_public_surface.md` — the default-deny middleware, `/login` route, `/api/auth/login|logout|status`, CSRF enforcement on every write, `fetchJSON` attaching the token and bouncing to `/login` on 401, plus the test that enumerates **every** route and demands a session.
4. `04_change_password.md` — `POST /api/auth/password`: current-password check, verifier replace, session-secret rotation (every other browser is out), event row, throttle reset.
5. `05_login_page.md` — `pages/login.html` + `static/page-login.js`: password field, inline accessible errors, lockout message, no CDN, theme parity with the other pages.
6. `06_settings_page.md` — `/settings`: change-password form, sign-out, session age; a `⚙` link in the masthead of all four dashboards.
7. `07_dev_config_docs_browser_verification.md` — env templates, the operator's gitignored `.env.dev`, README + AGENTS.md, then live browser proof with screenshots and the leak checks (`/api/summary` 401 anonymous, `/system` 303, `/api/health` 200).
## Testing & Quality
- Unit: `tests/test_auth.py` for every primitive — wrong password, tampered cookie, expired
cookie, cookie signed with a rotated secret, CSRF token from another session, throttle
backoff and reset, `needs_rehash` when the configured cost rises.
- Integration: `tests/test_auth_api.py` — anonymous 401 on every `/api/*` data route, 303 →
`/login?next=…` for HTML, login success/malfailure/lockout over HTTP, logout kills the
cookie, change-password invalidates the *old* session and the *other* browser's,
`POST /api/ups/command` refuses without the CSRF header even **with** a valid session.
- The default-deny guard: `test_every_route_that_is_not_public_requires_a_session` walks
`create_app().routes` and asserts nothing outside the public list answers anonymously. A
route added by a future phase that forgets the gate fails here, not in production.
- `tests/test_api.py` keeps passing unmodified in intent: `build_client()` logs in once with
`testpass`, so the ~200 existing HTTP assertions still assert what they assert. The nine
direct `create_app(Settings(...))` call sites in `tests/` get the same password.
- Coverage: **>90%** on new/modified code (`auth.py` should be near 100%); the overall gate
(94%, currently ~98%) must hold.
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test
in the project, not just this phase's), including `uv run pytest -m live --live`.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands
after this phase — fixing every inconsistency found, **including pre-existing ones not
caused by this phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible
(setup, commands, configuration, features, conventions). This phase is user-visible in the
strongest sense: the first thing an operator meets is a login prompt.
## Completion Criteria
- [ ] `curl -si http://127.0.0.1:8090/system` → `303` to `/login?next=/system`; `curl -s
http://127.0.0.1:8090/api/summary` → `401 {"ok": false, …}`; `curl -s
http://127.0.0.1:8090/api/config` → `401`; `curl -s http://127.0.0.1:8090/api/health` →
`200` (the one deliberate public data route)
- [ ] login with `testpass` in a browser reaches `/`; a wrong password shows an inline error
and, after 5 failures, the lockout message with `Retry-After` honoured
- [ ] `/settings` changes the password; the same browser is signed out and gets back in with
the new one; a second browser (or a saved cookie) is rejected with 401
- [ ] with `DASH_ADMIN_PASSWORD` unset **and** no `auth.verifier` row, `uv run dashboards`
exits non-zero with a message naming the variable and the reset command
- [ ] `./scripts/dev-server.sh status` and the Containerfile `HEALTHCHECK` still work
unauthenticated (`/api/health` unchanged)
- [ ] `uv run pytest` green, `uv run pytest --cov --cov-fail-under=94` green,
`uv run ruff check src tests agent && uv run pyright` clean, `bash .agents/validate.sh`
green
- [ ] screenshots in `data/logs/`: `auth-login.png`, `auth-login-rejected.png`,
`auth-locked-out.png`, `auth-signed-in-overview.png`, `auth-settings.png` (AGENTS.md
invariant 10 — a UI change is screenshotted, not claimed)
- [ ] `README.md` documents the four new `DASH_*` variables, the reset procedure, and the fact
that a UI change wins over the env var; `AGENTS.md` carries the new invariant naming the
public surface; both are accurate and consistent with the whole project (final pass)
- [ ] no behavior change in completed phases: every payload, hero card, chart and gate the
five completed phases shipped still renders identically **once signed in**
@@ -0,0 +1,112 @@
# Task 01 — Auth primitives: hashes, session tokens, CSRF tokens, throttle
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "password protect this dashboard for a single admin user"
**Story:** n/a
## Objective
`src/dashboards/auth.py`: every authentication decision as a pure, unit-testable function with
no HTTP and no I/O — password hash/verify, session token mint/read, CSRF token mint/verify,
login throttle, origin check. Nothing here knows about FastAPI; task 03 wires it.
## Work
1. New `src/dashboards/auth.py`, module docstring stating the design and **why** it is
hand-rolled (stdlib only — the same reasoning as `prom.py` and the NUT wire client; no
`itsdangerous`, no `bcrypt`, no session store to operate). Constants at the top:
```python
ADMIN_USER = "admin" # single admin; there is no user table and none is coming
SESSION_COOKIE = "dash_session"
CSRF_HEADER = "X-CSRF-Token"
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
PBKDF2_ITERATIONS = 600_000 # OWASP's PBKDF2-HMAC-SHA256 figure; DASH_PBKDF2_ITERATIONS overrides
PBKDF2_ALGO = "pbkdf2_sha256"
```
2. Password verifier — self-describing so the cost can rise later without a migration:
- `hash_password(password: str, *, iterations: int = PBKDF2_ITERATIONS) -> str` →
`pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>`, salt `secrets.token_bytes(16)`,
`hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)`.
- `verify_password(password: str, encoded: str) -> bool` — parses defensively and returns
`False` for `""`, a wrong field count, a non-numeric iteration count, an unknown algo
name, odd-length hex, or a length mismatch. **It must never raise**: a corrupt `meta`
row has to read as "wrong password", not a 500 on the login page. Compare with
`hmac.compare_digest`.
- `needs_rehash(encoded: str, *, iterations: int) -> bool` — true when the stored cost (or
algo) is below the configured one, so a login can transparently upgrade the hash.
- No password, hash, salt or session token may ever appear in a log line, an exception
message, or a return value used for display. Name this in the docstring: AGENTS.md
invariant 6 now covers the admin's own credential.
3. Sessions — stateless and signed, so there is no session table and no cleanup job:
- `new_secret() -> str` → `secrets.token_hex(32)`. Stored by task 02 under the meta key
`auth.session_secret`; rotating it invalidates every issued cookie, which is how changing
the password signs everyone else out.
- `@dataclass(frozen=True) Session: username: str; expires_at: float; token: str`
- `mint_session(secret: str, *, username: str = ADMIN_USER, hours: float = 720.0,
now: float | None = None) -> str` → `b64url(f"{username}|{expires_at:.0f}")` + `"."` +
`b64url(hmac_sha256(secret, payload))`. Use `base64.urlsafe_b64encode` and strip padding;
`hmac.new(secret.encode(), payload.encode(), hashlib.sha256)`.
- `read_session(secret: str, token: str, *, now: float | None = None) -> Session | None` —
signature first (constant-time), then `expires_at > now`. Returns `None` for: `""`,
no dot, non-base64 payload, payload without `|`, non-numeric expiry, an expired session,
a token signed by a rotated secret, and a payload whose *username* was tampered with.
- `mint_csrf(secret: str, session_token: str) -> str` →
`hmac_sha256(secret, "csrf|" + session_token).hexdigest()` — bound to the session, so a
token stolen from one session cannot authorise another's write, and it needs no expiry
of its own because the session has one.
- `verify_csrf(secret: str, session_token: str, provided: str | None) -> bool` —
`compare_digest`, `False` on `None`/empty.
4. Login throttle — in-process memory, because there is one admin and one uvicorn process:
- `class LoginThrottle: def __init__(self, *, max_attempts: int = 5, base_seconds: float =
60.0, max_seconds: float = 3600.0)`
- `retry_after(key: str, *, now: float | None = None) -> float` → seconds still blocked,
`0.0` when allowed.
- `failure(key: str, *, now: float | None = None) -> float` → the backoff just applied.
Exponential from `base_seconds` per key, capped at `max_seconds`; a key that gets a
successful login is cleared.
- `success(key: str) -> None`, and `prune(now)` (or an inline length guard) so a spoofed
swarm of source addresses cannot grow the dict without bound.
- Take `now` as an injectable seconds value on every method (`time.monotonic` at the call
site in task 03) — a test must not sleep 60 s to prove the backoff works.
- ASSUMPTION: the key is the socket peer address. Behind nginx every login shares one
bucket, so a determined attacker can lock the operator out of the dashboard (not of the
UPS, not of the data) for at most `max_seconds`. Accepted for a single-admin box; it goes
in `AGENTS.md` → Known gaps rather than becoming an `X-Forwarded-For` knob that is
trivially spoofed when someone forgets to trust the proxy.
5. Origin / Sec-Fetch gate as pure string functions (unit-testable without a `Request`):
- `origin_matches(origin: str, own_origin: str, allowed: Sequence[str] = ()) -> bool`
- `sec_fetch_site_allowed(value: str | None) -> bool` — allow `None`, `"same-origin"`,
`"none"`; reject `"cross-site"`, `"cross-origin"`, `"same-site"`(unknown → reject).
Anything unrecognised is rejected: a browser that invents a new value is a browser doing
something we did not reason about.
## Testing & Quality
- `tests/test_auth.py` (new) — the exhaustive negative table above. Minimum:
round-trip hash/verify, wrong password, unicode password (`"pässwörd"` — proves the utf-8
encode), five malformed encodings all `False` with no exception, `needs_rehash` on a
1_000-iteration hash against the default cost, session round-trip, tampered username,
flipped signature byte, truncated token, expired session (via `now`), cookie from a rotated
secret, CSRF ok/wrong-session/missing, throttle allows 5, blocks the 6th with
`retry_after > 0`, backoff doubles and caps at `max_seconds`, `success()` clears,
`origin_matches` accepts its own origin and the allow-list and rejects
`http://evil.example`, `sec_fetch_site_allowed("cross-site") is False`.
- Cover `auth.py` to ~100% — every branch here is a security decision, and a branch no test
reaches is a decision nobody checked.
- Coverage: **>90%** on this task's new code; overall gate (94%) maintained.
## Completion Criteria
- [ ] `uv run pytest tests/test_auth.py -v` green
- [ ] `python -c "from dashboards import auth; h=auth.hash_password('testpass'); print(h)"`
prints a `pbkdf2_sha256$600000$…` string and `auth.verify_password('testpass', h)` is
`True` while `verify_password('wrong', h)` and `verify_password('testpass', 'junk')` are
`False`
- [ ] `grep -n "print(\|logger\|logging" src/dashboards/auth.py` shows nothing that could
echo a credential
- [ ] no new dependency in `pyproject.toml` / `uv.lock` (`uv pip list --outdated` stays empty)
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,119 @@
# Task 02 — Where the password lives: `meta`, the four settings, and failing closed
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "configured initially with an env var (but should not be required to change)"
**Story:** n/a
## Objective
Make the credential durable and the startup honest: the verifier and the signing secret live in
the SQLite `meta` table, the environment seeds them once, and a server with neither a stored
verifier nor `DASH_ADMIN_PASSWORD` **refuses to start** instead of serving the fleet open.
## Work
1. `src/dashboards/store.py` — three small public helpers over the `meta` table that already
exists (`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)`), each under
`self._lock`:
- `get_meta(key: str) -> str | None`
- `set_meta(key: str, value: str) -> None` (`INSERT OR REPLACE`)
- `delete_meta(key: str) -> int` (returns rowcount; the reset procedure needs it and a
test should prove it removes only the named key)
Route `stats()`'s `last_maintain` read through `get_meta()` so there is one path to the
table. Leave `maintain()`'s write inline — it is inside that method's transaction.
2. `src/dashboards/config.py` — four new `Settings` fields beside the server block, using the
existing `_env` / `_num` / `_flag` / `_int` helpers (field defaults are read once at import;
that is the established pattern, see the module docstring):
- `admin_password: str = _env("DASH_ADMIN_PASSWORD")` — the **seed**. `_env()` strips, so
`DASH_ADMIN_PASSWORD=` means *unset*, never "the password is empty".
- `session_hours: float = _num("DASH_SESSION_HOURS", 720)` — 30 days; a homelab dashboard
that logs the operator out every shift change gets bypassed, which is worse.
- `cookie_secure: bool = _flag("DASH_COOKIE_SECURE")` — off by default because dev is
`http://127.0.0.1`; task 03 also marks the cookie `Secure` when the request arrived over
`X-Forwarded-Proto: https`.
- `pbkdf2_iterations: int = _int("DASH_PBKDF2_ITERATIONS", PBKDF2_ITERATIONS)` — import the
constant from `.auth` (`auth.py` imports nothing from this package, so there is no cycle).
The knob exists so the hermetic suite can hash in microseconds instead of 0.3 s; the
comment must say that, and must not suggest lowering it in production.
3. `src/dashboards/auth.py` — the loader that turns settings + `meta` into request-time state.
Declare a `MetaStore` `Protocol` (`get_meta` / `set_meta` / `delete_meta`) so this module
still imports nothing from `config`/`store`:
```python
@dataclass(frozen=True)
class AuthState:
verifier: str # encoded PBKDF2, never the password
secret: str # session-signing key
session_hours: float
iterations: int
seeded: bool # True when this start wrote the verifier row
env_ignored: bool # True when a stored verifier beat DASH_ADMIN_PASSWORD
```
- `load_auth_state(store: MetaStore, *, admin_password: str, iterations: int =
PBKDF2_ITERATIONS, session_hours: float = 720.0, now: float | None = None) -> AuthState`
- `verifier = store.get_meta("auth.verifier")`.
- If absent and `admin_password` → `hash_password(...)`, `set_meta("auth.verifier", …)`,
`seeded=True`.
- If absent and no `admin_password` → raise `RuntimeError` with the exact operator text:
name `DASH_ADMIN_PASSWORD`, name the meta key, and give the reset command
(`sqlite3 <db> "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"`).
Fail closed here — not "start open with a warning": the domain is public and the fleet's
hostnames and UPS controls are behind it.
- `secret = store.get_meta("auth.session_secret")` or generate with `new_secret()` and
persist it. Persisting it is what makes a restart not invalidate every browser.
- `env_ignored = bool(admin_password) and not seeded`. When true,
`logging.getLogger("dashboards.auth").warning()` **once**, at startup, with the words:
`DASH_ADMIN_PASSWORD is ignored — the password set in /settings wins; delete the
'auth.verifier' row to reset from the environment`. Never log the value, the hash, or
any prefix of either.
- `update_verifier(store: MetaStore, state: AuthState, new_password: str) -> AuthState` —
hashes, writes both `auth.verifier` and a **fresh** `auth.session_secret`, returns the new
state (task 04 uses it; the rotation is what signs out every other browser). Empty /
whitespace-only `new_password` raises `ValueError` — an empty password must never be
storable.
4. `src/dashboards/app.py` — build the state during lifespan startup, before `start()`, for
both the owned-store and injected-store branches, and hang it off the context:
`ctx.auth = load_auth_state(store, admin_password=settings.admin_password,
iterations=settings.pbkdf2_iterations, session_hours=settings.session_hours)`.
`AppContext` (in `api.py`) gains `auth: Any = None` (typed as `AuthState | None`; import the
dataclass — that is how it stays honest under pyright). Task 03 is what *enforces* it; this
task only guarantees it exists or the process does not come up.
Startup failure must be loud and non-zero: a `RuntimeError` raised in the lifespan does that
for `uvicorn dashboards.app:app` and `uv run dashboards` alike. Do **not** swallow it.
5. Tests:
- `tests/test_store.py` — `get_meta` `None` for a missing key, round-trip, `set_meta`
replaces, `delete_meta` returns 1 for an existing key / 0 for a missing one and leaves the
other keys alone.
- `tests/test_config.py` — the four new fields pick up their env vars and their defaults;
`DASH_ADMIN_PASSWORD=` is `""`.
- `tests/test_auth.py` (or a new `tests/test_auth_bootstrap.py` if 01's file is getting
long) — seeds on first call and writes both rows; second call with `admin_password=""`
reuses the stored verifier (so a deployment can drop the env var after first boot);
`admin_password=""` **and** no row raises `RuntimeError` naming both the variable and the
key; a stored verifier makes a conflicting env password `env_ignored=True` and does **not**
overwrite the row; a corrupt `auth.session_secret`… (state the behaviour you choose:
regenerate and persist is fine, since the effect is "everyone signs in again", which is a
login prompt, not a lockout); `update_verifier` changes both rows and the new state's
secret, and refuses an empty password.
- `tests/test_api.py` — one test that `create_app(Settings(db_path=db), store)` (no
password anywhere) fails to start, and that with `admin_password="testpass"` it starts.
## Testing & Quality
- Coverage **>90%** on `auth.py`, `config.py`, `store.py`, `app.py` changes; overall gate
(94%) maintained.
- Prove the fail-closed branch and the seeded branch each run — a security default that no test
exercises is a rumour.
## Completion Criteria
- [ ] `uv run pytest tests/test_store.py tests/test_config.py tests/test_auth.py -v` green
- [ ] starting the real dev server with `DASH_ADMIN_PASSWORD` unset and a scratch
`DASH_DB=/tmp/auth-probe.sqlite3` exits non-zero and prints the message naming the
variable and the reset command (paste the line into the commit message)
- [ ] the same command with `DASH_ADMIN_PASSWORD=testpass` starts, and
`sqlite3 /tmp/auth-probe.sqlite3 "SELECT key FROM meta"` lists `auth.verifier` and
`auth.session_secret`
- [ ] the stored hash in `meta` is a `pbkdf2_sha256$…` string; `grep -c testpass` in that DB
is 0
- [ ] `uv run pytest` green; `uv run ruff check src tests agent && uv run pyright` clean
@@ -0,0 +1,163 @@
# Task 03 — The gate: default-deny middleware, `/login` route, `/api/auth/*`
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "password protect this dashboard"
**Story:** n/a
## Objective
Put every route behind the session except four deliberately public ones, with the login,
logout and status endpoints that create and end it, and make the browser scripts carry the
credentials so no page has to think about auth.
## Work
1. `src/dashboards/app.py` — state the public surface **next to the route table** it guards:
```python
# Everything else requires a signed-in admin. This list is the whole attack surface, so
# adding a path here is a security decision, not a convenience: /api/health is here
# because the Containerfile HEALTHCHECK and ./scripts/dev-server.sh status poll it
# unauthenticated, and it is the only data route that stays public.
PUBLIC_PATHS = frozenset({"/login", "/api/auth/login", "/api/health"})
PUBLIC_PREFIXES = ("/static/",)
```
Add `"/login": "login.html"` to `PAGES`. Task 05 supplies the file; until then serve the
existing shape it will get (a minimal page is fine to create here and flesh out in 05 — do
**not** leave `/login` 404, or the gate redirects to a dead end).
2. Add the gate as an `@app.middleware("http")` in `create_app()`, beside the existing
`no_store_for_api`:
- `ctx = getattr(request.app.state, "ctx", None)`; no `ctx` → `503 {"ok": false, "detail":
"startup did not complete"}` for non-public paths (the app that failed to build its
context must not serve pages with `ctx.auth = None`).
- public path → straight through.
- session: `request.cookies.get(auth.SESSION_COOKIE)`, or `Authorization: Bearer <token>`
(so `curl -H "Authorization: Bearer …"` can script the API — document it in README).
Read with `auth.read_session(ctx.auth.secret, token)`.
- no/invalid session →
- `GET`/`HEAD` with `text/html` in `Accept` and a path not starting with `/api` →
`303` to `/login?next=<safe next>`;
- everything else → `401 {"ok": false, "detail": "sign in required", "login": "/login"}`.
- `safe_next(raw)` — keep it only if it starts with `/`, does not start with `//`, and
contains no `://`; else drop it. That is the open-redirect check; a `?next=` that sends
the operator to someone else's site would be a phishing kit with a homelab theme.
- `GET`/`HEAD` with a valid session → set `request.state.session = session` and continue.
- non-`GET`/`HEAD` (everything that can write, `POST` included) additionally:
- origin check with `auth.origin_matches(origin, own_origin, allowed)` where `own_origin`
is `f"{request.url.scheme}://{request.url.netloc}"` and `allowed` comes from
`DASH_ALLOWED_ORIGINS` — **no such setting is added**: same-origin is the only policy
this app needs, so pass `()`. Reject when `Origin` is present and mismatched, or when
`Sec-Fetch-Site` is present and `auth.sec_fetch_site_allowed()` rejects it;
- CSRF: required when the caller authenticated with the **cookie** (a bearer caller
cannot be forged by another site — that is what the header buys), validated with
`auth.verify_csrf(ctx.auth.secret, session.token, request.headers.get(auth.CSRF_HEADER))`;
- failures → `403 {"ok": false, "detail": "cross-origin request refused"}` /
`403 {"ok": false, "detail": "missing or invalid CSRF token"}`.
- **every response the gate produces carries `NO_STORE`** (the gate may short-circuit
before `no_store_for_api` runs, depending on middleware order — do not depend on the
order, pin the header in a test).
3. `POST /api/auth/login` (public; put it in a new `auth_router()` in
`src/dashboards/api.py`, included in `app.py` alongside the other routers, prefix `/api/auth`):
- body `{"password": "<string>"}` — there is no username field (single admin;
`auth.ADMIN_USER`). An absent/empty password is `422`, not a hash comparison.
- throttle first: `key = request.client.host if request.client else "unknown"`;
`retry_after = ctx.login_throttle.retry_after(key)`; blocked → `429` with
`Retry-After: <ceil>` and `detail` naming the lockout seconds — no "wrong password"
lie, and no hint about which account.
- `auth.verify_password(password, ctx.auth.verifier)`:
- no → `throttle.failure(key)`, log an event
(`Event(now, "auth", "admin", "login-denied", "warn", "sign-in refused (attempt N)")`)
and `401 {"ok": false, "detail": "wrong password"}` — one message for every failure
kind, because there is one account and the response must not rank failures.
`alerts.py` only turns `kind == "unreachable"` events into overview rows and the page
feeds filter by `source`, so an `auth` event cannot blink the overview — say so in the
code comment so nobody "fixes" it by hiding the event.
- yes → `throttle.success(key)`, `auth.mint_session(...)`, set the cookie
(`httponly=True`, `samesite="lax"`, `path="/"`, `max_age=int(hours*3600)`,
`secure=True` when `settings.cookie_secure` **or** the request's first
`X-Forwarded-Proto` value is `https` — marking it `Secure` for an https visit is safe
even if the header lies, because it only ever tightens), and answer
`{"ok": true, "username": "admin", "expires_at": ts, "csrf": <token>, "next": "/safe"}`.
Never echo the password, the verifier, or the session token in any other field.
- `GET /login` while already holding a valid session → `303` to `/` (or to a safe `next`).
4. Protected auth routes on the same router:
- `GET /api/auth/status` → `{"ok": true, "authenticated": true, "username": "admin",
"expires_at": ts, "seconds_left": n, "csrf": auth.mint_csrf(secret, session.token),
"session_hours": …}`. This is the only place the CSRF token is published; it is inside
the session for a reason.
- `POST /api/auth/logout` (session + CSRF required — a plain link would be a CSRF that
signs the operator out) → clears the cookie (`delete_cookie(path="/")`) and answers
`{"ok": true}`.
5. `src/dashboards/api.py` — `AppContext` gains `login_throttle: Any = None` next to the
`auth` field from task 02; `app.py` constructs one `auth.LoginThrottle()` per app (per
process, which is the documented model). Do not create it per request: a new throttle per
request is a throttle that never trips.
6. `src/dashboards/web/static/app.js` — one place learns the token, every request uses it:
- module-level `authState = null` + `async function ensureAuth()` that fetches
`/api/auth/status` once (memoise the promise, not just the result, or 4 page scripts race
four status calls), caching `{csrf}`;
- `fetchJSON(url, options)` attaches `X-CSRF-Token` for every method outside
`GET`/`HEAD`, awaiting `ensureAuth()` first;
- on `401`, `window.location.assign('/login?next=' + encodeURIComponent(location.pathname))`
and throw once — guard with a module flag so a 2 s poll loop whose session expired does
not spam the login page with redirects;
- keep the exported surface additive (`app.js`'s `return { … }` list): export `authStatus`
(the cached object) because task 06's `/settings` page prints the session expiry.
7. Tests — `tests/test_auth_api.py` (new) plus the plumbing this task forces on the suite:
- `tests/auth_client.py` (new helper module, imported the way `tests/card_contract.py`
is): `ADMIN_PASSWORD = "testpass"`, `sign_in(client, password=ADMIN_PASSWORD)` that posts
the login, asserts 200, and stashes the token on the client
(`client.headers[auth.CSRF_HEADER] = body["csrf"]`). Setting it on the TestClient is what
keeps the 12 existing `client.post(` assertions in `tests/test_api.py` unedited — they get
cookies *and* the CSRF header for free.
- `tests/test_api.py::build_client` logs in through that helper. The seven other
`create_app(` sites in `tests/test_api.py` (search `grep -n "create_app(" tests/`; there
are also two in `tests/test_system_contract.py` and `tests/test_ups_contract.py`) each get
`admin_password=ADMIN_PASSWORD` **and** a `sign_in()`, or their assertions read 401s.
- gate behaviour: every `/api/*` data route (at least `/api/config`, `/api/summary`,
`/api/events`, `/api/metrics`, `/api/system/snapshot`, `/api/system/history`,
`/api/llm/snapshot`, `/api/ups/snapshot`, `/api/ups/commands`) answers `401` with that
exact body shape anonymously; `GET /`, `/system`, `/llm`, `/ups` with `Accept:
text/html` → `303` with `Location` starting `/login?next=`; `/api/health`, `/login`,
`/static/dash.css` stay reachable anonymously; every gate response has
`cache-control: no-store`.
- `test_every_route_that_is_not_public_requires_a_session` — build the app, walk
`app.routes`, derive each path (`getattr(r, "path", "")`; a `Mount` counts as its prefix),
and assert: anything outside `PUBLIC_PATHS`/`PUBLIC_PREFIXES` is `401`/`303` for anonymous
`GET` **and** `POST`. This is the test that protects the next phase that adds a route.
- `?next=` handling: `/nope` passes through, `//evil.example` and
`http://evil.example` are dropped to `/` (assert the `Location` has no `evil` in it).
- login: wrong password 401 `wrong password`; empty 422; success sets a `Set-Cookie`
containing `HttpOnly`, `SameSite=lax` and `Max-Age`, and returns a usable `csrf`;
`X-Forwarded-Proto: https` adds `Secure`; `DASH_COOKIE_SECURE=1` adds it without the
header; 6th bad attempt from the same client → 429 with `Retry-After` and the event row
exists in `/api/events?source=auth` once signed in.
- CSRF: `POST /api/ups/command` with a valid session and **no** `X-CSRF-Token` → 403, and
prove it never reached the wire (the scripted `ScriptedNutClient` in `tests/test_api.py`
records nothing); with the header it behaves exactly as it did before this phase
(`DANGER` still required for `driver.killpower`); `Origin: http://evil.example` → 403 on
login and on a write; `Sec-Fetch-Site: cross-site` → 403; a bearer-token caller can write
without the CSRF header but not without the token.
- logout kills the cookie (subsequent `/api/summary` → 401).
- the served-pages tests in `tests/test_api.py` that scrape page scripts still pass — the
orphan-`id` test and the two-panel/hero pins must stay green without weakening them.
## Testing & Quality
- Coverage **>90%** on `app.py`, `api.py`, `auth.py` changes; every branch of the gate
(public, sessionless HTML, sessionless JSON, bad-session write, CSRF-missing,
origin-mismatch, no-ctx) needs a test — this module is the entire perimeter.
- `node --check src/dashboards/web/static/app.js` is part of `.agents/validate.sh`; run it.
## Completion Criteria
- [ ] `uv run pytest tests/test_auth_api.py -v` green, including the route-enumeration test
- [ ] `curl -si http://127.0.0.1:8090/system | head -5` shows `303` + `Location: /login?next=/system`
- [ ] `curl -s http://127.0.0.1:8090/api/summary` → the 401 JSON; `curl -s
http://127.0.0.1:8090/api/health` → 200; `curl -s -o /dev/null -w '%{http_code}'
http://127.0.0.1:8090/static/dash.css` → 200
- [ ] `curl -s -X POST -H 'content-type: application/json' -d '{"password":"testpass"}'
http://127.0.0.1:8090/api/auth/login` returns `ok: true` with a `csrf`, and the returned
token used as `X-CSRF-Token` + the cookie makes `POST /api/ups/command` behave as before
- [ ] `uv run pytest` fully green (all ~550 tests, not just the auth ones); `ruff` + `pyright`
clean; `bash .agents/validate.sh` green
@@ -0,0 +1,87 @@
# Task 04 — Change the password from the API, and mean it for every session
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "the password should be changeable once logged in"
**Story:** n/a
## Objective
`POST /api/auth/password`: prove the current password, replace the verifier, rotate the signing
secret so the change takes effect everywhere at once. Optional in every sense — nothing forces
a change, no expiry, no first-login interstitial.
## Work
1. `src/dashboards/api.py` — add to `auth_router()`:
`POST /api/auth/password`, body `{"current": str, "new": str}` (session **and**
`X-CSRF-Token` required by task 03's gate):
- Validate `new` before touching anything: non-empty after `.strip()`, at least
`MIN_PASSWORD_LENGTH = 8`, and different from `current` → otherwise `422` with a `detail`
that says which. No composition rules (symbols/digits requirements produce sticky notes,
not passwords); length is the part that does work.
- Throttle the *current* password guesses on a **separate key** —
`f"password:{request.client.host}"` on the same `LoginThrottle` instance — so the
change-password form is not an unthrottled oracle for the old password once a cookie is
stolen. Blocked → `429` with `Retry-After`, same as login.
- `auth.verify_password(current, ctx.auth.verifier)`; wrong → `throttle.failure(that key)`
and `403 {"ok": false, "detail": "current password is wrong"}`. Nothing else changes and
an event is logged (`source="auth"`, `kind="password-denied"`, `severity="warn"`).
- Right → `auth.update_verifier(store, ctx.auth, new)` (task 02: writes `auth.verifier` and
a fresh `auth.session_secret`), assign `ctx.auth = new_state` (`AppContext` is a plain
mutable dataclass), `throttle.success()`, log
`Event(now, "auth", "admin", "password-changed", "warn", "admin password changed")`, and
respond with a **fresh cookie minted from the new secret** plus
`{"ok": true, "changed": true, "expires_at": ts, "csrf": <new token>,
"hint": "other browsers are now signed out"}`.
Cookie attributes identical to login (including the `X-Forwarded-Proto` /
`DASH_COOKIE_SECURE` rule).
- Rotating the secret is the whole point: sessions are stateless, so without it a stolen
cookie would outlive the password that "revoked" it. Say that in a comment — the next
reader will wonder why a password change touches the signing key.
2. Also in `auth_router()`: make login upgrade a cheap hash transparently. After a successful
`verify_password`, if `auth.needs_rehash(ctx.auth.verifier,
iterations=settings.pbkdf2_iterations)` → re-hash with the configured cost and write it
(keep the secret — this is not a logout). Without this, an operator who raises
`DASH_PBKDF2_ITERATIONS` never actually gets the new cost.
3. Order-of-operations guard: the response must be built from `new_state`, never from the old
`ctx.auth`. A response signed with the old secret is a browser that dies on its next
request — the exact bug this task can introduce.
4. Tests (extend `tests/test_auth_api.py`):
- wrong `current` → 403, and the `auth.verifier` string in the DB is byte-identical to
before; five wrong tries then a **correct** one is still `429` (proves the separate
throttle key actually throttles);
- `new` empty / whitespace / 7 chars / equal to `current` → 422 and both `meta` rows
unchanged;
- success → `auth.verifier` and `auth.session_secret` both changed; `verify_password`
confirms new-yes/old-no against the stored string; the **old** cookie now gets `401` from
`/api/summary`; the cookie in the response works and its returned `csrf` authorises
`POST /api/ups/command`; a second browser's cookie (captured earlier in the test) gets
`401`;
- restart semantics: after a change, rebuild the app from the same DB with
`admin_password="testpass"` in settings → login with the **new** password works and
`testpass` does not (the env var is a seed, not an override);
- rehash: seed a verifier at `iterations=1000`, start with `pbkdf2_iterations=2000`, log in
→ 200 and the stored string now says `pbkdf2_sha256$2000$…`, and the same password still
works afterwards;
- the `auth` events exist and are visible via `/api/events?source=auth`, and `alerts.build`
does **not** turn them into overview alerts (assert the summary's `alerts` list is
unchanged by them — invariant: a login failure is a fact for the feed, not a red panel).
5. `/api/config` and `/api/health` must not gain any auth detail: `/api/health` is public, and
"the admin password was changed at T" is not something to serve to an anonymous probe. The
fact lives in the event feed (authenticated) and in `/settings`.
## Testing & Quality
- Coverage **>90%** on the new route and `auth.update_verifier`; every rejection path above is
a test, not a comment.
- The suite's existing `/api/ups/command` tests (the `ScriptedNutClient` ones) must stay green
unchanged in behaviour.
## Completion Criteria
- [ ] `uv run pytest tests/test_auth_api.py -v` green for the eight scenarios above
- [ ] by hand on the dev server: sign in with `testpass`, change it to something else with
`curl`, and confirm the old cookie now gets `401` while the returned one works
- [ ] `sqlite3 data/dashboards.sqlite3 "SELECT value FROM meta WHERE key='auth.verifier'"`
still prints only an encoded PBKDF2 string — no plaintext, anywhere
- [ ] `uv run pytest` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green
@@ -0,0 +1,83 @@
# Task 05 — The login page
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "password protect this dashboard"
**Story:** n/a
## Objective
A single-purpose page at `/login` that asks for the admin password, says honestly why it
refused, and lands the operator on `/` (or the page they were heading to). Hand-rolled HTML +
one small script, no CDN, dark/light parity with the dashboards.
## Work
1. `src/dashboards/web/pages/login.html` — modelled on the other four pages' `<head>`
(`/static/dash.css`, the same inline SVG favicon, `data-theme="dark"` so `app.js` can flip
it from `localStorage` before paint):
- title `Homelab · sign in`; the `.masthead` brand link, but **no** `.tabs` nav — the tabs
lead to pages that would bounce straight back here, and a nav of links that all redirect
is a lie about what is available.
- one `.card` in `main`: `<h1>Sign in</h1>`, a `.sub` line naming what this protects ("the
fleet's system, engine and battery dashboards — including the UPS controls"), then a form:
`<label for="admin-password">Admin password</label>` +
`<input id="admin-password" type="password" autocomplete="current-password" required>` +
`<button class="action" id="sign-in" type="submit">Sign in</button>`.
- a `<p id="login-error" class="login-error" role="alert" hidden>` for refusals and
`<p id="login-note" class="faint mono" hidden>` for the lockout countdown.
- scripts: `/static/app.js` then `/static/page-login.js`, `defer`.
- **no** username field: one admin, and a field that is always the same word teaches the
operator that half the form is theatre (`auth.ADMIN_USER` is the server's business).
2. `src/dashboards/web/static/page-login.js` — IIFE in the house style (see
`page-index.js`), no framework:
- `submit` handler → `POST /api/auth/login` with
`Dash.fetchJSON` (it adds the JSON accept header and turns a non-2xx into a thrown
`Error` with `status`/`body`); body `{password}`;
- on success `location.assign(body.next || '/')`;
- on `401` show `Wrong password.` and select the field's contents (fixing a typo without
retyping is the whole UX of a login form);
- on `429` show the `detail` plus the `Retry-After` seconds ticking down, and disable the
button until it reaches 0 — a submit that will be refused should not look pressable;
- on any other failure print the server's `detail` verbatim through the escape path the app
already uses for text (`textContent`, never `innerHTML`);
- honour `?next=` **only** if it starts with `/` and not `//` (the same rule the server
enforces; a client-side copy is defence in depth, and the server's check is the one that
counts);
- if the browser already holds a valid session, `GET /api/auth/status` succeeds → redirect
to `/` immediately (task 03 also does this server-side; this is what stops a flash of the
form).
3. `src/dashboards/web/static/dash.css`:
- extend the existing `input[type="text"]` rule (line ~642) to
`input[type="text"], input[type="password"]` — do not duplicate it;
- add `.login-card { max-width: 26rem; margin: 3rem auto; }` and `.login-error { color:
var(--fail); }`, `.login-card form { display: grid; gap: .6rem; }`, plus
`.login-card label { font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em;
color: var(--text-faint); }` (the same caption vocabulary as `.hero .label`);
- only existing `:root` tokens (`--fail`, `--text-faint`, `--accent`, …) —
`tests/test_api.py` fails if a rule reaches for a token `:root` does not declare, and if a
`.sev-*` family appears.
4. Keep the invariant: nothing external. `tests/test_api.py` already asserts the pages load no
foreign asset — extend that assertion to `/login` (same test, one more path).
## Testing & Quality
- `tests/test_api.py` (the served-HTML section) — `/login` is served anonymously with
`HTTP/1.1 200`, contains `id="admin-password"`, `type="password"`,
`autocomplete="current-password"`, `role="alert"`, and **no** `/system`-style `.tabs`; no
`http://` or `https://` asset reference in the served bytes.
- Orphan-`id` pin: every new `id` (`admin-password`, `sign-in`, `login-error`, `login-note`)
must be referenced by `page-login.js` (or the CSS) — that test fails on a dead `id`, so name
what you use and use what you name.
- `node --check src/dashboards/web/static/page-login.js` (part of `validate.sh`).
- Coverage: no new Python; the served-bytes assertions are the tests. Do not add a JS test
runner (a locked anchor).
## Completion Criteria
- [ ] anonymous `GET /login` → 200 with the form; anonymous `GET /` → 303 to `/login?next=/`
- [ ] in the dev server: wrong password shows `Wrong password.` inline with no page reload;
`testpass` lands on `/` and the header status dots go green
- [ ] five wrong passwords then a correct one shows the lockout with a counting-down
`Retry-After` and a disabled button; it clears and the next attempt works
- [ ] `/login?next=/ups` after a successful sign-in lands on `/ups`
- [ ] dark and light both readable (screenshot both, or one plus a note in `NOTES-*.md`)
- [ ] `uv run pytest` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green
@@ -0,0 +1,89 @@
# Task 06 — `/settings`: change the password, sign out, see when the session ends
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "the password should be changeable once logged in"
**Story:** n/a
## Objective
A fifth page the operator reaches from the masthead: the session facts, a working
change-password form, and a sign-out. It has to survive the secret rotation from task 04
without leaving the browser holding a stale CSRF token.
## Work
1. `src/dashboards/app.py` — add `"/settings": "settings.html"` to `PAGES`. It lands behind the
gate with no extra code (task 03's route-enumeration test proves it); do not add it to the
public list.
2. `src/dashboards/web/pages/settings.html` — same `<head>`/masthead as the other pages, `.tabs`
nav **without** `aria-current` (Settings is not one of the four dashboards), and in `main`:
- **Session** panel: `<dl>` with the signed-in name, the expiry as a local timestamp *and*
"in 29d 14h" via `Dash.duration(seconds_left)`, and `<button class="action"
id="sign-out" type="button">Sign out</button>`.
- **Change password** panel: three fields — current (`autocomplete="current-password"`),
new (`autocomplete="new-password"`, `id="new-password"`), confirm
(`autocomplete="new-password"`, `id="confirm-password"`) — each with a real `<label
for=…>` and an `aria-describedby` hint naming the 8-character floor, a `<button
class="action" id="change-password">Change password</button>`, and one
`<p id="settings-result" role="status" aria-live="polite">` for the outcome.
- A static `.sub` paragraph stating the precedence rule in the app's own voice: the password
was seeded from `DASH_ADMIN_PASSWORD` at first start, changing it here takes precedence,
the env var never overwrites a password set here, and resetting from the environment means
deleting the `auth.verifier` row and restarting. (This is the sentence that stops the
"I edited the env file and nothing happened" support call.)
- No `#nav-status` — `Dash.initChrome()` already guards its absence.
3. `src/dashboards/web/static/app.js`:
- `resetAuth()` — clears the memoised `authState`/promise added in task 03, exported in the
`return { … }` list. Needed twice: after a password change (the secret rotated, so the
cached `csrf` is now invalid) and after logout. Forgetting this is the bug where the
operator changes the password and then every UPS button 403s until they reload.
- also call it when a `401` bounces to `/login` (the flag from task 03 already stops the
redirect storm).
4. `src/dashboards/web/static/page-settings.js`:
- on load `Dash.fetchJSON('/api/auth/status')` → fill the session panel;
- sign-out → `POST /api/auth/logout` → `Dash.resetAuth()` → `location.assign('/login')`;
- change password → local checks first (new ≥ 8 chars, `new === confirm`, current non-empty),
reported inline without a round-trip; then
`POST /api/auth/password` with `{current, new}`; on success write
"Password changed. Other browsers are signed out." into `#settings-result`,
`Dash.resetAuth()` (so the next write re-reads the fresh `csrf`), clear all three fields
(`form.reset()` plus explicit blanking — password managers autofill them), and keep the
session cookie the response set; on `403` print "current password is wrong", on `422`
print the server's `detail`, on `429` the lockout text — every message via `textContent`;
- after a successful change, verify once by calling `/api/auth/status` again so the panel
cannot show a session the server no longer honours.
5. Masthead on all four dashboard pages (`index.html`, `system.html`, `llm.html`, `ups.html`) —
one link in `.masthead-right`, before the theme toggle:
`<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>`.
**No `id`** (the orphan-`id` test in `tests/test_api.py` flags ids nothing references — an
`<a href>` needs none), and it must sit outside `.tabs` so the pinned tab structure of the
four dashboards is untouched. Add `.ghost` styling for `a` alongside the existing
`button.ghost` rule (extend its selector to `a.ghost` rather than duplicating; existing
tokens only).
6. `src/dashboards/web/static/page-login.js` — a sign-in that carries `?next=/settings` is a
normal path once the session dies on the settings page; task 05's `next` handling covers it,
just verify it in the browser.
## Testing & Quality
- `tests/test_api.py` (served-bytes section, signed-in client) —
`GET /settings` is `200` and contains `id="new-password"`, `autocomplete="new-password"`,
`id="sign-out"`, `role="status"`; anonymous `GET /settings` is `401`/`303` (already covered by
the enumeration test — do not duplicate it there, but assert it once here for the page);
`test_every_dashboard_page_links_to_settings` over the four page paths; `/settings` carries no
external asset; the orphan-`id` test still passes with the four new ids.
- `node --check` for `page-settings.js` and `app.js` (in `validate.sh`).
- No new Python except the one `PAGES` line, so coverage moves only through `app.py`'s existing
route tests; keep the overall gate (94%) where it is.
## Completion Criteria
- [ ] in a browser: the `⚙ Account` link appears on all four dashboards and opens `/settings`
- [ ] signing out from `/settings` returns to `/login`, and `/system` afterwards bounces to
`/login` rather than rendering an empty page of `n/a`s
- [ ] changing the password on `/settings` shows the success line, keeps the operator signed
in, and a UPS button on `/ups` (e.g. beeper disable → confirm) still works **without a
page reload** — that is `resetAuth()` doing its job
- [ ] the same change in a second browser tab signed in earlier shows the 401 → `/login` path
- [ ] `/settings` renders correctly in dark and light; screenshots
`data/logs/auth-settings.png` and `data/logs/auth-account-link.png`
- [ ] `uv run pytest` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green
@@ -0,0 +1,125 @@
# Task 07 — Dev config, the two docs, and proof in a browser against live data
**Phase:** `11_admin_login` · **Source:** operator request 2026-10-17 — "for local testing the default admin password should be `testpass`"
**Story:** n/a
## Objective
Make `testpass` the password a developer types locally without ever putting it in a tracked
template, document the whole thing for the operator, and prove the gate in a real browser on
live fleet data (AGENTS.md invariant 10).
## Work
1. `.env.example` — new `Server` block lines, production-shaped and placeholder-only:
```
# Admin password. Seeds the SQLite verifier on first start; after that the password set
# in /settings wins and this line is ignored (delete the 'auth.verifier' row to reset).
# With neither this variable nor a stored verifier the app REFUSES to start.
DASH_ADMIN_PASSWORD=change-me
DASH_SESSION_HOURS=720
# 1 marks the session cookie Secure. Set it in deployment (TLS/nginx); the app also sets
# it automatically for requests that arrive with X-Forwarded-Proto: https.
DASH_COOKIE_SECURE=0
# Cost of the PBKDF2 hash. Lowering it weakens the one thing standing between the network
# and your fleet; it exists so the test suite can hash fast.
DASH_PBKDF2_ITERATIONS=600000
```
2. `.env.dev.example` — `export DASH_ADMIN_PASSWORD=change-me`, and the local-testing value in
prose **on a comment line**, e.g. `# Local throwaway value: the hermetic suite and every
dev box here use the word the tests sign in with (see tests/auth_client.py).`
- **The trap to avoid:** `.agents/validate.sh` scans every tracked env *template* and fails
the phase if a `*_PASSWORD=` / `*_TOKEN=` / `*_SECRET=` / `*_KEY=` line carries anything
but an allow-listed placeholder (`changeme`, `change-me`, `replace-me*`, `secret`,
`placeholder`, `dummy`, `example`, `x+`, empty). **`testpass` is not on that list**, so
`DASH_ADMIN_PASSWORD=testpass` in a tracked template fails the gate — correctly. Do not
edit `validate.sh` to let it through; the fix is a placeholder in the template and the
real word in the gitignored file. Comment lines are not matched (the pattern is anchored
at `^` and cannot start at `#`), which is why prose is safe and an assignment is not.
3. The developer's own `.env.dev` (gitignored, mode `0600`) — append
`export DASH_ADMIN_PASSWORD=testpass` if no `DASH_ADMIN_PASSWORD` line exists. Append with
`printf '…\n' >> .env.dev` (`tee` truncates in place; the mode is 0600 for a reason) and do
not reorder or reformat the rest of that file — it carries real upsd credentials.
Never print its contents into a log, a commit message, or a screenshot.
4. Optional one-line ergonomics in `scripts/dev-server.sh`'s `start()` — after the success
`echo`, add `echo " sign in with DASH_ADMIN_PASSWORD from .env.dev"`. `start`'s readiness
probe and `status` both use `/api/health`, which stayed public, so **no auth logic belongs in
this script** and no password belongs in it either.
5. `README.md`:
- **Quickstart** — after `cp .env.dev.example .env.dev`, one line: local dev signs in with
the dev file's password; the app refuses to start without one.
- New **Signing in** section (before *Configuration*): one admin; the four public paths and
why `/api/health` is one of them (container `HEALTHCHECK`, `dev-server.sh status`);
password seeded from `DASH_ADMIN_PASSWORD`, changed in `/settings`, never forced;
sessions are HMAC-signed cookies (`DASH_SESSION_HOURS`, default 30 days) invalidated
together by a password change; the reset procedure, verbatim:
`sqlite3 data/dashboards.sqlite3 "DELETE FROM meta WHERE key IN ('auth.verifier','auth.session_secret')"`;
scripting the API with `Authorization: Bearer <session token>`;
deployment note — set the password in the container env file (the deployment repo's quadlet
env template), set `DASH_COOKIE_SECURE=1` behind the TLS/nginx front, and note that nginx
config/HSTS live in the deployment repo like every other service here.
- **Configuration table** — the four new variables with their defaults, in the same
two-column style as the existing rows.
6. `AGENTS.md`:
- a new numbered rule in **Non-negotiable rules** — *"One admin, one password, four public
paths."* Everything else is behind the session; the public list is
`/login`, `/api/auth/login`, `/api/health`, `/static/*`; the verifier is PBKDF2 in the
SQLite `meta` table and the env var is a **seed, never an override**; no stored verifier
and no env var means the process refuses to start; a password change rotates the signing
secret so every browser is out; the login POST and every write are origin/CSRF-checked;
and `/api/health` is public by decision, which means nothing sensitive may be added to it
without re-deciding.
- `auth.py` in the one-line-per-responsibility architecture list (pure primitives: PBKDF2
verifier, signed sessions, CSRF tokens, login throttle — stdlib only, no new dependency).
- Architecture/API prose: the four dashboards and `/settings` now require a session.
- **Known gaps** — three entries: the login throttle keys the socket peer address, so behind
nginx a lockout is global (at worst `max_seconds`; accepted for a single admin);
no second factor and no per-user accounts by design; the throttle is per-process, so
running more than one uvicorn worker multiplies the buckets (and the session/CSRF design
itself is worker-safe because it is stateless).
- `PLAN.md`'s Architectural Anchors table has no auth row. This phase adds no dependency, so
it stays inside the existing anchors — but note in the phase `NOTES-*.md` that the operator
may want to ratify "in-house stdlib auth (PBKDF2 + HMAC cookie)" as an anchor; this skill
and this phase must not edit `PLAN.md`.
7. Live browser verification (dev server on `http://127.0.0.1:8090`, tunnels up, live data):
- `./scripts/dev-server.sh restart`, then confirm `./scripts/dev-server.sh status` still
prints per-source `ok`/`DOWN` **without** credentials;
- screenshots into `data/logs/`: `auth-login.png`, `auth-login-rejected.png`,
`auth-locked-out.png`, `auth-signed-in-overview.png`, `auth-settings.png`,
`auth-account-link.png` (the last two from task 06 — retake if a pixel moved);
- leak checks, recorded in `NOTES-verify.md` with the actual status codes:
`curl -si localhost:8090/system` (303 → `/login?next=/system`),
`curl -s localhost:8090/api/summary` (401), `/api/config` (401), `/api/events` (401),
`/api/system/history?...` (401), `/api/health` (200),
`curl -s localhost:8090/login | grep -c 'http'` (0 external refs);
- once signed in, walk all five pages and confirm the five completed phases look exactly as
they did before: the `/system` hero rows, the `/llm` sparklines and latency feet, the `/ups`
glyph and heroes, the overview's two panels, the `Δ<window>` footnotes;
- `uv run pytest -m live --live` — the live suite talks to the agents, engines and upsd
directly rather than to the dashboard, so it must be unaffected; run it and say so.
8. `NOTES-auth.md` in the phase directory — what was verified how (which screenshot shows
which behaviour), the `curl` outputs, the exact `meta` keys written, and anything the operator
must do in the deployment repo (set `DASH_ADMIN_PASSWORD`, set `DASH_COOKIE_SECURE=1`).
## Testing & Quality
- `bash .agents/validate.sh` green — this is the task where the tracked-template secret gate
bites, so run it before declaring the docs done.
- `git status --porcelain` must show no `.env` / `.env.dev` change staged (both are ignored) and
no screenshot except the `data/logs/` drops the repo already ignores.
- Coverage: no code change expected; the gate (94%) must not move.
## Completion Criteria
- [ ] `.env.example` and `.env.dev.example` are placeholder-clean and `bash .agents/validate.sh`
passes the template scan (quote the passing line in the commit message)
- [ ] `.env.dev` has the dev password, still mode `0600`, still containing its real NUT
credentials untouched
- [ ] `./scripts/dev-server.sh restart && ./scripts/dev-server.sh status` works unauthenticated
- [ ] all six screenshots exist in `data/logs/` and show live data, not the simulator
- [ ] the leak-check list above returns exactly the codes listed, recorded in `NOTES-auth.md`
- [ ] README's *Signing in* section and four config rows exist; `AGENTS.md` has the new
invariant, the `auth.py` line, and the three known gaps
- [ ] `uv run pytest` and `uv run pytest -m live --live` both green;
`uv run ruff check src tests agent && uv run pyright` clean; `bash .agents/validate.sh`
green