- Build-time discovery (D-028, the Caddy pattern): a module self-registers from init(); internal/modules/imports_gen.go (go:generate) blank-imports every module directory, so THE DIRECTORY IS THE SOURCE OF TRUTH - The registry: deterministic registration order, config-backed enable/disable (D-038), required modules, name-prefixed migrations, Mount() into the door - The name-collision guard now also fails a module named for a reserved level-1 workflow (SPEC-009 §13 Q-1) — access · static · api · media · ws · dev — derived from routing.ReservedWorkflows, `home` excepted (D-2) - home: the reference dashboard module, and the core Modules page
411 lines
13 KiB
Go
411 lines
13 KiB
Go
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
|
|
//
|
|
// 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"}
|
|
|
|
// 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 "<nil>"
|
|
}
|
|
return m.Name()
|
|
}
|