feat(#470): plugin UI — generated Tailwind subset, static SPA serving, ingest gate (C8) - #784
Merged
Merged
Conversation
…, ingest gate (C8) C8 is the abandonment checkpoint of epic #470: the mechanism a plugin distributed as a package needs to ship a real user interface. Everything before it was core capability work; this is what the extraction was blocked on, and it stands on its own if the epic goes no further. Theme bridge extracted. The `@theme inline` block left globals.css for web-ui/app/_lib/tailwind-bridge.css. The shell imports it and so does the generated plugin stylesheet, so the drift this work exists to end is now structurally impossible instead of asked for in a comment. Plugin stylesheet generated, not hand-written. web-ui/scripts/build-plugin-ui-css.mjs compiles a finite Tailwind v4 vocabulary (`@import 'tailwindcss' source(none)` + `@source inline(...)`) from the same Lume tokens the shell uses into the committed middleware/assets/plugin-ui/plugin-ui.css. CI regenerates and diffs it inside the existing web-ui job. 69,559 B raw / 12,105 B gzip / 9,208 B brotli. harness-admin-css.ts deleted. 345 hand-maintained lines whose own header asked the next maintainer to keep two palettes "roughly in sync". /api/_harness/admin-ui.css is now an alias for the generated sheet and still carries the .harness-* helpers, so no shipped plugin admin UI is restyled. Static serving. A plugin ships ui/ (multi-file, hashed assets); core serves it at /p/<pluginId>/ui/... — traversal-checked lexically and by realpath (with the root realpath'd too, or every check fails behind /var -> /private/var), extension-allowlisted, no directory listing, immutable caching for hashed files, CSP on the document. .woff2 joins the ZIP allowlist scoped to ui/. .css does NOT, and must not: the inability to ship CSS is the enforcement. Host page. /plugin-ui/<pluginId> embeds the bundle in a sandboxed iframe and passes ?theme=&palette=&locale=, closing both regressions from implementation.md 2.3 — neither next/font nor data-theme crosses an iframe. The font case is worse than the note suggested: an undefined --font-geist invalidates --font-sans entirely, so the generated sheet always binds it. Ingest gate. Arbitrary Tailwind values in ui/**/*.js are rejected with file, line and token. It scans compiled bundle text, not JSX, and its false-positive and false-negative limits are written down rather than implied. Proved rather than asserted: test/fixtures/plugin-ui-proof/ is a throwaway SPA driven through the real ingest path and the real routers, including both negative cases. Ratchet held at 3296. Vocabulary documented in specs/470-dev-platform-plugin/plugin-ui-vocabulary.md.
`String.length` counts UTF-16 code units, and the generated stylesheet carries section-sign and em-dash characters, so the CI line under-reported the artifact by seven bytes against every other measurement of the same file.
…n, wider ingest scan Cross-family review findings on C8, each with a test that fails on the pre-fix code. 1. SVG was served from the core origin with no CSP. `/p/<id>/ui/**` is on the publicPaths allowlist, so a directly navigated `image/svg+xml` was an active document in the operator's own origin — inline <script> and on* handlers ran, with same-origin fetch to /api/*. nosniff does not help when the declared type is already active. The CSP now goes on every response rather than only on `.html`, so the branch cannot be forgotten again, and SVG gets a harder policy (`default-src 'none'; sandbox`) because it is an image asset and never a document. 2. HASHED_BASENAME matched any 8+ character trailing dash-segment, so `app-bootstrap.js` and `vendor-polyfills.js` — ordinary Rollup output — were served `immutable, max-age=1y`. A plugin upgrade could then not be seen for a year, with no cache-busting handle because the URL is unchanged. The candidate hash must now contain a digit; the residual failure direction is "revalidate", not "frozen". 3. The ingest scanner missed its own documented variant-prefix forms: `group-hover:w-[137px]`, `peer-focus:bg-[#abc]`, `2xl:w-[137px]`, `-mt-[3px]` and `lg:-mt-[3px]` all passed. The prefix chain admitted neither dashes nor a leading digit, and the lookbehind then blocked the utility from matching on its own. The two narrowings that hold false positives down (lower-case utility head, no whitespace/quotes in the bracket) are unchanged, and the unicode-escape false negative is now pinned by a test rather than left implicit. 4. `answers 304 for a matching ETag` never sent If-None-Match and never asserted 304 — `invoke()` had no way to set request headers, so the fourth argument was silently dropped. The helper takes headers now and the test asserts the 304. Also repoints the shipped admin-ui boilerplate at the generated stylesheet: it still named the deleted `harness-admin-css.ts` as source of truth, which sends a new plugin author to a file that no longer exists. The `<link>` is untouched — `admin-ui.css` remains a same-bytes alias.
Weegy
enabled auto-merge (squash)
August 20, 2026 16:02
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.
What and why
Epic #470, item C8 (G7 / P3b) — the abandonment checkpoint.
Everything shipped in this epic so far was core capability work. C8 is the piece the
extraction was actually blocked on: a plugin distributed as a package could not ship a
real user interface. Not because of React or the bundler — because it could not ship a
stylesheet.
.csswas absent from the ZIP extension allowlist, so the only precedentwas one hand-written HTML file with inline styles.
plan.md§4.3a made the call that the missing.cssis the wrong thing to fix.web-ui is a Tailwind v4 project; if plugin markup is required to use Tailwind
utilities, a plugin never needs a stylesheet — it links one core serves. The catch is
the whole design: Tailwind emits only classes it has seen, and a plugin installed at
runtime from another repository is never scanned. So core pre-generates a documented,
finite vocabulary, and the inability to ship CSS is the enforcement for it
(
implementation.md§1 row 3).This PR builds that, end to end, and proves it with a throwaway SPA driven through the
real ingest path and the real routers.
It is the checkpoint because if the epic stops here it is still net-positive: any
third-party plugin can now ship a real UI, which was the platform's weakest extension
point, and dev-platform can stay in core with zero partial-move debt.
Design
1. The token bridge left
globals.cssweb-ui/app/_lib/tailwind-bridge.cssnow holds the@theme inlineblock that mapsLume tokens onto Tailwind's
--color-*/--font-*/--radius-*/--shadow-*namespace. The shell imports it; so does the generated plugin stylesheet. Two copies of
that block is the exact drift this work exists to end — a plugin whose
bg-accentresolved against a stale palette would be indistinguishable from a working one until
someone re-themed the shell.
web-ui is visually identical: the same declarations, reached through one import.
2. The plugin stylesheet is generated, not written
web-ui/scripts/build-plugin-ui-css.mjscompilesweb-ui/scripts/plugin-ui.source.css(@import 'tailwindcss' source(none)+@source inline(...)) into the committed artifactmiddleware/assets/plugin-ui/plugin-ui.css.Committed on purpose: middleware serves it at runtime and must not depend on web-ui's
toolchain being present in the image. CI regenerates and diffs it
(
npm run plugin-ui:css:check) as a step inside the existing web-ui job — no newrequired check — so an edit to the tokens, the bridge or the vocabulary that was not
regenerated fails loudly instead of shipping a sheet that disagrees with the shell.
Served at
GET /api/_harness/plugin-ui.css(browser:/bot-api/…).3.
harness-admin-css.tsis deleted345 hand-maintained lines whose own header read: "Tokens mirror
web-ui/app/_lib/theme.css; keep the two roughly in sync when the design systemchanges." That sync obligation is now gone rather than restated — the same
element baseline is generated from the same tokens as everything else.
GET /api/_harness/admin-ui.cssremains as an alias for the generated bytes, and thegenerated sheet still carries the
.harness-*helpers (.harness-admin,.harness-btn,.harness-banner-error, …), because shipped plugin admin UIs in thestandalone plugin repos already link them. Deleting them would have restyled every
installed plugin's admin page on upgrade. They are frozen, not extended — new UIs use
the utility vocabulary.
4. Static serving for SPA bundles
A plugin ships
ui/(multi-file,index.html+ hashed JS/assets). Core serves it at/p/<pluginId>/ui/…— under the plugin's own prefix, so the nav contribution API, thepublicPathsentry and the web-ui/p/*proxy all apply unchanged. The router ismounted at
/pandnext()s every non-uipath, so a plugin's own Express routerstill owns the rest of its prefix.
Core serves this rather than the plugin because handing plugins
express.staticwouldput the traversal, content-type and caching decisions in thirty repositories instead
of one — and because the served extension allowlist has no
.cssin it, which keepsthe vocabulary the only styling channel a plugin has even if a stylesheet somehow got
past the extractor.
5. The host page and the iframe boundary
/plugin-ui/<pluginId>in web-ui embeds the bundle in a sandboxed iframe and passes?theme=&palette=&locale=. This closes both silent regressions fromimplementation.md§2.3:next/fontdoes not cross an iframe. The generated sheet re-binds--font-geist/--font-geist-mono/--font-source-serif. The note understatedthis:
theme.csscomposes--font-sans: var(--font-geist), system-ui, …, and anundefined var invalidates the whole declaration at computed-value time — so the
plugin would not fall back to the intended stack, it would drop to the browser's
serif default. Dropping
.woff2files intomiddleware/assets/plugin-ui/fonts/additionally emits
@font-faceand serves them; empty today, mechanism wired.data-themedoes not cross either. The frame reads the live<html>attributesand a
MutationObserverre-reads them, so flipping appearance in the header updatesthe embedded UI without a reload.
Nav entries come from the existing
ctx.uiRoutes.registerNav(PR #536) pointing at/plugin-ui/<id>— validated as an in-app single-slash path.6. The ingest gate
Arbitrary Tailwind values in
ui/**/*.jsare rejected at package ingest with file,line and token.
Vocabulary
Documented in
specs/470-dev-platform-plugin/plugin-ui-vocabulary.md— thecontract, not a changelog. Layout, flex/grid, spacing 0–12, typography, borders,
shadows, motion, a11y,
sm:/md:/lg:/xl:,hover:/focus:/disabled:.Colour is the Lume tokens only.
bg-blue-500does not exist and will not beadded; the names are
bg-accent,text-fg-muted,border-border-strong,text-dangerand so on, each wired to the runtime variable. A plugin cannot hardcodea hex and drift from the operator's active palette — that is the point of the whole
mechanism, and it is true by construction rather than by convention.
Size, honestly
plugin-tailwind-subset.probe.css(measured reference, never built)plugin-ui.css(shipped)+4.4 KB gzip over the probe, from two things and neither is slack:
w-/h-scale, noxl:breakpoint, no
focus:/disabled:variants, no motion or a11y utilities..harness-*helpers that
harness-admin-css.tsused to serve as a separate 8.6 KB request.Netted against that file, the plugin surface got cheaper, not dearer.
Widening is a documented, deliberate act with a regenerate-and-commit step; it is not
something that happens by accident.
Security
The
/p/*prefix is on thepublicPathsallowlist — it has been since plugin UIsurfaces were first iframed by Teams. So this handler is the boundary, not defence
behind one. Every property below has a test.
..segment or NUL, resolved, then re-checked for containment. Percent-encoded, double-encoded, backslash and deep../../../etc/passwdall covered/var→/private/varon macOS). That bug was live in the first draft and the tests caught itindex.html. Empty directories 404 tooContent-Typecomes from a fixed extension table, never from content;X-Content-Type-Options: nosniffon everything.cssis in neither the ZIP allowlist nor the served table. A.cssinsideui/is rejected at extraction — asserted by test, so removing the rule fails CIdefault-src 'none',script-src 'self',frame-ancestors 'self',base-uri 'none',form-action 'none'allow-scripts allow-forms allow-popups, deliberately notallow-same-origin— third-party bundle code stays out of the operator's cookies and storage on our originno-referreron served assets.woff2scopeui/. A.woff2anywhere else is still rejected — asserted both directionsOn the arbitrary-value scanner's limits
Stated in the source and in the vocabulary doc rather than implied, because a scanner
whose blind spots are undocumented gets trusted past its competence:
class names. Prose like
"see step-[2] of the guide"is reported. The mitigation isthe report — file, 1-based line, matched token — not a cleverer regex. This is not
hypothetical: the first run rejected the proof fixture's own explanatory comment,
which is now the worked example in the doc and a pinned test case.
at runtime defeats any static check. Nothing here claims otherwise; a plugin that
routes around the gate merely ends up unstyled.
ui/**/*.{js,mjs}is scanned, capped at 200 files / 8 MB.Deployment note
middleware/assets/plugin-ui/joins the OB-41 asset-bundle registry(
PLUGIN_UI_ASSETS_DIR,verifyAssetBundles()) with a matching DockerfileCOPY.A missing stylesheet is a boot abort with a clear message, not a runtime 404 on
every plugin UI. The previous CSS was compiled into
dist, so this trades one failuremode for a louder one deliberately.
Tests
New: 56 cases across three files.
tailwindArbitraryValueScan.test.ts— 7 reject cases, 6 accept cases (arrayindexing, regex character classes, destructuring — the obvious false positives), the
offender cap, dedup, and both documented limits pinned as tests so a future
"improvement" to the regex has to argue with them.
pluginUiStaticServing.test.ts— happy path, caching (hashed immutable / unhashedand
index.htmlnot), and eleven security cases. Driven throughapp.handlevia theexisting
_helpers/httpInvoke.ts, so no port is held.pluginUiProof.test.ts— the end-to-end proof. Zipstest/fixtures/plugin-ui-proof/, pushes it through the realPackageUploadService.ingest, mounts the result on a real Express app, fetches backindex.html, the hashed JS and the stylesheet, and asserts the.harness-*helperssurvive so shipped plugins are not restyled. Plus the two negative cases and the
.woff2scope in both directions.Full suites green: middleware 7,261 tests / 0 fail, web-ui 759 / 0 fail.
Commands run (Node 22.22.3)
typecheck:testratchet: 406, no regressions.check-core-decoupling: held at 3296 — baseline untouched. The four referencesthis PR initially added were all spec-directory paths in comments; they were
reworded to name the documents instead of the directory, per the standing rule
that you reword rather than raise.
i18n:check: 3,848 keys,en+de— five newpluginUi.*keys in both.package-lock.json: one line, for the explicitpostcssdevDependency the buildscript needs (it was previously a phantom dependency resolved through
@tailwindcss/postcss).Spec updates
README.md— new C8 — the abandonment checkpoint section; theplugin-ui-vocabulary.mdrow added to the document table; the G7 fallback decision resolved (option B is
built and proved, option E should not be revived).
plan.md§4.3a — status note recording the three things the implementation correctedin that section: the real size, the under-specified ingest check, and the font
problem being worse than described.
Not in this PR
@font-faceslot is wired but empty. The shell's faces come fromnext/font/googleat build time and are committed nowhere in this repo; adding themmeans adding binaries or a dependency, which is its own decision. The stylesheet
binds the fallback stacks correctly in the meantime — see the fonts README.
Cross-family review (Forge)
Adversarial review against the code, run on a different model family (GPT-5.4 at high
reasoning) from the one that wrote the PR, to avoid shared blind spots. Six areas were
probed as posed: static-serving traversal,
/p/*exposure surface, scanner falsenegatives, migration of the deleted
harness-admin-css.ts, whether the CI drift checkis load-bearing, and web-ui token parity.
Four defects found and fixed in
cdfa18e3. Every fix is pinned by a test that wasdemonstrated to fail on the pre-fix code.
ext === '.html'..svgis inCONTENT_TYPES,/p/<id>/ui/**is onpublicPaths, and the bytes come from the same origin as the authenticated app — so navigating directly to a plugin'slogo.svgrendered it as a document with inline<script>andon*handlers live, able tofetch/api/*same-origin.nosniffis no help when the declared type is already an active one..svggets a harderdefault-src 'none'; style-src 'unsafe-inline'; sandboxbecause it is an image asset and never a document.<img>/CSS use is unaffected — CSP is not applied to image subresource loads.HASHED_BASENAMEfalse-positived, freezing ordinary assets for a year./-[A-Za-z0-9_]{8,}\./matches any 8+ char trailing dash-segment:app-bootstrap.js,vendor-polyfills.js,chunk-runtime.mjs— all ordinary Rollup output — were servedimmutable, max-age=1y. A plugin upgrade is then invisible for a year with no cache-busting handle, because the URL does not change. The existing regression test usedapp.js, which has no dash at all, so it passed trivially and killed nothing.group-hover:w-[137px],peer-focus:bg-[#abc],2xl:w-[137px],-mt-[3px],lg:-mt-[3px]. The prefix chain(?:[a-z][a-z0-9]*:)*admitted neither a dash nor a leading digit, and the(?<![\w:$-])lookbehind then blocked the utility from matching on its own — so the whole token was silently accepted. These are everyday Tailwind forms, not exotica.data-[state=open]:,min-[320px]:), plus optional leading-/!. The two narrowings that hold false positives down are untouched, and all existing "accepts" cases (arr[i], regex classes, destructured imports) stay green — verified against a 25-case probe.answers 304 for a matching ETagnever sentIf-None-Matchand never asserted304;invoke()had no headers parameter, so the fourth argument was silently discarded. Separately, the shipped boilerplate still named the deletedharness-admin-css.tsas source of truth, sending new plugin authors to a file that no longer exists.invoke()takes request headers; the test asserts the real304. Boilerplate repointed at the generated stylesheet — the<link>itself is untouched, sinceadmin-ui.cssremains a same-bytes alias.What held up
..(raw,%2e%2e, double-encoded), NUL, backslash separators, absolute paths and malformed escapes are all rejected before touching the filesystem;path.resolvecontainment is re-checked after normalisation, andrealpathis applied to both the candidate and the root — the latter being the detail most implementations get wrong (/var→/private/varmakes a resolved-vs-unresolved comparison a silent false negative). Symlinks are additionally rejected at extraction./p/*exposes nothing beyondui/.bundleRootis<packageRoot>/uiand containment is enforced against it, somanifest.yaml,dist/plugin.jsand any.envinside the package are unreachable — and the extension allowlist would 404 them regardless.resolvePackageRootis aMaplookup, so the plugin id itself cannot traverse. Mount order was checked: the static router is installed at line ~2696, well beforepluginRouteRegistry.mountAllat ~5045, so core owns theui/segment and everything else still falls through to the plugin's own router.web-ui (lint + typecheck + vitest)is in the branch-protection contexts list. Mutation-tested three ways: changing a bridge token → exit 1; editing the committed artifact → exit 1; a comment-only source edit → correctly exit 0 (Tailwind strips it).@theme inlineblock moved totailwind-bridge.cssverbatim. Compiled declaration sets compared old-vs-new: 26 → 26, none lost, none added.omadia-channel-{discord,slack,whatsapp}link only the URL/bot-api/_harness/admin-ui.css, which is preserved as an alias. The only stale pointers were the two boilerplate files, fixed in chore(deps,docker,web-dev): Bump node from 20-slim to 26-slim in /web-dev #4. (omadia-agent-builder,omadia-clean,omadia-public-orphanare archived monorepo checkouts, not live sources.)Verification
Mutation proof, each fix reverted in isolation: CSP → 2 failures · hash regex → 2 failures · scanner regex → 5 failures ·
invoke()headers → 1 failure. Restored → 58/58.Verdict: MERGE
No blocking issues remain. The design calls that carry the most weight — core owning static serving rather than thirty plugin repos, and the absent
.cssbeing the enforcement rather than a lint — are right, and the security commentary inpluginUiStatic.tsis accurate about what it claims. The one thing the header did not claim, and should have, was the SVG case; that is now closed.Residual, accepted, not blocking: the scanner remains defeatable by runtime string assembly and unicode-escaped brackets. Both are pinned by tests as documented limits, and the failure mode is an unstyled element rather than a broken boundary — which is the right direction for a vocabulary gate.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.