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:
@@ -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
|
||||||
Reference in New Issue
Block a user