# 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_ids` → `resolve_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`:
```python
"""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`:
```python
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`:

```yaml
    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**

```bash
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 rebuild** — `docker 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**

```bash
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`:
```python
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`:
```python
"""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**

```bash
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:
```python
    # --- 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`:
```python
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`:
```python
#!/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**

```bash
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"
```

---

## Task 4: Portal session-mint + link-token + lockout helpers

**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`:
```python
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`:
```python
# --- 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**

```bash
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`:
```jinja
{% 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`:
```python
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`:
```python
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, Form
```
and extend the `backend.portal_auth` import to add the new helpers:
```python
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):
```python
@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**

```bash
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`:
```python
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**

```bash
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`:
```python
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`):
```python
@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**

```bash
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:
```html
    <!-- 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:
```html
<!-- 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**

```bash
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**

```bash
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`:
```python
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:
```python
@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**

```bash
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**

```bash
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"
```

---

## Task 10: Harden the session cookie (Secure flag, env-driven)

**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:
```python
# 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`:
```python
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`:
```python
    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**

```bash
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:
```markdown
### 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**

```bash
docker exec terra-view-terra-view-1 python -m pytest tests/ -v
```
Expected: all green.

- [ ] **Step 4: Commit**

```bash
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.
