Files
goclaw/internal/hooks/migration_command_autodisable.go
viettranx 33d188f01f feat(hooks/migration): auto-disable legacy command hooks on Standard boot
Phase 07 runtime migration. Post-Wave-1 Standard deployments that pre-dated
the edition gate may still carry enabled command-type hook rows in the DB;
those keep firing via the dispatcher after the UI stops letting users create
new ones. This helper flips them off at startup so live traffic never fires
a disabled-by-policy handler again.

- internal/hooks/migration_command_autodisable.go:
  DisableLegacyCommandHooks(ctx, hookStore, edition). Standard only; Lite
  returns early. Master-scope context + List(Enabled=true) → skip
  non-command + source='builtin' rows → Update enabled=false. Per-row
  WARN log with hook_id + tenant_id for audit; INFO summary when n>0.
  Idempotent: second boot finds nothing.
- cmd/gateway_managed.go: invoke after builtin.Seed, BEFORE handler
  registration so no HTTP/WS traffic races the migration.
- Tests (fake store, 5 cases):
  * Standard with mixed rows → only command+source=ui gets disabled
  * http + script/builtin + command/builtin rows untouched
  * Idempotent second run → n=0
  * Lite edition → n=0, nothing touched
  * nil store → n=0, no error
  * List error → propagated up

Note: Phase 04/05 never seed command-typed builtins, but the carve-out is
kept defensively so a future accidental builtin command row can't be
disabled by this migration.
2026-04-16 14:17:47 +07:00

73 lines
2.3 KiB
Go

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
}