Files
terra-view/docs/superpowers/plans/2026-06-15-portal-auth.md
T
2026-06-15 21:11:33 +00:00

52 KiB

Portal Authentication (Phase 1) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Gate each project's read-only client portal behind a secure per-project link + shared password.

Architecture: Add portal_enabled / portal_password_hash / portal_link_token to Project. A new /portal/p/{link_token} page resolves the project from the (unguessable) token and prompts for the password; on success it mints the existing signed session cookie — pinned to a dedicated 1:1 portal client for that project — so every existing client-scoped read-only route (overview, location, live/history/events) works unchanged and is automatically scoped to that one project. The interim magic-link / open-link entry points are retired.

Why reuse the client cookie instead of re-keying routes to "project": the existing scoped-data layer (get_current_client_client_project_idsresolve_client_location) is battle-tested in prod. Giving each portal-enabled project its own dedicated Client (slug portal-<project.id>, owning exactly that one project) yields per-project isolation with zero route surgery. Project.client_id is left untouched as substrate for the deferred per-client rollup.

Tech Stack: FastAPI, SQLAlchemy 2 + SQLite (raw-sqlite3 migrations, no Alembic), Jinja2, argon2-cffi (new), pytest + Starlette TestClient (new — no test harness exists yet). Docker: deps are baked into the image, so new deps require a rebuild.

Key existing references (verbatim from codebase):

  • backend/database.py: Base, SessionLocal, get_db (generator dependency). SQLite at data/seismo_fleet.db.
  • backend/models.py: Project (table projects, id is caller-supplied str(uuid.uuid4()), no FK constraints/relationships), Client (slug unique), ClientAccessToken.
  • backend/portal_auth.py: SECRET_KEY (env, default "dev-insecure-change-me"), COOKIE_NAME="portal_session", COOKIE_MAX_AGE=60*60*24*30, make_session_cookie(token_id){"tid","iat"} signed cookie, client_from_cookie, get_current_client, ensure_project_client, provision_preview_session, mint_link_token, PortalAuthError, PORTAL_OPEN_LINKS.
  • backend/routers/portal.py: APIRouter(prefix="/portal"). Routes /portal/enter/{token}, /portal/open/{project_id}, /portal/logout, /portal/access, /portal, /portal/location/{id}, /portal/api/location/{id}/{live,history,events,thresholds}, WS /stream. Cookie set with max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax". Scope helpers _client_project_ids, resolve_client_location, active_unit_for_location, _client_locations.
  • backend/main.py: app.include_router(portal.router); PortalAuthError handler; operator routes /projects/{id}/portal-preview, /projects/{id}/portal-link (create/list/revoke); project_detail_page passes portal_open_links to projects/detail.html.
  • templates/portal/base.html: blocks title/head/content/scripts; shows client chip + Sign-out when client context var is truthy; globals esc(), cssVar(). templates/portal/access_required.html: lock-card splash branching on reason.
  • templates/projects/detail.html: breadcrumb buttons (lines 21-41) + share modal & JS (lines 2101-2219) — the operator portal-sharing UI to replace.
  • backend/migrate_add_client_portal.py: the raw-sqlite3, idempotent PRAGMA table_info + ALTER TABLE ADD COLUMN migration pattern.

Task 1: Stand up the test harness + add dependencies

Files:

  • Modify: requirements.txt

  • Create: requirements-dev.txt

  • Create: tests/__init__.py (empty)

  • Create: tests/conftest.py

  • Create: tests/test_harness.py (sanity test, deleted at end of task)

  • Step 1: Add the runtime dependency (argon2)

Append to requirements.txt:

argon2-cffi==23.1.0
  • Step 2: Create the dev/test dependency file

Create requirements-dev.txt:

-r requirements.txt
pytest==8.3.3
  • Step 3: Create the test package + fixtures

Create tests/__init__.py (empty file).

Create tests/conftest.py:

"""Test harness: a throwaway SQLite DB per test, get_db overridden, a TestClient
that does NOT run lifespan startup (so schedulers/SLMM polling stay off)."""
import os
import uuid
import pytest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from starlette.testclient import TestClient

from backend.database import Base, get_db
import backend.models as models  # noqa: F401  (ensure all tables are registered on Base)


@pytest.fixture()
def db_session(tmp_path):
    db_file = tmp_path / "test.db"
    engine = create_engine(f"sqlite:///{db_file}", connect_args={"check_same_thread": False})
    Base.metadata.create_all(bind=engine)
    TestingSession = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    sess = TestingSession()
    try:
        yield sess
    finally:
        sess.close()
        engine.dispose()


@pytest.fixture()
def client(db_session):
    from backend.main import app  # imported lazily so module side effects are contained
    def _override():
        try:
            yield db_session
        finally:
            pass
    app.dependency_overrides[get_db] = _override
    # No `with` → lifespan/startup events do not run (no scheduler/SLMM threads).
    c = TestClient(app)
    yield c
    app.dependency_overrides.clear()


@pytest.fixture(autouse=True)
def _reset_portal_lockout():
    """Portal lockout state is a module-global dict; clear it between tests so
    one test's failed attempts can't lock out another."""
    try:
        import backend.portal_auth as _pa
        if hasattr(_pa, "_failures"):
            _pa._failures.clear()
    except Exception:
        pass
    yield


def make_project(db_session, name=None, **kwargs):
    """Insert and return a Project with a unique name."""
    p = models.Project(
        id=str(uuid.uuid4()),
        name=name or f"Proj {uuid.uuid4().hex[:8]}",
        status="active",
        created_at=datetime.utcnow(),
        **kwargs,
    )
    db_session.add(p)
    db_session.commit()
    return p
  • Step 4: Add a sanity test that proves the harness boots

