From 0c45080dc235a7ee900bba7bc6e12cda9bd20907 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Tue, 6 Oct 2026 22:44:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(udgaver):=20VERDANDE=5FEDITION=20afg=C3=B8?= =?UTF-8?q?r,=20om=20instansen=20er=20et=20opgaveprogram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Første skridt mod noter som et selvstændigt produkt. Kun routeren: fladen, navnet og runen kommer bagefter. --- hvorfor én kodebase og ikke en fork --- Målt før noget blev skrevet. Noternes Go-kode er 2.386 af 49.407 linjer, og den nævner `Task` nul gange, `quickadd` nul, `sections` nul. Den anden vej er også nul. De to halvdele rører hinanden to steder: en `note_links`- række, hvis `kind` tilfældigvis er "task", og rettigheder, hvor en note i et projekt arver projektets rolle. Delingen findes allerede uden projekter. `note_shares` er en tildeling pr. note — note, person, rolle — med del, fjern, rolleopslag, publikum og `SyncLinkedShares`. Og adgangsreglen har allerede grenen for en note uden projekt: `WHERE project_id IS NULL AND created_by = ?`. Et selvstændigt noteprogram er de regler, der allerede er der, med projektgrenen fjernet. Det, der IKKE kan skilles ad, er fundamentet: konti, sessioner, 2FA, invitationer, beskeder, vedhæftninger, søgning, sprog, PWA, runen, beaconen, OpenAPI-dokumentet, e2e-stilladset. Begge programmer skal bruge det hele. To kopier af det er den fejl, der står flest gange i vores egen playbook — de syv plugins med et kopieret ci/-kit, den frysede delte fil. Projekter bliver derfor stående i begge udgaver. I noter-udgaven er et projekt en notesbog: samme kolonne, samme deling, andet ord. En anden grupperingsmodel ved siden af `note_shares` ville være én ting mere at holde i trit, for en forskel ingen kan se. --- skakten --- Opgaveruterne er ikke skjult i fladen. De er fraværende fra routeren, så en instans, der ikke er et opgaveprogram, heller ikke kan bedes om at være det gennem sit API. En ukendt værdi stopper programmet ved opstart. "note", "Notes", "none" — alle afvist. Det er den stille retning af fejlen: ellers ville en instans, der blev sat op uden opgaver, servere hver eneste opgaverute, og den eneste måde at opdage det på ville være at bemærke, at noget virker, som ikke burde. --- prøven --- Den tjekker ikke, at der STÅR en skakt. Den vandrer begge routere og sammenligner, hvad de faktisk serverer: 200 ruter mod 171, 29 tilbageholdt. De 29 er skrevet ned enkeltvis frem for som en præfiks- regel — et præfiks beskriver, hvad nogen mente, og det her skal beskrive, hvad der er. En rute tilføjet senere, der er forskellig mellem udgaverne, fejler i begge retninger, til nogen siger hvilken halvdel den hører til. Og den anden halvdel: noter-udgaven skal STADIG servere noter, eksport, søgning, projekter og /auth/me. En skakt, der også slukker for det, den er til for, er en sletning. Efterprøvet ved at plante fejlen: /labels-skakten åbnet, prøven fejlede og navngav alle seks ruter. Tallet står også på det grønne — "udgaverne er forskellige" over nul tilbageholdte ruter og over 29 er det samme pass. Go-suiten grøn. Fuld chromium-suite: 104 bestået, 0 fejlet. Co-Authored-By: Claude Opus 5 --- internal/config/config.go | 36 +++++++ internal/config/edition.go | 14 +++ internal/config/edition_test.go | 44 +++++++++ internal/httpapi/edition_test.go | 160 +++++++++++++++++++++++++++++++ internal/httpapi/server.go | 86 ++++++++++------- 5 files changed, 304 insertions(+), 36 deletions(-) create mode 100644 internal/config/edition.go create mode 100644 internal/config/edition_test.go create mode 100644 internal/httpapi/edition_test.go 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) + }) }) - }) + } }) })