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

490 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**
```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 914):
```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).