Create tests/test_harness.py:

def test_app_boots_and_serves(client):
    r = client.get("/portal/access")
    assert r.status_code == 200


def test_make_project_persists(db_session):
    from tests.conftest import make_project
    p = make_project(db_session, name="Harness Co")
    assert p.id and p.name == "Harness Co"
  • Step 5: Bind-mount the source into the dev container (one-time, so code is live)

The dev container bakes source at build time (only data-dev is bind-mounted), so it won't pick up code edits — which would force a rebuild per TDD change. Fix it once by mounting the source. In docker-compose.override.yml, under the terra-view service volumes:, add a source mount above the existing data mount so the data mount still wins for /app/data:

    volumes:
      - .:/app
      - ./data-dev:/app/data

(Local dev-env convenience — does not need to be committed with the feature.)

  • Step 6: Rebuild (bakes argon2), recreate with the mount, install pytest, run the sanity test
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
docker exec terra-view-terra-view-1 pip install -r requirements-dev.txt
docker exec terra-view-terra-view-1 python -m pytest tests/test_harness.py -v

Expected: both tests PASS. With the source mounted, later tasks need no rebuilddocker exec … pytest sees your edits live. (pip install of pytest is ephemeral; re-run it only if you recreate the container.)

  • Step 7: Remove the throwaway sanity test and commit
rm tests/test_harness.py
git add requirements.txt requirements-dev.txt tests/__init__.py tests/conftest.py
git commit -m "test: stand up pytest harness + add argon2-cffi"

Task 2: Password hashing helpers

Files:

  • Create: backend/auth_passwords.py

  • Test: tests/test_auth_passwords.py

  • Step 1: Write the failing tests

Create tests/test_auth_passwords.py:

import pytest
from backend.auth_passwords import hash_password, verify_password, generate_password


def test_hash_is_not_plaintext_and_verifies():
    h = hash_password("hunter2")
    assert h != "hunter2"
    assert h.startswith("$argon2")
    assert verify_password("hunter2", h) is True


def test_verify_rejects_wrong_password():
    h = hash_password("hunter2")
    assert verify_password("nope", h) is False


def test_verify_is_safe_on_garbage_hash():
    assert verify_password("anything", "not-a-real-hash") is False


def test_generated_password_is_strong_and_unique():
    a, b = generate_password(), generate_password()
    assert a != b
    assert len(a) >= 12
  • Step 2: Run to verify failure

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_auth_passwords.py -v Expected: FAIL (ModuleNotFoundError: backend.auth_passwords).

  • Step 3: Implement the helpers

Create backend/auth_passwords.py:

"""Password hashing for the client portal — argon2id via argon2-cffi.

Kept separate from portal_auth (cookie signing) so the future operator auth can
reuse the same hasher. Never store or log raw passwords."""
import secrets
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError

_ph = PasswordHasher()


def hash_password(raw: str) -> str:
    """Return an argon2id hash string for a raw password."""
    return _ph.hash(raw)


def verify_password(raw: str, hashed: str) -> bool:
    """True iff raw matches the stored hash. Never raises."""
    try:
        return _ph.verify(hashed, raw)
    except (VerifyMismatchError, VerificationError, InvalidHashError, Exception):
        return False


def generate_password(n_bytes: int = 12) -> str:
    """A strong, URL-safe shareable password (~16 chars for n_bytes=12)."""
    return secrets.token_urlsafe(n_bytes)
  • Step 4: Run to verify pass

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_auth_passwords.py -v Expected: PASS (4 tests).

  • Step 5: Commit
git add backend/auth_passwords.py tests/test_auth_passwords.py
git commit -m "feat: argon2 password hashing helpers for the portal"

Task 3: Project portal columns + migration

Files:

  • Modify: backend/models.py (the Project class, after client_id ~models.py:195)

  • Create: backend/migrate_add_project_portal_auth.py

  • Test: tests/test_portal_migration.py

  • Step 1: Add the three columns to the Project model

In backend/models.py, inside class Project, immediately after the client_id line, add:

    # --- Client portal (Phase 1: per-project link + password gate) ---
    portal_enabled = Column(Boolean, default=False)          # is the portal open for this project
    portal_password_hash = Column(String, nullable=True)     # argon2 hash of the shared password
    portal_link_token = Column(String, nullable=True, unique=True, index=True)  # unguessable token in the secure link
  • Step 2: Write the failing migration test

Create tests/test_portal_migration.py:

import sqlite3
import importlib


def _columns(db_file):
    conn = sqlite3.connect(db_file)
    cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")}
    conn.close()
    return cols


def test_migration_adds_columns_and_is_idempotent(tmp_path, monkeypatch):
    db_file = tmp_path / "seismo_fleet.db"
    conn = sqlite3.connect(db_file)
    conn.execute("CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT)")
    conn.commit()
    conn.close()

    monkeypatch.chdir(tmp_path)            # migration resolves data/ relative to cwd
    (tmp_path / "data").mkdir()
    (tmp_path / "data" / "seismo_fleet.db").write_bytes(db_file.read_bytes())

    mod = importlib.import_module("backend.migrate_add_project_portal_auth")
    mod.migrate()
    cols = _columns(tmp_path / "data" / "seismo_fleet.db")
    assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= cols

    mod.migrate()  # second run must not raise
    assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= _columns(tmp_path / "data" / "seismo_fleet.db")
  • Step 3: Run to verify failure

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_migration.py -v Expected: FAIL (ModuleNotFoundError: backend.migrate_add_project_portal_auth).

  • Step 4: Write the migration (copy the migrate_add_client_portal.py pattern)

