package module import ( "context" "errors" "fmt" "log/slog" "strings" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" ) // Sentinel errors. var ( // ErrUnknownModule is returned for a name that names no discovered module. ErrUnknownModule = errors.New("module: unknown module") // ErrRequiredModule is returned when a required module would be disabled // (D-038, AC-4). ErrRequiredModule = errors.New("module: required modules cannot be disabled") // ErrDuplicateModule is returned when two directories claim the same name. ErrDuplicateModule = errors.New("module: duplicate module name") // ErrCoreDispatchName is returned when a discovered module claims a name the // CORE dispatches for itself — either a core dispatch module (auth · setup · // recovery · core, SPEC-008 §9 D-9) or a reserved level-1 workflow (access · // setup · api · static · media · ws · dev, SPEC-009 §13 Q-1). The name would // silently lose precedence — or silently shadow the core — so the module // FAILS TO LOAD. `home` is the sole exception (SPEC-009 §13 D-2). ErrCoreDispatchName = errors.New("module: name is reserved by the core") ) // CoreDispatchNames are the module (PARAM2 / `do`) names the CORE registers into // the door's dispatch registry (SPEC-008 §9 D-9, SPEC-009 §8): // // auth — the SPEC-006 enroll/login flow + the SPEC-007 `recover` action // setup — the SPEC-005 wizard's steps // recovery — the SPEC-007 approve/deny actions // core — the SPEC-008 Modules page // account — the SPEC-010 account page (factors · add a passkey · opt out) // users — the SPEC-010 admin Users page (list · create + invite) // // A discovered module may not claim one of them: the door would either drop the // discovered module (RegisterModule overwrites by name) or the discovered module // would shadow the core. Either way it would be a SILENT loss of precedence, so // the load fails closed instead. var CoreDispatchNames = []string{"auth", "setup", "recovery", "core", "account", "users"} // ReservedWorkflowNames are the reserved level-1 workflows (SPEC-002 §4) that a // discovered module may NOT claim (SPEC-009 §13 Q-1). It is DERIVED from // routing.ReservedWorkflows — the ONE source for the reserved names (D-004) — // with `home` removed (SPEC-009 §13 D-2). // // `home` is deliberately EXCLUDED. `home` is both a reserved level-1 workflow // AND the PARAM2 dashboard module, and the dashboard *is* a discovered module // (internal/modules/home — the reference module, SPEC-008 AC-1): the two are // separate switches, not a collision (SPEC-002 §4.4). The core registers no // `home` *module*, so nothing can be shadowed. The derived set is therefore // access · setup · api · static · media · ws · dev. var ReservedWorkflowNames = reservedWorkflowNames() // ReservedModuleNames is the full guarded set — the core dispatch modules plus // the reserved level-1 workflows (minus `home`). A discovered module claiming // any of these names FAILS TO LOAD (SPEC-008 §9 D-9, SPEC-009 §13 Q-1). var ReservedModuleNames = unionNames(CoreDispatchNames, ReservedWorkflowNames) // reservedWorkflowNames derives the guarded reserved-workflow set from the one // source (routing.ReservedWorkflows), excluding the `home` exception. func reservedWorkflowNames() []string { out := make([]string, 0, len(routing.ReservedWorkflows)) for _, n := range routing.ReservedWorkflows { if n == "home" { continue // the exception — the reference dashboard module IS `home` } out = append(out, n) } return out } // unionNames concatenates name lists, dropping duplicates, preserving order. func unionNames(lists ...[]string) []string { var out []string seen := make(map[string]bool) for _, l := range lists { for _, n := range l { if !seen[n] { seen[n] = true out = append(out, n) } } } return out } // IsCoreDispatchName reports whether name is a core dispatch module name — the // core dispatch set above (SPEC-008 §9 D-9). func IsCoreDispatchName(name string) bool { name = strings.TrimSpace(name) for _, n := range CoreDispatchNames { if n == name { return true } } return false } // IsReservedWorkflowName reports whether name is a reserved level-1 workflow a // discovered module may not claim — home excluded (SPEC-009 §13 Q-1). func IsReservedWorkflowName(name string) bool { name = strings.TrimSpace(name) for _, n := range ReservedWorkflowNames { if n == name { return true } } return false } // IsReservedModuleName reports whether name is reserved by the core — the guard // consulted by Add. It is true for a core dispatch module OR a reserved level-1 // workflow (`home` excepted) — SPEC-008 §9 D-9, SPEC-009 §13 Q-1. func IsReservedModuleName(name string) bool { name = strings.TrimSpace(name) for _, n := range ReservedModuleNames { if n == name { return true } } return false } // Registry is the runtime module registry: the discovered modules, in // registration order, together with their enabled state (D-038). // // A disabled module is NOT in the registry (SPEC-008 §6): it is not mounted, so // its routes 404 and its nav entry is hidden. Disabling never deletes data. type Registry struct { order []Module byName map[string]Module enabled map[string]bool } // NewRegistry returns an empty registry. func NewRegistry() *Registry { return &Registry{ byName: make(map[string]Module), enabled: make(map[string]bool), } } // Load discovers the modules self-registered in this binary and builds the // runtime registry, applying the `modules:` map from config (D-038). A module // absent from cfg keeps its default: required modules are always on, every // other module defaults to on. // // A discovered module with a name reserved by the core FAILS THE LOAD // (SPEC-008 §9 D-9, SPEC-009 §13 Q-1): the error is returned AND logged, so a // silent loss of precedence is impossible. func Load(cfg map[string]bool) (*Registry, error) { r, err := LoadModules(Discovered(), cfg) if err != nil { slog.Default().Error("module: refusing to load the discovered module set", "error", err) return nil, err } return r, nil } // LoadModules builds a registry from an explicit module set. Load uses it with // Discovered(); tests use it with fakes. func LoadModules(mods []Module, cfg map[string]bool) (*Registry, error) { r := NewRegistry() for _, m := range mods { if err := m.Register(r); err != nil { return nil, fmt.Errorf("module: register %s: %w", moduleName(m), err) } } for _, m := range r.order { if m.Required() { r.enabled[m.Name()] = true continue } on := true // default on; an explicit config value wins if v, ok := cfg[m.Name()]; ok { on = v } r.enabled[m.Name()] = on } return r, nil } // Add records a module. It is called by a module's Register method. It fails // closed on a nil module, an empty name, a name reserved by the core (a core // dispatch module or a reserved level-1 workflow — SPEC-008 §9 D-9, SPEC-009 // §13 Q-1), a duplicate name, or a malformed route/action set. func (r *Registry) Add(m Module) error { if m == nil { return errors.New("module: nil module") } name := strings.TrimSpace(m.Name()) if name == "" { return errors.New("module: empty module name") } // The guard runs BEFORE the registry is touched: a discovered module with a // reserved name must fail to load, never silently lose (or steal) precedence. if IsReservedModuleName(name) { return fmt.Errorf("%w: %q", ErrCoreDispatchName, name) } if _, dup := r.byName[name]; dup { return fmt.Errorf("%w: %q", ErrDuplicateModule, name) } if err := validate(name, m.Routes(), m.Actions()); err != nil { return err } r.order = append(r.order, m) r.byName[name] = m r.enabled[name] = true // default on; Load applies config, SetEnabled overrides return nil } // Modules returns every registered module in registration order (deterministic // — never map iteration, AC-2). func (r *Registry) Modules() []Module { out := make([]Module, len(r.order)) copy(out, r.order) return out } // Get returns the registered module by name. func (r *Registry) Get(name string) (Module, bool) { m, ok := r.byName[strings.TrimSpace(name)] return m, ok } // Enabled reports whether a module is mounted. An unknown module is not // enabled. func (r *Registry) Enabled(name string) bool { return r.enabled[strings.TrimSpace(name)] } // Required reports whether the named module is required. func (r *Registry) Required(name string) bool { m, ok := r.Get(name) return ok && m.Required() } // SetEnabled turns a module on or off. It refuses an unknown module and a // required module being turned off (D-038, AC-4). It changes the RUNTIME state // only; the config file is written by the Modules page (applies at next start). func (r *Registry) SetEnabled(name string, on bool) error { name = strings.TrimSpace(name) m, ok := r.byName[name] if !ok { return fmt.Errorf("%w: %q", ErrUnknownModule, name) } if !on && m.Required() { return fmt.Errorf("%w: %q", ErrRequiredModule, name) } r.enabled[name] = on return nil } // Nav returns the names of the enabled modules, in registration order — the // navigation the shell renders. A disabled module is absent (AC-3: its nav // hides). Rendering is SPEC-009 (web layer); this is the seam the shell reads. func (r *Registry) Nav() []string { out := make([]string, 0, len(r.order)) for _, m := range r.order { if r.enabled[m.Name()] { out = append(out, m.Name()) } } return out } // Migrations returns the migrations of every ENABLED module, in registration // order, with each version prefixed by the module name. The core owns the // runner (AC-5); the prefix keeps module schemas from colliding with the core // schema or with each other. // // A disabled module's migrations are not applied — it is not mounted — but // disabling never drops the tables an earlier run created (AC-3). func (r *Registry) Migrations() []store.Migration { var out []store.Migration for _, m := range r.order { if !r.enabled[m.Name()] { continue } for _, mig := range m.Migrations() { out = append(out, store.Migration{ Version: m.Name() + "/" + mig.Version, SQL: mig.SQL, }) } } return out } // Mount registers every ENABLED module's routes and actions into the door's // dispatch registry. A disabled module is skipped, so the pure model reports it // as not found and its routes 404 (SPEC-008 §6, AC-3). func (r *Registry) Mount(dst *server.MapRegistry) { if dst == nil { return } for _, m := range r.order { if !r.enabled[m.Name()] { continue } mm := server.NewModule(m.Name()) for _, rt := range m.Routes() { if rt.Default { mm.SetDefault(rt.Function, rt.Handler) continue } mm.AddRoute(rt.Name, rt.Function, rt.Handler) } for _, a := range m.Actions() { mm.AddAction(a.Name, a.Function, a.Handler) } dst.RegisterModule(mm) } } // Start runs every enabled module's optional background work, in registration // order. The first failure stops the sequence. func (r *Registry) Start(ctx context.Context) error { for _, m := range r.order { if !r.enabled[m.Name()] { continue } if err := m.Start(ctx); err != nil { return fmt.Errorf("module: start %s: %w", m.Name(), err) } } return nil } // LogLoaded writes the loaded module set at startup (D-028 guardrail: "log the // loaded set — a visible registry beats hidden magic", AC-2). func (r *Registry) LogLoaded(l *slog.Logger) { if l == nil { l = slog.Default() } all := make([]string, 0, len(r.order)) on := make([]string, 0, len(r.order)) off := make([]string, 0, len(r.order)) for _, m := range r.order { all = append(all, m.Name()) if r.enabled[m.Name()] { on = append(on, m.Name()) } else { off = append(off, m.Name()) } } l.Info("myday: modules loaded", "count", len(all), "loaded", all, "enabled", on, "disabled", off) } // validate rejects a malformed route/action set so a broken module fails at // startup, not at request time. func validate(name string, routes []Route, actions []Action) error { seenRoutes := make(map[string]bool, len(routes)) defaults := 0 for _, rt := range routes { if rt.Default { defaults++ if defaults > 1 { return fmt.Errorf("module: %s declares more than one default route", name) } } else { key := strings.TrimSpace(rt.Name) if key == "" { return fmt.Errorf("module: %s has a route with an empty name", name) } if seenRoutes[key] { return fmt.Errorf("module: %s declares route %q twice", name, key) } seenRoutes[key] = true } if strings.TrimSpace(rt.Function) == "" { return fmt.Errorf("module: %s has a route with an empty function", name) } if rt.Handler == nil { return fmt.Errorf("module: %s route %q has no handler", name, rt.Name) } } seenActions := make(map[string]bool, len(actions)) for _, a := range actions { key := strings.TrimSpace(a.Name) if key == "" { return fmt.Errorf("module: %s has an action with an empty name", name) } if seenActions[key] { return fmt.Errorf("module: %s declares action %q twice", name, key) } seenActions[key] = true if strings.TrimSpace(a.Function) == "" { return fmt.Errorf("module: %s action %q has an empty function", name, key) } if a.Handler == nil { return fmt.Errorf("module: %s action %q has no handler", name, key) } } return nil } // moduleName is a defensive Name() for error messages (a nil module never gets // here). func moduleName(m Module) string { if m == nil { return "" } return m.Name() }