Getting started
diff --git a/docs/setup-banner.md b/docs/setup-banner.md
deleted file mode 100644
index 4aa11f7..0000000
--- a/docs/setup-banner.md
+++ /dev/null
@@ -1,94 +0,0 @@
-# Setup page banner
-
-The setup banner is a block of HTML (or plain text) shown **above** the
-auto-generated bootstrap commands on the `/setup` page. Use it for
-org-specific operational notes that analysts need before they install the
-client: VPN requirements, support channel, data-classification policy,
-platform prerequisites, etc.
-
-The banner is empty by default — no content is shown until an admin sets one.
-
-## How to edit
-
-- **Admin UI:** `/admin/setup-banner` — split-pane editor with a placeholder
- cheatsheet and a live HTML preview. Click **Save banner** to persist,
- **Remove banner** to clear.
-- **REST API:**
- - `GET /api/admin/setup-banner` — returns `{content, updated_at, updated_by}`.
- `content` is `null` when no banner is set.
- - `PUT /api/admin/setup-banner` with body `{"content": "..."}` — validates
- Jinja2 syntax and stores the banner.
- - `DELETE /api/admin/setup-banner` — clears the banner; `/setup` shows no
- banner until one is set again.
- - `POST /api/admin/setup-banner/preview` with body `{"content": "..."}` —
- renders arbitrary content against the calling admin's context without
- persisting. Backs the editor's live preview.
-
-The banner lives in `system.duckdb` (table `setup_banner`, singleton row id=1).
-
-## Available placeholders
-
-| Placeholder | Type | Notes |
-|---|---|---|
-| `instance.name` | string | `instance.name` in `instance.yaml` |
-| `instance.subtitle` | string | `instance.subtitle` in `instance.yaml` |
-| `server.url` | string | full origin of the Agnes server |
-| `server.hostname` | string | host part only (no port or path) |
-| `user.email` | string | logged-in user, or `null` for anonymous visitors |
-| `user.name` | string | logged-in user display name |
-| `user.is_admin` | bool | `true` when the visitor is in the Admin group |
-| `now` | datetime (UTC, tz-aware) | server time at render |
-| `today` | string (`YYYY-MM-DD`) | server date at render |
-
-> **`user` may be `null`** — `/setup` is partly public (anonymous visitors
-> get the install one-liner). Always guard user-specific placeholders:
->
-> ```jinja2
-> {% if user %}Welcome back, {{ user.name }}!{% endif %}
-> ```
-
-## Autoescape semantics
-
-The Jinja2 environment runs with `autoescape=True`, which means template
-**variable output** (`{{ ... }}`) is HTML-escaped automatically. Literal HTML
-in the template source is passed through unchanged — that is how the banner
-outputs `` tags, ``, etc.
-
-To output a literal `<` or `&` from a variable, use the `| safe` filter only
-when you are certain the value is trusted:
-
-```jinja2
-{# Safe — admin-authored constant: #}
-{{ "VPN required" | safe }}
-
-{# Dangerous — never pipe user-controlled values through | safe: #}
-{{ user.name | safe }} {# do NOT do this #}
-```
-
-## Security note
-
-Admin-authored banner content is rendered for **all `/setup` visitors**,
-including anonymous users. As a defense-in-depth measure, inline ``` blocks (case-insensitive, including unclosed).
- - ```` blocks.
- - ``on*=`` event-handler attributes (e.g. onclick, onload, onerror).
- - ``javascript:`` and ``data:`` URI schemes in href/src attributes.
- """
- html = _RE_SCRIPT.sub("", html)
- html = _RE_IFRAME.sub("", html)
- html = _RE_ON_ATTR.sub("", html)
- html = _RE_JS_URI.sub(lambda m: m.group(1) + "#" + m.group(2), html)
- return html
-
-
-def build_setup_banner_context(
- *,
- user: Optional[dict],
- server_url: str,
-) -> dict[str, Any]:
- """Compose the Jinja2 render context for the setup banner.
-
- ``user`` may be None on the anonymous path of /setup (the page is partly
- public — anonymous visitors get the curl-install one-liner). Templates
- must guard for that with ``{% if user %}``.
- """
- parsed = urlparse(server_url)
- return {
- "instance": {
- "name": get_instance_name(),
- "subtitle": get_instance_subtitle(),
- },
- "server": {
- "url": server_url,
- "hostname": parsed.hostname or "",
- },
- "user": (
- {
- "id": user.get("id", ""),
- "email": user.get("email", ""),
- "name": user.get("name") or "",
- "is_admin": bool(user.get("is_admin")),
- }
- if user
- else None
- ),
- "now": datetime.now(timezone.utc),
- "today": date.today().isoformat(),
- }
-
-
-def render_setup_banner(
- conn: duckdb.DuckDBPyConnection,
- *,
- user: Optional[dict],
- server_url: str,
-) -> str:
- """Render the banner. Returns "" when no override is set or render fails.
-
- Render failures are swallowed (logged) — a broken admin banner must NOT
- break /setup for analysts. The /admin/setup-banner editor catches Jinja
- errors at PUT time anyway, so this is defense-in-depth.
- """
- row = SetupBannerRepository(conn).get()
- source = row.get("content")
- if not source:
- return ""
- env = Environment(undefined=StrictUndefined, autoescape=True)
- try:
- template = env.from_string(source)
- rendered = template.render(**build_setup_banner_context(user=user, server_url=server_url))
- return _sanitize_banner_html(rendered)
- except TemplateError:
- _logger.warning(
- "setup_banner render failed; returning empty banner. "
- "Admin can fix at /admin/setup-banner."
- )
- return ""
diff --git a/tests/test_setup_banner_api.py b/tests/test_setup_banner_api.py
deleted file mode 100644
index 9c71571..0000000
--- a/tests/test_setup_banner_api.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""End-to-end tests for /api/admin/setup-banner endpoints."""
-
-import duckdb
-
-from src.db import _ensure_schema
-from src.setup_banner import build_setup_banner_context
-
-
-def _auth(token: str) -> dict[str, str]:
- return {"Authorization": f"Bearer {token}"}
-
-
-def test_admin_can_set_and_clear_banner(seeded_app):
- c = seeded_app["client"]
- admin = _auth(seeded_app["admin_token"])
-
- # GET initial state
- r = c.get("/api/admin/setup-banner", headers=admin)
- assert r.status_code == 200
- body = r.json()
- assert body["content"] is None
-
- # PUT banner
- r = c.put(
- "/api/admin/setup-banner",
- json={"content": "VPN required before install.
"},
- headers=admin,
- )
- assert r.status_code == 200
-
- # GET shows new content
- r = c.get("/api/admin/setup-banner", headers=admin)
- assert r.json()["content"] == "VPN required before install.
"
- assert r.json()["updated_by"] is not None
-
- # DELETE = clear
- r = c.delete("/api/admin/setup-banner", headers=admin)
- assert r.status_code == 204
-
- r = c.get("/api/admin/setup-banner", headers=admin)
- assert r.json()["content"] is None
-
-
-def test_non_admin_cannot_edit_banner(seeded_app):
- c = seeded_app["client"]
- analyst = _auth(seeded_app["analyst_token"])
- r = c.put("/api/admin/setup-banner", json={"content": "x
"}, headers=analyst)
- assert r.status_code == 403
-
-
-def test_put_rejects_invalid_jinja2(seeded_app):
- c = seeded_app["client"]
- admin = _auth(seeded_app["admin_token"])
- r = c.put(
- "/api/admin/setup-banner",
- json={"content": "{% for x in y %}"}, # unclosed loop
- headers=admin,
- )
- assert r.status_code == 400
- assert "invalid" in r.json()["detail"].lower()
-
-
-def test_put_rejects_undefined_placeholder(seeded_app):
- """Templates that reference unknown placeholders must be rejected at PUT
- time so the admin sees the error immediately."""
- c = seeded_app["client"]
- admin = _auth(seeded_app["admin_token"])
- r = c.put(
- "/api/admin/setup-banner",
- json={"content": "Hello {{ user.emial }}"}, # typo
- headers=admin,
- )
- assert r.status_code == 400
- assert "emial" in r.json()["detail"] or "undefined" in r.json()["detail"].lower()
-
-
-def test_preview_renders_arbitrary_content(seeded_app):
- c = seeded_app["client"]
- admin = _auth(seeded_app["admin_token"])
- r = c.post(
- "/api/admin/setup-banner/preview",
- json={"content": "Hello {{ user.email }}"},
- headers=admin,
- )
- assert r.status_code == 200
- # autoescape=True: rendered content must contain the escaped or literal email
- assert "admin@test.com" in r.json()["content"]
-
-
-def test_preview_requires_admin(seeded_app):
- c = seeded_app["client"]
- analyst = _auth(seeded_app["analyst_token"])
- r = c.post(
- "/api/admin/setup-banner/preview",
- json={"content": "x
"},
- headers=analyst,
- )
- assert r.status_code == 403
-
-
-def test_preview_rejects_invalid_template(seeded_app):
- c = seeded_app["client"]
- admin = _auth(seeded_app["admin_token"])
- r = c.post(
- "/api/admin/setup-banner/preview",
- json={"content": "{% for x in y %}"},
- headers=admin,
- )
- assert r.status_code == 400
-
-
-def test_validation_stub_matches_build_context_shape(seeded_app, tmp_path, monkeypatch):
- """If build_setup_banner_context grows new keys, _VALIDATION_STUB_CONTEXT
- must too — otherwise admins can save templates referencing keys the PUT
- validator accepts but the live render rejects."""
- from app.api.setup_banner import _VALIDATION_STUB_CONTEXT
-
- monkeypatch.setenv("DATA_DIR", str(tmp_path))
- db_path = tmp_path / "system.duckdb"
- conn = duckdb.connect(str(db_path))
- _ensure_schema(conn)
- conn.close()
-
- user = {"id": "u1", "email": "admin@test.com", "name": "Admin", "is_admin": True}
- real_ctx = build_setup_banner_context(user=user, server_url="https://example.com")
-
- # Top-level keys must match (stub has user=dict, real has user=dict when logged in)
- assert set(_VALIDATION_STUB_CONTEXT.keys()) == set(real_ctx.keys()), (
- f"_VALIDATION_STUB_CONTEXT top-level keys differ from build_setup_banner_context output. "
- f"Stub has: {set(_VALIDATION_STUB_CONTEXT.keys())}, "
- f"real has: {set(real_ctx.keys())}"
- )
-
- # One level deep for nested dicts
- for key in ("instance", "server", "user"):
- if isinstance(real_ctx.get(key), dict):
- assert set(_VALIDATION_STUB_CONTEXT[key].keys()) == set(real_ctx[key].keys()), (
- f"_VALIDATION_STUB_CONTEXT[{key!r}] drifted from build_setup_banner_context output"
- )
diff --git a/tests/test_setup_banner_migration.py b/tests/test_setup_banner_migration.py
deleted file mode 100644
index 8b8b3d0..0000000
--- a/tests/test_setup_banner_migration.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""v21 → v22 migration: adds setup_banner singleton table."""
-
-from pathlib import Path
-
-import duckdb
-
-from src.db import SCHEMA_VERSION, _ensure_schema, get_schema_version
-
-
-def _open(path: Path) -> duckdb.DuckDBPyConnection:
- return duckdb.connect(str(path))
-
-
-def test_v22_creates_setup_banner_table(tmp_path):
- db_path = tmp_path / "system.duckdb"
- conn = _open(db_path)
- # Pretend we're on v21: run schema then roll version back.
- _ensure_schema(conn)
- conn.execute("UPDATE schema_version SET version = 21")
- conn.execute("DROP TABLE IF EXISTS setup_banner")
- conn.close()
-
- # Re-open: migration ladder runs.
- conn = _open(db_path)
- _ensure_schema(conn)
- assert get_schema_version(conn) == SCHEMA_VERSION
- # Singleton row must exist with NULL content (= no banner).
- rows = conn.execute(
- "SELECT id, content, updated_at, updated_by FROM setup_banner"
- ).fetchall()
- assert len(rows) == 1
- assert rows[0][0] == 1 # singleton id
- assert rows[0][1] is None # NULL = no banner
-
-
-def test_fresh_install_seeds_setup_banner(tmp_path):
- db_path = tmp_path / "system.duckdb"
- conn = _open(db_path)
- _ensure_schema(conn)
- assert get_schema_version(conn) == SCHEMA_VERSION
- rows = conn.execute("SELECT id, content FROM setup_banner").fetchall()
- assert len(rows) == 1
- assert rows[0][0] == 1
- assert rows[0][1] is None
-
-
-def test_welcome_template_unaffected_by_v22(tmp_path):
- """welcome_template table must still coexist after v22 migration."""
- db_path = tmp_path / "system.duckdb"
- conn = _open(db_path)
- _ensure_schema(conn)
- rows = conn.execute("SELECT id, content FROM welcome_template").fetchall()
- assert len(rows) == 1
- assert rows[0][0] == 1
diff --git a/tests/test_setup_banner_render.py b/tests/test_setup_banner_render.py
deleted file mode 100644
index e1dc571..0000000
--- a/tests/test_setup_banner_render.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""Unit tests for the setup-banner renderer module."""
-
-import duckdb
-import pytest
-
-from src.db import _ensure_schema
-from src.repositories.setup_banner import SetupBannerRepository
-from src.setup_banner import _sanitize_banner_html, build_setup_banner_context, render_setup_banner
-
-
-@pytest.fixture
-def conn(tmp_path, monkeypatch):
- monkeypatch.setenv("DATA_DIR", str(tmp_path))
- db_path = tmp_path / "system.duckdb"
- c = duckdb.connect(str(db_path))
- _ensure_schema(c)
- yield c
- c.close()
-
-
-def _user(email="alice@example.com"):
- return {"id": "u1", "email": email, "name": "Alice", "is_admin": False}
-
-
-def test_render_returns_empty_when_no_override(conn):
- out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
- assert out == ""
-
-
-def test_render_uses_override(conn):
- SetupBannerRepository(conn).set(
- "VPN: {{ server.hostname }}
", updated_by="admin@example.com"
- )
- out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
- # autoescape=True — rendered as HTML
- assert "example.com" in out
- assert "" in out
-
-
-def test_render_returns_empty_on_invalid_template_does_not_raise(conn):
- """A broken admin banner must not raise; it must return "" (defense-in-depth)."""
- SetupBannerRepository(conn).set(
- "{{ does_not_exist }}", updated_by="admin@example.com"
- )
- out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
- assert out == "" # swallowed, not raised
-
-
-def test_render_with_anonymous_user(conn):
- SetupBannerRepository(conn).set(
- "{% if user %}{{ user.email }}{% else %}anonymous{% endif %}",
- updated_by="admin@example.com",
- )
- out = render_setup_banner(conn, user=None, server_url="https://example.com")
- assert "anonymous" in out
-
-
-def test_context_exposes_documented_keys(conn):
- ctx = build_setup_banner_context(user=_user(), server_url="https://example.com")
- for top in ("instance", "server", "user", "now", "today"):
- assert top in ctx, f"missing top-level key: {top}"
- assert ctx["server"]["hostname"] == "example.com"
- assert ctx["user"]["email"] == "alice@example.com"
-
-
-def test_context_with_anonymous_user_returns_none(conn):
- ctx = build_setup_banner_context(user=None, server_url="https://example.com")
- assert ctx["user"] is None
-
-
-def test_autoescape_escapes_html_entities(conn):
- """autoescape=True must escape < > & in template variable output."""
- SetupBannerRepository(conn).set(
- "{{ server.hostname }}", updated_by="admin@example.com"
- )
- out = render_setup_banner(
- conn, user=_user(), server_url="https://example.com/"
- )
- # hostname won't contain < > but the render must succeed without injection
- assert out != ""
-
-
-# ── Sanitizer unit tests ─────────────────────────────────────────────────────
-
-def test_render_strips_script_tags(conn):
- """render_setup_banner must remove ',
- updated_by="admin@example.com",
- )
- out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
- assert "