Create backend/migrate_add_project_portal_auth.py:

#!/usr/bin/env python3
"""
Database migration: Project portal auth (Phase 1).

Adds the per-project portal gate columns to `projects`:
  - portal_enabled        (BOOLEAN, default 0)
  - portal_password_hash  (TEXT, nullable)
  - portal_link_token     (TEXT, nullable)   [+ unique index]

Idempotent. Run once per existing DB:
    docker exec terra-view-terra-view-1 python3 backend/migrate_add_project_portal_auth.py
"""
import sqlite3
from pathlib import Path

_COLUMNS = {
    "portal_enabled": "BOOLEAN DEFAULT 0",
    "portal_password_hash": "TEXT",
    "portal_link_token": "TEXT",
}


def migrate():
    possible_paths = [Path("data/seismo_fleet.db"), Path("data/sfm.db"), Path("data/seismo.db")]
    db_path = next((p for p in possible_paths if p.exists()), None)
    if db_path is None:
        print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
        print("A fresh DB created via models.py will include these columns automatically.")
        return

    print(f"Using database: {db_path}")
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    cursor.execute("PRAGMA table_info(projects)")
    existing = {row[1] for row in cursor.fetchall()}

    for col, ddl in _COLUMNS.items():
        if col in existing:
            print(f"○ Column already exists: projects.{col}")
            continue
        try:
            cursor.execute(f"ALTER TABLE projects ADD COLUMN {col} {ddl}")
            print(f"✓ Added column: projects.{col} ({ddl})")
        except sqlite3.OperationalError as e:
            print(f"✗ Failed to add projects.{col}: {e}")

    # Unique index on the link token (separate from ADD COLUMN; idempotent via IF NOT EXISTS).
    try:
        cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_projects_portal_link_token "
                       "ON projects (portal_link_token)")
        print("✓ Ensured unique index: ix_projects_portal_link_token")
    except sqlite3.OperationalError as e:
        print(f"✗ Failed to create index: {e}")

    conn.commit()
    conn.close()
    print("\n✓ Project portal-auth migration complete.")


if __name__ == "__main__":
    migrate()
  • Step 5: Run to verify pass

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_migration.py -v Expected: PASS.

  • Step 6: Commit
git add backend/models.py backend/migrate_add_project_portal_auth.py tests/test_portal_migration.py
git commit -m "feat: add per-project portal gate columns + migration"

Files:

  • Modify: backend/portal_auth.py (append new helpers + a lockout store)
  • Test: tests/test_portal_auth_helpers.py

These build on the existing ensure_project_client-style pattern but pin a dedicated 1:1 portal client to the project, and add link-token resolution + an in-memory lockout.

  • Step 1: Write the failing tests

Create tests/test_portal_auth_helpers.py:

import time
from tests.conftest import make_project
from backend import portal_auth as pa
from backend.models import Client, ClientAccessToken
from backend.auth_passwords import hash_password


def test_portal_client_for_project_is_1to1_and_idempotent(db_session):
    p = make_project(db_session)
    c1 = pa.portal_client_for_project(p, db_session)
    c2 = pa.portal_client_for_project(p, db_session)
    assert isinstance(c1, Client) and c1.id == c2.id
    assert c1.slug == f"portal-{p.id}"
    # exactly one client row for this project
    assert db_session.query(Client).filter_by(slug=f"portal-{p.id}").count() == 1


def test_mint_portal_session_returns_usable_token_id(db_session):
    p = make_project(db_session)
    tid = pa.mint_portal_session(p, db_session)
    tok = db_session.query(ClientAccessToken).filter_by(id=tid, revoked_at=None).first()
    assert tok is not None
    # the cookie built from it resolves back to this project's portal client
    cookie = pa.make_session_cookie(tid)
    client = pa.client_from_cookie(cookie, db_session)
    assert client is not None and client.slug == f"portal-{p.id}"


def test_resolve_project_by_link_token(db_session):
    p = make_project(db_session, portal_enabled=True, portal_link_token="tok-abc")
    assert pa.resolve_project_by_link_token("tok-abc", db_session).id == p.id
    assert pa.resolve_project_by_link_token("nope", db_session) is None


def test_resolve_project_ignores_disabled_portal(db_session):
    make_project(db_session, portal_enabled=False, portal_link_token="tok-off")
    assert pa.resolve_project_by_link_token("tok-off", db_session) is None


def test_lockout_after_max_attempts():
    pa.clear_failures("k1")
    assert pa.is_locked("k1") is False
    for _ in range(pa.MAX_ATTEMPTS):
        pa.register_failure("k1")
    assert pa.is_locked("k1") is True
    pa.clear_failures("k1")
    assert pa.is_locked("k1") is False
  • Step 2: Run to verify failure

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_auth_helpers.py -v Expected: FAIL (AttributeError: module 'backend.portal_auth' has no attribute 'portal_client_for_project').

  • Step 3: Append the helpers to backend/portal_auth.py

Add at the end of backend/portal_auth.py:

# --- Phase-1 per-project password gate -------------------------------------------
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
# owning exactly that project, so the existing client-scoped routes are automatically
# per-project. Project.client_id is left untouched (deferred per-client rollup).

from backend.models import Project  # local import; Project not needed above


