From 3a7ec376a25fa4e54f878aa516015bf8a8ec2dcb Mon Sep 17 00:00:00 2001 From: Donkie Date: Mon, 27 Jul 2026 09:55:14 +0200 Subject: [PATCH] docs: add authentication & authorization design plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design proposal for an opt-in auth system on top of client_v2: local password login, generic OIDC, API keys, optional TOTP 2FA, and optional mTLS-as-identity behind a reverse proxy. Three permission levels (read/edit/manage) plus orthogonal admin/owner flags. Nothing is implemented yet; this records the agreed shape and the decisions behind it so the five delivery phases can be picked up independently. Placed at the repo root because .gitignore:75 carries a blanket docs/ rule from the Python template. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- AUTHENTICATION_PLAN.md | 387 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 AUTHENTICATION_PLAN.md diff --git a/AUTHENTICATION_PLAN.md b/AUTHENTICATION_PLAN.md new file mode 100644 index 00000000..1423e535 --- /dev/null +++ b/AUTHENTICATION_PLAN.md @@ -0,0 +1,387 @@ +# Spoolman authentication & authorization + +> **Status: design proposal β€” nothing here is implemented yet.** +> Targets the `feature/v2-client` branch (Svelte `client_v2`). Written 2026-07-27. + +## Context + +Spoolman today has **zero** authentication. Every endpoint under `/api/v1` β€” including +`POST /setting/{key}`, `DELETE /spool/{id}` and the full-database `GET /export/*` dumps β€” +is open to anyone who can reach the port. That is acceptable for a LAN-only homelab +instance and unacceptable for anything else, and it blocks the multi-tenant work planned +for the near future. + +This project adds an **opt-in** auth system: off by default (`SPOOLMAN_AUTH_ENABLED=FALSE`), +so zero-config boot and every existing integration keep working untouched. When enabled it +provides local username/password login, generic OIDC (Pocket ID, Authentik, Keycloak, +Zitadel β€” no provider-specific code), API keys for machine consumers, optional TOTP 2FA, +and optional mTLS-as-identity behind a reverse proxy. Three permission levels gate reads, +edits, and creates/deletes. The first user to register becomes the owner and administers +everyone else. Password recovery works without any email setup: admins reset from the UI, +and a new `spoolman.cli` module rescues a locked-out owner from the host shell. + +Decisions already made with the maintainer: server-side CLI + owner-driven password reset +(no per-user recovery codes); mTLS terminated at the reverse proxy and usable as an +identity source; **no tenant concept in this project** (design for it, don't build it); +brute-force protection, session management UI, audit log and TOTP 2FA all in scope; +`pyjwt[crypto]` as the only new Python dependency; open first-run owner claim; `/health`, +the SPA bundle, and an opt-in anonymous-read mode stay reachable without credentials. + +--- + +## Ground rules this must not break + +- **API v1 is additive-only.** Everything new lives at new paths (`/api/v1/auth/*`, + `/user/*`, `/apikey/*`, `/audit/*`). No existing path, param, field, or status code + changes shape. Adding a 401/403 possibility to existing routes only happens when the + operator opts in. +- **Zero-config boot.** `SPOOLMAN_AUTH_ENABLED` defaults to `FALSE`; with it unset the + request path is byte-identical to today. +- **Single migration, no data backfill** β†’ CockroachDB-safe in one revision. +- **`select = ["ALL"]` ruff** (incl. bandit `S` rules) applies to all new backend code. +- **armv7 Docker builds** rule out `argon2-cffi` / `bcrypt` (C/Rust wheels). Password + hashing uses stdlib `hashlib.scrypt`. `pyjwt` is pure Python and `cryptography` is + already resolved transitively via `aiomysql[rsa]`. + +--- + +## Backend + +### New dependency + +`pyproject.toml` β†’ `pyjwt[crypto]~=2.10`, then `uv lock` (CI gate `uv lock --check`, +`lefthook.yml:14,54`). Nothing else. TOTP is ~20 lines of stdlib `hmac`/`hashlib`/`struct`; +secret encryption uses `cryptography.fernet`; OIDC HTTP uses the existing `httpx`. + +### Data model β€” `spoolman/database/models.py` + +Five new tables. **All prefixed `auth_`** β€” `user` and `session` are reserved words in +PostgreSQL/MySQL and quoting them everywhere is a footgun not worth taking. + +| Table | Key columns | +|---|---| +| `auth_user` | `id` PK, `username` String(64) unique (stored lowercased), `display_name`, `email` (nullable, OIDC matching only), `password_hash` Text nullable (null = OIDC/cert-only), `level` String(16) enum, `is_admin` bool, `is_owner` bool, `is_active` bool, `must_change_password` bool, `totp_secret` Text nullable (Fernet-encrypted), `totp_enabled` bool, `oidc_issuer`/`oidc_subject` (nullable, unique together), `failed_logins` int, `locked_until` datetime nullable, `registered`, `last_login` | +| `auth_api_key` | `id` PK, `user_id` FK, `name` String(64), `level`, `prefix` String(16) unique indexed, `key_hash` Text, `created`, `expires` nullable, `last_used` nullable, `revoked` bool | +| `auth_session` | `id` PK, `user_id` FK, `token_hash` String(64) unique indexed, `created`, `expires`, `last_seen`, `remember` bool, `user_agent` String(256), `ip` String(64) | +| `auth_audit_log` | `id` PK, `date` indexed, `event` String(64), `actor_user_id` nullable FK, `actor_kind`, `target` String(128) nullable, `ip`, `user_agent`, `detail` Text (JSON) | +| `auth_certificate` | `id` PK, `user_id` FK, `name`, `fingerprint_sha256` String(95) unique indexed, `subject_dn` String(512), `created`, `last_used` | + +Relationships use `lazy="selectin"`, never `joined` β€” follow the comment at +`spoolman/database/models.py:10-21`. + +**Migration**: one revision in `migrations/versions/`, `down_revision = "415a8f855e14"` +(current head, `2024_05_28_1846-415a8f855e14_multi_colors.py`). Pure `op.create_table` + +`op.create_index(op.f("ix_..."))` β€” copy the shape of +`migrations/versions/2024_01_04_2209-b8881bdb716c_added_extra_fields.py`. No backfill, so +no second revision needed. `downgrade()` drops the five tables in reverse order. + +### Permission model + +Two orthogonal axes, deliberately: + +**Level** (`spoolman/auth/levels.py`, a `StrEnum` ordered `read < edit < manage`): + +| Level | Grants | +|---|---| +| `read` | Every `GET` and every WebSocket subscription | +| `edit` | `read` + modify existing: `PATCH /spool\|filament\|vendor/{id}`, `PUT /spool/{id}/use`, `PUT /spool/{id}/measure`, `PATCH /location/{location}` | +| `manage` | `edit` + create/delete: `POST`/`DELETE` on spool, filament, vendor; `POST /setting/{key}`; `POST`/`DELETE /field/...`; `POST /backup` | + +`PUT /spool/{id}/use` sits at `edit` on purpose β€” that is Moonraker's and OctoPrint's hot +path, so a printer gets an `edit` API key and cannot delete anything. + +**Flags**: `is_admin` (manage users, API keys of others, view the audit log) and +`is_owner` (exactly one; can promote/demote admins and transfer ownership; cannot be +deleted or demoted by anyone else). Both imply `manage`. + +API keys carry their own `level`, capped at their owning user's level at issue time and +re-capped on every request (demoting a user instantly weakens their keys). + +### Enforcement β€” `spoolman/auth/` + +``` +spoolman/auth/ + levels.py Level enum + comparison helpers + principal.py Principal dataclass: kind (user|apikey|cert|anonymous), user_id, + level, is_admin, is_owner, session_id, api_key_id + hashing.py scrypt password hash/verify (versioned string), token hashing, + constant-time compare, dummy-verify for timing equalization + secret.py SPOOLMAN_SECRET_KEY / auto-generated /secret.key (0600); + derives Fernet key for TOTP secrets and signing key for OIDC state + dependencies.py get_principal(), require_level(Level.X), require_admin, + require_owner, and WebSocket variants + ratelimit.py in-memory sliding-window throttle keyed by username and client IP + audit.py record(event, principal, request, target, detail) + totp.py stdlib RFC-6238 + oidc.py discovery + PKCE + token exchange + JWKS via pyjwt + mtls.py trusted-proxy header parsing -> certificate fingerprint + cli.py lives at spoolman/cli.py (see below) +``` + +`get_principal()` resolution order, first match wins: + +1. **Session cookie** `spoolman_session` β€” opaque 256-bit token, SHA-256 hashed in + `auth_session.token_hash`. Non-GET requests additionally require a matching + `X-CSRF-Token` header (double-submit against a non-httpOnly `spoolman_csrf` cookie). +2. **API key** β€” `Authorization: Bearer spm__` or `X-Api-Key: `. + Prefix indexes the row, secret is scrypt-compared. No CSRF (not an ambient credential). +3. **mTLS** β€” only when `SPOOLMAN_MTLS_ENABLED` and the peer address is inside + `SPOOLMAN_TRUSTED_PROXIES`; reads verify/fingerprint/subject headers (names + configurable, defaults match nginx's `X-SSL-Client-Verify` / `X-SSL-Client-Fingerprint` + / `X-SSL-Client-S-DN`) and looks up `auth_certificate`. +4. **Anonymous** β€” level `read` if the `auth_anonymous_read` setting is on, else no access. + +Every route in the v1 app gets an explicit +`dependencies=[Depends(require_level(Level.READ))]` (or `EDIT`/`MANAGE`). To make that +fail-closed rather than fail-open, **startup asserts coverage**: after +`v1_app.include_router(...)` runs, iterate `v1_app.routes` and raise if any route lacks an +auth dependency, excluding an explicit allow-list (`/health`, `/auth/config`, +`/auth/setup`, `/auth/login`, `/auth/oidc/*`). A forgotten route then crashes the server on +boot instead of silently serving unauthenticated. This gets its own integration test. + +When `SPOOLMAN_AUTH_ENABLED` is false, `require_level` short-circuits to an +all-permissions principal before touching the DB β€” no query, no measurable overhead. + +**WebSockets**: browsers can't set headers on `WebSocket`, but the session cookie *is* +sent on the same-origin handshake, so the SPA needs no change. Machine clients pass +`?api_key=`. WS dependencies must close before `accept()` β€” copy the precedent at +`spoolman/api/v1/setting.py:124-133`, using close codes **4401** (unauthenticated) and +**4403** (insufficient level). + +**Outer app** (`spoolman/main.py`): `/metrics` gets a check requiring an API key when auth +is on, with `SPOOLMAN_METRICS_PUBLIC=TRUE` / `SPOOLMAN_METRICS_TOKEN` escape hatches. The +SPA mount at line 116 stays public. Any new outer-app route must be registered **before** +line 104 β€” the SPA mount is a catch-all. + +**CORS fix required**: `spoolman/main.py:144-151` sets `allow_origins=["*"]` with +`allow_credentials=True` in debug mode, which browsers reject outright for credentialed +requests. Change debug mode to reflect the request `Origin` (or require an explicit +`SPOOLMAN_CORS_ORIGIN`) when auth is enabled. Dev is otherwise fine: `localhost:5174` and +`localhost:8000` are same-site (ports are ignored by SameSite), so a `SameSite=Lax` cookie +works across the Vite dev server. + +### New endpoints β€” `spoolman/api/v1/auth.py`, `user.py`, `apikey.py`, `audit.py` + +| Route | Gate | Purpose | +|---|---|---| +| `GET /auth/config` | public | `{enabled, setup_required, anonymous_read, oidc:{enabled,name}, mtls}` β€” the SPA's bootstrap probe | +| `POST /auth/setup` | public, only while 0 users | Claim ownership. Logs a loud warning + audit entry with the claiming IP | +| `POST /auth/login` | public | `{username, password, remember, totp_code?}` β†’ sets cookies; 401 on failure, 429 when throttled, 428 when TOTP is required | +| `POST /auth/logout` | authed | Revokes the current session | +| `GET /auth/session` | authed | Current principal + capabilities | +| `POST /auth/password` | authed | Change own password (requires current) | +| `GET /auth/oidc/start`, `GET /auth/oidc/callback` | public | Auth Code + PKCE(S256), signed state/nonce cookie | +| `GET /auth/sessions`, `DELETE /auth/sessions/{id}`, `DELETE /auth/sessions` | authed | Session management UI | +| `POST /auth/totp/enroll`, `POST /auth/totp/confirm`, `DELETE /auth/totp` | authed | 2FA enrollment; returns an `otpauth://` URI (QR rendered client-side) | +| `GET/POST /user`, `GET/PATCH/DELETE /user/{id}` | admin | User CRUD; owner-only for admin/owner changes | +| `POST /user/{id}/password-reset` | admin | Returns a one-time temp password, sets `must_change_password` | +| `DELETE /user/{id}/sessions`, `DELETE /user/{id}/totp` | admin | Force logout / clear a lost authenticator | +| `GET/POST /apikey`, `PATCH/DELETE /apikey/{id}` | authed (own) / admin (all) | Plaintext key returned **once** on create | +| `GET /audit` | admin | Filterable, paginated, `x-total-count` | +| `GET/POST/DELETE /user/{id}/certificate` | admin | mTLS cert β†’ identity mapping | + +Business logic goes in `spoolman/database/auth_*.py`, not in routers β€” the routers +validate, delegate, and map exceptions to status codes, matching the existing convention. + +### Sessions & remember-me + +Opaque random token, hashed at rest, so sessions are revocable and listable (JWTs are +neither). `remember=false` β†’ browser-session cookie, 12 h server-side idle expiry. +`remember=true` β†’ 90-day `Max-Age`, sliding renewal on use. Cookie is `HttpOnly`, +`SameSite=Lax`, `Secure` whenever the request arrived over HTTPS (including via +`X-Forwarded-Proto` from a trusted proxy). Path is `env.get_base_path() + "/"`. + +### Brute-force protection + +In-memory sliding windows (safe: the deployment contract is one process) keyed by username +and by client IP, plus `failed_logins`/`locked_until` persisted on `auth_user` so account +lockout survives a restart. 5 failures β†’ 1 min, doubling to a 15 min cap. Login always +runs a dummy scrypt verify on unknown usernames so response time doesn't leak account +existence. Client IP comes from `X-Forwarded-For` **only** when the peer is in +`SPOOLMAN_TRUSTED_PROXIES`. + +### CLI β€” `spoolman/cli.py`, run as `python -m spoolman.cli` + +Module form rather than a console script, because the Docker image runs from source rather +than an installed distribution. Connects with the same env config; refuses to run if the +schema is missing (it never migrates). + +``` +python -m spoolman.cli user list +python -m spoolman.cli user create --level manage --admin [--owner] +python -m spoolman.cli user reset-password # prints a one-time password +python -m spoolman.cli user set-level +python -m spoolman.cli user disable|enable +python -m spoolman.cli user clear-totp +python -m spoolman.cli apikey list|revoke +python -m spoolman.cli session revoke-all [--user ] +python -m spoolman.cli auth status +``` + +Documented as `docker exec -it spoolman python -m spoolman.cli ...`. This is the owner's +lockout escape hatch and is the reason no per-user recovery codes are needed. + +### Configuration + +New env vars in `spoolman/env.py` (copy the boolean-parsing pattern at `env.py:471-488` and +the `_FILE` secret pattern at `env.py:139-170`), all documented in `.env.example`: + +`SPOOLMAN_AUTH_ENABLED`, `SPOOLMAN_SECRET_KEY(_FILE)`, `SPOOLMAN_TRUSTED_PROXIES`, +`SPOOLMAN_METRICS_PUBLIC`, `SPOOLMAN_METRICS_TOKEN`, +`SPOOLMAN_OIDC_{ENABLED,ISSUER,CLIENT_ID,CLIENT_SECRET(_FILE),SCOPES,DISPLAY_NAME,REDIRECT_BASE,USERNAME_CLAIM,GROUPS_CLAIM,GROUP_LEVEL_MAP,ADMIN_GROUP,AUTO_CREATE_USERS,DEFAULT_LEVEL}`, +`SPOOLMAN_MTLS_{ENABLED,REQUIRED,HEADER_VERIFY,HEADER_FINGERPRINT,HEADER_SUBJECT}`. + +New DB settings via `register_setting()` in `spoolman/settings.py` (no migration needed): +`auth_anonymous_read` (boolean, default `false`), `audit_log_retention_days` (number, +default 90). **Guard `GET /setting/`** so it never returns auth-internal keys to a +non-admin. + +Audit-log pruning is a new job on the existing `Scheduler` in `main.py:startup()`. + +--- + +## Frontend β€” `client_v2` (Svelte 5 + SvelteKit 2) + +No React, no Refine, no AntD, no form library, no query library. Everything is hand-rolled +components + rune-based singleton stores, so auth wiring is small and explicit. + +### Core plumbing + +- **`src/lib/api/http.ts`** β€” the single choke point. Add `credentials: 'include'` to all + six verb helpers, attach `X-CSRF-Token` from the `spoolman_csrf` cookie on non-GET, and + make `ensureOk` route 401 β†’ `auth.markSignedOut()` and 403 β†’ a localized permission + toast. `src/lib/api/search.ts` builds its own `fetch` and must be moved onto `getJson` + so it isn't an auth hole. +- **`src/lib/api/auth.ts`** β€” new endpoint module (config, login, logout, session, users, + api keys, sessions, TOTP, audit). +- **`src/lib/stores/auth.svelte.ts`** β€” new rune store, matching the existing + `settings.svelte.ts` / `serverInfo.svelte.ts` shape: + `state: 'loading'|'setup'|'anon'|'authed'`, `user`, `level`, and derived capability + getters `canEdit` / `canCreate` / `isAdmin` / `isOwner`. When auth is disabled the store + reports full capability, so every call site works unchanged in the default config. +- **`src/routes/+layout.svelte`** β€” add `auth.load()` to the existing `$effect` alongside + `settings.load()` / `serverInfo.load()`, and render `` / `` + instead of `{@render children()}` while unauthenticated. `startLiveSync()` must not + start until authenticated. +- **`src/lib/api/live.ts`** β€” on close codes 4401/4403, stop the reconnect loop (currently + it would hot-retry with backoff forever) and hand off to `auth.markSignedOut()`. + Reconnect after a successful login. + +### New routes + +`src/routes/login/`, `src/routes/setup/`, `src/routes/account/`, `src/routes/users/`. +`adapter-static` prerenders each into its own `.html`, which `spoolman/client.py` +`lookup_path()` already resolves generically β€” no backend change needed. `NavTabs.svelte` +gains a conditional **Users** tab (its `tabs` array is `satisfies { href: Pathname }`, so +the route must exist for the type to pass), and `TopBar.svelte` gains a user menu with +Account / Sign out. + +- **`/login`** β€” username + password + a **Remember me** checkbox, an "Sign in with + {provider}" button when `oidc.enabled`, and a TOTP step when the server answers 428. +- **`/setup`** β€” first-run owner creation, shown when `setup_required`. Carries a visible + warning that the instance is unclaimed. +- **`/account`** β€” change password, TOTP enrollment (QR via the already-present `qrcode` + npm dep, from the server's `otpauth://` URI), active sessions list with revoke / + "sign out everywhere", and personal API keys (plaintext shown once, copy button). +- **`/users`** β€” admin only. User list, create, level/admin editing, disable, force + password reset (shows the one-time password), revoke sessions, clear TOTP, manage that + user's API keys and mTLS certificates. Audit-log viewer as a section here. + +### Capability gating + +Reuse the existing patterns rather than adding a framework: the inline auto-save editors +(`SpoolInspector.svelte`, `FilamentInspector.svelte`, `VendorInspector.svelte` via +`src/lib/utils/saver.ts`) become read-only at level `read`; the add and scan buttons in +`TopBar.svelte`, `AddSpoolModal.svelte`, delete actions, and the settings page's +write controls hide or disable below their required level. Gating is cosmetic β€” the server +is the authority. + +### i18n + +New keys go into **`client_v2/locales/en/common.json` only** (flat dotted keys, +i18next-style `{{var}}`); every other locale is Weblate's. Used as +`m['auth.signIn']()` after `import * as m from '$lib/paraglide/messages'`. + +--- + +## Multi-tenancy readiness (design only β€” no tenant code in this project) + +Keep the seam cheap to open later: + +- All auth DB access goes through `spoolman/database/auth_*.py` functions that already take + the `Principal`. A tenant filter later slots into those functions, not into 40 routers. +- `Principal` gets a `tenant_id: int | None = None` field now, unused. Everything that + passes a principal is then already threading tenancy. +- Document in the model file that `auth_user.username` uniqueness becomes + `(tenant_id, username)`, and that `auth_api_key.prefix` stays globally unique (it is a + lookup key, not a name). +- Prefer integer surrogate PKs on all five tables (matches existing tables) so a + `tenant_id` FK is a pure add-column later. + +--- + +## Delivery phases + +| Phase | Contents | +|---|---| +| **1 β€” Foundation** | Env flags, secret key, all five tables + the single migration, `spoolman/auth/` core, route-level annotations + startup coverage assertion, password login, sessions/cookies/CSRF, brute-force, open first-run setup, anonymous-read setting, `/metrics` gating, CORS fix, CLI. Frontend: auth store, `http.ts` wiring, `/login` + `/setup`, layout gate, logout, capability gating, live.ts 4401 handling. | +| **2 β€” Machine access & accountability** | API key endpoints + `/account` key UI, audit log write sites + `/audit` + viewer, `/users` admin page with password reset. | +| **3 β€” OIDC** | Discovery, PKCE, JWKS, claimβ†’level/admin mapping, JIT provisioning, account linking, "Sign in with…" button. | +| **4 β€” 2FA & sessions** | TOTP enroll/confirm/disable, login TOTP step, session list + revoke UI, admin clear-TOTP. | +| **5 β€” mTLS** | Trusted-proxy header parsing, `auth_certificate` mapping, cert management UI, `SPOOLMAN_MTLS_REQUIRED` gate, nginx/Traefik/Caddy documentation. | + +Each phase is independently shippable and leaves auth-disabled behavior untouched. + +--- + +## Verification + +**Regression (the most important gate).** With auth off, behavior must be identical: +run the full 4-DB suite unchanged β€” `poe itest` (`tests_integration/run.py`, SQLite + +PostgreSQL + MariaDB + CockroachDB). No existing test file may be edited; if one needs +editing, the contract broke. `tests_integration/tests/conftest.py:40-54` polls `GET /` and +`tests_frontend/run.py:30` polls `/api/v1/health` β€” both stay public, so neither harness +needs changes. + +**Migration.** Confirm `alembic upgrade head` applies cleanly on all four backends from a +pre-existing populated database, then `downgrade` back and `upgrade` again on SQLite. The +4-DB compose run covers the upgrade path automatically since migrations run at startup. + +**New auth suite.** Add `tests_integration/docker-compose-sqlite-auth.yml` (auth enabled, +plus one Postgres variant) and `tests_integration/tests/auth/`: + +- `test_route_coverage.py` β€” every v1 route carries an auth dependency (asserts the + startup self-check, the single highest-value test here). +- `test_levels.py` β€” a `read` key gets 403 on `PATCH /spool/{id}`; an `edit` key succeeds + on `PUT /spool/{id}/use` but gets 403 on `DELETE /spool/{id}`; `manage` succeeds on both. +- `test_login.py` β€” success, wrong password, unknown user (equal timing), lockout after N + failures, `remember` cookie `Max-Age`. +- `test_setup.py` β€” first call claims ownership; second call 409. +- `test_apikey.py` β€” plaintext returned once, revoke takes effect, key capped by owner + level, demoting the user weakens the key. +- `test_csrf.py` β€” cookie-authenticated POST without `X-CSRF-Token` is rejected; the same + request with an API key succeeds. +- `test_websocket.py` β€” unauthenticated WS closes with 4401; wrong-level closes 4403; + `?api_key=` connects. +- `test_anonymous_read.py` β€” setting off β†’ 401 on `GET /spool`; on β†’ 200 on GET, 401 on POST. + +**OIDC (phase 3).** Stand up a local generic provider container (Dex or Keycloak) in a +compose file and test discovery β†’ PKCE β†’ callback β†’ session end-to-end. Then a manual +smoke against a real Pocket ID instance before merge, since Pocket ID is the named target. + +**mTLS (phase 5).** An nginx container terminating mTLS in front of Spoolman, with a +self-signed CA; assert certβ†’user mapping works and that the same headers sent directly +(peer not in `SPOOLMAN_TRUSTED_PROXIES`) are ignored β€” that spoofing test matters more +than the happy path. + +**Client.** `cd client_v2 && npm run check` (svelte-check), `npm run lint`, prettier, and +the existing `e2e/a11y-mobile.spec.ts` axe audit extended to the login and users pages. +Manual smoke of the two-terminal dev loop (`uvicorn` + `vite --port 5174` with +`VITE_APIURL` and an explicit `SPOOLMAN_CORS_ORIGIN`) to confirm cookies cross the dev +origin, plus a production-image run to confirm the prerendered `/login` and `/users` +documents resolve under a non-empty `SPOOLMAN_BASE_PATH`. + +**Manual acceptance.** Fresh SQLite instance, auth on: claim owner β†’ create an `edit` user +β†’ create a `read` API key β†’ point a real Moonraker (or a curl script replaying its +`PUT /use` calls) at it and confirm usage tracking works with an `edit` key and is +rejected with a `read` key.