# Issues #14 + #10 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:** Resolve two independent GitHub issues — add `scripts/switch-dev-vm.sh` helper for the hackathon (#14) and make unauthenticated HTML routes redirect to `/login` instead of returning raw JSON 401 (#10).

**Architecture:** Two independent changes on separate branches, shipped as two separate PRs.
- **#14** is a new standalone shell script plus a one-liner in `docs/QUICKSTART.md`. No app code touched. Blast radius = zero.
- **#10** adds a single FastAPI exception handler in `app/main.py` that intercepts `HTTPException(401)` for non-`/api/*` paths and redirects to `/login?next=<path>`. Implementation choice: path-scoped global handler (not per-route dep-swap) because it's deterministic, keeps `app/web/router.py` unchanged, and guarantees API routes under `/api/*` keep their existing JSON-401 contract. The `?next=` round-trip is honored only for the password web-login form (`/auth/password/login/web`) — the most common path. Google OAuth and email-link logins continue to land on `/dashboard` as today (documented follow-up, no regression).

**Tech Stack:** Bash, FastAPI, Starlette, pytest, Jinja2.

---

## Out of Scope

- Rewriting Google OAuth or email-magic-link flows to honor `?next=`. Those land on `/dashboard` today; this PR does not change that. A follow-up issue can track it.
- Any refactor of `app/auth/dependencies.py` beyond what's needed. `get_current_user` and `require_role` stay untouched.
- Any change to `/api/*` auth behavior. JSON 401 remains the contract for API callers.

---

## Task 1: Add `scripts/switch-dev-vm.sh` helper (Issue #14)

**Files:**
- Create: `scripts/switch-dev-vm.sh`
- Modify: `docs/QUICKSTART.md` (append a "Hackathon" section)

Reference implementation lives in `docs/superpowers/plans/2026-04-21-hackathon-dry-run.md` lines 694–750 (Task 7.1). Copy that script verbatim.

- [ ] **Step 1.1: Create the feature branch**

```bash
cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss"
git checkout main
git pull --ff-only
git checkout -b feature/switch-dev-vm-helper
```

- [ ] **Step 1.2: Create `scripts/switch-dev-vm.sh`**

Write the file at `scripts/switch-dev-vm.sh` with this exact content:

```bash
#!/usr/bin/env bash
# switch-dev-vm.sh — point the shared hackathon dev VM at the caller's branch image.
#
# Usage:
#   scripts/switch-dev-vm.sh <branch-slug>
#   scripts/switch-dev-vm.sh hack-zs-metrics
#
# Prerequisite: your branch has been pushed and the release.yml workflow has completed,
# producing ghcr.io/keboola/agnes-the-ai-analyst:dev-<slug>.
#
# The slug is derived from your branch name by stripping the leading "feature/" and
# replacing non-alphanumeric chars with "-". For branch "feature/hack-zs-metrics" the slug
# is "hack-zs-metrics".
set -euo pipefail

if [ $# -ne 1 ]; then
  echo "Usage: $0 <branch-slug>" >&2
  echo "Example: $0 hack-zs-metrics" >&2
  exit 2
fi

SLUG="$1"
VM="agnes-dev"
ZONE="europe-west1-b"
TAG="dev-$SLUG"
IMAGE="ghcr.io/keboola/agnes-the-ai-analyst:$TAG"

echo "[1/4] Verifying $IMAGE exists on GHCR..."
docker manifest inspect "$IMAGE" > /dev/null || {
  echo "ERROR: $IMAGE not found on GHCR. Did your release.yml run finish?" >&2
  echo "Check: gh run list --branch feature/$SLUG --workflow release.yml" >&2
  exit 1
}

echo "[2/4] Updating AGNES_TAG on $VM to $TAG..."
gcloud compute ssh "$VM" --zone="$ZONE" --quiet --command "\
  sudo sed -i 's|^AGNES_TAG=.*|AGNES_TAG=$TAG|' /data/.env && \
  sudo grep -E '^AGNES_TAG=' /data/.env"

echo "[3/4] Triggering auto-upgrade..."
gcloud compute ssh "$VM" --zone="$ZONE" --quiet --command \
  "sudo /usr/local/bin/agnes-auto-upgrade.sh 2>&1 | tail -10"

echo "[4/4] Waiting for app to become healthy..."
for i in $(seq 1 30); do
  STATUS=$(curl -s --max-time 5 http://<dev-vm-ip>:8000/api/health | python3 -c 'import sys,json; print(json.load(sys.stdin).get("status","down"))' 2>/dev/null || echo down)
  echo "  [$i/30] status=$STATUS"
  if [ "$STATUS" = "healthy" ] || [ "$STATUS" = "degraded" ]; then
    echo "OK — agnes-dev now running $TAG. Open http://<dev-vm-ip>:8000"
    exit 0
  fi
  sleep 3
done
echo "ERROR: agnes-dev did not become healthy in 90s. SSH in and check: docker compose logs" >&2
exit 1
```

