feat: HTML page format for view-only visualizations#16
Conversation
Brainstorm output for the planned HTML page format alongside A2UI. View-only, no JS, sandbox+CSP+sanitizer defense-in-depth. Two MCP tools (show_ui, show_html). No subdomain isolation; no-JS is a structural constraint enforced by CI.
Step-by-step plan covering 16 tasks across 3 commits (API+sanitizer, MCP tools, renderer+skill+docs) + quality gate + PR. Each task has bite-sized TDD steps with the actual code.
POST /new accepts an optional { format: "a2ui" | "html", spec } body.
Default remains "a2ui" — every existing client keeps working unchanged.
HTML payloads up to 1 MB are sanitized server-side (DOMPurify+jsdom via
isomorphic-dompurify) before storage, then echoed in GET /:id and
GET /:id/result so renderers and tools can route. POST /:id/result on
an HTML page returns 400 invalid_for_format — HTML is view-only.
Schema: new `format` column on `pages`, NOT NULL DEFAULT 'a2ui' with
CHECK ('a2ui','html'). Added in init() with an idempotent ALTER TABLE
for existing deployments.
Three defensive layers for HTML: sanitize -> CSP (renderer) -> iframe
sandbox (renderer). This commit lands the sanitizer; renderer work in
a follow-up commit on the same branch.
Spec: docs/superpowers/specs/2026-05-15-html-format-design.md
…haustiveness)
- sanitize.ts: wrap DOMPurify hook lifecycle in try/finally so hooks always
clear even on mid-sanitize exceptions; add comment noting the function must
remain synchronous because hook state is module-global.
- db.test.ts: move the closed-union assertion for PageFormat to a top-level
type-only check; the prior runtime `expect('pdf').toBe('pdf')` was
tautological and obscured the real (compile-time) verification.
- sanitize.test.ts: add five test cases the spec lists explicitly — strip
data:application/javascript URLs, <applet>, <frame>, <frameset>, and onload.
All pass against the current sanitizer config; documents the contract.
- app.ts: rewrite the format guard from `!== 'a2ui'` to `=== 'html'` plus a
TypeScript exhaustiveness check on the residual type, so adding a new
PageFormat variant fails to typecheck and forces explicit handling.
- app.ts: rename structured log keys `removedTags`/`removedAttrs` to
`sanitizer_removed_tags`/`sanitizer_removed_attrs` to match the codebase's
snake_case convention for log fields.
- mcp/tools.ts: add TODO(phase-2) marker near optional `format` on
CheckResultOutcome.
- app.test.ts: replace `expect([400, 413]).toContain(res.status)` with the
explicit 413 assertion (bodyLimit middleware fires first).
show_html({ html }) creates an HTML page (view-only, sandbox-rendered).
The model gets two clearly distinct tools — show_ui to ask, show_html
to show — instead of a single tool with a format flag. Sharpens
show_ui's description to point at show_html for visualization use cases.
check_result now returns the page's format in structuredContent so an
agent that polls an HTML page (against guidance) at least gets the
explicit "stop polling" signal.
Both the stdio adapter (apps/mcp/server.ts) and the in-process HTTP
MCP share the tool definitions through apps/api/mcp/tools.ts —
description sync is automatic.
Spec: docs/superpowers/specs/2026-05-15-html-format-design.md
… empty-output) Test depth: tools.test.ts now invokes the registered handlers — a show_html test asserts structuredContent + the "do not poll" guidance, and a check_result test asserts the HTML branch surfaces "stop polling". The test setup uses a makeOps(overrides) helper so each test can partially override PageOps without rewriting the full stub. E2E: http.test.ts gains an integration test that drives show_html via the SDK client and asserts <script> is stripped before storage, plus a sibling test that the sanitized-empty path surfaces an MCP error. Log naming: the in-process MCP path's HTML log now uses sanitizer_removed_tags / sanitizer_removed_attrs — same snake_case shape as the REST path — so dashboards and log queries see a uniform field set. Dedup: pulled the sanitize+log+store ritual into a shared store.createHtmlPage helper. REST POST /new and the MCP show_html op both call it, eliminating drift between the two paths. Magic number: the 1_000_000 HTML payload cap moved to a new apps/api/limits.ts (HTML_MAX_BYTES). app.ts re-exports MAX_BODY_BYTES from it; schemas.ts and mcp/tools.ts both import it directly. Empty-output: SanitizedEmptyError is thrown when sanitize() strips the input to nothing. REST maps it to a 400 sanitized_empty response; the MCP transport surfaces the throw to the client as an error. Tests cover both paths. Nits: check_result's ternary-of-ternary is now an explicit if/else chain. The show_html constraints paragraph is split into newline- delimited bullets so the LLM sees clear paragraph breaks. Bundle: rebuilt apps/mcp/server.bundle.js so stdio MCP users get the updated tool descriptions and HTML_MAX_BYTES wiring.
The renderer routes by the new `format` field. A2UI pages keep the existing Lit/A2UI path unchanged. HTML pages render inside an iframe with sandbox="" (no allow-scripts, no allow-same-origin) and an opaque origin, with a strict CSP meta tag and noindex/no-referrer attached to the scaffold. The shell adds a thin chrome bar with an "AI-generated content" label and a mailto: Report link. Adds buildIframeCsp() to apps/web/csp.ts and a small html-renderer module with pure functions (buildScaffoldedHtml, createSandboxedIframe) so the security-critical paths are unit-tested in isolation. SKILL.md gets a "show vs ask" section and a worked HTML example; the front-matter description now covers both tools. OpenAPI spec documents the new format field on POST /new and the GET responses. Spec: docs/superpowers/specs/2026-05-15-html-format-design.md
Plan and spec markdown files contain TypeScript pseudo-code blocks
that prettier mis-parses into semantically-broken output (e.g.
`{ a: 'x' | 'y', b }` parses as a comma expression). The docs were
author-formatted and committed as such in 7a5c0c5 and a7cb4c7.
Add docs/superpowers/{plans,specs}/ to .prettierignore so the
format:check gate stays green without rewriting the docs.
Final-review feedback caught three OpenAPI drifts: - Error400.enum was missing `sanitized_empty` (returned by POST /new on HTML payloads stripped to empty by the sanitizer); added it plus a `format` field that surfaces on both `invalid_for_format` and `sanitized_empty` rejections. - Error413 still referenced the old 262 144-byte cap and lacked the `format` field that the API includes on every 413 response; updated to A2UI 256 000 / HTML 1 000 000 and added the `format` discriminator. - The 413 response description on POST /new restated the same caps with guidance on which one triggered. No behavior change — these match what the API already does in apps/api/app.ts (250-253, 224-232).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e2a3170cf5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // The bodyLimit middleware lets us inspect the parsed value here without | ||
| // double-paying for the read. | ||
| const serialized = JSON.stringify(spec ?? null); | ||
| if (serialized.length > A2UI_MAX_SPEC_BYTES) { |
There was a problem hiding this comment.
Enforce A2UI size cap using bytes, not string length
The 256 KB guard uses serialized.length, which counts UTF-16 code units rather than UTF-8 bytes. As a result, non-ASCII payloads can exceed the documented 256,000-byte A2UI cap while still passing this check (for example, multibyte characters like é undercount by ~2x). This regresses the intended limit enforcement and allows materially larger A2UI specs than the API contract advertises.
Useful? React with 👍 / 👎.
| // enforces it on the wire body so HTML payloads at the spec'd 1 MB ceiling | ||
| // pass through cleanly. The historical 256 KB cap for A2UI specs is enforced | ||
| // post-parse in newPageHandler. Re-exported for tests / external callers. | ||
| export const MAX_BODY_BYTES = HTML_MAX_BYTES; |
There was a problem hiding this comment.
Raise wire body limit above HTML spec max
Setting MAX_BODY_BYTES equal to HTML_MAX_BYTES makes the documented 1,000,000-byte HTML limit unattainable over REST, because the JSON envelope ({"format":"html","spec":"..."}) adds extra bytes before parsing. A spec that is exactly 1,000,000 bytes is rejected by bodyLimit with 413, so requests that should be valid per the declared cap fail in production.
Useful? React with 👍 / 👎.
CI started failing on `npm audit --audit-level=high` between the last main CI run (2026-05-11) and this PR's CI (2026-05-15). 17 high-sev advisories all traced back to protobufjs@8.0.1 via the @opentelemetry/* chain (GHSA-66ff-xgx4-vchm "code injection via bytes field defaults in generated toObject code", affected range >=8.0.0 <=8.0.1). Fix: add a root-package `overrides` entry forcing every transitive protobufjs resolution to ^8.2.0. After regenerating the lockfile, npm consolidates the duplicate copies (one was 8.0.1, one was 7.5.7) onto protobufjs@8.3.0 and the audit drops from 23 vulns (17 high) to 6 (0 high; 4 low + 2 moderate, all transitive through tmp/jsdom and wireit's brace-expansion — no available fix, sub-high-sev gate). Orthogonal to the HTML format feature this PR ships; folded in here because it blocks the same CI gate.
Summary
Adds HTML as a second first-class page format alongside A2UI. The agent gets two single-purpose tools:
show_ui(existing) — ask the user a question; A2UI form/picker/confirmation; structured result viacheck_resultpolling.show_html(new) — show a rich visualization (report, dashboard, chart, infographic, comparison table). View-only; nothing comes back.Rule for the agent:
show_htmlto show,show_uito ask.What's in scope
formatfield onPOST /new("a2ui"default — every existing client keeps working unchanged).isomorphic-dompurify) on HTML submission.formatcolumn onpages(added ininit()with idempotentALTER TABLE).""iframe with strict meta-CSP, no-referrer, noindex.role="banner""AI-generated content" label +mailto:Report link).show_ui,show_html);check_resultnow returnsformat.invalid_for_formatandsanitized_empty4xx codes, and updated 413 caps.What's deliberately out
docs/superpowers/specs/2026-05-15-html-format-design.md. JS support would require subdomain isolation; per product call, we're not making that investment. A CI test asserts the iframe'ssandboxattribute stays empty.<script>,<link>, fonts, or images.mailto:Report link in the chrome bar; richer intake is a separate project.Three layers of defense for HTML
<script>,<iframe>,<object>,<embed>,<link>,<base>,<meta>, allon*=handlers,formaction,srcdoc,xlink:href, and dangerous URL schemes (javascript:,vbscript:,data:text/html,data:application/*). Returnsoutput, removedTags, removedAttrs; the API logs the counts. Sanitization runs through a sharedstore.createHtmlPagehelper so REST and in-process MCP go through identical code.default-src 'none',img-src 'self' data:,style-src 'unsafe-inline',font-src data:,form-action 'none',frame-src 'none',base-uri 'none',sandbox. Injected as a<meta http-equiv>inside the srcdoc scaffold.sandbox=""(no tokens) → opaque origin, no scripts, no forms, no top-nav, no popups, no plugins. CI test asserts this stays empty.If any one layer is bypassed, the next two still block code execution.
Architecture
Single API endpoint discriminates on
format:Backwards compatibility
Every existing client keeps working without changes:
show_uisignature unchanged. Description sharpened to point atshow_html.POST /newaccepts{ spec }(noformatfield) and defaults toa2ui.format = 'a2ui'via the column default + idempotentALTER TABLEmigration.formatasa2ui.Rollout
Three logical commits + per-phase fix commits + final OpenAPI polish:
5cc77a7+373dc45) — API + storage + sanitizerae6e614+94936f4) — MCP tools (show_html, sharpenedshow_ui)f19f112+ecd0dcb+439c8a5) — Renderer + skill + OpenAPIe2a3170) — OpenAPI 4xx schemasDeploy ordering on merge: Vercel (renderer) and Railway (API) both auto-deploy from
main. Vercel finishes first (smaller bundle); worst case is a few seconds where the API returnsformat: "html"and an old renderer mishandles it (renders the spec as A2UI and shows an error). Refresh fixes it.Test plan
npm run typecheck— greennpm run lint— greennpm run format:check— greennpm run test— 237 passing across 16 test files (all existing + ~55 new across sanitize / schemas / app / mcp tools / mcp http / csp / html-renderer)curl -X POST $API_URL/new -H 'content-type: application/json' -d '{"format":"html","spec":"<style>body{padding:24px}</style><h1>Hello</h1>"}'returns a URL whose page renders the styled "Hello" with the chrome barshow_uifrom any existing client still worksKnown follow-ups (deferred from final review)
These are doc/test gaps, not correctness bugs. Worth filing as issues against this branch post-merge:
html-renderer.test.tscovers the pure helpers in isolation; no test exercises the actualloadPage→renderHtml→ DOM-mount path insideAgentUIApp. Adding a happy-dom test against the LitElement would close the gap.ShowUiResulttype name is reused forshow_html's return type. Rename toCreatePageResultfor clarity.htmlBody = ''silent render. If the DB ever held an empty active page, the renderer would show chrome only. Server already 400s onsanitized_empty, so unreachable today — but a client-side warning would be defensive.REPORT_EMAILenv var. Currently hard-coded toalex@blockful.iowith a// TODOcomment near the constant. Self-hosters will wantVITE_REPORT_EMAIL.request_id). REST path uses a request-scoped child logger. Aligning would give end-to-end log correlation for the MCP submission path.Refs
docs/superpowers/specs/2026-05-15-html-format-design.mddocs/superpowers/plans/2026-05-15-html-format.md