diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index b04f5eb7..3d1e3edf 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -164,6 +164,17 @@ func wireExtras( } } + // Phase 07: runtime migration — auto-disable legacy command-type hooks + // on Standard edition. No-op on Lite. Idempotent. Runs synchronously + // before listeners so traffic never sees a command hook fire on a + // post-Wave-1 Standard instance. + if n, err := hooks.DisableLegacyCommandHooks(context.Background(), hs, edition.Current()); err != nil { + slog.Warn("hooks.command_migration_failed", "err", err) + } else if n > 0 { + slog.Info("hooks.command_migration_ran", + "disabled_count", n, "edition", edition.Current().Name) + } + handlers := buildHookHandlers(stores, providerReg, appCfg.Hooks) stdOpts := hooks.StdDispatcherOpts{ Store: hs, diff --git a/internal/hooks/migration_command_autodisable.go b/internal/hooks/migration_command_autodisable.go new file mode 100644 index 00000000..9638c614 --- /dev/null +++ b/internal/hooks/migration_command_autodisable.go @@ -0,0 +1,72 @@ +package hooks + +import ( + "context" + "log/slog" + + "github.com/nextlevelbuilder/goclaw/internal/edition" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// DisableLegacyCommandHooks turns off any enabled command-typed hook rows +// when running on Standard edition. No-op on Lite. Idempotent: second call +// finds nothing to disable. +// +// Context: Phase 01 removed the UI affordance for creating `command` hooks on +// Standard edition; Phase 03's edition gate rejects new writes at the WS RPC +// layer. But Standard deployments that existed BEFORE Wave 1 already have +// command-hook rows in the DB, and those rows keep firing via the dispatcher. +// This helper is the one-time-per-boot runtime migration that flips them off. +// +// Why not a SQL migration? +// - Lite runs on SQLite and must not be affected even when sharing code +// - Edition is a runtime flag, not a schema property +// - Fresh Standard DBs that restore a Lite backup may reintroduce command +// rows; re-running on each boot handles that safely +// +// Called once at startup from cmd/gateway_managed.go after migrations and the +// builtin seed, BEFORE HTTP/WS listeners accept traffic. +func DisableLegacyCommandHooks(ctx context.Context, hs HookStore, ed edition.Edition) (int, error) { + if ed.Name != edition.Standard.Name { + return 0, nil + } + if hs == nil { + return 0, nil + } + + // Reach all tenants' rows. store.WithOwnerRole does NOT exist — see + // phase-04 red-team notes. Use WithRole explicitly. + ctx = store.WithRole(ctx, store.RoleOwner) + + enabled := true + rows, err := hs.List(ctx, ListFilter{Enabled: &enabled}) + if err != nil { + return 0, err + } + + n := 0 + for _, r := range rows { + if r.HandlerType != HandlerCommand { + continue + } + if r.Source == SourceBuiltin { + // Defensive: Phase 04/05 only seed HandlerScript, but guard anyway + // so a future accidental builtin command row wouldn't get disabled. + continue + } + if err := hs.Update(ctx, r.ID, map[string]any{"enabled": false}); err != nil { + slog.Warn("hooks.command_hook_auto_disable_failed", + "hook_id", r.ID, "tenant_id", r.TenantID, "err", err) + continue + } + slog.Warn("hooks.command_hook_auto_disabled", + "hook_id", r.ID, + "tenant_id", r.TenantID, + "reason", "command_handler_disabled_on_standard_edition") + n++ + } + if n > 0 { + slog.Info("hooks.command_migration_complete", "disabled_count", n) + } + return n, nil +} diff --git a/internal/hooks/migration_command_autodisable_test.go b/internal/hooks/migration_command_autodisable_test.go new file mode 100644 index 00000000..a62dd704 --- /dev/null +++ b/internal/hooks/migration_command_autodisable_test.go @@ -0,0 +1,182 @@ +package hooks_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/edition" + "github.com/nextlevelbuilder/goclaw/internal/hooks" +) + +// migStore is a minimal HookStore backing the command-autodisable unit tests. +// Only List + Update are exercised by the migration; Create/Delete/etc. are +// stubbed to zero values. +type migStore struct { + rows []hooks.HookConfig + listErr error +} + +func (m *migStore) Create(context.Context, hooks.HookConfig) (uuid.UUID, error) { + return uuid.Nil, nil +} +func (m *migStore) GetByID(_ context.Context, id uuid.UUID) (*hooks.HookConfig, error) { + for i := range m.rows { + if m.rows[i].ID == id { + c := m.rows[i] + return &c, nil + } + } + return nil, nil +} +func (m *migStore) List(_ context.Context, f hooks.ListFilter) ([]hooks.HookConfig, error) { + if m.listErr != nil { + return nil, m.listErr + } + out := []hooks.HookConfig{} + for _, r := range m.rows { + if f.Enabled != nil && r.Enabled != *f.Enabled { + continue + } + out = append(out, r) + } + return out, nil +} +func (m *migStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error { + for i := range m.rows { + if m.rows[i].ID != id { + continue + } + if v, ok := updates["enabled"].(bool); ok { + m.rows[i].Enabled = v + } + return nil + } + return errors.New("row not found") +} +func (m *migStore) Delete(context.Context, uuid.UUID) error { return nil } +func (m *migStore) ResolveForEvent(context.Context, hooks.Event) ([]hooks.HookConfig, error) { + return nil, nil +} +func (m *migStore) WriteExecution(context.Context, hooks.HookExecution) error { return nil } + +// fixtureRows covers every branch of the migration: +// +// A: enabled command + ui → should be disabled +// B: already-disabled command + ui → skip (not in filter result) +// C: enabled http + ui → skip (not command) +// D: enabled script + builtin → skip (builtin carve-out) +// E: enabled command + builtin → skip (defensive carve-out even though +// Phase 04/05 never seed command builtins) +func fixtureRows() []hooks.HookConfig { + mk := func(ht hooks.HandlerType, src string, enabled bool) hooks.HookConfig { + return hooks.HookConfig{ + ID: uuid.New(), + TenantID: uuid.New(), + Event: hooks.EventPreToolUse, + HandlerType: ht, + Scope: hooks.ScopeTenant, + Source: src, + Enabled: enabled, + Version: 1, + } + } + return []hooks.HookConfig{ + mk(hooks.HandlerCommand, "ui", true), + mk(hooks.HandlerCommand, "ui", false), + mk(hooks.HandlerHTTP, "ui", true), + mk(hooks.HandlerScript, hooks.SourceBuiltin, true), + mk(hooks.HandlerCommand, hooks.SourceBuiltin, true), + } +} + +func TestDisableLegacyCommandHooks_StandardDisablesOnlyCommandUI(t *testing.T) { + s := &migStore{rows: fixtureRows()} + n, err := hooks.DisableLegacyCommandHooks(context.Background(), s, edition.Standard) + if err != nil { + t.Fatalf("err: %v", err) + } + if n != 1 { + t.Fatalf("disabled=%d, want 1 (only the enabled+command+ui row)", n) + } + + var cntCommandEnabled, cntCommandBuiltin, cntHTTP, cntScriptBuiltin int + for _, r := range s.rows { + switch { + case r.HandlerType == hooks.HandlerCommand && r.Source == "ui" && r.Enabled: + cntCommandEnabled++ + case r.HandlerType == hooks.HandlerCommand && r.Source == hooks.SourceBuiltin: + if r.Enabled { + cntCommandBuiltin++ + } + case r.HandlerType == hooks.HandlerHTTP && r.Enabled: + cntHTTP++ + case r.HandlerType == hooks.HandlerScript && r.Source == hooks.SourceBuiltin && r.Enabled: + cntScriptBuiltin++ + } + } + if cntCommandEnabled != 0 { + t.Errorf("command/ui rows still enabled: %d", cntCommandEnabled) + } + if cntCommandBuiltin != 1 { + t.Errorf("command/builtin row should stay enabled: got %d", cntCommandBuiltin) + } + if cntHTTP != 1 { + t.Errorf("http row touched: got %d enabled", cntHTTP) + } + if cntScriptBuiltin != 1 { + t.Errorf("script/builtin row touched: got %d enabled", cntScriptBuiltin) + } +} + +func TestDisableLegacyCommandHooks_Idempotent(t *testing.T) { + s := &migStore{rows: fixtureRows()} + if _, err := hooks.DisableLegacyCommandHooks(context.Background(), s, edition.Standard); err != nil { + t.Fatalf("first run: %v", err) + } + n, err := hooks.DisableLegacyCommandHooks(context.Background(), s, edition.Standard) + if err != nil { + t.Fatalf("second run: %v", err) + } + if n != 0 { + t.Fatalf("second run disabled=%d, want 0 (idempotent)", n) + } +} + +func TestDisableLegacyCommandHooks_LiteNoOp(t *testing.T) { + s := &migStore{rows: fixtureRows()} + n, err := hooks.DisableLegacyCommandHooks(context.Background(), s, edition.Lite) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("Lite returned disabled=%d, want 0", n) + } + // Nothing touched. + for _, r := range s.rows { + if r.HandlerType == hooks.HandlerCommand && r.Source == "ui" && !r.Enabled { + // This row started disabled; fine. + continue + } + if r.HandlerType == hooks.HandlerCommand && r.Source == "ui" && !r.Enabled { + t.Errorf("Lite disabled a command row: %+v", r) + } + } +} + +func TestDisableLegacyCommandHooks_NilStore(t *testing.T) { + n, err := hooks.DisableLegacyCommandHooks(context.Background(), nil, edition.Standard) + if err != nil || n != 0 { + t.Fatalf("nil store: n=%d err=%v", n, err) + } +} + +func TestDisableLegacyCommandHooks_ListErrorPropagates(t *testing.T) { + s := &migStore{listErr: errors.New("db down")} + _, err := hooks.DisableLegacyCommandHooks(context.Background(), s, edition.Standard) + if err == nil { + t.Fatal("want error from List propagation") + } +}