Plugin Anatomy
Deep dive into plugin structure: directory layout, plugins.yaml schema, entry points, exports, bundle configuration, and component organization.
πLearning Objectives
- Understand the complete plugin directory structure and purpose of each file
- Master the
plugins.yamlmanifest schema (the source of truth) - Learn about entry points, exports, and how the platform loads your code
- Understand routes, widgets, and component organization patterns
- Configure security settings: permissions, integrity, and PDP scoping
- Set up the build process with esbuild
πPlugin Directory Structure
File Locations
| Purpose | Path |
|---|---|
| Tenant registry (Source of Truth) | ServiceConfigs/BFF/config/plugins/index.yaml |
| Plugin manifests (per-plugin) | ServiceConfigs/BFF/config/plugins/manifests/<plugin-id>.yaml |
| Plugin bundles | ServiceConfigs/BFF/plugins/<pluginId>/<version>/index.esm.js |
| BFF container mounts | /app/plugins and /app/config |
Complete Plugin Structure
Each plugin version gets its own directory. This allows A/B testing, rollbacks, and multiple versions running simultaneously for different tenants.
πPlugin Manifest Schema
manifest.jsonβin EmpowerNow, plugin configuration is split: index.yaml (tenant registry) + manifests/<plugin-id>.yaml (per-plugin config). This approach means you can manage all plugins across tenants, and the BFF hot-reloads changes without rebuilding.
Complete Schema Reference
tenants:
# Use "*" for all tenants, or specific host for tenant isolation
experience.self.empowernow.ai:
- id: my-plugin # Unique identifier (required)
title: "My Plugin" # Display name in navigation
version: "1.0.0" # Semantic version
enabled: true # Toggle without removing
# π― Host compatibility
engine:
experience: ">=1.0.0" # Required platform version
# π Security configuration (CRITICAL)
authorization:
pdp_application: "my-plugin" # Policy namespace isolation
context:
service_tier: "standard" # free/standard/premium/enterprise
compliance_level: "medium" # low/medium/high/critical
data_classification: "general" # public/general/internal/pii/secret
# π¦ Bundle configuration
bundle:
file: "/app/plugins/my-plugin/1.0.0/index.esm.js"
integrity: "sha256:abc123..." # Optional: hash verification
# π API permissions (allowlist)
permissions:
api:
- method: GET
path: /api/crud/tasks
- method: POST
path: /api/crud/execute
- method: POST
path: /access/v1/evaluation
sse: # Server-Sent Events endpoints
- /api/events/tasks/summary
# πΊοΈ Routes and widgets
contributions:
routes:
- path: /my-plugin
component: Dashboard
title: "My Plugin"
resource: plugin.route # PDP resource hint
action: view # PDP action hint
- path: /my-plugin/settings
component: SettingsPage
title: "Settings"
widgets:
- slot: dashboard.main
component: SummaryWidget
resource: plugin.widget
action: view
Key Fields Explained
| Field | Description | Required |
|---|---|---|
id | Unique plugin identifier (kebab-case) | Yes |
version | Semantic version string | Yes |
engine.experience | Minimum platform version (semver range) | Yes |
bundle.file | Absolute path to ESM bundle in container | Yes |
bundle.integrity | SHA256 hash for tamper detection | No* |
authorization.pdp_application | Policy namespace for isolation | Yes |
permissions.api[] | Method + path allowlist for API calls | Yes |
contributions.routes[] | URL paths and component mappings | Yes |
Always set bundle.integrity in production. Without it, a compromised bundle can be loaded without detection.
πͺEntry Point (index.tsx)
Minimal Entry Point
// src/index.tsx - The simplest valid plugin
const React = (globalThis as any).React;
export function Dashboard() {
return React.createElement('div', {
style: {
padding: '24px',
background: 'var(--card-bg)',
color: 'var(--main-text)'
}
}, 'Hello from My Plugin!');
}
// Required export: routes object
export const routes = { Dashboard };
// Optional: widgets for dashboard slots
export const widgets = {};
// Default export for compatibility
export default { routes, widgets };
Production Entry Point
// src/index.tsx - Full-featured plugin
import { Protected, IfCan } from '@experience/plugin-auth';
import { CrudGrid } from '@empowernow/crud-ui';
const React = (globalThis as any).React;
const { usePlugin } = (globalThis as any).PluginSDK || {};
// Main page component
export function Dashboard() {
const { sdk } = usePlugin();
return React.createElement('div', { style: styles.container },
React.createElement('h1', { style: styles.title }, 'My Plugin'),
// Authorization-gated content
React.createElement(IfCan, { resource: 'data', action: 'view' },
React.createElement(CrudGrid, {
sdk,
from: 'system.object.get_all',
columns: ['Name', 'Status', 'Created'],
serverSidePagination: true,
pageSize: 50
})
)
);
}
// Detail page for individual items
export function DetailPage() {
return React.createElement('div', null, 'Detail View');
}
// Dashboard widget (rendered in slots)
export function SummaryWidget() {
return React.createElement('div', {
style: {
padding: '16px',
background: 'var(--card-bg)',
borderRadius: '8px'
}
}, 'Quick Summary Widget');
}
// Export mappings (component names must match plugins.yaml)
export const routes = { Dashboard, DetailPage };
export const widgets = { SummaryWidget };
export default { routes, widgets };
The component field in plugins.yaml must exactly match the exported function name. If you export Dashboard, the manifest must reference component: Dashboard.
π§Build Configuration (esbuild)
build.mjs
// build.mjs
import esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/index.tsx'],
bundle: true,
format: 'esm', // ESM required
platform: 'browser',
target: 'es2020',
outfile: 'dist/index.esm.js',
// Critical: these are provided by the platform
external: [
'react',
'react-dom',
'@empowernow/ui',
'@empowernow/crud-ui',
'@experience/plugin-auth'
],
// Production settings
minify: true,
sourcemap: true,
// Tree-shaking for smaller bundles
treeShaking: true
});
console.log('β
Build complete: dist/index.esm.js');
package.json
{
"name": "my-plugin",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "node build.mjs",
"watch": "node build.mjs --watch",
"integrity": "powershell -Command \"(Get-FileHash dist/index.esm.js -Algorithm SHA256).Hash.ToLower()\""
},
"devDependencies": {
"esbuild": "^0.21.0",
"typescript": "^5.5.0"
}
}
# PowerShell
$hash = (Get-FileHash dist/index.esm.js -Algorithm SHA256).Hash.ToLower()
Write-Host "sha256:$hash"
# Then add to plugins.yaml
bundle:
integrity: "sha256:$hash"
π§©Component Organization Patterns
Pattern: Feature-Based Organization
# Group by feature, not file type
src/
βββ features/
β βββ users/
β β βββ UserList.tsx
β β βββ UserDetail.tsx
β β βββ userService.ts
β β βββ useUsers.ts
β β
β βββ tasks/
β βββ TaskList.tsx
β βββ taskService.ts
β βββ useTasks.ts
β
βββ shared/
β βββ components/ # Cross-feature UI
β βββ hooks/ # Cross-feature logic
β
βββ index.tsx # Re-exports everything
Pattern: Lazy Loading for Large Plugins
// Only load DetailPage when navigated to
const DetailPage = React.lazy(() => import('./pages/DetailPage'));
export function Dashboard() {
return React.createElement(React.Suspense, {
fallback: React.createElement('div', null, 'Loading...')
},
React.createElement(DetailPage)
);
}
βοΈPlugin SDK Reference
SDK Namespaces
The SDK exposes five namespaces plus metadata:
interface PluginSDK {
metadata: {
pluginId: string;
pdpApplication: string; // From manifest.authorization.pdp_application
baseContext: Record<string, any>; // From manifest.authorization.context
};
api: { // HTTP requests
fetch: (path, init?) => Promise<Response>;
useQuery: (key[], fn, opts?) => UseQueryResult;
};
sse: { // Server-Sent Events
subscribe: (path, onMessage) => () => void;
unsubscribeAll: () => void;
};
authz: { // Authorization checks
evaluate: (req) => Promise<AuthZENResponse>;
batchEvaluate: (evaluations[]) => Promise<AuthZENBatchResponse>;
};
crud: { // Orchestration Service
use: (system, object, action, params?) => UseQueryResult;
execute: (system, object, action, params?) => Promise<any>;
run: (workflowName, data) => Promise<WorkflowResponse>;
};
telemetry: { // Custom event tracking
track: (event, properties?) => void;
};
logger: Console;
}
api.fetch() β Authenticated Requests
// GET request with auto-stamped X-Plugin-Id header
const response = await sdk.api.fetch('/crud/tasks');
const tasks = await response.json();
// POST with body
const response = await sdk.api.fetch('/crud/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'New task' })
});
authz.evaluate() β Authorization Checks
// Check if user can perform action on resource
const result = await sdk.authz.evaluate({
resource: { type: 'workflow', id: workflowId },
action: { name: 'start' },
context: { reason: 'manual_launch' }
});
if (result.decision === true) {
// User is authorized
}
authz.batchEvaluate() β Bulk Permissions (3x Faster!)
// Check multiple permissions in single API call
const results = await sdk.authz.batchEvaluate([
{ resource: { type: 'user', id: userId }, action: { name: 'edit' } },
{ resource: { type: 'user', id: userId }, action: { name: 'delete' } },
{ resource: { type: 'user', id: userId }, action: { name: 'reset-password' } }
]);
const permissions = {
canEdit: results.decisions[0]?.decision === 'Permit',
canDelete: results.decisions[1]?.decision === 'Permit',
canReset: results.decisions[2]?.decision === 'Permit'
};
crud.use() β Data Fetching Hook
// React hook for CRUD data with automatic caching
const { data, isLoading, error, refetch } = sdk.crud.use(
'av_openldap', // system
'account', // object type
'get_all_users', // action
{ page_size: 50 } // params
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return data.map(user => <li key={user.id}>{user.name}</li>);
sse.subscribe() β Real-Time Updates
// Subscribe to server-sent events
useEffect(() => {
const unsubscribe = sdk.sse.subscribe('/events/tasks/summary', data => {
setCount(data.pending_count);
});
return unsubscribe; // Cleanup on unmount
}, []);
Every SDK request includes X-Plugin-Id header. The BFF validates against the plugin's allowlist and rejects mismatches with 403 X-Allowlist-Violation: 1.
πAuthorization Patterns
Protected Components
// β BAD: Manual auth check (25 lines of boilerplate)
const [canDelete, setCanDelete] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const result = await sdk.authz.evaluate({...});
setCanDelete(result.decision === 'Permit');
} finally {
setLoading(false);
}
})();
}, []);
if (loading) return <button disabled>Loading...</button>;
if (!canDelete) return null;
return <button onClick={handleDelete}>Delete</button>;
// β
GOOD: SDK handles everything (1 line!)
<Protected.Button resource="user" action="delete" onClick={handleDelete}>
Delete
</Protected.Button>
Conditional Sections with IfCan
// Show entire section only if authorized
<IfCan resource="admin" action="view">
<AdminPanel />
</IfCan>
// Multiple permissions (any match)
const { canAny } = usePluginAuth();
if (canAny([
['user', 'edit'],
['user', 'delete']
])) {
return <UserActionsToolbar />;
}
Programmatic Checks
const { can, canAll, canAny } = usePluginAuth();
// Single permission
if (can('report', 'export')) {
showExportButton = true;
}
// Multiple permissions (all required)
if (canAll([
['budget', 'view'],
['budget', 'approve']
])) {
showApprovalUI = true;
}
- β Authorization check (auto-hides if denied)
- β Loading state with spinner
- β Error handling with toast notifications
- β Success feedback
- β ARIA accessibility labels
- β Hover animations
π‘οΈAPI Permissions & Enforcement
plugins.yaml. This prevents plugins from accessing endpoints they weren't designed for.
How Enforcement Works
- Request: Plugin makes API call via SDK
- Header Stamp: SDK adds
X-Plugin-Id: my-plugin - Validation: BFF checks method + path against
permissions.api[] - Response: If allowed, request proceeds; if denied,
403
Permission Configuration
permissions:
api:
# Exact path matching
- method: GET
path: /api/crud/tasks
# Path templates with wildcards
- method: GET
path: /api/crud/tasks/{id}
# PDP authorization endpoint
- method: POST
path: /access/v1/evaluation
sse:
# Server-Sent Events endpoints
- /api/events/tasks/summary
- /crud/events/workflow/status
BFF Routes Reference
| Route | Purpose | Auth |
|---|---|---|
GET /api/plugins/manifests | List all plugin manifests | Session required |
GET /api/plugins/bundle?id=... | Serve plugin bundle | Session required |
Enforcement Responses
| Scenario | Response | Header |
|---|---|---|
| Not in allowlist | 403 Forbidden | X-Allowlist-Violation: 1 |
| Integrity mismatch | 409 Conflict | X-Integrity-Error: 1 |
| No session | 401 Unauthorized | β |
πCRUD Integration
CrudGrid for data tables and CrudForm for workflows.
CrudGrid β Data Table with Everything Built-in
<CrudGrid
sdk={sdk}
from="system.object.get_all"
columns={['Name', 'Email', 'Status']}
serverSidePagination={true} // Handles 10,000+ items
pageSize={50}
enableSelection={true}
multiSelect={true}
rowActions={[
'View: view', // Format: "Label: action"
'Edit: edit',
'Delete: delete'
]}
onRowClick={(row) => handleRowClick(row)}
onSelectionChange={(selected) => setSelected(selected)}
/>
What CrudGrid Provides
- β Server-side pagination (handles 10,000+ items)
- β Sorting and filtering
- β Row selection (single/multi)
- β Row actions with authorization checks
- β Loading states and error handling
- β Empty states
- β Responsive design
Execute Workflows
// Run a workflow
const result = await sdk.crud.run('av_openldap_create_user', {
FirstName: 'John',
LastName: 'Doe',
Email: 'john.doe@example.com'
});
console.log('User created:', result.workflow_id);
// Monitor workflow status
const status = await sdk.crud.getWorkflowStatus(result.workflow_id);
console.log('Status:', status.status); // 'running', 'completed', 'failed'
Subscribe to Workflow Updates
const unsubscribe = sdk.crud.subscribeWorkflowStatus(executionId, (update) => {
setStatus(update);
if (update.status === 'completed') {
console.log('Workflow completed:', update.result);
unsubscribe();
}
});
// Requires SSE permission:
// sse:
// - /crud/events/workflow/status
π¬Hands-on Labs
Open the plugins configuration (replace <YourRepoRoot> with your project root):
# Windows β tenant registry
type <YourRepoRoot>\ServiceConfigs\BFF\config\plugins\index.yaml
# macOS/Linux β tenant registry
cat <YourRepoRoot>/ServiceConfigs/BFF/config/plugins/index.yaml
# View a plugin manifest (e.g. it-shop)
cat <YourRepoRoot>/ServiceConfigs/BFF/config/plugins/manifests/it-shop.yaml
For each plugin, identify:
- The
pdp_applicationvalue - How many API endpoints are in the permissions allowlist
- Which routes are contributed
- Why does each plugin have its own
pdp_application? - What happens if an API call is made to an endpoint not in
permissions.api?
Create directory structure (replace <YourRepoRoot> with your project root):
mkdir -p <YourRepoRoot>/plugins/anatomy-lab/1.0.0/src
cd <YourRepoRoot>/plugins/anatomy-lab/1.0.0
Windows: use backslashes in path if in PowerShell; or use Git Bash/WSL for the same commands.
Create package.json with build scripts
Create src/index.tsx with exports:
const React = (globalThis as any).React;
export function Dashboard() {
return React.createElement('div', {
style: {
padding: '24px',
background: 'var(--card-bg)',
borderRadius: '12px'
}
},
React.createElement('h1', {
style: { color: 'var(--main-text)' }
}, 'π¬ Anatomy Lab Plugin')
);
}
export const routes = { Dashboard };
export const widgets = {};
export default { routes, widgets };
Build and generate integrity hash:
npm install
npm run build
npm run integrity
You should have dist/index.esm.js and a SHA256 hash ready for plugins.yaml.
Add SDK usage to your plugin:
const React = (globalThis as any).React;
const { useState, useEffect } = React;
const sdk = (globalThis as any).pluginSdk;
export function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
(async () => {
try {
// Use SDK to fetch data
const response = await sdk.api.fetch('/api/health');
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
})();
}, []);
if (loading) return React.createElement('div', null, 'Loading...');
if (error) return React.createElement('div', null, 'Error: ' + error);
return React.createElement('div', { style: { padding: '24px' } },
React.createElement('h1', null, 'SDK Lab'),
React.createElement('pre', null, JSON.stringify(data, null, 2))
);
}
Add the required API permission to plugins.yaml:
permissions:
api:
- method: GET
path: /api/health
Rebuild and test in browser:
npm run build
# Restart BFF or wait for hot-reload
Your plugin should display the health check response. Check browser DevTools Network tab to see the X-Plugin-Id header.
- What happens if you call an endpoint not in
permissions.api? - Can you see the
X-Plugin-Idheader in DevTools? - What's the difference between
sdk.api.fetch()and nativefetch()?
Add an authorization-gated button:
export function Dashboard() {
const [canDelete, setCanDelete] = useState(false);
const [checking, setChecking] = useState(true);
useEffect(() => {
(async () => {
try {
const result = await sdk.authz.evaluate({
resource: { type: 'task', id: 'demo-task' },
action: { name: 'delete' }
});
setCanDelete(result.decision === true);
} finally {
setChecking(false);
}
})();
}, []);
return React.createElement('div', { style: { padding: '24px' } },
React.createElement('h1', null, 'Authorization Lab'),
checking
? React.createElement('p', null, 'Checking permissions...')
: canDelete
? React.createElement('button', {
onClick: () => alert('Delete clicked!'),
style: { background: '#ef4444', color: 'white', padding: '8px 16px' }
}, 'Delete Task')
: React.createElement('p', { style: { color: '#6b7280' } },
'You do not have permission to delete.')
);
}
Add PDP permission to plugins.yaml:
permissions:
api:
- method: POST
path: /access/v1/evaluation
The plugin should check permissions and conditionally render the delete button based on PDP response.
π§Troubleshooting Plugin Anatomy Issues
Route renders blank. Console shows "Component 'Dashboard' not found in plugin exports."
The component name in plugins.yaml doesn't match the exported function name.
Ensure exact match between export and manifest:
// index.tsx
export function Dashboard() { ... } // Function name
# plugins.yaml
component: Dashboard # Must match exactly
Plugin crashes with "Cannot read property 'createElement' of undefined."
React wasn't marked as external in esbuild, so the plugin bundled its own copy which conflicts with the platform's React.
// build.mjs
external: ['react', 'react-dom'] // Mark as external
// index.tsx
const React = (globalThis as any).React; // Use platform's React
Network tab shows 404 for plugin bundle. "Plugin failed to load."
The bundle.file path doesn't match where the file was actually copied.
# Verify file exists at expected path (replace <YourRepoRoot> with your project root)
# Windows
dir <YourRepoRoot>\ServiceConfigs\BFF\plugins\my-plugin\1.0.0\
# macOS/Linux
ls <YourRepoRoot>/ServiceConfigs/BFF/plugins/my-plugin/1.0.0/
# Update plugins.yaml to match
bundle:
file: "/app/plugins/my-plugin/1.0.0/index.esm.js"
API calls return 403 Forbidden. Response includes header X-Allowlist-Violation: 1.
The endpoint is not in the plugin's permissions.api allowlist, or the method doesn't match.
# Check what you're calling
# sdk.api.fetch('/api/crud/tasks', { method: 'DELETE' })
# Add to plugins.yaml
permissions:
api:
- method: DELETE
path: /api/crud/tasks/{id}
Bundle request returns 409 Conflict with X-Integrity-Error: 1.
The bundle.integrity hash in plugins.yaml doesn't match the actual file hash. This happens when:
- You rebuilt the bundle but didn't update the hash
- The file was modified after hash was generated
- Wrong file was deployed
# Regenerate the hash
$hash = (Get-FileHash dist/index.esm.js -Algorithm SHA256).Hash.ToLower()
Write-Host "sha256:$hash"
# Update plugins.yaml
bundle:
integrity: "sha256:$hash"
Plugin looks fine in development but colors are wrong in production. CSS variables like var(--card-bg) aren't resolving.
You're using custom CSS variable names instead of the platform's standard variables, or styles are being overridden.
// Use platform's CSS variables
style: {
background: 'var(--card-bg)', // β Platform variable
color: 'var(--main-text)', // β Platform variable
border: '1px solid var(--border)', // β Platform variable
// NOT these custom ones:
// background: 'var(--my-custom-bg)', // β Won't work
}
βKnowledge Check
ServiceConfigs/BFF/config/plugins/index.yaml (tenant registry) and ServiceConfigs/BFF/config/plugins/manifests/<plugin-id>.yaml (per-plugin manifest) β not a manifest.json in the plugin directory. This split approach allows managing all plugins from a centralized config directory.
At minimum: export const routes = { ComponentName }. Optionally also export const widgets = {} and export default { routes, widgets }.
The platform provides React via window.React. If your plugin bundles its own copy, you get two React instances which causes hooks to break and components to fail.
It's a SHA256 hash of the bundle file. The BFF verifies this hash before serving the bundle, preventing:
- Tampered or malicious bundles
- Accidental deployment of wrong version
- Man-in-the-middle attacks
They must match exactly. If plugins.yaml says component: Dashboard, your entry point must have export function Dashboard() or export const routes = { Dashboard }.
ServiceConfigs/BFF/plugins/<pluginId>/<version>/index.esm.js
This gets mounted to /app/plugins in the BFF container.
- api: HTTP requests with auto-stamped headers (
fetch,useQuery) - sse: Server-Sent Events for real-time updates (
subscribe,unsubscribeAll) - authz: PDP authorization checks (
evaluate,batchEvaluate) - crud: Orchestration Service integration (
use,execute,run) - telemetry: Custom event tracking (
track)
batchEvaluate() sends multiple permission checks in a single API callβ3x faster than individual calls.
Use case: Table rows with multiple actions per row. Instead of N Γ 3 API calls, you make 1 batch call.
The BFF returns 403 Forbidden with header X-Allowlist-Violation: 1.
Fix: Add the endpoint to permissions.api[] in plugins.yaml with the correct method and path.
pdp_application is the policy namespace for the plugin. It isolates the plugin's authorization policies from other plugins.
When sdk.authz.evaluate() is called, the SDK automatically adds this to the request context so PDP evaluates against the correct policy namespace.
Protected.Button and similar components handle everything automatically:
- Authorization check (auto-hides if denied)
- Loading spinner while checking
- Error handling with toast notifications
- Success feedback
- ARIA accessibility labels
Manual checks require 25+ lines of boilerplate; Protected components are 1 line.
πDay 12 Checkpoint
- Understand the complete plugin directory structure
- Can read and write valid plugins.yaml entries
- Know what exports the entry point must provide
- Understand esbuild configuration and external dependencies
- Can generate and verify bundle integrity hashes
- Know how component names map between manifest and exports
- Understand all 5 Plugin SDK namespaces (api, sse, authz, crud, telemetry)
- Can use Protected components for authorization-gated UI
- Understand API permissions and allowlist enforcement
- Can use batchEvaluate() for efficient permission checks
- Know how to integrate with CrudGrid and CRUD operations
- Can troubleshoot common anatomy and SDK issues