feat(B-PLUGIN-GUIDE): Plugin-Dev-Guide Kapitel 10-22 — 22 Kapitel komplett
B-PLUGIN-GUIDE: docs/plugin-development-guide.md 1107→1813 Zeilen - Kapitel 10-22 hinzugefügt (Trigger, Message-System, Search, File Storage, Redis, Permissions, AI Tools, MCP, UI-Events, AI UI Control, Sensitive Data, Migration-Staffelung, Error-Handling) - Supplementary Kapitel 23-29 (vorhandene Inhalte umnummeriert) - Keine Duplikate, keine Änderungen an Kapitel 1-9
This commit is contained in:
@@ -504,7 +504,7 @@ await event_bus.publish("contact.created", {"contact_id": str(contact.id)})
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration Runner
|
||||
## 23. Migration Runner
|
||||
|
||||
### 7.1 Writing Migrations
|
||||
|
||||
@@ -541,7 +541,7 @@ CREATE INDEX idx_my_plugin_items_tenant ON my_plugin_items(tenant_id);
|
||||
|
||||
---
|
||||
|
||||
## 8. Service Container
|
||||
## 24. Service Container
|
||||
|
||||
After activation, plugins can access shared services via `self.services`:
|
||||
|
||||
@@ -569,7 +569,7 @@ Available services (defined in `app/core/service_container.py`):
|
||||
|
||||
---
|
||||
|
||||
## 9. RBAC / Permissions
|
||||
## 25. RBAC / Permissions
|
||||
|
||||
### 9.1 Declaring Permissions
|
||||
|
||||
@@ -624,7 +624,7 @@ field_definitions=[
|
||||
|
||||
---
|
||||
|
||||
## 10. AI Agent Integration
|
||||
## 26. AI Agent Integration
|
||||
|
||||
### 10.1 Agent Capabilities
|
||||
|
||||
@@ -685,7 +685,7 @@ async def on_deactivate(self, db, service_container, event_bus):
|
||||
|
||||
---
|
||||
|
||||
## 11. Testing Guide
|
||||
## 27. Testing Guide
|
||||
|
||||
### 11.1 Backend Tests
|
||||
|
||||
@@ -743,7 +743,7 @@ describe('PluginRegistry', () => {
|
||||
|
||||
---
|
||||
|
||||
## 12. Do's and Don'ts
|
||||
## 28. Do's and Don'ts
|
||||
|
||||
### Do's
|
||||
|
||||
@@ -771,7 +771,7 @@ describe('PluginRegistry', () => {
|
||||
|
||||
---
|
||||
|
||||
## 13. Examples
|
||||
## 29. Examples
|
||||
|
||||
### 13.1 Minimal Plugin
|
||||
|
||||
@@ -1104,4 +1104,710 @@ Plugin-Migrationen werden bei Plugin-Aktivierung automatisch ausgeführt (`sync_
|
||||
|
||||
---
|
||||
|
||||
## 10. Trigger
|
||||
|
||||
Plugins können durch vier Trigger-Typen aktiviert werden: **Domain-Events**, **UI-Events**, **Cron-Jobs** und **manuelle Trigger**. Webhook-Trigger folgt in Phase G.
|
||||
|
||||
### 10.1 Domain-Event-Trigger (durable)
|
||||
|
||||
Domain-Events werden über die **Outbox** gepublished und sind reliable. Siehe Kapitel 8 für die Event-System-Rollen.
|
||||
|
||||
```python
|
||||
# Plugin deklariert Events im Manifest
|
||||
manifest = PluginManifest(
|
||||
name="my_plugin",
|
||||
events=["contact.created", "contact.updated"],
|
||||
...
|
||||
)
|
||||
|
||||
# Handler wird automatisch via on_activate registriert:
|
||||
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
||||
"""Reagiert auf contact.created Outbox-Event."""
|
||||
contact_id = payload.get("contact_id")
|
||||
# Business logic here
|
||||
```
|
||||
|
||||
### 10.2 UI-Event-Trigger (ephemeral)
|
||||
|
||||
Flüchtige UI-Events (z.B. `ui.contact_selected`) laufen über den **EventBus** — nicht über die Outbox. Siehe Kapitel 18.
|
||||
|
||||
```python
|
||||
event_bus = get_event_bus()
|
||||
event_bus.subscribe("ui.contact_selected", self._on_contact_selected)
|
||||
```
|
||||
|
||||
### 10.3 Cron-Trigger
|
||||
|
||||
Cron-Jobs werden im Manifest deklariert und vom Worker ausgeführt:
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import CronJobContribution
|
||||
|
||||
cron_jobs=[
|
||||
CronJobContribution(
|
||||
name="my_plugin_daily_cleanup",
|
||||
cron_expression="0 3 * * *",
|
||||
job_type="custom",
|
||||
plugin_name="my_plugin",
|
||||
),
|
||||
],
|
||||
```
|
||||
|
||||
### 10.4 Manuelle Trigger
|
||||
|
||||
Manuelle Trigger laufen über API-Routes oder Automation-Templates:
|
||||
|
||||
```python
|
||||
@router.post("/api/v1/my-plugin/run-sync")
|
||||
async def run_manual_sync(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Manueller Trigger — Benutzer startet Sync von der UI."""
|
||||
# Business logic
|
||||
return {"status": "started"}
|
||||
```
|
||||
|
||||
**Wichtig:** Durable Domain Events (Outbox) und ephemere UI-Events (EventBus) strikt trennen. Siehe Kapitel 8.5 Entscheidungsregel.
|
||||
|
||||
---
|
||||
|
||||
## 11. Message-System
|
||||
|
||||
Plugins können System-Nachrichten in Chat-Räume posten, eigene Chat-Räume erstellen und Mini-Apps registrieren. Das Communication-Plugin (`kommunikation`) stellt die Infrastruktur bereit.
|
||||
|
||||
### 11.1 System-Nachrichten posten
|
||||
|
||||
```python
|
||||
from app.plugins.builtins.kommunikation.contracts import create_plugin_room, send_message
|
||||
|
||||
# Plugin-Room für einen Benutzer erstellen
|
||||
await create_plugin_room(
|
||||
db, tenant_id, user_id,
|
||||
plugin_name="my_plugin",
|
||||
title="My Plugin",
|
||||
participant_type="system",
|
||||
user_role="reader",
|
||||
)
|
||||
|
||||
# Nachricht in den Room senden
|
||||
await send_message(
|
||||
db, tenant_id, conversation_id,
|
||||
sender_id=None,
|
||||
sender_type="system",
|
||||
content="**Sync abgeschlossen**\n120 Kontakte aktualisiert",
|
||||
content_format="markdown",
|
||||
blocks=None,
|
||||
metadata={"event_type": "sync.completed"},
|
||||
)
|
||||
```
|
||||
|
||||
### 11.2 Rich Content Blocks
|
||||
|
||||
Nachrichten können strukturierte Blocks enthalten (`app/plugins/builtins/kommunikation/content_types.py`):
|
||||
|
||||
```python
|
||||
blocks = [
|
||||
{
|
||||
"block_type": "action_card",
|
||||
"block_data": {
|
||||
"title": "Backup fehlgeschlagen",
|
||||
"body": "Letztes Backup um 03:00 Uhr ist gescheitert.",
|
||||
"actions": [
|
||||
{"label": "Öffnen", "action": "/settings/backup", "type": "primary"},
|
||||
{"label": "Archivieren", "action": "dismiss", "type": "secondary"},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"block_type": "contact_card",
|
||||
"block_data": {"contact_id": str(contact_id), "name": "Max Mustermann"},
|
||||
},
|
||||
{
|
||||
"block_type": "miniapp",
|
||||
"block_data": {"app_id": "my_miniapp", "config": {"contact_id": str(contact_id)}},
|
||||
},
|
||||
]
|
||||
|
||||
await send_message(db, tenant_id, conv_id, sender_id=None, sender_type="system",
|
||||
content="Neuer Kontakt", content_format="markdown", blocks=blocks)
|
||||
```
|
||||
|
||||
Unterstützte Block-Typen: `text`, `markdown`, `html`, `image`, `audio`, `video`, `file`, `action_card`, `contact_card`, `miniapp`.
|
||||
|
||||
### 11.3 Mini-Apps registrieren
|
||||
|
||||
Mini-Apps werden im Manifest deklariert:
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import MiniAppContribution
|
||||
|
||||
miniapps=[
|
||||
MiniAppContribution(
|
||||
app_id="my_miniapp",
|
||||
name="My Mini App",
|
||||
icon="AppWindow",
|
||||
description="Interactive mini-app embedded in chat",
|
||||
render_schema={"type": "object", "properties": {"contact_id": {"type": "string"}}},
|
||||
),
|
||||
],
|
||||
```
|
||||
|
||||
Siehe `system_notif` Plugin als Referenz-Implementierung.
|
||||
|
||||
---
|
||||
|
||||
## 12. Search
|
||||
|
||||
Plugins können Search-Provider registrieren, um ihre Entitäten in der Unified Search bereitzustellen.
|
||||
|
||||
### 12.1 SearchProvider implementieren
|
||||
|
||||
Implementiere das `SearchProvider`-Protokoll oder erbe von `BaseSearchProvider` für automatische Visibility-Filterung:
|
||||
|
||||
```python
|
||||
from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider
|
||||
|
||||
class MyEntitySearchProvider(BaseSearchProvider):
|
||||
entity_type = "my_entity"
|
||||
|
||||
async def _search_fts_filtered(self, db, tsquery, tenant_id, limit, visible_ids):
|
||||
# FTS-Query mit visible_ids Filter
|
||||
...
|
||||
|
||||
async def _search_vector_filtered(self, db, embedding, tenant_id, limit, visible_ids):
|
||||
# Vector-Search mit pgvector + visible_ids Filter
|
||||
...
|
||||
|
||||
async def get_embedding_text(self, db, entity_id, tenant_id) -> str:
|
||||
# Text-Repräsentation für Embedding-Generierung
|
||||
...
|
||||
|
||||
def to_search_result(self, entity) -> dict:
|
||||
return {"id": str(entity.id), "type": "my_entity", "title": entity.name}
|
||||
```
|
||||
|
||||
### 12.2 Provider registrieren
|
||||
|
||||
```python
|
||||
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus):
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
registry = get_search_registry()
|
||||
registry.register(MyEntitySearchProvider())
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus):
|
||||
get_search_registry().unregister("my_entity")
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
```
|
||||
|
||||
### 12.3 Unterstützte Modi
|
||||
|
||||
| Modus | Beschreibung |
|
||||
|-------|-------------|
|
||||
| **FTS** | PostgreSQL Full-Text Search (`tsquery`) |
|
||||
| **Vector** | Semantische Suche via pgvector (`embedding`) |
|
||||
| **RAG** | Retrieval-Augmented Generation (Vector + LLM) |
|
||||
| **Graph** | Beziehungs-Suche (zukünftig) |
|
||||
|
||||
### 12.4 Auto-Indexierung
|
||||
|
||||
Wenn eine Entität erstellt/aktualisiert wird, wird ein Outbox-Event gepublished. Der Worker generiert das Embedding und aktualisiert den Such-Index automatisch. Plugins müssen nur `get_embedding_text()` korrekt implementieren.
|
||||
|
||||
---
|
||||
|
||||
## 13. File Storage
|
||||
|
||||
Plugins speichern Files über das zentrale Storage-Backend (`app/core/storage.py`). **Keine eigenen Storage-Backends implementieren.**
|
||||
|
||||
### 13.1 save_with_metadata()
|
||||
|
||||
```python
|
||||
from app.core.storage import save_with_metadata, get_storage_backend
|
||||
|
||||
# Speichert mit MIME-Prüfung, Size-Limit und Hash-Berechnung
|
||||
metadata = await save_with_metadata(
|
||||
path=f"my_plugin/{tenant_id}/{file_name}",
|
||||
data=file_bytes,
|
||||
allowed_mimes=["application/pdf", "image/png", "image/jpeg"], # None = Default-Allowlist
|
||||
max_size_mb=10, # None = aus Config
|
||||
)
|
||||
# Returns: {path, mime_type, size, hash, storage_path}
|
||||
```
|
||||
|
||||
### 13.2 MIME-Prüfung und Path-Traversal-Schutz
|
||||
|
||||
```python
|
||||
from app.core.storage import validate_mime, validate_size, compute_hash
|
||||
|
||||
# MIME wird content-based erkannt (python-magic) mit Extension-Fallback
|
||||
mime = validate_mime(path, data, allowed_mimes=["application/pdf"])
|
||||
# ValueError bei nicht erlaubtem MIME-Typ
|
||||
|
||||
# Path-Traversal wird im LocalStorage automatisch blockiert:
|
||||
# _full_path() normt den Pfad und prüft, ob er innerhalb base_path bleibt
|
||||
```
|
||||
|
||||
### 13.3 Storage-Backend lesen
|
||||
|
||||
```python
|
||||
backend = get_storage_backend()
|
||||
data = await backend.read(path)
|
||||
url = await backend.get_url(path, expires=3600)
|
||||
exists = await backend.exists(path)
|
||||
await backend.delete(path)
|
||||
```
|
||||
|
||||
Für File-Embedding siehe Kapitel 7.2 (LLM Integration — Embedding).
|
||||
|
||||
---
|
||||
|
||||
## 14. Redis
|
||||
|
||||
Plugins nutzen den globalen Redis-Singleton. **Keine eigenen Connections, kein `aioredis.from_url()`.**
|
||||
|
||||
### 14.1 Redis-Client
|
||||
|
||||
```python
|
||||
from app.core.auth import get_redis
|
||||
|
||||
redis = get_redis() # Globaler Singleton
|
||||
await redis.setex(f"my_plugin:lock:{resource_id}", 30, "locked")
|
||||
value = await redis.get(f"my_plugin:lock:{resource_id}")
|
||||
await redis.delete(f"my_plugin:lock:{resource_id}")
|
||||
```
|
||||
|
||||
### 14.2 Cache-Wrapper
|
||||
|
||||
```python
|
||||
from app.core.cache import cache_get, cache_set, cache_delete, cache_flush_pattern
|
||||
|
||||
# Set mit TTL (Default: 300s)
|
||||
await cache_set(f"my_plugin:summary:{tenant_id}", {"count": 42}, ttl=60)
|
||||
|
||||
# Get
|
||||
data = await cache_get(f"my_plugin:summary:{tenant_id}")
|
||||
|
||||
# Delete
|
||||
await cache_delete(f"my_plugin:summary:{tenant_id}")
|
||||
|
||||
# Pattern-Flush (alle Keys mit Prefix)
|
||||
await cache_flush_pattern("my_plugin:*")
|
||||
```
|
||||
|
||||
**Verboten:** `aioredis.from_url()` in Plugin-Code — immer `get_redis()` oder `get_cache()` verwenden.
|
||||
|
||||
---
|
||||
|
||||
## 15. Permissions
|
||||
|
||||
Plugins deklarieren Permissions im Manifest und nutzen das bestehende RBAC/ABAC-System.
|
||||
|
||||
### 15.1 Permissions deklarieren
|
||||
|
||||
```python
|
||||
manifest = PluginManifest(
|
||||
name="my_plugin",
|
||||
permissions=[
|
||||
"my_plugin:read",
|
||||
"my_plugin:write",
|
||||
"my_plugin:admin",
|
||||
],
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### 15.2 Permissions in Routes erzwingen
|
||||
|
||||
```python
|
||||
from app.deps import require_permission
|
||||
|
||||
@router.get("/api/v1/my-plugin/items",
|
||||
dependencies=[Depends(require_permission("my_plugin:read"))])
|
||||
async def list_items(...):
|
||||
...
|
||||
|
||||
@router.post("/api/v1/my-plugin/items",
|
||||
dependencies=[Depends(require_permission("my_plugin:write"))])
|
||||
async def create_item(...):
|
||||
...
|
||||
```
|
||||
|
||||
### 15.3 Wildcard-Support
|
||||
|
||||
Das Permission-System unterstützt Wildcards: `my_plugin:*`, `*:read`, `*:*` (Superadmin). Bare `*` ist verboten.
|
||||
|
||||
### 15.4 Field-Level Permissions
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import FieldDefinition
|
||||
|
||||
field_definitions=[
|
||||
FieldDefinition(module="my_plugin", field="secret_code",
|
||||
label="Secret Code", sensitivity="sensitive"),
|
||||
],
|
||||
```
|
||||
|
||||
**Wichtig:** Tools, Skills und MCP dürfen keine Rechte verleihen. Siehe `docs/permissions.md` und `docs/permissions_plugin_dev.md`.
|
||||
|
||||
---
|
||||
|
||||
## 16. AI Tools
|
||||
|
||||
Plugins können Tools in der globalen `ToolRegistry` registrieren, die von AI-Agenten aufgerufen werden können.
|
||||
|
||||
### 16.1 Tool registrieren
|
||||
|
||||
```python
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus):
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
registry = get_tool_registry()
|
||||
registry.register(
|
||||
name="my_plugin_lookup_contact",
|
||||
description="Look up a contact by name in My Plugin",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Contact name"},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
handler=self._lookup_contact_handler,
|
||||
plugin_name="my_plugin",
|
||||
required_permission="my_plugin:read",
|
||||
category="contacts",
|
||||
)
|
||||
|
||||
async def _lookup_contact_handler(self, arguments: dict, context: dict) -> str:
|
||||
name = arguments.get("name", "")
|
||||
# Business logic — return JSON string
|
||||
return f'{{"found": true, "name": "{name}"}}'
|
||||
```
|
||||
|
||||
### 16.2 Tool bei Deaktivierung entfernen
|
||||
|
||||
```python
|
||||
async def on_deactivate(self, db, service_container, event_bus):
|
||||
get_tool_registry().unregister_plugin("my_plugin")
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
```
|
||||
|
||||
### 16.3 Permission-Prüfung
|
||||
|
||||
Jedes Tool hat ein `required_permission`-Feld. Der AI-Service prüft dies vor der Ausführung:
|
||||
|
||||
```python
|
||||
# Wird automatisch im Service geprüft:
|
||||
if tool.required_permission:
|
||||
if not check_permission(user_context, tool.required_permission):
|
||||
return f"Error: Permission '{tool.required_permission}' required"
|
||||
```
|
||||
|
||||
### 16.4 Tool-Schema (OpenAI-kompatibel)
|
||||
|
||||
Das `AITool`-Dataclass konvertiert automatisch ins OpenAI Function-Calling-Format via `to_openai_schema()`.
|
||||
|
||||
---
|
||||
|
||||
## 17. MCP
|
||||
|
||||
MCP (Model Context Protocol) dient als **dünne Exposure-Schicht** auf bestehende Tools/Services. MCP erhält **keine eigenen Rechte**.
|
||||
|
||||
### 17.1 Architektur
|
||||
|
||||
```
|
||||
External MCP Server → MCP Client Plugin → ToolRegistry → Existing Tools/Services
|
||||
```
|
||||
|
||||
MCP-Tools werden mit dem Naming-Schema `mcp__{server}__{tool}` in der ToolRegistry registriert.
|
||||
|
||||
### 17.2 Tool-Registrierung
|
||||
|
||||
```python
|
||||
# MCP Client Plugin registriert externe Tools automatisch:
|
||||
registry.register(
|
||||
name=f"mcp__{server_name}__{tool_name}",
|
||||
description=f"[MCP:{server_name}] {tool.description}",
|
||||
parameters=tool.parameters,
|
||||
handler=_make_handler(server_cfg, tool.name),
|
||||
plugin_name="mcp_client",
|
||||
required_permission="mcp-client:read", # Bestehende Permission
|
||||
category="mcp-external",
|
||||
)
|
||||
```
|
||||
|
||||
### 17.3 Auth- und Run-as-Kontext
|
||||
|
||||
Der vorhandene Auth-/Run-as-Kontext und normale Permission-Prüfungen bleiben maßgeblich. MCP-Tools erben die Permissions des aufrufenden Benutzers — MCP kann keine Rechte verleihen, die der Benutzer nicht hat.
|
||||
|
||||
**Verboten:** Eigene Auth-Bypass-Logik in MCP-Handlern. Immer den Standard-Permission-Check verwenden.
|
||||
|
||||
---
|
||||
|
||||
## 18. UI-Events
|
||||
|
||||
Plugins können auf UI-Events reagieren und eigene UI-Events publishen. UI-Events sind **ephemeral** — über EventBus, nicht über Outbox.
|
||||
|
||||
### 18.1 UI-Events abonnieren
|
||||
|
||||
```python
|
||||
from app.core.event_bus import get_event_bus
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus):
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
event_bus.subscribe("ui.contact_selected", self._on_contact_selected)
|
||||
event_bus.subscribe("ui.page_navigated", self._on_page_navigated)
|
||||
event_bus.subscribe("ui.mail_opened", self._on_mail_opened)
|
||||
|
||||
async def _on_contact_selected(self, payload: dict) -> None:
|
||||
contact_id = payload.get("contact_id")
|
||||
# React to contact selection — e.g. preload data
|
||||
```
|
||||
|
||||
### 18.2 UI-Events publishen
|
||||
|
||||
```python
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish("ui.my_plugin_widget_ready", {
|
||||
"widget_id": "summary",
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
})
|
||||
```
|
||||
|
||||
### 18.3 Bekannte UI-Events
|
||||
|
||||
| Event | Payload | Beschreibung |
|
||||
|-------|---------|-------------|
|
||||
| `ui.contact_selected` | `{contact_id, user_id}` | Benutzer hat Kontakt ausgewählt |
|
||||
| `ui.page_navigated` | `{path, user_id}` | Benutzer hat Seite navigiert |
|
||||
| `ui.mail_opened` | `{mail_id, user_id}` | Benutzer hat E-Mail geöffnet |
|
||||
|
||||
**Wichtig:** UI-Events gehen bei Crash verloren. Für reliable Delivery Outbox verwenden (Kapitel 8).
|
||||
|
||||
---
|
||||
|
||||
## 19. AI UI Control
|
||||
|
||||
Das `ai_ui_control` Plugin ermöglicht AI-Agenten, die Frontend-UI zu steuern: Navigation, Filter, Kontakte öffnen, Modals, Tabs und Settings.
|
||||
|
||||
### 19.1 Command-Typen
|
||||
|
||||
```python
|
||||
from app.plugins.builtins.ai_ui_control.schemas import UICommandType
|
||||
|
||||
# Unterstützte Actions:
|
||||
# navigate → {action: 'navigate', path: '/contacts/123'}
|
||||
# filter → {action: 'filter', entity: 'contacts', filter: {type: 'company'}}
|
||||
# open_contact → {action: 'open_contact', contact_id: '...'}
|
||||
# modal → {action: 'modal', modal: 'edit', contact_id: '...'}
|
||||
# tab → {action: 'tab', tab: 'emails', contact_id: '...'}
|
||||
# settings → {action: 'settings', section: 'ai', key: 'model', value: 'gpt-4'}
|
||||
```
|
||||
|
||||
### 19.2 Command senden (REST)
|
||||
|
||||
```python
|
||||
# AI-Agent sendet Command via REST:
|
||||
POST /api/v1/ai-ui-control/command
|
||||
{
|
||||
"action": "navigate",
|
||||
"path": "/contacts/abc-123",
|
||||
"description": "Opening contact detail page"
|
||||
}
|
||||
# Response: {command_id, status: "pending", action: "navigate"}
|
||||
```
|
||||
|
||||
### 19.3 Command empfangen (WebSocket)
|
||||
|
||||
Das Frontend verbindet sich via WebSocket `/ws/ai-ui-control` und empfängt Commands in Echtzeit. Nach Ausführung sendet das Frontend Feedback zurück:
|
||||
|
||||
```python
|
||||
UICommandFeedback(
|
||||
command_id="...",
|
||||
status=UICommandStatus.success,
|
||||
action=UICommandType.navigate,
|
||||
current_path="/contacts/abc-123",
|
||||
)
|
||||
```
|
||||
|
||||
### 19.4 Permissions
|
||||
|
||||
AI UI Control benötigt `ai_ui_control:write` für Commands und `ai_ui_control:read` für Status-Abfragen.
|
||||
|
||||
**Wichtig:** Persistente Mutationen (Daten ändern, erstellen, löschen) dürfen **nicht** über AI UI Control laufen. Diese müssen über reguläre Tools/Services mit Permission-Prüfungen gehen. AI UI Control ist nur für UI-Navigation und Anzeige.
|
||||
|
||||
---
|
||||
|
||||
## 20. Sensitive Data
|
||||
|
||||
Plugins müssen sensible Daten explizit deklarieren und sicherstellen, dass diese nicht in Snapshots, Such-Index, Embeddings, Exporten oder Logs landen.
|
||||
|
||||
### 20.1 SENSITIVE_FIELDS deklarieren
|
||||
|
||||
Im Manifest über `field_definitions` mit `sensitivity="sensitive"` oder `sensitivity="critical"`:
|
||||
|
||||
```python
|
||||
from app.plugins.manifest import FieldDefinition
|
||||
|
||||
field_definitions=[
|
||||
FieldDefinition(module="my_plugin", field="api_key",
|
||||
label="API Key", sensitivity="critical"),
|
||||
FieldDefinition(module="my_plugin", field="internal_notes",
|
||||
label="Internal Notes", sensitivity="sensitive"),
|
||||
],
|
||||
```
|
||||
|
||||
### 20.2 Was NICHT in Snapshots/Index/Embeddings/Export/Logs darf
|
||||
|
||||
- **Passwords, Tokens, API Keys, Session-IDs** — niemals loggen, indexieren oder embedden
|
||||
- **Personenbezogene Daten** (DSGVO-relevant) — nicht in Such-Embeddings ohne explizite Freigabe
|
||||
- **Interne Notizen** mit `sensitivity="sensitive"` — nicht in Exporten ohne Berechtigung
|
||||
|
||||
### 20.3 Error-Logging Sanitization
|
||||
|
||||
Das Error-Logging-Endpoint (`/api/v1/errors`) sanitized automatisch sensible Keys:
|
||||
|
||||
```python
|
||||
# app/routes/errors.py — _SENSITIVE_PATTERNS
|
||||
# Erkennt: token, password, secret, authorization, cookie, session,
|
||||
# api_key, access_token, refresh_token, csrf, bearer, private_key
|
||||
# Diese Felder werden durch "[redacted]" ersetzt.
|
||||
```
|
||||
|
||||
### 20.4 AI/Data Exposure Policy
|
||||
|
||||
- AI-Tools dürfen keine sensiblen Felder an LLM-Provider senden, ohne dass der Benutzer die entsprechende Permission hat
|
||||
- Embeddings dürfen nur aus nicht-sensiblen Texten generiert werden
|
||||
- Export-Service respektiert Field-Level Permissions (`hidden`, `readonly`, `read`)
|
||||
|
||||
---
|
||||
|
||||
## 21. Migration-Staffelung
|
||||
|
||||
Schema-Änderungen in Plugins müssen gestaffelt durchgeführt werden, um Downtime und Datenverlust zu vermeiden.
|
||||
|
||||
### 21.1 Sechs-Schritt-Prozess
|
||||
|
||||
```text
|
||||
1. Neue Struktur erstellen (neue Tabelle/Spalte/Index)
|
||||
→ Migration 0002_add_new_column.sql
|
||||
|
||||
2. Daten migrieren (Backfill)
|
||||
→ Migration 0003_backfill_data.sql (oder Worker-Job)
|
||||
|
||||
3. Reads/Writes umstellen
|
||||
→ Code schreibt in neue UND alte Struktur (Dual-Write)
|
||||
→ Code liest aus neuer Struktur (mit Fallback auf alte)
|
||||
|
||||
4. Tests
|
||||
→ Unit-Tests mit neuer Struktur
|
||||
→ Integration-Tests mit Dual-Write
|
||||
→ Migration-Tests (upgrade + downgrade)
|
||||
|
||||
5. Stabiler Release
|
||||
→ Deploy mit neuer Struktur + Dual-Write
|
||||
→ Verify: alle Daten korrekt migriert
|
||||
|
||||
6. Alte Struktur entfernen
|
||||
→ Migration 0004_drop_old_column.sql
|
||||
→ Code: Dual-Write entfernen, nur neue Struktur
|
||||
```
|
||||
|
||||
### 21.2 Plugin-Migration-Beispiel
|
||||
|
||||
```sql
|
||||
-- migrations/0002_add_status_v2.sql
|
||||
ALTER TABLE my_plugin_items ADD COLUMN status_v2 VARCHAR(20) DEFAULT 'active';
|
||||
|
||||
-- migrations/0003_backfill_status.sql
|
||||
UPDATE my_plugin_items SET status_v2 = CASE
|
||||
WHEN status = 'pending' THEN 'pending'
|
||||
WHEN status = 'done' THEN 'completed'
|
||||
ELSE 'active'
|
||||
END;
|
||||
```
|
||||
|
||||
```python
|
||||
# Schritt 3: Dual-Write in Service
|
||||
item.status = old_status # alte Spalte
|
||||
item.status_v2 = map_to_new_status(old_status) # neue Spalte
|
||||
```
|
||||
|
||||
**Wichtig:** Jeder Schritt ist ein separater Release. Nie Struktur ändern und Daten migrieren in einer Migration.
|
||||
|
||||
---
|
||||
|
||||
## 22. Error-Handling
|
||||
|
||||
Plugins werfen strukturierte Errors über `ApiError` mit `code`, `detail`, `field` und `status`. Tracebacks werden niemals an den User gesendet.
|
||||
|
||||
### 22.1 ApiError werfen
|
||||
|
||||
```python
|
||||
from app.core.error_codes import ApiError
|
||||
|
||||
@router.post("/api/v1/my-plugin/items")
|
||||
async def create_item(body: ItemCreate, db: AsyncSession = Depends(get_db)):
|
||||
if not body.name:
|
||||
raise ApiError(code="validation_error", detail="Name is required", field="name")
|
||||
|
||||
existing = await check_duplicate(db, body.name)
|
||||
if existing:
|
||||
raise ApiError(code="not_found", detail="Item already exists", status=409)
|
||||
|
||||
# Service unavailable
|
||||
raise ApiError(code="service_unavailable", detail="External API timeout")
|
||||
```
|
||||
|
||||
### 22.2 Standardisierte Error-Codes
|
||||
|
||||
| Code | HTTP Status | Beschreibung |
|
||||
|------|------------|-------------|
|
||||
| `not_found` | 404 | Resource nicht gefunden |
|
||||
| `permission_denied` | 403 | Keine Berechtigung |
|
||||
| `validation_error` | 422 | Validierung fehlgeschlagen |
|
||||
| `rate_limited` | 429 | Zu viele Requests |
|
||||
| `internal_error` | 500 | Interner Fehler |
|
||||
| `service_unavailable` | 503 | Service temporär nicht verfügbar |
|
||||
|
||||
### 22.3 Error-Propagation-Kette
|
||||
|
||||
```text
|
||||
Plugin → ApiError(code, detail)
|
||||
→ Core Exception Handler → JSON Response {error: {code, detail, field}}
|
||||
→ Frontend API Client → TanStack Query onError
|
||||
→ ErrorBoundary / Toast Notification → User
|
||||
```
|
||||
|
||||
### 22.4 trace_id-Korrelation
|
||||
|
||||
Jeder Error bekommt eine `trace_id` für End-to-End-Tracing:
|
||||
|
||||
```python
|
||||
# WebSocket-Errors via ws_helpers:
|
||||
from app.core.ws_helpers import send_ws_error
|
||||
await send_ws_error(websocket, code="validation_error",
|
||||
detail="Invalid input", trace_id=trace_id)
|
||||
```
|
||||
|
||||
### 22.5 Frontend ErrorBoundary
|
||||
|
||||
Plugin-Seiten und MiniApps **müssen** eine React ErrorBoundary haben. Bei unhandled Errors wird eine freundliche Fehlermeldung angezeigt — kein Stacktrace.
|
||||
|
||||
### 22.6 Partial-Failure bei Batch-Operationen
|
||||
|
||||
Bei Batch-Operationen (z.B. Bulk-Import) wird **nicht** die gesamte Operation abgebrochen. Erfolgreiche Items werden committed, fehlgeschlagene Items werden mit Fehlergrund gesammelt zurückgegeben:
|
||||
|
||||
```python
|
||||
results = {
|
||||
"success": [item_id_1, item_id_2],
|
||||
"failed": [{"item": item_3, "error": "validation_error: Name required"}],
|
||||
}
|
||||
```
|
||||
|
||||
**Verboten:** Unbehandelte Tracebacks an den User senden. Alle Plugin-Errors müssen als `ApiError` geworfen werden.
|
||||
|
||||
---
|
||||
|
||||
*This document is authoritative for all plugin development at LeoCRM.*
|
||||
|
||||
Reference in New Issue
Block a user