From bd8cf4c3ffb98da059ca08c9377f3f004c834181 Mon Sep 17 00:00:00 2001
From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com>
Date: Tue, 3 Nov 2026 12:58:49 +0100
Subject: [PATCH] feat(plugins): add the editor.markdown extension point
(WI-1787)
Expose a plugin extension point so an installed plugin can render its own
Markdown editor (e.g. a plain textarea). The host stays the only writer: it
holds the content, validates it, and persists through the normal item/page
APIs. The editor plugin itself lives in a separate repository.
Backend:
- Extension gains mode (toggle/replace, default toggle) and entityTypes.
- LoadPlugin validates editor.markdown manifests (component required, known
entity types, known mode) before compilation and again for the resolved
manifest+metadata set.
- GetExtensions now applies the same license gate as GetCapabilities, so an
unlicensed plugin cannot advertise an editor provider.
- GET /api/plugins/extensions accepts ?point= so an editor surface receives
only the point it needs.
Frontend:
- extensions store: ensureExtensionsLoaded() shares one in-flight request;
getEditorProviders() filters by entity type and sorts by order.
- PluginBridge: reject messages whose event.source is not the bridge's
iframe; add the editor protocol (init/update/save/cancel/dispose and the
plugin -> host counterparts), with every plugin message required to echo
the sessionId so two editors on one page cannot cross-talk.
- PluginEditorHost.svelte: resolves a provider, shows an opt-in toggle,
mounts the plugin iframe, forwards save/cancel to the parent, and mirrors
MilkdownEditor's imperative surface. Unsaved edits are confirmed before
switching, and a save over 256 KB is refused.
- ItemDetailDescription enables providers for the item description.
The react-demo manifest drops its unparsed displayMode field.
---
.../src/lib/editors/PluginEditorHost.svelte | 297 ++++++++++++++++++
.../items/ItemDetailDescription.svelte | 6 +-
frontend/src/lib/locales/de/ui.js | 8 +
frontend/src/lib/locales/en/ui.js | 8 +
frontend/src/lib/locales/fr/ui.js | 8 +
frontend/src/lib/locales/ru/ui.js | 8 +
frontend/src/lib/locales/zh-CN/ui.js | 7 +
frontend/src/lib/services/PluginBridge.js | 74 +++++
frontend/src/lib/stores/extensions.svelte.js | 77 ++++-
internal/handlers/plugins.go | 10 +
internal/plugins/manager.go | 52 +++
internal/plugins/types.go | 37 ++-
plugin-src/react-demo/manifest.json | 1 -
13 files changed, 565 insertions(+), 28 deletions(-)
create mode 100644 frontend/src/lib/editors/PluginEditorHost.svelte
diff --git a/frontend/src/lib/editors/PluginEditorHost.svelte b/frontend/src/lib/editors/PluginEditorHost.svelte
new file mode 100644
index 000000000..9c927598d
--- /dev/null
+++ b/frontend/src/lib/editors/PluginEditorHost.svelte
@@ -0,0 +1,297 @@
+
+
+
+ {#if providers.length > 0 && !readonly}
+
+ {#if activeProvider}
+ {activeProvider.label || activeProvider.id}
+
+ {t('editors.useRichText')}
+
+ {:else}
+ {#each providers as provider (provider.id)}
+ openProvider(provider)}
+ data-testid={testId
+ ? `${testId}-provider-${provider.id}`
+ : `plugin-editor-provider-${provider.id}`}
+ >
+ {t('editors.useProvider', { label: provider.label || provider.id })}
+
+ {/each}
+ {/if}
+
+ {/if}
+
+ {#if activeProvider}
+ {#if activeStyles}
+
+ {/if}
+ {#if pluginError}
+
+ {pluginError}
+
+ {/if}
+ {#if !pluginReady}
+
+
+
+ {/if}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/features/items/ItemDetailDescription.svelte b/frontend/src/lib/features/items/ItemDetailDescription.svelte
index 1b0209654..3c3c23b50 100644
--- a/frontend/src/lib/features/items/ItemDetailDescription.svelte
+++ b/frontend/src/lib/features/items/ItemDetailDescription.svelte
@@ -9,6 +9,7 @@
import Button from '../../components/Button.svelte';
import FileInput from '../../components/FileInput.svelte';
import MilkdownEditor from '../../editors/LazyMilkdownEditor.svelte';
+ import PluginEditorHost from '../../editors/PluginEditorHost.svelte';
import AttachmentDiagramList from '../assets/AttachmentDiagramList.svelte';
import AIActionsDropdown from './AIActionsDropdown.svelte';
import { getShortcut, matchesShortcut, getDisplayString } from '../../utils/keyboardShortcuts.js';
@@ -177,14 +178,17 @@
{#if editingDescription}
-
diff --git a/frontend/src/lib/locales/de/ui.js b/frontend/src/lib/locales/de/ui.js
index 2f86cc412..661ed8fc0 100644
--- a/frontend/src/lib/locales/de/ui.js
+++ b/frontend/src/lib/locales/de/ui.js
@@ -221,6 +221,14 @@ export default {
},
editors: {
enterText: 'Text eingeben...',
+ useProvider: 'Mit {label} bearbeiten',
+ useRichText: 'Rich-Text-Editor verwenden',
+ providerActive: '{label} ist aktiv',
+ discardProviderChanges: 'Ungespeicherte Änderungen verwerfen?',
+ discardProviderChangesMessage:
+ 'Ihre Änderungen in {label} wurden nicht gespeichert. Beim Wechseln des Editors werden sie verworfen.',
+ discardChanges: 'Verwerfen',
+ providerError: 'Das Editor-Plugin hat einen Fehler gemeldet: {message}',
selectDate: 'Datum auswählen...',
clickToChangeColor: 'Klicken zum Ändern der Farbe',
saveEnter: 'Speichern (Enter)',
diff --git a/frontend/src/lib/locales/en/ui.js b/frontend/src/lib/locales/en/ui.js
index 19c9c530a..8262ffcf5 100644
--- a/frontend/src/lib/locales/en/ui.js
+++ b/frontend/src/lib/locales/en/ui.js
@@ -257,6 +257,14 @@ export default {
mermaidRendering: 'Rendering diagram',
mermaidParseError: 'Mermaid parse error',
mermaidEmpty: 'Empty mermaid block',
+ useProvider: 'Edit with {label}',
+ useRichText: 'Use rich text editor',
+ providerActive: '{label} is active',
+ discardProviderChanges: 'Discard unsaved changes?',
+ discardProviderChangesMessage:
+ 'Your edits in {label} have not been saved. Switching editors discards them.',
+ discardChanges: 'Discard',
+ providerError: 'The editor plugin reported an error: {message}',
},
dialogs: {
diff --git a/frontend/src/lib/locales/fr/ui.js b/frontend/src/lib/locales/fr/ui.js
index ff86b7e30..3e9aa0610 100644
--- a/frontend/src/lib/locales/fr/ui.js
+++ b/frontend/src/lib/locales/fr/ui.js
@@ -216,6 +216,14 @@ export default {
editors: {
enterText: 'Saisir du texte…',
+ useProvider: 'Modifier avec {label}',
+ useRichText: 'Utiliser l’éditeur de texte enrichi',
+ providerActive: '{label} est actif',
+ discardProviderChanges: 'Ignorer les modifications non enregistrées ?',
+ discardProviderChangesMessage:
+ 'Vos modifications dans {label} n’ont pas été enregistrées. Changer d’éditeur les supprime.',
+ discardChanges: 'Ignorer',
+ providerError: 'Le plugin d’éditeur a signalé une erreur : {message}',
selectDate: 'Sélectionner une date…',
clickToChangeColor: 'Cliquer pour changer la couleur',
saveEnter: 'Enregistrer (Entrée)',
diff --git a/frontend/src/lib/locales/ru/ui.js b/frontend/src/lib/locales/ru/ui.js
index 5ad063530..8d5652a68 100644
--- a/frontend/src/lib/locales/ru/ui.js
+++ b/frontend/src/lib/locales/ru/ui.js
@@ -172,6 +172,14 @@ export default {
editors: {
enterText: 'Введите текст…',
+ useProvider: 'Редактировать с помощью {label}',
+ useRichText: 'Использовать визуальный редактор',
+ providerActive: '{label} активен',
+ discardProviderChanges: 'Отменить несохранённые изменения?',
+ discardProviderChangesMessage:
+ 'Ваши изменения в {label} не сохранены. При смене редактора они будут потеряны.',
+ discardChanges: 'Отменить',
+ providerError: 'Плагин редактора сообщил об ошибке: {message}',
selectDate: 'Выберите дату…',
clickToChangeColor: 'Нажмите, чтобы изменить цвет',
saveEnter: 'Сохранить (Enter)',
diff --git a/frontend/src/lib/locales/zh-CN/ui.js b/frontend/src/lib/locales/zh-CN/ui.js
index 1a5e0a475..df98dd0ba 100644
--- a/frontend/src/lib/locales/zh-CN/ui.js
+++ b/frontend/src/lib/locales/zh-CN/ui.js
@@ -152,6 +152,13 @@ export default {
editors: {
enterText: '输入文本...',
+ useProvider: '使用 {label} 编辑',
+ useRichText: '使用富文本编辑器',
+ providerActive: '{label} 已启用',
+ discardProviderChanges: '放弃未保存的更改?',
+ discardProviderChangesMessage: '您在 {label} 中的编辑尚未保存。切换编辑器将放弃这些更改。',
+ discardChanges: '放弃',
+ providerError: '编辑器插件报告错误:{message}',
selectDate: '选择日期...',
clickToChangeColor: '点击更改颜色',
saveEnter: '保存(Enter)',
diff --git a/frontend/src/lib/services/PluginBridge.js b/frontend/src/lib/services/PluginBridge.js
index dc0d5e63f..cc4f02d5a 100644
--- a/frontend/src/lib/services/PluginBridge.js
+++ b/frontend/src/lib/services/PluginBridge.js
@@ -26,8 +26,30 @@ export const MESSAGE_TYPES = {
THEME_UPDATE: 'host:themeUpdate',
MODAL_RESULT: 'host:modalResult',
USER_PICKER_RESULT: 'host:userPickerResult',
+
+ // Editor provider protocol (editor.markdown). Every plugin → host editor
+ // message must echo the sessionId the host sent in host:editorInit; the host
+ // drops mismatches so two editors on one page cannot cross-talk.
+ EDITOR_READY: 'plugin:editorReady',
+ EDITOR_CHANGE: 'plugin:editorChange',
+ EDITOR_SAVE: 'plugin:editorSave',
+ EDITOR_CANCEL: 'plugin:editorCancel',
+ EDITOR_ERROR: 'plugin:editorError',
+ EDITOR_INIT: 'host:editorInit',
+ EDITOR_UPDATE: 'host:editorUpdate',
+ EDITOR_SAVE_REQUEST: 'host:editorSaveRequest',
+ EDITOR_CANCEL_REQUEST: 'host:editorCancelRequest',
+ EDITOR_DISPOSE: 'host:editorDispose',
};
+const EDITOR_MESSAGE_TYPES = new Set([
+ MESSAGE_TYPES.EDITOR_READY,
+ MESSAGE_TYPES.EDITOR_CHANGE,
+ MESSAGE_TYPES.EDITOR_SAVE,
+ MESSAGE_TYPES.EDITOR_CANCEL,
+ MESSAGE_TYPES.EDITOR_ERROR,
+]);
+
/**
* Initialize plugin bridge for an iframe
* @param {HTMLIFrameElement} iframe - The iframe element
@@ -40,6 +62,14 @@ export function createPluginBridge(iframe, options = {}) {
onResize = () => {},
onShowUserPicker = () => {},
pluginName = 'unknown',
+ // sessionId is set for editor providers. Editor messages that do not echo
+ // it are ignored.
+ sessionId = null,
+ onEditorReady = () => {},
+ onEditorChange = () => {},
+ onEditorSave = () => {},
+ onEditorCancel = () => {},
+ onEditorError = () => {},
} = options;
// Track active modals by ID
@@ -65,10 +95,40 @@ export function createPluginBridge(iframe, options = {}) {
if (event.origin !== expectedOrigin) {
return;
}
+ // Only accept messages from this bridge's own iframe. Two plugins (or two
+ // editor instances) on one page would otherwise receive each other's
+ // messages.
+ if (!iframe?.contentWindow || event.source !== iframe.contentWindow) {
+ return;
+ }
const message = event.data;
if (!message?.type) return;
+ if (EDITOR_MESSAGE_TYPES.has(message.type)) {
+ if (sessionId == null || message.sessionId !== sessionId) {
+ return;
+ }
+ switch (message.type) {
+ case MESSAGE_TYPES.EDITOR_READY:
+ onEditorReady(message);
+ break;
+ case MESSAGE_TYPES.EDITOR_CHANGE:
+ onEditorChange(message);
+ break;
+ case MESSAGE_TYPES.EDITOR_SAVE:
+ onEditorSave(message);
+ break;
+ case MESSAGE_TYPES.EDITOR_CANCEL:
+ onEditorCancel(message);
+ break;
+ case MESSAGE_TYPES.EDITOR_ERROR:
+ onEditorError(message);
+ break;
+ }
+ return;
+ }
+
switch (message.type) {
case MESSAGE_TYPES.READY:
onReady();
@@ -216,6 +276,12 @@ export function createPluginBridge(iframe, options = {}) {
// Listen for messages from plugin
window.addEventListener('message', handleMessage);
+ // Editor provider protocol. The host is the only writer: it holds the
+ // markdown and persists it through the normal item/page APIs.
+ function sendEditorInit(payload) {
+ sendToPlugin({ type: MESSAGE_TYPES.EDITOR_INIT, sessionId, ...payload });
+ }
+
// Return API for host to interact with plugin
return {
destroy: () => {
@@ -226,5 +292,13 @@ export function createPluginBridge(iframe, options = {}) {
},
sendThemeUpdate,
sendToPlugin,
+ sendEditorInit,
+ sendEditorUpdate: (markdown) =>
+ sendToPlugin({ type: MESSAGE_TYPES.EDITOR_UPDATE, sessionId, markdown }),
+ sendEditorSaveRequest: () =>
+ sendToPlugin({ type: MESSAGE_TYPES.EDITOR_SAVE_REQUEST, sessionId }),
+ sendEditorCancelRequest: () =>
+ sendToPlugin({ type: MESSAGE_TYPES.EDITOR_CANCEL_REQUEST, sessionId }),
+ sendEditorDispose: () => sendToPlugin({ type: MESSAGE_TYPES.EDITOR_DISPOSE, sessionId }),
};
}
diff --git a/frontend/src/lib/stores/extensions.svelte.js b/frontend/src/lib/stores/extensions.svelte.js
index e17afce5c..82efe6405 100644
--- a/frontend/src/lib/stores/extensions.svelte.js
+++ b/frontend/src/lib/stores/extensions.svelte.js
@@ -1,7 +1,13 @@
// Extensions store for plugin extension registry
// Uses Svelte 5 runes for proper reactivity tracking
+export const EDITOR_MARKDOWN_POINT = 'editor.markdown';
+
let extensionsData = $state({});
+// Single in-flight load shared by every consumer. The editor host mounts many
+// times per page (item description, comment composers, page editors), so it
+// cannot fetch per mount.
+let loadPromise = null;
export const extensions = {
get value() {
@@ -10,25 +16,45 @@ export const extensions = {
};
/**
- * Load extensions from the API
+ * Load extensions from the API once, sharing the in-flight request and caching
+ * the result. Pass `{ force: true }` to bypass the cache.
+ * @param {{ force?: boolean }} [options]
* @returns {Promise}
*/
-export async function loadExtensions() {
- try {
- const response = await fetch('/api/plugins/extensions', {
- credentials: 'include',
- });
-
- if (!response.ok) {
- console.error('Failed to load extensions:', response.statusText);
- return;
+export async function ensureExtensionsLoaded({ force = false } = {}) {
+ if (!force && Object.keys(extensionsData).length > 0) return extensionsData;
+ if (!force && loadPromise) return loadPromise;
+
+ loadPromise = (async () => {
+ try {
+ const response = await fetch('/api/plugins/extensions', {
+ credentials: 'include',
+ });
+
+ if (!response.ok) {
+ console.error('Failed to load extensions:', response.statusText);
+ return extensionsData;
+ }
+
+ extensionsData = await response.json();
+ return extensionsData;
+ } catch (error) {
+ console.error('Error loading extensions:', error);
+ return extensionsData;
+ } finally {
+ loadPromise = null;
}
+ })();
- extensionsData = await response.json();
- return extensionsData;
- } catch (error) {
- console.error('Error loading extensions:', error);
- }
+ return loadPromise;
+}
+
+/**
+ * Load extensions from the API
+ * @returns {Promise}
+ */
+export async function loadExtensions() {
+ return ensureExtensionsLoaded({ force: true });
}
/**
@@ -38,5 +64,24 @@ export async function loadExtensions() {
* @returns {Array} Array of extensions for the given point
*/
export function getExtensionsForPoint(data, point) {
- return data[point] || [];
+ return data?.[point] || [];
+}
+
+/**
+ * Get the editor providers a surface may use, ordered by `order`.
+ *
+ * A provider that declares no `entityTypes` applies to every surface; one that
+ * declares a list applies only to those.
+ *
+ * @param {object} data - The extensions data from the store
+ * @param {string|null} entityType - "item" | "page" | "comment"
+ * @returns {Array} Ordered editor providers
+ */
+export function getEditorProviders(data, entityType) {
+ return getExtensionsForPoint(data, EDITOR_MARKDOWN_POINT)
+ .filter(
+ (extension) =>
+ !entityType || !extension.entityTypes?.length || extension.entityTypes.includes(entityType)
+ )
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
diff --git a/internal/handlers/plugins.go b/internal/handlers/plugins.go
index c29ccb424..0898fdd50 100644
--- a/internal/handlers/plugins.go
+++ b/internal/handlers/plugins.go
@@ -256,6 +256,16 @@ func (h *PluginHandler) GetExtensions(w http.ResponseWriter, r *http.Request) {
}
extensions := h.manager.GetExtensions()
+ // Editor surfaces only need the editor providers; ?point= keeps the
+ // response to the requested point so unrelated plugin metadata is not
+ // shipped to them.
+ if point := strings.TrimSpace(r.URL.Query().Get("point")); point != "" {
+ filtered := make(map[string][]plugins.Extension, 1)
+ if list, ok := extensions[point]; ok {
+ filtered[point] = list
+ }
+ extensions = filtered
+ }
_ = json.NewEncoder(w).Encode(extensions)
}
diff --git a/internal/plugins/manager.go b/internal/plugins/manager.go
index a1352fc83..c25bc1506 100644
--- a/internal/plugins/manager.go
+++ b/internal/plugins/manager.go
@@ -257,6 +257,12 @@ func (m *Manager) LoadPlugin(pluginPath string) error {
}
manifest.Permissions = normalizedPermissions
+ // Validate editor-provider extensions before compilation so a malformed
+ // manifest fails the load with the offending value.
+ if err = normalizeEditorExtensions(manifest.Extensions); err != nil {
+ return fmt.Errorf("invalid plugin extensions: %w", err)
+ }
+
wasmPath := filepath.Join(pluginPath, manifest.EntryPoint)
if _, err = os.Stat(wasmPath); err != nil {
return fmt.Errorf("failed to read WASM file: %w", err)
@@ -300,6 +306,12 @@ func (m *Manager) LoadPlugin(pluginPath string) error {
plugin.Extensions = attachPluginName(manifest.Name, manifest.Extensions, nil)
}
+ // get_metadata can add editor providers the manifest did not declare, so
+ // validate the resolved set as well.
+ if err := normalizeEditorExtensions(plugin.Extensions); err != nil {
+ return fmt.Errorf("invalid plugin extensions: %w", err)
+ }
+
// Route auth defaults to session and is validated here so a malformed or
// ambiguous manifest fails the load before the plugin is registered.
normalizedRoutes, err := normalizePluginRoutes(plugin.Routes, m.allowPublicRoutes)
@@ -859,6 +871,9 @@ func (m *Manager) GetExtensions() map[string][]Extension {
if !p.Enabled {
continue
}
+ if m.licenseVerifier != nil && !p.Licensed {
+ continue
+ }
for _, ext := range p.Extensions {
extensionsByPoint[ext.Point] = append(extensionsByPoint[ext.Point], ext)
@@ -937,6 +952,43 @@ func normalizePluginRoutes(routes []Route, allowPublic bool) ([]Route, error) {
return normalized, nil
}
+// normalizeEditorExtensions validates editor-provider extensions and defaults
+// an unset mode to toggle. Non-editor extension points pass through unchanged.
+// Called for the manifest extensions before compilation and again for the
+// resolved set (manifest plus get_metadata) afterwards, so a provider declared
+// either way is validated.
+func normalizeEditorExtensions(extensions []Extension) error {
+ for i := range extensions {
+ ext := &extensions[i]
+ if ext.Point != ExtensionPointEditorMarkdown {
+ continue
+ }
+ if strings.TrimSpace(ext.Component) == "" {
+ return fmt.Errorf("editor.markdown extension %q: component is required", ext.ID)
+ }
+ switch ext.Mode {
+ case "":
+ ext.Mode = EditorModeToggle
+ case EditorModeToggle, EditorModeReplace:
+ default:
+ return fmt.Errorf("editor.markdown extension %q: mode must be %q or %q", ext.ID, EditorModeToggle, EditorModeReplace)
+ }
+ for _, entityType := range ext.EntityTypes {
+ if _, ok := editorEntityTypes[entityType]; !ok {
+ return fmt.Errorf("editor.markdown extension %q: unknown entity type %q", ext.ID, entityType)
+ }
+ }
+ }
+ return nil
+}
+
+// editorEntityTypes is the closed set of surfaces an editor provider may edit.
+var editorEntityTypes = map[string]struct{}{
+ "item": {},
+ "page": {},
+ "comment": {},
+}
+
// routesOverlap reports whether two routes can match the same request. Methods
// overlap when either is empty (any) or they are equal.
func routesOverlap(a, b Route) bool {
diff --git a/internal/plugins/types.go b/internal/plugins/types.go
index 557b9c33c..6b015ab61 100644
--- a/internal/plugins/types.go
+++ b/internal/plugins/types.go
@@ -39,18 +39,35 @@ type Route struct {
// Extension represents a UI extension point provided by a plugin.
type Extension struct {
- Point string `json:"point"` // Extension point type (e.g., "admin.tab")
- ID string `json:"id"` // Unique identifier for this extension
- Label string `json:"label"` // Display label
- Description string `json:"description"` // Description of what this extension does
- Icon string `json:"icon,omitempty"` // Icon name (kebab-case, e.g. shield-check)
- Component string `json:"component"` // Path to the frontend component (e.g., "frontend.js")
- Styles string `json:"styles,omitempty"` // Optional path to CSS file
- Group string `json:"group,omitempty"` // Grouping for organization (e.g., "Security & Audit")
- Order int `json:"order,omitempty"` // Display order (higher = later)
- PluginName string `json:"pluginName"` // Name of the plugin this extension belongs to
+ Point string `json:"point"` // Extension point type (e.g., "admin.tab")
+ ID string `json:"id"` // Unique identifier for this extension
+ Label string `json:"label"` // Display label
+ Description string `json:"description"` // Description of what this extension does
+ Icon string `json:"icon,omitempty"` // Icon name (kebab-case, e.g. shield-check)
+ Component string `json:"component"` // Path to the frontend component (e.g., "frontend.js")
+ Styles string `json:"styles,omitempty"` // Optional path to CSS file
+ Group string `json:"group,omitempty"` // Grouping for organization (e.g., "Security & Audit")
+ Order int `json:"order,omitempty"` // Display order (higher = later)
+ Mode string `json:"mode,omitempty"` // editor.markdown: "toggle" (default) or "replace"
+ EntityTypes []string `json:"entityTypes,omitempty"` // editor.markdown: surfaces the provider may edit
+ PluginName string `json:"pluginName"` // Name of the plugin this extension belongs to
}
+// ExtensionPointEditorMarkdown lets an installed plugin render its own editor
+// for a Markdown surface (e.g. a plain textarea). The host remains the only
+// writer: it holds the content, validates it, and calls the normal item/page/
+// comment APIs. See docs/durable-action-delivery.md for the bridge protocol.
+const ExtensionPointEditorMarkdown = "editor.markdown"
+
+// Editor extension modes.
+const (
+ // EditorModeToggle lets the user opt into the provider from the editor
+ // toolbar; the native editor stays the default.
+ EditorModeToggle = "toggle"
+ // EditorModeReplace makes the provider the default editor for the surface.
+ EditorModeReplace = "replace"
+)
+
// Principal is the authoritative caller identity forwarded to a plugin. It is
// nil on public routes. Raw headers stay for compatibility, but this is the
// only trusted identity.
diff --git a/plugin-src/react-demo/manifest.json b/plugin-src/react-demo/manifest.json
index cdf7d7069..5d5502373 100644
--- a/plugin-src/react-demo/manifest.json
+++ b/plugin-src/react-demo/manifest.json
@@ -11,7 +11,6 @@
"label": "React Demo App",
"description": "Demo plugin showcasing React 19 with UI bridge integration",
"icon": "code",
- "displayMode": "iframe",
"component": "index.html",
"group": "Development",
"order": 200