Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/features/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ Each widget renderer composes the shared `<Widget>` primitive and receives only

A plugin with the `dashboard.widgets.register` permission can register widgets from its admin-window entrypoint via `api.dashboard.widgets.register(...)`. The widget's React `component` runs in the **admin app context** (not the QuickJS sandbox) — plugin server code runs sandboxed, but admin / dashboard widgets render in-process.

The host mounts plugin-owned widgets under the same `PluginContext` in the dashboard grid, Customize mode, and Block Library preview. Widget components can therefore use `usePluginContext`, `usePluginSettings`, `usePluginRoutes`, and other host hooks with the plugin's identity, grants, settings, and route scope intact.

Plugin-owned analytics tiles such as `visitors` or `top-pages` are plugin widgets, not first-party dashboard widgets. They are not seeded into the default layout; once a plugin registers them, users can add them from the Block Library and their saved layout references the plugin-owned id.

---
Expand Down Expand Up @@ -333,6 +335,8 @@ That's it. Users see it in the BlockLibrary; dragging it onto the grid persists

Plugins with `dashboard.widgets.register` permission register widgets from their admin-window entrypoint via `api.dashboard.widgets.register(...)`. The widget's `component` runs in the **admin React app** (not the QuickJS sandbox). Plugin server code runs sandboxed; plugin dashboard widgets do not.

Every dashboard render location supplies the widget's plugin context, including the grid, Customize mode, and the Block Library preview. Host hooks imported from `@instatic/host-hooks` therefore resolve the registering plugin just as they do in panels, app pages, and canvas overlays.

### Gate widget data on capability

Dashboard widget definitions do not carry a `requires` field. Gate sensitive data at the endpoint that feeds the widget:
Expand Down
4 changes: 2 additions & 2 deletions docs/features/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ That trust level is gated by one permission: **`editor.code`** (risk: dangerous)
- `adminPages[].content.assetPath` is pinned to the plugin's own `/uploads/plugins/{id}/{version}` subtree so a manifest can't point the dynamic import at foreign code.
- The install review dialog (always shown — even for zero-permission plugins) calls out `editor.code` with a dedicated unsandboxed-code warning.

Inside the admin window, plugin React surfaces (panels, app pages, canvas overlays) mount under a `PluginContext` carrying the granted permission set; permission-gated host hooks enforce against it — `useEditorStore` from `@instatic/host-hooks` requires `editor.store.read` and exposes no write accessor (writes go through `api.editor.store.transaction`, which requires `editor.store.write`).
Inside the admin window, plugin React surfaces (panels, app pages, canvas overlays, and dashboard widgets) mount under a `PluginContext` carrying the granted permission set; permission-gated host hooks enforce against it — `useEditorStore` from `@instatic/host-hooks` requires `editor.store.read` and exposes no write accessor (writes go through `api.editor.store.transaction`, which requires `editor.store.write`). Dashboard widgets receive the same context in the grid, Customize mode, and the Block Library preview.

### What's available inside

