Skip to content

feat: custom widget system + remove legacy theme stack (v1.0.0-alpha.5)#128

Merged
ZingerLittleBee merged 64 commits into
mainfrom
minnetonka-v5
May 28, 2026
Merged

feat: custom widget system + remove legacy theme stack (v1.0.0-alpha.5)#128
ZingerLittleBee merged 64 commits into
mainfrom
minnetonka-v5

Conversation

@ZingerLittleBee
Copy link
Copy Markdown
Owner

@ZingerLittleBee ZingerLittleBee commented May 28, 2026

Summary

  • New custom widget system end-to-end: @serverbee/widget-sdk package with defineWidget + JSDoc static manifest; admin install via URL or .js/.zip upload through /api/widget-modules; built-in widgets emitted by a Vite nested-build plugin and embedded via rust-embed; dashboard editor renders module widgets through a Zustand registry + useSyncExternalStore runtime bridge
  • Deletes the legacy spa_theme (full-SPA replacement) and custom_theme (CSS-vars) systems plus seven preset themes, including the appearance UI, backend service/router/entity, and theme_ref / SERVERBEE_FEATURE__CUSTOM_THEMES config
  • Bilingual custom-widgets.mdx (en + zh) documents methods B (single file) and C (zip collection bundle) with the full safety surface

Implementation

Four-plan rollout (specs + plans live under docs/superpowers/):

  1. SDK + registry + asset servingwidget-sdk package (zod-lite primitives, hooks, define-widget, form renderer, actions), browser-side WidgetRegistry + loader + runtime bridge with import-map shims, backend widget_module entity/migration/service with JSDoc extractor, list + asset routes
  2. Build pipeline + builtin embed — Vite plugin emitting widget bundles to apps/web/dist/builtin-widgets/, rust-embed register-on-boot, first builtin hello-world.widget.tsx
  3. Install UX + legacy theme cleanupPOST /api/widget-modules (URL + multipart), DELETE /api/widget-modules/{id}, settings page, and full deletion of legacy theme code (46 files + drop-tables migration)
  4. Docs + zip collectioninstall_collection_from_zip with collection.json contract + folder-relative asset resolution; bilingual docs

V2 dashboard integration: dashboard_widget.module_id column, picker surfaces installed modules, WidgetRenderer dispatches widget_type='module' through ModuleWidgetHost, WidgetConfigDialog renders the module's configSchema via the SDK form renderer.

Hardening (post-review)

