# Client Portal — Design & Build Plan

**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x

> **Update (Phase-1 auth landed):** the interim magic-link gate described below is
> **retired** — client access is now a per-project secure link + shared password
> (argon2). See the design at `docs/superpowers/specs/2026-06-15-portal-auth-design.md`
> and the build plan at `docs/superpowers/plans/2026-06-15-portal-auth.md`. The
> operator manages access from each project's **Portal access** panel.

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, status rollup).
- **Live project map** — upgrade the overview's basic location pins into a real
  project map: pins colored by measuring/level, popups showing each location's
  current reading, centered/zoomed to the project. (M1 ships the plain pin map;
  this makes it a live status map.)
- Surface each location's **threshold-alert status** (read-only) + an event/inbox
  view. Leans on the SLMM alert engine + dispatch.

### Notes carried from M1
- Tile headline metric is **Leq** (energy-average, the sound-monitoring compliance
  metric) — chosen over the twitchy instantaneous Lp. If clients ever want a
  different headline (e.g. Lmax for peaks), make it a per-deployment setting.

### 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.

## Going to prod (M1)

1. **Run the migration on the prod DB** — `migrate_add_client_portal.py` adds
   `projects.client_id` (the new tables auto-create via `create_all`). Skipping it
   500s anything that touches `Project.client_id`. This is the silent killer.
   ```bash
   docker compose exec web-app python3 backend/migrate_add_client_portal.py
   ```
2. **Set a real `SECRET_KEY`** in the prod env (compose). The portal signs session
   cookies with it; the insecure dev default (it logs a warning at boot) is
   forgeable. Non-negotiable for an internet-facing portal.
3. **SLMM_BASE_URL** — prod base compose already points at `:8100` (correct; the
   `:9100` mismatch is a dev-only override quirk). For full live data (L1/L10 +
   chart backfill) prod SLMM must be on the `dev` build with its migrations
   (`migrate_add_ln_percentiles`, `migrate_add_monitor_enabled`) and **keepalive on**
   for the client's units — otherwise the portal degrades gracefully (cards show
   `--`, chart empty), it just isn't fully populated.
4. **Seed real clients** with the CLI (`backend/portal_admin.py`): `create-client`
   → `link-project` (a real sound project with an active SLM assignment) →
   `mint-link` → send the client the printed URL (shown once).
5. **Exposure** — portal routes are auth-gated, but port 8001 still serves the
   whole *internal* app with no auth. Before real clients are on it, the portal
   should sit behind the reverse proxy with only `/portal/*` exposed (or the app
   restricted). This is the point where the parked reverse-proxy/TLS work becomes
   load-bearing.

## 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.

## Security hardening backlog ("Fest 2026")

The to-do for the dedicated hardening pass, roughly highest-impact first. Until
then the portal runs on security-by-obscurity (open port + interim links) — fine
for a not-in-use demo, not for real clients.

**Exposure (the big one):** port 8001 serves the *entire operator app* (roster,
projects, `/admin/*`, device config, the SLMM proxy) with **zero auth**, so an
open port exposes far more than the read-only portal.
- [ ] Reverse proxy (NPM/Caddy/Nginx) in front, exposing **only `/portal/*`** to
      the internet; keep the operator app reachable on the LAN only.
- [ ] TLS everywhere (Let's Encrypt). Then set portal cookies `Secure`.
- [ ] Don't port-forward the raw app; if a quick gate is wanted before M4, an
      auth proxy (Authelia / Authentik) can front the portal without writing auth.

**Config musts:**
- [ ] Set a real `SECRET_KEY` env (signs session cookies; default is public).
- [ ] `PORTAL_OPEN_LINKS=false` in any internet-facing env (it defaults off now).

**M4 — real auth** (replaces the interim token behind `get_current_client`):
- [ ] Magic-link email and/or accounts; proper sessions + password reset.
- [ ] Authenticate the **operator** app too (it currently has none).
- [ ] Gate the operator-only endpoints that are presently unauthenticated:
      `/projects/{id}/portal-preview`, `/projects/{id}/portal-link*`,
      `/portal/open/*`.

**Smaller items from the pre-merge code review:**
- [ ] Keepalive isn't auto-turned-off when the last alert rule on a unit is
      deleted (intentional "never auto-off"; revisit if it wastes cellular).
- [ ] Consider rate-limiting the scoped portal endpoints once public.
