agnes-the-ai-analyst/docs/archive/superpowers/plans/2026-04-21-issues-14-and-10.md
ZdenekSrotyr a48524509a
docs: consolidate and de-clutter the documentation tree (#306)
CLAUDE.md rewritten (708 -> ~320 lines): four overlapping release
sections collapsed to one, stale v1->v35 schema history dropped (it
lives in CHANGELOG), marketplace endpoint internals and verbose
process sections moved out or tightened.

New focused docs:
- docs/RELEASING.md - release process, deploy workflows, CI quirks
  (RELEASE_TEMPLATE.md folded in as an appendix)
- docs/marketplace.md - marketplace ingestion + re-serving internals
- docs/README.md - documentation index by audience, linked from
  README.md and CLAUDE.md

Archived under docs/archive/: docs/superpowers/ (52 historical
planning artifacts), HACKATHON.md, pd-ps-comments.md,
security-audit-2026-04.md, future/NOTIFICATIONS.md.

Removed the docs/auto-install.md stub. Fixed dangling links in
connectors/jira/README.md and dev_docs/README.md, repointed
code/doc references to archived paths.
2026-05-14 18:54:22 +00:00

20 KiB
Raw Blame History

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 694750 (Task 7.1). Copy that script verbatim.

  • Step 1.1: Create the feature branch
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:

#!/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
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:


## 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
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
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):

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
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 914):

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:

    @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
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
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:

@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:

@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:

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):

    <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:

@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:

    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
pytest tests/test_web_ui.py::TestUnauthenticatedHtmlRedirects -v

Expected: all five tests PASS.

  • Step 2.12: Run the broader suite to check for regressions
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
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
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

  • 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.
  • No placeholders: every step has the concrete file path, code block, and command. No TBD/TODO.
  • 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).