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.
This commit is contained in:
viettranx committed 2026-04-16 14:17:47 +07:00
1 parent a6592fd65f
commit 33d188f01f
3 files changed
+265

No files matched your search

+11
View File
@@ -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,
@@ -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
}
@@ -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")
}
}