Skip to content

M3 web console: shell, screens, and the notification-channel API (#12, #13) - #96

Merged
richardmhope merged 3 commits into
mainfrom
m3-web-ui
Jul 31, 2026
Merged

M3 web console: shell, screens, and the notification-channel API (#12, #13)#96
richardmhope merged 3 commits into
mainfrom
m3-web-ui

Conversation

@richardmhope

Copy link
Copy Markdown
Contributor

Builds both M3 epics — the HTMX + Alpine shell (#12) and the operational screens (#13) — plus the notification-channel CRUD API that screen #72 has to drive.

The shape

The console is server-rendered Jinja with HTMX for partial updates and Alpine for the interactions that genuinely need client state. It is a client of the API, not a second implementation of it: every web route resolves the RBAC dependency its API counterpart declares, then calls that route's handler as a function.

@router.get("/sources")
async def sources_page(request, viewer: CurrentViewer, user: WebViewer, db: SessionDep, ...):
    page = await api.list_sources(user=user, db=db, limit=DEFAULT_LIMIT, cursor=cursor)
    return render_page(request, "sources/list.html", viewer, {"sources": page.items, ...})

One query, one permission check, one place a behaviour change lands. test_web_invariants.py holds the line: no module under iceberg_api.web may import the ORM or call select/exec/commit/refresh/flush.

Two consequences the code and docs call out: calling a handler directly does not run its own Depends(require_role(...)), so a web route must declare the gate itself (the Web* aliases wrap rbac.require_role and change only the failure shape — a redirect instead of JSON); and a handler's signature default is a Query(...) marker, so every argument is passed explicitly.

Screens

Issue Screen
#53 Base layout, auth-gated nav, login shell, role-shaped rail
#54 HTMX partial conventions, Alpine CSP registry, documented reference partial
#55 Sources list + create/edit form + connectivity test
#56 Scan launch + live status (self-terminating HTMX polling)
#57 Findings table + filters + detail/triage with FindingEvent history
#58 Suppressions, schedules, engine-health dashboard (+ a rules view)
#72 Notification channels and user/role management

The rail's Administration group renders only for admins because every route behind it is admin-only. That is a courtesy; rbac.require_role is the control, and a viewer who types /engines gets a 403 page.

Security posture

script-src 'self' with no 'unsafe-inline' and no 'unsafe-eval'. A findings viewer renders page titles, resource paths, and redacted snippets that came out of somebody else's Confluence, which makes it the highest-value stored-XSS target in the deployment. That policy is affordable because the UI carries no inline JavaScript at all:

  • the self-hosted @alpinejs/csp build (interprets directives, never new Function());
  • every component registered in static/js/tags.js, loaded before Alpine so its alpine:init listener exists when Alpine fires the event;
  • server data through <script type="application/json"> islands rather than attributes — the CSP expression parser cannot decode the \uXXXX escapes Jinja's |tojson emits, so a page title with an apostrophe would arrive corrupted.

Plus frame-ancestors 'none', nosniff, Referrer-Policy, a deny-all Permissions-Policy, Cross-Origin-Opener-Policy, and HSTS in production. FastAPI's /docs is exempted by path — it loads Swagger UI from a CDN and bootstraps it inline, and a policy loose enough to run it would be no policy.

No credential is ever rendered: the source form has none to echo, the channels screen shows sealed/none, and an engine token appears once, in the response that minted it.

Notification channels (#72)

The screen needed routes to drive, the NotificationChannel model already existed, and docs/api.md already specified the paths — so the CRUD landed here. Dispatch stays M4. Admin-only, audited with the destination it points at, and channel secrets are sealed through the secret store and never returned. Header names that carry authentication (Authorization, Proxy-Authorization, Cookie) are refused on write: config is stored as plain JSON, so a bearer token in one would be precisely the finding this product reports in other people's systems.

Assets, and why there is no Tailwind

web/vendor_assets.py pins exact versions, pulls them from npm registry tarballs (not a CDN — a third party in the supply chain, unreachable from a locked build network), and writes assets.lock.json with a sha384 per file that base.html emits as integrity=. A test verifies the committed bytes against the lock offline. Fonts are self-hosted woff2, so the console works air-gapped.

The design system is hand-authored plain CSS carrying the Iceberg family's token block byte-identical to iceberg/IcebergCM, with the automatic prefers-color-scheme dark variant. Tailwind would add a 110 MB standalone binary, a Docker build stage, and a CI verification step in exchange for layout utilities that are forty lines here — and every component would still be hand-authored, because that is how the shared design system works. Adding it later is additive.

Because those files are byte-pinned, .pre-commit-config.yaml now excludes them: the end-of-file-fixer hook was rewriting htmx.min.js, which silently invalidates its integrity attribute and makes the browser block the script with a diff that looks like nothing happened.

Also

  • get_core_settings() in iceberg_core.config, so the browser security headers can key HSTS off environment without requiring a database URL — which create_app() with no settings does not have.
  • A deploy invariant that the api image ships the console: .dockerignore exists to keep secrets out of the layer, and a pattern broad enough to also drop the templates would produce an API that boots, answers /healthz, and renders an unstyled page with no JavaScript.

Verification

make check is green: ruff, mypy across 175 files, 985 tests (was 866). Beyond the unit tests, every screen was rendered against a live uvicorn process with a real session cookie — all ten return 200, every referenced asset serves, and the CSP and hx-headers survive the round trip intact.

New invariant tests worth knowing about:

  • test_web_invariants.py — the ORM boundary, CSRF on every mutating browser route, no HTML endpoint in the OpenAPI document, asset SRI, and the shared design tokens.
  • test_web_shell.py — no template carries an inline <script>/<style> or an onclick=-style handler, and the CSP never grows 'unsafe-inline' for scripts.

Docs: new docs/web.md, plus updates to api.md, security.md, backlog.md, ARCHITECTURE.md, README.md, and CLAUDE.md.

Refs ADR 0002, 0004, 0005, 0008, 0009.

Closes #12
Closes #13
Closes #53
Closes #54
Closes #55
Closes #56
Closes #57
Closes #58
Closes #72

🤖 Generated with Claude Code

richardmhope and others added 3 commits July 31, 2026 10:57
…12, #13)

The console is server-rendered Jinja with HTMX for partial updates and Alpine
for the interactions that need client state. It is a *client of the API, not a
second implementation of it*: every web route resolves the RBAC dependency its
API counterpart declares and then calls that route's handler as a function, so
there is one query, one permission check, and one place a behaviour change
lands. `test_web_invariants.py` holds that boundary — no module under
`iceberg_api.web` may import the ORM or build a query.

Screens (#53#58, #72): overview queue; findings table with explicit URL
filters, detail, and triage with its FindingEvent history; scan launch and live
status; sources with the connectivity check; suppressions; schedules; the engine
fleet; notification channels; users and roles. The rail's Administration group
renders only for admins because every route behind it is admin-only — a
courtesy, with `rbac.require_role` as the control.

Notification-channel CRUD (#72) landed here rather than in M4: the screen needs
routes to drive, the model already existed, and docs/api.md already specified
the paths. Dispatch stays M4. Channel secrets are sealed through the secret
store and never returned, and the header names that carry authentication are
refused on write — config is plain JSON, so a bearer token in one would be
exactly the finding this product reports in other people's systems.

The policy is `script-src 'self'` with no 'unsafe-inline' and no 'unsafe-eval'.
A findings viewer renders page titles, resource paths, and redacted snippets
that came out of somebody else's Confluence, which makes it the highest-value
stored-XSS target here. That is affordable because the UI carries no inline
JavaScript at all: the self-hosted @alpinejs/csp build, every component
registered in static/js/tags.js, and server data through JSON islands rather
than attributes (the CSP expression parser cannot decode the \uXXXX escapes
Jinja's |tojson emits). Assets are vendored from npm registry tarballs with SRI
in assets.lock.json and verified against the committed bytes offline.

No CSS build step: the design system is hand-authored plain CSS carrying the
Iceberg family's token block byte-identical to iceberg/IcebergCM, with the
automatic prefers-color-scheme dark variant. Tailwind would add a 110 MB binary,
a Docker stage, and a CI check in exchange for utilities that are forty lines
here — and every component would still be hand-authored.

Adds `get_core_settings()` so the browser security headers can key HSTS off
`environment` without requiring a database URL, which `create_app()` with no
settings does not have.

Refs ADR 0002, 0004, 0005, 0008, 0009.

Closes #12
Closes #13
Closes #53
Closes #54
Closes #55
Closes #56
Closes #57
Closes #58
Closes #72

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`packages/detect/tests/test_signals.py` needs a token entropic enough for
`generic-api-key` to match, because what it asserts is that `oauth` clipped by a
window edge does not count as the keyword `auth` — the fixture and the finding
have the same cause. The secret-scan job scans all of history, so the string is
unreachable by editing the file today; e6b1317 already contains it.

Pre-existing on main rather than introduced here, but it fails every PR until it
is allowlisted. Keyed on the literal, per this file's own rule, so the file stays
scanned for everything else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The console reuses a design system that lives in three other repositories, and
until now that was only visible to whoever built it. A future change to a colour,
the Alpine pin, or the rail chrome should go back to the same source rather than
be re-derived, so docs/web.md § Provenance names each artifact and its canonical
home: IcebergCM's app.css for the tokens, iceberg for the shell vocabulary, the
@alpinejs/csp build and the woff2 set (both byte-identical), ~/Projects/.github
for the brand marks, and the `iceberg-frontend` skill for the rules they satisfy.

Also records the two deliberate divergences from iceberg — no Tailwind, and an
automatic dark variant — so neither reads as drift later.

The token test now says where its expected values came from. It stays pinned
rather than reading a sibling checkout, because CI has none: its job is to fail
when this repo drifts, and the fix is to go back to that file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@richardmhope
richardmhope merged commit 5727de0 into main Jul 31, 2026
3 checks passed
@richardmhope
richardmhope deleted the m3-web-ui branch July 31, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment