diff --git a/internal/config/config.go b/internal/config/config.go index 8f3a448..f269895 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,6 +38,28 @@ type Config struct { // Empty means a key file beside the data — see internal/secret. SecretKey string + // Edition decides which half of the program this instance serves. + // + // "full" is tasks, projects and notes together — what Verdande has always + // been. "notes" mounts the notes half and the base underneath it, and leaves + // the task routes unmounted: not hidden in the interface, absent from the + // router, so an instance that is not a task manager cannot be asked to be one + // through its API. + // + // Notes already stand nearly alone — they reference no task type anywhere in + // the store, and the one link between the halves is a `note_links` row whose + // kind happens to be "task". What is NOT separable is everything underneath + // both: accounts, sessions, invites, sharing, notifications, attachments, + // search. Hence one program with two faces rather than two programs: the + // alternative is two copies of the base, and two copies of a thing is how the + // copies start to disagree. + // + // Projects stay mounted in both. In the notes edition a project is a + // notebook — the same column, the same sharing, a different word — because a + // second grouping concept beside `note_shares` would be a second thing to keep + // in step for a difference nobody can see. + Edition string + // UpdateCheck asks GitHub whether a newer release exists. Off unless asked // for: a self-hosted app that reaches out without being told to has broken // the deal its operator made by self-hosting. @@ -136,6 +158,7 @@ func Load() (*Config, error) { PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""), GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""), SecretKey: env("VERDANDE_SECRET_KEY", ""), + Edition: env("VERDANDE_EDITION", EditionFull), UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false), } @@ -171,6 +194,19 @@ func Load() (*Config, error) { return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err) } + // Afvist frem for gætte. En stavefejl — "note", "Notes" — ville ellers falde + // tilbage til den fulde udgave og servere opgaveruterne på en instans, der + // blev sat op for ikke at have dem. Det er den stille retning af fejlen, og + // den eneste måde at opdage den på ville være at bemærke, at noget virker, + // som ikke burde. + switch c.Edition { + case EditionFull, EditionNotes: + case "": + c.Edition = EditionFull + default: + return nil, fmt.Errorf("VERDANDE_EDITION: %q is not %q or %q", c.Edition, EditionFull, EditionNotes) + } + c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For") if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil { return nil, err diff --git a/internal/config/edition.go b/internal/config/edition.go new file mode 100644 index 0000000..22d9731 --- /dev/null +++ b/internal/config/edition.go @@ -0,0 +1,14 @@ +package config + +// The two faces of the program. Strings rather than a bool, because a bool named +// `NotesOnly` answers one question and this one will be asked again — and a +// third edition would mean changing every reading of it rather than adding a case. +const ( + EditionFull = "full" + EditionNotes = "notes" +) + +// NotesOnly is the one question the router asks. Written as a method so the +// comparison lives in one place: `cfg.Edition == "notes"` spelled out at twenty +// call sites is twenty chances to typo a string the compiler does not check. +func (c *Config) NotesOnly() bool { return c.Edition == EditionNotes } diff --git a/internal/config/edition_test.go b/internal/config/edition_test.go new file mode 100644 index 0000000..2d2dfc6 --- /dev/null +++ b/internal/config/edition_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "testing" +) + +// A typo in VERDANDE_EDITION must stop the program, not fall through to the full +// edition. That is the silent direction of this fault: an instance set up to be a +// notes program would serve every task route, and the only way to notice would be +// to spot that something works which should not. +func TestUnknownEditionIsRefused(t *testing.T) { + for _, tc := range []struct { + value string + want string // the edition we expect, or "" when Load must fail + }{ + {"", EditionFull}, // unset is the full program, as it has always been + {EditionFull, EditionFull}, + {EditionNotes, EditionNotes}, + {"note", ""}, // a letter short + {"Notes", ""}, // the right word, the wrong case + {"none", ""}, // plausible and meaning nothing + } { + t.Setenv("VERDANDE_EDITION", tc.value) + t.Setenv("VERDANDE_DATA_DIR", t.TempDir()) + + got, err := Load() + if tc.want == "" { + if err == nil { + t.Errorf("VERDANDE_EDITION=%q was accepted and gave %q", tc.value, got.Edition) + } + continue + } + if err != nil { + t.Errorf("VERDANDE_EDITION=%q: %v", tc.value, err) + continue + } + if got.Edition != tc.want { + t.Errorf("VERDANDE_EDITION=%q gave %q, want %q", tc.value, got.Edition, tc.want) + } + if notes := got.NotesOnly(); notes != (tc.want == EditionNotes) { + t.Errorf("VERDANDE_EDITION=%q: NotesOnly() = %v", tc.value, notes) + } + } +} diff --git a/internal/httpapi/edition_test.go b/internal/httpapi/edition_test.go new file mode 100644 index 0000000..3b69a67 --- /dev/null +++ b/internal/httpapi/edition_test.go @@ -0,0 +1,160 @@ +package httpapi + +import ( + "io" + "log/slog" + "net/http" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/kristianwind/verdande/internal/config" + "github.com/kristianwind/verdande/internal/store" +) + +// The notes edition is a different program, and this is what makes that true. +// +// A gate in a route tree is the easiest thing in the world to believe and the +// hardest to see: the code reads `if full {`, somebody confirms the mechanism +// exists, and nothing ever asks whether it covers the routes it was written for. +// So this does not check that a gate is *present*. It walks both routers and +// compares the two sets of routes they actually serve. +// +// The difference is written down below. A route added later that differs between +// the editions — in either direction — fails here until somebody says which half +// it belongs to. That is the point: the list is the decision, and the walk is +// what keeps the decision honest. +func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) { + full := routesOf(t, config.EditionFull) + notes := routesOf(t, config.EditionNotes) + + // Every route the full edition has and the notes edition does not. Written as + // the whole list rather than a prefix rule: a prefix is a description of what + // somebody meant, and this has to be a description of what is there. + withheld := map[string]bool{} + for _, r := range []string{ + "DELETE /api/v1/filters/{filterID}", + "DELETE /api/v1/labels/{labelID}", + "DELETE /api/v1/reminders/{reminderID}", + "DELETE /api/v1/tasks/{taskID}", + "GET /api/v1/delegated", + "GET /api/v1/filters", + "GET /api/v1/filters/preview", + "GET /api/v1/filters/{filterID}/tasks", + "GET /api/v1/labels", + "GET /api/v1/tasks", + "GET /api/v1/tasks/quick-add/preview", + "GET /api/v1/tasks/{taskID}", + "GET /api/v1/tasks/{taskID}/comments", + "GET /api/v1/tasks/{taskID}/reminders", + "GET /api/v1/upcoming", + "PATCH /api/v1/filters/{filterID}", + "PATCH /api/v1/labels/{labelID}", + "PATCH /api/v1/tasks/{taskID}", + "POST /api/v1/filters", + "POST /api/v1/labels", + "POST /api/v1/tasks", + "POST /api/v1/tasks/quick-add", + "POST /api/v1/tasks/{taskID}/attachments", + "POST /api/v1/tasks/{taskID}/comments", + "POST /api/v1/tasks/{taskID}/complete", + "POST /api/v1/tasks/{taskID}/move", + "POST /api/v1/tasks/{taskID}/reminders", + "POST /api/v1/tasks/{taskID}/reopen", + "POST /api/v1/tasks/{taskID}/snooze", + } { + withheld[r] = true + } + + var unexpected, stillThere []string + for r := range full { + if notes[r] { + if withheld[r] { + stillThere = append(stillThere, r) + } + continue + } + if !withheld[r] { + unexpected = append(unexpected, r) + } + } + // And the other direction: the notes edition must not grow a route the full + // one lacks. A gate that adds is not a gate either. + var onlyInNotes []string + for r := range notes { + if !full[r] { + onlyInNotes = append(onlyInNotes, r) + } + } + + sort.Strings(unexpected) + sort.Strings(stillThere) + sort.Strings(onlyInNotes) + for _, r := range unexpected { + t.Errorf("withheld from the notes edition but not written down here: %s", r) + } + for _, r := range stillThere { + t.Errorf("listed as withheld and still served by the notes edition: %s", r) + } + for _, r := range onlyInNotes { + t.Errorf("served only by the notes edition: %s", r) + } + + // Printed on the green as well as the red. "The editions differ correctly" over + // zero withheld routes and over twenty-nine are the same pass, and only one of + // them means anything — so the number is on screen either way. + t.Logf("full: %d routes · notes: %d · withheld: %d", len(full), len(notes), len(full)-len(notes)) + + // The half that stops this being a deletion rather than a gate: what the notes + // edition is FOR has to still be there. + for _, must := range []string{ + "GET /api/v1/notes", + "POST /api/v1/notes", + "GET /api/v1/export/notes.zip", + "GET /api/v1/projects", // a notebook, in this edition + "GET /api/v1/search", + "GET /api/v1/auth/me", + } { + if !notes[must] { + t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must) + } + } +} + +// routesOf builds a server at one edition and returns every route it serves. +func routesOf(t *testing.T, edition string) map[string]bool { + t.Helper() + + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + srv := New(&config.Config{ + BaseURL: "http://127.0.0.1", DataDir: t.TempDir(), Edition: edition, + SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour, + }, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + + out := map[string]bool{} + err = chi.Walk(srv.router, + func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + if route != "/" { + route = strings.TrimSuffix(route, "/") + } + out[method+" "+route] = true + return nil + }) + if err != nil { + t.Fatalf("walk routes: %v", err) + } + if len(out) < 50 { + // A walk that found almost nothing would make every comparison below pass. + t.Fatalf("only %d routes at edition %q — the walk found nothing to compare", len(out), edition) + } + return out +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 579b588..4f3b29e 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -285,6 +285,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server // Everything past this point needs a complete login. r.Group(func(r chi.Router) { + // Opgavehalvdelen monteres kun i den fulde udgave. Ikke skjult i + // fladen — fraværende fra routeren, så en instans, der ikke er et + // opgaveprogram, heller ikke kan bedes om at være det gennem sit API. + full := !s.cfg.NotesOnly() r.Use(s.requireAuth) r.Get("/ws", s.handleWebSocket) @@ -294,15 +298,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Post("/mcp", s.handleMCP) r.Get("/today", s.handleToday) - r.Get("/filters/preview", s.handlePreviewFilter) + if full { + r.Get("/filters/preview", s.handlePreviewFilter) + } - r.Route("/filters", func(r chi.Router) { - r.Get("/", s.handleListFilters) - r.Post("/", s.handleCreateFilter) - r.Get("/{filterID}/tasks", s.handleRunFilter) - r.Patch("/{filterID}", s.handleUpdateFilter) - r.Delete("/{filterID}", s.handleDeleteFilter) - }) + if full { + r.Route("/filters", func(r chi.Router) { + r.Get("/", s.handleListFilters) + r.Post("/", s.handleCreateFilter) + r.Get("/{filterID}/tasks", s.handleRunFilter) + r.Patch("/{filterID}", s.handleUpdateFilter) + r.Delete("/{filterID}", s.handleDeleteFilter) + }) + } r.Get("/feed", s.handleGetFeed) @@ -468,16 +476,20 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Delete("/{templateID}", s.handleDeleteTemplate) }) - r.Delete("/reminders/{reminderID}", s.handleDeleteReminder) + if full { + r.Delete("/reminders/{reminderID}", s.handleDeleteReminder) + } - r.Route("/labels", func(r chi.Router) { - r.Get("/", s.handleListLabels) - r.Post("/", s.handleCreateLabel) - r.Patch("/{labelID}", s.handleUpdateLabel) - r.Delete("/{labelID}", s.handleDeleteLabel) - }) - r.Get("/upcoming", s.handleUpcoming) - r.Get("/delegated", s.handleDelegated) + if full { + r.Route("/labels", func(r chi.Router) { + r.Get("/", s.handleListLabels) + r.Post("/", s.handleCreateLabel) + r.Patch("/{labelID}", s.handleUpdateLabel) + r.Delete("/{labelID}", s.handleDeleteLabel) + }) + r.Get("/upcoming", s.handleUpcoming) + r.Get("/delegated", s.handleDelegated) + } r.Get("/people", s.handlePeople) r.Get("/search", s.handleSearch) @@ -583,27 +595,29 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Delete("/", s.handleDeleteSection) }) - r.Route("/tasks", func(r chi.Router) { - r.Get("/", s.handleListTasks) - r.Post("/", s.handleCreateTask) - r.Post("/quick-add", s.handleQuickAdd) - r.Get("/quick-add/preview", s.handleQuickAddPreview) + if full { + r.Route("/tasks", func(r chi.Router) { + r.Get("/", s.handleListTasks) + r.Post("/", s.handleCreateTask) + r.Post("/quick-add", s.handleQuickAdd) + r.Get("/quick-add/preview", s.handleQuickAddPreview) - r.Route("/{taskID}", func(r chi.Router) { - r.Get("/", s.handleGetTask) - r.Patch("/", s.handleUpdateTask) - r.Delete("/", s.handleDeleteTask) - r.Get("/comments", s.handleListComments) - r.Post("/comments", s.handleCreateComment) - r.Post("/attachments", s.handleUploadAttachment) - r.Get("/reminders", s.handleListReminders) - r.Post("/reminders", s.handleCreateReminder) - r.Post("/complete", s.handleCompleteTask) - r.Post("/reopen", s.handleReopenTask) - r.Post("/move", s.handleMoveTask) - r.Post("/snooze", s.handleSnoozeTask) + r.Route("/{taskID}", func(r chi.Router) { + r.Get("/", s.handleGetTask) + r.Patch("/", s.handleUpdateTask) + r.Delete("/", s.handleDeleteTask) + r.Get("/comments", s.handleListComments) + r.Post("/comments", s.handleCreateComment) + r.Post("/attachments", s.handleUploadAttachment) + r.Get("/reminders", s.handleListReminders) + r.Post("/reminders", s.handleCreateReminder) + r.Post("/complete", s.handleCompleteTask) + r.Post("/reopen", s.handleReopenTask) + r.Post("/move", s.handleMoveTask) + r.Post("/snooze", s.handleSnoozeTask) + }) }) - }) + } }) })