diff --git a/README.md b/README.md index 839318e..49ac577 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,22 @@ Not what has been, and not what is fated to be: the thing you are in the middle of. Which is what a to-do list is. +## Two editions: verdande and urd + +This repository builds one program with two faces. Start it with +`VERDANDE_EDITION=notes` and it is **urd** — notes and nothing else: the task +routes are not mounted, the sidebar has no way to them, and the program calls +itself urd in its tab, its wordmark and its web manifest. + +Urðr is Verðandi's sister in Völuspá, and the poem has the Norns cutting marks into +wood. Verdande is what is becoming; urd is what has become and was kept. + +Not a fork, because the two share everything that is hard — accounts, sharing, +passkeys, the realtime socket, search, backups, migrations — and notes are about a +twentieth of the Go here. A test walks both routers and fails in either direction, +so "the task routes are not there" is measured rather than promised. See +[Two editions](docs/editions.md). + ## Status All six phases are built. See [What works today](#what-works-today) for what is diff --git a/cmd/verdande/main.go b/cmd/verdande/main.go index 6784b71..dc6f700 100644 --- a/cmd/verdande/main.go +++ b/cmd/verdande/main.go @@ -26,6 +26,9 @@ var version = "dev" func main() { if err := run(); err != nil { + // Not the edition's name: this fires before the configuration is read, so + // the edition is not known yet — and one of the things it reports is a + // VERDANDE_EDITION the program refused. fmt.Fprintf(os.Stderr, "verdande: %v\n", err) os.Exit(1) } @@ -37,7 +40,9 @@ func run() error { return err } log := httpapi.NewLogger(cfg.Dev) - log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL) + // The edition's own name, so an urd instance's log says urd. The full edition + // still logs "starting verdande", which is what the release check reads. + log.Info("starting "+cfg.ProductSlug(), "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL, "edition", cfg.Edition) // The data volume is the only state that matters; create the whole layout up // front so a failure here is a startup error rather than a surprise at the diff --git a/docs/beacon.md b/docs/beacon.md index e2fa5ed..4bca6a9 100644 --- a/docs/beacon.md +++ b/docs/beacon.md @@ -1,6 +1,7 @@ # The install count -verdande reports that it exists. Two values, once a day: +verdande reports that it exists — and so does urd, which is the same program in +its [notes edition](editions.md). Two values, once a day: ```json { @@ -11,7 +12,8 @@ verdande reports that it exists. Two values, once a day: That is the whole message. No IP is stored by the collector, no hostname, no domain, no account, no counts of anything you have made, nothing about what you -use it for. The payload is small enough to be printed in full on the settings page, +use it for — and not which of the two editions is running, so the count does not +tell the two apart either. The payload is small enough to be printed in full on the settings page, and it is printed there — a promise about telemetry is worth exactly as much as your ability to check it. diff --git a/docs/configuration.md b/docs/configuration.md index 9d6cfbb..d253a3d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,6 +11,7 @@ nowhere to put one that the operator would ever see. | `VERDANDE_ADDR` | `:8080` | Listen address. | | `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. | | `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. | +| `VERDANDE_EDITION` | `full` | Which of the two editions this instance is. `full` is verdande — tasks, projects and notes. `notes` is urd: the task routes are not mounted at all, and the program calls itself urd. Any other value refuses to start, by name. See [Two editions](editions.md). | ## Behind a proxy diff --git a/docs/editions.md b/docs/editions.md new file mode 100644 index 0000000..57297b6 --- /dev/null +++ b/docs/editions.md @@ -0,0 +1,69 @@ +# Two editions: verdande and urd + +One program, built once, with two faces. + +| | **verdande** | **urd** | +|---|---|---| +| What it is | Tasks, projects and notes | Notes | +| `VERDANDE_EDITION` | `full` (the default) | `notes` | +| Routes served | 200 | 171 | +| Mark | ᚹ | ᚢ | + +Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not +mounted, the sidebar has no way to them, search answers with notes only, and the +program calls itself urd in its tab, its wordmark and its web manifest. + +## Why one codebase and not a fork + +The two share everything that is hard: accounts, sessions, passkeys, sharing, +invitations, the realtime socket, search, the backup format, the migrations. Notes +are about a twentieth of the Go in this repository and reference no task type; +tasks reference no note type. A fork would have duplicated the other nineteen +twentieths in order to separate the one — and then every security fix would have +needed applying twice, by hand, forever. + +So the split is a configuration value read in one place, and the thing that keeps +it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes` +walks both routers, compares them against a written-down list of the 29 withheld +routes, and fails in **both** directions — a route that appears in the notes +edition and a route that stops being withheld are both a failure. + +## An unknown value refuses to start + +`VERDANDE_EDITION=note`, `Notes` and `none` are all rejected at startup, by name, +rather than being treated as "not notes". + +The silent direction is the dangerous one here. An operator who sets up an instance +for notes and mistypes the value would otherwise get every task route served on it, +with no error anywhere and no way to tell from the outside. + +## What urd does not have + +The 29 withheld routes are tasks, sub-tasks, labels, saved filters, reminders, the +upcoming and delegated views, and the task half of search and export. Everything +else is the same program: + +- accounts, invitations, passkeys and two-factor +- projects — which in urd are what a note belongs to +- per-note sharing with other people, independent of projects +- search, over notes +- export, of notes +- the mail, calendar and MCP surfaces, minus their task routes + +## The name + +Verðandi and Urðr are two of the three Norns in Völuspá, and the poem has them +cutting marks into wood — the act of writing something down. Verdande is what is +becoming; Urd is what has become and was kept. + +An instance started without the task routes is a different product to the person +using it, so it says a different name. That name is read from the configuration in +one place (`Config.ProductName`), which is why there is no build-time flag and no +second frontend: the frontend is byte-identical in both editions, and the server +renames the two files a browser reads before any JavaScript runs — the HTML shell's +title and the web manifest. + +!!! note "The beacon counts them separately" + Both editions report the same two values once a day — instance id and version + — and nothing else; see [The install count](beacon.md). The edition is not in + the payload, so the count does not distinguish them. diff --git a/internal/config/edition.go b/internal/config/edition.go index 22d9731..dc26bd5 100644 --- a/internal/config/edition.go +++ b/internal/config/edition.go @@ -12,3 +12,35 @@ const ( // 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 } + +// ProductName and ProductSlug are the two forms of the name the running instance +// calls itself: "Urd" in a sentence, "urd" in a window title, a wordmark and a +// manifest. +// +// Two editions of one program, two names — which is the whole reason the name is +// read from the configuration rather than written into the templates. Verdande is +// what is happening now; Urd, its sister in Völuspá, is what was cut into the wood +// and kept. An instance started without the task routes is a different product to +// whoever uses it, and a product that calls itself by its sibling's name is not a +// product, it is a flag somebody set. +// +// The lowercase form is deliberate and not a styling accident: the wordmark, the +// repository and the window title are all lowercase, so the slug is the name and +// ProductName is the one that gets a capital because it stands in prose. +func (c *Config) ProductName() string { + if c.NotesOnly() { + return "Urd" + } + return "Verdande" +} + +// ProductSlug is ProductName lowercased, kept as its own method rather than as +// strings.ToLower at the call sites: the two are the same transformation today and +// a name that does not lowercase cleanly would otherwise be a bug in every caller +// at once. +func (c *Config) ProductSlug() string { + if c.NotesOnly() { + return "urd" + } + return "verdande" +} diff --git a/internal/httpapi/csp.go b/internal/httpapi/csp.go index 0a88430..6c24df8 100644 --- a/internal/httpapi/csp.go +++ b/internal/httpapi/csp.go @@ -36,7 +36,18 @@ func scriptHashes(web fs.FS) []string { if err != nil { return nil } + return scriptHashesIn(raw) +} +// scriptHashesIn is the same thing over bytes that are already in hand. +// +// It exists because the notes edition does not serve the index.html that is on +// disk — it serves a renamed copy of it (see shell.go) — and the policy has to +// describe the bytes that actually leave the server. Hashing the file while +// serving something else is the shape of mistake that cannot be noticed locally: +// the policy is not enforced for a page opened from a file, so the page is blank +// in production and nowhere else. +func scriptHashesIn(raw []byte) []string { var out []string seen := map[string]bool{} for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) { diff --git a/internal/httpapi/import_handlers.go b/internal/httpapi/import_handlers.go index 7528042..e0bd1e1 100644 --- a/internal/httpapi/import_handlers.go +++ b/internal/httpapi/import_handlers.go @@ -358,7 +358,7 @@ func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) { return } - cal := ics.Calendar{Name: "Verdande", Domain: feedDomain(s.cfg.BaseURL)} + cal := ics.Calendar{Name: s.cfg.ProductName(), Domain: feedDomain(s.cfg.BaseURL)} for _, t := range tasks { if t.DueDate == "" { continue diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 4f3b29e..7821668 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -3,6 +3,7 @@ package httpapi import ( + "bytes" "encoding/json" "errors" "io/fs" @@ -77,6 +78,11 @@ type Server struct { // Computed once from the built index.html; see csp.go. csp string + // The shell and the manifest with this edition's name in them, or nil to serve + // the built files unchanged. Only the notes edition needs them; see shell.go. + shell []byte + manifest []byte + router chi.Router } @@ -86,7 +92,7 @@ type Server struct { func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server { s := &Server{ cfg: cfg, db: db, log: log, web: web, - mail: mail.New(cfg.SMTP, cfg.BaseURL, log), + mail: mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log), hub: realtime.NewHub(log), loginLimiter: newLimiter(10, 15*time.Minute), // Generous on purpose: the caller is the mail server, one address delivering @@ -101,8 +107,36 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server // generous for a person and hopeless for a search through a million. totpLimiter: newLimiter(10, time.Hour), } - s.csp = contentSecurityPolicy(scriptHashes(web)) - if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil { + // Before the CSP, deliberately: the policy has to describe the bytes that go + // out, and in the notes edition those are the renamed ones. + // + // A failure here is logged rather than fatal, and that is a judgement about + // this particular failure rather than a habit: an unknown VERDANDE_EDITION + // refuses to start, because serving task routes on an instance set up without + // them is a different kind of wrong. A shell whose title says the sibling's + // name is cosmetic — it must be loud, and it must not take the instance down. + // What makes it loud is not this line: the end-to-end suite reads the served + // title, so an anchor that falls out of app.html goes red there. + if web != nil && cfg.NotesOnly() { + shell, manifest, err := rebrandedAssets(web, cfg.ProductSlug(), notesShellDescription, notesManifestDescription) + if err != nil { + log.Error("the frontend could not be renamed for this edition; it will call itself verdande", + "edition", cfg.Edition, "err", err) + } else { + s.shell, s.manifest = shell, manifest + } + } + + if s.shell != nil { + s.csp = contentSecurityPolicy(scriptHashesIn(s.shell)) + } else { + s.csp = contentSecurityPolicy(scriptHashes(web)) + } + // The second argument is WebAuthn's display name, not its relying party id — + // the id comes from BaseURL. So this is what the operating system's passkey + // prompt calls the site, and the slug keeps it identical to what every existing + // verdande instance already registered. + if w, err := auth.NewWebAuthn(cfg.BaseURL, cfg.ProductSlug()); err == nil { s.passkeys = w } else { log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL) @@ -685,7 +719,7 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) { if s.web == nil { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) - w.Write([]byte("verdande — API is running. The web interface was not built into this binary.\n")) + w.Write([]byte(s.cfg.ProductSlug() + " — API is running. The web interface was not built into this binary.\n")) return } @@ -706,6 +740,24 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) { } defer f.Close() + // Only here is `name` settled, and that matters: every client-side route — + // /noter and all the rest — arrives as a path with no file behind it and is + // resolved to index.html by the fallback above. Asking before the fallback + // would have renamed the shell for a request to "/" and handed back the built + // one for every other route in the app. + // + // These two assets never reach the filesystem: they are the built files with + // this edition's name in them, held in memory since startup. See shell.go. + if b := s.renamed(name); b != nil { + w.Header().Set("Content-Type", contentTypeFor(name)) + // A zero modification time tells ServeContent to send no Last-Modified and + // to answer no conditional request — right for a shell that must never be + // cached, and the same thing the built shell gets below. + w.Header().Set("Cache-Control", "no-cache") + http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b)) + return + } + info, err := f.Stat() if err != nil || info.IsDir() { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) diff --git a/internal/httpapi/shell.go b/internal/httpapi/shell.go new file mode 100644 index 0000000..0c6cac0 --- /dev/null +++ b/internal/httpapi/shell.go @@ -0,0 +1,139 @@ +package httpapi + +import ( + "bytes" + "fmt" + "io/fs" +) + +// The product name is in the built frontend as a literal in four places, because +// all four are read before any JavaScript runs: the window title and the meta +// description in the HTML shell, and the two names in the web manifest that decide +// what an installed PWA is called on a home screen. +// +// They cannot be filled in by the app at runtime. The shell is parsed before the +// first script executes, the manifest is fetched by the browser rather than by us, +// and the edition is not known until /auth/setup-state has answered — so a frontend +// that renamed itself after loading would show the sibling's name first and correct +// itself a moment later, on every load, in the tab and on the install prompt. +// +// So the server does it, once at startup, on the way out. One frontend build, two +// names: the build is identical in both editions and the bytes served differ. +// +// Each replacement is REQUIRED and the anchors are exact. A rewrite that quietly +// matched nothing is the whole failure mode here — it leaves an Urd instance +// calling itself verdande, which is cosmetic enough that nobody files it and +// nothing else notices. +type rebrand struct { + old, new string +} + +// shellRebrands returns the replacements for index.html. +// +// Note what is NOT in here: `localStorage.getItem('verdande:theme')` and its two +// neighbours in the inline theme script. Those keys are storage, not a name — the +// rest of the app reads the same three, so renaming them here and nowhere else +// would set the theme from one key and read it from another, and the only symptom +// is the white flash that script exists to prevent. This is why the replacements +// are written out as whole anchored strings rather than as a replace of the word +// "verdande". +func shellRebrands(slug, description string) []rebrand { + return []rebrand{ + {"