- [ ] **Step 1.3: Make executable and syntax-check**

```bash
chmod +x scripts/switch-dev-vm.sh
bash -n scripts/switch-dev-vm.sh
```

Expected: `bash -n` prints nothing and exits 0.

- [ ] **Step 1.4: Append a Hackathon section to `docs/QUICKSTART.md`**

At the end of `docs/QUICKSTART.md`, append:

```markdown

## Hackathon: switch the shared dev VM to your branch

During the hackathon the shared VM `agnes-dev` can be pointed at any per-branch image built by `release.yml` (`ghcr.io/keboola/agnes-the-ai-analyst:dev-<slug>`).

```bash
# Slug = branch name without "feature/" prefix, non-alphanumeric → "-"
scripts/switch-dev-vm.sh hack-zs-metrics
```

The script verifies the image exists on GHCR, updates `AGNES_TAG` in `/data/.env` on the VM, triggers the auto-upgrade, and polls `/api/health` for up to 90 s. Requires `gcloud`, `docker`, `curl`, and `python3`.
```

- [ ] **Step 1.5: Commit**

```bash
git add scripts/switch-dev-vm.sh docs/QUICKSTART.md
git commit -m "chore: add switch-dev-vm.sh helper for hackathon (#14)"
```

- [ ] **Step 1.6: Push and open PR**

```bash
git push -u origin HEAD
gh pr create \
  --base main \
  --title "chore: add switch-dev-vm.sh helper for hackathon (#14)" \
  --body "$(cat <<'EOF'
## Summary

Adds `scripts/switch-dev-vm.sh` — one-shot helper for the hackathon that points `agnes-dev` at the caller's per-branch image and waits for the app to become healthy.

Script verbatim from `docs/superpowers/plans/2026-04-21-hackathon-dry-run.md` Task 7.1.

Closes #14.

## Test plan

- [ ] `bash -n scripts/switch-dev-vm.sh` passes
- [ ] Running against a non-existent tag exits non-zero before touching the VM
- [ ] Running against a real `dev-<slug>` tag leaves `agnes-dev` healthy within 90 s (verified manually during hackathon dry-run)
EOF
)"
```

Expected: PR URL printed. Do not merge — leave for user review.

---

## Task 2: HTML routes redirect to `/login` on missing auth (Issue #10)

**Files:**
- Modify: `app/main.py` (register the exception handler)
- Modify: `app/web/router.py:193-221` (`login_page` reads `?next=` from query and passes into template context)
- Modify: `app/web/templates/login_email.html` (add hidden `<input name="next">` to the password form)
- Modify: `app/auth/providers/password.py` (`password_login_web` accepts `next` form field and redirects to sanitized path)
- Modify: `tests/test_web_ui.py` (add regression tests)

**Approach:** A single `HTTPException` handler on the FastAPI app instance. When the raised status is `401` *and* the request path does not start with `/api/`, return `RedirectResponse("/login?next=<path>", 302)`. Otherwise fall through to Starlette's default JSON response. API routes are unaffected by path scoping.

The `?next=` round-trip is implemented only for the `password_login_web` path (most common). The login template receives the raw `next` query-string param and embeds it as a hidden form field. The web-login handler sanitizes (must start with `/`, must not start with `//`, must not contain a scheme) and redirects to that path on success.

- [ ] **Step 2.1: Create the feature branch**

