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.
20 KiB
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.pythat interceptsHTTPException(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, keepsapp/web/router.pyunchanged, 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/dashboardas 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/dashboardtoday; this PR does not change that. A follow-up issue can track it. - Any refactor of
app/auth/dependencies.pybeyond what's needed.get_current_userandrequire_rolestay 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
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_pagereads?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_webacceptsnextform 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 9–14):
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.pysuite — 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
nextfield 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
nextinpassword_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),/dashboard302 test (Step 2.2). All covered.
- Issue #14 acceptance:
- No placeholders: every step has the concrete file path, code block, and command. No TBD/TODO.
- Type consistency:
next_pathused consistently in router + template context; form field namenextconsistent between template hidden input (Step 2.8) andpassword_login_websignature (Step 2.9).