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.
490 lines
20 KiB
Markdown
490 lines
20 KiB
Markdown
# 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).
|