```bash
cd "/Users/zdeneksrotyr/Library/Mobile Documents/com~apple~CloudDocs/Sources/VsCode/component_factory/tmp_oss"
git checkout main
git pull --ff-only
git checkout -b fix/web-auth-redirect-to-login
```

- [ ] **Step 2.2: Write the failing test for unauthenticated HTML redirect**

Append to `tests/test_web_ui.py` (inside the existing file, after `TestWebUISmoke` or as a new class at the bottom):

```python
class TestUnauthenticatedHtmlRedirects:
    def test_dashboard_unauthenticated_redirects_to_login(self, web_client):
        resp = web_client.get("/dashboard", follow_redirects=False)
        assert resp.status_code == 302
        assert resp.headers["location"].startswith("/login")
        assert "next=%2Fdashboard" in resp.headers["location"]

    def test_catalog_unauthenticated_redirects_to_login(self, web_client):
        resp = web_client.get("/catalog", follow_redirects=False)
        assert resp.status_code == 302
        assert resp.headers["location"].startswith("/login")
        assert "next=%2Fcatalog" in resp.headers["location"]

    def test_api_route_still_returns_json_401(self, web_client):
        # /api/sync/status requires auth; must keep JSON 401 (no redirect).
        resp = web_client.get("/api/sync/status", follow_redirects=False)
        assert resp.status_code == 401
        assert resp.headers["content-type"].startswith("application/json")
```

- [ ] **Step 2.3: Run the new tests — confirm they fail**

```bash
source .venv/bin/activate
pytest tests/test_web_ui.py::TestUnauthenticatedHtmlRedirects -v
```

Expected: `test_dashboard_unauthenticated_redirects_to_login` and `test_catalog_unauthenticated_redirects_to_login` FAIL with `assert 401 == 302`. `test_api_route_still_returns_json_401` passes already (baseline behavior).

- [ ] **Step 2.4: Register the exception handler in `app/main.py`**

Open `app/main.py` and add these imports at the top of the file (alongside existing imports near line 9–14):

```python
from urllib.parse import quote
from starlette.exceptions import HTTPException as StarletteHTTPException
from fastapi.responses import RedirectResponse
```

Then, inside `create_app()` after the routers are registered (after all `app.include_router(...)` calls but before `return app`), add:

```python
    @app.exception_handler(StarletteHTTPException)
    async def _html_auth_redirect_handler(request, exc: StarletteHTTPException):
        """Redirect unauthenticated HTML requests to /login; leave /api/* as JSON 401."""
        if exc.status_code == 401 and not request.url.path.startswith("/api/"):
            next_param = quote(request.url.path, safe="")
            return RedirectResponse(url=f"/login?next={next_param}", status_code=302)
        # Fall back to Starlette's default JSON handler
        from starlette.exceptions import HTTPException as _SE
        from fastapi.exception_handlers import http_exception_handler
        return await http_exception_handler(request, exc)
```

Note: registering a handler for `StarletteHTTPException` catches both FastAPI's `HTTPException` (which subclasses it) and any direct Starlette-raised one.

- [ ] **Step 2.5: Re-run the failing tests — confirm they now pass**

```bash
pytest tests/test_web_ui.py::TestUnauthenticatedHtmlRedirects -v
```

Expected: all three tests PASS.

- [ ] **Step 2.6: Run the full `test_web_ui.py` suite — confirm no regressions**

```bash
pytest tests/test_web_ui.py -v
```

Expected: all tests PASS (existing + new).

- [ ] **Step 2.7: Pass `?next=` into the login page context**

In `app/web/router.py`, in the `login_page` function (around line 193), read the query param. Replace the existing function body:

