521 lines
49 KiB
Markdown
521 lines
49 KiB
Markdown
# 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.)
|