Files
HiveGrid-Wireframes/hivegrid-cloud/docs/improvement-plan/06-plugin-system/hooks-lifecycle.md
T

521 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Host-Hook-Points + Plugin-Lifecycle + Tenant-Admin-UI + Crash-Handling
**Status:** in_progress (foundation-shipped, settings iframe/CSP/postMessage shipped; marketplace + hot-reload + output-hooks + 13-more-hook-points + global-crash-counter deferred)
**Task-ID:** #22
**Phase:** 06
**Estimated Effort:** 4-5 Wochen
**Owner:** unassigned
> **Historical implementation plan.** Current truth lives in
> `docs/features/plugins.md`, `docs/operations/plugin-lifecycle.md`,
> `docs/architecture/plugin-hooks.md`, the tenant plugin API at
> `apps/web/app/routes/api.tenants.$tenant.plugins.tsx`, the tenant settings UI
> under `apps/web/app/routes/app.$tenant/settings/plugins*`, and worker fanout at
> `apps/worker/src/jobs/plugin-hook-fanout.ts`. The plan below is retained as
> historical design context; do not treat early "Problem" or checklist text as
> live status without reconciling it against those canonical docs. Where the
> original plan says `plugin.invoke` outbox/job events, the shipped queue path is
> `outbox.plugin-hook` fanout into `toolctrl.plugin-invoke`.
## Problem
When this plan was written, the runtime existed only as an early foundation and
host trigger/lifecycle/UI paths still needed to be built. That is no longer the
live state: tenant-scoped lifecycle APIs, settings UI, settings iframe
CSP/postMessage contract, hook fanout, invoke queue, crash tracking, and
capability review paths now exist behind the production runtime gate.
Original gaps listed by the plan:
1. **Hook-Points im Host-Code** — es gibt heute ~15-20 Stellen im Code (Tenant-Signup, User-Create, Entity-CRUD, Workflow-Execution, Webhook-Outbound, Email-Send, Chatbot-Message, Export-Run, Billing-Pre/Post, Auth-Login etc.), an denen Plugins zu Recht etwas tun wollen würden. Diese Stellen rufen keinerlei Plugin-Registry auf.
2. **Plugin-Lifecycle-Management** — kein `installPlugin(file)`, kein `enablePlugin(pluginId, tenantId)`, kein Update-Path, kein Uninstall-Path. Plugins die per Hand auf Disk landen (siehe Staging-Steps von Task #21) sind ein Hack, kein Workflow.
3. **DB-Schema pro Plugin** — Plugins die eigene Tabellen brauchen (`plugin_com_acme_slack_messages`) haben keinen Mechanismus diese anzulegen, zu migrieren oder beim Uninstall zu bereinigen.
4. **Tenant-Admin-UI** — kein UI um Plugins zu sehen, zu installieren, Capabilities zu reviewen, Settings zu konfigurieren oder per-User zu enablen. Tenant-Admin müsste DB-Access haben, was wir nicht erlauben.
5. **Per-User-Enablement** — manche Plugins (z.B. Slack-Notifier mit Per-User-Channel-Config) brauchen Per-User-Einstellungen, nicht nur Per-Tenant.
6. **Crash-Handling** — ein Plugin das in einer Endlosschleife immer wieder crashed füllt die Queue, das Audit-Log und blockiert Worker-Threads ohne dass jemand etwas merkt. Es fehlt Auto-Disable + Notification.
Betroffene Codestellen (zu modifizieren):
- `apps/web/app/routes/onboarding.tsx` — Tenant-Signup-Pfad
- `apps/web/app/utils/db/users.db.server.ts` — User-Create
- `apps/web/app/utils/services/entities/entitiesSingleService.ts` — Entity-CRUD
- `apps/web/app/modules/workflowEngine/services/WorkflowEngineService.ts` — Workflow-Execute
- `apps/web/app/modules/webhooks/services/WebhookService.ts` — Outbound-Webhook
- `apps/web/app/utils/services/emailService.ts` — Email-Send
- `apps/web/app/modules/chatbot/services/*` — Chatbot-Message (kommt aus Phase 07; Hook-Stub vorbereiten)
- `apps/api/src/billing/handlers.ts` — Billing-Pre/Post
- `apps/web/app/utils/services/.server/authService.ts` — Auth-Login
## Depends on
- `06-plugin-system/wasm-sandbox.md` — die Runtime + SDK + Invoker müssen lauffähig sein, sonst gibt es nichts zu triggern. Hook-Calls landen auf `invokePlugin()` aus Task #21.
- `03-scaling-infrastructure/outbox-pattern.md` — Hooks werden niemals synchron aufgerufen, sondern immer als Outbox-Event-Producer (`plugin.hook.<name>`). Aktueller Code: der `worker-general`-Pool konsumiert `outbox.plugin-hook`, fanoutet tenant-scoped Subscriber und enqueued `toolctrl.plugin-invoke`; der `worker-plugin`-Pool führt nur die isolierten WASM-Invocations aus. Synchrone Hook-Calls würden den Web-Request-Pfad blockieren und Tenant-Operationen mit Plugin-Crashes koppeln.
- `05-business-features/feature-flags.md` — Capability-Grants pro Tenant werden über das Feature-Flag-System gespeichert/abgefragt (cached in SharedStore), nicht über eigene Lookup-Tabelle.
## Blocks
- `06-plugin-system/audit-docs.md` — Audit-Logging pro Plugin-Call hängt am Invoker; Developer-Docs + Beispiel-Plugins brauchen die Hook-API und das Install-Flow um realistisch zu sein.
- `11-backlog/marketplace.md` — kein Marketplace ohne funktionierenden Install-Flow.
## Approach
**Hook-Registry-Modul + Outbox-Event-Producer + Plugin-DB-Schema mit Tabellen-Prefix + Lifecycle-State-Machine + Tenant-Admin-Routes.**
### 1. Hook-Registry
Ein zentrales `packages/plugin-hooks/`-Package definiert alle Hook-Namen + ihre Input/Output-Schemas (Zod) als Single Source of Truth. Hook-Caller im Host-Code rufen niemals direkt Plugin-Code auf — sie produzieren ein Outbox-Event:
```ts
import { emitHook } from "@toolctrl/plugin-hooks";
// In Tenant-Signup-Handler:
await emitHook("onTenantSignup", { tenantId, tenantSlug, ownerEmail, plan });
```
`emitHook` macht intern:
1. Validiert Payload gegen das Hook-Schema (Schutz vor falschen Payloads).
2. Lädt aus DB / Cache welche installed/enabled Plugins diesen Hook abonniert haben (für diesen Tenant).
3. Pro Plugin: erzeugt einen `plugin.invoke`-Outbox-Event mit `{ pluginId, version, hookName, tenantId, input }`.
4. Returnt sofort — kein Warten auf Plugin-Output.
Die 15-20 Hook-Points (initial-Scope, weitere erlaubt in Folge-Phasen):
| Hook-Name | Ausgelöst von | Input-Felder | Output-Verwendung |
| ------------------------ | -------------------------- | ---------------------------------------------------------------------- | ----------------------------------------- |
| `onTenantSignup` | Tenant-Signup-Flow | tenantId, slug, ownerEmail, plan, createdAt | none (fire-and-forget) |
| `onTenantDeleted` | Tenant-Hard-Delete | tenantId, deletedAt | none |
| `onUserCreate` | User-Create | userId, tenantId, email, role | none |
| `onUserUpdate` | User-Update | userId, tenantId, changedFields[] | none |
| `onUserDelete` | User-Delete | userId, tenantId | none |
| `onEntityCreate` | Entity-Create (CRM/etc.) | entityType, entityId, tenantId, data | none |
| `onEntityUpdate` | Entity-Update | entityType, entityId, tenantId, changedFields[], beforeData, afterData | none |
| `onEntityDelete` | Entity-Delete | entityType, entityId, tenantId | none |
| `onWorkflowExecute` | Workflow-Run | workflowId, runId, tenantId, status, durationMs | none |
| `onWebhookOutbound` | Outbound-Webhook-Pre-Send | webhookId, url, method, tenantId, payloadHash | optional `transform` Output (siehe unten) |
| `onEmailSend` | Email-Pre-Send | toEmail, subject, templateId, tenantId | optional `cancel` |
| `onChatbotMessage` | Chatbot-Message (Phase 07) | conversationId, tenantId, userMessage, intent | optional `additionalTools[]` |
| `onExportRun` | Export-Run-Start | exportId, tenantId, format, rowCountEstimate | none |
| `beforeBilling` | Billing-Cycle-Pre | tenantId, periodStart, periodEnd, lineItems[] | optional `additionalLineItems[]` |
| `afterBilling` | Billing-Cycle-Post | tenantId, invoiceId, total, currency | none |
| `afterAuthLogin` | Successful-Auth | userId, tenantId, method, ipHash | none |
| `onApiKeyCreated` | API-Key-Create | apiKeyId, tenantId, scopes[] | none |
| `onIntegrationConnected` | OAuth-Integration-Connect | integrationProvider, tenantId, userId | none |
**Output-Hooks** (wo Plugin etwas zurückgibt das den Host-Pfad beeinflusst, z.B. `onWebhookOutbound.transform`): nur eine kleine Whitelist. Diese laufen **synchron** gegen die Plugin-Runtime mit striktem Wall-Clock-Limit von 500ms — wenn das Limit überschritten wird, wird der Original-Payload unverändert gesendet (graceful degradation). **Default: fire-and-forget**, Output-Hooks sind die Ausnahme.
### 2. Plugin-Lifecycle-State-Machine
```
┌─────────────┐
│ pending │ ← Tenant-Admin lädt Plugin hoch, Signature-Verify läuft
└──────┬──────┘
│ verify-ok
▼
┌─────────────┐
│ awaiting- │ ← Capabilities werden Tenant-Admin im Consent-Dialog gezeigt
│ consent │
└──────┬──────┘
│ tenant-admin clicks "Approve"
▼
┌─────────────┐
│ installed │ ← Migrations laufen, Plugin ist installiert aber disabled
│ (disabled) │
└──────┬──────┘
│ tenant-admin enables
▼
┌─────────────┐ ┌─────────────┐
│ enabled │ ◄────► │ disabled │ (per-tenant + per-user)
└──────┬──────┘ └─────────────┘
│
│ tenant-admin clicks "Uninstall"
▼
┌─────────────┐
│ uninstalling│ ← cleanup-Hook ausgelöst, Migrations-Down, Storage-Cleanup
└──────┬──────┘
│
▼
┌─────────────┐
│ uninstalled│ ← DB-Eintrag gelöscht
└─────────────┘
Crash-Path (separater Übergang):
enabled ──(>5 crashes in 10 min)──► auto-disabled ──(tenant-admin re-enables)──► enabled
```
### 3. Plugin-DB-Schema-Migrations
Plugins die eigene Tabellen brauchen, deklarieren das im Manifest:
```jsonc
{
"migrations": [
{ "version": 1, "file": "migrations/001_init.sql" },
{ "version": 2, "file": "migrations/002_add_column.sql" },
],
}
```
**Tabellen-Prefix-Pflicht:** Jede Tabelle die ein Plugin anlegt MUSS mit `plugin_<sanitized-plugin-id>_` beginnen (z.B. `plugin_com_acme_slack_messages`). Der Migration-Runner parsed jede `CREATE TABLE`-Statement und lehnt Migrationen ab, deren Tabellen-Namen den Prefix nicht haben.
**Alternative die wir nicht nehmen:** Separate Postgres-Schemas pro Plugin (`plugin_com_acme_slack.messages`). Eleganter, aber: erschwert Multi-Tenant-Queries (jeder Schema-Switch ist eine eigene Connection-Search-Path-Manipulation), bricht den `prismaRead/prismaWrite`-Split, macht Backups komplexer. **Prefix-basiert reicht.**
**Tenant-Scoping in Plugin-Tabellen:** Jede Plugin-Tabelle MUSS eine `tenantId TEXT NOT NULL` Spalte haben + Index darauf. Migration-Runner validiert das via Schema-Inspection nach Apply. Plugin-DB-Capabilities (siehe `wasm-sandbox.md`) injizieren `WHERE tenantId = …` automatisch — der Plugin-Autor muss das nicht selber tun, aber das Schema muss es supporten.
**Down-Migrations:** Pflicht — beim Uninstall werden alle Down-Migrations in umgekehrter Reihenfolge ausgeführt. Wenn keine Down-Migration existiert: Tabelle wird DROPped (Default-Behavior).
**Connection:** Plugin-Migrations laufen via `prismaWrite` (Primary-DB), NIEMALS via Plugin-Sandbox. Migration ist ein Host-Operation, kein Plugin-Operation.
### 4. Tenant-Admin-UI
Historical target: neue Route `app/$tenant/plugins` mit folgenden Sub-Routes. Shipped UI lives under tenant settings: `apps/web/app/routes/app.$tenant/settings/plugins.tsx` and `apps/web/app/routes/app.$tenant/settings/plugins.$id.audit.tsx`.
| Route | View | Aktion |
| ----------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/app/$tenant/plugins` | Plugin-Liste (installed) | Search/Filter, Status-Badges (enabled/disabled/auto-disabled), Per-Card-Link auf Settings/Uninstall |
| `/app/$tenant/plugins/install` | Install-Dialog | File-Upload (.zip), zeigt Manifest-Parse-Preview, Signature-Verify-Status, Capability-Liste mit Beschreibungen + Consent-Checkbox-pro-Capability + „Install"-Button |
| `/app/$tenant/plugins/$pluginId` | Plugin-Detail | Status, Version, Author, Capabilities, Last-Invocations-Stats (count, error-rate), Enable/Disable-Toggle, Uninstall-Button |
| `/app/$tenant/plugins/$pluginId/settings` | Plugin-Settings-Page | Lazy-loaded UI-Slot — rendert die `ui/settings.html` aus dem Plugin in einem sandboxed iframe (siehe Security Notes) |
| `/app/$tenant/plugins/$pluginId/users` | Per-User-Enablement | Liste aller Tenant-User mit Toggle „Plugin für diesen User aktiv?" |
| `/app/$tenant/plugins/$pluginId/audit` | Plugin-Audit-Trail | Letzte 100 Invocations mit Outcome, Capability-Used, Duration — Link auf `audit-docs.md`-Full-View |
### 5. Per-User-Enablement
Default: ein installed + enabled Plugin gilt für **alle** Tenant-User. Optional kann Tenant-Admin die Per-User-Granularität aktivieren (Toggle in Settings). Wenn aktiv: Plugin wird nur für User getriggert die explizit enabled sind. Stored in `tenant_plugin_user_enablement (tenantId, pluginId, userId, enabled)`.
### 6. Crash-Handling + Auto-Disable
- Jeder Plugin-Invocation-Fehler (Trap, Timeout, Capability-Denied, Resource-Exceeded) wird in SharedStore in einem Sliding-Window-Counter pro `(pluginId, tenantId)` gezählt.
- Schwellwert: **5 Failures in 10 Minuten** → Plugin wird auto-disabled für diesen Tenant (`status='auto-disabled'`, `autoDisabledReason='repeated_failures'`, `autoDisabledAt=now()`).
- Tenant-Admin bekommt eine In-App-Notification + (wenn aktiviert) Email mit Link auf Plugin-Detail-Page.
- Tenant-Admin kann manuell wieder enablen — Counter wird gleichzeitig zurückgesetzt.
- Counter-Reset-Mechanismus: bei jedem erfolgreichen Call wird der Failure-Counter dekrementiert (nicht auf 0, aber um 1 — verhindert dass Plugins die 4× crashen + 1× klappen permanent im Counter-Fast-Path bleiben).
- Operator-Level-Auto-Disable: zusätzlich zum Tenant-Level gibt es einen Global-Counter. Wenn ein Plugin in mehreren Tenants gleichzeitig crashed (Schwellwert: 3 Tenants in 30 Min mit ≥3 Crashes), wird das Plugin **global** deaktiviert und der Operator wird via PagerDuty-Style-Alert benachrichtigt.
## Implementation Steps
1. **Plugin-Hooks-Package scaffolden** — Single Source of Truth für Hook-Namen + Schemas
- Files:
- `packages/plugin-hooks/package.json`, `tsconfig.json`, `src/index.ts`
- `packages/plugin-hooks/src/hooks.ts` — Const-Record `HOOKS` mit Hook-Name als Key und `{ inputSchema: ZodSchema, outputSchema: ZodSchema | null, allowsOutput: boolean }` als Value. 18 Hooks aus Approach-Tabelle.
- PNPM-Workspace-Eintrag.
- Tests: `packages/plugin-hooks/src/hooks.test.ts` — Exhaustiveness, Schema-Validity.
- Commit: `06-plugin-system/hooks-lifecycle: scaffold plugin-hooks package with 18 hook definitions`
2. **`emitHook`-Funktion** — Producer-Side
- Files: `packages/plugin-hooks/src/emit.ts` — `emitHook(name, payload, ctx)` validiert Payload, sucht Subscribers per Cache + DB (cached via `tenant-plugin-subscriptions` SharedStore-Key, TTL 60s), produziert pro Subscriber einen `plugin.invoke` Outbox-Event.
- Tests: `packages/plugin-hooks/src/emit.test.ts` — Payload-Validation, Subscriber-Lookup, Outbox-Producer-Call (mocked).
- Commit: `06-plugin-system/hooks-lifecycle: add emitHook with outbox event production`
3. **Synchron-Output-Hook-Pfad** — für `transform`/`cancel`-Capability
- Files: `packages/plugin-hooks/src/emitSync.ts` — `emitHookSync(name, payload, ctx)` ruft Invoker mit Wall-Clock-500ms direkt auf (statt Outbox), aggregiert Outputs der Plugins (chained `transform` order via Manifest-`priority` Feld), graceful fallback bei Timeout.
- Tests: `packages/plugin-hooks/src/emitSync.test.ts` — Single-Plugin, Chained-Plugins, Timeout-Fallback, Output-Schema-Violation-Fallback.
- Commit: `06-plugin-system/hooks-lifecycle: add emitHookSync for transform/cancel hooks`
4. **DB-Migration: installed_plugins-Schema** — Lifecycle-State + Subscriptions + Secrets + User-Enablement
- Files: `prisma/schema.prisma`-Ergänzung + Migration:
- `installed_plugins (id, tenantId, pluginId, version, status, manifestJson, signature, publisherKeyId, installedAt, installedByUserId, lastInvocationAt, autoDisabledReason, autoDisabledAt)` — eindeutig per `(tenantId, pluginId)`
- `tenant_plugin_capability_grants (id, tenantId, pluginId, capabilityName, scopeJson, grantedAt, grantedByUserId)`
- `tenant_plugin_secrets (id, tenantId, pluginId, keyName, valueEncrypted, updatedAt)` — Value via PG-encrypted Field (TC-existing Pattern)
- `tenant_plugin_user_enablement (id, tenantId, pluginId, userId, enabled, updatedAt)`
- `tenant_plugin_settings (id, tenantId, pluginId, settingsJson, updatedAt)` — Plugin-Settings (key-value-blob)
- `plugin_invocations (id, tenantId, pluginId, hookName, durationMs, outcome, errorCode, capabilityUsedCsv, startedAt)` — wird hier vorbereitet, voll-genutzt in `audit-docs.md`
- `plugin_migrations_applied (id, tenantId, pluginId, migrationVersion, appliedAt)`
- Prisma-Migration `prisma/migrations/<ts>_plugin_lifecycle_schema/migration.sql` + Down-SQL.
- Tests: Prisma-Migration-Test (apply + rollback gegen ephemeral postgres).
- Commit: `06-plugin-system/hooks-lifecycle: add db schema for plugin lifecycle`
5. **Plugin-Migration-Runner** — Prefix-Validation + Apply + Down
- Files:
- `packages/plugin-runtime/src/migrations.ts` — `applyPluginMigrations(pluginId, manifestMigrations, prismaWrite)`: parsed SQL via `pg-query-emscripten`, validiert dass alle `CREATE TABLE`-Statements den `plugin_<sanitized-id>_`-Prefix haben + dass jede Tabelle `tenantId`-Spalte hat. Apply via `prismaWrite.$executeRawUnsafe`, tracks via `plugin_migrations_applied`.
- `rollbackPluginMigrations(pluginId)` — liest down-SQL aus geladenem Manifest oder DROP'ed Tabellen via Information-Schema-Lookup.
- Tests: `packages/plugin-runtime/src/migrations.test.ts` — gegen testcontainers-postgres: valid Migration apply, invalid Prefix → Reject, fehlende tenantId-Spalte → Reject, Down-Path round-trip.
- Commit: `06-plugin-system/hooks-lifecycle: add plugin db migration runner with prefix validation`
6. **Plugin-Lifecycle-Service** — Install/Enable/Disable/Update/Uninstall
- Files: `apps/web/app/modules/plugins/services/PluginLifecycleService.ts`
- `install(tenantId, zipBuffer, userId)` → parsed Manifest, Verify-Signature, in `pending` insert, returnt `{ pluginId, capabilities }` für Consent-Dialog.
- `grantConsent(tenantId, pluginId, grants[], userId)` → schreibt Grants in `tenant_plugin_capability_grants`, läuft Migrations, transition `installed (disabled)`.
- `enable(tenantId, pluginId, userId)`, `disable(tenantId, pluginId, userId)` → Status-Toggle + Cache-Invalidate (`tenant-plugin-subscriptions` Key).
- `update(tenantId, pluginId, newVersionZip, userId)` → installiert neue Version side-by-side, läuft up-Migrations vom alten Version-Tail bis neue Version, marked alte Version `superseded` (bleibt für Rollback verfügbar).
- `uninstall(tenantId, pluginId, userId)` → triggert `onUninstall`-Hook im Plugin (falls deklariert), läuft Down-Migrations, löscht Storage-Keys via Storage-Capability-Sweep, löscht Secrets, löscht DB-Einträge, löscht Binary von Filesystem.
- `autoDisable(tenantId, pluginId, reason)` → wird vom Crash-Counter-Handler aufgerufen, sets `status='auto-disabled'`, emittiert In-App-Notification.
- Tests: `apps/web/app/modules/plugins/services/PluginLifecycleService.test.ts` — pro Lifecycle-Transition ein Test, plus negative Paths (Install mit invalider Signatur, Enable von uninstalled Plugin, etc.).
- Commit: `06-plugin-system/hooks-lifecycle: add PluginLifecycleService`
7. **Plugin-API-Routes (Backend)** — REST/Action-Routes für Tenant-Admin-UI
- Historical note: the shipped tenant plugin API is consolidated in `apps/web/app/routes/api.tenants.$tenant.plugins.tsx`; do not add new `apps/web/app/routes/api/plugins/*` routes from the old sketch below.
- Files:
- Current API: `apps/web/app/routes/api.tenants.$tenant.plugins.tsx` — GET list, POST install/enable/disable via operation params.
- Current audit UI: `apps/web/app/routes/app.$tenant/settings/plugins.$id.audit.tsx`.
- Historical permission sketch: mutating routes require `userHasPermission('app.plugins.manage')`; current read-only list/audit access is split to `app.plugins.use` as documented in `docs/architecture/plugin-hooks.md`.
- Tests: pro Route ein Vitest mit gemocktem PluginLifecycleService.
- Commit: pro Route-Group ein Commit (4-5 Commits).
8. **Tenant-Admin-UI-Routes (Frontend)** — neue React-Router-Routes
- Files:
- Current list UI: `apps/web/app/routes/app.$tenant/settings/plugins.tsx`.
- Current audit UI: `apps/web/app/routes/app.$tenant/settings/plugins.$id.audit.tsx`.
- **WICHTIG:** Nach Hinzufügen sofort `pnpm run typecheck` ausführen damit `./+types/<route>` generiert werden. Imports strikt als `import type { Route } from "./+types/<routeName>"`.
- UI-Komponenten: nutzt existierende shadcn-UI-Komponenten (`Card`, `Dialog`, `Table`, `Switch`, `Button`), keine neuen Design-System-Tokens.
- Tests: Component-Tests via Vitest + Testing-Library für jede Route.
- Commit: pro Route ein Commit (7 Commits).
9. **Settings-Page-Sandboxed-iframe** — Sicheres Rendern von Plugin-UI
- Files: `apps/web/app/modules/plugins/components/PluginSettingsFrame.tsx`
- Rendert `<iframe sandbox="allow-scripts allow-forms" srcDoc={pluginHtml} />`
- `pluginHtml` wird server-seitig aus dem Plugin-ZIP gelesen, durch DOMPurify gelassen, mit einer CSP-Header-Wrapper-Doc gewrapped die nur `'self'` erlaubt + keine Inline-Scripts erlaubt
- Plugin kann mit Host kommunizieren via `postMessage` über einen definierten Protokoll: `{ type: 'plugin.settings.get' | 'plugin.settings.set', payload }`. Host-Seite validiert Message-Origin + serialisiert nichts unsanitized.
- Tests: `PluginSettingsFrame.test.tsx` — Sandbox-Attribute korrekt, CSP-Header korrekt, postMessage-Validation rejected falsche Origins.
- Commit: `06-plugin-system/hooks-lifecycle: add sandboxed plugin settings iframe`
10. **In-App-Notification + Email für Auto-Disable**
- Files: `apps/web/app/modules/plugins/services/PluginNotificationService.ts` — `notifyAutoDisabled(tenantId, pluginId, reason)` → schreibt In-App-Notification (existierendes Notification-System) + queued Mail-Job über `mail-transactional`-Queue mit Template `plugin-auto-disabled.html`.
- Tests: gemocktes Notification-System, assert Calls.
- Commit: `06-plugin-system/hooks-lifecycle: add auto-disable notification service`
11. **Crash-Counter + Auto-Disable-Trigger** — SharedStore-basiert
- Files: `packages/plugin-runtime/src/crash-tracker.ts` — `recordOutcome(pluginId, tenantId, outcome)` (success/failure), `getFailureCountInWindow(pluginId, tenantId, windowMs)`. Default Sliding-Window 10 Min via Redis-Sorted-Set.
- Integration in `packages/plugin-runtime/src/invoker.ts` — nach jedem Call recordOutcome aufrufen. Wenn `getFailureCountInWindow > 5` → triggert `PluginLifecycleService.autoDisable(...)` via internem RPC.
- Global-Crash-Counter äquivalent über separaten Key `plugin-failures-global:<pluginId>:<tenantId>`.
- Tests: `packages/plugin-runtime/src/crash-tracker.test.ts` — Window-Aging (alte Failures fallen raus nach windowMs), Decrement bei Success.
- Commit: `06-plugin-system/hooks-lifecycle: add crash tracker and auto-disable trigger`
12. **Worker-Plugin-Job-Handler** — `plugin.invoke` Outbox-Event-Konsument
- Files: `apps/worker/src/plugin-handlers.ts` — handler `plugin.invoke` ruft `invokePlugin()` aus Task #21 mit Job-Payload auf. Schreibt am Ende `plugin_invocations`-Row mit Outcome.
- Outbox-Producer-Hook in `emitHook` aus Step 2 verweist auf diesen Handler implizit über Queue-Namen.
- Tests: `apps/worker/src/plugin-handlers.test.ts` — Job mit gültigem Payload → invokePlugin called, Outcome geloggt; Job mit Plugin-not-installed → soft-failure (kein Crash).
- Commit: `06-plugin-system/hooks-lifecycle: add plugin.invoke worker handler`
13. **Hook-Wiring: 18 Hook-Points in Host-Code einbauen**
- **Pro Hook ein separater Commit** — atomic per Hook-Point. Beispiel `onTenantSignup`:
- File: `apps/web/app/routes/onboarding.tsx` (oder wo Tenant-Signup-Logic landet)
- Edit: Nach erfolgreichem Tenant-Insert: `await emitHook('onTenantSignup', { tenantId, slug, ownerEmail, plan, createdAt });`
- Test: Existing Signup-Test ergänzen — assert emitHook wurde aufgerufen.
- Commit: `06-plugin-system/hooks-lifecycle: wire onTenantSignup hook in signup flow`
- Wiederholen für die übrigen 17 Hooks. Total 18 Commits.
14. **App-Sidebar-Integration** — Plugin-Menüpunkt nur sichtbar wenn User Admin-Permission hat
- Files:
- `apps/web/app/application/sidebar/AppSidebar.tsx` — add Plugin-Nav-Item
- `apps/web/app/components/layouts/sidebars/shadcn/app-sidebar.tsx` — analog
- `apps/web/app/application/ui/uiRegistry.ts` — register Plugin-Section-Icon
- Tests: Sidebar-Render-Test für Admin/Non-Admin.
- Commit: `06-plugin-system/hooks-lifecycle: add plugins entry to app sidebar`
15. **Permission-System-Eintrag** — neue Permission `app.plugins.manage` + `app.plugins.use`
- Files: `apps/web/app/utils/entitlements/.server/productAccess.server.ts` + Seed-Datei für Default-Roles.
- Migration: existierende Tenant-Admin-Rollen bekommen `app.plugins.manage` automatisch, alle anderen `app.plugins.use`.
- Tests: Permission-Resolution-Test.
- Commit: `06-plugin-system/hooks-lifecycle: add plugin permissions to product access`
16. **Doku** — Tenant-Admin- + Operator-Guide
- Files:
- `docs/features/plugins.md` (vorher Stub, jetzt füllen) — Was sind Plugins, wie installiere ich eines, wie verwalte ich Capabilities, wie deaktiviere ich Plugins, Troubleshooting.
- `docs/operations/plugin-lifecycle.md` — Installation-Pipeline, Migration-Apply-Process, Auto-Disable-Schwellwerte, manuelle Override-Pfade (z.B. `pnpm tsx scripts/plugins-force-disable.ts <pluginId>`), Backup-Strategie für Plugin-Tabellen.
- Commit: `06-plugin-system/hooks-lifecycle: add tenant-admin and operator docs for plugin lifecycle`
17. **Integration-Test-Suite** — kompletter Lifecycle-Flow
- Files: `apps/web/test/integration/plugin-lifecycle.integration.test.ts` — gegen testcontainers-postgres + testcontainers-redis:
- Hello-Plugin uploaden → Manifest geparst, Signature verified, in `pending`
- Consent geben für `log`, `audit` Capabilities → in `installed (disabled)`, Migration applied
- Enable → Status `enabled`, Cache invalidiert
- `emitHook('onTenantSignup', ...)` → Outbox-Event produziert → Worker konsumiert → Plugin-Call ausgeführt → `plugin_invocations` row written
- Plugin disablen → Cache invalidiert, neuer Hook → kein Plugin-Call mehr
- Plugin uninstallen → Down-Migration läuft, Tabellen weg, DB-Eintrag weg.
- Commit: `06-plugin-system/hooks-lifecycle: add end-to-end lifecycle integration test`
## Test Requirements
- **Unit-Tests:**
- `packages/plugin-hooks/src/hooks.test.ts` — Exhaustiveness (alle 18 Hooks haben Input+Output Schemas).
- `packages/plugin-hooks/src/emit.test.ts` — Producer-Path mit gemocktem Outbox.
- `packages/plugin-hooks/src/emitSync.test.ts` — Chained-Plugins, Timeout-Fallback, Schema-Violation-Fallback.
- `packages/plugin-runtime/src/migrations.test.ts` — Prefix-Validation (positive + negative), tenantId-Spalten-Validation, Apply, Down-Roundtrip, Idempotenz beim erneuten Apply.
- `packages/plugin-runtime/src/crash-tracker.test.ts` — Sliding-Window, Decrement, Threshold-Trigger.
- `apps/web/app/modules/plugins/services/PluginLifecycleService.test.ts` — alle Lifecycle-Transitions + Edge-Cases (Install ohne Signature in `PLUGIN_REQUIRE_SIGNATURE=true` → Reject, Enable von uninstalled → 404, Uninstall mit fehlender Down-Migration → DROP-Fallback).
- `apps/web/app/modules/plugins/services/PluginNotificationService.test.ts` — In-App + Mail.
- `apps/web/app/routes/api.tenants.$tenant.plugins.test.ts` — Permission-Guard + Happy-Path.
- `apps/web/app/routes/app.$tenant/settings/plugins.test.ts` — Component-Tests pro Route.
- `apps/web/app/modules/plugins/components/PluginSettingsFrame.test.tsx` — Sandbox-Attribute, CSP, postMessage-Origin-Validation.
- `apps/worker/src/plugin-handlers.test.ts` — Job-Handling.
- **Integration-Tests:**
- `apps/web/test/integration/plugin-lifecycle.integration.test.ts` — kompletter End-to-End-Flow (siehe Step 17).
- `apps/web/test/integration/plugin-hook-emission.integration.test.ts` — Für ein Subset von 5 Hooks (`onTenantSignup`, `onEntityCreate`, `onWorkflowExecute`, `onEmailSend`, `afterAuthLogin`): Trigger im Host-Code → Outbox-Event → Worker-Pickup → Plugin-Invocation → Audit-Log-Eintrag.
- `apps/web/test/integration/plugin-crash-auto-disable.integration.test.ts` — 5× crashen lassen in <10 Min → Status auto-disabled, Notification gefeuert.
- `apps/web/test/integration/plugin-migrations.integration.test.ts` — Plugin mit gültigen Migrations: Install → Tabelle exists, Uninstall → Tabelle weg. Plugin mit Migration ohne tenantId → Install-Reject. Plugin mit Migration ohne Prefix → Install-Reject.
- `apps/web/test/integration/plugin-per-user-enablement.integration.test.ts` — Toggle für User A on / User B off → Hook für User A's Action triggert, für User B's nicht.
- **E2E-Tests (Playwright):**
- `apps/web/tests/e2e/plugin-install-flow.spec.ts` — Tenant-Admin loggt sich ein, navigiert zu Plugins, lädt ZIP hoch, sieht Capability-Consent-Dialog, klickt Approve, sieht Plugin in Liste, enabled es, navigiert zu Settings, ändert ein Setting, sieht in Audit-Trail die Setting-Update-Action.
- `apps/web/tests/e2e/plugin-uninstall-flow.spec.ts` — Plugin uninstallen → Confirm-Dialog → Plugin weg, Datenbank-Tabellen weg (verifiziert via DB-Query im Test-Setup).
- `apps/web/tests/e2e/plugin-permission-guard.spec.ts` — User ohne `app.plugins.manage` sieht keinen Menüpunkt, kann nicht zu `/app/$tenant/plugins/install` navigieren (403).
- **Coverage-Target:** ≥ 85% für `packages/plugin-hooks/src/`, ≥ 85% für `apps/web/app/modules/plugins/`, ≥ 90% für `packages/plugin-runtime/src/migrations.ts` und `crash-tracker.ts`.
- **Test-Files:** wie oben aufgelistet.
**NICHT als completed markieren bevor alle Tests grün sind. Insbesondere die Integration-Tests mit testcontainers MÜSSEN auf CI laufen, nicht nur lokal.**
## Acceptance Criteria
- [ ] `packages/plugin-hooks/` exportiert 18 Hook-Definitions
- [ ] `emitHook` produziert Outbox-Events korrekt; Payload-Validation rejected invalide Payloads
- [ ] `emitHookSync` ist verfügbar für Output-Hooks; Timeout-Fallback bewiesen
- [ ] DB-Schema-Migration für `installed_plugins`, `tenant_plugin_capability_grants`, `tenant_plugin_secrets`, `tenant_plugin_user_enablement`, `tenant_plugin_settings`, `plugin_invocations`, `plugin_migrations_applied` applied + Down-tested
- [ ] Plugin-Migration-Runner enforced `plugin_<sanitized-id>_`-Prefix + `tenantId`-Spalte; Negative-Tests passieren
- [ ] `PluginLifecycleService` deckt Install/GrantConsent/Enable/Disable/Update/Uninstall/AutoDisable ab
- [ ] API-Routes mit Permission-Guard `app.plugins.manage` für alle mutierenden Operations
- [ ] Tenant-Admin-UI: Liste, Install-Dialog mit Capability-Consent, Detail-View, Settings-iframe (sandbox + CSP), Per-User-Toggle, Audit-Stub-View
- [ ] PluginSettingsFrame ist iframe-sandboxed mit `allow-scripts allow-forms`, CSP `default-src 'self'`, DOMPurified, postMessage-Origin-validated
- [ ] Per-User-Enablement funktional: Toggle gespeichert in DB, Hook-Producer respektiert es
- [ ] Crash-Tracker: 5 Failures in 10 Min → Auto-Disable; Tenant-Admin-Notification gefeuert
- [ ] Global-Crash-Counter: 3 Tenants in 30 Min mit ≥3 Crashes → Plugin global deaktiviert + Operator-Alert
- [ ] `worker-plugin`-Service konsumiert `toolctrl.plugin-invoke` jobs korrekt; subscriber fanout happens first from `outbox.plugin-hook` on the general worker
- [ ] **Alle 18 Hook-Points im Host-Code wired** (alle Commits aus Step 13 gemerged)
- [ ] App-Sidebar zeigt Plugins-Nav-Item nur für Tenant-Admins
- [ ] Permissions `app.plugins.manage` + `app.plugins.use` im productAccess.server.ts
- [ ] `docs/features/plugins.md` + `docs/operations/plugin-lifecycle.md` vollständig
- [ ] End-to-End-Integration-Test grün
- [ ] Per-Hook-Wiring-Integration-Tests grün (mindestens 5)
- [ ] Auto-Disable-Integration-Test grün
- [ ] Migration-Integration-Tests grün (positive + negative)
- [ ] Per-User-Enablement-Integration-Test grün
- [ ] Alle Playwright-E2E-Tests grün
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test` grün
- [ ] Staging-Deployment verifiziert: Hello-Plugin via UI installierbar, enable-bar, disable-bar, uninstall-bar; Hook-Wiring funktioniert (z.B. neuer User registriert → `onUserCreate`-Hook löst Plugin-Call aus, sichtbar in Audit-Trail)
- [ ] Detail-MD Progress Log aktualisiert
- [ ] PROGRESS.md Status auf `completed`
## Rollback Strategy
- **Code-Rollback:** Commits per Step revertieren. Hook-Wiring-Commits (Step 13) sind klein und einzeln revert-bar — wenn ein einzelner Hook Probleme macht (z.B. Performance-Regression), kann er allein zurückgenommen werden.
- **Lifecycle-UI:** Routes auskommentieren in `routes.ts` → Tenant-Admins sehen die UI nicht. Backend bleibt nutzbar für CLI/Operator-Aktionen.
- **DB-Migration:** Prisma-Down-Migration vorhanden; bei Rollback wird das Plugin-Schema dropped — **WARNUNG:** alle installierten Plugins werden dabei verloren. Vor Rollback Backup der Tabellen, oder Migration so designen dass Backup-Snapshot vorher gemacht wird (`pg_dump --table='installed_plugins' --table='tenant_plugin_*' --table='plugin_*'`).
- **Plugin-Tabellen (`plugin_<id>_*`):** Bei Migration-Rollback der Lifecycle-Schemas bleiben die Plugin-Tabellen orphaned. Rollback-Skript `scripts/cleanup-orphan-plugin-tables.ts` schreiben das alle Tabellen mit `plugin_`-Prefix listet und auf Operator-Confirm DROPped.
- **Feature-Flag:** `PLUGIN_HOOKS_ENABLED=false` → `emitHook` no-ops (returnt sofort ohne Outbox-Producer). Notfall-Kill-Switch ohne Code-Revert.
- **Auto-Disable-Override:** Operator kann via `pnpm tsx scripts/plugins-force-enable.ts <pluginId> <tenantId>` ein auto-disabled Plugin zurückholen wenn der Crash-Counter false-positive war.
## Security Notes
- **Capability-Consent ist NICHT Capability-Enforcement.** Diese Task baut das Consent-UI. Die echte Enforcement passiert in der Sandbox (`wasm-sandbox.md`). Diese Trennung ist wichtig: niemand sollte den Consent-Dialog umgehen können (z.B. via direktem Datenbankschreiben in `tenant_plugin_capability_grants`), aber selbst wenn doch — die Sandbox enforced unabhängig.
- **iframe-Sandboxing der Plugin-Settings-Page:** `<iframe sandbox="allow-scripts allow-forms">` (kein `allow-same-origin`, kein `allow-top-navigation`). CSP-Header im iframe-Document: `default-src 'self'; script-src 'unsafe-inline' 'self'; img-src data: https:; connect-src 'self'`. Plugin-HTML wird vor Insertion via DOMPurify gelassen (entfernt `<script src=…>` zu fremden Origins, entfernt `<iframe>`-Nesting, entfernt `on*`-Handler die JS injecten könnten). postMessage-Listener auf Host-Seite validiert `event.origin === 'null'` (sandbox-iframe) und das Message-Schema (Zod). Plugin kann via postMessage NIEMALS auf Host-Window-Properties zugreifen.
- **ZIP-Upload-Security:** Plugin-ZIP wird via `adm-zip` oder `yauzl` mit Limits geparsed (max 50 MiB komprimiert, max 200 MiB unkomprimiert — verhindert ZIP-Bombs). Nur `plugin.json`, `plugin.wasm`, `ui/**`, `migrations/**` Files werden extrahiert; alle anderen Files werden ignoriert (verhindert Path-Traversal). Path-Traversal-Detection (`../` in Entry-Namen) → Reject.
- **Permission-Guard:** Alle mutierenden API-Routes (Install, Enable, Disable, Uninstall, Consent, Settings-Schreibend, Secret-Setzen, Per-User-Toggle) prüfen `app.plugins.manage`. Lese-Routes prüfen mindestens `app.plugins.use`. Niemand außer Tenant-Admins sollte Plugins installieren können — auch nicht Tenant-User mit Custom-Rollen, außer explizit gewährt. Cross-Tenant-Calls werden auf API-Ebene durch Tenant-Scoped-Loader verhindert.
- **Secret-Storage:** Plugin-Secrets werden via Tenant-Encryption-Key encrypted (existierendes Pattern in der Boilerplate). Secret-Read-Capability hat `keyPattern`-Scope — Plugin kann nur Secrets lesen deren Key matcht. Secret-Write-API ist write-only (kein Read-Endpoint im Backend für GET-Secret); Tenant-Admin sieht nur Maske im UI.
- **Plugin-Lifecycle-Audit:** Jede Install/Enable/Disable/Uninstall-Aktion wird in das existierende Audit-Log geschrieben (NICHT das Plugin-spezifische `plugin_invocations`). Tenant-Admin kann nachvollziehen wann wer was getan hat.
- **Migration-Security:** SQL aus Plugin-Migrations wird nicht direkt durch `$executeRawUnsafe` ohne Inspection laufen gelassen — Parser validiert Statements gegen Allowlist (`CREATE TABLE`, `CREATE INDEX`, `ALTER TABLE ADD COLUMN`, `DROP TABLE`, `DROP INDEX`, `DROP COLUMN`). Verbotene Statements: `DROP DATABASE`, `GRANT`, `REVOKE`, `CREATE USER`, `COPY`, `pg_*`-Functions, jede Statement die nicht auf `plugin_<sanitized-id>_*` Tabellen target. Statements mit semicolon-injection-Verdacht (mehrere Statements in einer Datei nicht klar separiert) → Reject.
- **Plugin-Auto-Disable-DoS-Vector:** Ein Tenant-User mit Schreibrechten könnte versuchen, ein Plugin mit absichtlichen Fehler-Triggers (z.B. invalid Inputs) zu „bombardieren" um es auto-disabled zu kriegen. **Mitigation:** Crash-Counter zählt nur **Plugin-internal** Failures (Trap, Resource-Exceeded, Capability-Denied auf Plugin-Internal-Calls), NICHT „Plugin returnt Error-Output". Reine Output-Errors zählen separat und triggern nicht Auto-Disable. Falls dennoch der Verdacht aufkommt: Tenant-Admin sieht die Crash-Reasons im UI und kann re-enablen.
- **Update-Path-Security:** Plugin-Update läuft die SAME Signature-Verify wie Install. Wenn Publisher-Key zwischenzeitlich revoked wurde — Update wird abgelehnt, alte Version bleibt aktiv (oder wird abgeschaltet via Operator-Override).
- **iframe-CSRF:** Settings-API-Routes nutzen das existierende CSRF-Token-Pattern; postMessage aus iframe an Host muss den Token mitschicken — Host validiert.
## Subagent Recommendations
- **Planning:** `Plan`-Subagent mit dieser Detail-MD + `wasm-sandbox.md` als Inputs → produziert Subtask-Mapping auf die 17 Steps und schlägt Parallelisierungs-Gruppen vor. Insbesondere Step 13 (18 Hook-Wirings) kann von 3-4 Subagents parallel angegangen werden, einer pro Modul-Bereich (Tenant/User, Entity, Workflow/Webhook, Email/Auth/Billing).
- **Research:** `Explore`-Subagent zur Inventur aller existierenden Hook-Stellen im Code: „Suche im Codebase nach allen Stellen wo Tenants/User/Entities/Workflows/Webhooks/Emails/Auth/Billing-Operations passieren. Liste pro Hook-Name aus der Tabelle den exakten file:line wo das Wiring rein soll." Output landet als Anhang in dieser Detail-MD oder als separate `WIRING-INVENTORY.md`.
- **Implementation:**
- 1 `general-purpose`-Subagent für Steps 1-3 (Hooks-Package — eng zusammenhängend).
- 1 `general-purpose`-Subagent für Step 4 (DB-Schema-Migration — Prisma-Pattern).
- 1 `general-purpose`-Subagent für Step 5 (Migration-Runner — komplex, isoliert).
- 1 `general-purpose`-Subagent für Step 6 (Lifecycle-Service — komplex, isoliert).
- 1 `general-purpose`-Subagent für Steps 7-8 (Routes — repetitiv, kann gebündelt werden).
- 1 `general-purpose`-Subagent für Step 9 (Sandboxed-iframe — Security-kritisch, isoliert).
- 1 `general-purpose`-Subagent für Steps 10-11 (Notification + Crash-Tracker).
- 1 `general-purpose`-Subagent für Step 12 (Worker-Handler).
- **3-4 parallele `general-purpose`-Subagents für Step 13** (Hook-Wiring).
- 1 `general-purpose`-Subagent für Steps 14-15 (Sidebar + Permissions).
- 1 `general-purpose`-Subagent für Step 16 (Doku).
- **Tests:** Separater `general-purpose`-Subagent für Integration-Tests (Step 17 + die Integration-Tests aus Test Requirements). Playwright-E2E-Subagent separat.
- **Verification:** `gsd-verifier`-Subagent über die Acceptance-Criteria.
- **Code-Review:** `gsd-code-reviewer` insbesondere für Step 9 (iframe-Sandboxing — Security-kritisch) und Step 5 (SQL-Migration-Runner — Injection-Vektor).
## Staging-Deployment-Steps
1. Branch `agent/06-hooks-lifecycle` pushen.
2. Coolify-Auto-Deploy auf Staging triggert. **WICHTIG:** Database-Migration läuft automatisch via Coolify-Pre-Deploy-Hook (oder manuell vor Deploy: `pnpm prisma migrate deploy`).
3. Verifiziere dass `worker-plugin`-Container den `toolctrl.plugin-invoke` Job-Handler registriert hat:
```bash
ssh staging-host "docker logs worker-plugin --tail 50 | grep -i 'plugin-invoke'"
```
4. Tenant-Admin-Login auf Staging via `https://staging.toolctrl.de/login` mit Test-Account.
5. Navigate zu `/app/test-tenant/plugins` → Liste leer (erwartet).
6. Klick „Install Plugin" → Upload `hello-plugin-signed.zip` (aus `wasm-sandbox.md`-Staging-Steps).
7. Capability-Consent-Dialog erscheint → alle Capabilities anhaken → „Approve".
8. Plugin erscheint in Liste mit Status „Installed (Disabled)".
9. Toggle „Enable" → Status wechselt zu „Enabled".
10. Trigger Hook via Test-Endpoint (entweder real durch Tenant-Signup eines neuen Test-Tenants oder via temporärem `/api/internal/plugin-test/emit-hook` Endpoint):
```bash
curl -X POST https://staging.toolctrl.de/api/internal/plugin-test/emit-hook \
-H "X-Internal-Test-Token: $TOKEN" \
-d '{"hook":"onTenantSignup","payload":{"tenantId":"test-tenant","slug":"test","ownerEmail":"a@b.c","plan":"free","createdAt":"2026-05-16T00:00:00Z"}}'
```
11. Nach 2-3 Sek `/app/test-tenant/plugins/com.toolctrl.hello/audit` öffnen → Invocation-Entry sichtbar mit `outcome=success`, `durationMs<500`, `capabilityUsed=log,audit`.
12. Plugin in Settings → setze ein Test-Setting → Verify postMessage funktioniert (Browser-DevTools-Console).
13. Per-User-Toggle: User A enable, User B disable → trigger Hook für User B's Action → kein Invocation in Audit.
14. Crash-Test: temporär `crash-plugin.zip` (Plugin das immer Trap'ed) installieren + enablen → 5× Hook triggern → erwartet: Status auf „Auto-Disabled", Notification-Bell zeigt Eintrag.
15. Uninstall „crash-plugin" → bestätigen → verschwindet aus Liste, DB-Verify dass `plugin_com_toolctrl_crash_*`-Tabellen weg sind:
```bash
ssh staging-host "psql -d toolctrl -c '\dt plugin_*'"
```
16. Update-Path: Hello-Plugin v1.0.0 → upgrade zu v1.1.0 (mit zusätzlicher Migration) → Status bleibt enabled, Migration wird applied, alte Version in `superseded`.
17. Coolify-Health-Check für neue Routes verifizieren (`/app/$tenant/plugins` returnt 200).
18. Vor Production: temporäre `/api/internal/plugin-test/*` Endpoints entfernen (siehe `wasm-sandbox.md` Staging-Notes).
docker-compose-Snippet (Anhang `docs/operations/plugin-lifecycle.md`) bleibt identisch zum `wasm-sandbox.md`-Snippet plus zusätzlich:
```yaml
services:
worker-plugin:
environment:
PLUGIN_AUTO_DISABLE_THRESHOLD: 5
PLUGIN_AUTO_DISABLE_WINDOW_MS: 600000
PLUGIN_GLOBAL_AUTO_DISABLE_TENANTS: 3
PLUGIN_GLOBAL_AUTO_DISABLE_WINDOW_MS: 1800000
PLUGIN_HOOKS_ENABLED: "true"
```
## Deviations from Plan
- **Hook count scoped to 6 (not 18).** Foundation pass wires the highest-value hook points (`tenant.created`, `user.created`, `entity.created`, `entity.updated`, `entity.deleted`, `workflow.executed`). The remaining hook points stay in the plan but are deferred so the foundation can ship incrementally instead of the original 17-step roadmap.
- **No separate `packages/plugin-hooks` package.** The registry + emit helper live in `packages/plugin-runtime/src/hooks/` to avoid an extra workspace package; the boundary stays clean because the helper accepts an injected publisher function.
- **Producer emits one `plugin.hook.<name>` outbox event, not one per subscriber.** Subscriber fan-out now runs on the general worker via `outbox.plugin-hook` and enqueues idempotent `toolctrl.plugin-invoke` jobs on the isolated plugin queue. Keeps the producer cheap and avoids hitting the Plugin table inside hot request paths.
- **Capability consent shipped for the current admin surface.** Tenant settings
ships a list-level approval path for missing manifest-declared grants with
explicit review confirmation and stale-manifest digest validation. The fuller
guided marketplace install dialog remains deferred. The Sandbox already
enforces capabilities at runtime via #21's gateway.
- **`emitHookSync` / output-hooks deferred.** No `transform` / `cancel` capability shipped in this pass — only fire-and-forget hooks. Output hooks land alongside the chatbot capability work in Phase 07.
- **Per-user enablement model shipped, gating not wired.** `PluginUserConfig` table + migration exist; the producer side that filters hooks by user opt-in is a follow-up commit once user identity is plumbed through `RowsApi`.
- **Settings sandboxed iframe shipped for settings UI.** `PluginSettingsFrame`
provides the sandbox/CSP/postMessage boundary for tenant plugin settings.
Marketplace packaging and broader UI-slot coverage remain follow-ups.
- **Global cross-tenant crash counter deferred.** Crash tracker only enforces per-tenant auto-disable for the foundation. Operator-level alerting via PagerDuty lands with #23.
## Progress Log
- 2026-05-17 22:33 — `72ccf4d9` — `06/plugin-hooks: add hook registry with zod payload schemas`
- 2026-05-17 22:42 — `aacdc600` — `06/plugin-hooks: instrument tenant.created hook in tenant create flow`
- 2026-05-17 22:45 — `c1831cc6` — `06/plugin-hooks: instrument user.created hook in user register`
- 2026-05-17 22:48 — `2a47391f` — `06/plugin-hooks: instrument entity.created/updated/deleted hooks`
- 2026-05-17 22:51 — `d1d1a036` — `06/plugin-hooks: add plugin lifecycle service and tenant API routes`
- 2026-05-17 22:53 — `9b5f9eaf` — `06/plugin-hooks: add plugin db migration applier with prefix validation`
- 2026-05-17 22:55 — `3ce43c20` — `06/plugin-hooks: add tenant-admin plugins settings ui`
- 2026-05-17 22:58 — `ce9e12c0` — `06/plugin-hooks: add crash tracker with auto-disable`
- 2026-05-17 22:59 — `e0f60033` — `06/plugin-hooks: add PluginUserConfig prisma model and migration`
- 2026-05-17 23:02 — `9fc8fb57` — `06/plugin-hooks: add cli plugin lifecycle commands`
- 2026-05-17 23:03 — `8901218d` — `06/plugin-hooks: document hooks lifecycle architecture`
## Lessons Learned
(Optional, am Ende des Tasks — insbesondere: welche Hook-Points haben sich als hinderlich erwiesen, welche Capability-Granularität war zu fein/grob, welche Lifecycle-Edge-Cases wurden gefunden, Findings aus dem Sandboxed-iframe-Setup, Migrations-Performance-Beobachtungen.)