From d7d2c7d2b65ebc24957d8fef4ef655431be11011 Mon Sep 17 00:00:00 2001 From: Claus Lohmar Date: Wed, 14 Oct 2026 10:53:57 +0000 Subject: [PATCH] feat(module): add the module registry, discovery and the home module (SPEC-008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/core/module/module.go | 150 +++++++++ internal/core/module/page.go | 326 ++++++++++++++++++ internal/core/module/page_test.go | 201 +++++++++++ internal/core/module/registry.go | 411 +++++++++++++++++++++++ internal/core/module/registry_test.go | 465 ++++++++++++++++++++++++++ internal/modules/discovery_test.go | 62 ++++ internal/modules/gen/generate.go | 97 ++++++ internal/modules/gen/generate_test.go | 135 ++++++++ internal/modules/gen/main.go | 83 +++++ internal/modules/generate.go | 11 + internal/modules/home/app.go | 43 +++ internal/modules/home/handlers.go | 27 ++ internal/modules/home/routing.go | 16 + internal/modules/imports_gen.go | 11 + 14 files changed, 2038 insertions(+) create mode 100644 internal/core/module/module.go create mode 100644 internal/core/module/page.go create mode 100644 internal/core/module/page_test.go create mode 100644 internal/core/module/registry.go create mode 100644 internal/core/module/registry_test.go create mode 100644 internal/modules/discovery_test.go create mode 100644 internal/modules/gen/generate.go create mode 100644 internal/modules/gen/generate_test.go create mode 100644 internal/modules/gen/main.go create mode 100644 internal/modules/generate.go create mode 100644 internal/modules/home/app.go create mode 100644 internal/modules/home/handlers.go create mode 100644 internal/modules/home/routing.go create mode 100644 internal/modules/imports_gen.go diff --git a/internal/core/module/module.go b/internal/core/module/module.go new file mode 100644 index 0000000..6b47af3 --- /dev/null +++ b/internal/core/module/module.go @@ -0,0 +1,150 @@ +// Package module is MyDay's module registry (SPEC-008): the module CONTRACT, +// the runtime registry, the build-time DISCOVERY seam, and the core Modules +// page. +// +// Discovery is at BUILD time, not runtime (D-028, the Caddy pattern): a module +// is a package under internal/modules// that self-registers from its +// init() by calling Add; a generated aggregator (internal/modules/imports_gen.go, +// produced by `go generate`) blank-imports every module directory, so THE +// DIRECTORY IS THE SOURCE OF TRUTH. Adding a module is: add the directory → +// `go generate` → rebuild. No core edits. +// +// The registry makes the loaded set explicit and deterministic: modules keep +// their registration order (never map iteration), the loaded set is logged at +// startup, and enable/disable is config-backed (D-038) — a disabled module is +// not mounted, so its routes 404; its data is never deleted. +// +// Spec: docs/specs/spec-008-module-registry.md. +package module + +import ( + "context" + "strings" + "sync" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" +) + +// Migration is one ordered, named schema migration owned by a module. It reuses +// the core runner's type so the CORE owns the runner (SPEC-008 §4/AC-5): the +// registry prefixes each version with the module name, avoiding collisions with +// the core schema and between modules. +type Migration = store.Migration + +// Handler is a dispatched module function. It is the door's handler signature: +// the module receives the resolved RequestContext — identification and routing, +// never a raw path (the rule of the door, AGENT.md §3.2). +type Handler = server.Handler + +// Route is one path-mode function a module declares (SPEC-008 §4, §4.2): +// `/{GUID}/{module}/{name}/…` resolves to Function. +type Route struct { + // Name is the PARAM3 value the route answers (e.g. "pto"). Ignored when + // Default is set. + Name string + // Function is the resolved function name the door dispatches on. + Function string + // Handler runs the function. + Handler Handler + // Default marks the route served when PARAM3 is absent or trailing + // (SPEC-002 §3.4): a module declares at most one. + Default bool +} + +// Action is one action-mode function a module declares (SPEC-008 §4, §4.3): +// `POST /{GUID}?do={module}&action={name}` resolves to Function. +type Action struct { + // Name is the `action` value the module answers. + Name string + // Function is the resolved function name the door dispatches on. + Function string + // Handler runs the function. + Handler Handler +} + +// Module is the module contract every module implements (SPEC-008 §4). The +// default skeleton lives in internal/modules// as app.go (this manifest) · +// routing.go (Routes/Actions) · handlers.go (the handlers), per D-037. +type Module interface { + // Name is the PARAM2 segment — the registry key ("home", "time", …). + Name() string + + // Required reports whether the module is always on. A required module + // cannot be disabled (D-038, AC-4). + Required() bool + + // Migrations is the module's own schema, versioned and applied in + // registration order by the core runner (AC-5). + Migrations() []Migration + + // Routes are the module's path-mode functions (§4.2). + Routes() []Route + + // Actions are the module's action-mode functions (§4.3). + Actions() []Action + + // Register adds the module to the runtime registry. The loader calls it + // once per discovered module; the usual implementation is `r.Add(m)`. + Register(r *Registry) error + + // Start is OPTIONAL background work (integrations, sync). A module without + // background work returns nil. + Start(ctx context.Context) error +} + +// Kind is an OPTIONAL capability a module may implement to declare its +// catalogue kind (SPEC-008 §4.8: records · domain · workflow · analytical · +// integration). The Modules page lists it (§7); absent, KindOf reports +// "module". +type Kind interface { + Kind() string +} + +// KindOf returns a module's kind for the Modules page, defaulting to "module" +// when the module does not implement Kind. +func KindOf(m Module) string { + if k, ok := m.(Kind); ok { + if s := strings.TrimSpace(k.Kind()); s != "" { + return s + } + } + return "module" +} + +// ── build-time discovery (D-028) ───────────────────────────────────────────── + +// discovered is the process-wide self-registration list. Modules append to it +// from init(); because the generated aggregator blank-imports every module +// directory IN SORTED ORDER, Go runs those init() functions in that order and +// the list is deterministic (D-028 guardrail: never map iteration). +var ( + discoveryMu sync.Mutex + discovered []Module +) + +// Add self-registers a module from its package init() (D-028). It is called by +// each module directory; the generated aggregator guarantees the package is +// imported, so the directory is the source of truth. +// +// A duplicate name is not detected here (init cannot fail usefully): the +// runtime Add catches it when Load builds the registry, which is loud and +// testable. +func Add(m Module) { + if m == nil { + return + } + discoveryMu.Lock() + discovered = append(discovered, m) + discoveryMu.Unlock() +} + +// Discovered returns the process-wide, self-registered modules in registration +// order. It returns a copy: the caller may not mutate the registry through it. +func Discovered() []Module { + discoveryMu.Lock() + defer discoveryMu.Unlock() + out := make([]Module, len(discovered)) + copy(out, discovered) + return out +} diff --git a/internal/core/module/page.go b/internal/core/module/page.go new file mode 100644 index 0000000..0e17be9 --- /dev/null +++ b/internal/core/module/page.go @@ -0,0 +1,326 @@ +package module + +import ( + "bytes" + "context" + "errors" + "html/template" + "log/slog" + "net/http" + "os" + "strings" + "sync" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" +) + +// The Modules page is a CORE page (D-038), not a module: it lists the +// discovered modules and enables/disables them. It rides the door under a +// dedicated core dispatch module so the door still gates it (session + role + +// CSRF) exactly like any other function. +const ( + // ConsoleModule is the `do`/PARAM2 module carrying the core Modules page. + ConsoleModule = "core" + // FunctionModules is the path function (GET /{GUID}/core/modules). + FunctionModules = "modules" + // FunctionToggle is the action function + // (POST /{GUID}?do=core&action=modules.toggle). + FunctionToggle = "modules.toggle" +) + +// Outcome notices the toggle reports. +const ( + noticeSaved = "saved_restart_required" + noticeEditAndRestart = "edit_and_restart" +) + +// PageDeps assembles the Modules page. +type PageDeps struct { + // Registry is the runtime module registry (list + required flags). + Registry *Registry + // Config is the loaded configuration; its `modules:` map is the desired + // state the page edits (D-035/D-038). + Config *config.Config + // ConfigPath is where the toggles are persisted (usually config.yaml, + // never the committed example). + ConfigPath string + // WriteFile writes the config (tests inject a failing writer to exercise + // the "edit and restart" path). Defaults to os.WriteFile 0600. + WriteFile func(path string, data []byte) error + // Audit receives the enable/disable events (A11, AC-6). + Audit audit.Audit + // Logger receives structured logs. Defaults to slog.Default(). + Logger *slog.Logger +} + +// Page renders and toggles the core Modules page. +type Page struct { + d PageDeps + mu sync.Mutex // guards the config map read/modify/write +} + +// NewPage validates and builds the Modules page. +func NewPage(d PageDeps) (*Page, error) { + if d.Registry == nil { + return nil, errors.New("module: Modules page requires a Registry") + } + if d.Config == nil { + return nil, errors.New("module: Modules page requires a Config") + } + if strings.TrimSpace(d.ConfigPath) == "" { + return nil, errors.New("module: Modules page requires a ConfigPath") + } + if d.WriteFile == nil { + d.WriteFile = func(path string, data []byte) error { return os.WriteFile(path, data, 0o600) } + } + if d.Logger == nil { + d.Logger = slog.Default() + } + return &Page{d: d}, nil +} + +// Register mounts the page into the door registry: the list is a PATH route +// (GET /{GUID}/core/modules) and the toggle is an ACTION +// (POST /{GUID}?do=core&action=modules.toggle), which the door CSRF-protects. +// +// The caller must require the admin role for both (server.Tiers); the page does +// not decide its own access control. +func (p *Page) Register(reg *server.MapRegistry) { + if reg == nil { + return + } + reg.RegisterModule(server.NewModule(ConsoleModule). + AddRoute(FunctionModules, FunctionModules, p.List). + AddAction(FunctionToggle, FunctionToggle, p.Toggle)) +} + +// List renders the Modules page (GET /{GUID}/core/modules). +func (p *Page) List(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error { + return p.render(w, r, rc, "", "") +} + +// Toggle saves a module's enabled state to the config +// (POST /{GUID}?do=core&action=modules.toggle). A required module is refused +// (AC-4); an unwritable config reports "edit and restart" (D-035). The change +// is applied at the NEXT start (D-038: no live remount). +func (p *Page) Toggle(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error { + ctx := r.Context() + name := strings.TrimSpace(r.FormValue("module")) + if name == "" { + p.record(ctx, "module.toggle", "", "bad_request") + writeError(w, http.StatusBadRequest, "bad request") + return nil + } + + m, ok := p.d.Registry.Get(name) + if !ok { + p.record(ctx, "module.toggle", name, "unknown") + writeError(w, http.StatusNotFound, "not found") + return nil + } + on := parseEnabled(r.FormValue("enabled")) + + if !on && m.Required() { + p.record(ctx, "module.toggle", name, "refused_required") + writeError(w, http.StatusForbidden, "required modules cannot be disabled") + return nil + } + + notice, err := p.save(name, on) + if err != nil { + return err + } + p.record(ctx, auditAction(on), name, notice) + return p.render(w, r, rc, notice, name) +} + +// save writes the desired state to the config file. It returns a human notice +// describing the outcome. A marshal failure is returned (500); a write failure +// is reported as "edit and restart" and is NOT fatal (D-035). +func (p *Page) save(name string, on bool) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + + c := *p.d.Config + if c.Modules == nil { + c.Modules = make(map[string]bool) + } + // c.Modules aliases the live config's map, so consecutive toggles compose + // instead of clobbering one another. The mounted (running) registry is + // unaffected: it was latched at startup (D-038). + c.Modules[name] = on + + data, err := config.Marshal(&c) + if err != nil { + return "", err + } + if err := p.d.WriteFile(p.d.ConfigPath, data); err != nil { + p.d.Logger.Warn("module: config file is not writable — edit and restart", + "path", p.d.ConfigPath, "error", err) + return noticeEditAndRestart, nil + } + return noticeSaved, nil +} + +// render draws the page inside the core shell (SPEC-009 §4/§7). notice names an +// outcome banner ("" for none). +func (p *Page) render(w http.ResponseWriter, r *http.Request, rc *server.RequestContext, notice, changed string) error { + p.mu.Lock() + rows := p.rowsLocked() + restart := false + for _, row := range rows { + if row.Wanted != row.Enabled { + restart = true + break + } + } + p.mu.Unlock() + + data := pageData{ + Rows: rows, + Token: httpx.TokenFromRequest(r), + NoticeText: noticeText(notice, changed), + RestartNeeded: restart, + } + + var body bytes.Buffer + if err := modulesTemplate.Execute(&body, data); err != nil { + return err + } + + w.Header().Set("Cache-Control", "no-store") + return web.Render(w, http.StatusOK, web.Page{ + Title: "Modules", + GUID: rc.Workflow, + Active: ConsoleModule, + CSRF: data.Token, + Content: web.Markup(body.String()), + }) +} + +// rowsLocked snapshots what the page lists. The caller holds p.mu. +func (p *Page) rowsLocked() []row { + out := make([]row, 0, len(p.d.Registry.Modules())) + for _, m := range p.d.Registry.Modules() { + name := m.Name() + wanted := true + if !m.Required() { + if v, ok := p.d.Config.Modules[name]; ok { + wanted = v + } + } + out = append(out, row{ + Name: name, + Kind: KindOf(m), + Required: m.Required(), + Enabled: p.d.Registry.Enabled(name), + Wanted: wanted, + Locked: m.Required(), + }) + } + return out +} + +// record writes one audit row for a toggle (A11, AC-6). The door attached the +// actor (the admin) to the context. +func (p *Page) record(ctx context.Context, action, name, outcome string) { + if p.d.Audit == nil { + return + } + p.d.Audit.Record(ctx, audit.Event{ + Action: action, + Subject: "module:" + name, + Detail: outcome, + }) +} + +// auditAction names the audit event for an enable/disable (A11). +func auditAction(on bool) string { + if on { + return "module.enable" + } + return "module.disable" +} + +// parseEnabled reads a form's desired state. Absent/unknown means OFF (the +// safest default for a disable gate). +func parseEnabled(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "on", "yes", "enable", "enabled": + return true + } + return false +} + +// noticeText renders the outcome banner. +func noticeText(notice, changed string) string { + switch notice { + case noticeSaved: + return "Saved " + changed + " to config.yaml. Restart MyDay to apply the change." + case noticeEditAndRestart: + return "Could not write config.yaml. Edit it by hand and restart MyDay to apply the change." + } + return "" +} + +// writeError writes a terse plain-text error response (matching the door's). +// It is deliberately void: a handler that has already written its response must +// return nil, or the door would append a second (500) response on a write error. +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _, _ = w.Write([]byte(msg + "\n")) +} + +// pageData is the template's input. +type pageData struct { + Rows []row + Token string + NoticeText string + RestartNeeded bool +} + +// row is one module's line on the page. +type row struct { + Name string + Kind string + Required bool + Enabled bool // running (mounted now) + Wanted bool // desired per config (applies at restart) + Locked bool +} + +// modulesTemplateHTML is the page BODY only: the shell (SPEC-009 §4) owns the +// document, the header and the nav; this is the content slot. +const modulesTemplateHTML = `

Modules

+{{if .NoticeText}}

{{.NoticeText}}

{{end}} +{{if .RestartNeeded}}

A change is pending — restart MyDay to apply it.

{{end}} + + + +{{range .Rows}} + + + + + + +{{end}} +
ModuleKindRequiredEnabled
{{.Name}}{{.Kind}}{{if .Required}}yes{{else}}no{{end}}{{if .Enabled}}yes{{else}}no{{end}}{{if ne .Enabled .Wanted}} (wanted: {{if .Wanted}}yes{{else}}no{{end}}){{end}} +{{if .Locked}}locked +{{else}}
+ + +{{if .Wanted}} +{{else}}{{end}} +
{{end}} +
+` + +// modulesTemplate is parsed once; a parse failure is impossible for a constant. +var modulesTemplate = template.Must(template.New("modules").Parse(modulesTemplateHTML)) diff --git a/internal/core/module/page_test.go b/internal/core/module/page_test.go new file mode 100644 index 0000000..ab1501a --- /dev/null +++ b/internal/core/module/page_test.go @@ -0,0 +1,201 @@ +package module + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" +) + +// recordingAudit captures events in memory (AC-6). +type recordingAudit struct { + mu sync.Mutex + events []audit.Event +} + +func (a *recordingAudit) Record(_ context.Context, e audit.Event) { + a.mu.Lock() + defer a.mu.Unlock() + a.events = append(a.events, e) +} + +func (a *recordingAudit) actions() []string { + a.mu.Lock() + defer a.mu.Unlock() + out := make([]string, 0, len(a.events)) + for _, e := range a.events { + out = append(out, e.Action) + } + return out +} + +func pageFixture(t *testing.T, write func(string, []byte) error) (*Page, *config.Config, *recordingAudit, string) { + t.Helper() + reg, err := LoadModules([]Module{ + &fakeModule{name: "home", required: true, kind: "core", routes: []Route{defaultRoute()}}, + &fakeModule{name: "expense", kind: "domain", routes: []Route{defaultRoute()}}, + }, map[string]bool{"expense": true}) + if err != nil { + t.Fatalf("LoadModules: %v", err) + } + cfg := &config.Config{Modules: map[string]bool{"expense": true}} + path := filepath.Join(t.TempDir(), config.FileName) + aud := &recordingAudit{} + p, err := NewPage(PageDeps{ + Registry: reg, + Config: cfg, + ConfigPath: path, + WriteFile: write, + Audit: aud, + }) + if err != nil { + t.Fatalf("NewPage: %v", err) + } + return p, cfg, aud, path +} + +func toggleRequest(name, enabled string) *http.Request { + form := url.Values{"module": {name}, "enabled": {enabled}} + r := httptest.NewRequest("POST", "/guid?do=core&action=modules.toggle", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return r.WithContext(httpx.WithToken(r.Context(), "token")) +} + +// ── AC-4: the UI refuses a required module ─────────────────────────────────── + +func TestPageToggleRequiredIsRefused(t *testing.T) { + p, cfg, aud, path := pageFixture(t, nil) + + rec := httptest.NewRecorder() + if err := p.Toggle(rec, toggleRequest("home", "false"), &server.RequestContext{}); err != nil { + t.Fatalf("Toggle: %v", err) + } + if rec.Code != http.StatusForbidden { + t.Errorf("disabling a required module = %d, want 403 (AC-4)", rec.Code) + } + if cfg.Modules["home"] { + t.Error("the config recorded a change to a required module (AC-4)") + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Error("config.yaml was written for a refused toggle (AC-4)") + } + if got := aud.actions(); len(got) == 0 || got[len(got)-1] != "module.toggle" { + t.Errorf("refused toggle audit = %v, want a module.toggle row", got) + } +} + +// ── AC-6: a toggle is audited and persisted ────────────────────────────────── + +func TestPageToggleSavesAndAudits(t *testing.T) { + p, cfg, aud, path := pageFixture(t, nil) + + rec := httptest.NewRecorder() + if err := p.Toggle(rec, toggleRequest("expense", "false"), &server.RequestContext{}); err != nil { + t.Fatalf("Toggle: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("toggle = %d, want 200 (body %q)", rec.Code, rec.Body.String()) + } + if cfg.Modules["expense"] { + t.Error("the in-memory config was not updated") + } + + // Persisted (D-035): the file carries the new state. + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read config: %v", err) + } + if !strings.Contains(string(data), "expense: false") { + t.Errorf("config.yaml does not record expense: false:\n%s", data) + } + // Audited (A11/AC-6). + if got := aud.actions(); !contains(got, "module.disable") { + t.Errorf("audit = %v, want module.disable (AC-6)", got) + } + + // Re-enabling audits module.enable. + rec = httptest.NewRecorder() + if err := p.Toggle(rec, toggleRequest("expense", "true"), &server.RequestContext{}); err != nil { + t.Fatalf("Toggle(on): %v", err) + } + if got := aud.actions(); !contains(got, "module.enable") { + t.Errorf("audit = %v, want module.enable (AC-6)", got) + } +} + +// TestPageUnwritableConfig: D-035's caveat — unwritable ⇒ edit and restart. +func TestPageUnwritableConfig(t *testing.T) { + p, _, aud, _ := pageFixture(t, func(string, []byte) error { + return errors.New("read-only filesystem") + }) + + rec := httptest.NewRecorder() + if err := p.Toggle(rec, toggleRequest("expense", "false"), &server.RequestContext{}); err != nil { + t.Fatalf("Toggle: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("toggle on an unwritable config = %d, want 200 (render the caveat)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Edit it by hand and restart") { + t.Errorf("unwritable-config page did not show the edit-and-restart caveat:\n%s", rec.Body.String()) + } + if got := aud.actions(); !contains(got, "module.disable") { + t.Errorf("logical toggle was not audited: %v", got) + } +} + +// TestPageList: every module is listed with kind · required · enabled. +func TestPageList(t *testing.T) { + p, _, _, _ := pageFixture(t, nil) + + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/guid/core/modules", nil) + r = r.WithContext(httpx.WithToken(r.Context(), "token")) + if err := p.List(rec, r, &server.RequestContext{}); err != nil { + t.Fatalf("List: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("List = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"home", "expense", "core", "domain", "locked"} { + if !strings.Contains(body, want) { + t.Errorf("Modules page is missing %q:\n%s", want, body) + } + } +} + +// TestPageUnknownModule: an unknown name is a 404, audited as such. +func TestPageUnknownModule(t *testing.T) { + p, _, aud, _ := pageFixture(t, nil) + rec := httptest.NewRecorder() + if err := p.Toggle(rec, toggleRequest("ghost", "false"), &server.RequestContext{}); err != nil { + t.Fatalf("Toggle: %v", err) + } + if rec.Code != http.StatusNotFound { + t.Errorf("unknown module toggle = %d, want 404", rec.Code) + } + if got := aud.actions(); !contains(got, "module.toggle") { + t.Errorf("unknown toggle was not audited: %v", got) + } +} + +func contains(xs []string, want string) bool { + for _, x := range xs { + if x == want { + return true + } + } + return false +} diff --git a/internal/core/module/registry.go b/internal/core/module/registry.go new file mode 100644 index 0000000..5378693 --- /dev/null +++ b/internal/core/module/registry.go @@ -0,0 +1,411 @@ +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 "" + } + return m.Name() +} diff --git a/internal/core/module/registry_test.go b/internal/core/module/registry_test.go new file mode 100644 index 0000000..2bcbbb8 --- /dev/null +++ b/internal/core/module/registry_test.go @@ -0,0 +1,465 @@ +package module + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net/http" + "path/filepath" + "slices" + "strings" + "testing" + + "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" +) + +// ── fakes ──────────────────────────────────────────────────────────────────── + +func okHandler(w http.ResponseWriter, _ *http.Request, _ *server.RequestContext) error { + w.WriteHeader(http.StatusOK) + return nil +} + +// fakeModule is an in-memory Module for the registry tests. +type fakeModule struct { + name string + required bool + kind string + routes []Route + actions []Action + migs []Migration + started *int +} + +func (m *fakeModule) Name() string { return m.name } +func (m *fakeModule) Required() bool { return m.required } +func (m *fakeModule) Kind() string { return m.kind } +func (m *fakeModule) Migrations() []Migration { return m.migs } +func (m *fakeModule) Routes() []Route { return m.routes } +func (m *fakeModule) Actions() []Action { return m.actions } +func (m *fakeModule) Register(r *Registry) error { return r.Add(m) } +func (m *fakeModule) Start(context.Context) error { + if m.started != nil { + *m.started++ + } + return nil +} + +func route(name string) Route { + return Route{Name: name, Function: name, Handler: okHandler} +} + +func defaultRoute() Route { + return Route{Function: "dashboard", Handler: okHandler, Default: true} +} + +// fakeResolver answers the pure model's GUID question. +type fakeResolver map[string]bool // guid -> enrolled + +func (f fakeResolver) GUID(guid string) (bool, bool) { + enrolled, found := f[guid] + return found, enrolled +} + +// ── AC-2: deterministic order ──────────────────────────────────────────────── + +func TestLoadOrderIsRegistrationOrder(t *testing.T) { + mods := []Module{ + &fakeModule{name: "org", routes: []Route{defaultRoute()}}, + &fakeModule{name: "time", routes: []Route{defaultRoute()}}, + &fakeModule{name: "absence", routes: []Route{defaultRoute()}}, + } + r, err := LoadModules(mods, nil) + if err != nil { + t.Fatalf("LoadModules: %v", err) + } + + got := make([]string, 0, 3) + for _, m := range r.Modules() { + got = append(got, m.Name()) + } + want := []string{"org", "time", "absence"} // registration order, never map iteration + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("Modules() = %v, want %v (AC-2)", got, want) + } + // Repeated reads are stable. + if again := r.Modules(); strings.Join(names(again), ",") != strings.Join(want, ",") { + t.Errorf("Modules() is not deterministic: %v", names(again)) + } +} + +// TestLogLoaded pins AC-2's "log the loaded set at startup". +func TestLogLoaded(t *testing.T) { + var buf bytes.Buffer + log := slog.New(slog.NewTextHandler(&buf, nil)) + + r, err := LoadModules([]Module{ + &fakeModule{name: "home", required: true, routes: []Route{defaultRoute()}}, + &fakeModule{name: "expense", routes: []Route{defaultRoute()}}, + }, map[string]bool{"expense": false}) + if err != nil { + t.Fatalf("LoadModules: %v", err) + } + r.LogLoaded(log) + + out := buf.String() + for _, want := range []string{"modules loaded", "home", "expense"} { + if !strings.Contains(out, want) { + t.Errorf("startup log is missing %q:\n%s", want, out) + } + } +} + +// ── AC-3: disable → not mounted (routes 404, nav hidden), data intact ──────── + +func TestDisabledModuleIsNotMountedAndDataSurvives(t *testing.T) { + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + home := &fakeModule{name: "home", required: true, routes: []Route{defaultRoute()}} + expense := &fakeModule{ + name: "expense", + kind: "domain", + routes: []Route{route("list"), defaultRoute()}, + actions: []Action{{Name: "submit", Function: "submit", Handler: okHandler}}, + migs: []Migration{{Version: "0001_init", + SQL: "CREATE TABLE expense_items (item_id INTEGER PRIMARY KEY)"}}, + } + + // First run: expense ENABLED — its migration creates its table. + enabled, err := LoadModules([]Module{home, expense}, map[string]bool{"expense": true}) + if err != nil { + t.Fatalf("LoadModules(enabled): %v", err) + } + if err := store.RunMigrations(db.DB, enabled.Migrations()); err != nil { + t.Fatalf("RunMigrations: %v", err) + } + assertTableExists(t, db, "expense_items", true) + + // Restart with expense DISABLED (D-038): it is not mounted. + disabled, err := LoadModules([]Module{home, expense}, map[string]bool{"expense": false}) + if err != nil { + t.Fatalf("LoadModules(disabled): %v", err) + } + if disabled.Enabled("expense") { + t.Fatal("expense is enabled but the config disabled it (D-038)") + } + + dst := server.NewMapRegistry() + disabled.Mount(dst) + res := fakeResolver{"g": true} + + // The disabled module's path route 404s... + if got := routing.Dispatch(res, dst, "/g/expense/list", "", ""); got.Kind != routing.KindNotFound { + t.Errorf("/{guid}/expense/list = %+v, want NotFound (AC-3)", got) + } + // ...and its action 404s too. + if got := routing.Dispatch(res, dst, "/g", "expense", "submit"); got.Kind != routing.KindNotFound { + t.Errorf("do=expense&action=submit = %+v, want NotFound (AC-3)", got) + } + // A required module (home) still resolves. + if got := routing.Dispatch(res, dst, "/g", "", ""); got.Kind != routing.KindRoute || got.Module != "home" { + t.Errorf("/{guid} = %+v, want the home dashboard (AC-3)", got) + } + + // Nav hides the disabled module. + if nav := disabled.Nav(); strings.Contains(strings.Join(nav, ","), "expense") { + t.Errorf("Nav() = %v, want no expense (AC-3: nav hides)", nav) + } + + // Re-enabling restores it, and the module's table was NEVER dropped. + reenabled, err := LoadModules([]Module{home, expense}, map[string]bool{"expense": true}) + if err != nil { + t.Fatalf("LoadModules(re-enabled): %v", err) + } + dst2 := server.NewMapRegistry() + reenabled.Mount(dst2) + if got := routing.Dispatch(res, dst2, "/g/expense/list", "", ""); got.Kind != routing.KindRoute { + t.Errorf("re-enabled /{guid}/expense/list = %+v, want Route (AC-3)", got) + } + assertTableExists(t, db, "expense_items", true) +} + +// ── AC-4: required modules cannot be disabled (API) ────────────────────────── + +func TestRequiredModuleCannotBeDisabled(t *testing.T) { + r, err := LoadModules([]Module{ + &fakeModule{name: "home", required: true, routes: []Route{defaultRoute()}}, + &fakeModule{name: "expense", routes: []Route{defaultRoute()}}, + }, map[string]bool{"home": false, "expense": false}) + if err != nil { + t.Fatalf("LoadModules: %v", err) + } + + // The config cannot disable a required module: Load keeps it on. + if !r.Enabled("home") { + t.Error("home is required but Load disabled it (AC-4)") + } + // The API refuses too. + if err := r.SetEnabled("home", false); !errors.Is(err, ErrRequiredModule) { + t.Errorf("SetEnabled(home,false) = %v, want ErrRequiredModule (AC-4)", err) + } + if err := r.SetEnabled("home", true); err != nil { + t.Errorf("SetEnabled(home,true) = %v, want nil", err) + } + // An optional module toggles fine. + if err := r.SetEnabled("expense", true); err != nil { + t.Errorf("SetEnabled(expense,true) = %v, want nil", err) + } + if err := r.SetEnabled("expense", false); err != nil { + t.Errorf("SetEnabled(expense,false) = %v, want nil", err) + } + // An unknown module is refused. + if err := r.SetEnabled("nope", true); !errors.Is(err, ErrUnknownModule) { + t.Errorf("SetEnabled(nope) = %v, want ErrUnknownModule", err) + } +} + +// ── AC-5: migrations run in registration order, core owns the runner ───────── + +func TestMigrationsOrderPrefixAndRunner(t *testing.T) { + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + first := &fakeModule{name: "alpha", routes: []Route{defaultRoute()}, + migs: []Migration{ + {Version: "0001_a", SQL: "CREATE TABLE alpha_a (id INTEGER PRIMARY KEY)"}, + {Version: "0002_b", SQL: "CREATE TABLE alpha_b (id INTEGER PRIMARY KEY)"}, + }} + second := &fakeModule{name: "beta", routes: []Route{defaultRoute()}, + migs: []Migration{{Version: "0001_init", SQL: "CREATE TABLE beta_init (id INTEGER PRIMARY KEY)"}}} + + r, err := LoadModules([]Module{first, second}, nil) + if err != nil { + t.Fatalf("LoadModules: %v", err) + } + + got := r.Migrations() + wantVersions := []string{"alpha/0001_a", "alpha/0002_b", "beta/0001_init"} + if len(got) != len(wantVersions) { + t.Fatalf("Migrations() = %d, want %d (AC-5)", len(got), len(wantVersions)) + } + for i, v := range wantVersions { + if got[i].Version != v { + t.Errorf("Migrations()[%d].Version = %q, want %q (order + prefix, AC-5)", i, got[i].Version, v) + } + } + + // The CORE runs them (store.RunMigrations), and both tables appear. + if err := store.RunMigrations(db.DB, got); err != nil { + t.Fatalf("RunMigrations: %v", err) + } + for _, table := range []string{"alpha_a", "alpha_b", "beta_init"} { + assertTableExists(t, db, table, true) + } + + // A disabled module contributes no migrations. + r2, err := LoadModules([]Module{first, second}, map[string]bool{"beta": false}) + if err != nil { + t.Fatalf("LoadModules: %v", err) + } + for _, m := range r2.Migrations() { + if strings.HasPrefix(m.Version, "beta/") { + t.Errorf("a disabled module contributed migration %q (AC-5)", m.Version) + } + } +} + +// ── validation & failures ──────────────────────────────────────────────────── + +func TestLoadRejectsDuplicateAndMalformed(t *testing.T) { + dup := []Module{ + &fakeModule{name: "x", routes: []Route{defaultRoute()}}, + &fakeModule{name: "x", routes: []Route{defaultRoute()}}, + } + if _, err := LoadModules(dup, nil); err == nil { + t.Error("duplicate module name was accepted") + } + + cases := map[string]Module{ + "empty name": &fakeModule{name: "", routes: []Route{defaultRoute()}}, + "two defaults": &fakeModule{name: "x", routes: []Route{defaultRoute(), {Function: "y", Handler: okHandler, Default: true}}}, + "nil handler": &fakeModule{name: "x", routes: []Route{{Name: "r", Function: "r"}}}, + "empty function": &fakeModule{name: "x", routes: []Route{{Name: "r", Handler: okHandler}}}, + "duplicate route": &fakeModule{name: "x", routes: []Route{route("r"), route("r")}}, + "empty action name": &fakeModule{name: "x", routes: []Route{defaultRoute()}, actions: []Action{{Function: "f", Handler: okHandler}}}, + } + for label, m := range cases { + if _, err := LoadModules([]Module{m}, nil); err == nil { + t.Errorf("%s was accepted, want an error (fail closed)", label) + } + } +} + +// ── AC-6 (SPEC-009 §8/§13 · SPEC-008 §9 D-9 · SPEC-009 §13 Q-1): the guard ─── + +// TestAC6_DiscoveredModuleCollidingWithACoreDispatchNameFailsToLoad: a module +// claiming a name the CORE dispatches for itself — a core dispatch module +// (`auth` · `setup` · `recovery` · `core`) OR a reserved level-1 workflow +// (`access` · `setup` · `api` · `static` · `media` · `ws` · `dev`, SPEC-009 §13 +// Q-1) — must FAIL TO LOAD rather than silently losing precedence or shadowing +// the core (the door's RegisterModule overwrites by name). +func TestAC6_DiscoveredModuleCollidingWithACoreDispatchNameFailsToLoad(t *testing.T) { + if len(CoreDispatchNames) == 0 { + t.Fatal("CoreDispatchNames is empty — the guard would never fire") + } + if len(ReservedWorkflowNames) == 0 { + t.Fatal("ReservedWorkflowNames is empty — Q-1's extension is missing") + } + + // Every guarded name — core dispatch module or reserved level-1 workflow — + // must fail to load. + for _, name := range ReservedModuleNames { + t.Run(name, func(t *testing.T) { + colliding := &fakeModule{name: name, routes: []Route{defaultRoute()}} + _, err := LoadModules([]Module{colliding}, nil) + if err == nil { + t.Fatalf("a discovered module named %q loaded, want a hard failure (AC-6)", name) + } + if !errors.Is(err, ErrCoreDispatchName) { + t.Errorf("LoadModules(%q) = %v, want ErrCoreDispatchName (AC-6)", name, err) + } + // The registry is not half-built: the guard runs before Add. + r := NewRegistry() + if err := r.Add(colliding); !errors.Is(err, ErrCoreDispatchName) { + t.Errorf("Add(%q) = %v, want ErrCoreDispatchName", name, err) + } + if r.Enabled(name) { + t.Errorf("%q reached the registry despite the guard (AC-6)", name) + } + }) + } + + // The predicates are the single source of truth the guard consults. + for _, name := range CoreDispatchNames { + if !IsCoreDispatchName(name) || !IsReservedModuleName(name) { + t.Errorf("IsCoreDispatchName/IsReservedModuleName(%q) = false, want true", name) + } + } + for _, name := range ReservedWorkflowNames { + if !IsReservedWorkflowName(name) || !IsReservedModuleName(name) { + t.Errorf("IsReservedWorkflowName/IsReservedModuleName(%q) = false, want true", name) + } + } + + // `home` is the documented EXCEPTION (SPEC-002 §4.4, SPEC-009 §13 D-2: the + // reserved level-1 workflow and the PARAM2 dashboard are separate switches — + // the dashboard IS a discovered module, the reference one). It must load. + if IsCoreDispatchName("home") || IsReservedWorkflowName("home") || IsReservedModuleName("home") { + t.Error("`home` is guarded, but the reference dashboard module is named `home`") + } + if _, err := LoadModules([]Module{&fakeModule{name: "home", required: true, routes: []Route{defaultRoute()}}}, nil); err != nil { + t.Errorf("the `home` dashboard module failed to load: %v", err) + } + + // An ordinary, non-colliding module is unaffected. + if _, err := LoadModules([]Module{&fakeModule{name: "expense", routes: []Route{defaultRoute()}}}, nil); err != nil { + t.Errorf("a non-colliding module failed to load: %v", err) + } +} + +// TestReservedWorkflowNamesDeriveFromRouting pins the Q-1 extension: the guarded +// reserved set is DERIVED from routing.ReservedWorkflows — the one source for +// the reserved level-1 names (D-004) — with `home` removed (SPEC-009 §13 D-2), +// so it can never drift out of sync. +func TestReservedWorkflowNamesDeriveFromRouting(t *testing.T) { + // The derivation: routing.ReservedWorkflows minus `home`, order preserved. + derived := make([]string, 0, len(routing.ReservedWorkflows)) + for _, n := range routing.ReservedWorkflows { + if n != "home" { + derived = append(derived, n) + } + } + if !slices.Equal(ReservedWorkflowNames, derived) { + t.Errorf("ReservedWorkflowNames = %v, want the routing source minus home %v", + ReservedWorkflowNames, derived) + } + // And the concrete expectation the ruling names (Q-1). + want := []string{"access", "setup", "api", "static", "media", "ws", "dev"} + if !slices.Equal(ReservedWorkflowNames, want) { + t.Errorf("ReservedWorkflowNames = %v, want %v", ReservedWorkflowNames, want) + } + // The full guarded set is the union of both categories, de-duplicated. + union := append(append([]string{}, CoreDispatchNames...), ReservedWorkflowNames...) + if !slices.Equal(ReservedModuleNames, unionNames(union)) { + t.Errorf("ReservedModuleNames = %v, want the union %v", ReservedModuleNames, unionNames(union)) + } + // Every name in the union is guarded; nothing else is. + for _, n := range union { + if !IsReservedModuleName(n) { + t.Errorf("IsReservedModuleName(%q) = false, want true", n) + } + } + for _, n := range []string{"home", "expense", "org", "time", "absence"} { + if IsReservedModuleName(n) { + t.Errorf("IsReservedModuleName(%q) = true, want false", n) + } + } +} + +func TestStartRunsEnabledOnly(t *testing.T) { + var enabled, disabled int + r, err := LoadModules([]Module{ + &fakeModule{name: "on", routes: []Route{defaultRoute()}, started: &enabled}, + &fakeModule{name: "off", routes: []Route{defaultRoute()}, started: &disabled}, + }, map[string]bool{"off": false}) + if err != nil { + t.Fatalf("LoadModules: %v", err) + } + if err := r.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if enabled != 1 { + t.Errorf("enabled module Start calls = %d, want 1", enabled) + } + if disabled != 0 { + t.Errorf("disabled module Start calls = %d, want 0", disabled) + } +} + +func TestKindOf(t *testing.T) { + if got := KindOf(&fakeModule{name: "x", kind: "domain"}); got != "domain" { + t.Errorf("KindOf = %q, want domain", got) + } + if got := KindOf(&fakeModule{name: "x"}); got != "module" { + t.Errorf("KindOf default = %q, want module", got) + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +func names(mods []Module) []string { + out := make([]string, 0, len(mods)) + for _, m := range mods { + out = append(out, m.Name()) + } + return out +} + +func assertTableExists(t *testing.T, db *store.DB, table string, want bool) { + t.Helper() + var n int + err := db.QueryRow( + `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&n) + if err != nil { + t.Fatalf("sqlite_master: %v", err) + } + if (n > 0) != want { + t.Errorf("table %q exists = %v, want %v", table, n > 0, want) + } +} diff --git a/internal/modules/discovery_test.go b/internal/modules/discovery_test.go new file mode 100644 index 0000000..0ed8034 --- /dev/null +++ b/internal/modules/discovery_test.go @@ -0,0 +1,62 @@ +package modules + +import ( + "testing" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" +) + +// fakeResolver answers the pure model's GUID question. +type fakeResolver map[string]bool // guid -> enrolled + +func (f fakeResolver) GUID(guid string) (bool, bool) { + enrolled, found := f[guid] + return found, enrolled +} + +// TestDiscoveryFindsHome proves the generated aggregator wires the `home` +// directory into the registry — build-time discovery, no core edits (AC-1). +func TestDiscoveryFindsHome(t *testing.T) { + var found bool + for _, m := range module.Discovered() { + if m.Name() == "home" { + found = true + } + } + if !found { + t.Fatalf("the `home` module was not discovered: %v", module.Discovered()) + } +} + +// TestDoorDispatchesToDiscoveredHome is the end-to-end proof (definition of +// done): the discovered registry mounts into the door's registry, and the pure +// model resolves /{GUID} and /{GUID}/home to the home module's dashboard. +func TestDoorDispatchesToDiscoveredHome(t *testing.T) { + reg, err := module.Load(nil) + if err != nil { + t.Fatalf("module.Load: %v", err) + } + dst := server.NewMapRegistry() + reg.Mount(dst) + + res := fakeResolver{"guid-1": true} + + // /{GUID} → the home module's DEFAULT route. + root := routing.Dispatch(res, dst, "/guid-1", "", "") + if root.Kind != routing.KindRoute || root.Module != "home" || root.Function != "dashboard" { + t.Errorf("/{GUID} = %+v, want module home function dashboard (AC-1)", root) + } + // /{GUID}/home → `home` is the PARAM2 case at level 2 (SPEC-002 §4.4); with + // PARAM3 absent it reaches the home module's default too. + home := routing.Dispatch(res, dst, "/guid-1/home", "", "") + if home.Kind != routing.KindRoute || home.Module != "home" || home.Function != "dashboard" { + t.Errorf("/{GUID}/home = %+v, want module home function dashboard (AC-1)", home) + } + // An unknown module still 404s. + bad := routing.Dispatch(res, dst, "/guid-1/nope/x", "", "") + if bad.Kind != routing.KindNotFound { + t.Errorf("/{GUID}/nope/x = %+v, want NotFound", bad) + } +} diff --git a/internal/modules/gen/generate.go b/internal/modules/gen/generate.go new file mode 100644 index 0000000..947d69c --- /dev/null +++ b/internal/modules/gen/generate.go @@ -0,0 +1,97 @@ +package main + +import ( + "bytes" + "fmt" + "go/format" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +// Generate renders the discovery aggregator for the module directory dir. It +// blank-imports every sub-directory that holds Go source, in sorted order, so +// the aggregator is deterministic (SPEC-008 AC-2) and the DIRECTORY is the +// source of truth (D-028). importBase is the Go import path of dir. +func Generate(dir, importBase string) ([]byte, error) { + names, err := moduleDirs(dir) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + buf.WriteString("// Code generated by go generate; DO NOT EDIT.\n") + buf.WriteString("//\n") + buf.WriteString("// Produced by `go generate ./internal/modules` (internal/modules/generate.go).\n") + buf.WriteString("// Every sub-directory that holds Go source is blank-imported here so its\n") + buf.WriteString("// init() self-registration runs at startup (D-028): THE DIRECTORY IS THE\n") + buf.WriteString("// SOURCE OF TRUTH for the module set. Add a directory and re-run go generate.\n") + buf.WriteString("package modules\n\n") + if len(names) == 0 { + buf.WriteString("// No module directories were found.\n") + } else { + buf.WriteString("import (\n") + for _, n := range names { + fmt.Fprintf(&buf, "\t_ %q\n", path.Join(importBase, n)) + } + buf.WriteString(")\n") + } + + src, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("gendiscovery: format output: %w", err) + } + return src, nil +} + +// moduleDirs lists the module directories under dir, sorted. +func moduleDirs(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var names []string + for _, e := range entries { + if !e.IsDir() || skipDir(e.Name()) { + continue + } + ok, err := hasGoSource(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + if ok { + names = append(names, e.Name()) + } + } + sort.Strings(names) + return names, nil +} + +// skipDir reports whether a directory is tooling, not a module. +func skipDir(name string) bool { + switch name { + case "gen", "testdata": + return true + } + return strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") +} + +// hasGoSource reports whether dir holds at least one non-test Go file. +func hasGoSource(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + for _, e := range entries { + if e.IsDir() { + continue + } + n := e.Name() + if strings.HasSuffix(n, ".go") && !strings.HasSuffix(n, "_test.go") { + return true, nil + } + } + return false, nil +} diff --git a/internal/modules/gen/generate_test.go b/internal/modules/gen/generate_test.go new file mode 100644 index 0000000..a578ca8 --- /dev/null +++ b/internal/modules/gen/generate_test.go @@ -0,0 +1,135 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeModule creates a minimal module directory (one non-test Go file). +func writeModule(t *testing.T, dir, name string) { + t.Helper() + d := filepath.Join(dir, name) + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + if err := os.WriteFile(filepath.Join(d, "app.go"), []byte("package "+name+"\n"), 0o644); err != nil { + t.Fatalf("write %s: %v", d, err) + } +} + +const importBase = "example.com/mod/internal/modules" + +// TestGenerateSortedAndDeterministic: the aggregator is deterministic and the +// imports are sorted (AC-1/AC-2). +func TestGenerateSortedAndDeterministic(t *testing.T) { + dir := t.TempDir() + for _, n := range []string{"time", "home", "expense"} { + writeModule(t, dir, n) + } + + src, err := Generate(dir, importBase) + if err != nil { + t.Fatalf("Generate: %v", err) + } + got := string(src) + if !strings.Contains(got, "Code generated") { + t.Error("the header is missing") + } + + // Sorted, regardless of creation order. + iHome := strings.Index(got, importBase+"/home") + iExpense := strings.Index(got, importBase+"/expense") + iTime := strings.Index(got, importBase+"/time") + if iHome < 0 || iExpense < 0 || iTime < 0 { + t.Fatalf("not all modules were imported:\n%s", got) + } + if !(iExpense < iHome && iHome < iTime) { + t.Errorf("imports are not sorted (expense 1 { + dir = os.Args[1] + } + if err := run(dir); err != nil { + fmt.Fprintln(os.Stderr, "gendiscovery:", err) + os.Exit(1) + } +} + +func run(dir string) error { + abs, err := filepath.Abs(dir) + if err != nil { + return err + } + root, modulePath, err := findModule(abs) + if err != nil { + return err + } + rel, err := filepath.Rel(root, abs) + if err != nil { + return err + } + importBase := path.Join(modulePath, filepath.ToSlash(rel)) + + src, err := Generate(dir, importBase) + if err != nil { + return err + } + out := filepath.Join(dir, "imports_gen.go") + if err := os.WriteFile(out, src, 0o644); err != nil { + return err + } + fmt.Fprintln(os.Stderr, "gendiscovery: wrote", out) + return nil +} + +// findModule walks up from dir to the nearest go.mod and returns its directory +// and module path. +func findModule(dir string) (root, modulePath string, err error) { + cur := dir + for { + candidate := filepath.Join(cur, "go.mod") + if data, readErr := os.ReadFile(candidate); readErr == nil { + if mp := parseModulePath(string(data)); mp != "" { + return cur, mp, nil + } + return "", "", fmt.Errorf("go.mod at %s declares no module path", candidate) + } + parent := filepath.Dir(cur) + if parent == cur { + return "", "", fmt.Errorf("no go.mod found above %s", dir) + } + cur = parent + } +} + +// parseModulePath extracts the `module ` line from a go.mod. +func parseModulePath(goMod string) string { + for _, line := range strings.Split(goMod, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")) + } + } + return "" +} diff --git a/internal/modules/generate.go b/internal/modules/generate.go new file mode 100644 index 0000000..caa6328 --- /dev/null +++ b/internal/modules/generate.go @@ -0,0 +1,11 @@ +// Package modules aggregates MyDay's drop-in modules for BUILD-time discovery +// (D-028, SPEC-008 §3). The directory is the source of truth: every +// sub-directory under internal/modules/ that holds Go source is a module, and +// the generated aggregator (imports_gen.go) blank-imports each so its init() +// self-registration runs at process start. +// +// Adding a module is: create internal/modules// (app.go · routing.go · +// handlers.go), run `go generate ./internal/modules`, rebuild. No core edits. +package modules + +//go:generate go run ./gen . diff --git a/internal/modules/home/app.go b/internal/modules/home/app.go new file mode 100644 index 0000000..41fba77 --- /dev/null +++ b/internal/modules/home/app.go @@ -0,0 +1,43 @@ +// Package home is MyDay's core dashboard module: the landing page a user +// reaches at /{GUID} (the registry default) and /{GUID}/home. It is built on +// the SPEC-008/D-037 skeleton — app.go (this manifest) · routing.go (routes) · +// handlers.go (the handlers) — and is the reference module that proves build-time +// discovery works end to end. +package home + +import ( + "context" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module" +) + +// Module is the `home` manifest (SPEC-008 §4). +type Module struct{} + +// init self-registers the module (D-028): the generated aggregator +// (internal/modules/imports_gen.go) blank-imports this package, so this runs at +// process start and the directory alone is what makes `home` exist. +func init() { module.Add(&Module{}) } + +var _ module.Module = (*Module)(nil) + +// Name is the PARAM2 segment (SPEC-002 §4.4: PARAM2 = "home"). +func (*Module) Name() string { return "home" } + +// Required is true: the routing model's default module is `home` +// (routing.DefaultModule), so disabling it would break /{GUID} for every user +// (D-038, AC-4). +func (*Module) Required() bool { return true } + +// Kind marks the dashboard as core chrome rather than a domain module +// (SPEC-008 §4.8, listed on the Modules page §7). +func (*Module) Kind() string { return "core" } + +// Migrations is empty: the dashboard owns no tables. +func (*Module) Migrations() []module.Migration { return nil } + +// Register self-registers into the runtime registry. +func (m *Module) Register(r *module.Registry) error { return r.Add(m) } + +// Start has no background work (the optional hook, SPEC-008 §4). +func (*Module) Start(context.Context) error { return nil } diff --git a/internal/modules/home/handlers.go b/internal/modules/home/handlers.go new file mode 100644 index 0000000..f145940 --- /dev/null +++ b/internal/modules/home/handlers.go @@ -0,0 +1,27 @@ +package home + +import ( + "net/http" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web" +) + +// dashboard renders the user's landing page inside the core shell (SPEC-009 +// §4/§7). The door has already resolved identity and gated the session/role, so +// rc carries the authenticated user — the handler never parses a raw path (the +// rule of the door). +// +// It is the reference for the module view contract: a module supplies its +// CONTENT; the shell owns the header, the nav (from Registry.Nav, D-038) and +// the flash area. +func dashboard(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error { + w.Header().Set("Cache-Control", "no-store") + return web.Render(w, http.StatusOK, web.Page{ + Title: "MyDay", + GUID: rc.Workflow, + Active: "home", + Content: web.Markup(`

MyDay

+

Your day, at a glance.

`), + }) +} diff --git a/internal/modules/home/routing.go b/internal/modules/home/routing.go new file mode 100644 index 0000000..0841537 --- /dev/null +++ b/internal/modules/home/routing.go @@ -0,0 +1,16 @@ +package home + +import "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/module" + +// Routes are the module's path-mode functions (SPEC-008 §4). The dashboard is +// the module DEFAULT: SPEC-002 §4.4 makes `home` a PARAM2 case at level 2, and +// with PARAM3 absent the module's default runs — so BOTH /{GUID} and +// /{GUID}/home reach it. +func (*Module) Routes() []module.Route { + return []module.Route{ + {Function: "dashboard", Handler: dashboard, Default: true}, + } +} + +// Actions is empty: the dashboard has no mutations. +func (*Module) Actions() []module.Action { return nil } diff --git a/internal/modules/imports_gen.go b/internal/modules/imports_gen.go new file mode 100644 index 0000000..acfe52f --- /dev/null +++ b/internal/modules/imports_gen.go @@ -0,0 +1,11 @@ +// Code generated by go generate; DO NOT EDIT. +// +// Produced by `go generate ./internal/modules` (internal/modules/generate.go). +// Every sub-directory that holds Go source is blank-imported here so its +// init() self-registration runs at startup (D-028): THE DIRECTORY IS THE +// SOURCE OF TRUTH for the module set. Add a directory and re-run go generate. +package modules + +import ( + _ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/modules/home" +)