From 6154e6b247493380d241b7bff281c346a8009061 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Wed, 7 Oct 2026 01:11:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(udgaver):=20urd=20=E2=80=94=20noter-udgave?= =?UTF-8?q?n=20siger=20sit=20eget=20navn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verdande og Urd er to af nornerne i Völuspá, og digtet har dem skære mærker i træ: *skáru á skíði*. Verdande er det der bliver; Urd er det der blev, og blev gemt. En instans startet uden opgaveruterne er et andet produkt for den der bruger den, så den siger et andet navn. Navnet læses fra udgaven i ÉT sted — Config.ProductName/ProductSlug — frem for at stå skrevet i teksterne. Frontend-bygget er byte-identisk i begge udgaver; serveren omdøber de to filer, browseren læser før noget JavaScript kører. Der er to titler, og det blev først klart, da e2e-prøven sagde det: skallens , som serveren omdøber, og rutens egen i <svelte:head>, som appen sætter bagefter og som overskriver den første. Go-prøven var grøn over de bytes, serveren sendte, mens fanen sagde "Noter · verdande". Begge prøver spørger nu om begge. Omdøbningen er ankret på hele strenge frem for på ordet "verdande", fordi app.html også indeholder localStorage.getItem('verdande:theme'). Omdøbes præfikset der og ikke i resten af appen, bliver temaet sat fra én nøgle og læst fra en anden, og det eneste symptom er det hvide blink, det indlejrede script findes for at undgå. Tre plantede fejl bekræfter, at det er de rigtige prøver, der fanger det: et flyttet anker i app.html, en bred søg-erstat, og en applyRebrands uden fejlretur. Mailen var det vigtigste sted. appName var et felt med en værdi og ingen læser, og kommentaren ved siden af sagde, at det stod i hver besked — de tre steder havde ordet skrevet ud i stedet. En invitation fra en urd-instans går til et andet menneskes indbakke. Også omdøbt: web-manifestet (og sendt som application/manifest+json — Go kender ikke .webmanifest og sniffer den til text/plain, hvorefter browseren ignorerer manifestet uden at sige noget, og installationsknappen bare ikke er der), mærket i menuen (ᚢ uruz frem for ᚹ wunjo), opstartsloggen, passkey-promptens visningsnavn, ICS-kalendernavnet og udseendet, der er opkaldt efter programmet. Ikonet er IKKE med: icon.svg er stadig ᚹ, og de tre PNG'er genereres fra den. Det rører binære, genererede filer og hører i sin egen ændring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 16 ++ cmd/verdande/main.go | 7 +- docs/beacon.md | 6 +- docs/configuration.md | 1 + docs/editions.md | 69 ++++++ internal/config/edition.go | 32 +++ internal/httpapi/csp.go | 11 + internal/httpapi/import_handlers.go | 2 +- internal/httpapi/server.go | 60 +++++- internal/httpapi/shell.go | 139 ++++++++++++ internal/httpapi/shell_test.go | 235 +++++++++++++++++++++ internal/jobs/jobs_test.go | 2 +- internal/mail/mail.go | 32 +-- internal/mail/mail_test.go | 76 +++++++ mkdocs.yml | 1 + web/e2e/edition.spec.js | 61 ++++++ web/e2e/smoke.spec.js | 32 +++ web/src/lib/components/BeaconNotice.svelte | 2 +- web/src/lib/components/NoConnection.svelte | 2 +- web/src/lib/components/Sidebar.svelte | 12 +- web/src/lib/locales/da.js | 6 +- web/src/lib/locales/en.js | 6 +- web/src/lib/stores.svelte.js | 29 +++ web/src/routes/faerdige/+page.svelte | 2 +- web/src/routes/gruppe/[id]/+page.svelte | 2 +- web/src/routes/indstillinger/+page.svelte | 5 +- web/src/routes/invite/+page.svelte | 6 +- web/src/routes/noter/+page.svelte | 2 +- web/src/routes/reset/+page.svelte | 6 +- 29 files changed, 821 insertions(+), 41 deletions(-) create mode 100644 docs/editions.md create mode 100644 internal/httpapi/shell.go create mode 100644 internal/httpapi/shell_test.go create mode 100644 internal/mail/mail_test.go 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{ + {"<title>verdande", "" + slug + ""}, + { + ``, + ``, + }, + } +} + +// manifestRebrands returns the replacements for manifest.webmanifest. The +// manifest's own description is a sentence rather than the shell's "name — sentence" +// form, so it is passed separately rather than derived. +func manifestRebrands(slug, description string) []rebrand { + return []rebrand{ + {`"name": "verdande"`, `"name": "` + slug + `"`}, + {`"short_name": "verdande"`, `"short_name": "` + slug + `"`}, + {`"description": "Opgaver og projekter, delt."`, `"description": "` + description + `"`}, + } +} + +// applyRebrands replaces every pair exactly once and fails on the first anchor it +// cannot find, naming it. Failing loudly rather than returning what it managed is +// the point: a partial rename is a page whose tab and whose install prompt disagree +// about which program this is. +func applyRebrands(raw []byte, pairs []rebrand) ([]byte, error) { + out := raw + for _, p := range pairs { + old := []byte(p.old) + if bytes.Count(out, old) != 1 { + return nil, fmt.Errorf("found %d occurrences of %q, want exactly 1", bytes.Count(out, old), p.old) + } + out = bytes.Replace(out, old, []byte(p.new), 1) + } + return out, nil +} + +// rebrandedAssets reads index.html and manifest.webmanifest out of the built +// frontend and returns them renamed for this edition. +// +// Both or neither: an instance serving a renamed shell beside the sibling's +// manifest installs under the wrong name on a phone, which is the half nobody looks +// at until it is on their home screen. +func rebrandedAssets(web fs.FS, slug, shellDesc, manifestDesc string) (shell, manifest []byte, err error) { + raw, err := fs.ReadFile(web, "index.html") + if err != nil { + return nil, nil, fmt.Errorf("index.html: %w", err) + } + shell, err = applyRebrands(raw, shellRebrands(slug, shellDesc)) + if err != nil { + return nil, nil, fmt.Errorf("index.html: %w", err) + } + + raw, err = fs.ReadFile(web, "manifest.webmanifest") + if err != nil { + return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err) + } + manifest, err = applyRebrands(raw, manifestRebrands(slug, manifestDesc)) + if err != nil { + return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err) + } + return shell, manifest, nil +} + +// The descriptions for the notes edition. Kept beside the replacements they feed +// rather than in the locale files, because these two strings are read by a browser +// and a home screen before any locale has been chosen — the shell is in Danish for +// the same reason the built app.html is. +const ( + notesShellDescription = "urd — noter, delt." + notesManifestDescription = "Noter, delt." +) + +// renamed returns the renamed bytes for `name`, or nil to serve the built file. +// +// Nil for everything in the full edition, which is what keeps that path exactly as +// it was: one comparison per request against a nil slice. +func (s *Server) renamed(name string) []byte { + switch name { + case "index.html": + return s.shell + case "manifest.webmanifest": + return s.manifest + } + return nil +} + +// contentTypeFor names the type for the two assets served from memory. +// +// http.ServeContent sniffs from the extension, and it gets .html right and +// .webmanifest wrong — Go's table does not have it, so the sniffer reads the first +// bytes, sees JSON-ish text and answers text/plain. A manifest served as text/plain +// is ignored by the browser without an error anywhere: the install prompt simply +// does not appear. +func contentTypeFor(name string) string { + if name == "manifest.webmanifest" { + return "application/manifest+json" + } + return "text/html; charset=utf-8" +} diff --git a/internal/httpapi/shell_test.go b/internal/httpapi/shell_test.go new file mode 100644 index 0000000..cd117f2 --- /dev/null +++ b/internal/httpapi/shell_test.go @@ -0,0 +1,235 @@ +package httpapi + +import ( + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/kristianwind/verdande/internal/config" + "github.com/kristianwind/verdande/internal/store" +) + +// serverWithWeb builds the real router around a frontend, which newTestServerWith +// cannot do — it passes nil for the web filesystem, because every other test here +// is about the API. The renaming only happens when there is something to rename. +func serverWithWeb(t *testing.T, edition string, web fs.FS) *Server { + 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() }) + + cfg := &config.Config{ + BaseURL: "http://localhost", + DataDir: t.TempDir(), + SessionTTL: 24 * time.Hour, + Edition: edition, + } + return New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), web) +} + +// The shell as the frontend build produces it, cut down to the parts that matter +// here: the two anchors that get renamed, and the inline theme script that must +// not be touched. TestAppHTMLStillCarriesTheAnchors below is what keeps this +// honest against the real file. +const builtShell = ` + + + + + verdande + + + + +` + +const builtManifest = `{ + "name": "verdande", + "short_name": "verdande", + "description": "Opgaver og projekter, delt.", + "start_url": "/" +} +` + +func builtWeb() fstest.MapFS { + return fstest.MapFS{ + "index.html": {Data: []byte(builtShell)}, + "manifest.webmanifest": {Data: []byte(builtManifest)}, + "_app/immutable/app.js": {Data: []byte("// app")}, + } +} + +// TestRenamingTheShellLeavesTheStorageKeysAlone is the one this file exists for. +// +// The product name and the localStorage prefix are the same word in app.html, and +// they are not the same thing: the prefix is read back by the app itself, so +// renaming it here and nowhere else sets the theme from one key and reads it from +// another. The symptom is the white flash on load that the inline script exists to +// prevent — which is to say, no error, nothing in a log, and nothing a test that +// merely checked the title would see. +func TestRenamingTheShellLeavesTheStorageKeysAlone(t *testing.T) { + shell, manifest, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.") + if err != nil { + t.Fatalf("rebrandedAssets: %v", err) + } + + got := string(shell) + for _, want := range []string{ + "urd", + `content="urd — noter, delt."`, + } { + if !strings.Contains(got, want) { + t.Errorf("the shell does not contain %q", want) + } + } + // Named values rather than "no occurrences of verdande": the three keys are + // what has to survive, so the assertion says the three keys. + for _, want := range []string{ + `localStorage.getItem('verdande:theme')`, + `localStorage.getItem('verdande:look')`, + `look !== 'verdande'`, + } { + if !strings.Contains(got, want) { + t.Errorf("renaming the shell changed a storage key: %q is gone", want) + } + } + if strings.Contains(got, "urd:theme") || strings.Contains(got, "urd:look") { + t.Error("renaming the shell rewrote a localStorage prefix; the app reads the verdande: ones") + } + + gotManifest := string(manifest) + for _, want := range []string{`"name": "urd"`, `"short_name": "urd"`, `"description": "Noter, delt."`} { + if !strings.Contains(gotManifest, want) { + t.Errorf("the manifest does not contain %q", want) + } + } +} + +// TestRenamingTheShellKeepsTheCSPHashValid pins the relationship between the two +// files that is easy to break and impossible to notice: the policy names a hash of +// the inline script, and the renamed shell is what gets served. If a replacement +// ever reached inside the -{t('done.title')} — verdande +{t('done.title')} — {app.productSlug}
diff --git a/web/src/routes/gruppe/[id]/+page.svelte b/web/src/routes/gruppe/[id]/+page.svelte index aa62d9b..f06e243 100644 --- a/web/src/routes/gruppe/[id]/+page.svelte +++ b/web/src/routes/gruppe/[id]/+page.svelte @@ -126,7 +126,7 @@ } -{group?.name ?? t('group.projects')} — verdande +{group?.name ?? t('group.projects')} — {app.productSlug}
{#if status === 'denied'} diff --git a/web/src/routes/indstillinger/+page.svelte b/web/src/routes/indstillinger/+page.svelte index 871b992..1e83892 100644 --- a/web/src/routes/indstillinger/+page.svelte +++ b/web/src/routes/indstillinger/+page.svelte @@ -379,7 +379,10 @@ - {t(option.name)} + + {t(option.name, { product: app.productName })} {t(option.note)} {/each} diff --git a/web/src/routes/invite/+page.svelte b/web/src/routes/invite/+page.svelte index b367ccc..ff77e23 100644 --- a/web/src/routes/invite/+page.svelte +++ b/web/src/routes/invite/+page.svelte @@ -12,8 +12,12 @@ * and it reads the token out of the URL itself. */ import SignIn from '$lib/components/SignIn.svelte'; + // Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()` + // har svaret, så udgaven er kendt — også uden en session, hvilket er hele + // pointen med den her rute. + import { app } from '$lib/stores.svelte.js'; -Invitation · verdande +Invitation · {app.productSlug} diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 837cfac..78be862 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -933,7 +933,7 @@ -{t('notes.title')} · verdande +{t('notes.title')} · {app.productSlug} -Ny adgangskode · verdande +Ny adgangskode · {app.productSlug}