def portal_client_for_project(project, db) -> Client:
    """Get-or-create the dedicated 1:1 portal client for a project."""
    slug = f"portal-{project.id}"
    client = db.query(Client).filter_by(slug=slug).first()
    if client is None:
        client = Client(id=str(uuid.uuid4()),
                        name=(project.client_name or project.name or "Client"),
                        slug=slug, active=True)
        db.add(client)
        db.flush()
    return client


def mint_portal_session(project, db) -> str:
    """Ensure the project's portal client + an access token exist; return the token
    id to seal into a session cookie. Reuses an existing token to avoid clutter."""
    client = portal_client_for_project(project, db)
    tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
    if tok is None:
        tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
                                token_hash=hash_token(secrets.token_urlsafe(32)),
                                label="portal")
        db.add(tok)
    db.commit()
    return tok.id


def resolve_project_by_link_token(link_token: str, db):
    """Return the portal-enabled Project for a link token, or None."""
    if not link_token:
        return None
    return db.query(Project).filter_by(
        portal_link_token=link_token, portal_enabled=True).first()


# In-memory brute-force lockout (per link_token+IP). Resets on restart; adequate for
# a read-only surface behind the UniFi edge. Single-worker dev; note multi-worker
# would need a shared store.
MAX_ATTEMPTS = 5
LOCK_SECONDS = 15 * 60
_failures: dict = {}  # key -> (count, first_failure_epoch)


def is_locked(key: str) -> bool:
    rec = _failures.get(key)
    if not rec:
        return False
    count, first = rec
    if count < MAX_ATTEMPTS:
        return False
    if (time.time() - first) > LOCK_SECONDS:
        _failures.pop(key, None)  # window expired
        return False
    return True


def register_failure(key: str) -> None:
    count, first = _failures.get(key, (0, time.time()))
    _failures[key] = (count + 1, first)


def clear_failures(key: str) -> None:
    _failures.pop(key, None)

Note: hash_token, secrets, uuid, time, Client, ClientAccessToken are already imported at the top of portal_auth.py.

  • Step 4: Run to verify pass

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_auth_helpers.py -v Expected: PASS (5 tests).

  • Step 5: Commit
git add backend/portal_auth.py tests/test_portal_auth_helpers.py
git commit -m "feat: per-project portal session mint + link-token resolve + lockout"

Task 5: The password-gate page + the password prompt template

Files:

  • Create: templates/portal/password.html

  • Modify: backend/routers/portal.py (add GET + POST /portal/p/{link_token}; import Form, the new helpers, verify_password)

  • Test: tests/test_portal_gate.py

  • Step 1: Create the password prompt template

Create templates/portal/password.html:

{% extends "portal/base.html" %}
{% block title %}{{ project_name }}{% endblock %}
{% block content %}
<div class="max-w-md mx-auto mt-20 text-center reveal">
    <div class="panel inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6">
        <svg class="w-7 h-7 text-[var(--text-dim)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
                  d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
        </svg>
    </div>
    <h1 class="text-2xl font-bold tracking-tight mb-1">{{ project_name }}</h1>
    <p class="text-[var(--text-dim)] text-sm mb-6">Enter the password to view this monitoring portal.</p>
    {% if error %}
    <p class="text-[var(--lvl-bad)] text-sm mb-4">{{ error }}</p>
    {% endif %}
    <form method="post" action="/portal/p/{{ link_token }}" class="panel p-5 text-left">
        <label class="block text-xs text-[var(--text-dim)] mb-1" for="password">Password</label>
        <input id="password" name="password" type="password" autofocus required
               class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--panel-b)] text-[var(--text)] mb-4">
        <button type="submit"
                class="w-full px-4 py-2 rounded-lg bg-seismo-orange text-white font-medium hover:opacity-90">
            View portal
        </button>
    </form>
</div>
{% endblock %}
  • Step 2: Write the failing route tests

Create tests/test_portal_gate.py:

from tests.conftest import make_project
from backend import portal_auth as pa
from backend.auth_passwords import hash_password


def _enabled_project(db_session, token="tok-1", password="secretpw"):
    return make_project(db_session, portal_enabled=True, portal_link_token=token,
                        portal_password_hash=hash_password(password))


def test_get_prompt_renders_for_valid_token(client, db_session):
    _enabled_project(db_session)
    r = client.get("/portal/p/tok-1")
    assert r.status_code == 200
    assert "password" in r.text.lower()


def test_get_unknown_token_shows_generic_page(client, db_session):
    r = client.get("/portal/p/does-not-exist")
    assert r.status_code in (403, 404)
    assert "password" not in r.text.lower() or "isn't valid" in r.text.lower()


def test_wrong_password_is_rejected(client, db_session):
    _enabled_project(db_session, password="rightpw")
    r = client.post("/portal/p/tok-1", data={"password": "wrongpw"}, follow_redirects=False)
    assert r.status_code == 200            # re-renders the form, no cookie
    assert "portal_session" not in r.headers.get("set-cookie", "")


def test_correct_password_sets_cookie_and_redirects(client, db_session):
    _enabled_project(db_session, password="rightpw")
    r = client.post("/portal/p/tok-1", data={"password": "rightpw"}, follow_redirects=False)
    assert r.status_code == 303
    assert r.headers["location"] == "/portal"
    assert "portal_session=" in r.headers.get("set-cookie", "")