Security and correctness fixes landed in a follow-up sweep:

  • SSRF: DNS resolution + reject any reserved/private/CGNAT/link-local/cloud-metadata/IPv6-ULA IP; HTTP 3xx redirects refused
  • Body cap: per-route DefaultBodyLimit::max(1 MiB + slack); URL fetch streamed with running-total accounting
  • Zip caps: 32 MiB aggregate uncompressed + 64-entry max + 5 MiB per entry + zip-slip
  • ID conflict: upload whose id belongs to a different source_type (e.g. overwriting a builtin) returns 409 Conflict
  • Audit log: every install / uninstall recorded with actor, source, id, version, code SHA-256
  • sdkVersion: semver-range gate at module load; mismatched modules go to registry failure list, not registered
  • module_id referential check: widget_type='module' is rejected unless the referenced module is installed
  • 404 vs 400: AssetNotFound maps to 404; .. traversal stays 400
  • Live hooks: useServers/useServer/useMetric/useCapability/useTheme rewritten on useSyncExternalStore against the React Query servers cache and the host theme provider
  • ActionButton: confirm dialog, pending state, success/error toast, audit hook scaffolding
  • /runtime/* shims: Cache-Control: no-cache to avoid stale shim drift after SPA upgrade
  • Drift test: parses public/runtime/widget-sdk.js and asserts every re-exported name exists on the real SDK namespace

Verification

  • VPS end-to-end against 207.241.173.217 covered: method B (12 steps), method C (15 steps), V2 dashboard module_id round-trip (9 steps), post-review hardening (10 steps — SSRF exhaustive, id conflict 409, body cap 400, module_id referential check 400, audit log entries, 404 vs 400)
  • cargo test --workspace green; cargo clippy --workspace --all-targets -- -D warnings clean
  • Widget SDK vitest: 53 tests passing
  • Web vitest: 611 tests passing; bun run typecheck clean; bun x ultracite check 0 errors (the 9 pre-existing errors from before this branch are also cleared)
  • apps/docs typecheck clean

Test plan

  • Pull the branch and run the migrations on an existing alpha.4 instance — spa_themes and custom_theme tables are dropped; dashboard_widget.module_id column is added
  • Browser: confirm Settings → Appearance now redirects to Widget Modules, no preset/custom theme UI remains; light/dark/system toggle still works
  • Install the builtin Hello World widget from the picker, save, reload — widget renders with live server count from the WS store
  • Upload a single-file .widget.js via Settings → Widget Modules → file input; verify list, then add it to a dashboard
  • Upload a .zip collection bundle with collection.json listing two widgets; verify both ids appear and each serves its own entry
  • Try a public URL that resolves to a private IP — install must be rejected with 400 private/loopback/reserved address rejected
  • Try uploading a widget with id com.serverbee.hello-world — must be rejected with 409 Conflict
  • DELETE /api/widget-modules/com.serverbee.hello-world must 400 (cannot uninstall builtin)
  • Verify the audit log shows install + uninstall entries with code SHA-256

Address review feedback:
- add action/mutation model (useApiQuery, useApiMutation, actions[])
- replace Blob URL loader with server-path serving (/api/widget-modules/{id}/{*path})
- externalise react, react-dom, react/jsx-runtime in import-map
- switch manifest extraction to JSDoc literal block (no AST eval)
- add Vite multi-entry build pipeline section for builtins
- add first-class domain hooks (useAlerts, useServiceMonitors, useTraffic, useUptime, useGeoIp)
- delete full-SPA-replacement spa_theme system; reclaim Theme name
- chunk Plan 2 (2A/2B/2C) and Plan 3 (3A/3B/3C)
- add security-and-trust, troubleshooting, data-and-recipes docs
Sniff the magic bytes on POST /api/widget-modules and dispatch single
.js uploads to install_single_file as before, while .zip uploads now go
through a new install_collection_from_zip path. A zip bundle must contain
a top-level collection.json listing one or more entries, each pointing to
a .js or .mjs file with a valid @serverbee-widget JSDoc manifest. Widget
ids must be unique within the bundle. All widgets in a collection share
the same blob; each becomes its own row with entry_path set to the path
inside the zip.

serve_asset now mirrors the Builtin resolution behavior for zip blobs:
the requested asset path is resolved relative to the entry's folder so
that requests like /api/widget-modules/<id>/index.js find weather/index.js
inside the bundle. The single-file path is unchanged.

The endpoint response shape is documented in the utoipa annotation:
single-file installs return { id, version } and zip collections return
an array of { id, version }.

Adds five integration tests covering the happy path, missing
collection.json, duplicate ids, zip-slip in collection.json entries, and
entries missing a JSDoc manifest.
Delete the legacy custom-themes and custom-frontend pages in both
languages — the underlying theme/spa-override system was removed in
recent commits. Replace both with a single custom-widgets page (cn + en)
that documents the new widget module system end to end:

- concept overview and trust model (admin-only, same-origin)
- method B: single .widget.js file (JSDoc manifest, defineWidget, build
  config that preserves the manifest comment, UI + API install flows)
- method C: .zip collection bundle with a collection.json schema, folder
  layout, per-entry asset resolution rules, build/pack walkthrough, and
  the array response shape
- sizing strategies, SDK surface, uninstall, size/SSRF/zip-slip limits

Update meta.json in both languages to drop the two removed entries and
insert custom-widgets under the Features section. Repoint the two
remaining cross-references (status-page footer card, index card) at the
new page.
Introduce a nullable module_id column so a widget row can reference
an installed widget module. Legacy widgets remain unaffected
(module_id stays NULL).
Add a Custom Widgets section in the picker that lists every entry from
the widget registry. Picking a module records widget_type='module' with
module_id on the dashboard editor draft, using the manifest's default
sizing. The picker callback now passes a structured selection
({type:'builtin', widgetType} | {type:'module', moduleId, manifest}) so
the editor can distinguish the two paths cleanly.
When a widget row has widget_type='module', look up its module_id in
the runtime registry and render the registered component with the
parsed config. Render clear placeholders for the not-installed and
invalid-config cases. Includes a renderer test that registers a fake
module and asserts the component output appears.
- Reject install when id collides with an existing module of a different
  source_type (spec §3.5: Builtin must not be silently overwritten by an
  Upload/Url upsert). Returns 409 Conflict.
- Introduce WidgetModuleError::AssetNotFound mapped to HTTP 404; reserve
  InvalidAssetPath for actual traversal attempts (still 400).
- serve_asset now returns a ServedAsset { bytes, mime, version,
  code_sha256 } so the route can build ETag + Cache-Control headers
  without a second DB read.
- Extend mime_for with .wasm, .html, .txt, .md so module bundles can
  ship companion docs/assets without falling back to octet-stream.
- Replace string-based is_private_host with full IP-resolution SSRF
  guard. The URL's host is resolved via tokio::net::lookup_host and
  every returned address must pass is_public_ip, which rejects
  loopback, private, CGNAT (100.64/10), link-local (incl. cloud
  metadata 169.254.169.254), benchmarking, documentation, multicast,
  and reserved-for-future-use ranges for both IPv4 and IPv6 (incl.
  ULA fc00::/7, fe80::/10, ::ffff:0:0/96 v4-mapped, 2001:db8::/32).
- Disable HTTP redirects on the fetch client and explicitly reject
  3xx responses so a public-IP DNS check cannot be bypassed by a
  Location pointing into the intranet.
- Stream the URL body with a running total instead of buffering the
  full response, aborting early once MAX_MODULE_BYTES is exceeded.
- Apply a tower DefaultBodyLimit::max(MAX_MODULE_BYTES + 64 KiB) to
  the write router so multipart uploads cannot exhaust memory before
  our own checks run.
- Audit log every install (per widget for collection installs) and
  every uninstall via AuditService::log; failures are swallowed so
  audit never blocks the user-visible response.
- Add unit tests covering is_public_ip across v4/v6 reserved ranges.

Adds reqwest stream feature; futures_util was already in the tree.
- Loopback / cloud-metadata / private-CIDR install URLs are rejected.
- Non-admin (member) cannot POST or DELETE on /api/widget-modules.
- Uploading a single-file widget whose id matches a Builtin returns
  409 Conflict (B3 spec §3.5 enforcement).
- An Upload-source row can be upgraded by re-uploading with a higher
  version under the same source_type.

Seeds a member user in start_test_server_with_db so role tests can
log in without bespoke setup.
Add a public ZodSchema.introspect() that exposes kind/label/default/optional
plus shape (for object) and values (for enum), so renderers no longer reach
into private fields via 'as any'. Form's top-level renderer now consumes
introspect() and tests cover all schema kinds.
Replace the generic text-input fallback with purpose-built widgets:
- metricPath: text input + datalist seeded from runtime.getMetricPaths()
  (optional host hook) with a baseline of well-known paths
- color: native <input type=color>
- duration: text input with pattern + placeholder + title hint

All three drop through cleanly when the schema kind doesn't match and are
covered by new vitest cases.
… cast

defineWidget now throws on duplicate action ids (caught at registration
time instead of producing confusing runtime behavior where actions.find()
silently picks the first match). Also remove the redundant 'as any' cast on
component now that the generic propagates correctly through WidgetModule.
Object iteration order isn't a contract — without sorting, callers passing
the same params in different orders produced different URLs and therefore
different cache keys. Sort entries by key before building URLSearchParams.
Add a minimal semver-range matcher supporting exact, caret (^), and tilde (~)
ranges. The loader now compares the host SDK_VERSION against each manifest's
sdkVersion before importing the module — incompatible modules are recorded
as load failures instead of being imported with potentially missing exports.
Also assert SDK_VERSION stays in sync with package.json.
Add subscribeServers / subscribeTheme to WidgetRuntime so widgets can
re-render on host data changes. Rewrite useServers / useServer / useMetric /
useCapability / useTheme using useSyncExternalStore with memoized
snapshot getters. Add NotifyOptions / ConfirmOptions / ThemeSnapshot types
and optional notify / requestConfirm runtime slots so the host can wire
toast and confirm dialog primitives later.
…hook

Wire ActionButton through the runtime's optional requestConfirm / notify
slots so the host can plug in shadcn AlertDialog and sonner toast. Falls
back to window.confirm and console.* when no host hook is registered.
Errors are caught and surfaced as 'error' notifications instead of
bubbling. Server-side audit log emission is deferred — recorded in a
TODO comment until POST /api/widget-actions/audit lands.
mountRuntimeBridge now derives serversStore / serverByIdStore from the
['servers'] React Query cache (memoized so useSyncExternalStore sees a
stable reference), filters QueryCache events to the servers key for
subscribeServers, mirrors the <html> dark-class theme through a
MutationObserver-backed watcher, and routes notify() to sonner toasts.
Also adds a runtime-bridge.test.ts that exercises cache→summary mapping,
subscription dispatch, theme lookup, notify routing, plus a shim-drift
test that parses public/runtime/widget-sdk.js and asserts every named
export still resolves on the real SDK namespace.
The widget-sdk / react / react-dom / jsx-runtime shim files referenced by
the SPA import-map are unhashed and re-emitted on every server upgrade.
Without explicit no-cache, browsers held onto stale shims after upgrades,
breaking dynamically-imported widget modules. Detect the /runtime/ prefix
in static_files and emit Cache-Control: no-cache, no-store, must-revalidate.
When widget_type is "module", look up the registered module and use
the SDK's renderConfigForm to render fields for its configSchema.
Handles missing modules (disable save, show id-aware placeholder)
and modules with empty schemas (show a "no configurable fields"
notice). Adds tests for all three branches.
Spec §5 originally claimed widget_type would be renamed to module_id
in a single migration. The actual implementation keeps both columns
to let legacy V1 widgets and module-backed V2 widgets coexist on the
same dashboard during the rewrite period. Document the dual-column
state and the eventual cutover.

custom-widgets.mdx: move useCapability to the live-hook section
(it subscribes to the WS store via useSyncExternalStore, not REST).
Split data hooks into "live" (server snapshot) vs "domain" (REST).
custom-widgets.mdx now documents the post-review hardening: 32 MiB
zip total cap + 64-entry cap, broadened SSRF reserved ranges with
explicit redirect rejection, id-collision 409, sdkVersion semver
gating, audit logging, and the built-in ActionButton confirm/toast
behaviour.

Drop SERVERBEE_FEATURE__CUSTOM_THEMES env var and [feature].custom_themes
config table from configuration.mdx — the legacy custom-theme feature
was deleted in f2a1cb72 and the corresponding config field no longer
exists.

Drop theme_ref and "resolved theme variables" mentions from
status-page.mdx — the column was dropped in m20260526_000036 and the
public response no longer carries a theme block.
The dialog placeholders for "module not installed" and "no
configurable fields" were finalised under the dashboard namespace
(dashboard.json) in 68249ab6 / 853958e. Leave only the settings-page
toast strings here.
CI runs ultracite from the repo root and lints packages/widget-sdk/,
which the local apps/web invocation skips. Add biome overrides for the
SDK's barrel files, runtime introspection sites, and tests so the test
suite can keep using inline regex / no-op stubs / shared bitmask
helpers. Inline-suppress the two legitimate src cases (window.confirm
fallback in ActionButton, capability bitmask check in useCapability).

Picks up the unsafe biome auto-fixes: useless React fragments around a
ReactNode render, template literal for ZError prefix, and the JSDoc
closing-asterisk on WidgetRuntime.notify.
@ZingerLittleBee ZingerLittleBee merged commit 059d4c3 into main May 28, 2026
2 checks passed
@ZingerLittleBee ZingerLittleBee deleted the minnetonka-v5 branch May 28, 2026 17:25
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