Skip to content

feat(#470): plugin UI — generated Tailwind subset, static SPA serving, ingest gate (C8) - #784

Merged
Weegy merged 7 commits into
mainfrom
feat/470-c8-plugin-ui-tailwind-static
Aug 20, 2026
Merged

feat(#470): plugin UI — generated Tailwind subset, static SPA serving, ingest gate (C8)#784
Weegy merged 7 commits into
mainfrom
feat/470-c8-plugin-ui-tailwind-static

Conversation

@Weegy

@Weegy Weegy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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. .css was absent from the ZIP extension allowlist, so the only precedent
was one hand-written HTML file with inline styles.

plan.md §4.3a made the call that the missing .css is 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.css

web-ui/app/_lib/tailwind-bridge.css now holds the @theme inline block that maps
Lume 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-accent
resolved 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.mjs compiles
web-ui/scripts/plugin-ui.source.css (@import 'tailwindcss' source(none) +
@source inline(...)) into the committed artifact
middleware/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 new
required 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.ts is deleted

345 hand-maintained lines whose own header read: "Tokens mirror
web-ui/app/_lib/theme.css; keep the two roughly in sync when the design system
changes."
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.css remains as an alias for the generated bytes, and the
generated sheet still carries the .harness-* helpers (.harness-admin,
.harness-btn, .harness-banner-error, …), because shipped plugin admin UIs in the
standalone 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, the
publicPaths entry and the web-ui /p/* proxy all apply unchanged. The router is
mounted at /p and next()s every non-ui path, so a plugin's own Express router
still owns the rest of its prefix.

Core serves this rather than the plugin because handing plugins express.static would
put the traversal, content-type and caching decisions in thirty repositories instead
of one — and because the served extension allowlist has no .css in it, which keeps
the 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 from
implementation.md §2.3:

  • next/font does not cross an iframe. The generated sheet re-binds
    --font-geist / --font-geist-mono / --font-source-serif. The note understated
    this
    : theme.css composes --font-sans: var(--font-geist), system-ui, …, and an
    undefined 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 .woff2 files into middleware/assets/plugin-ui/fonts/
    additionally emits @font-face and serves them; empty today, mechanism wired.
  • data-theme does not cross either. The frame reads the live <html> attributes
    and a MutationObserver re-reads them, so flipping appearance in the header updates
    the 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/**/*.js are rejected at package ingest with file,
line and token.


Vocabulary

Documented in specs/470-dev-platform-plugin/plugin-ui-vocabulary.md — the
contract, 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-500 does not exist and will not be
added; the names are bg-accent, text-fg-muted, border-border-strong,
text-danger and so on, each wired to the runtime variable. A plugin cannot hardcode
a 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

raw gzip brotli
plugin-tailwind-subset.probe.css (measured reference, never built) 43,199 B 7,704 B 5.7 KB
plugin-ui.css (shipped) 69,559 B 12,105 B 9,208 B

+4.4 KB gzip over the probe, from two things and neither is slack:

  1. The probe's vocabulary was too narrow for a real SPA — no w-/h- scale, no xl:
    breakpoint, no focus:/disabled: variants, no motion or a11y utilities.
  2. The shipped sheet also absorbs the element baseline and the .harness-*
    helpers that harness-admin-css.ts used 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 the publicPaths allowlist — it has been since plugin UI
surfaces were first iframed by Teams. So this handler is the boundary, not defence
behind one. Every property below has a test.

Property How
No traversal path decoded, rejected on a surviving .. segment or NUL, resolved, then re-checked for containment. Percent-encoded, double-encoded, backslash and deep ../../../etc/passwd all covered
No symlink escape the resolved real path is containment-checked too — and the root is realpath'd as well, or the check silently passes wherever the packages dir sits behind a symlink (/var/private/var on macOS). That bug was live in the first draft and the tests caught it
No directory listing a path resolving to a directory is 404, except the bundle root, which serves index.html. Empty directories 404 too
No sniffing Content-Type comes from a fixed extension table, never from content; X-Content-Type-Options: nosniff on everything
No CSS, ever .css is in neither the ZIP allowlist nor the served table. A .css inside ui/ is rejected at extraction — asserted by test, so removing the rule fails CI
CSP on the HTML document: default-src 'none', script-src 'self', frame-ancestors 'self', base-uri 'none', form-action 'none'
Sandbox allow-scripts allow-forms allow-popups, deliberately not allow-same-origin — third-party bundle code stays out of the operator's cookies and storage on our origin
Referrer no-referrer on served assets
.woff2 scope added to the ZIP allowlist only under ui/. A .woff2 anywhere else is still rejected — asserted both directions

On 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:

  • False positives are possible. It reads text, and text in a bundle is not only
    class names. Prose like "see step-[2] of the guide" is reported. The mitigation is
    the 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.
  • False negatives are possible, and that is the safer direction. A class assembled
    at runtime defeats any static check. Nothing here claims otherwise; a plugin that
    routes around the gate merely ends up unstyled.
  • Only 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 Dockerfile COPY.
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 failure
mode for a louder one deliberately.


Tests

New: 56 cases across three files.

  • tailwindArbitraryValueScan.test.ts — 7 reject cases, 6 accept cases (array
    indexing, 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 / unhashed
    and index.html not), and eleven security cases. Driven through app.handle via the
    existing _helpers/httpInvoke.ts, so no port is held.
  • pluginUiProof.test.ts — the end-to-end proof. Zips
    test/fixtures/plugin-ui-proof/, pushes it through the real
    PackageUploadService.ingest, mounts the result on a real Express app, fetches back
    index.html, the hashed JS and the stylesheet, and asserts the .harness-* helpers
    survive so shipped plugins are not restyled. Plus the two negative cases and the
    .woff2 scope in both directions.

Full suites green: middleware 7,261 tests / 0 fail, web-ui 759 / 0 fail.


Commands run (Node 22.22.3)

middleware: npm install && npm run build && npm run typecheck
            && npm run typecheck:test && npm run lint && npm test
web-ui:     npm run lint && npm run typecheck && npx vitest run && npm run build
            && npm run plugin-ui:css:check && npm run i18n:check
root:       node scripts/check-core-decoupling.mjs
  • typecheck:test ratchet: 406, no regressions.
  • check-core-decoupling: held at 3296 — baseline untouched. The four references
    this 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 new pluginUi.* keys in both.
  • package-lock.json: one line, for the explicit postcss devDependency the build
    script needs (it was previously a phantom dependency resolved through
    @tailwindcss/postcss).

Spec updates

  • README.md — new C8 — the abandonment checkpoint section; the plugin-ui-vocabulary.md
    row 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 corrected
    in that section: the real size, the under-specified ingest check, and the font
    problem being worse than described.

Not in this PR

  • No dev-platform code moved. C8 is a platform capability; P2 is the port.
  • The @font-face slot is wired but empty. The shell's faces come from
    next/font/google at build time and are committed nowhere in this repo; adding them
    means adding binaries or a dependency, which is its own decision. The stylesheet
    binds the fallback stacks correctly in the meantime — see the fonts README.
  • H3, the chat card, is untouched and still needs a decision before P4.

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 false
negatives, migration of the deleted harness-admin-css.ts, whether the CI drift check
is load-bearing, and web-ui token parity.

Four defects found and fixed in cdfa18e3. Every fix is pinned by a test that was
demonstrated to fail on the pre-fix code.

# Sev Finding Fix
1 HIGH SVG served same-origin with no CSP. The CSP was set only when ext === '.html'. .svg is in CONTENT_TYPES, /p/<id>/ui/** is on publicPaths, and the bytes come from the same origin as the authenticated app — so navigating directly to a plugin's logo.svg rendered it as a document with inline <script> and on* handlers live, able to fetch /api/* same-origin. nosniff is no help when the declared type is already an active one. CSP now goes on every response, so the branch cannot be forgotten again; .svg gets a harder default-src 'none'; style-src 'unsafe-inline'; sandbox because it is an image asset and never a document. <img>/CSS use is unaffected — CSP is not applied to image subresource loads.
2 MED HASHED_BASENAME false-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 served immutable, 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 used app.js, which has no dash at all, so it passed trivially and killed nothing. The candidate hash must now contain a digit. Real Vite/Rollup hashes do; the residual failure direction becomes revalidate, which is safe, instead of frozen, which is not.
3 MED The ingest scanner missed its own documented variant-prefix forms. Verified passing ingest undetected: 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. Prefix chain widened to dashed, digit-led and arbitrary-bracket variants (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.
4 LOW A test that did not test its own name. answers 304 for a matching ETag never sent If-None-Match and never asserted 304; invoke() had no headers parameter, so the fourth argument was silently discarded. Separately, the shipped boilerplate still named the deleted harness-admin-css.ts as source of truth, sending new plugin authors to a file that no longer exists. invoke() takes request headers; the test asserts the real 304. Boilerplate repointed at the generated stylesheet — the <link> itself is untouched, since admin-ui.css remains a same-bytes alias.

What held up

  • Traversal is sound. .. (raw, %2e%2e, double-encoded), NUL, backslash separators, absolute paths and malformed escapes are all rejected before touching the filesystem; path.resolve containment is re-checked after normalisation, and realpath is applied to both the candidate and the root — the latter being the detail most implementations get wrong (/var/private/var makes a resolved-vs-unresolved comparison a silent false negative). Symlinks are additionally rejected at extraction.
  • /p/* exposes nothing beyond ui/. bundleRoot is <packageRoot>/ui and containment is enforced against it, so manifest.yaml, dist/plugin.js and any .env inside the package are unreachable — and the extension allowlist would 404 them regardless. resolvePackageRoot is a Map lookup, so the plugin id itself cannot traverse. Mount order was checked: the static router is installed at line ~2696, well before pluginRouteRegistry.mountAll at ~5045, so core owns the ui/ segment and everything else still falls through to the plugin's own router.
  • The CI drift check is load-bearing, and it runs in a required job. 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).
  • Zero token drift in web-ui. The @theme inline block moved to tailwind-bridge.css verbatim. Compiled declaration sets compared old-vs-new: 26 → 26, none lost, none added.
  • Migration is complete. No sibling repo references the deleted module — 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-orphan are archived monorepo checkouts, not live sources.)

Verification

middleware  build ✓  typecheck ✓  typecheck:test ✓ (406 = baseline)  lint ✓
middleware  pluginUiStaticServing + tailwindArbitraryValueScan + pluginUiProof + builder/codegen  →  118/118
middleware  httpInvoke consumers (toolPluginRuntimeRouteDisposal, uiNavigationRoute)  →  17/17
middleware  zipExtractor + packageUploadService  →  7/7
web-ui      typecheck ✓   vitest 759/759 (88 files)   plugin-ui:css:check ✓ up to date
root        check-core-decoupling.mjs  →  held at 3296

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 .css being the enforcement rather than a lint — are right, and the security commentary in pluginUiStatic.ts is 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.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Weegy added 5 commits August 20, 2026 16:26
…, 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
Weegy enabled auto-merge (squash) August 20, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant