49 KiB
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 atapps/web/app/routes/api.tenants.$tenant.plugins.tsx, the tenant settings UI underapps/web/app/routes/app.$tenant/settings/plugins*, and worker fanout atapps/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 saysplugin.invokeoutbox/job events, the shipped queue path isoutbox.plugin-hookfanout intotoolctrl.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:
- 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.
- Plugin-Lifecycle-Management — kein
installPlugin(file), keinenablePlugin(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. - 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. - 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.
- Per-User-Enablement — manche Plugins (z.B. Slack-Notifier mit Per-User-Channel-Config) brauchen Per-User-Einstellungen, nicht nur Per-Tenant.
- 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-Pfadapps/web/app/utils/db/users.db.server.ts— User-Createapps/web/app/utils/services/entities/entitiesSingleService.ts— Entity-CRUDapps/web/app/modules/workflowEngine/services/WorkflowEngineService.ts— Workflow-Executeapps/web/app/modules/webhooks/services/WebhookService.ts— Outbound-Webhookapps/web/app/utils/services/emailService.ts— Email-Sendapps/web/app/modules/chatbot/services/*— Chatbot-Message (kommt aus Phase 07; Hook-Stub vorbereiten)apps/api/src/billing/handlers.ts— Billing-Pre/Postapps/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 aufinvokePlugin()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: derworker-general-Pool konsumiertoutbox.plugin-hook, fanoutet tenant-scoped Subscriber und enqueuedtoolctrl.plugin-invoke; derworker-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:
import { emitHook } from "@toolctrl/plugin-hooks";
// In Tenant-Signup-Handler:
await emitHook("onTenantSignup", { tenantId, tenantSlug, ownerEmail, plan });
emitHook macht intern:
- Validiert Payload gegen das Hook-Schema (Schutz vor falschen Payloads).
- Lädt aus DB / Cache welche installed/enabled Plugins diesen Hook abonniert haben (für diesen Tenant).
- Pro Plugin: erzeugt einen
plugin.invoke-Outbox-Event mit{ pluginId, version, hookName, tenantId, input }. - 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:
{
"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
-
Plugin-Hooks-Package scaffolden — Single Source of Truth für Hook-Namen + Schemas
- Files:
packages/plugin-hooks/package.json,tsconfig.json,src/index.tspackages/plugin-hooks/src/hooks.ts— Const-RecordHOOKSmit 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
- Files:
-
emitHook-Funktion — Producer-Side- Files:
packages/plugin-hooks/src/emit.ts—emitHook(name, payload, ctx)validiert Payload, sucht Subscribers per Cache + DB (cached viatenant-plugin-subscriptionsSharedStore-Key, TTL 60s), produziert pro Subscriber einenplugin.invokeOutbox-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
- Files:
-
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 (chainedtransformorder via Manifest-priorityFeld), 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
- Files:
-
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 inaudit-docs.mdplugin_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
- Files:
-
Plugin-Migration-Runner — Prefix-Validation + Apply + Down
- Files:
packages/plugin-runtime/src/migrations.ts—applyPluginMigrations(pluginId, manifestMigrations, prismaWrite): parsed SQL viapg-query-emscripten, validiert dass alleCREATE TABLE-Statements denplugin_<sanitized-id>_-Prefix haben + dass jede TabelletenantId-Spalte hat. Apply viaprismaWrite.$executeRawUnsafe, tracks viaplugin_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
- Files:
-
Plugin-Lifecycle-Service — Install/Enable/Disable/Update/Uninstall
- Files:
apps/web/app/modules/plugins/services/PluginLifecycleService.tsinstall(tenantId, zipBuffer, userId)→ parsed Manifest, Verify-Signature, inpendinginsert, returnt{ pluginId, capabilities }für Consent-Dialog.grantConsent(tenantId, pluginId, grants[], userId)→ schreibt Grants intenant_plugin_capability_grants, läuft Migrations, transitioninstalled (disabled).enable(tenantId, pluginId, userId),disable(tenantId, pluginId, userId)→ Status-Toggle + Cache-Invalidate (tenant-plugin-subscriptionsKey).update(tenantId, pluginId, newVersionZip, userId)→ installiert neue Version side-by-side, läuft up-Migrations vom alten Version-Tail bis neue Version, marked alte Versionsuperseded(bleibt für Rollback verfügbar).uninstall(tenantId, pluginId, userId)→ triggertonUninstall-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, setsstatus='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
- Files:
-
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 newapps/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.
- Current API:
- Historical permission sketch: mutating routes require
userHasPermission('app.plugins.manage'); current read-only list/audit access is split toapp.plugins.useas documented indocs/architecture/plugin-hooks.md. - Tests: pro Route ein Vitest mit gemocktem PluginLifecycleService.
- Commit: pro Route-Group ein Commit (4-5 Commits).
- Historical note: the shipped tenant plugin API is consolidated in
-
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.
- Current list UI:
- WICHTIG: Nach Hinzufügen sofort
pnpm run typecheckausführen damit./+types/<route>generiert werden. Imports strikt alsimport 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).
- Files:
-
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} /> pluginHtmlwird 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.
- Rendert
- 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
- Files:
-
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 übermail-transactional-Queue mit Templateplugin-auto-disabled.html. - Tests: gemocktes Notification-System, assert Calls.
- Commit:
06-plugin-system/hooks-lifecycle: add auto-disable notification service
- Files:
-
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. WenngetFailureCountInWindow > 5→ triggertPluginLifecycleService.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
- Files:
-
Worker-Plugin-Job-Handler —
plugin.invokeOutbox-Event-Konsument- Files:
apps/worker/src/plugin-handlers.ts— handlerplugin.invokeruftinvokePlugin()aus Task #21 mit Job-Payload auf. Schreibt am Endeplugin_invocations-Row mit Outcome. - Outbox-Producer-Hook in
emitHookaus 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
- Files:
-
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
- File:
- Wiederholen für die übrigen 17 Hooks. Total 18 Commits.
- Pro Hook ein separater Commit — atomic per Hook-Point. Beispiel
-
App-Sidebar-Integration — Plugin-Menüpunkt nur sichtbar wenn User Admin-Permission hat
- Files:
apps/web/app/application/sidebar/AppSidebar.tsx— add Plugin-Nav-Itemapps/web/app/components/layouts/sidebars/shadcn/app-sidebar.tsx— analogapps/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
- Files:
-
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.manageautomatisch, alle anderenapp.plugins.use. - Tests: Permission-Resolution-Test.
- Commit:
06-plugin-system/hooks-lifecycle: add plugin permissions to product access
- Files:
-
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
- Files:
-
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,auditCapabilities → ininstalled (disabled), Migration applied - Enable → Status
enabled, Cache invalidiert emitHook('onTenantSignup', ...)→ Outbox-Event produziert → Worker konsumiert → Plugin-Call ausgeführt →plugin_invocationsrow written- Plugin disablen → Cache invalidiert, neuer Hook → kein Plugin-Call mehr
- Plugin uninstallen → Down-Migration läuft, Tabellen weg, DB-Eintrag weg.
- Hello-Plugin uploaden → Manifest geparst, Signature verified, in
- Commit:
06-plugin-system/hooks-lifecycle: add end-to-end lifecycle integration test
- Files:
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 inPLUGIN_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 ohneapp.plugins.managesieht keinen Menüpunkt, kann nicht zu/app/$tenant/plugins/installnavigieren (403).
-
Coverage-Target: ≥ 85% für
packages/plugin-hooks/src/, ≥ 85% fürapps/web/app/modules/plugins/, ≥ 90% fürpackages/plugin-runtime/src/migrations.tsundcrash-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-DefinitionsemitHookproduziert Outbox-Events korrekt; Payload-Validation rejected invalide PayloadsemitHookSyncist 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_appliedapplied + Down-tested - Plugin-Migration-Runner enforced
plugin_<sanitized-id>_-Prefix +tenantId-Spalte; Negative-Tests passieren PluginLifecycleServicedeckt Install/GrantConsent/Enable/Disable/Update/Uninstall/AutoDisable ab- API-Routes mit Permission-Guard
app.plugins.managefü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, CSPdefault-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 konsumierttoolctrl.plugin-invokejobs korrekt; subscriber fanout happens first fromoutbox.plugin-hookon 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.useim productAccess.server.ts docs/features/plugins.md+docs/operations/plugin-lifecycle.mdvollstä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 testgrü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-Skriptscripts/cleanup-orphan-plugin-tables.tsschreiben das alle Tabellen mitplugin_-Prefix listet und auf Operator-Confirm DROPped. - Feature-Flag:
PLUGIN_HOOKS_ENABLED=false→emitHookno-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 intenant_plugin_capability_grants), aber selbst wenn doch — die Sandbox enforced unabhängig. -
iframe-Sandboxing der Plugin-Settings-Page:
<iframe sandbox="allow-scripts allow-forms">(keinallow-same-origin, keinallow-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, entfernton*-Handler die JS injecten könnten). postMessage-Listener auf Host-Seite validiertevent.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-zipoderyauzlmit Limits geparsed (max 50 MiB komprimiert, max 200 MiB unkomprimiert — verhindert ZIP-Bombs). Nurplugin.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 mindestensapp.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
$executeRawUnsafeohne 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 aufplugin_<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.mdals 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 separateWIRING-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).
- 1
- 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-reviewerinsbesondere für Step 9 (iframe-Sandboxing — Security-kritisch) und Step 5 (SQL-Migration-Runner — Injection-Vektor).
Staging-Deployment-Steps
- Branch
agent/06-hooks-lifecyclepushen. - 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). - Verifiziere dass
worker-plugin-Container dentoolctrl.plugin-invokeJob-Handler registriert hat:ssh staging-host "docker logs worker-plugin --tail 50 | grep -i 'plugin-invoke'" - Tenant-Admin-Login auf Staging via
https://staging.toolctrl.de/loginmit Test-Account. - Navigate zu
/app/test-tenant/plugins→ Liste leer (erwartet). - Klick „Install Plugin" → Upload
hello-plugin-signed.zip(auswasm-sandbox.md-Staging-Steps). - Capability-Consent-Dialog erscheint → alle Capabilities anhaken → „Approve".
- Plugin erscheint in Liste mit Status „Installed (Disabled)".
- Toggle „Enable" → Status wechselt zu „Enabled".
- Trigger Hook via Test-Endpoint (entweder real durch Tenant-Signup eines neuen Test-Tenants oder via temporärem
/api/internal/plugin-test/emit-hookEndpoint):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"}}' - Nach 2-3 Sek
/app/test-tenant/plugins/com.toolctrl.hello/auditöffnen → Invocation-Entry sichtbar mitoutcome=success,durationMs<500,capabilityUsed=log,audit. - Plugin in Settings → setze ein Test-Setting → Verify postMessage funktioniert (Browser-DevTools-Console).
- Per-User-Toggle: User A enable, User B disable → trigger Hook für User B's Action → kein Invocation in Audit.
- 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. - Uninstall „crash-plugin" → bestätigen → verschwindet aus Liste, DB-Verify dass
plugin_com_toolctrl_crash_*-Tabellen weg sind:ssh staging-host "psql -d toolctrl -c '\dt plugin_*'" - 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. - Coolify-Health-Check für neue Routes verifizieren (
/app/$tenant/pluginsreturnt 200). - Vor Production: temporäre
/api/internal/plugin-test/*Endpoints entfernen (siehewasm-sandbox.mdStaging-Notes).
docker-compose-Snippet (Anhang docs/operations/plugin-lifecycle.md) bleibt identisch zum wasm-sandbox.md-Snippet plus zusätzlich:
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-hookspackage. The registry + emit helper live inpackages/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 viaoutbox.plugin-hookand enqueues idempotenttoolctrl.plugin-invokejobs 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. Notransform/cancelcapability 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.
PluginUserConfigtable + migration exist; the producer side that filters hooks by user opt-in is a follow-up commit once user identity is plumbed throughRowsApi. - Settings sandboxed iframe shipped for settings UI.
PluginSettingsFrameprovides 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.)