ADR-0107: Self-Hosted HTMX in the VayuPress Binary
Status: Accepted
Date: 2026-07-03
Author: @johalputt
Extends: ADR-0099
Context
The VayuOS admin panel is a server-rendered surface with a strict
Content-Security-Policy — script-src 'self' 'nonce-…' and style-src 'self',
with no unsafe-inline, no unsafe-eval, and no third-party host (see
internal/render/csp.go). Every first-party script is served same-origin so it
satisfies script-src 'self', and vendored assets (DOMPurify, Pico CSS) are
compiled into the binary and mirrored to STATIC_DIR on boot so a binary-only
self-update never leaves stale or missing assets behind (ADR-0099).
We want light, declarative progressive enhancement for admin interactions —
live-refreshing a fragment, inline saves — without adopting a front-end build
step or a SPA framework. htmx fits: it drives AJAX and
partial DOM swaps from hx-* HTML attributes, with the server returning HTML
fragments. But htmx is normally loaded from a CDN, which would violate both the
sovereignty goal (no external requests) and the strict script-src 'self' CSP.
Two CSP details matter:
- htmx must be same-origin. A CDN
<script>would needscript-srcto admit an external host; we refuse to widen the policy. - By default htmx injects a
<style>element for its request indicators. Understyle-src 'self'(nounsafe-inline) that injection is blocked and logged as a violation.
Decision
Self-host htmx inside the binary, exactly like the other vendored assets, and serve it same-origin.
- Ship it in the binary.
static/js/htmx.min.js(htmx 2.0.4, 0BSD) is committed understatic/js/, so it is compiled in via the existing module-root//go:embed static→StaticFS(embedded_assets.go) and written toSTATIC_DIRon boot bysyncEmbeddedStatic(cmd/vayupress/static_sync.go). A binary-only self-update therefore carries htmx with it — no separate copy step, no stale-asset window (ADR-0099). The 0BSD license text is vendored atstatic/js/htmx.LICENSEand attributed inNOTICE. - Serve it same-origin.
handleHTMXJS(mirroringhandleThemeToggleJSincmd/vayupress/handlers_theme.go) serves the embedded bytes at/static/js/htmx.min.jswithContent-Type: application/javascript; charset=utf-8and a longCache-Control. The route is registered next to the other/static/js/*routes inregisterRoutes(cmd/vayupress/routes.go). Because it is same-origin it satisfiesscript-src 'self'with no nonce and no external host. - Keep style-src intact. The admin layout head carries
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>, so htmx never injects its indicator<style>andstyle-src 'self'holds unchanged. The layout loads htmx with<script src="/static/js/htmx.min.js?v=…" defer>, content-hash-versioned like every other admin asset. - Actually use it. The Members page "Recent activity" card gains a Refresh
button (
hx-get="/os/members/activity",hx-target="#os-activity-feed",hx-swap="innerHTML") served byhandleOSMembersActivityFragment, a session-guarded read-only GET returning just the feed fragment. Without htmx the card still renders the server-side feed, so the page degrades gracefully.
Consequences
- The admin panel gets declarative, framework-free progressive enhancement while
the strict CSP is unchanged: no
unsafe-inline, nounsafe-eval, no CDN, no external request. - The binary grows by htmx's minified size (~51 KB). Acceptable for a sovereign single-binary product and consistent with the "assets ship inside the binary" rule (ADR-0099); a self-update advances htmx atomically with everything else.
- Fragment endpoints follow the existing convention — session-guarded, read-only GETs need no CSRF token; writes stay CSRF-protected.
- Future admin interactions can be enhanced with
hx-*incrementally without any new dependency, build step, or CSP change.