ADR-0068 — Admin v3: Next-Generation Admin & Block Editor
Status: Accepted Date: 2026-06-20 Deciders: VayuPress Maintainers
Context
Admin v2 (ADR-0065) delivered a CSP-clean, editor-first redesign on a vendored
stack. The next goal is an admin experience that surpasses Ghost, WordPress, and
Substack in design quality, feature depth, and security — while keeping every
VayuPress invariant: a sovereign single binary, zero CDN dependencies, and a
strict Content-Security-Policy with no unsafe-eval and no unsafe-inline.
A naïve path (ship a React/Tailwind SPA, add a rich-text editor library, pull a QR/2FA package) would violate those invariants and balloon the dependency surface. We need a ground-up design system, a block editor, a media library, memberships, and two-factor auth — all server-rendered or vanilla-JS, all same-origin, all sanitised server-side.
Decision
Build Admin v3 at /admin/v3, mounted in parallel with /admin/v2 so the
work is non-breaking and can be cut over gradually.
- Design system. A single hand-authored
admin-v3.cssscoped to a.vp-v3body class, driven by CSS custom properties, with dark/light/auto themes via[data-theme]. No inline<style>and nostyle="…"attributes anywhere — width/spacing/colour are utility/component classes sostyle-src 'self'holds. Self-hosted fonts underfont-src 'self'with system fallbacks. - Interactivity. Vanilla JS in same-origin files; the only inline
<script>is the nonce-gated bootstrap. All DOM mutation usescreateElement/textContent— neverinnerHTMLwith untrusted data. The one place rendered HTML is injected (editor live preview) consumes server-sanitised output and is additionally re-sanitised with the vendored DOMPurify. - Block editor. The canonical document is a JSON array of typed blocks stored
in
articles.blocks_json(migration 025).articles.contentremains the authoritative rendered HTML, kept in sync on save, so every reader, feed, and search path is unchanged.internal/blockrenderconverts blocks → HTML by HTML-escaping every field and running the result through a bluemonday UGC policy. There is no raw-HTML block escape hatch. A safe migration rule opens the block editor only for articles that already carry a block document or are empty drafts; legacy content and brand-new posts keep the lossless v2 editor, so a stray save can never wipe existing content. - Media library. Reuses the hardened upload backend (content-addressed,
type-allowlisted, SVG-refused, CSRF-protected). The listing endpoint only ever
surfaces server-generated content-addressed names (
safeMediaName), so there is no path-traversal or info-leak vector. - Two-factor auth.
internal/totpimplements RFC 6238 (over RFC 4226) using only the standard library — no third-party dependency — validated against the official RFC test vectors. Enrolment is a two-step ceremony (store secret disabled → verify code → enable) so an abandoned setup never locks an operator out. Sign-in enforcement is wired into both the v2 and v3 login handlers so an enrolled account cannot bypass 2FA via the older surface. QR rendering is intentionally deferred; manual key entry (every authenticator app supports it) avoids adding a QR-encoder dependency. - Intelligence. Native SEO readiness and privacy-preserving analytics are computed only from the local DB and on-disk cache — no third-party services, consistent with VayuPress's zero-telemetry stance.
The work shipped in seven phases; each phase was independently built, tested
(go test, -tags integration, staticcheck, gofmt), and only advanced once
CI was green.
Consequences
- v2 and v3 coexist; operators can adopt v3 incrementally and v2 remains the fallback for legacy Markdown/HTML editing until a later cutover.
- New schema: migrations 025 (
articles.blocks_json) and 026 (users.totp_secret,users.totp_enabled). Both are additive with safe defaults. - The block document and the rendered HTML are stored together;
blockrenderis the single trusted boundary for block → HTML and must remain the only path. - Zero new third-party dependencies were added for 2FA, QR, or the editor.
Alternatives considered
- SPA + component library / rich-text editor dependency — rejected: violates zero-CDN/sovereign posture and the strict CSP, and expands the supply chain.
- Replacing the v2 editor outright — rejected: risks data loss for legacy articles; the state-based editor selection is safer.
- Third-party TOTP/QR libraries — rejected: the stdlib implementation is small, auditable, and dependency-free.