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{ + {"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}