def test_lockout_after_five_wrong(client, db_session):
    _enabled_project(db_session, token="tok-lock", password="rightpw")
    for _ in range(5):
        client.post("/portal/p/tok-lock", data={"password": "x"}, follow_redirects=False)
    # 6th attempt — even the CORRECT password is refused while locked
    r = client.post("/portal/p/tok-lock", data={"password": "rightpw"}, follow_redirects=False)
    assert r.status_code == 200
    assert "portal_session=" not in r.headers.get("set-cookie", "")
    assert "too many" in r.text.lower()
  • Step 3: Run to verify failure

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_gate.py -v Expected: FAIL (404 — routes not defined).

  • Step 4: Add the routes to backend/routers/portal.py

Update the imports block in backend/routers/portal.py:

from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, Form

and extend the backend.portal_auth import to add the new helpers:

from backend.portal_auth import (
    get_current_client, client_from_cookie, make_session_cookie, resolve_token,
    provision_preview_session, PORTAL_OPEN_LINKS,
    COOKIE_NAME, COOKIE_MAX_AGE,
    resolve_project_by_link_token, mint_portal_session,
    is_locked, register_failure, clear_failures,
)
from backend.auth_passwords import verify_password

Add these two routes (place them just after the existing /portal/access handler):

@router.get("/p/{link_token}")
def portal_password_prompt(link_token: str, request: Request, db: Session = Depends(get_db)):
    """Secure per-project link: resolve the project from the token, prompt for the
    shared password. Generic page if the token is unknown/disabled (no leak)."""
    project = resolve_project_by_link_token(link_token, db)
    if not project:
        return templates.TemplateResponse(
            "portal/access_required.html", {"request": request, "reason": "invalid"},
            status_code=404)
    return templates.TemplateResponse("portal/password.html", {
        "request": request, "link_token": link_token,
        "project_name": project.name, "error": None})


@router.post("/p/{link_token}")
def portal_password_submit(link_token: str, request: Request,
                           password: str = Form(...), db: Session = Depends(get_db)):
    """Verify the shared password; on success mint a project-scoped session cookie."""
    project = resolve_project_by_link_token(link_token, db)
    if not project:
        return templates.TemplateResponse(
            "portal/access_required.html", {"request": request, "reason": "invalid"},
            status_code=404)

    lock_key = f"{link_token}:{request.client.host if request.client else '?'}"
    if is_locked(lock_key):
        return templates.TemplateResponse("portal/password.html", {
            "request": request, "link_token": link_token, "project_name": project.name,
            "error": "Too many attempts. Try again in 15 minutes."}, status_code=200)

    if not project.portal_password_hash or not verify_password(password, project.portal_password_hash):
        register_failure(lock_key)
        return templates.TemplateResponse("portal/password.html", {
            "request": request, "link_token": link_token, "project_name": project.name,
            "error": "Incorrect password."}, status_code=200)

    clear_failures(lock_key)
    token_id = mint_portal_session(project, db)
    resp = RedirectResponse(url="/portal", status_code=303)
    resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
                    max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
    logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened")
    return resp
  • Step 5: Run to verify pass

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_gate.py -v Expected: PASS (5 tests). (If a prior test left lock state, restart the container or run the file in isolation — lockout state is module-global by design.)

  • Step 6: Commit
git add templates/portal/password.html backend/routers/portal.py tests/test_portal_gate.py
git commit -m "feat: per-project portal password gate (/portal/p/{token}) + lockout"

Task 6: Per-project scope isolation test

Files:

  • Test: tests/test_portal_scope.py

Proves a session minted for project A cannot read project B's location.

  • Step 1: Write the test

Create tests/test_portal_scope.py:

import uuid
from datetime import datetime
from tests.conftest import make_project
from backend import portal_auth as pa
from backend.auth_passwords import hash_password
from backend.models import MonitoringLocation


def _sound_location(db_session, project):
    loc = MonitoringLocation(
        id=str(uuid.uuid4()), project_id=project.id, name="Site",
        location_type="sound", created_at=datetime.utcnow())
    db_session.add(loc)
    db_session.commit()
    return loc


def test_session_for_A_cannot_open_B_location(client, db_session):
    a = make_project(db_session, portal_enabled=True, portal_link_token="ta",
                     portal_password_hash=hash_password("pw"))
    b = make_project(db_session)
    b_loc = _sound_location(db_session, b)

    # Establish an A session
    r = client.post("/portal/p/ta", data={"password": "pw"}, follow_redirects=False)
    assert r.status_code == 303

    # Try to open B's location page → 404 (not 403), no leak
    r2 = client.get(f"/portal/location/{b_loc.id}")
    assert r2.status_code == 404

Note: confirm MonitoringLocation's required columns by checking backend/models.py; add any nullable=False fields the constructor needs (e.g. sort_order defaults). If the model requires extra non-null fields, set them in _sound_location.

  • Step 2: Run to verify pass (behavior already implemented by resolve_client_location)

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_scope.py -v Expected: PASS. (If it fails on a missing non-null column, fix _sound_location per the note, not the app.)

  • Step 3: Commit
git add tests/test_portal_scope.py
git commit -m "test: portal session is isolated to its own project (404 on others)"

Task 7: Operator "Portal access" endpoints

Files:

  • Modify: backend/main.py (add 4 routes near the existing /projects/{id}/portal-* routes ~main.py:417-487)

  • Test: tests/test_portal_access_admin.py

  • Step 1: Write the failing tests

Create tests/test_portal_access_admin.py:

from tests.conftest import make_project
from backend.models import Project


def test_enable_creates_link_token_and_reports_state(client, db_session):
    p = make_project(db_session)
    r = client.post(f"/projects/{p.id}/portal-access/enable")
    assert r.status_code == 200
    body = r.json()
    assert body["enabled"] is True
    assert body["link_url"].endswith(f"/portal/p/{db_session.get(Project, p.id).portal_link_token}")