```python
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
    next_path = request.query_params.get("next", "")
    # Safety: only accept same-origin paths (must start with "/", must not start with "//").
    if not next_path.startswith("/") or next_path.startswith("//"):
        next_path = ""

    providers = []
    try:
        from app.auth.providers.google import is_available as google_available
        if google_available():
            providers.append({"name": "google", "display_name": "Google", "icon": "google"})
    except Exception:
        pass
    providers.append({"name": "password", "display_name": "Email & Password", "icon": "key"})
    try:
        from app.auth.providers.email import is_available as email_available
        if email_available():
            providers.append({"name": "email", "display_name": "Email Link", "icon": "mail"})
    except Exception:
        pass

    login_buttons = []
    for p in providers:
        if p["name"] == "google":
            login_buttons.append({"url": "/auth/google/login", "text": "Sign in with Google", "css_class": "btn-primary", "icon_html": ""})
        elif p["name"] == "password":
            login_buttons.append({"url": "/login/password", "text": "Sign in with Email & Password", "css_class": "btn-secondary", "icon_html": ""})
        elif p["name"] == "email":
            login_buttons.append({"url": "/login/email", "text": "Sign in with Email Link", "css_class": "btn-secondary", "icon_html": ""})

    ctx = _build_context(request, providers=providers, login_buttons=login_buttons, next_path=next_path)
    return templates.TemplateResponse(request, "login.html", ctx)
```

Also update `login_password_page` (around line 224) the same way — it renders `login_email.html` which contains the password form:

```python
@router.get("/login/password", response_class=HTMLResponse)
async def login_password_page(request: Request):
    next_path = request.query_params.get("next", "")
    if not next_path.startswith("/") or next_path.startswith("//"):
        next_path = ""
    google_ok = False
    try:
        from app.auth.providers.google import is_available as google_available
        google_ok = google_available()
    except Exception:
        pass
    ctx = _build_context(request, google_available=google_ok, next_path=next_path)
    return templates.TemplateResponse(request, "login_email.html", ctx)
```

- [ ] **Step 2.8: Add the hidden `next` field to the password login form**

First, inspect the current form markup to find the right insertion point:

```bash
grep -n "action=\"/auth/password/login/web\"\|<form" app/web/templates/login_email.html
```

Then open `app/web/templates/login_email.html` and, inside the `<form>` that POSTs to `/auth/password/login/web`, add **as the first child of the form** (right after the opening `<form ...>` tag):

```html
    <input type="hidden" name="next" value="{{ next_path|default('', true) }}">
```

If `login.html` also contains a direct-POST login form that hits `/auth/password/login/web`, add the same hidden input there too. (Grep first: `grep -n "/auth/password/login/web" app/web/templates/*.html`.)

- [ ] **Step 2.9: Honor `next` in `password_login_web`**

In `app/auth/providers/password.py`, replace the `password_login_web` function body so that the redirect target is derived from the `next` form field. The updated function:

```python
@router.post("/login/web")
async def password_login_web(
    email: str = Form(...),
    password: str = Form(""),
    next: str = Form(""),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    """Web form login — sets cookie and redirects to `next` (or /dashboard)."""
    repo = UserRepository(conn)
    user = repo.get_by_email(email)
    if not user or not user.get("password_hash"):
        return RedirectResponse(url="/login/password?error=invalid", status_code=302)

    try:
        ph = PasswordHasher()
        ph.verify(user["password_hash"], password)
    except (VerifyMismatchError, Exception):
        return RedirectResponse(url="/login/password?error=invalid", status_code=302)

    token = create_access_token(user["id"], user["email"], user["role"])
    use_secure = os.environ.get("DOMAIN", "") != ""

    # Sanitize next: must start with "/" and not with "//" (prevent open-redirect).
    target = next if (next.startswith("/") and not next.startswith("//")) else "/dashboard"
    response = RedirectResponse(url=target, status_code=302)
    response.set_cookie(
        key="access_token", value=token,
        httponly=True, max_age=86400, samesite="lax",
        secure=use_secure,
    )
    return response
```

- [ ] **Step 2.10: Add a test for the `?next=` round-trip**

Append to `TestUnauthenticatedHtmlRedirects` in `tests/test_web_ui.py`:

