feat(module): add the module registry, discovery and the home module (SPEC-008)

- 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
This commit is contained in:
Claus Lohmar 2026-10-14 10:53:57 +00:00
parent 4c839ff30d
commit d7d2c7d2b6
14 changed files with 2038 additions and 0 deletions

View file

@ -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/<name>/ 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/<name>/ 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
}

View file

@ -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 = `<h1>Modules</h1>
{{if .NoticeText}}<p class="notice">{{.NoticeText}}</p>{{end}}
{{if .RestartNeeded}}<p class="restart">A change is pending — restart MyDay to apply it.</p>{{end}}
<table>
<thead><tr><th>Module</th><th>Kind</th><th>Required</th><th>Enabled</th><th></th></tr></thead>
<tbody>
{{range .Rows}}<tr>
<td>{{.Name}}</td>
<td>{{.Kind}}</td>
<td>{{if .Required}}yes{{else}}no{{end}}</td>
<td>{{if .Enabled}}yes{{else}}no{{end}}{{if ne .Enabled .Wanted}} (wanted: {{if .Wanted}}yes{{else}}no{{end}}){{end}}</td>
<td>
{{if .Locked}}<span class="locked">locked</span>
{{else}}<form method="post" action="?do=core&amp;action=modules.toggle">
<input type="hidden" name="csrf" value="{{$.Token}}">
<input type="hidden" name="module" value="{{.Name}}">
{{if .Wanted}}<button name="enabled" value="false">Disable</button>
{{else}}<button name="enabled" value="true">Enable</button>{{end}}
</form>{{end}}
</td>
</tr>
{{end}}</tbody>
</table>
`
// modulesTemplate is parsed once; a parse failure is impossible for a constant.
var modulesTemplate = template.Must(template.New("modules").Parse(modulesTemplateHTML))

View file

@ -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
}

View file

@ -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 "<nil>"
}
return m.Name()
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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<home<time):\n%s", got)
}
// Deterministic across runs.
again, err := Generate(dir, importBase)
if err != nil {
t.Fatalf("Generate (again): %v", err)
}
if string(again) != got {
t.Error("Generate is not deterministic")
}
}
// TestGenerateDiscoversANewDirectory: a new directory alone changes the output
// with no edits elsewhere (AC-1).
func TestGenerateDiscoversANewDirectory(t *testing.T) {
dir := t.TempDir()
writeModule(t, dir, "home")
before, err := Generate(dir, importBase)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if strings.Contains(string(before), "/reports") {
t.Fatal("reports appeared before it existed")
}
writeModule(t, dir, "reports")
after, err := Generate(dir, importBase)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if !strings.Contains(string(after), importBase+"/reports") {
t.Errorf("a new directory was not discovered:\n%s", after)
}
}
// TestGenerateSkipsTooling: the generator dir, testdata, dot/underscore dirs
// and test-only packages are not modules.
func TestGenerateSkipsTooling(t *testing.T) {
dir := t.TempDir()
writeModule(t, dir, "home")
for _, n := range []string{"gen", "testdata", "_scratch", ".hidden"} {
writeModule(t, dir, n)
}
// A directory that holds only a _test.go file is not a module either.
only := filepath.Join(dir, "onlytests")
if err := os.MkdirAll(only, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(only, "x_test.go"), []byte("package onlytests\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
src, err := Generate(dir, importBase)
if err != nil {
t.Fatalf("Generate: %v", err)
}
got := string(src)
if !strings.Contains(got, importBase+"/home") {
t.Errorf("home was skipped:\n%s", got)
}
for _, n := range []string{"gen", "testdata", "_scratch", ".hidden", "onlytests"} {
if strings.Contains(got, importBase+"/"+n) {
t.Errorf("%s was imported, want it skipped:\n%s", n, got)
}
}
}
// TestImportsGenIsUpToDate guards the committed aggregator against drift: the
// real internal/modules tree must reproduce imports_gen.go exactly.
func TestImportsGenIsUpToDate(t *testing.T) {
const real = ".."
const realPath = "git.lohmar.co.uk/cclohmar/NextMyDay/internal/modules"
committed, err := os.ReadFile(filepath.Join(real, "imports_gen.go"))
if err != nil {
t.Fatalf("read imports_gen.go: %v", err)
}
want, err := Generate(real, realPath)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if string(committed) != string(want) {
t.Errorf("imports_gen.go is stale — run `go generate ./internal/modules`.\n--- committed ---\n%s\n--- generated ---\n%s",
committed, want)
}
}

View file

@ -0,0 +1,83 @@
// Command gendiscovery writes the module discovery aggregator
// (internal/modules/imports_gen.go) from the directories under
// internal/modules (D-028). It is invoked by `go generate ./internal/modules`.
//
// Usage: gendiscovery [dir] (dir defaults to ".")
package main
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
)
func main() {
dir := "."
if len(os.Args) > 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 <path>` 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 ""
}

View file

@ -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/<name>/ (app.go · routing.go ·
// handlers.go), run `go generate ./internal/modules`, rebuild. No core edits.
package modules
//go:generate go run ./gen .

View file

@ -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 }

View file

@ -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(`<h1>MyDay</h1>
<p>Your day, at a glance.</p>`),
})
}

View file

@ -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 }

View file

@ -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"
)