feat(M1): Universal-MiniApp-Registry — Plugin-Layer, permission fail-closed, Lifecycle, /api/v1/miniapps
Check Cross-Plugin Imports / check (push) Has been cancelled

- app/plugins/miniapp_registry.py: Registry aus kommunikation in Plugin-Layer gehoben (Plattform-Konzept, hosts chat/dashboard/window)
- MiniAppDef: permission (fail-closed) + settings_schema + col/row_span + hosts + component + order + builtin
- MiniAppContribution + FrontendDashboardWidget (Manifest-Schema) um M1-Felder erweitert — dashboard_widgets ist Alias von miniapps (ein Contribution-Typ, #359-Philosophie)
- BasePlugin.on_activate: automatische Manifest-Registrierung; on_deactivate: unregister_plugin (nur eigene Apps)
- GET /api/v1/miniapps (server-seitig permission-gefiltert, ?host=) + GET /api/v1/miniapps/{app_id} (403/404 fail-closed)
- kommunikation/miniapp_registry.py = Kompatibilitaets-Bruecke (Bestands-Importer unveraendert)
- Doku: api-documentation.md + plugin-development-guide.md (MiniApp-Beitragsmuster)

TDD: Rot 16 failed -> Gruen 16/16; Regressionen: contracts 23/23, lifecycle+route_order 4/4; ruff clean (M1-Dateien, Vorbestand per Stash bewiesen); create_app OK
This commit is contained in:
Agent Zero
2026-08-30 13:00:33 +02:00
parent 5eade3e005
commit 84cb82d2c4
10 changed files with 664 additions and 251 deletions
+56 -2
View File
@@ -53,8 +53,10 @@ class BasePlugin(ABC):
) -> None:
"""Called when the plugin is activated.
Override to register event listeners and prepare runtime state.
Default implementation subscribes to events listed in the manifest.
Default implementation subscribes to events listed in the manifest and
registers manifest MiniApps (Phase M1): ``miniapps`` contributions plus
``dashboard_widgets`` entries (alias — one contribution type, #359
philosophy). Registered automatically here; no per-plugin code needed.
"""
for event_name in self.manifest.events:
handler = self._make_event_handler(event_name)
@@ -62,6 +64,8 @@ class BasePlugin(ABC):
event_bus.subscribe(event_name, handler)
self._container = service_container
self._register_manifest_miniapps()
async def on_deactivate(
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
) -> None:
@@ -80,6 +84,56 @@ class BasePlugin(ABC):
get_hook_registry().unregister_all_for_plugin(self.manifest.name)
# Unregister MiniApps owned by this plugin (Phase M1)
from app.plugins.miniapp_registry import get_miniapp_registry
get_miniapp_registry().unregister_plugin(self.manifest.name)
def _register_manifest_miniapps(self) -> None:
"""Register manifest MiniApps in the universal registry (Phase M1).
Sources:
- ``manifest.miniapps`` — native MiniApp contributions
- ``manifest.dashboard_widgets`` — alias: FrontendDashboardWidget entries
become MiniApps with component path + spans + permission so existing
plugin manifests keep working without changes.
"""
from app.plugins.miniapp_registry import get_miniapp_registry
registry = get_miniapp_registry()
name = self.manifest.name
for m in getattr(self.manifest, "miniapps", None) or []:
registry.register(
app_id=m.app_id,
name=m.name,
icon=m.icon,
description=m.description,
plugin_name=name,
render_schema=m.render_schema,
permission=getattr(m, "permission", ""),
settings_schema=getattr(m, "settings_schema", {}),
col_span=getattr(m, "col_span", 1),
row_span=getattr(m, "row_span", 1),
hosts=getattr(m, "hosts", None),
order=getattr(m, "order", 100),
)
for w in getattr(self.manifest, "dashboard_widgets", None) or []:
registry.register(
app_id=w.id,
name=w.label or w.id,
icon=w.icon,
description="",
plugin_name=name,
permission=w.permission,
col_span=w.col_span,
row_span=w.row_span,
hosts=["chat", "dashboard", "window"],
component=w.component,
order=w.order,
)
async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
"""Called when the plugin is uninstalled (before data tables are dropped).
@@ -1,92 +1,25 @@
"""Mini-App registry for plugin-provided interactive chat components."""
"""Compatibility bridge — the MiniApp registry moved to the plugin layer.
The registry is a platform concept now (Phase M1): MiniApps are universal
building blocks for Chat, Dashboard and Windows. This module re-exports the
universal registry so every existing importer (kommunikation contracts,
automation routes, tests) keeps working unchanged.
"""
from __future__ import annotations
import logging
from typing import Any
from app.plugins.miniapp_registry import ( # noqa: F401
DEFAULT_HOSTS,
MiniAppDef,
MiniAppRegistry,
get_miniapp_registry,
reset_miniapp_registry,
)
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class MiniAppDef(BaseModel):
"""Definition of a mini-app that plugins can register."""
app_id: str = Field(..., description="Unique app identifier")
name: str = Field(..., description="Display name")
icon: str = Field(default="app", description="Icon name")
description: str = Field(default="", description="App description")
plugin_name: str = Field(..., description="Plugin that registered this app")
render_schema: dict[str, Any] = Field(
default_factory=dict, description="JSON schema for frontend rendering"
)
class MiniAppRegistry:
"""Registry for mini-apps that plugins provide for chat embedding."""
def __init__(self) -> None:
self._apps: dict[str, MiniAppDef] = {}
def register(
self,
app_id: str,
name: str,
icon: str,
description: str,
plugin_name: str,
render_schema: dict[str, Any] | None = None,
) -> None:
"""Register a mini-app."""
app = MiniAppDef(
app_id=app_id,
name=name,
icon=icon,
description=description,
plugin_name=plugin_name,
render_schema=render_schema or {},
)
self._apps[app_id] = app
logger.info(f"Mini-app registered: {app_id} by {plugin_name}")
def unregister(self, app_id: str) -> None:
"""Unregister a mini-app."""
app = self._apps.pop(app_id, None)
if app:
logger.info(f"Mini-app unregistered: {app_id}")
def unregister_plugin(self, plugin_name: str) -> None:
"""Unregister all mini-apps from a specific plugin."""
to_remove = [app_id for app_id, app in self._apps.items() if app.plugin_name == plugin_name]
for app_id in to_remove:
self._apps.pop(app_id, None)
if to_remove:
logger.info(f"Unregistered {len(to_remove)} mini-apps from plugin {plugin_name}")
def list_apps(self) -> list[dict[str, Any]]:
"""List all available mini-apps for frontend."""
return [app.model_dump() for app in self._apps.values()]
def get_app(self, app_id: str) -> MiniAppDef | None:
"""Get a specific mini-app definition."""
return self._apps.get(app_id)
# ─── Singleton helpers ───
_registry: MiniAppRegistry | None = None
def get_miniapp_registry() -> MiniAppRegistry:
"""Return the shared singleton MiniAppRegistry instance."""
global _registry
if _registry is None:
_registry = MiniAppRegistry()
return _registry
def reset_miniapp_registry() -> None:
"""Reset the singleton instance (useful for tests)."""
global _registry
_registry = None
__all__ = [
"MiniAppDef",
"MiniAppRegistry",
"get_miniapp_registry",
"reset_miniapp_registry",
"DEFAULT_HOSTS",
]
+17
View File
@@ -148,6 +148,10 @@ class FrontendDashboardWidget(BaseModel):
col_span: int = Field(default=1, description="Grid column span (1-4)")
row_span: int = Field(default=1, description="Grid row span")
permission: str = Field(default="", description="Optional permission required")
settings_schema: dict[str, Any] = Field(
default_factory=dict,
description="JSON schema for per-instance settings (Phase M1)",
)
class CustomFieldDefinition(BaseModel):
@@ -173,6 +177,19 @@ class MiniAppContribution(BaseModel):
icon: str = Field(default="AppWindow")
description: str = Field(default="")
render_schema: dict[str, Any] = Field(default_factory=dict)
# Phase M1: universal MiniApp fields
permission: str = Field(default="", description="Required permission (fail-closed)")
settings_schema: dict[str, Any] = Field(
default_factory=dict, description="JSON schema for per-instance settings"
)
col_span: int = Field(default=1, ge=1, le=12, description="Grid column span")
row_span: int = Field(default=1, ge=1, le=12, description="Grid row span")
hosts: list[str] = Field(
default_factory=lambda: ["chat", "dashboard", "window"],
description="Hosts this MiniApp may appear in",
)
component: str = Field(default="", description="Frontend component path")
order: int = Field(default=100, description="Sort order in palettes")
class PluginManifest(BaseModel):
+154
View File
@@ -0,0 +1,154 @@
"""Universal MiniApp registry — platform-level (Phase M1).
MiniApps are the platform's universal UI building blocks, hostable in Chat,
Dashboard, Windows and AI agent output. Every plugin registers its MiniApps
here (manifest-driven via BasePlugin lifecycle or programmatically at
runtime). The registry is deliberately host-agnostic: hosts declare where
an app may appear, the registry itself never renders anything.
History: grew out of kommunikation/miniapp_registry.py (chat-only, 92
lines). That module is now a compatibility bridge re-exporting this one —
all existing importers (kommunikation contracts, automation routes) keep
working unchanged.
Security model (fail-closed):
- ``MiniAppDef.permission`` — apps requiring a permission the user lacks are
filtered out server-side by the /api/v1/miniapps listing and answered
with 403 on single-app lookup. Empty permission = visible to everyone
(backward compatible with the old chat miniapps).
- Hosts additionally restrict where an app may appear (?host= filter).
"""
from __future__ import annotations
import logging
from typing import Any
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
DEFAULT_HOSTS: list[str] = ["chat", "dashboard", "window"]
class MiniAppDef(BaseModel):
"""Definition of a MiniApp that plugins can register."""
app_id: str = Field(..., description="Unique app identifier")
name: str = Field(..., description="Display name")
icon: str = Field(default="AppWindow", description="Icon name (lucide or emoji)")
description: str = Field(default="", description="App description")
plugin_name: str = Field(..., description="Plugin that registered this app")
render_schema: dict[str, Any] = Field(
default_factory=dict, description="JSON schema for frontend rendering"
)
# ── Phase M1 extensions ──
permission: str = Field(
default="",
description="Required permission (fail-closed). Empty = everyone.",
)
settings_schema: dict[str, Any] = Field(
default_factory=dict,
description="JSON schema for the per-instance settings form",
)
col_span: int = Field(default=1, ge=1, le=12, description="Grid column span")
row_span: int = Field(default=1, ge=1, le=12, description="Grid row span")
hosts: list[str] = Field(
default_factory=lambda: list(DEFAULT_HOSTS),
description="Hosts this app may appear in (chat, dashboard, window)",
)
component: str = Field(
default="", description="Frontend component path (for manifest-contributed apps)"
)
order: int = Field(default=100, description="Sort order in palettes")
builtin: bool = Field(
default=True, description="Registered in-process (vs. future remote apps)"
)
class MiniAppRegistry:
"""Registry for MiniApps provided by system and plugins."""
def __init__(self) -> None:
self._apps: dict[str, MiniAppDef] = {}
def register(
self,
app_id: str,
name: str,
icon: str = "AppWindow",
description: str = "",
plugin_name: str = "system",
render_schema: dict[str, Any] | None = None,
permission: str = "",
settings_schema: dict[str, Any] | None = None,
col_span: int = 1,
row_span: int = 1,
hosts: list[str] | None = None,
component: str = "",
order: int = 100,
) -> None:
"""Register or replace a MiniApp."""
app = MiniAppDef(
app_id=app_id,
name=name,
icon=icon,
description=description,
plugin_name=plugin_name,
render_schema=render_schema or {},
permission=permission,
settings_schema=settings_schema or {},
col_span=col_span,
row_span=row_span,
hosts=hosts if hosts is not None else list(DEFAULT_HOSTS),
component=component,
order=order,
)
self._apps[app_id] = app
logger.info("MiniApp registered: %s by %s", app_id, plugin_name)
def unregister(self, app_id: str) -> None:
"""Unregister a MiniApp."""
app = self._apps.pop(app_id, None)
if app:
logger.info("MiniApp unregistered: %s", app_id)
def unregister_plugin(self, plugin_name: str) -> None:
"""Unregister all MiniApps from a specific plugin."""
to_remove = [
app_id for app_id, app in self._apps.items() if app.plugin_name == plugin_name
]
for app_id in to_remove:
self._apps.pop(app_id, None)
if to_remove:
logger.info("Unregistered %d MiniApps from plugin %s", len(to_remove), plugin_name)
def list_apps(self, host: str | None = None) -> list[dict[str, Any]]:
"""List MiniApp definitions, optionally filtered by host."""
apps = self._apps.values()
if host:
apps = [a for a in apps if host in a.hosts]
return [app.model_dump() for app in apps]
def get_app(self, app_id: str) -> MiniAppDef | None:
"""Get a specific MiniApp definition."""
return self._apps.get(app_id)
# ─── Singleton helpers ──
_registry: MiniAppRegistry | None = None
def get_miniapp_registry() -> MiniAppRegistry:
"""Return the shared singleton MiniAppRegistry instance."""
global _registry
if _registry is None:
_registry = MiniAppRegistry()
return _registry
def reset_miniapp_registry() -> None:
"""Reset the singleton instance (useful for tests)."""
global _registry
_registry = None