```python
    def test_password_login_honors_next(self, web_client, tmp_path):
        from argon2 import PasswordHasher
        from src.db import get_system_db
        from src.repositories.users import UserRepository
        password = "TestPass1!"
        conn = get_system_db()
        UserRepository(conn).create(
            id="u1", email="u1@test.com", name="U1", role="admin",
            password_hash=PasswordHasher().hash(password),
        )
        conn.close()
        resp = web_client.post(
            "/auth/password/login/web",
            data={"email": "u1@test.com", "password": password, "next": "/catalog"},
            follow_redirects=False,
        )
        assert resp.status_code == 302
        assert resp.headers["location"] == "/catalog"

    def test_password_login_rejects_open_redirect(self, web_client, tmp_path):
        from argon2 import PasswordHasher
        from src.db import get_system_db
        from src.repositories.users import UserRepository
        password = "TestPass1!"
        conn = get_system_db()
        UserRepository(conn).create(
            id="u2", email="u2@test.com", name="U2", role="admin",
            password_hash=PasswordHasher().hash(password),
        )
        conn.close()
        resp = web_client.post(
            "/auth/password/login/web",
            data={"email": "u2@test.com", "password": password, "next": "//evil.example/"},
            follow_redirects=False,
        )
        assert resp.status_code == 302
        assert resp.headers["location"] == "/dashboard"
```

- [ ] **Step 2.11: Run the new tests — confirm they pass**

```bash
pytest tests/test_web_ui.py::TestUnauthenticatedHtmlRedirects -v
```

Expected: all five tests PASS.

- [ ] **Step 2.12: Run the broader suite to check for regressions**

```bash
pytest tests/test_web_ui.py tests/test_auth_providers.py tests/test_access_control.py -v
```

Expected: all PASS. If any fail that previously passed, investigate before committing.

- [ ] **Step 2.13: Commit**

```bash
git add app/main.py app/web/router.py app/web/templates/login_email.html app/auth/providers/password.py tests/test_web_ui.py
# Only stage login.html if it was actually edited in step 2.8:
git status --short
git commit -m "fix: redirect unauthenticated HTML routes to /login (#10)"
```

- [ ] **Step 2.14: Push and open PR**

```bash
git push -u origin HEAD
gh pr create \
  --base main \
  --title "fix: redirect unauthenticated HTML routes to /login (#10)" \
  --body "$(cat <<'EOF'
## Summary

Unauthenticated access to HTML pages like `/dashboard` returned a raw JSON 401 body; it now redirects to `/login?next=<path>` and the password login form honors `next` on success.

Implementation: a single `StarletteHTTPException` handler registered on the FastAPI app. When status is `401` and the request path does not start with `/api/`, return `302 /login?next=<path>`. Otherwise fall through to Starlette's default JSON response, so `/api/*` routes keep their existing JSON 401 contract.

Closes #10.

## What this PR does

- `/dashboard`, `/catalog`, `/corporate-memory`, `/activity-center`, admin pages, etc. → 302 to `/login?next=<path>`
- `/api/*` routes unchanged — still return JSON 401
- Password web-login form carries `next` through as a hidden field and redirects there on success (sanitized: must start with `/`, must not start with `//`)

## Out of scope (follow-up)

Google OAuth and the email-magic-link provider still land on `/dashboard` after login regardless of `next`. No regression vs. today; tracked for a follow-up issue.

## Test plan

- [x] `pytest tests/test_web_ui.py -v` passes
- [x] New tests cover: HTML redirect, API route JSON 401 preserved, `next` honored, open-redirect rejected
- [ ] Manual: open `/dashboard` in a fresh browser → lands on `/login?next=%2Fdashboard`, sign in with email+password, end up on `/dashboard`
EOF
)"
```

Expected: PR URL printed. Do not merge — leave for user review.

---

## Self-Review

- [x] **Spec coverage:**
  - Issue #14 acceptance: `chmod +x` + `bash -n` (Step 1.3), runs against good tag (PR test-plan manual), non-existent tag exits before VM (script logic lines), docs section (Step 1.4). All covered.
  - Issue #10 acceptance: unauthenticated HTML redirects (Step 2.2/2.3/2.5), `?next=` round-trip after sign-in (Step 2.10), API JSON 401 preserved (Step 2.2 third case), `/dashboard` 302 test (Step 2.2). All covered.
- [x] **No placeholders:** every step has the concrete file path, code block, and command. No TBD/TODO.
- [x] **Type consistency:** `next_path` used consistently in router + template context; form field name `next` consistent between template hidden input (Step 2.8) and `password_login_web` signature (Step 2.9).