def test_set_password_returns_raw_once_and_stores_hash(client, db_session):
    p = make_project(db_session)
    client.post(f"/projects/{p.id}/portal-access/enable")
    r = client.post(f"/projects/{p.id}/portal-access/password")
    assert r.status_code == 200
    raw = r.json()["password"]
    assert len(raw) >= 12
    fresh = db_session.get(Project, p.id)
    assert fresh.portal_password_hash and fresh.portal_password_hash != raw


def test_disable_turns_off_and_rotates_token(client, db_session):
    p = make_project(db_session)
    client.post(f"/projects/{p.id}/portal-access/enable")
    old = db_session.get(Project, p.id).portal_link_token
    r = client.post(f"/projects/{p.id}/portal-access/disable")
    assert r.status_code == 200
    fresh = db_session.get(Project, p.id)
    assert fresh.portal_enabled is False
    assert fresh.portal_link_token != old


def test_get_state(client, db_session):
    p = make_project(db_session)
    r = client.get(f"/projects/{p.id}/portal-access")
    assert r.status_code == 200
    assert r.json() == {"enabled": False, "has_password": False, "link_url": None}
  • Step 2: Run to verify failure

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_access_admin.py -v Expected: FAIL (404).

  • Step 3: Implement the four routes in backend/main.py

Add after the existing /projects/{project_id}/portal-link/{token_id}/revoke route (~main.py:487):

@app.get("/projects/{project_id}/portal-access")
async def project_portal_access_state(project_id: str, request: Request, db: Session = Depends(get_db)):
    """Current portal-access state for the operator panel."""
    from backend.models import Project
    p = db.query(Project).filter_by(id=project_id).first()
    if not p:
        return JSONResponse(status_code=404, content={"detail": "Project not found"})
    link_url = (str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}") \
        if (p.portal_enabled and p.portal_link_token) else None
    return {"enabled": bool(p.portal_enabled), "has_password": bool(p.portal_password_hash),
            "link_url": link_url}


@app.post("/projects/{project_id}/portal-access/enable")
async def project_portal_access_enable(project_id: str, request: Request, db: Session = Depends(get_db)):
    """Turn the portal on; mint a link token if one doesn't exist yet."""
    import secrets
    from backend.models import Project
    p = db.query(Project).filter_by(id=project_id).first()
    if not p:
        return JSONResponse(status_code=404, content={"detail": "Project not found"})
    if not p.portal_link_token:
        p.portal_link_token = secrets.token_urlsafe(24)
    p.portal_enabled = True
    db.commit()
    link_url = str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}"
    return {"enabled": True, "has_password": bool(p.portal_password_hash), "link_url": link_url}


@app.post("/projects/{project_id}/portal-access/password")
async def project_portal_access_password(project_id: str, db: Session = Depends(get_db)):
    """Generate a fresh strong password, store its hash, return the raw once."""
    from backend.models import Project
    from backend.auth_passwords import hash_password, generate_password
    p = db.query(Project).filter_by(id=project_id).first()
    if not p:
        return JSONResponse(status_code=404, content={"detail": "Project not found"})
    raw = generate_password()
    p.portal_password_hash = hash_password(raw)
    db.commit()
    return {"password": raw}


@app.post("/projects/{project_id}/portal-access/disable")
async def project_portal_access_disable(project_id: str, db: Session = Depends(get_db)):
    """Turn the portal off and rotate the link token (kills the old link)."""
    import secrets
    from backend.models import Project
    p = db.query(Project).filter_by(id=project_id).first()
    if not p:
        return JSONResponse(status_code=404, content={"detail": "Project not found"})
    p.portal_enabled = False
    p.portal_link_token = secrets.token_urlsafe(24)  # rotate so the old link 404s
    db.commit()
    return {"enabled": False}
  • Step 4: Run to verify pass

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_access_admin.py -v Expected: PASS (4 tests).

  • Step 5: Commit
git add backend/main.py tests/test_portal_access_admin.py
git commit -m "feat: operator portal-access endpoints (enable/password/disable/state)"

Task 8: Operator "Portal access" panel (replace the share modal)

Files:

  • Modify: templates/projects/detail.html (replace breadcrumb buttons lines 21-41 and the share modal + JS lines 2101-2219)

This is a UI task — verify manually in the browser.

  • Step 1: Replace the breadcrumb buttons (lines 21-41)

Replace the whole <!-- Client portal actions for this project --> block (lines 21-41) with:

    <!-- Client portal access for this project -->
    <div class="shrink-0 flex items-center gap-2">
        <button type="button" onclick="openPortalAccess()"
                class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-600 bg-slate-700/40 text-gray-200 hover:bg-slate-700 transition-colors"
                title="Manage this project's client portal access">
            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                      d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
            </svg>
            Portal access
        </button>
        <a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener"
           class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
           title="Preview this project's client portal in a new tab">
            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
            </svg>
            Preview
        </a>
    </div>
  • Step 2: Replace the share modal + JS (lines 2101-2219)

Replace the entire <!-- Share client portal link modal --> block and its <script> (lines 2101-2219, ending just before {% endblock %}) with:

<!-- Portal access modal -->
<div id="portal-access-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
     onclick="if(event.target===this)closePortalAccess()">
    <div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg p-6">
        <div class="flex items-center justify-between mb-1">
            <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal access</h3>
            <button onclick="closePortalAccess()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
                <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
            </button>
        </div>
        <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
            Send the client the link <em>and</em> the password. Read-only. Disabling rotates the link.
        </p>

        <div class="flex items-center justify-between mb-4">
            <span class="text-sm font-medium text-gray-700 dark:text-gray-200">Portal enabled</span>
            <button id="pa-toggle" onclick="togglePortalEnabled()"
                    class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600"></button>
        </div>

        <div id="pa-details" class="hidden space-y-4">
            <div>
                <label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Portal link</label>
                <div class="flex gap-2">
                    <input id="pa-link" readonly class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
                    <button onclick="copyField('pa-link', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
                </div>
            </div>
            <div>
                <label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Password</label>
                <div class="flex gap-2">
                    <input id="pa-pass" readonly placeholder="•••••••• (set one below)"
                           class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
                    <button onclick="copyField('pa-pass', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
                </div>
                <button onclick="regeneratePassword()" class="mt-2 text-sm text-seismo-orange hover:text-seismo-navy font-medium">↻ Generate new password</button>
                <p class="text-xs text-gray-400 mt-1">Shown once — copy it now. Regenerating invalidates the old one.</p>
            </div>
        </div>
    </div>
</div>

<script>
const PA_PROJECT_ID = "{{ project_id }}";
function openPortalAccess() { document.getElementById('portal-access-modal').classList.remove('hidden'); loadPortalAccess(); }
function closePortalAccess() { document.getElementById('portal-access-modal').classList.add('hidden'); }

function copyField(id, btn) {
    const inp = document.getElementById(id); inp.select();
    const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
    if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
    else { document.execCommand('copy'); done(); }
}

