Experience Plugin System (Primer)
This is a concise primer. For the full canonical architecture and operations, see ./experience_plugins. Development steps are in ./quickstart and the full ops/security/API/versioning references are linked from the Experience index.
Background & context
Most enterprise portals bolt on extensions with iframes or cross‑origin script tags. That breaks CSP, complicates zero‑trust headers, and circumvents centralized authorization. The Experience plugin system takes a stricter path:
- Same‑origin, BFF‑proxied bundle import keeps CSP
script‑src 'self' - BFF enforces per‑plugin method/path allow‑lists and SSE channels, stamping
X‑Plugin‑Idfor every request - The SPA pre‑gates routes/widgets with OpenID AuthZEN batch evaluations so unauthorized contributions never render
Compared to market leaders:
- SSO launchers (Okta/ForgeRock) expose tiles; they don’t ship a PDP‑aware extension model for actions and widgets
- ITSM portals allow UI widgets but often rely on cross‑origin scripts; Experience maintains strict CSP and centralized policy
- We emphasize observability (telemetry endpoint, OTEL) and governance (allow‑lists, rate limits), not just DX
Goals
- Secure, extensible end‑user pages/widgets integrated into Experience SPA
- Same‑origin ESM bundles via BFF proxy (CSP
script-src 'self') - Server‑side enforcement by BFF of per‑plugin permissions
High‑level flow
Contract
- Manifest
id,version,engine.experience(semver range)contributions.routes[{ path, component }],contributions.widgets[{ slot, component }]permissions.api[{ method, path }],permissions.sse[string]
- Runtime loader
- Discovery:
/api/plugins/manifests - Bundle import:
/api/plugins/bundle?entry=<url>&id=<pluginId> - Resolve
manifest→plugin.routes/widgetsrecords
- Discovery:
- SDK (per plugin)
api.fetch,api.useQuery,sse.subscribe,authz.evaluate,logger,telemetry- Stamps
X-Plugin-Idon every request
Subject normalization: When showing PDP examples, use the canonical subject
{ "type": "account", "id": "auth:account:{provider}:{user_id}" }. Older{ "type": "user" }forms are deprecated in the SPA.
BFF enforcement (required)
- Allow‑list per plugin id: method + path prefixes
- Require
X‑Plugin‑Id; attach tenant/context; rate‑limit per plugin - SSE: only declared channels; close unauthorized streams
Allow‑list templates (updated)
- Rules are templated by method + path with optional param shapes; paths are normalized and compiled to regex
- Examples:
permissions:
api:
- method: GET
path: /api/plugins/secure-echo
- method: POST
path: /api/crud/tasks/{uuid}
- method: GET
path: /api/workflow/status/{id}
sse:
- /api/events/tasks/summary
- Violations return 403 with header
X-Allowlist-Violation: 1
Quarantine (kill switch)
- Immediate ops control to disable a plugin without redeploy
- Endpoints:
POST /api/plugins/quarantine/{plugin_id}POST /api/plugins/unquarantine/{plugin_id}
- When quarantined, bundle and API/SSE calls are blocked with
X-Plugin-Quarantined: 1
Response hygiene
Vary: Cookie, X-Plugin-Idon/api/plugins/manifestsand/api/plugins/bundle
CSP & routing
- CSP remains strict: only same‑origin scripts via BFF bundle proxy
- Traefik SPA router excludes
/api/,/auth/,/events/,/configs/stream,/stream/ - SSE prefixes are normalized; API routing is template‑aware for precise matching
DX
- Simple SDK; clear errors; telemetry hooks
- Dev mode (optional): allow localhost plugin URLs only in development
- Import maps groundwork: peer deps (react, react-dom, @empowernow/ui) can be pinned and served same‑origin under
/vendor/*(externals planned)
Testing
- Unit: loader resolution, SDK stamps header
- E2E: hello‑plugin renders; 403 without
X‑Plugin-Id; widget slot; SSE gated
Appendices
- Performance caching in loader, telemetry example, health endpoints, migration helper, plugin harness checks
See also: Canonical reference ./experience_plugins, Quickstart ./quickstart, React App Overview ./overview, BFF routes & middleware ../bff/devops/experience_routing.
Visual: end‑to‑end governance
Reference snippets (ready to drop in)
Loader with base URL and caching:
// src/plugins/loader.ts
import type { LoadedPlugin, PluginManifest } from './types';
export class PluginRegistry {
private loaded = new Map<string, LoadedPlugin>();
private bundleCache = new Map<string, Promise<any>>();
async discover(): Promise<PluginManifest[]> {
const base = (import.meta as any).env?.VITE_BFF_BASE_URL || '';
const res = await fetch(`${base}/api/plugins/manifests`, { credentials: 'include' });
return res.json();
}
private importBundle(url: string) {
if (!this.bundleCache.has(url)) {
this.bundleCache.set(url, import(/* @vite-ignore */ url));
}
return this.bundleCache.get(url)!;
}
async load(manifest: PluginManifest): Promise<LoadedPlugin> {
const base = (import.meta as any).env?.VITE_BFF_BASE_URL || '';
const url = `${base}/api/plugins/bundle?entry=${encodeURIComponent(manifest.id)}&id=${encodeURIComponent(manifest.id)}`;
const mod = await this.importBundle(url);
const plugin: LoadedPlugin = {
id: manifest.id,
manifest,
routes: mod.default?.routes ?? mod.routes ?? {},
widgets: mod.default?.widgets ?? mod.widgets ?? {},
};
this.loaded.set(manifest.id, plugin);
return plugin;
}
getLoaded() { return Array.from(this.loaded.values()); }
}
Shared registry and PDP‑gated routes:
// src/plugins/registry.ts
import type { LoadedPlugin, PluginManifest } from './types';
import { PluginRegistry } from './loader';
let cached: Promise<LoadedPlugin[]> | null = null;
export function getLoadedPlugins(): Promise<LoadedPlugin[]> {
if (cached) return cached;
const reg = new PluginRegistry();
cached = (async () => {
const manifests: PluginManifest[] = await reg.discover();
return Promise.all(manifests.map(m => reg.load(m)));
})();
return cached;
}
// src/plugins/DynamicRoutes.tsx (excerpt)
const routeGuard = useAuthorization('plugin.route', 'view', true);
useEffect(() => {
(async () => {
const plugins = await getLoadedPlugins();
const routes = plugins
.flatMap(p => (p.manifest.contributions?.routes || []).map(r => ({
path: r.path,
element: routeGuard.authorized ? React.createElement(p.routes?.[r.component] || (() => null)) : null
})))
.filter(r => r.element);
setElements(routes);
})();
}, []);
Dashboard widgets from plugins:
// src/pages/Dashboard.tsx (excerpt)
import { getLoadedPlugins } from '../plugins/registry';
useEffect(() => {
(async () => {
const plugins = await getLoadedPlugins();
const widgets = plugins.flatMap(p => Object.entries(p.widgets || {}).map(([name, Comp]) => ({
id: `${p.id}:${name}`,
title: name,
resource: 'plugin.widget',
action: 'view',
render: () => <Comp />,
})));
setPluginWidgets(widgets);
})();
}, []);
Plugin SDK with stamped requests and SSE:
// src/plugins/sdk.ts (excerpt)
export function createPluginSdk(pluginId: string, qc: QueryClient) {
const base = (import.meta as any).env?.VITE_BFF_BASE_URL || '';
const apiFetch = async (path: string, init?: RequestInit) => {
const res = await fetch(`${base}/api${path.startsWith('/') ? path : '/' + path}` , {
...(init || {}),
credentials: 'include',
headers: { 'X-Plugin-Id': pluginId, ...(init?.headers || {}) }
});
if (!res.ok) throw new Error(`${res.status}`);
return res;
};
const subscribe = (path: string, onMessage: (data: any) => void) => {
const url = `${base}/api${path}`;
const es = new EventSource(url, { withCredentials: true } as any);
es.onmessage = (e) => onMessage(JSON.parse(e.data));
return () => es.close();
};
return { /* api, sse, authz ... as described above */ };
}