M3 web console: shell, screens, and the notification-channel API (#12, #13) - #96
Merged
Conversation
…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>
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
One query, one permission check, one place a behaviour change lands.
test_web_invariants.pyholds the line: no module undericeberg_api.webmay import the ORM or callselect/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 (theWeb*aliases wraprbac.require_roleand change only the failure shape — a redirect instead of JSON); and a handler's signature default is aQuery(...)marker, so every argument is passed explicitly.Screens
The rail's Administration group renders only for admins because every route behind it is admin-only. That is a courtesy;
rbac.require_roleis the control, and a viewer who types/enginesgets 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:@alpinejs/cspbuild (interprets directives, nevernew Function());static/js/tags.js, loaded before Alpine so itsalpine:initlistener exists when Alpine fires the event;<script type="application/json">islands rather than attributes — the CSP expression parser cannot decode the\uXXXXescapes Jinja's|tojsonemits, so a page title with an apostrophe would arrive corrupted.Plus
frame-ancestors 'none',nosniff,Referrer-Policy, a deny-allPermissions-Policy,Cross-Origin-Opener-Policy, and HSTS in production. FastAPI's/docsis 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
NotificationChannelmodel already existed, anddocs/api.mdalready 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.pypins 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 writesassets.lock.jsonwith asha384per file thatbase.htmlemits asintegrity=. 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 automaticprefers-color-schemedark 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.yamlnow excludes them: theend-of-file-fixerhook was rewritinghtmx.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()iniceberg_core.config, so the browser security headers can key HSTS offenvironmentwithout requiring a database URL — whichcreate_app()with no settings does not have..dockerignoreexists 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 checkis 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 andhx-headerssurvive 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 anonclick=-style handler, and the CSP never grows'unsafe-inline'for scripts.Docs: new
docs/web.md, plus updates toapi.md,security.md,backlog.md,ARCHITECTURE.md,README.md, andCLAUDE.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