Expand Down Expand Up @@ -501,7 +501,7 @@ export function activate(api) {
}
```

Widget ids must be namespaced under the plugin id (`<pluginId>.<rest>`). The component should compose the host `Widget` primitive so plugin tiles use the same card chrome, drag handle, menu, loading state, and tint behavior as first-party widgets.
Widget ids must be namespaced under the plugin id (`<pluginId>.<rest>`). The component should compose the host `Widget` primitive so plugin tiles use the same card chrome, drag handle, menu, loading state, and tint behavior as first-party widgets. It may use `usePluginContext`, `usePluginSettings`, `usePluginRoutes`, and the other `@instatic/host-hooks`; the host supplies the registering plugin's context at every widget mount.

### CMS routes — requires `cms.routes` (public routes also require `cms.routes.public`)

Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/architecture/dashboard-widget-context-mounts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'

const COMPONENT_ROOT = join(import.meta.dir, '../../admin/pages/dashboard/components')

function countMounts(fileName: string): number {
const source = readFileSync(join(COMPONENT_ROOT, fileName), 'utf8')
return source.match(/<DashboardWidgetMount\b/g)?.length ?? 0
}

describe('dashboard widget context mounts', () => {
it('routes view, customize, and library preview renderers through the shared mount', () => {
expect(countMounts('DashboardGrid.tsx')).toBe(2)
expect(countMounts('BlockLibrary.tsx')).toBe(1)
})
})
123 changes: 123 additions & 0 deletions src/__tests__/plugins/pluginDashboardWidgetContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { createRef } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DndContext } from '@dnd-kit/core'
import { DashboardGrid } from '@admin/pages/dashboard/components/DashboardGrid'
import {
usePluginContext,
usePluginRoutes,
usePluginSettings,
} from '@admin/plugin-host-hooks'
import {
activateEditorPlugin,
bindDashboardWidgetIconResolver,
pluginRuntime,
} from '@core/plugins/runtime'
import { dashboardWidgetRegistry } from '@core/dashboard'
import type {
PixelArtIconComponent,
PluginDashboardWidget,
PluginManifest,
} from '@core/plugin-sdk'

const NoopIcon = (() => null) as unknown as PixelArtIconComponent

const manifest: PluginManifest = {
id: 'acme.analytics',
name: 'Analytics',
version: '1.0.0',
apiVersion: 1,
permissions: ['editor.code', 'dashboard.widgets.register'],
grantedPermissions: ['editor.code', 'dashboard.widgets.register'],
entrypoints: { editor: 'editor/index.js' },
resources: [],
adminPages: [],
}

let requests: Array<{ input: string; credentials: RequestCredentials | undefined }> = []
let originalFetch: typeof globalThis.fetch

function ContextWidget() {
const context = usePluginContext()
const settings = usePluginSettings<{ sampleRate: number }>()
const routes = usePluginRoutes()
return (
<>
<span data-testid="plugin-widget-context">
{context.pluginId}|{context.pluginVersion}|{context.surfaceId}|{context.surfaceLabel}|
{settings.sampleRate}
</span>
<button type="button" onClick={() => void routes.fetch('/status')}>
Load status
</button>
</>
)
}

beforeEach(() => {
originalFetch = globalThis.fetch
globalThis.fetch = async (input, init) => {
requests.push({ input: String(input), credentials: init?.credentials })
return new Response('{}', { status: 200 })
}
requests = []
dashboardWidgetRegistry.reset()
pluginRuntime.reset()
bindDashboardWidgetIconResolver(() => NoopIcon)
})

afterEach(() => {
globalThis.fetch = originalFetch
dashboardWidgetRegistry.reset()
pluginRuntime.reset()
cleanup()
})

describe('plugin dashboard widget context', () => {
it('provides plugin identity, settings, and scoped routes in the dashboard grid', async () => {
pluginRuntime.setPluginSettings(manifest.id, { sampleRate: 7 })
await activateEditorPlugin(manifest, {
activate(api) {
api.dashboard.widgets.register({
id: 'acme.analytics.pageviews',
name: 'Pageviews',
description: 'Site-wide pageview chart',
iconName: 'chart',
defaultSize: 6,
tint: 'lilac',
component: ContextWidget as PluginDashboardWidget['component'],
})
},
})

const definition = dashboardWidgetRegistry.get('acme.analytics.pageviews')
expect(definition).toBeDefined()

render(
<DndContext>
<DashboardGrid
items={[{ id: 'acme.analytics.pageviews', col: 1, row: 1, size: 6, rows: 3 }]}
definitions={new Map([[definition!.id, definition!]])}
editing={false}
onResize={() => {}}
onResizeRows={() => {}}
onAddBlock={() => {}}
gridRef={createRef<HTMLDivElement>()}
dropTarget={null}
/>
</DndContext>,
)

expect(screen.getByTestId('plugin-widget-context').textContent).toBe(
'acme.analytics|1.0.0|acme.analytics.pageviews|Pageviews|7',
)

fireEvent.click(screen.getByRole('button', { name: 'Load status' }))
await waitFor(() => {
expect(requests).toEqual([{
input: '/admin/api/cms/plugins/acme.analytics/runtime/status',
credentials: 'include',
}])
})
})
})
28 changes: 28 additions & 0 deletions src/__tests__/plugins/pluginDashboardWidgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ const baseManifest: PluginManifest = {
adminPages: [],
}

const pluginContext = {
version: baseManifest.version,
grantedPermissions: baseManifest.grantedPermissions ?? [],
}

beforeEach(() => {
dashboardWidgetRegistry.reset()
pluginRuntime.reset()
Expand Down Expand Up @@ -76,6 +81,7 @@ describe('dashboardWidgetRegistry — namespace + lifecycle', () => {
dashboardWidgetRegistry.register({
id: 'pageviews',
ownerId: 'acme.analytics',
pluginContext,
name: 'Pageviews',
description: 'Bad — no namespace',
icon: NoopIcon,
Expand All @@ -90,6 +96,7 @@ describe('dashboardWidgetRegistry — namespace + lifecycle', () => {
dashboardWidgetRegistry.register({
id: 'acme.analytics.pageviews',
ownerId: 'acme.analytics',
pluginContext,
name: 'Pageviews',
description: 'Site-wide pageview chart',
icon: NoopIcon,
Expand All @@ -101,6 +108,21 @@ describe('dashboardWidgetRegistry — namespace + lifecycle', () => {
expect(dashboardWidgetRegistry.get('acme.analytics.pageviews')?.tint).toBe('lilac')
})

it('rejects a plugin widget without host context metadata', () => {
expect(() =>
dashboardWidgetRegistry.register({
id: 'acme.analytics.pageviews',
ownerId: 'acme.analytics',
name: 'Pageviews',
description: 'Missing plugin context',
icon: NoopIcon,
defaultSize: 6,
tint: 'lilac',
render: NoopBody,
}),
).toThrow(/must include its host context metadata/)
})

it('drops every widget for a given owner via unregisterByOwner', () => {
dashboardWidgetRegistry.register({
id: 'core-only',
Expand All @@ -115,6 +137,7 @@ describe('dashboardWidgetRegistry — namespace + lifecycle', () => {
dashboardWidgetRegistry.register({
id: 'acme.analytics.first',
ownerId: 'acme.analytics',
pluginContext,
name: 'First',
description: 'one',
icon: NoopIcon,
Expand All @@ -125,6 +148,7 @@ describe('dashboardWidgetRegistry — namespace + lifecycle', () => {
dashboardWidgetRegistry.register({
id: 'acme.analytics.second',
ownerId: 'acme.analytics',
pluginContext,
name: 'Second',
description: 'two',
icon: NoopIcon,
Expand Down Expand Up @@ -188,6 +212,10 @@ describe('plugin runtime — dashboard.widgets.register', () => {
expect(captured).not.toBeNull()
const def = dashboardWidgetRegistry.get('acme.analytics.pageviews')
expect(def?.ownerId).toBe('acme.analytics')
expect(def?.pluginContext).toEqual({
version: '1.0.0',
grantedPermissions: ['dashboard.widgets.register'],
})
expect(def?.tint).toBe('mint')
expect(def?.icon).toBe(NoopIcon)
})
Expand Down
4 changes: 2 additions & 2 deletions src/admin/pages/dashboard/components/BlockLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
LIBRARY_MAX_HEIGHT,
LIBRARY_MIN_HEIGHT,
} from '../hooks/useDashboardLayout'
import { DashboardWidgetMount } from './DashboardWidgetMount'
import styles from './BlockLibrary.module.css'

/**
Expand Down Expand Up @@ -419,7 +420,6 @@ function LibraryItem({ widget, onAdd }: LibraryItemProps) {
attributes,
isDragging,
} = useDraggable({ id: `${LIBRARY_DRAG_PREFIX}${widget.id}` })
const Render = widget.render

// Preview height in pixels, matching what the same widget will occupy
// on the dashboard once dropped. The dashboard uses
Expand Down Expand Up @@ -487,7 +487,7 @@ function LibraryItem({ widget, onAdd }: LibraryItemProps) {
<div className={styles.itemPreview} aria-hidden="true">
{/* Render with edit-mode chrome so Widget previews do not
introduce nested buttons inside the add/drag surface. */}
<Render span={widget.defaultSize} editing />
<DashboardWidgetMount definition={widget} span={widget.defaultSize} editing />
</div>
</button>
<footer className={styles.itemFoot}>
Expand Down
7 changes: 3 additions & 4 deletions src/admin/pages/dashboard/components/DashboardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
readDashboardGridGap,
type DashboardItem,
} from '../hooks/useDashboardLayout'
import { DashboardWidgetMount } from './DashboardWidgetMount'

/**
* Extra empty rows reserved below the lowest widget while in customize
Expand Down Expand Up @@ -192,7 +193,6 @@ export function DashboardGrid({
/>
)
}
const Render = def.render
return (
<div
key={item.id}
Expand All @@ -208,7 +208,7 @@ export function DashboardGrid({
['--row' as string]: String(item.row),
}}
>
<Render span={item.size} editing={false} />
<DashboardWidgetMount definition={def} span={item.size} editing={false} />
</div>
)
})}
Expand Down Expand Up @@ -346,7 +346,6 @@ interface DraggableCellProps {

function DraggableCell({ item, definition, onResize, onResizeRows }: DraggableCellProps) {
const draggable = useDraggable({ id: item.id })
const Render = definition.render

const containerRef = useRef<HTMLDivElement | null>(null)
const resizeStateRef = useRef<{
Expand Down Expand Up @@ -442,7 +441,7 @@ function DraggableCell({ item, definition, onResize, onResizeRows }: DraggableCe
{...draggable.listeners}
{...draggable.attributes}
>
<Render span={item.size} editing />
<DashboardWidgetMount definition={definition} span={item.size} editing />

{/* 4 edge handles + 1 corner handle. The corner is stacked above
the edges (z-index: 11 vs 10) so the small overlap area
Expand Down
42 changes: 42 additions & 0 deletions src/admin/pages/dashboard/components/DashboardWidgetMount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { DashboardWidgetDefinition } from '@core/dashboard'
import { pluginRuntime } from '@core/plugins/runtime'
import { PluginContextProvider } from '@admin/plugin-host-hooks'

interface DashboardWidgetMountProps {
definition: DashboardWidgetDefinition
span: number
editing: boolean
}

/** Mount a dashboard renderer with plugin context when its owner is a plugin. */
export function DashboardWidgetMount({
definition,
span,
editing,
}: DashboardWidgetMountProps) {
const Render = definition.render

if (definition.ownerId === 'core') {
return <Render span={span} editing={editing} />
}

const context = definition.pluginContext
if (!context) {
throw new Error(
`[dashboard] plugin widget "${definition.id}" is missing its host context metadata.`,
)
}

return (
<PluginContextProvider
pluginId={definition.ownerId}
pluginVersion={context.version}
surfaceId={definition.id}
surfaceLabel={definition.name}
grantedPermissions={context.grantedPermissions}
settings={pluginRuntime.getPluginSettings(definition.ownerId)}
>
<Render span={span} editing={editing} />
</PluginContextProvider>
)
}
Loading
Loading