From a64c9ced65a4d34fbfe102847d5b89eff6d25cfa Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 10 Jun 2026 21:29:14 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20client=20portal=20design=20+=20mileston?= =?UTF-8?q?e=20plan=20(M1=20live=20view=20=E2=86=92=20M4=20full=20auth)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only, client-scoped portal inside Terra-View (/portal/*), reusing cached SLMM reads. Data chain Client -> Project.client_id -> MonitoringLocation -> active UnitAssignment -> unit_id -> SLMM cache. Auth is a swappable get_current_client gate; M1-M3 ride an interim signed "magic URL", M4 replaces the backing. Milestones: M1 live view, M2 dashboard+alerts, M3 reports, M4 auth. Co-Authored-By: Claude Opus 4.8 --- docs/CLIENT_PORTAL.md | 142 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/CLIENT_PORTAL.md diff --git a/docs/CLIENT_PORTAL.md b/docs/CLIENT_PORTAL.md new file mode 100644 index 0000000..b74a94d --- /dev/null +++ b/docs/CLIENT_PORTAL.md @@ -0,0 +1,142 @@ +# Client Portal — Design & Build Plan + +**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x + +A client-facing, **read-only**, **scoped** view into a client's own monitoring +data. The first internet-facing-with-real-clients surface in the system. Built +*inside* the Terra-View app (new `/portal/*` namespace), reusing the cached SLMM +reads and Terra-View's report generation — Terra-View stays the UI/business layer; +SLMM stays the device layer. + +## Principles + +1. **Read-only.** No device control (start/stop/config), no roster editing, no + internal pages. A client can look, never touch. +2. **Strictly scoped.** A client only ever sees data for *their* projects. Every + portal endpoint verifies ownership server-side — never trust a `unit_id` / + `location_id` from the request. +3. **Cache-first, no device contention.** Portal live data comes from SLMM's + cache (the same cached `/status` + `/history` the internal dashboard uses). + No device-hitting calls from the portal — a client can't make us hammer the + NL-43. Freshness depends on **keepalive being on** for the client's units. +4. **Auth is a swappable gate.** Every route depends on one resolver, + `get_current_client()`. M1–M3 ride on an interim signed "magic URL"; M4 + replaces the resolver's backing without touching routes or templates. + +## The data chain (how a client maps to live data) + +``` +Client.id + └─ Project (client_id == Client.id, status != deleted) + └─ MonitoringLocation (project_id, location_type == "sound", removed_at IS NULL) + └─ UnitAssignment (location_id, status == "active", device_type == "slm", + assigned_until IS NULL or future) + └─ unit_id == RosterUnit.id == SLMM unit_id + └─ SLMM cached /status + /history (read-only) +``` + +So the portal shows a client their **locations**, each surfacing the live sound +level from whatever SLM is currently assigned there. + +## Data model (new) + +```python +class Client(Base): # the customer org + id, name, slug (unique, URL-safe), contact_email (nullable, for M4), + active (bool), created_at + +class ClientAccessToken(Base): # the interim "magic URL" gate + id, client_id, token_hash (sha256 — raw shown once on creation), + label, created_at, last_used_at, revoked_at (nullable) +``` + +Plus a migration adding **`Project.client_id`** (nullable FK → `clients.id`). +The existing free-text `Project.client_name` stays for display/back-compat; +`client_id` is the authoritative link. + +## Auth — the swappable gate + +```python +def get_current_client(request, db) -> Client: # every /portal route depends on this + # M1–M3: read signed `portal_client` cookie -> load Client + # M4: same signature, backed by real sessions (magic-link / password) +``` + +**Interim "magic URL" flow (M1–M3):** +- Operator creates a `Client` + an access token → gets a one-time-display URL: + `https://…/portal/enter/{token}`. +- Client clicks it → token is hashed, looked up (must be un-revoked) → + sets a **signed session cookie** (`portal_client`, HMAC via a new `SECRET_KEY` + env) → redirects to `/portal`. `last_used_at` updated. +- `get_current_client` reads + verifies the cookie thereafter. No valid cookie → + "link invalid / expired" page. +- Revoke = set `revoked_at`; the link (and any cookie minted from it) stops working. + +Unguessable + revocable + per-person, no email infra or passwords yet — and M4 +slots in behind the same `get_current_client` with zero route/template churn. + +## Routes (`/portal/*`) + +| Route | Purpose | +|-------|---------| +| `GET /portal/enter/{token}` | validate token → set cookie → redirect to `/portal` | +| `GET /portal` | client's locations overview (status tiles + map) | +| `GET /portal/location/{id}` | read-only live panel for that location's SLM | +| `GET /portal/api/location/{id}/live` | **scoped** cached `/status` for the location's unit | +| `GET /portal/api/location/{id}/history` | **scoped** cached trail for the chart | +| `GET /portal/logout` | clear cookie | + +**Scoping helper** (used by every data route): +`resolve_client_location(client, location_id, db) -> (location, unit_id)` — raises +403 if the location isn't in one of the client's projects. The portal never calls +the open `/api/slmm/{unit}/*` endpoints with a client-supplied id. + +## Templates (`templates/portal/`) + +- `portal/base.html` — minimal client-branded shell (no internal sidebar/nav). +- `portal/overview.html` — location tiles (live cards mini) + a locations map. +- `portal/location.html` — the read-only live panel: cards (Lp/Leq/Lmax/L1/L10), + L1/L10 chart, measuring + freshness badge. Reuses the cache-populate JS from the + internal panel, **stripped** of start/stop, config, and the device-hitting + refresh (cache + 15s auto-poll only). + +--- + +## Milestones + +### M1 — Live view only *(current)* +Interim magic-URL gate; a client sees their locations and per-location read-only +live data, all from cache. +- [ ] `Client` + `ClientAccessToken` models; `Project.client_id` migration. +- [ ] `SECRET_KEY` env + signed-cookie session helper. +- [ ] `get_current_client` dependency + `/portal/enter/{token}` + logout. +- [ ] Scoping helper `resolve_client_location`. +- [ ] `/portal` overview + `/portal/location/{id}` (read-only live panel). +- [ ] Scoped `/portal/api/location/{id}/live` + `/history`. +- [ ] Portal templates (base, overview, location). +- [ ] Minimal admin: create client + mint/revoke access link (small `/admin` + page or a script for now). + +### M2 — Dashboard + alerts +- Richer client dashboard (multi-location at-a-glance, map, status rollup). +- Surface each location's **threshold-alert status** (read-only) + an event/inbox + view. Leans on the SLMM alert engine + dispatch. + +### M3 — Reports +- Client-facing list + download of the daily baseline-comparison reports. +- Depends on the FTP report pipeline (`feat/ftp-report-pipeline`) landing and + being wired into the portal's scoped routes. + +### M4 — Full auth system +- Replace the interim token behind `get_current_client` with a real auth design: + magic-link (passwordless email) and/or accounts, proper sessions, password + reset, and likely auth for the *internal* app too. Reverse-proxy + TLS posture. + +## Security notes + +- Portal is auth-gated from day one (even the interim gate) — never wide-open like + the internal app. +- All scoping enforced server-side; client-supplied ids are always re-checked. +- `SECRET_KEY` must be a real secret in prod (env, not committed). +- Cookies: `HttpOnly`, `SameSite=Lax`, `Secure` once behind TLS. +- Tokens stored hashed; raw shown once. Revocation is immediate.