async function loadPortalAccess() {
    const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access`)).json();
    renderPortalAccess(j);
}
function renderPortalAccess(j) {
    const toggle = document.getElementById('pa-toggle');
    const details = document.getElementById('pa-details');
    toggle.textContent = j.enabled ? 'On — click to disable' : 'Off — click to enable';
    toggle.className = 'px-3 py-1.5 text-sm rounded-lg border ' +
        (j.enabled ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-slate-300 dark:border-slate-600');
    details.classList.toggle('hidden', !j.enabled);
    if (j.enabled && j.link_url) document.getElementById('pa-link').value = j.link_url;
}
async function togglePortalEnabled() {
    const on = document.getElementById('pa-toggle').textContent.startsWith('On');
    const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access/${on ? 'disable' : 'enable'}`, { method: 'POST' })).json();
    if (on) renderPortalAccess({ enabled: false, link_url: null });
    else renderPortalAccess(j);
}
async function regeneratePassword() {
    const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access/password`, { method: 'POST' })).json();
    if (j.password) { const f = document.getElementById('pa-pass'); f.value = j.password; f.placeholder = ''; }
}
</script>
{% endblock %}
  • Step 3: Verify manually in the browser
docker compose restart terra-view

Then open http://localhost:1001/projects/<a-real-project-id>, click Portal access:

  • Toggle On → a link appears.

  • Generate new password → a password appears.

  • Copy buttons work.

  • Open the link in a private window → password prompt → enter the generated password → lands on the project's read-only portal.

  • Step 4: Commit

git add templates/projects/detail.html
git commit -m "feat: operator Portal access panel (enable + password + link)"

Task 9: Retire the interim magic-link / open-link entry points

Files:

  • Modify: backend/routers/portal.py (remove /portal/enter/{token}, /portal/open/{project_id})

  • Modify: backend/main.py (remove /projects/{id}/portal-link create/list/revoke; repoint /projects/{id}/portal-preview to mint_portal_session; drop the portal_open_links template arg)

  • Modify: backend/portal_auth.py (remove PORTAL_OPEN_LINKS + its warning)

  • Test: tests/test_retired_routes.py

  • Step 1: Write the failing test (routes should be gone / preview still works)

Create tests/test_retired_routes.py:

from tests.conftest import make_project


def test_enter_and_open_are_gone(client, db_session):
    assert client.get("/portal/enter/anything", follow_redirects=False).status_code == 404
    assert client.get("/portal/open/anything", follow_redirects=False).status_code == 404


def test_portal_link_endpoints_are_gone(client, db_session):
    p = make_project(db_session)
    assert client.post(f"/projects/{p.id}/portal-link").status_code == 404
    assert client.get(f"/projects/{p.id}/portal-links").status_code == 404


def test_preview_still_mints_a_session(client, db_session):
    p = make_project(db_session)
    r = client.get(f"/projects/{p.id}/portal-preview", follow_redirects=False)
    assert r.status_code == 303
    assert "portal_session=" in r.headers.get("set-cookie", "")
  • Step 2: Run to verify failure

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_retired_routes.py -v Expected: FAIL (the old routes still answer 200/303).

  • Step 3: Remove /portal/enter/{token} and /portal/open/{project_id} from backend/routers/portal.py

Delete both route functions (portal_enter at portal.py:94-110 and portal_open at portal.py:113-132). From the backend.portal_auth import line in portal.py, remove the now-unused resolve_token, provision_preview_session, PORTAL_OPEN_LINKS names (keep make_session_cookie, mint_portal_session, etc.).

  • Step 4: Update backend/main.py

  • Delete the three routes project_portal_link_create, project_portal_links_list, project_portal_link_revoke (main.py:436-487).

  • In project_portal_preview (main.py:417-433), change the body to use the new mint helper:

@app.get("/projects/{project_id}/portal-preview")
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
    """Operator testing shortcut: open this project's client portal (no CLI)."""
    from backend.models import Project
    from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE
    project = db.query(Project).filter_by(id=project_id).first()
    if not project:
        return JSONResponse(status_code=404, content={"detail": "Project not found"})
    token_id = mint_portal_session(project, db)
    resp = RedirectResponse(url="/portal", status_code=303)
    resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
                    max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax")
    return resp
  • In project_detail_page (main.py:407-414), remove the "portal_open_links": PORTAL_OPEN_LINKS, context line.

  • In the imports (main.py:72), change from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS to from backend.portal_auth import PortalAuthError.

  • Step 5: Remove PORTAL_OPEN_LINKS from backend/portal_auth.py

Delete the PORTAL_OPEN_LINKS = ... assignment and its if PORTAL_OPEN_LINKS: logger.warning(...) block (portal_auth.py:43-50).

  • Step 6: Run to verify pass + full suite green
docker exec terra-view-terra-view-1 python -m pytest tests/ -v

Expected: all tests PASS (including test_retired_routes.py). Fix any import errors surfaced by the removals.

  • Step 7: Commit
git add backend/routers/portal.py backend/main.py backend/portal_auth.py tests/test_retired_routes.py
git commit -m "refactor: retire interim magic-link/open-link in favor of password gate"

Files:

  • Modify: backend/portal_auth.py (add COOKIE_SECURE)

  • Modify: backend/routers/portal.py + backend/main.py (pass secure=COOKIE_SECURE at the cookie set sites)

  • Test: tests/test_cookie_secure.py

  • Step 1: Add the flag to backend/portal_auth.py

Near COOKIE_NAME/COOKIE_MAX_AGE, add:

# Set COOKIE_SECURE=true once the portal is served over HTTPS (TLS terminates at
# the Synology reverse proxy). Default false so plain-HTTP dev still works.
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
  • Step 2: Write the failing test

Create tests/test_cookie_secure.py:

import importlib
from tests.conftest import make_project
from backend.auth_passwords import hash_password


def test_cookie_secure_flag_is_applied(monkeypatch, client, db_session):
    import backend.portal_auth as pa
    monkeypatch.setattr(pa, "COOKIE_SECURE", True, raising=False)
    # also patch the name imported into the router module
    import backend.routers.portal as pr
    monkeypatch.setattr(pr, "COOKIE_SECURE", True, raising=False)

    make_project(db_session, portal_enabled=True, portal_link_token="ts",
                 portal_password_hash=hash_password("pw"))
    r = client.post("/portal/p/ts", data={"password": "pw"}, follow_redirects=False)
    assert "secure" in r.headers.get("set-cookie", "").lower()
  • Step 3: Run to verify failure

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_cookie_secure.py -v Expected: FAIL (cookie has no Secure).

  • Step 4: Apply secure=COOKIE_SECURE at every set_cookie site

In backend/routers/portal.py: add COOKIE_SECURE to the backend.portal_auth import, and in the portal_password_submit set_cookie call add secure=COOKIE_SECURE:

    resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
                    max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax",
                    secure=COOKIE_SECURE)

In backend/main.py project_portal_preview, import COOKIE_SECURE alongside the others and add secure=COOKIE_SECURE to its set_cookie call the same way.

  • Step 5: Run to verify pass

Run: docker exec terra-view-terra-view-1 python -m pytest tests/test_cookie_secure.py -v Expected: PASS.

  • Step 6: Commit
git add backend/portal_auth.py backend/routers/portal.py backend/main.py tests/test_cookie_secure.py
git commit -m "feat: env-driven Secure flag on portal session cookie"

Task 11: Docs, changelog, and rollout notes

Files:

  • Modify: CHANGELOG.md ([Unreleased])

  • Modify: docs/CLIENT_PORTAL.md (note Phase-1 password gate supersedes the magic-link)

  • Step 1: Add a changelog entry

Under [Unreleased] in CHANGELOG.md, add:

### Portal authentication (Phase 1)
- Each project's client portal is now gated by a **secure per-project link + shared password** (argon2-hashed). Operators manage it from the project page's **Portal access** panel (enable, generate password, copy link).
- Brute-force lockout (5 tries / 15 min) on the password gate.
- Retired the interim magic-link / `PORTAL_OPEN_LINKS` open links.
- **Upgrade:** run `python3 backend/migrate_add_project_portal_auth.py` per DB. Set `COOKIE_SECURE=true` once served over HTTPS.
  • Step 2: Note the supersession in docs/CLIENT_PORTAL.md

Add a short line at the top of docs/CLIENT_PORTAL.md pointing to the new spec/plan and noting the magic-link is retired in favor of the per-project password gate (see docs/superpowers/specs/2026-06-15-portal-auth-design.md).

  • Step 3: Run the full suite one last time
docker exec terra-view-terra-view-1 python -m pytest tests/ -v

Expected: all green.

  • Step 4: Commit
git add CHANGELOG.md docs/CLIENT_PORTAL.md
git commit -m "docs: changelog + portal-auth Phase 1 notes"

Rollout (after merge to dev)

  1. Run the migration on each DB: docker exec <container> python3 backend/migrate_add_project_portal_auth.py (dev: terra-view-terra-view-1; prod: terra-view-web-app-1). Rebuild images first so argon2-cffi is present.
  2. Set a real SECRET_KEY in prod (already required); set COOKIE_SECURE=true once behind TLS.
  3. Enable a project's portal, set a password, and walk the link→password→dashboard flow over HTTPS before sending a client.

Deferred (separate specs later)

  • Operator auth (roles, UniFi edge, staged flip) — see docs/superpowers/specs/2026-06-15-portal-auth-design.md §Deferred A.
  • Full multi-tenancy (per-client rollup, individual client accounts) — §Deferred B.