Compare commits

..
Author SHA1 Message Date
Kristian Wind 807aa83e58 Merge pull request #99 from kristianwind/urd-faar-sin-egen-doer
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Successful in 15m49s
CI / Docker image builds (push) Successful in 2m55s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 26m49s
feat(urd): den anden dør — /urd kan installeres som sin egen app
2026-10-07 10:42:20 +02:00
kristianwindandClaude Opus 5 9199709aa5 feat(urd): den anden dør — /urd kan installeres som sin egen app
Én instans, én database, ét login — og to ting, der kan installeres.

Et styresystem installerer ikke en URL, det installerer et MANIFEST, og en
installeret apps identitet er manifestets `id`. To manifester på samme
origin med forskellige id'er er to apps; to adresser, der deler ét manifest,
er én app med et bogmærke. Det er hele mekanismen.

  /                     → verdande, ᚹ, /manifest.webmanifest, id "/"
  /urd                  → urd, ð, /urd.webmanifest, id "/urd"

Skallen serveres per dør frem for at blive rettet af JavaScript bagefter,
fordi iOS læser sidens <head> ved "Føj til hjemmeskærm" — og Safari leder
efter apple-touch-icon VED NAVN og har historisk ignoreret manifestets
ikoner helt. Et <head> rettet efter indlæsning er et <head>, der måske ikke
er det, iOS læste.

urds manifest bygges AF verdandes frem for at stå ved siden af det, så de
felter, de faktisk deler — farverne, visningstilstanden, sproget — kommer ét
sted fra og ikke kan drive fra hinanden. Seks nøgler ændres: id, start_url,
scope, de to navne og ikonerne. Et ikon i det byggede manifest, som urd
ikke har en fil til, stopper serveren frem for at gå igennem: symptomet
ville ellers være, at urds installationsprompt stille viste verdandes mærke.

`scope` skrives ud, selvom "/" er hvad specifikationen ville udlede af en
start_url på "/urd". En app, hvis scope ikke dækkede hele origin, ville åbne
sine egne links i en browserfane, første gang nogen navigerede ud af /urd —
og det er en regel, ingen skal behøve at huske, mens de læser koden.

## Ansigtet er en præference, ikke en port

`urdFace` holdes skarpt adskilt fra `notesOnly`, og det er den vigtigste
linje i ændringen:

  notesOnly — serveren HAR ingen opgaveruter. En vagt: hent dem ikke, de
              svarer 404.
  urdFace   — vinduet kom ind ad urd-døren. En præference: ruterne er der,
              dataene er personens egne, kun navnet er et andet.

Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
notesbog ville holde op med at vise sine opgaver, fordi nogen valgte en
anden indgang, og så er det den separate instans i forklædning. En prøve
påstår det modsatte direkte: urd-vinduet får stadig 200 på /api/v1/tasks.

Ansigtet ligger i sessionStorage og ikke localStorage, og det er dét, der
lader de to apps være to apps: de deler origin og dermed localStorage,
cookies og service worker. sessionStorage er per vindue, og en installeret
PWA er sit eget vindue. Det overlever en genindlæsning, hvilket det skal —
efter første navigation står adressen på /noter, og ingen går gennem /urd
igen.

## Undervejs

contentTypeFor slog op på det eksakte navn "manifest.webmanifest", så den
anden dørs manifest blev serveret som HTML i fire minutter. Intet ville have
meldt det: et manifest med forkert type ignoreres af browseren uden fejl, og
det eneste symptom er en installationsknap, der ikke dukker op. Læser nu
endelsen.

Og drift-kontrollen mod app.html dækkede kun udgavens to ankre, ikke dørens
fem — hvilket er grunden til, at dørens fikstur manglede to <link>-linjer,
den rigtige fil har. Fanget alene fordi applyRebrands nægter et anker, den
ikke kan finde. Kontrollen dækker nu begge sæt.

Plantet: et manifest uden `id` giver "the urd manifest has id = <nil>, want
/urd" — altså den værdi, hele designet hviler på.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 10:32:25 +02:00
Kristian Wind 1d15cb18c0 Merge pull request #98 from kristianwind/urd-faar-sit-maerke
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m51s
CI / Docker image builds (push) Successful in 3m16s
CI / Go (push) Failing after 27m5s
feat(urd): mærket bliver ð, og frontend-bygget bliver deterministisk
2026-10-07 10:20:28 +02:00
kristianwindandClaude Opus 5 ff988d26bb feat(urd): mærket bliver ð, og frontend-bygget bliver deterministisk
## Mærket

Urds oplagte mærke var ᚢ uruz — bogstavet navnet begynder med i den ældre
futhark, præcis som Verdandes er ᚹ wunjo. Det blev tegnet sådan først.

Men uruz er allerede et af husets egne produkter, og dets egen
`scripts/gen-icons.ts` siger i sin kommentar at den "draws the Uruz rune (ᚢ)
in rune-gold" med "a tall left vertical, a shoulder across the top, and a
shorter" — altså samme glyf, samme konstruktion, kun en anden farve. To af
samme persons apps side om side på en hjemmeskærm, hvilket er nøjagtig det
sted mærket skal kunne skelnes.

ᚦ thurisaz blev forkastet et niveau nede af samme grund: en stav med en
trekant til højre, og det er ᚹ også. Ved 16px er de én form.

Så bogstavet frem for runen: ð, ethet i Urðr. Centreret ved at tælle malede
pixels frem for ud fra path-tallene, fordi en centreret kasse ikke er en
centreret glyf:

  verdande ᚹ   blæk x  53–108  midte 80.5  ·  y  18–131  midte 74.5
  urd      ð   blæk x  39–110  midte 74.5  ·  y  15–134  midte 74.5

Verdandes +5.5 i x er dens egen bevidste forskydning — en tung stav ved
siden af et let flag skal sidde til højre for midten for at se centreret ud.
ð er symmetrisk nok til at undvære den, og de to er enige i y.

Læsbarhed, målt: ð læses fra 32px og op. Ved 16px er det en klat, og det er
ᚹ også — en favicon er ikke det, der skiller to installerede apps i en dock.

`productRune` hedder nu `productMark`, fordi ð ikke er en rune.

## Determinisme

SvelteKits `kit.version.name` er som standard `Date.now()`. Millisekundet
landede i version.json, i index.html og i service-workeren, ændrede
indholdshashen på hver chunk der refererer dem, og dermed deres filnavne. To
bygninger af samme commit havde forskellige filer i sig.

Dockerfilen har i et år sagt at `-trimpath` og en tom buildid "keep the
output reproducible". Flagene gjorde deres arbejde; frontenden i binæren bar
et tidsstempel. Sætningen var falsk, og ingen læste den.

Fastgjort til VERSION, som udgivelsen allerede sender ind, og `dev` når
intet sætter den. Målt ved at bygge to gange og diffe træet, før og efter.
Prisen er at et lokalt `npm run build` ikke længere ligner en ny version for
service-workeren — hvilket kun betyder noget på en udrullet instans, hvor
udgivelsen sender det rigtige mærkat.

Det gør også CI's nye påstand mulig: de to images deler alle lag, så urd
bærer samme binær som verdande frem for en genbygning der tilfældigvis
matcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 10:10:30 +02:00
Kristian Wind b451fd585d Merge pull request #97 from kristianwind/porten-daekker-eksporten
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m55s
CI / Docker image builds (push) Successful in 3m17s
CI / Go (push) Failing after 27m10s
fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene
2026-10-07 02:11:49 +02:00
kristianwindandClaude Opus 5 8c3aa33f13 fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene
Prøven, der skal holde udgaverne adskilt, stod grøn gennem to merges, mens
34 opgaveruter blev betjent af noter-udgaven. Den fejlede ikke: den
sammenligner routeren med en nedskreven liste, og listen var skrevet ud
fra de ruter, nogen havde tænkt på. Den er en DRIFT-kontrol, og den kan
ikke være en fuldstændighedskontrol. Det står nu i prøven selv.

Lukket: CalDAV-træet (8 ruter, alle læser eller skriver en VTODO),
.well-known/caldav (en påstand om at der er en CalDAV-server), ICS-feedet
og de to ruter til dets token, de fire opgave-eksporter, Todoist- og
CSV-importen, skabelonerne, sektionerne, de seks AI-opgaveruter og
indbakke-krogen. Plus fem af ni MCP-værktøjer — endepunktet er der
stadig, værktøjslisten er kortere.

Tre blev afgjort ved at læse frem for ved navnet: en skabelon laver
opgaver (CreateProjectFromTemplate), `section_id` står på `tasks` og
findes ikke på `notes`, og add_comment hænger på en opgave.

Men den dyre halvdel var fladen. En vandring gennem hver side, der kan
nås i noter-udgaven, med konsollen og svarene aflæst, fandt SYV brud —
fire af dem fra den første port:

  sidebjælken      GET /filters  404 ved hver navigation, ædt af .catch()
  sidebjælken      GET /labels   404 ved hver navigation, ædt af .catch()
  notifikationer   GET /ai/plan  404, stille
  notesbogens side GET /tasks    kastede → status = 'failed'
  notesbogens side GET /sections kastede → samme fejlskærm
  indstillinger    GET /templates  toast med fejlbesked på skærmen
  integrationer    GET /feed     404, stille

Den fjerde er den, der har kostet noget: /projekt/{id} er notesbogens
side, og kaldene samles i et Promise.all, så én 404 tog hele siden med.
Den har vist en fejlskærm frem for noter siden porten blev sat. Ingen
prøve åbnede en notesbog i den udgave, og .catch(() => {}) sørgede for,
at resten ikke sagde noget. "Fladen følger udgaven" var sandt om menuen
og falsk om hentningerne.

Vagten for opgaver står i butikken frem for ved kaldestederne: der var
tre, og den fjerde blev glemt — hvilket er præcis sådan, den slap
igennem.

Og rettelsen lavede selv en fejl, som vandringen fangede: da opgavekaldet
holdt op med at fejle, kom en uendelig effekt-løkke frem, som 404'eren
havde holdt låg på. En ubetinget tilskrivning til app.tasks får
projektsidens effekt til at kalde tilbage. 404'eren var låget på en
løkke, ikke bare støj.

Også: -timeout 20m i begge CI-workflows. internal/httpapi tog 664s her og
498s i CI på #95, mod Go's standardgrænse på 600 — og når den krydses, er
beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, som
læser som en deadlock i koden frem for som en suite, der blev for stor.

watchForTrouble udskilt til web/e2e/trouble.js, så udgave-suiten kan bruge
den samme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 02:03:40 +02:00
Kristian Wind c6c7979d1a Merge pull request #96 from kristianwind/urd-faar-sit-navn
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 5m49s
CI / Docker image builds (push) Successful in 3m9s
CI / Go (push) Failing after 16m57s
feat(udgaver): urd — noter-udgaven siger sit eget navn
2026-10-07 01:33:53 +02:00
kristianwindandClaude Opus 5 6154e6b247 feat(udgaver): urd — noter-udgaven siger sit eget navn
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 <title>, 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>
2026-10-07 01:11:25 +02:00
Kristian Wind 7e3d300183 Merge pull request #95 from kristianwind/fladen-foelger-udgaven
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 15m10s
CI / Go (push) Failing after 16m56s
CI / Docker image builds (push) Successful in 2m40s
feat(udgaver): fladen følger udgaven, og søgningen holder opgaverne inde
2026-10-07 00:23:16 +02:00
kristianwindandClaude Opus 5 3cee9bfb6e feat(udgaver): fladen følger udgaven, og søgningen holder opgaverne inde
Trin 2. Serveren kunne allerede lade være med at montere opgaveruterne;
fladen tilbød dem stadig, så en noter-instans havde links til sider, der
ikke fandtes.

--- hvordan fladen får det at vide ---

`/auth/setup` bærer nu `edition`. Dér og ikke på `/auth/me`: udgaven er en
egenskab ved instansen, ikke ved den, der er logget ind, og fladen skal
kende den, før nogen er. Det er i forvejen kaldet, der spørger "hvad er
det her for en instans", så den følger med uden en ekstra tur.

Butikken henter formen ved siden af kontoen og tier, hvis den fejler: en
401 fra `me` må ikke tage udgaven med sig — login-skærmen er også en flade
i en udgave. Afventet før `loading` slippes, så fladen aldrig tegnes, før
den ved hvilket program den er; tegnet først og rettet bagefter ville vise
opgaver i et sekund på en instans, der ikke har nogen.

`full` som udgangspunkt, fordi det er det, programmet altid har været.

--- hvad der forsvinder ---

Menuens opgaveindgange er udeladt, ikke gråtonet. Forsiden er noterne. En
gemt adresse til /upcoming, /kalender, /uddelegeret, /faerdige, /opgave,
/filter eller /etiket fører til noterne frem for en blindgyde — der er
ikke sket noget forkert, adressen hører bare til et andet program end det,
der kører her. Listen står ét sted i layoutet frem for som en vagt pr.
rute: listen ER beslutningen, og en vagt pr. fil er syv steder at glemme
den ottende.

--- et hul, prøven fandt ---

/search er monteret i begge udgaver, fordi noter og notesbøger skal kunne
findes — og den returnerede opgaver. Ikke teoretisk: en instans lavet om
fra en fuld har opgaverne liggende i databasen endnu, så ⌘K ville være den
ene dør, der stod åben ind til den halvdel, resten af programmet lige
havde lukket. Lukket på serveren, hvor det hører hjemme, og ⌘K spørger
også selv — en tom liste af den rigtige grund og en tom liste ved et
tilfælde ser ens ud.

Prøven har en kontrol: den fulde udgave SKAL finde opgaven, ellers beviser
nullet i noter-udgaven ingenting.

--- prøverne ---

Et nyt Playwright-projekt med sin egen server, sin egen port og sin egen
database. Ingen lånt session: den instans har aldrig set den konto, den
fulde udgaves opsætning lavede. En skakt, der kun er prøvet ved at kalde en
funktion, er prøvet på det, nogen mente — det her måler programmet, som en
bruger møder det.

Seks prøver, heriblandt forskellen på skjult og fraværende målt udefra:
/api/v1/tasks, /labels, /upcoming og /filters skal svare 404, og
/api/v1/notes skal svare 200 — ellers ville de fire bestå på en server,
der bare var væk.

`testIgnore` på chromium-projektet, fordi filen ellers også kørte dér, mod
den fulde udgave, hvor den fejler med beskeder der læser som om skakten
ikke virker. Den rigtige fejl stod i projektnavnet, ikke i påstanden.

Go-suiten grøn. chromium + notes-edition: 110 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 23:14:00 +02:00
Kristian Wind 9b067f7057 Merge pull request #94 from kristianwind/to-udgaver-af-samme-program
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m50s
CI / Docker image builds (push) Successful in 2m36s
CI / Go (push) Failing after 16m59s
feat(udgaver): VERDANDE_EDITION afgør, om instansen er et opgaveprogram
2026-10-06 22:54:07 +02:00
54 changed files with 2748 additions and 248 deletions
+21 -1
View File
@@ -97,7 +97,13 @@ jobs:
# -race is the whole point of running tests in CI for a server with a
# WebSocket fan-out: a data race is exactly the bug that passes locally.
- name: Test
run: go test -race -count=1 ./...
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
# det læser som en deadlock i koden frem for som en suite, der blev for
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
# "noget hænger" i stedet for "maskinen var langsom i dag".
run: go test -race -count=1 -timeout 20m ./...
- name: Build
run: CGO_ENABLED=0 go build ./...
@@ -271,3 +277,17 @@ jobs:
set -eu
docker buildx create --use --name ci-verdande 2>/dev/null || docker buildx use ci-verdande
docker buildx build --platform linux/amd64 --output type=cacheonly .
# Begge udgaver, fordi EDITION rører den sidste stage: en Dockerfile, der
# ikke længere bygger med den, skal fejle her og ikke på et mærkat.
docker buildx build --platform linux/amd64 --build-arg EDITION=notes --output type=cacheonly .
# Hvad der IKKE er her, og hvorfor, så den næste læser ikke tror det er glemt:
# GitHubs ci.yml har et trin, der henter begge images ind i daemonen og
# påstår, at de deler alle lag — altså at verdande og urd bærer samme binær.
# Det kræver `--load` to gange, og det er et halvt minut per image på en
# runner, der deles med Yggdrasils produktionscontainere og allerede har
# dræbt en kørsel på hukommelse (exit 137). Påstanden er den samme kode
# begge steder, så det er nok at evaluere den ét sted — og GitHub er
# autoritativ for de offentlige projekter.
#
# Det, der SKAL være her, er ovenstående: at begge varianter kan bygges.
+57 -1
View File
@@ -40,7 +40,13 @@ jobs:
# -race is the whole point of running tests in CI for a server with a
# WebSocket fan-out: a data race is exactly the bug that passes locally.
- name: Test
run: go test -race -count=1 ./...
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
# det læser som en deadlock i koden frem for som en suite, der blev for
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
# "noget hænger" i stedet for "maskinen var langsom i dag".
run: go test -race -count=1 -timeout 20m ./...
- name: Build
run: CGO_ENABLED=0 go build ./...
@@ -117,9 +123,59 @@ jobs:
# Build only — publishing happens on a tag, in release.yml. This job exists
# so a Dockerfile that no longer builds fails on the pull request rather
# than at release time.
#
# Loaded into the local daemon rather than discarded, because the step below
# has to inspect what came out.
- uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: verdande:ci
cache-from: type=gha
cache-to: type=gha,mode=max
- uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: urd:ci
build-args: EDITION=notes
cache-from: type=gha
cache-to: type=gha,mode=max
# The claim this repository makes about urd is that it is the same program,
# not a fork — and that claim is checkable rather than a sentence in a README.
#
# Stages 1 and 2 are identical for both builds, and the edition is set with
# ENV, which is image *config* rather than a layer. So the two images must
# have the same layer digests and differ only in that one variable. A change
# that made them diverge — a second COPY, a conditional RUN, a separate build
# of the binary — would quietly turn "one source" into two, and the only
# symptom would be a bug fixed in one product and not the other.
#
# This was impossible until the frontend build became deterministic: SvelteKit
# put Date.now() in version.json, so no two builds ever matched. See the note
# in web/svelte.config.js.
- name: The two images are one build
run: |
set -eu
layers() { docker image inspect "$1" --format '{{json .RootFS.Layers}}'; }
if [ "$(layers verdande:ci)" != "$(layers urd:ci)" ]; then
echo "The verdande and urd images do not share their layers:"
layers verdande:ci
layers urd:ci
echo "They are built from one source, so this is either a Dockerfile that"
echo "now builds the binary twice, or a stage that is no longer shared."
exit 1
fi
echo "Shared layers: $(layers verdande:ci | tr ',' '\n' | wc -l)"
# And the half that stops the check above from passing over two identical
# images: they must actually differ in the one way they are supposed to.
edition() { docker image inspect "$1" --format '{{json .Config.Env}}' | tr ',' '\n' | grep VERDANDE_EDITION; }
echo "verdande: $(edition verdande:ci)"
echo "urd: $(edition urd:ci)"
edition verdande:ci | grep -q 'VERDANDE_EDITION=full'
edition urd:ci | grep -q 'VERDANDE_EDITION=notes'
+5
View File
@@ -44,6 +44,11 @@ node_modules.nosync/
# End-to-end runs: the throwaway instance the suite starts, the saved session it
# signs in with, and whatever a failure left behind.
/web/.playwright-data/
# Noter-udgavens egen instans i prøvekørslen. Den har sin egen database OG sin
# egen secret.key, og den nåede at blive committet én gang: et mønster, der
# nævner den ene mappe ved navn, dækker ikke den anden, selv om de er to kopier
# af det samme.
/web/.playwright-data-notes/
/web/e2e/.auth/
/web/test-results/
/web/playwright-report/
+35 -1
View File
@@ -20,6 +20,14 @@ FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src
COPY web/ ./
# Passed into the frontend build, because SvelteKit's default build version is
# `Date.now()` — a millisecond that landed in version.json, in index.html and in
# the service worker, changed the content hash of every chunk referencing them, and
# so changed those chunks' filenames. Every build of one commit produced different
# bytes. See the long note in web/svelte.config.js.
ARG VERSION=dev
ENV VERDANDE_VERSION=${VERSION}
# The frontend is built when it is present. During backend-only development it is
# not, and the build must still produce a working image rather than failing on a
# missing directory — so this stage falls back to a placeholder page. The Go stage
@@ -58,6 +66,12 @@ ARG TARGETARCH=amd64
# CGO off is what makes this binary static, and it is only possible because the
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
#
# "Reproducible" was false for a year and nobody read the sentence that said it:
# the flags above do their job, and the frontend embedded below carried a
# millisecond timestamp, so every build of one commit produced a different binary.
# Fixed in web/svelte.config.js, and CI now asserts that the two images' layers
# match rather than trusting this comment.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-tags embedweb \
-trimpath \
@@ -65,8 +79,27 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-o /verdande ./cmd/verdande
# --- 3. what actually ships --------------------------------------------------
#
# Two images come out of this stage, and the only difference between them is one
# environment variable:
#
# docker build . → verdande
# docker build --build-arg EDITION=notes → urd
#
# Stages 1 and 2 are byte-identical for both, so the binary in the two images is
# the same build rather than a rebuild that happens to match. That is the point:
# urd is not a fork, and "built from the same source" is a property of the layers
# instead of a sentence in a README. ENV is image *config* rather than a layer, so
# the two images share every layer digest and differ only in that config — which CI
# asserts on every pull request.
FROM gcr.io/distroless/static-debian12:nonroot
# Declared here as well as used here: an ARG is scoped to the stage it appears in,
# so one above the FROM above would be invisible down here. `full` by default,
# because that is what this program has always been and an unqualified build must
# not quietly produce the other product.
ARG EDITION=full
# SQLite writes timestamps and verdande resolves due dates in the user's timezone,
# so the container needs a tz database and CA certificates for outbound SMTP.
COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip
@@ -78,7 +111,8 @@ COPY --from=build /verdande /verdande
# nightly backups.
VOLUME ["/data"]
ENV VERDANDE_DATA_DIR=/data \
VERDANDE_ADDR=:8080
VERDANDE_ADDR=:8080 \
VERDANDE_EDITION=${EDITION}
EXPOSE 8080
USER nonroot:nonroot
+17
View File
@@ -15,6 +15,23 @@
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 — 63 routes and
five of nine MCP tools. See
[Two editions](docs/editions.md).
## Status
All six phases are built. See [What works today](#what-works-today) for what is
+6 -1
View File
@@ -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
+4 -2
View File
@@ -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.
+1
View File
@@ -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
+205
View File
@@ -0,0 +1,205 @@
# 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 | 137 |
| 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.
## Running the notes edition
There is one image. The edition is an environment variable on the container:
```
docker run -e VERDANDE_EDITION=notes -v urd-data:/data -p 8080:8080 \
ghcr.io/kristianwind/verdande:latest
```
No second image and no rune of its own, deliberately.
The edition is for an operator who wants an instance that genuinely has no task
routes — the routes are absent from the router, not hidden in the interface. It is
**not** how urd is normally reached. That is the second door, below.
Two details that surprise people who look inside:
- **The binary is called `/verdande`** and the database file is `verdande.db`, in
both editions. Not an oversight: it is the same binary, and renaming the file per
edition would be a migration hazard in exchange for nothing a user sees.
- **The icons for both editions ship in the one image.** The server decides which
to serve at `/icon.svg`, because Safari looks for `apple-touch-icon.png` by name
and never reads the manifest, so renaming paths would have left iOS with the
wrong mark.
## The second door: two apps, one instance
urd is normally reached at **`/urd`** on an ordinary verdande. One instance, one
database, one login, one set of links between notes and tasks — and two things you
can install.
| | verdande | urd |
|---|---|---|
| entry | `/` | `/urd` |
| manifest | `/manifest.webmanifest` | `/urd.webmanifest` |
| manifest `id` | `/` | `/urd` |
| tab and mark | verdande, ᚹ | urd, ð |
| database, account, login | the same | the same |
An operating system does not install a URL, it installs a **manifest**, and the
identity of an installed app is that manifest's `id`. Two manifests on one origin
with different ids are two apps; two URLs sharing one manifest are one app with a
bookmark. That is the whole mechanism.
The shell is served per door rather than patched by JavaScript after the page
loads, because iOS reads the page's `<head>` when somebody taps Add to Home Screen
— and Safari looks for `apple-touch-icon` **by name**, having historically ignored
the manifest's icons altogether.
urd's manifest is built from verdande's rather than written beside it, so the
fields the two genuinely share — the colours, the display mode, the language —
come from one place and cannot drift. Six keys are changed: `id`, `start_url`,
`scope`, the two names and the icons. An icon in the built manifest that urd has no
file for stops the server rather than being passed through, because the symptom
would otherwise be urd's install prompt quietly showing Verdande's mark.
### The face is a preference, not a gate
Entering at `/urd` makes that **window** wear urd's face: its mark, its name, its
tab. It is kept strictly apart from the notes edition in the code, and the
distinction is the one thing worth reading twice:
| | what it means | what follows |
|---|---|---|
| `notesOnly` | the server has no task routes | a **guard** — the interface must not fetch them, they answer 404 |
| `urdFace` | this window came in through the urd door | a **preference** — the routes are there, the data is yours, only the name differs |
Conflate them and the urd face starts hiding data rather than chrome: a notebook
would stop showing its tasks because somebody chose a different entrance. A test
asserts the opposite directly — the urd window still gets 200 from
`/api/v1/tasks`.
The face lives in `sessionStorage`, not `localStorage`, and that is what lets the
two apps be two apps: they share an origin, so they share localStorage, cookies and
the service worker. `sessionStorage` is per window, and an installed PWA is its own
window. It survives a reload, which it must — after the first navigation the
address is `/noter` and nobody passes through `/urd` again.
!!! note "One thing the two doors cannot have separately"
Theme, typeface and text size are shared, because they are in the localStorage
the origin shares. That was a deliberate choice: splitting them per face would
mean migrating everybody's stored settings for a difference few people want.
## Moving between the two, measured
A `/data` directory moves between the editions in **both directions, losslessly**.
Same binary, same schema, same migrations — so this is not a feature that was built,
it is a property that was checked:
| step | result |
|---|---|
| verdande creates a notebook, a note in it, and a task | 1 note, 1 task |
| the same directory, `VERDANDE_EDITION=notes` | `needs_setup: false` — same account. The note keeps its title, its body **and its `project_id`**. `/api/v1/tasks` → 404 |
| the same directory, back to `full` | the task is still there; so is the note |
The ownership stayed at the panel's service account through all three.
So the way to move notes is to change the variable. `GET /export/notes.zip` is for
getting notes **into other programs** — it writes one `.md` per note and carries the
body and nothing else: no notebook, no sharing, no attachments, no pinned flag. That
is the right division of labour, because the database already holds those.
!!! note "The task rows stay"
An instance run as `notes` still has its tasks in the database; they are simply
not served. Harmless on a fresh instance, dead weight on a converted one — and
worth knowing before handing a converted instance to somebody else.
## 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 63 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 63 withheld routes:
| | what |
|---|---|
| 29 | tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views |
| 8 | the CalDAV server — every route in it reads or writes a VTODO |
| 1 | `/.well-known/caldav`, which is a claim that a CalDAV server is here |
| 1 | the ICS calendar feed, plus 2 for the token it is reached with |
| 4 | the task exports: `tasks.ics`, `projects.zip`, and a project's `.csv` and `.ics` |
| 2 | the Todoist and CSV imports, which create tasks |
| 4 | project templates — a template is a project plus sections plus tasks |
| 5 | sections, which group tasks: `section_id` is a column on `tasks` and not on `notes` |
| 6 | the AI task features: split, tidy the inbox, and the day's plan |
| 1 | the inbox hook, which pushes a line of text in as a task |
Plus five of the nine MCP tools — `search_tasks`, `create_task`, `update_task`,
`complete_task` and `add_comment`. The endpoint is still there; the tool list is
shorter. A tool a model can call is a surface, and one that answers "there are no
tasks" is worse than one that does not exist: the model tries it, gets an empty
answer, and tells the person there is nothing to do.
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 and task tools
- AI settings, `ask`, and the note actions
!!! warning "Still being decided"
Four connectors are reachable in urd today and have not been settled either
way, because each has a plausible notes reading and withholding them would be
deciding what this product is rather than closing a hole: the mail-in address,
IMAP mailboxes, Gmail, and Google Calendar. They currently file what they
receive as **tasks**, so in urd they connect and then have nowhere to put
anything. The same applies to one handler rather than a route:
`POST /api/v1/ai/notes/{noteID}/actions/apply` creates tasks from a note.
## 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.
+11 -1
View File
@@ -53,7 +53,7 @@ paths:
/api/v1/auth/setup:
get:
tags: [auth]
summary: Whether this instance still needs its first account.
summary: What this instance is, and whether it still needs its first account.
security: []
responses:
"200":
@@ -64,6 +64,16 @@ paths:
type: object
properties:
needs_setup: { type: boolean }
edition:
type: string
enum: [full, notes]
description: >
`full` is tasks, projects and notes together. `notes` is
the notes half: the task routes are not mounted at all, so
an instance that is not a task manager cannot be asked to
be one through its API. The interface reads this before
anybody is signed in, because it decides what there is to
show rather than what this person may see.
post:
tags: [auth]
summary: Create the first account, which becomes the administrator.
+32
View File
@@ -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"
}
+8 -1
View File
@@ -492,7 +492,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "count users", err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
// Udgaven hører til her og ikke på /auth/me: den er en egenskab ved
// instansen, ikke ved den, der er logget ind, og fladen skal kende den, før
// nogen er. Det her er i forvejen kaldet, der spørger "hvad er det her for en
// instans", så den følger med uden en ekstra tur.
writeJSON(w, http.StatusOK, struct {
NeedsSetup bool `json:"needs_setup"`
Edition string `json:"edition"`
}{n == 0, s.edition()})
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
+11
View File
@@ -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) {
+16
View File
@@ -0,0 +1,16 @@
package httpapi
import "github.com/kristianwind/verdande/internal/config"
// edition is what this instance tells the world it is.
//
// Never the empty string, even when the config was built by hand in a test and
// never went through config.Load: a flade that reads "" cannot tell "this is the
// full program" from "this build forgot to say", and the first of those is the
// answer that has always been true.
func (s *Server) edition() string {
if s.cfg.NotesOnly() {
return config.EditionNotes
}
return config.EditionFull
}
+215
View File
@@ -1,6 +1,8 @@
package httpapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
@@ -35,28 +37,69 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
// 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.
//
// And note what this list could NOT catch, because it is the shape of mistake
// that outlived the first version of this test. The list went green at 29
// routes for two merges while CalDAV, the ICS feed, the inbox hook, four task
// exports, both task imports, the project templates, the sections and six AI
// task endpoints were all still served by the notes edition — because the test
// compares the router against THIS LIST, and the list was written from the
// routes somebody had thought of. It is a drift check, and it cannot be an
// is-it-complete check.
//
// So when a route is added that one edition should not have, the question to
// ask is not "does the walk still pass" but "can this endpoint do anything at
// all without the half it is being withheld from". Three of the ones above were
// settled by reading rather than by the name: a template turned out to create
// tasks (CreateProjectFromTemplate), `section_id` turned out to exist on
// `tasks` and not on `notes`, and add_comment turned out to hang off a task.
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/sections/{sectionID}",
"DELETE /api/v1/tasks/{taskID}",
"DELETE /api/v1/templates/{templateID}",
"DELETE /caldav/{userID}/{projectID}/{taskFile}",
"GET /.well-known/caldav",
"GET /api/v1/ai/plan",
"GET /api/v1/delegated",
"GET /api/v1/export/projects.zip",
"GET /api/v1/export/projects/{projectID}.csv",
"GET /api/v1/export/projects/{projectID}.ics",
"GET /api/v1/export/tasks.ics",
"GET /api/v1/feed",
"GET /api/v1/filters",
"GET /api/v1/filters/preview",
"GET /api/v1/filters/{filterID}/tasks",
"GET /api/v1/labels",
"GET /api/v1/projects/{projectID}/sections",
"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/templates",
"GET /api/v1/upcoming",
"GET /caldav/{userID}/{projectID}/{taskFile}",
"GET /ics/{token}",
"OPTIONS /caldav/*",
"PATCH /api/v1/filters/{filterID}",
"PATCH /api/v1/labels/{labelID}",
"PATCH /api/v1/sections/{sectionID}",
"PATCH /api/v1/tasks/{taskID}",
"POST /api/v1/ai/inbox/tidy",
"POST /api/v1/ai/inbox/tidy/apply",
"POST /api/v1/ai/plan/now",
"POST /api/v1/ai/tasks/{taskID}/split",
"POST /api/v1/feed/rotate",
"POST /api/v1/filters",
"POST /api/v1/import/csv",
"POST /api/v1/import/todoist",
"POST /api/v1/labels",
"POST /api/v1/projects/{projectID}/sections",
"POST /api/v1/projects/{projectID}/sections/reorder",
"POST /api/v1/tasks",
"POST /api/v1/tasks/quick-add",
"POST /api/v1/tasks/{taskID}/attachments",
@@ -66,6 +109,15 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
"POST /api/v1/tasks/{taskID}/reminders",
"POST /api/v1/tasks/{taskID}/reopen",
"POST /api/v1/tasks/{taskID}/snooze",
"POST /api/v1/templates",
"POST /api/v1/templates/{templateID}/use",
"POST /inbound/hook/{token}",
"PROPFIND /caldav",
"PROPFIND /caldav/{userID}",
"PROPFIND /caldav/{userID}/{projectID}",
"PUT /api/v1/ai/plan",
"PUT /caldav/{userID}/{projectID}/{taskFile}",
"REPORT /caldav/{userID}/{projectID}",
} {
withheld[r] = true
}
@@ -114,10 +166,25 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
for _, must := range []string{
"GET /api/v1/notes",
"POST /api/v1/notes",
"PATCH /api/v1/notes/{noteID}",
"DELETE /api/v1/notes/{noteID}",
// Deling er hele grunden til at det er et selvstændigt produkt frem for en
// enkeltbrugers notesblok, så den halvdel skal navngives her.
"POST /api/v1/notes/{noteID}/shares",
"GET /api/v1/notes/{noteID}/shares",
"POST /api/v1/notes/{noteID}/attachments",
"POST /api/v1/notes/import",
"GET /api/v1/export/notes.zip",
"GET /api/v1/export/account",
"POST /api/v1/ai/notes/{noteID}/actions",
"GET /api/v1/projects", // a notebook, in this edition
"GET /api/v1/search",
"GET /api/v1/auth/me",
// 34 ruter blev lukket i den anden omgang af denne port. Den her linje er
// den, der afgør, at det var en port: MCP-endepunktet er stadig der, og det
// er værktøjslisten bagved, der er kortere.
"POST /api/v1/mcp",
"POST /mcp",
} {
if !notes[must] {
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
@@ -158,3 +225,151 @@ func routesOf(t *testing.T, edition string) map[string]bool {
}
return out
}
// The interface cannot gate itself on something it is not told, and it has to be
// told before anybody is signed in — an instance's shape is not a permission.
func TestSetupStateSaysWhichEdition(t *testing.T) {
for _, tc := range []struct{ edition, want string }{
{config.EditionFull, "full"},
{config.EditionNotes, "notes"},
{"", "full"}, // a config built by hand is the program as it has always been
} {
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
_, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
if got := body["edition"]; got != tc.want {
t.Errorf("edition %q reported as %v, want %q", tc.edition, got, tc.want)
}
// And the field it has always had is still there: adding one must not
// quietly replace the answer the sign-in screen depends on.
if _, ok := body["needs_setup"]; !ok {
t.Errorf("edition %q: needs_setup is gone from the answer", tc.edition)
}
}
}
// Search is mounted in both editions — notes and notebooks have to be findable —
// so it is the one door that could still open onto the half the router just shut.
//
// Not a theoretical case. An instance converted from a full one still has the
// tasks in its database, and ⌘K would be the only place they turned up.
func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
for _, tc := range []struct {
edition string
want int
}{
{config.EditionFull, 1},
{config.EditionNotes, 0},
} {
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
ts.bootstrap(t)
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
userID, _ := me["id"].(string)
if userID == "" {
t.Fatalf("no user: %v", me)
}
// Created through the store rather than the API: in the notes edition there
// is no route to create one, and the row this is about is the row that is
// already there.
inbox, err := ts.db.InboxID(context.Background(), userID)
if err != nil {
t.Fatal(err)
}
task := &store.Task{ProjectID: inbox, Content: "Kvartalsregnskabet", CreatedBy: userID}
if err := ts.db.CreateTask(context.Background(), task, nil); err != nil {
t.Fatal(err)
}
_, body := ts.do(t, "GET", "/api/v1/search?q=Kvartalsregnskabet", nil)
found, _ := body["tasks"].([]any)
if len(found) != tc.want {
t.Errorf("edition %q: search returned %d tasks, want %d", tc.edition, len(found), tc.want)
}
// The full edition's number is the control: a search that finds nothing at
// all would satisfy the notes case for the wrong reason.
if tc.edition == config.EditionFull && len(found) == 0 {
t.Errorf("the full edition found no tasks either — this proves nothing about the notes one")
}
}
}
// TestNotesEditionOffersNoTaskToolsOverMCP.
//
// The same hole as the routes, through a different door — and it was still open
// after the first gate. An MCP server advertises its tools, so a model connected
// to a notes instance would be told it can create_task, call it, get something
// back, and tell the person their task was filed.
//
// Asked through tools/list rather than by reading the server's own slice: that is
// what a client actually sees, and the slice is unexported for the same reason.
func TestNotesEditionOffersNoTaskToolsOverMCP(t *testing.T) {
taskTools := []string{"search_tasks", "create_task", "update_task", "complete_task", "add_comment"}
noteTools := []string{"search_notes", "create_note", "update_note"}
for _, edition := range []string{config.EditionFull, config.EditionNotes} {
t.Run(edition, func(t *testing.T) {
names := mcpToolNames(t, edition)
// Both editions: the shared tool, and the notes half.
for _, want := range append([]string{"list_projects"}, noteTools...) {
if !names[want] {
t.Errorf("%s edition does not offer %s", edition, want)
}
}
for _, task := range taskTools {
switch edition {
case config.EditionFull:
if !names[task] {
t.Errorf("the full edition lost %s — that is a deletion, not a gate", task)
}
default:
if names[task] {
t.Errorf("the notes edition offers %s over MCP", task)
}
}
}
t.Logf("%s: %d tools", edition, len(names))
})
}
}
// mcpToolNames asks one edition's MCP server for its tool list.
func mcpToolNames(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() })
cfg := &config.Config{BaseURL: "http://localhost", DataDir: t.TempDir(), SessionTTL: time.Hour, Edition: edition}
srv := New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
resp := srv.mcp.Handle(context.Background(), "nobody", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
if resp == nil {
t.Fatal("tools/list got no response")
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatal(err)
}
var parsed struct {
Result struct {
Tools []struct{ Name string } `json:"tools"`
} `json:"result"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("tools/list: %v — %s", err, raw)
}
if len(parsed.Result.Tools) == 0 {
t.Fatalf("tools/list returned no tools at all: %s", raw)
}
names := map[string]bool{}
for _, tool := range parsed.Result.Tools {
names[tool.Name] = true
}
return names
}
+1 -1
View File
@@ -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
+72 -63
View File
@@ -23,7 +23,9 @@ import (
// `search_tasks` and `list_projects` is answering "which of these gets me closer",
// and a description that only names the endpoint gives it nothing to decide with.
func (s *Server) buildMCP() *mcp.Server {
m := mcp.NewServer("verdande", "1")
// Udgavens eget navn: det er sådan, instansen står i klientens
// forbindelsesliste.
m := mcp.NewServer(s.cfg.ProductSlug(), "1")
m.Register(mcp.Tool{
Name: "list_projects",
@@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server {
}),
}, s.mcpListProjects)
m.Register(mcp.Tool{
Name: "search_tasks",
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
"This is the tool for any question about what the user has to do — " +
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
"The query accepts the same filter language as the app: \"today & p1\", " +
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
InputSchema: mcp.Schema(map[string]any{
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
"text": mcp.Str("Free text to search task titles and descriptions for."),
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
}),
}, s.mcpSearchTasks)
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
// lave. add_comment er med her: en kommentar hænger på en opgave.
if !s.cfg.NotesOnly() {
m.Register(mcp.Tool{
Name: "search_tasks",
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
"This is the tool for any question about what the user has to do — " +
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
"The query accepts the same filter language as the app: \"today & p1\", " +
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
InputSchema: mcp.Schema(map[string]any{
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
"text": mcp.Str("Free text to search task titles and descriptions for."),
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
}),
}, s.mcpSearchTasks)
m.Register(mcp.Tool{
Name: "create_task",
Description: "Create a task. Prefer passing the whole thing as natural language in " +
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
"and English. Use the explicit fields only when the user was explicit.",
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
"content": mcp.Str("The task title, if not using `text`."),
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
"due_date": mcp.Str("A date as YYYY-MM-DD."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"description": mcp.Str("Longer notes on the task."),
"labels": mcp.StrArray("Label names."),
}),
}, s.mcpCreateTask)
m.Register(mcp.Tool{
Name: "create_task",
Description: "Create a task. Prefer passing the whole thing as natural language in " +
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
"and English. Use the explicit fields only when the user was explicit.",
InputSchema: mcp.Schema(map[string]any{
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
"content": mcp.Str("The task title, if not using `text`."),
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
"due_date": mcp.Str("A date as YYYY-MM-DD."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"description": mcp.Str("Longer notes on the task."),
"labels": mcp.StrArray("Label names."),
}),
}, s.mcpCreateTask)
m.Register(mcp.Tool{
Name: "update_task",
Description: "Change an existing task: its title, description, priority, due date or " +
"project. Only the fields given are changed. To move a due date, pass due_date; " +
"to clear it, pass an empty string.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to change."),
"content": mcp.Str("A new title."),
"description": mcp.Str("New notes."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
"project_id": mcp.Str("Move the task to another project."),
"labels": mcp.StrArray("Replace the task's labels."),
}, "task_id"),
}, s.mcpUpdateTask)
m.Register(mcp.Tool{
Name: "update_task",
Description: "Change an existing task: its title, description, priority, due date or " +
"project. Only the fields given are changed. To move a due date, pass due_date; " +
"to clear it, pass an empty string.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to change."),
"content": mcp.Str("A new title."),
"description": mcp.Str("New notes."),
"priority": mcp.Int("1 (highest) to 4 (none)."),
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
"project_id": mcp.Str("Move the task to another project."),
"labels": mcp.StrArray("Replace the task's labels."),
}, "task_id"),
}, s.mcpUpdateTask)
m.Register(mcp.Tool{
Name: "complete_task",
Description: "Mark a task done. A repeating task advances to its next occurrence " +
"instead of closing, and the result says so.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to complete."),
}, "task_id"),
}, s.mcpCompleteTask)
m.Register(mcp.Tool{
Name: "complete_task",
Description: "Mark a task done. A repeating task advances to its next occurrence " +
"instead of closing, and the result says so.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to complete."),
}, "task_id"),
}, s.mcpCompleteTask)
m.Register(mcp.Tool{
Name: "add_comment",
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to comment on."),
"body": mcp.Str("What to say."),
}, "task_id", "body"),
}, s.mcpAddComment)
m.Register(mcp.Tool{
Name: "add_comment",
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
InputSchema: mcp.Schema(map[string]any{
"task_id": mcp.Str("The task to comment on."),
"body": mcp.Str("What to say."),
}, "task_id", "body"),
}, s.mcpAddComment)
}
s.registerNoteTools(m)
return m
+198 -43
View File
@@ -3,6 +3,7 @@
package httpapi
import (
"bytes"
"encoding/json"
"errors"
"io/fs"
@@ -77,6 +78,20 @@ 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
// Icon paths this edition serves from another file. Empty in the full edition.
assets map[string]string
// The second door: /urd and its own web manifest. Built on every instance,
// whatever the edition, because it is how the notes face is installed as its
// own app rather than a property of the instance. See shell.go.
urdShell []byte
urdManifest []byte
router chi.Router
}
@@ -86,7 +101,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 +116,53 @@ 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
}
s.assets = resolveNotesAssets(web, func(name, alias string) {
log.Error("this edition has no icon of its own at that path; it will serve verdande's",
"path", name, "wanted", alias)
})
}
// The second door, on every edition. A notes instance already calls itself urd
// at the root; the door is still built there so the two addresses behave the
// same, and so a single test does not have to know which edition it is in.
if web != nil {
urdShell, urdManifest, err := urdDoorAssets(web)
if err != nil {
log.Error("the urd door could not be built; /urd will not be its own app", "err", err)
} else {
s.urdShell, s.urdManifest = urdShell, urdManifest
}
}
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)
@@ -125,13 +185,31 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Docker both need this to answer before anyone has logged in.
r.Get("/healthz", s.handleHealth)
// The same question as inside /api/v1, asked again here because four of the
// task-only surfaces live outside it: the calendar feed, CalDAV, the inbox
// hook, and the well-known path CalDAV clients discover through. They are out
// here because their callers cannot hold a session, not because they are any
// less about tasks — and the first gate missed all four for exactly that
// reason. It read as complete because the /tasks tree was covered.
full := !s.cfg.NotesOnly()
// The calendar feed is outside /api/v1 and outside the session: a calendar
// client cannot log in, so the token in the path is the whole credential.
r.Get("/ics/{token}", s.handleICSFeed)
//
// A feed of VTODOs, so there is nothing for it to answer in the notes edition.
if full {
r.Get("/ics/{token}", s.handleICSFeed)
// CalDAV, outside /api/v1 because clients do well-known-path discovery
// against the root and authenticate with Basic rather than a session.
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
// CalDAV, outside /api/v1 because clients do well-known-path discovery
// against the root and authenticate with Basic rather than a session.
//
// Withheld rather than left to answer an empty collection: the well-known
// path is a claim that a CalDAV server is here, and a client that believes
// it goes on to PROPFIND, authenticate, and sync nothing — reporting a
// broken calendar server on an instance that has none and needs none. Same
// reasoning as the /.well-known/ 404 in serveWeb.
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
}
// Inbound mail. Delivered by the mail server rather than by a browser, and
// authenticated by the token in the recipient address.
@@ -156,8 +234,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
//
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
// for den indgående post, og omvendt.
r.Method(http.MethodPost, "/inbound/hook/{token}",
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
//
// Opgaver, efter sin egen beskrivelse ovenfor — den skubber en linje tekst ind
// i indbakken — så den findes ikke i noter-udgaven.
if full {
r.Method(http.MethodPost, "/inbound/hook/{token}",
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
}
// MCP with the token in the query string, for clients that cannot send a
// header. Claude's custom-connector dialog takes a URL and nothing else, so
@@ -184,7 +267,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Gmail connection with calendar tokens.
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
})
r.Route("/caldav", s.caldavRoutes)
// Hele CalDAV-træet: otte ruter, der alle læser eller skriver en VTODO.
if full {
r.Route("/caldav", s.caldavRoutes)
}
r.Route("/api/v1", func(r chi.Router) {
r.Use(s.requireCSRF)
@@ -312,7 +398,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
}
r.Get("/feed", s.handleGetFeed)
// Tokenet til ICS-feedet ovenfor. Et token til et feed, der ikke
// findes, er en indstillingsside, der tilbyder en adresse, intet
// svarer på.
if full {
r.Get("/feed", s.handleGetFeed)
}
r.Route("/notifications", func(r chi.Router) {
r.Get("/", s.handleListNotifications)
@@ -338,7 +429,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateComment)
r.Delete("/", s.handleDeleteComment)
})
r.Post("/feed/rotate", s.handleRotateFeed)
if full {
r.Post("/feed/rotate", s.handleRotateFeed)
}
r.Get("/mail-address", s.handleGetMailAddress)
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
@@ -359,20 +452,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/settings", s.handleGetAISettings)
r.Put("/settings", s.handleSetAISettings)
r.Post("/summary", s.handleAISummary)
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
r.Post("/inbox/tidy", s.handleAITidyInbox)
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
if full {
r.Post("/tasks/{taskID}/split", s.handleAISplit)
// Forslag, ikke skrivninger. De to første svarer med linjer i
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
// der sker, når nogen siger ja til én af dem.
r.Post("/inbox/tidy", s.handleAITidyInbox)
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
}
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
// Dagens plan: indstillingen, og en knap til at se den nu. En
// indstilling, man skal vente til i morgen for at se virkningen af,
// er en indstilling, ingen tør slå til.
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
if full {
r.Get("/plan", s.handleGetPlanSettings)
r.Put("/plan", s.handleSetPlanSettings)
r.Post("/plan/now", s.handlePlanNow)
}
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
// det er bygget på.
@@ -457,24 +554,38 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
})
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
// Begge retninger laver eller læser opgaver: Todoist-importen laver
// dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem
// ud. De blev alle betjent i noter-udgaven indtil nu — de svarede tomt
// frem for at fejle, hvilket er den stille version af samme fejl.
if full {
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
}
r.Get("/export/account", s.handleExportAccount)
r.Get("/export/notes.zip", s.handleExportNotes)
r.Get("/export/projects.zip", s.handleExportAllCSV)
r.Get("/export/tasks.ics", s.handleExportAllICS)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
if full {
r.Get("/export/projects.zip", s.handleExportAllCSV)
r.Get("/export/tasks.ics", s.handleExportAllICS)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
}
r.Route("/templates", func(r chi.Router) {
r.Get("/", s.handleListTemplates)
r.Post("/", s.handleSaveTemplate)
r.Post("/{templateID}/use", s.handleUseTemplate)
r.Delete("/{templateID}", s.handleDeleteTemplate)
})
// En skabelon er et projekt plus sektioner plus opgaver — læst i
// CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som
// kun siger "projekt". Uden opgaver er den et tomt projekt med
// overskrifter i.
if full {
r.Route("/templates", func(r chi.Router) {
r.Get("/", s.handleListTemplates)
r.Post("/", s.handleSaveTemplate)
r.Post("/{templateID}/use", s.handleUseTemplate)
r.Delete("/{templateID}", s.handleDeleteTemplate)
})
}
if full {
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
@@ -505,7 +616,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(s.requireProject(store.RoleViewer))
r.Get("/", s.handleGetProject)
r.Get("/sections", s.handleListSections)
// Sektioner grupperer opgaver inde i et projekt, og det er
// afgjort af skemaet frem for af navnet: `section_id` står på
// `tasks` og findes ikke på `notes`. En sektion i en notesbog
// kan ikke indeholde noget.
if full {
r.Get("/sections", s.handleListSections)
}
r.Get("/members", s.handleListMembers)
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
@@ -514,8 +631,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireProject(store.RoleEditor))
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
if full {
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
}
})
r.Group(func(r chi.Router) {
@@ -590,10 +709,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/trash/projects", s.handleListTrashedProjects)
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
r.Route("/sections/{sectionID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateSection)
r.Delete("/", s.handleDeleteSection)
})
if full {
r.Route("/sections/{sectionID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateSection)
r.Delete("/", s.handleDeleteSection)
})
}
if full {
r.Route("/tasks", func(r chi.Router) {
@@ -685,7 +806,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
}
@@ -694,6 +815,22 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
name = "index.html"
}
// The second door, before anything else touches the filesystem: /urd is not a
// file, so the fallback below would hand back the other door's shell.
if b := s.door(name); b != nil {
w.Header().Set("Content-Type", contentTypeFor(name))
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
return
}
// This edition's own icon, where it has one. Before the Open rather than after,
// unlike the shell below: these are real files, so there is no fallback to get
// in the way, and resolveNotesAssets has already checked each one exists.
if alias, ok := s.assets[name]; ok {
name = alias
}
f, err := s.web.Open(name)
if errors.Is(err, fs.ErrNotExist) {
// A client-side route: hand back the shell and let the app route it.
@@ -706,6 +843,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"})
+341
View File
@@ -0,0 +1,341 @@
package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"strings"
)
// 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</title>", "<title>" + slug + "</title>"},
{
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
`<meta name="description" content="` + description + `" />`,
},
}
}
// 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 {
// Any .webmanifest, not the one name — there are two of them now, and the
// second one was served as HTML for about four minutes because this read
// `name == "manifest.webmanifest"`. Nothing would have reported it: a manifest
// with the wrong type is ignored silently, and the only symptom is an install
// prompt that does not appear.
if strings.HasSuffix(name, ".webmanifest") {
return "application/manifest+json"
}
return "text/html; charset=utf-8"
}
// notesAssetAliases maps the icon paths the shell and the manifest already point
// at to urd's own files. Both sets are in the binary; the server decides.
//
// Aliased rather than renamed in the manifest, and the reason is the third entry.
// Safari ignores a manifest's icons entirely and looks for `apple-touch-icon.png`
// BY NAME — so pointing the manifest at urd-icon-192.png would have left iOS with
// Verdande's mark, or, if that file were renamed too, with a screenshot of the page
// as its home-screen icon. Keeping the paths and swapping the bytes behind them
// fixes all four at once, including the favicon in `app.html`, which is not in the
// manifest either.
var notesAssetAliases = map[string]string{
"icon.svg": "urd-icon.svg",
"icon-192.png": "urd-icon-192.png",
"icon-512.png": "urd-icon-512.png",
"apple-touch-icon.png": "urd-apple-touch-icon.png",
}
// resolveNotesAssets returns the aliases that have a file behind them, and reports
// the ones that do not.
//
// Resolved once at startup rather than per request, so a missing file is one loud
// line in the log instead of a silent fallback on every page load. The fallback is
// still to Verdande's icon rather than to nothing: an icon is the one asset whose
// absence turns into a broken-image placeholder on the Updates screen — which is
// exactly how four releases of nolimit-views shipped with no icon — so serving the
// wrong mark beats serving none.
func resolveNotesAssets(web fs.FS, missing func(name, alias string)) map[string]string {
out := map[string]string{}
for name, alias := range notesAssetAliases {
if _, err := fs.Stat(web, alias); err != nil {
missing(name, alias)
continue
}
out[name] = alias
}
return out
}
// ---------------------------------------------------------------------------
// The second door.
//
// One instance, one database, one login — and two things you can install. urd is
// reached at /urd, which serves the same single-page app under its own identity:
// its own title, its own mark, and its own web manifest.
//
// The manifest is the part that matters and the part that is easy to get wrong.
// An operating system does not install a URL, it installs a *manifest*, and the
// identity of an installed app is the manifest's `id`. Two manifests on one origin
// with different ids are two installable apps; two URLs sharing one manifest are
// one app with a bookmark.
//
// And the shell has to be served per door rather than patched by JavaScript,
// because iOS reads the page's <head> when somebody taps Add to Home Screen —
// Safari looks for apple-touch-icon BY NAME and has historically ignored the
// manifest's icons entirely. A head rewritten after load is a head that may or may
// not be the one iOS read.
// ---------------------------------------------------------------------------
// urdDoorPath is the entry point, and it is also the manifest's id. One constant,
// because those two being the same string is the whole mechanism.
const urdDoorPath = "/urd"
// urdShellRebrands rewrites the four things in the shell's <head> that decide what
// this door is: what the tab says, what a crawler reads, which manifest the browser
// fetches, and which icons it finds without the manifest.
//
// Note what is NOT in here, for the same reason as in shellRebrands above: the
// three `verdande:` localStorage keys in the inline theme script. They are storage
// rather than a name, the rest of the app reads them, and — now more than before —
// the two doors share an origin and therefore share that storage. Renaming them
// here would give the two apps different themes by accident and lose everybody's
// setting on the way.
func urdShellRebrands() []rebrand {
return []rebrand{
{"<title>verdande</title>", "<title>urd</title>"},
{
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
`<meta name="description" content="` + notesShellDescription + `" />`,
},
{
`<link rel="manifest" href="/manifest.webmanifest" />`,
`<link rel="manifest" href="/urd.webmanifest" />`,
},
{
`<link rel="icon" href="/icon.svg" type="image/svg+xml" />`,
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
},
{
`<link rel="apple-touch-icon" href="/apple-touch-icon.png" />`,
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
},
}
}
// urdManifest builds the second door's manifest from the one the frontend build
// produced, rather than writing a second one by hand.
//
// Transformed as JSON rather than by string replacement, which the shell above
// does: a manifest needs keys the built one does not have — `id` and `start_url`
// — so there is nothing to anchor a replacement on, and a hand-written copy would
// drift on the fields the two genuinely share (the colours, the display mode, the
// language).
//
// `id` is what makes this a second app rather than a bookmark. `scope` is spelled
// out even though "/" is what the spec would default to from a start_url of
// "/urd": an app whose scope did not cover the whole origin would open its own
// links in a browser tab the first time somebody navigated out of /urd, and that
// is a rule nobody should have to remember while reading this.
func urdManifest(raw []byte) ([]byte, error) {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("the built manifest is not JSON: %w", err)
}
m["id"] = urdDoorPath
m["start_url"] = urdDoorPath
m["scope"] = "/"
m["name"] = "urd"
m["short_name"] = "urd"
m["description"] = notesManifestDescription
icons, ok := m["icons"].([]any)
if !ok || len(icons) == 0 {
return nil, fmt.Errorf("the built manifest has no icons to rewrite")
}
for i, entry := range icons {
icon, ok := entry.(map[string]any)
if !ok {
return nil, fmt.Errorf("icon %d is not an object", i)
}
src, _ := icon["src"].(string)
// Every icon must be one this door has a file for. Refusing an unknown one
// is the point: an icon added to the manifest and not to this map would
// otherwise leave urd's install prompt showing Verdande's mark, which is
// cosmetic enough that nobody files it.
swapped, ok := map[string]string{
"/icon.svg": "/urd-icon.svg",
"/icon-192.png": "/urd-icon-192.png",
"/icon-512.png": "/urd-icon-512.png",
}[src]
if !ok {
return nil, fmt.Errorf("icon %d is %q, which urd has no file for", i, src)
}
icon["src"] = swapped
}
out, err := json.MarshalIndent(m, "", " ")
if err != nil {
return nil, err
}
return append(out, '\n'), nil
}
// urdDoorAssets builds the second door from the files the frontend build produced.
//
// Both or neither, and loudly: a door whose shell points at /urd.webmanifest while
// that manifest is not being served is an install prompt that silently falls back
// to the other app's identity.
func urdDoorAssets(web fs.FS) (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, urdShellRebrands())
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 = urdManifest(raw)
if err != nil {
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
}
return shell, manifest, nil
}
// door returns the bytes for the second door's two paths, or nil.
//
// Keyed on the requested path rather than on the resolved file, unlike `renamed`
// below: /urd is not a file and never will be, so it has to be answered before the
// fallback to index.html gets to it — which would serve the other door's shell
// under this door's address, with no error anywhere.
func (s *Server) door(name string) []byte {
switch name {
case "urd":
return s.urdShell
case "urd.webmanifest":
return s.urdManifest
}
return nil
}
+442
View File
@@ -0,0 +1,442 @@
package httpapi
import (
"encoding/json"
"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 = `<!doctype html>
<html lang="da" data-theme="dark">
<head>
<meta name="description" content="verdande — opgaver og projekter, delt." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>verdande</title>
<script>
try {
const stored = localStorage.getItem('verdande:theme');
const look = localStorage.getItem('verdande:look');
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
} catch (e) {}
</script>
</head>
<body></body>
</html>
`
const builtManifest = `{
"name": "verdande",
"short_name": "verdande",
"description": "Opgaver og projekter, delt.",
"start_url": "/",
"display": "standalone",
"background_color": "#111312",
"theme_color": "#111312",
"lang": "da",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
`
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{
"<title>urd</title>",
`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 <script>, the hash would change, the browser would refuse
// to run it, and the page would be blank — in production only, because the policy
// is not enforced for a page opened from a file.
func TestRenamingTheShellKeepsTheCSPHashValid(t *testing.T) {
shell, _, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
if err != nil {
t.Fatalf("rebrandedAssets: %v", err)
}
before := scriptHashesIn([]byte(builtShell))
after := scriptHashesIn(shell)
if len(before) != 1 {
t.Fatalf("the fixture should have exactly one inline script, found %d", len(before))
}
if len(after) != len(before) || after[0] != before[0] {
t.Errorf("renaming changed the inline script: policy had %v, served shell hashes to %v", before, after)
}
}
// TestRenamingRefusesAMissingAnchor is the direction that matters more than the
// happy path. A replacement that silently matched nothing leaves an Urd instance
// calling itself verdande, which is cosmetic enough that nobody reports it — so the
// rewrite has to say so rather than return what it managed.
func TestRenamingRefusesAMissingAnchor(t *testing.T) {
cases := map[string]fstest.MapFS{
"no title": {
"index.html": {Data: []byte(strings.Replace(builtShell, "<title>verdande</title>", "<title>something else</title>", 1))},
"manifest.webmanifest": {Data: []byte(builtManifest)},
},
"no description": {
"index.html": {Data: []byte(strings.Replace(builtShell, `content="verdande — opgaver og projekter, delt."`, `content="other"`, 1))},
"manifest.webmanifest": {Data: []byte(builtManifest)},
},
"no manifest name": {
"index.html": {Data: []byte(builtShell)},
"manifest.webmanifest": {Data: []byte(strings.Replace(builtManifest, `"name": "verdande"`, `"name": "other"`, 1))},
},
"no index.html at all": {
"manifest.webmanifest": {Data: []byte(builtManifest)},
},
"no manifest at all": {
"index.html": {Data: []byte(builtShell)},
},
}
for name, web := range cases {
t.Run(name, func(t *testing.T) {
if _, _, err := rebrandedAssets(web, "urd", "d", "m"); err == nil {
t.Error("renaming reported success over a shell it could not rename")
}
})
}
}
// TestAppHTMLStillCarriesTheAnchors is what stops the fixture above from becoming a
// frozen copy of a file that has moved on.
//
// The anchors are exact strings in web/src/app.html, and that file is edited for
// unrelated reasons — a meta tag, a new theme attribute. An edit that reformats the
// title line or rewrites the description breaks the rename and nothing in Go would
// know, because the Go build graph has never heard of app.html.
//
// Note the caching trap that comes with reading a non-Go file from a test: `go
// test` will serve a cached green over a changed app.html. CI passes -count=1 in
// both workflows; a local run needs it typed.
func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
raw, err := os.ReadFile("../../web/src/app.html")
if err != nil {
t.Skipf("app.html is not reachable from here: %v", err)
}
src := string(raw)
// Both sets: the edition's rename and the second door's. The door was added
// after this test and its anchors were NOT covered, which is how its fixture
// came to be missing two <link> lines the real file has — caught only because
// applyRebrands refuses an anchor it cannot find.
for what, pairs := range map[string][]rebrand{
"the notes edition's rename": shellRebrands("urd", "d"),
"the urd door": urdShellRebrands(),
} {
for _, pair := range pairs {
if n := strings.Count(src, pair.old); n != 1 {
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — %s replaces it",
n, pair.old, what)
}
}
}
// And the keys the rename must not touch, asserted here too: app.html is where
// somebody would rename them, and if they are renamed there they must be
// renamed in stores.svelte.js in the same commit.
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
}
t.Logf("app.html: %d bytes, %d edition anchors, %d door anchors",
len(raw), len(shellRebrands("urd", "d")), len(urdShellRebrands()))
}
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
// is about the renamed path; this says the other edition does not go through it.
func TestTheFullEditionServesTheBuiltFilesUnchanged(t *testing.T) {
for _, edition := range []string{"full", "notes"} {
t.Run(edition, func(t *testing.T) {
srv := serverWithWeb(t, edition, builtWeb())
if edition == "full" {
if srv.renamed("index.html") != nil || srv.renamed("manifest.webmanifest") != nil {
t.Error("the full edition renamed the shell")
}
return
}
if srv.renamed("index.html") == nil || srv.renamed("manifest.webmanifest") == nil {
t.Fatal("the notes edition did not rename the shell")
}
if !strings.Contains(string(srv.renamed("index.html")), "<title>urd</title>") {
t.Error("the notes edition's shell does not say urd")
}
if srv.renamed("_app/immutable/app.js") != nil {
t.Error("a build asset was served from memory; only the shell and the manifest are renamed")
}
})
}
}
// webWithIcons is builtWeb plus both editions' icons, as a real build has them.
func webWithIcons() fstest.MapFS {
web := builtWeb()
for _, name := range []string{
"icon.svg", "icon-192.png", "icon-512.png", "apple-touch-icon.png",
"urd-icon.svg", "urd-icon-192.png", "urd-icon-512.png", "urd-apple-touch-icon.png",
} {
web[name] = &fstest.MapFile{Data: []byte("bytes of " + name)}
}
return web
}
// TestEachEditionServesItsOwnMark.
//
// Four paths, named one by one rather than counted: the favicon in app.html, the
// manifest's two, and the one Safari looks for by name. The last is the reason
// this is an alias rather than a rewritten manifest — Safari does not read the
// manifest at all.
func TestEachEditionServesItsOwnMark(t *testing.T) {
for _, tc := range []struct {
edition string
want map[string]string
}{
{"notes", map[string]string{
"icon.svg": "urd-icon.svg",
"icon-192.png": "urd-icon-192.png",
"icon-512.png": "urd-icon-512.png",
"apple-touch-icon.png": "urd-apple-touch-icon.png",
}},
// The control, and it is the half that stops this being a swap rather than
// a choice: the full edition must resolve nothing.
{"full", map[string]string{}},
} {
t.Run(tc.edition, func(t *testing.T) {
srv := serverWithWeb(t, tc.edition, webWithIcons())
if len(srv.assets) != len(tc.want) {
t.Errorf("%s edition aliases %d paths, want %d: %v", tc.edition, len(srv.assets), len(tc.want), srv.assets)
}
for path, alias := range tc.want {
if srv.assets[path] != alias {
t.Errorf("%s: %s resolves to %q, want %q", tc.edition, path, srv.assets[path], alias)
}
}
// And nothing else: an alias on a build asset would serve one file's
// bytes under another's hashed, immutable name.
for path := range srv.assets {
if _, ok := tc.want[path]; !ok {
t.Errorf("%s edition aliases %s, which is not an icon", tc.edition, path)
}
}
t.Logf("%s: %d aliased paths", tc.edition, len(srv.assets))
})
}
}
// TestAMissingMarkIsReportedAndFallsBack.
//
// The fallback is deliberate and it is the only place in this file that prefers
// carrying on to failing: an icon's absence is a broken-image placeholder on the
// one screen somebody reads before deciding whether to press update, so the wrong
// mark beats no mark. What must not happen is it being quiet.
func TestAMissingMarkIsReportedAndFallsBack(t *testing.T) {
web := webWithIcons()
delete(web, "urd-icon-512.png")
var reported []string
got := resolveNotesAssets(web, func(name, alias string) {
reported = append(reported, name+" → "+alias)
})
if len(reported) != 1 || reported[0] != "icon-512.png → urd-icon-512.png" {
t.Errorf("the missing icon was reported as %v, want exactly one naming both paths", reported)
}
if _, ok := got["icon-512.png"]; ok {
t.Error("a path with no file behind it was aliased anyway")
}
if got["icon.svg"] != "urd-icon.svg" {
t.Error("one missing icon took the other three with it")
}
}
// TestTheUrdDoorIsItsOwnApp.
//
// An operating system does not install a URL, it installs a manifest, and the
// identity of an installed app is that manifest's `id`. So this asserts the two
// things that decide whether /urd becomes a second icon on a home screen or just a
// bookmark into the first one — and it asserts them as named values, because an
// empty manifest, verdande's manifest and urd's all parse as JSON.
func TestTheUrdDoorIsItsOwnApp(t *testing.T) {
srv := serverWithWeb(t, "full", webWithIcons())
if srv.door("urd") == nil || srv.door("urd.webmanifest") == nil {
t.Fatal("the full edition does not serve the urd door; it is a second face, not an edition")
}
shell := string(srv.door("urd"))
for _, want := range []string{
"<title>urd</title>",
`<link rel="manifest" href="/urd.webmanifest" />`,
// iOS reads this one by name and has historically ignored the manifest's
// icons entirely, which is why the shell is served per door rather than
// patched by script after load.
`<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />`,
`<link rel="icon" href="/urd-icon.svg" type="image/svg+xml" />`,
} {
if !strings.Contains(shell, want) {
t.Errorf("the urd door's shell does not contain %q", want)
}
}
// The same trap as the edition shell: the two doors share an origin and
// therefore share localStorage, so renaming a storage key here would give the
// two apps different themes by accident.
if !strings.Contains(shell, `localStorage.getItem('verdande:theme')`) {
t.Error("the urd door's shell lost a storage key; the two doors share one origin and one localStorage")
}
var m map[string]any
if err := json.Unmarshal(srv.door("urd.webmanifest"), &m); err != nil {
t.Fatalf("the urd manifest is not JSON: %v", err)
}
for key, want := range map[string]any{
// id is the install identity. Without it, two manifests on one origin are
// one app with two start urls.
"id": "/urd",
"start_url": "/urd",
"scope": "/",
"name": "urd",
"short_name": "urd",
} {
if m[key] != want {
t.Errorf("the urd manifest has %s = %v, want %v", key, m[key], want)
}
}
// And the shared half, to prove it was transformed rather than written out: a
// hand-written copy would drift on exactly these.
var built map[string]any
raw, _ := fs.ReadFile(webWithIcons(), "manifest.webmanifest")
_ = json.Unmarshal(raw, &built)
for _, key := range []string{"display", "background_color", "theme_color", "lang"} {
if _, ok := built[key]; ok && m[key] != built[key] {
t.Errorf("the urd manifest lost the built one's %s: %v vs %v", key, m[key], built[key])
}
}
icons, _ := m["icons"].([]any)
if len(icons) == 0 {
t.Fatal("the urd manifest has no icons")
}
for i, entry := range icons {
src, _ := entry.(map[string]any)["src"].(string)
if !strings.HasPrefix(src, "/urd-") {
t.Errorf("icon %d is %q — the urd manifest points at verdande's mark", i, src)
}
}
t.Logf("urd door: shell %d bytes, manifest %d bytes, %d icons", len(srv.door("urd")), len(srv.door("urd.webmanifest")), len(icons))
}
// TestAnUnknownIconStopsTheUrdManifest is the direction that matters: an icon
// added to the built manifest and not to urd's map would otherwise leave urd's
// install prompt showing Verdande's mark, which nobody reports.
func TestAnUnknownIconStopsTheUrdManifest(t *testing.T) {
raw := []byte(`{"name":"verdande","icons":[{"src":"/icon-1024.png","sizes":"1024x1024"}]}`)
if _, err := urdManifest(raw); err == nil {
t.Error("urdManifest accepted an icon it has no file for")
} else if !strings.Contains(err.Error(), "/icon-1024.png") {
t.Errorf("the error does not name the icon: %v", err)
}
}
// TestTheWebmanifestTypeCoversBothDoors. A manifest served as text/html is ignored
// by the browser with no error anywhere; the only symptom is that the install
// prompt does not appear. This read `name == "manifest.webmanifest"` when the
// second door was added.
func TestTheWebmanifestTypeCoversBothDoors(t *testing.T) {
for _, name := range []string{"manifest.webmanifest", "urd.webmanifest"} {
if got := contentTypeFor(name); got != "application/manifest+json" {
t.Errorf("%s is served as %q", name, got)
}
}
if got := contentTypeFor("index.html"); got != "text/html; charset=utf-8" {
t.Errorf("index.html is served as %q", got)
}
}
+16 -7
View File
@@ -206,13 +206,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
Search: query,
// Completed tasks are included: "what was that thing I did last week" is
// a search, and excluding them makes the box feel broken.
IncludeCompleted: true,
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
})
// Søgningen er monteret i begge udgaver, fordi man også skal kunne finde sine
// noter og notesbøger. Men opgaverne må ikke komme med i noter-udgaven, og det
// er ikke en teoretisk sag: en instans lavet om fra en fuld har opgaverne
// liggende i databasen endnu, og så ville ⌘K være den ene dør, der stod åben
// ind til den halvdel, resten af programmet lige har lukket.
var tasks []store.Task
var err error
if !s.cfg.NotesOnly() {
tasks, err = s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
Search: query,
// Completed tasks are included: "what was that thing I did last week" is
// a search, and excluding them makes the box feel broken.
IncludeCompleted: true,
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
})
}
if err != nil {
s.internal(w, r, "search tasks", err)
return
+1 -1
View File
@@ -40,7 +40,7 @@ func newRunner(t *testing.T) (*Runner, *store.DB, *config.Config) {
t.Cleanup(func() { db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, log), realtime.NewHub(log)), db, cfg
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log), realtime.NewHub(log)), db, cfg
}
func seedUser(t *testing.T, db *store.DB) *store.User {
+19 -13
View File
@@ -24,13 +24,19 @@ import (
type Sender struct {
cfg config.SMTP
log *slog.Logger
// appName and baseURL appear in the body of every message.
// appName and baseURL appear in every message. appName is the edition's own
// name — an invite from an urd instance that said "verdande" would be a letter
// to somebody else's inbox about a program they are not being invited to.
//
// It was a field with a value and no reader until 2026-10-07: the three places
// that needed it had the word written out instead, and the comment above said
// it appeared in every message. It does now.
appName string
baseURL string
}
func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
return &Sender{cfg: cfg, log: log, appName: "verdande", baseURL: baseURL}
func New(cfg config.SMTP, baseURL, appName string, log *slog.Logger) *Sender {
return &Sender{cfg: cfg, log: log, appName: appName, baseURL: baseURL}
}
// Configured reports whether mail can actually be sent. Handlers use it to decide
@@ -38,7 +44,7 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
func (s *Sender) Configured() bool { return s.cfg.Configured() }
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
what := "verdande"
what := s.appName
if projectName != "" {
what = "projektet “" + projectName + "”"
}
@@ -61,7 +67,7 @@ func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle,
}
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
subject := fmt.Sprintf("%s har delt noget med dig i %s", inviterName, s.appName)
body := fmt.Sprintf(`Hej
%s har inviteret dig til %s.
@@ -71,9 +77,9 @@ Opret din konto her:
Linket virker i %s. Hvis du ikke ved, hvad det her handler om, kan du roligt slette denne mail — der sker ikke noget, hvis du ikke bruger linket.
— verdande
— %s
%s
`, inviterName, what, link, humanDuration(ttl), s.baseURL)
`, inviterName, what, link, humanDuration(ttl), s.appName, s.baseURL)
return s.send(ctx, to, subject, body)
}
@@ -88,17 +94,17 @@ Du bad om en påmindelse om:
%s
— verdande
`, name, task, link)
— %s
`, name, task, link, s.appName)
return s.send(ctx, to, subject, body)
}
func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error {
subject := "Nulstil din adgangskode i verdande"
subject := "Nulstil din adgangskode i " + s.appName
body := fmt.Sprintf(`Hej %s
Der er bedt om at nulstille adgangskoden til din verdande-konto.
Der er bedt om at nulstille adgangskoden til din %s-konto.
Vælg en ny adgangskode her:
%s
@@ -107,9 +113,9 @@ Linket virker i %s og kan kun bruges én gang.
Har du ikke selv bedt om det, behøver du ikke gøre noget — din nuværende adgangskode virker stadig, og linket udløber af sig selv.
— verdande
— %s
%s
`, name, link, humanDuration(ttl), s.baseURL)
`, name, s.appName, link, humanDuration(ttl), s.appName, s.baseURL)
return s.send(ctx, to, subject, body)
}
+76
View File
@@ -0,0 +1,76 @@
package mail
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
)
// TestEveryMessageCarriesTheEditionsOwnName.
//
// These three letters are the only thing this program sends to an address its
// operator does not control, and an invitation from an urd instance that says
// "verdande" is a letter to a stranger about a program they are not being invited
// to. The name was a field with a value and no reader until 2026-10-07 — the three
// places that needed it had the word written out instead — so the assertion that
// matters is not "it says urd" but "it does not say the other one".
//
// Measured through the unconfigured path on purpose: with no SMTP host, send()
// logs the whole message at warn level and returns, so the subject and the body
// can be read as the recipient would get them without a mail server in the test.
func TestEveryMessageCarriesTheEditionsOwnName(t *testing.T) {
for _, tc := range []struct{ name, other string }{
{"verdande", "urd"},
{"urd", "verdande"},
} {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
s := New(config.SMTP{}, "https://example.dk", tc.name,
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
ctx := context.Background()
if err := s.SendInvite(ctx, "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
t.Fatalf("SendInvite: %v", err)
}
if err := s.SendPasswordReset(ctx, "ny@example.dk", "Kristian", "https://example.dk/r/abc", time.Hour); err != nil {
t.Fatalf("SendPasswordReset: %v", err)
}
if err := s.SendReminder(ctx, "ny@example.dk", "Kristian", "Ring til tandlægen", "https://example.dk/opgave/1"); err != nil {
t.Fatalf("SendReminder: %v", err)
}
got := buf.String()
if n := strings.Count(got, `msg="no SMTP host configured`); n != 3 {
t.Fatalf("expected three logged messages, got %d — the test is reading the wrong thing", n)
}
if !strings.Contains(got, tc.name) {
t.Errorf("no message mentions %q", tc.name)
}
if strings.Contains(got, tc.other) {
t.Errorf("a message mentions %q on a %s instance; the name is hard-coded somewhere", tc.other, tc.name)
}
t.Logf("%s: %d bytes of mail across three messages", tc.name, len(got))
})
}
}
// TestAnInviteWithoutAProjectNamesTheProgram pins the one place the name is the
// *subject* of the sentence rather than a signature: an invitation to the instance
// itself, with no project, reads "has invited you to <program>".
func TestAnInviteWithoutAProjectNamesTheProgram(t *testing.T) {
var buf bytes.Buffer
s := New(config.SMTP{}, "https://example.dk", "urd",
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
if err := s.SendInvite(context.Background(), "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
t.Fatalf("SendInvite: %v", err)
}
if !strings.Contains(buf.String(), "inviteret dig til urd") {
t.Errorf("the invite does not read \"inviteret dig til urd\":\n%s", buf.String())
}
}
+1
View File
@@ -44,6 +44,7 @@ extra_css:
nav:
- Home: index.md
- Setting it up:
- Two editions: editions.md
- As a Rune: rune.md
- With Docker: self-hosting.md
- Configuration: configuration.md
+272
View File
@@ -0,0 +1,272 @@
import { test, expect } from '@playwright/test';
import { watchForTrouble } from './trouble.js';
/**
* Noter-udgaven, kørt som sit eget program.
*
* Egen server på sin egen port med `VERDANDE_EDITION=notes`, egen database og
* ingen lånt session: den her instans har aldrig set den konto, den fulde udgaves
* opsætning lavede, så den laver sin egen.
*
* Og det er hele grunden til at prøven findes som en separat server frem for som
* en indstilling, en prøve skruer på. En skakt, der kun er prøvet ved at kalde en
* funktion, er prøvet på det, nogen mente — det her måler programmet, som en
* bruger møder det.
*/
const KONTO = {
email: 'noter@example.dk',
name: 'Noteskriver',
password: 'et langt kodeord til test'
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Første konto, hvis den ikke er lavet endnu. Prøverne i filen deler instans,
// så den anden gang er det en almindelig login.
const opret = page.getByRole('button', { name: 'Opret konto' });
const login = page.getByRole('button', { name: 'Log ind' });
// Ventet på, at EN af de to er der, før der spørges hvilken. Spurgt før
// tegningen svarer begge nej, og så faldt prøven i den forkerte gren og ventede
// tredive sekunder på en knap, der aldrig kom.
await expect(opret.or(login)).toBeVisible();
if (await opret.isVisible()) {
await page.getByLabel(/Navn/).fill(KONTO.name);
await page.getByLabel(/E-mail/).fill(KONTO.email);
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
await opret.click();
// Beaconens besked står øverst første gang; den er ikke det her prøvens
// ærinde, men den dækker for det, der er.
const besked = page.getByRole('region', { name: /melder, at den findes/ });
if (await besked.isVisible().catch(() => false)) {
await besked.getByRole('button', { name: 'Behold den' }).click();
}
} else {
await page.getByLabel(/E-mail/).fill(KONTO.email);
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
await page.getByRole('button', { name: 'Log ind' }).click();
}
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
});
test('instansen siger selv, at den er noter-udgaven', async ({ page }) => {
// Fladen kan ikke lukke af for noget, den ikke får at vide, og den skal have
// det at vide, før nogen er logget ind.
const svar = await page.request.get('/api/v1/auth/setup');
expect((await svar.json()).edition).toBe('notes');
});
test('der er ingen vej til opgaverne i menuen', async ({ page }) => {
const menu = page.getByRole('navigation', { name: 'Hovedmenu' });
await expect(menu).toBeVisible();
// Udeladt, ikke gråtonet: ruterne bag dem er ikke monteret på serveren.
for (const navn of ['I dag', 'Kommende', 'Kalender', 'Uddelegeret']) {
await expect(menu.getByRole('link', { name: navn })).toHaveCount(0);
}
// Og det, udgaven ER til, står der. En menu uden opgaver OG uden noter ville
// også bestå prøven ovenfor, og den ville være en sletning frem for en skakt.
await expect(menu.getByRole('link', { name: 'Noter', exact: true })).toBeVisible();
});
test('forsiden er noterne, ikke en tom opgaveliste', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/noter$/);
});
test('en gemt adresse til en opgaveside fører til noterne i stedet for et blindt spor', async ({
page
}) => {
// Nogen, der har gemt et link fra en fuld instans, skal lande et sted, der
// giver mening. Der er ikke sket noget forkert — adressen hører bare til et
// andet program end det, der kører her.
for (const sti of ['/upcoming', '/kalender', '/uddelegeret', '/faerdige']) {
await page.goto(sti);
await expect(page).toHaveURL(/\/noter$/);
}
});
test('⌘K finder noter og ikke opgaver', async ({ page }) => {
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(felt).toHaveText('');
await felt.click();
await page.keyboard.type('Kvartalsregnskabet');
await felt.blur();
await expect(page.locator('.notes button.row strong').filter({ hasText: /^Kvartalsregnskabet$/ })).toHaveCount(1);
await page.getByRole('button', { name: /Søg/ }).click();
const palette = page.getByRole('dialog');
await palette.getByRole('textbox').fill('Kvartalsregnskabet');
// Noten findes — ellers beviser det næste ingenting.
await expect(palette.locator('li button', { hasText: 'Kvartalsregnskabet' })).toBeVisible();
// Og søgningen på serveren svarer uden opgaver overhovedet.
const svar = await page.request.get('/api/v1/search?q=Kvartalsregnskabet');
expect((await svar.json()).tasks ?? []).toEqual([]);
});
test('opgaveruterne findes slet ikke på serveren', async ({ page }) => {
// Det her er forskellen på skjult og fraværende, målt udefra.
for (const sti of ['/api/v1/tasks', '/api/v1/labels', '/api/v1/upcoming', '/api/v1/filters']) {
const svar = await page.request.get(sti);
expect(svar.status(), `${sti} svarede ${svar.status()}`).toBe(404);
}
// Kontrollen: en rute, der ER monteret, svarer ikke 404 — ellers ville
// ovenstående bestå på en server, der var helt væk.
expect((await page.request.get('/api/v1/notes')).status()).toBe(200);
});
/**
* Navnet.
*
* To udgaver af ét program er to produkter, og det eneste sted en læser afgør
* hvilket af dem de ser på, er navnet: fanen, mærket i menuen, og det navn appen
* lægger sig under på en hjemmeskærm. De tre står i filer, browseren læser FØR
* noget JavaScript kører, så serveren skriver dem om på vejen ud — og derfor skal
* de måles her, på det der faktisk blev sendt, frem for i en funktion.
*/
test('fanen, mærket og manifestet siger alle urd', async ({ page }) => {
// Der er TO titler, og det blev opdaget her frem for i en Go-prøve: skallens,
// som browseren læser før noget JavaScript kører og som serveren skriver om,
// og rutens egen, som appen sætter i <svelte:head> når siden tegner. Den sidste
// overskriver den første — så en prøve, der kun læste den ene, ville være grøn
// over en fane, der sagde "Noter · verdande".
const skal = await (await page.request.get('/')).text();
expect(skal, 'skallens titel, før appen kører').toContain('<title>urd</title>');
await expect(page, 'rutens egen titel, efter appen har tegnet').toHaveTitle(/· urd$/);
const mærke = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.brand');
await expect(mærke.locator('.name')).toHaveText('urd');
// ð, ikke ᚹ wunjo og ikke ᚢ uruz. Begge de to er navngivne værdier frem for
// "der står noget": en tom span, Verdandes mærke og uruz-appens ser ens ud for
// en prøve, der kun spørger, om feltet er udfyldt.
await expect(mærke.locator('.rune')).toHaveText('ð');
await expect(mærke.locator('.rune')).not.toHaveText('ᚢ');
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('urd');
expect(manifest.short_name).toBe('urd');
});
test('manifestet bliver sendt som et manifest, ikke som tekst', async ({ page }) => {
// Go kender ikke .webmanifest, så uden en eksplicit type sniffes den til
// text/plain — og en browser ignorerer så manifestet uden at sige noget.
// Symptomet er at installationsknappen bare ikke er der.
const svar = await page.request.get('/manifest.webmanifest');
expect(svar.headers()['content-type']).toContain('application/manifest+json');
});
test('omdøbningen rørte ikke lagernøglerne', async ({ page }) => {
// Fælden i hele øvelsen: produktnavnet og localStorage-præfikset er samme ord
// i app.html, og de er ikke samme ting. Omdøbes præfikset her 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å. Målt på de bytes der blev sendt, ikke på kilden.
const skal = await (await page.request.get('/')).text();
expect(skal).toContain("localStorage.getItem('verdande:theme')");
expect(skal).toContain("localStorage.getItem('verdande:look')");
expect(skal).not.toContain('urd:theme');
});
test('navnet står også i teksterne, ikke kun i mærket', async ({ page }) => {
// `{product}`-pladsholderen, hele vejen gennem i18n og ud på skærmen. Udseendet
// "som du kender det" er opkaldt efter programmet, så det er det kort, der
// skifter navn med udgaven.
await page.goto('/indstillinger');
await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0);
});
/**
* Hver side, der KAN nås i noter-udgaven, må ikke kalde noget, der er lukket.
*
* Det er den halvdel, en port let glemmer, og den fejler stille: lukkes en rute,
* fladen stadig henter, svarer serveren 404, komponenten fanger det, og siden
* tegner uden den sektion — pænt, tomt, og uden at nogen får at vide, at noget
* manglede. Der er ingen fejlskærm at se.
*
* Målt på svarene frem for på det tegnede: en manglende sektion ser ud som en
* sektion, der ikke var noget i.
*
* Skrevet før ruterne blev lukket, og den fangede SYV — fire af dem fra den første
* port, som havde været i drift i to merges: sidebjælken hentede /filters og
* /labels ved hver navigation, notifikationssiden hentede /ai/plan, og
* notesbogens egen side hentede /tasks. Den sidste var ikke støj: kaldene samles i
* et Promise.all, så 404'eren kastede og siden viste `status = 'failed'` frem for
* notesbogens noter.
*
* Og den fangede én mere, som rettelsen selv lavede: da opgavekaldet holdt op med
* at fejle, kom en uendelig effekt-løkke frem, som 404'eren havde holdt låg på
* (effect_update_depth_exceeded). Derfor læser den konsollen og ikke kun svarene.
*
* Hvad den IKKE dækker, sagt her frem for underforstået: elleve sider plus ét
* projekt. En gruppe (/gruppe/{id}) og en åben note er ikke med, fordi begge
* kræver, at prøven først laver en — og skuffer og dialoger, der åbnes ved et
* klik, er slet ikke besøgt. En grøn her betyder "ingen af de tolv adresser",
* ikke "ingen flade".
*/
test('ingen side kalder en rute, udgaven ikke har', async ({ page }) => {
const trouble = watchForTrouble(page);
// Projektet skal med som en rigtig adresse: /projekt/{id} er en notesbog her,
// og den er den eneste side med en id i stien, der kan nås.
await page.goto('/noter');
const projekter = await (await page.request.get('/api/v1/projects')).json();
const id = (projekter.projects ?? projekter)[0]?.id;
expect(id, 'der skal findes mindst ét projekt at åbne').toBeTruthy();
const sider = [
'/noter',
'/genveje',
'/indstillinger',
'/indstillinger/ai',
'/indstillinger/brugere',
'/indstillinger/data',
'/indstillinger/fejl',
'/indstillinger/historik',
'/indstillinger/integrationer',
'/indstillinger/notifikationer',
'/indstillinger/tokens',
`/projekt/${id}`
];
for (const side of sider) {
await page.goto(side);
// Ventet på at fladen er tegnet, så de kald siden laver ved opstart er
// nået at komme afsted. Uden den her går løkken videre, før svarene er
// kommet, og prøven måler ingenting.
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await page.waitForLoadState('networkidle');
}
// Tallet med på det grønne: elleve sider og et projekt, frem for "ingen fejl".
expect(trouble, `besøgte ${sider.length} sider`).toEqual([]);
});
test('ikonet er urds, hele vejen ud', async ({ page }) => {
// Målt på stregerne i SVG'en frem for på "der kom et billede": en tom
// respons, verdandes mærke og urds ser alle ens ud for en prøve, der kun
// spørger, om der står noget.
const svg = await (await page.request.get('/icon.svg')).text();
expect(svg, 'ð: skålen').toContain('cx="32" cy="42" r="12"');
expect(svg, 'ð: ascenderen').toContain('M44 36L27 10');
expect(svg, 'ð: tværstregen, som er det der skiller ð fra et d').toContain('M25 20h18');
expect(svg, 'og ikke ᚹ wunjos flag').not.toContain('M26 15l17');
// De tre PNG'er har ingen streger at læse, så de måles på at de er urds —
// sammenlignet med filen under sit eget navn, som ligger i samme byg.
for (const [sti, egen] of [
['/icon-192.png', '/urd-icon-192.png'],
['/icon-512.png', '/urd-icon-512.png'],
['/apple-touch-icon.png', '/urd-apple-touch-icon.png']
]) {
const a = await (await page.request.get(sti)).body();
const b = await (await page.request.get(egen)).body();
expect(a.length, `${sti} er tom`).toBeGreaterThan(1000);
expect(a.equals(b), `${sti} er ikke urds`).toBe(true);
}
});
+145 -34
View File
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test';
import { USER } from './user.js';
import { watchForTrouble } from './trouble.js';
/**
* The four flows a broken build must not survive: add a task, tick it off, reach
@@ -11,14 +12,6 @@ import { USER } from './user.js';
* precisely the bug this suite exists to catch.
*/
/**
* Collects anything that went wrong quietly: a thrown exception, a console error,
* or an API call that came back a failure.
*
* Failed responses are recorded with their URL and status. "A console error
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
* 500" is.
*/
/**
* Opens the project header's overflow menu and clicks one of its items.
*
@@ -93,32 +86,6 @@ async function nyNote(side) {
return felt;
}
function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
// The browser logs its own line for every failed fetch. It carries no URL,
// so it is noise next to the response listener below.
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
trouble.push(message.text());
}
});
page.on('pageerror', (error) => trouble.push(String(error)));
page.on('response', (response) => {
const url = response.url();
if (!url.includes('/api/') || response.ok()) return;
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
// which is the correct answer on the sign-in page and after signing out.
// Every other non-ok response is worth failing over — that is what this
// watcher is for.
const path = new URL(url).pathname;
if (path.endsWith('/auth/me') && response.status() === 401) return;
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
});
return trouble;
}
/**
* Kommendes liste kan se længere frem end en uge.
*
@@ -5509,3 +5476,147 @@ test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ pag
await page.unroute('**/api/v1/notes?*');
expect(trouble).toEqual([]);
});
/**
* The control for the notes edition's renaming, and it belongs here rather than
* beside it: the whole point is that the full edition does NOT go through that
* path, and that can only be measured on an instance which is not the notes one.
*
* A gate asserted in one direction is half a gate. edition.spec.js proves the
* notes edition calls itself urd; without this, a rename that fired in both
* editions would pass there and change the name of this program.
*/
test('the full edition still calls itself verdande', async ({ page }) => {
await page.goto('/');
// Both titles, for the same reason edition.spec.js checks both: the shell's,
// which the browser reads before any script runs, and the route's own, set in
// <svelte:head> once the page draws. Today has no title of its own, so the
// shell's is what stands here — and /noter has one, so it is asked separately.
const shell = await (await page.request.get('/')).text();
expect(shell).toContain('<title>verdande</title>');
await expect(page).toHaveTitle('verdande');
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
// ᚹ wunjo, not ð.
await expect(brand.locator('.rune')).toHaveText('ᚹ');
await page.goto('/noter');
await expect(page).toHaveTitle(/· verdande$/);
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('verdande');
expect(manifest.description).toBe('Opgaver og projekter, delt.');
// The other half of the icon alias: this edition must still get Wunjo. Both
// sets of files are in the same binary, so a swap that fired in both editions
// would pass in edition.spec.js and quietly change this program's mark.
const icon = await (await page.request.get('/icon.svg')).text();
expect(icon).toContain('M26 15l17');
expect(icon).not.toContain('M44 36L27 10');
});
/**
* The second door.
*
* One instance, one database, one login — and two things that can be installed.
* This runs in the FULL edition on purpose: urd is a second face of an ordinary
* verdande, not a property of an instance, so if it only worked where
* VERDANDE_EDITION=notes it would not be the thing that was asked for.
*
* Measured on the bytes the server sends. An operating system installs a manifest,
* not a URL, so these assertions are about the manifest's `id` and the shell's
* <head> — the two things that decide whether /urd becomes a second icon on a home
* screen or a bookmark into the first app.
*/
test('urd is a second installable app on the same instance', async ({ page }) => {
const shell = await (await page.request.get('/urd')).text();
expect(shell, 'the tab, before any script runs').toContain('<title>urd</title>');
expect(shell, 'which manifest the browser fetches').toContain(
'<link rel="manifest" href="/urd.webmanifest" />'
);
// iOS looks for this one BY NAME and has historically ignored the manifest's
// icons, which is why the shell is served per door rather than patched by
// script after the page loads.
expect(shell, 'what iOS puts on a home screen').toContain(
'<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />'
);
// And the root is untouched: two doors, not a rename.
const root = await (await page.request.get('/')).text();
expect(root).toContain('<title>verdande</title>');
expect(root).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
const res = await page.request.get('/urd.webmanifest');
// A manifest served as text/html is ignored with no error anywhere; the only
// symptom is an install prompt that never appears.
expect(res.headers()['content-type']).toContain('application/manifest+json');
const urd = await res.json();
// `id` is the install identity. Without it, two manifests on one origin are one
// app with two start urls.
expect(urd.id).toBe('/urd');
expect(urd.start_url).toBe('/urd');
// Not "/urd": a scope that did not cover the origin would open the app's own
// links in a browser tab the first time somebody navigated out of /urd.
expect(urd.scope).toBe('/');
expect(urd.name).toBe('urd');
for (const icon of urd.icons) {
expect(icon.src, 'the install prompt must not show verdande’s mark').toMatch(/^\/urd-/);
}
const verdande = await (await page.request.get('/manifest.webmanifest')).json();
expect(verdande.name).toBe('verdande');
expect(verdande.id ?? '/').not.toBe('/urd');
// The shared fields come from one source, so a colour changed in the build
// reaches both doors.
expect(urd.theme_color).toBe(verdande.theme_color);
expect(urd.background_color).toBe(verdande.background_color);
});
test('entering through the urd door lands on the notes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/urd');
// Asserted on what is drawn rather than on the URL: the Go binary serves the
// shell for anything it does not recognise, so a route that no longer exists
// still answers 200 here.
await expect(page.getByRole('heading', { name: /Noter/ }).first()).toBeVisible();
// replaceState, so the phone's back gesture does not return to a page whose
// only job is to redirect.
await expect(page).toHaveURL(/\/noter$/);
// And the window now wears urd's face: its mark, its name, its tab. This is
// chrome only — the task routes are all still there and still answer, which is
// the whole reason this is one instance.
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('urd');
await expect(brand.locator('.rune')).toHaveText('ð');
await expect(page).toHaveTitle(/· urd$/);
// Nothing is withheld: the same window can still read a task route. A face that
// hid data would be the separate instance wearing a costume.
const tasks = await page.request.get('/api/v1/tasks');
expect(tasks.status(), 'the task routes are still served to the urd window').toBe(200);
// And the face survives a reload — sessionStorage, because the two doors share
// an origin. Nobody passes through /urd again after the first navigation.
await page.reload();
await expect(brand.locator('.name')).toHaveText('urd');
expect(trouble).toEqual([]);
});
test('the verdande door is unaffected by the urd one', async ({ page }) => {
// The control, and it is the half that stops the face from being a rename: a
// window that never went through /urd must be verdande, in the same browser,
// on the same origin, with the other app's sessionStorage sitting beside it.
const trouble = watchForTrouble(page);
await page.goto('/noter');
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
await expect(brand.locator('.rune')).toHaveText('\u16B9');
await expect(page).toHaveTitle(/· verdande$/);
expect(trouble).toEqual([]);
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Collects anything that went wrong quietly: a thrown exception, a console error,
* or an API call that came back a failure.
*
* Failed responses are recorded with their URL and status. "A console error
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
* 500" is.
*
* Udskilt fra smoke.spec.js, så udgave-suiten kan bruge den samme — og den
* fortjente flytningen med det samme: lukker man en rute, fladen stadig kalder,
* står der en 404 på en side, der ser helt rigtig ud, og det her er det eneste,
* der ser den.
*/
export function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
// The browser logs its own line for every failed fetch. It carries no URL,
// so it is noise next to the response listener below.
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
trouble.push(message.text());
}
});
page.on('pageerror', (error) => trouble.push(String(error)));
page.on('response', (response) => {
const url = response.url();
if (!url.includes('/api/') || response.ok()) return;
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
// which is the correct answer on the sign-in page and after signing out.
// Every other non-ok response is worth failing over — that is what this
// watcher is for.
const path = new URL(url).pathname;
if (path.endsWith('/auth/me') && response.status() === 401) return;
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
});
return trouble;
}
+83 -36
View File
@@ -20,6 +20,7 @@ import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const repo = join(here, '..');
const dataDir = join(here, '.playwright-data');
const notesDataDir = join(here, '.playwright-data-notes');
// A clean instance every run: the first test creates the first account, which only
// works on an empty database.
@@ -31,9 +32,14 @@ const dataDir = join(here, '.playwright-data');
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
if (process.env.TEST_WORKER_INDEX === undefined) {
rmSync(dataDir, { recursive: true, force: true });
rmSync(notesDataDir, { recursive: true, force: true });
}
const PORT = 8097;
// Noter-udgaven kører som sin egen server ved siden af den fulde. Den kan ikke
// dele port, og den må ikke dele database: de to er to instanser, og en prøve der
// lod dem skrive i den samme ville måle en tilstand, ingen af dem ejer.
const NOTES_PORT = 8098;
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
// every browser makes. The passkey test cannot run against a loopback address.
@@ -72,6 +78,10 @@ export default defineConfig({
{ name: 'setup', testMatch: /.*\.setup\.js/ },
{
name: 'chromium',
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
testIgnore: /edition\.spec\.js/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup']
},
@@ -95,6 +105,21 @@ export default defineConfig({
*
* WebKit here is the same engine Safari is, which is the whole point.
*/
/**
* Noter-udgaven, som sit eget program.
*
* Egen server, egen port, egen database, og ingen `storageState`: den her
* instans har aldrig set den konto, den fulde udgaves opsætning lavede, og
* at låne dens cookie ville prøve to instanser som var de én.
*
* Den deler ikke `setup` af samme grund — den laver sin egen første konto.
*/
{
name: 'notes-edition',
testMatch: /edition\.spec\.js/,
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
},
{
name: 'webkit-forms',
grep: /@forms/,
@@ -103,42 +128,64 @@ export default defineConfig({
}
],
webServer: {
// The whole chain, every run.
//
// The frontend is embedded in the binary at compile time, from a copy under
// cmd/verdande/webbuild — so a suite that merely started the server would
// test whatever frontend was copied there last, which during development is
// nearly always the wrong one. Testing a stale build is worse than not
// testing: it reports green on code that is not running.
command: [
'npm run build',
'rm -rf ../cmd/verdande/webbuild',
'cp -r build ../cmd/verdande/webbuild',
'cd .. && go run -tags embedweb ./cmd/verdande'
].join(' && '),
cwd: here,
url: `${baseURL}/healthz`,
reuseExistingServer: false,
// The first run compiles the binary and builds nothing else; a cold Go
// build on a CI runner is comfortably under two minutes.
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
// different things: the suite's address is where the browser goes, and
// this one is what the server calls itself — which is now also the
// WebAuthn relying party id, and WebAuthn refuses an IP address.
webServer: [
{
// The whole chain, every run.
//
// The passkey test navigates to localhost explicitly so its origin
// matches. Everything else stays on the loopback address, where it has
// always been.
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
// The frontend is embedded in the binary at compile time, from a copy under
// cmd/verdande/webbuild — so a suite that merely started the server would
// test whatever frontend was copied there last, which during development is
// nearly always the wrong one. Testing a stale build is worse than not
// testing: it reports green on code that is not running.
command: [
'npm run build',
'rm -rf ../cmd/verdande/webbuild',
'cp -r build ../cmd/verdande/webbuild',
'cd .. && go run -tags embedweb ./cmd/verdande'
].join(' && '),
cwd: here,
url: `${baseURL}/healthz`,
reuseExistingServer: false,
// The first run compiles the binary and builds nothing else; a cold Go
// build on a CI runner is comfortably under two minutes.
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
// different things: the suite's address is where the browser goes, and
// this one is what the server calls itself — which is now also the
// WebAuthn relying party id, and WebAuthn refuses an IP address.
//
// The passkey test navigates to localhost explicitly so its origin
// matches. Everything else stays on the loopback address, where it has
// always been.
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
}
},
// Den samme binær en gang til, med den anden udgave slået til. Bygningen er
// allerede lavet af serveren ovenfor — `go run` genbruger byggecachen — så
// det her koster en opstart og ikke en oversættelse.
{
command: 'cd .. && go run -tags embedweb ./cmd/verdande',
cwd: here,
url: `http://127.0.0.1:${NOTES_PORT}/healthz`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${NOTES_PORT}`,
VERDANDE_BASE_URL: `http://localhost:${NOTES_PORT}`,
VERDANDE_DATA_DIR: notesDataDir,
VERDANDE_EDITION: 'notes',
VERDANDE_UPDATE_CHECK: 'false'
}
}
}
]
});
+47 -16
View File
@@ -1,40 +1,71 @@
/**
* Renders static/icon.svg to the PNGs the manifest and iOS need.
* Renders each edition's icon SVG to the PNGs the manifest and iOS need.
*
* node scripts/generate-icons.js
*
* A script rather than a test, so CI never runs it and never rewrites a tracked
* binary. Run it when the icon changes; the smoke test fetches every icon the
* binary. Run it when an icon changes; the smoke test fetches every icon the
* manifest names, so a reference with no file behind it fails there.
*
* It drives Chromium because there is no SVG renderer on this machine — which is
* why these references were once removed rather than left pointing at files that
* did not exist. Playwright is a renderer, and it is already a dependency.
*
* Two sets, because there are two editions. Both ship in the one binary and the
* server decides which to serve at /icon.svg and friends — see
* internal/httpapi/shell.go. The alternative, rewriting the manifest to point at
* urd-icon-192.png, would have left `apple-touch-icon.png` behind: Safari looks
* for that one BY NAME and ignores the manifest entirely, so a renamed path there
* is a home-screen icon that is a screenshot of the page.
*/
import { chromium } from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
// The three sizes, and why each is here.
const SIZES = [
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
{ file: 'static/icon-192.png', size: 192 },
{ file: 'static/icon-512.png', size: 512 },
// And the one Safari wants by name. It ignores the manifest's icons entirely
// and looks for this, which is why an installed PWA on iOS otherwise gets a
// screenshot of the page as its home-screen icon.
{ file: 'static/apple-touch-icon.png', size: 180 }
{ suffix: '-192.png', size: 192 },
{ suffix: '-512.png', size: 512 },
// And the one Safari wants by name.
{ suffix: '-apple-touch.png', size: 180 }
];
// `out` is spelled out per set rather than built from the prefix, because
// verdande's three were named before there was a second set and renaming them
// would move the paths the manifest and app.html already point at.
const SETS = [
{
source: 'static/icon.svg',
out: {
'-192.png': 'static/icon-192.png',
'-512.png': 'static/icon-512.png',
'-apple-touch.png': 'static/apple-touch-icon.png'
}
},
{
source: 'static/urd-icon.svg',
out: {
'-192.png': 'static/urd-icon-192.png',
'-512.png': 'static/urd-icon-512.png',
'-apple-touch.png': 'static/urd-apple-touch-icon.png'
}
}
];
const svg = readFileSync('static/icon.svg', 'utf8');
const browser = await chromium.launch();
const page = await browser.newPage();
for (const { file, size } of SIZES) {
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
);
writeFileSync(file, await page.locator('svg').screenshot());
console.log(`${file} ${size}×${size}`);
for (const set of SETS) {
const svg = readFileSync(set.source, 'utf8');
for (const { suffix, size } of SIZES) {
const file = set.out[suffix];
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
);
writeFileSync(file, await page.locator('svg').screenshot());
console.log(`${file} ${size}×${size}`);
}
}
await browser.close();
+1 -1
View File
@@ -64,7 +64,7 @@
{#if notice}
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
<h2>{t('beacon.noticeTitle')}</h2>
<p>{t('beacon.noticeBody')}</p>
<p>{t('beacon.noticeBody', { product: app.productName })}</p>
<!-- Det, der bliver sendt, med de rigtige værdier i. Ikke et eksempel: det
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
+5 -1
View File
@@ -85,7 +85,11 @@
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
...tasks.map((t) => ({
// Serveren sender ingen opgaver i noter-udgaven, så listen er alligevel tom.
// Spurgt her også, fordi en tom liste af den rigtige grund og en tom liste
// ved et tilfælde ser ens ud — og den dag nogen mounter søgningen anderledes,
// skal ⌘K ikke være stedet, det viser sig.
...(app.notesOnly ? [] : tasks).map((t) => ({
kind: 'task',
id: t.id,
label: t.content,
+1 -1
View File
@@ -38,7 +38,7 @@
</div>
<h2>{t('net.downTitle')}</h2>
<p class="lede">{t('net.downBody')}</p>
<p class="lede">{t('net.downBody', { product: app.productName })}</p>
<button onclick={again} disabled={trying}>
{trying ? t('net.reconnecting') : t('net.downRetry')}
+21 -9
View File
@@ -73,6 +73,11 @@
// through the connector.
$effect(() => {
app.labelsChanged;
// Ikke i noter-udgaven: begge ruter findes slet ikke der, så de to kald var
// to 404'ere ved hver navigation — opslugt af .catch() og derfor usynlige.
// Fundet af vandringen i edition.spec.js, efter at porten havde været i
// drift i to merges.
if (app.notesOnly) return;
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
});
@@ -155,9 +160,12 @@
// entry is a question the sidebar keeps asking and answering "no".
let navItems = $derived(
[
{ key: 'today', href: '/', label: 'nav.today' },
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
app.projects.some((p) => p.shared)
// Opgavernes egne indgange findes ikke i noter-udgaven. Ikke gråtonet og
// ikke skjult med CSS — udeladt, fordi ruterne bag dem ikke er monteret
// på serveren. Et link til noget, der ikke findes, er ikke en skønhedsfejl.
app.notesOnly ? null : { key: 'today', href: '/', label: 'nav.today' },
app.notesOnly ? null : { key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
!app.notesOnly && app.projects.some((p) => p.shared)
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
: null,
// No href: the inbox is a project and is drawn by projectRow, which knows
@@ -185,7 +193,7 @@
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
// den et andet sted her, ser en ny konto én rækkefølge og alle
// eksisterende en anden.
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
app.notesOnly ? null : { key: 'calendar', href: '/kalender', label: 'nav.calendar' }
].filter(Boolean)
);
@@ -472,11 +480,15 @@
<nav class="sidebar" aria-label={t('nav.main')}>
<div class="brand">
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
starts with in the elder futhark. One glyph, no wordmark beside it —
the name is in the tab and everywhere else already. -->
<span class="rune" aria-hidden="true">ᚹ</span>
<span class="name">verdande</span>
<!-- The mark — ᚹ Wunjo for Verdande, ð for Urd. Not two runes, though it
was meant to be: uruz is already another of this author's apps and
carries that glyph. See web/static/urd-icon.svg.
Both come from the store rather than from here, because the two editions
of this program are two products with two names, and a wordmark is the
one place a reader decides which one they are looking at. -->
<span class="rune" aria-hidden="true">{app.productMark}</span>
<span class="name">{app.productSlug}</span>
</div>
<div class="views">
+3 -3
View File
@@ -145,7 +145,7 @@ export const da = {
// læserens mulighed for at efterprøve det.
'beacon.noticeTitle': 'Denne installation melder, at den findes',
'beacon.noticeBody':
'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
'Én gang i døgnet sender {product} to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
'beacon.noticeTo': 'Sendes til {url}',
'beacon.noticeKeep': 'Behold den',
'beacon.noticeStop': 'Slå den fra',
@@ -238,7 +238,7 @@ export const da = {
'net.reconnecting': 'Genopretter forbindelse…',
'net.offline': 'Ingen forbindelse',
'net.downTitle': 'Ingen forbindelse',
'net.downBody': 'Verdande kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
'net.downBody': '{product} kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
'net.downRetry': 'Prøv igen',
'nav.doItToday': 'Gør det i dag',
@@ -353,7 +353,7 @@ export const da = {
'size.default': 'Standard',
'size.large': 'Stor',
'size.xl': 'Størst',
'look.verdande': 'Verdande',
'look.verdande': '{product}',
'look.verdandeNote': 'Som du kender den.',
'look.rolig': 'Rolig',
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
+3 -3
View File
@@ -142,7 +142,7 @@ export const en = {
// the reader's ability to check it.
'beacon.noticeTitle': 'This installation reports that it exists',
'beacon.noticeBody':
'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
'Once a day {product} sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
'beacon.noticeTo': 'Sent to {url}',
'beacon.noticeKeep': 'Keep it on',
'beacon.noticeStop': 'Turn it off',
@@ -235,7 +235,7 @@ export const en = {
'net.reconnecting': 'Reconnecting…',
'net.offline': 'No connection',
'net.downTitle': 'No connection',
'net.downBody': 'Verdande cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
'net.downBody': '{product} cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
'net.downRetry': 'Try again',
'nav.doItToday': 'Do it today',
@@ -350,7 +350,7 @@ export const en = {
'size.default': 'Default',
'size.large': 'Large',
'size.xl': 'Largest',
'look.verdande': 'Verdande',
'look.verdande': '{product}',
'look.verdandeNote': 'As you know it.',
'look.rolig': 'Calm',
'look.roligNote': 'Serif, more air. For reading and writing.',
+142
View File
@@ -43,6 +43,109 @@ class AppState {
*/
unreachable = $state(false);
/**
* Hvilken udgave af programmet den her instans er.
*
* `full` er opgaver, projekter og noter sammen. `notes` er notehalvdelen, hvor
* opgaveruterne slet ikke er monteret på serveren — så fladen må ikke tilbyde
* en vej til dem. Et link til en rute, der ikke findes, er ikke en skønhedsfejl:
* det er en knap, der fører et sted hen, hvor der ikke er noget.
*
* Instansens form, ikke personens rettigheder. Derfor hentes den ved siden af
* kontoen frem for efter den: den skal også være kendt, når ingen er logget ind.
*
* `full` som udgangspunkt, fordi det er det, programmet altid har været — en
* instans, der ikke når at svare, skal ikke pludselig se ud som et andet
* program.
*/
edition = $state('full');
get notesOnly() {
return this.edition === 'notes';
}
/**
* Hvilken dør dette vindue kom ind ad.
*
* HOLDT ADSKILT FRA `notesOnly` med vilje, og det er den vigtigste linje i
* filen her. De to ligner hinanden og betyder ikke det samme:
*
* notesOnly — serveren HAR ingen opgaveruter. En vagt: fladen må ikke hente
* dem, for de svarer 404.
* urdFace — personen kom ind ad urd-døren. En præference: ruterne er der,
* dataene er deres egne, og der er intet at holde tilbage.
*
* Slås de sammen, begynder urd-ansigtet at skjule data frem for pynt — en
* notesbog ville holde op med at vise sine opgaver, selvom de ligger i samme
* database og personen bare valgte en anden indgang.
*
* Læst fra sessionStorage frem for localStorage, fordi de to apps deler origin
* og dermed deler localStorage. sessionStorage er per vindue, og en installeret
* PWA er sit eget vindue.
*/
urdFace = $state(false);
enterUrd() {
this.urdFace = true;
try {
sessionStorage.setItem('verdande:door', 'urd');
} catch (e) {
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
// mister det.
}
}
/**
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
*
* To udgaver af ét program, to navne. Grunden til at det læses fra udgaven
* frem for at stå skrevet i teksterne: for den der bruger en instans uden
* opgaveruter er det et andet produkt, og et produkt, der kalder sig sin
* søsters navn, er ikke et produkt — det er et flag, nogen har sat.
*
* Vinduets titel og manifestet står der IKKE. De læses af browseren, før
* noget JavaScript kører, så serveren skriver dem om på vejen ud
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
* vise søsterens navn et øjeblik og så rette sig selv.
*/
/**
* Om DETTE vindue viser urd — enten fordi instansen ikke har opgaveruter, eller
* fordi personen kom ind ad urd-døren.
*
* De to grunde er forskellige og må ikke slås sammen andre steder end her: det
* her er navnet og mærket, altså pynt. `notesOnly` alene er vagten, der afgør,
* om der overhovedet er en rute at hente fra.
*/
get showsUrd() {
return this.notesOnly || this.urdFace;
}
get productName() {
return this.showsUrd ? 'Urd' : 'Verdande';
}
get productSlug() {
return this.showsUrd ? 'urd' : 'verdande';
}
/**
* Mærket: ᚹ wunjo for Verdande, ð for Urd.
*
* Ikke to runer, selvom det var meningen. Urds oplagte mærke var ᚢ uruz,
* bogstavet navnet begynder med i den ældre futhark, præcis som Verdandes er
* wunjo — og det blev tegnet sådan først. Men uruz er allerede et af husets
* egne produkter, og dets `gen-icons.ts` tegner samme glyf i guld. To af samme
* persons apps side om side på en hjemmeskærm, hvilket er nøjagtig det sted,
* mærket skal kunne skelnes. ᚦ thurisaz blev forkastet et niveau nede af samme
* grund: en stav med en trekant til højre, og det er ᚹ også.
*
* Så bogstavet frem for runen. Hele historien står i web/static/urd-icon.svg.
*/
get productMark() {
return this.showsUrd ? 'ð' : '\u16B9';
}
/** Transient messages: a failed save, a rolled-back change. */
toasts = $state([]);
connected = $state(false);
@@ -108,6 +211,26 @@ class AppState {
async load() {
this.loading = true;
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
// linje ville ansigtet falde af ved hvert F5.
try {
if (sessionStorage.getItem('verdande:door') === 'urd') this.urdFace = true;
} catch (e) {
// Privat browsing nægter. Så holder ansigtet vinduet ud og ikke længere.
}
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
const shape = api
.setupState()
.then((state) => {
if (state?.edition) this.edition = state.edition;
})
.catch(() => {});
try {
this.user = await api.me();
this.unreachable = false;
@@ -131,6 +254,10 @@ class AppState {
// arriving. A 401 is the server answering, and that is a real sign-out.
this.unreachable = e?.code === 'offline';
} finally {
// Afventet her, så fladen aldrig tegnes, før den ved hvilket program den
// er. Tegnet først og rettet bagefter ville vise opgaver i et sekund på
// en instans, der ikke har nogen.
await shape;
this.loading = false;
}
}
@@ -278,6 +405,21 @@ class AppState {
* `completed: 'exclude'`.
*/
async loadTasks(params) {
// Noter-udgaven har ingen opgaveruter, så den her ville kaste en 404 — og
// kalderne samler den i et Promise.all, hvor én fejl tager hele siden med.
// Vagten står HER frem for ved de tre kaldesteder, fordi butikken er det
// ene sted, der kender udgaven: en vagt pr. kalder er tre steder at glemme
// den fjerde, og det var præcis sådan den her slap igennem.
if (this.notesOnly) {
// Skrevet kun hvis der ER noget at rydde. En ubetinget tilskrivning
// looper: projektsiden har en effekt, der læser app.tasks og kalder
// herind, så et skriv ved hvert kald er en uendelig runde —
// effect_update_depth_exceeded, ikke en 404. Før vagten kastede kaldet
// på 404'eren og nåede aldrig at skrive, så løkken var skjult bag en
// fejl.
if (this.tasks.length) this.tasks = [];
return this.tasks;
}
const { tasks } = await api.listTasks({
completed: completedView.shown ? 'include' : undefined,
...params
+33
View File
@@ -57,6 +57,39 @@
app.load();
});
/**
* Opgavernes sider findes ikke i noter-udgaven.
*
* Ét sted frem for en vagt i hver rute: listen ER beslutningen, og en vagt pr.
* fil er syv steder at glemme den ottende. Skrevet som præfikser, fordi
* `/opgave/<id>` og `/filter/<id>` er sider under dem.
*
* Sendt til noterne og ikke til en fejlside: der er ikke sket noget forkert.
* Adressen hører bare til et andet program end det, der kører her — og nogen,
* der har gemt et link fra en fuld instans, skal lande et sted, der giver
* mening frem for i en blindgyde.
*
* `replaceState`, så Tilbage ikke fører hen til den side, man lige blev sendt
* væk fra, og derfra tilbage igen.
*/
const TASK_PAGES = [
'/',
'/upcoming',
'/uddelegeret',
'/kalender',
'/faerdige',
'/opgave',
'/filter',
'/etiket'
];
$effect(() => {
if (!app.notesOnly) return;
const here = $page.url.pathname;
const taskPage = TASK_PAGES.some((p) => here === p || (p !== '/' && here.startsWith(p + '/')));
if (taskPage) goto('/noter', { replaceState: true });
});
/**
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
*
+1 -1
View File
@@ -75,7 +75,7 @@
}
</script>
<svelte:head><title>{t('done.title')} — verdande</title></svelte:head>
<svelte:head><title>{t('done.title')} — {app.productSlug}</title></svelte:head>
<div class="view">
<header>
+1 -1
View File
@@ -126,7 +126,7 @@
}
</script>
<svelte:head><title>{group?.name ?? t('group.projects')} — verdande</title></svelte:head>
<svelte:head><title>{group?.name ?? t('group.projects')} — {app.productSlug}</title></svelte:head>
<div class="view">
{#if status === 'denied'}
+4 -1
View File
@@ -379,7 +379,10 @@
<!-- Each card is written in the face it selects, because that is the
only part of the choice a name cannot carry. -->
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
<span class="theme-name">{t(option.name)}</span>
<!-- The product name is passed to every look, and only one of them has a
place for it: the look named after the program. The other three
carry no placeholder, so the parameter is ignored. -->
<span class="theme-name">{t(option.name, { product: app.productName })}</span>
<span class="theme-note">{t(option.note)}</span>
</button>
{/each}
@@ -141,6 +141,13 @@
let startDate = $state('');
$effect(() => {
// Skabeloner er projekt plus sektioner plus opgaver, så ruten findes ikke i
// noter-udgaven — og den her fangede fejlen i en toast, altså en synlig
// fejlbesked på indstillingssiden frem for en tom liste.
if (app.notesOnly) {
loadingTemplates = false;
return;
}
api
.listTemplates()
.then((r) => (templates = r.templates))
@@ -296,7 +296,11 @@
let mailConfigured = $state(null);
$effect(() => {
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
// Feedet er en kalender af opgaver, så adressen findes ikke i
// noter-udgaven.
if (!app.notesOnly) {
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
}
api
.getMailAddress()
.then((r) => {
@@ -92,6 +92,8 @@
let planBusy = $state(false);
$effect(() => {
// Dagens plan er dagens opgaver.
if (app.notesOnly) return;
api
.planSettings()
.then((p) => (plan = p))
+5 -1
View File
@@ -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';
</script>
<svelte:head><title>Invitation · verdande</title></svelte:head>
<svelte:head><title>Invitation · {app.productSlug}</title></svelte:head>
<SignIn />
+1 -1
View File
@@ -933,7 +933,7 @@
</script>
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
<svelte:head><title>{t('notes.title')} · {app.productSlug}</title></svelte:head>
<svelte:window
onclick={onShareOutside}
+6 -1
View File
@@ -88,9 +88,14 @@
if (!projectId) return;
status = 'loading';
try {
// Sektioner findes ikke i noter-udgaven, hvor et projekt er en notesbog
// — og det er ikke bare støj: Promise.all kaster på den første, der
// fejler, så en 404 herfra satte status = 'failed', og siden viste en
// fejlskærm frem for notesbogens noter. `loadTasks` vogter sig selv i
// butikken, fordi den har tre kaldesteder her.
const [p, s] = await Promise.all([
api.getProject(projectId),
api.listSections(projectId),
app.notesOnly ? Promise.resolve({ sections: [] }) : api.listSections(projectId),
app.loadTasks({ project_id: projectId })
]);
project = p;
+5 -1
View File
@@ -8,8 +8,12 @@
* fill in. Same shape as the invite link, and found the same way.
*/
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';
</script>
<svelte:head><title>Ny adgangskode · verdande</title></svelte:head>
<svelte:head><title>Ny adgangskode · {app.productSlug}</title></svelte:head>
<SignIn />
+33
View File
@@ -0,0 +1,33 @@
<script>
/**
* Urds dør.
*
* Serveren svarer på /urd med sin egen skal — egen titel, eget mærke og et link
* til sit eget manifest — og det er dét, styresystemet installerer. Siden her er
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
* går videre til noterne.
*
* `sessionStorage` og ikke `localStorage`, og det er hele grunden til at de to
* apps kan være to apps: de deler origin, så de deler localStorage, cookies og
* service worker. sessionStorage er per vindue — og en installeret PWA er sit
* eget vindue — så urd-appen har sit ansigt og verdande-appen sit, uden at
* træde på hinanden. Det overlever også en genindlæsning, hvilket det skal:
* efter et par klik står adressen på /noter, og en F5 dér går ikke gennem /urd
* igen.
*
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
* der kun omdirigerer.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
onMount(() => {
app.enterUrd();
goto('/noter', { replaceState: true });
});
</script>
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville
være et glimt af noget, ingen nåede at læse. -->
<svelte:head><title>urd</title></svelte:head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Urd's mark: ð, the eth in Urðr. Drawn in Verdande's strokes so the two read
as one family — same width, same round caps, same plate.
NOT a rune, and that is the whole story of this file. The obvious mark was
ᚢ uruz, the letter the name begins with in the elder futhark, exactly as
Verdande's is ᚹ wunjo. It was drawn that way first. Then: uruz is already
one of this author's products, and uruz/scripts/gen-icons.ts says in its own
comment that it "draws the Uruz rune (ᚢ) in rune-gold" with "a tall left
vertical, a shoulder across the top, and a shorter" one — the same glyph,
the same construction, differing only in colour. Two of one person's apps
side by side on a home screen, which is precisely where this mark is meant
to be distinguishable.
ᚦ thurisaz was considered next, for the ð itself, and rejected for the same
reason one level down: it is a stave with a triangle on its right, and so is
ᚹ. At 16px they are one shape.
So the letter rather than the rune. ð is unmistakably Nordic, nothing else
here uses it, and it is a closed bowl where both neighbours are open staves.
Centred by counting painted pixels — white on black, headless Chromium, the
plate removed so the card is not what gets measured — rather than from the
path numbers, because a centred box is not a centred glyph:
verdande ᚹ ink x 53–108 centre 80.5 · y 18–131 centre 74.5
urd ð ink x 39–110 centre 74.5 · y 15–134 centre 74.5
Verdande's +5.5 in x is its own deliberate nudge: a heavy stave beside a
light flag has to sit right of centre for the ink to look centred. ð is
symmetric enough not to need it, and the two agree exactly in y.
Legibility, measured rather than hoped: it reads as ð at 32px and above. At
16px it is a blob — and so is ᚹ, and a favicon is not what tells two
installed apps apart in a dock. -->
<rect width="64" height="64" rx="14" fill="#111312"/>
<g fill="none" stroke="#5fbf8f" stroke-width="6.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="32" cy="42" r="12"/>
<path d="M44 36L27 10"/>
<path d="M25 20h18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+31
View File
@@ -14,6 +14,37 @@ export default {
}),
alias: {
$lib: 'src/lib'
},
/**
* The build's own version string, which SvelteKit puts in
* `_app/version.json` and the service worker reads to decide whether a new
* version has been deployed.
*
* Its default is `Date.now()`, and that made every build of this frontend
* produce different bytes: the millisecond lands in version.json, in
* index.html and in service-worker.js, which changes the content hash of
* every chunk that references them, which changes those chunks' filenames.
* So two builds of the same commit had different file names in them.
*
* That is not an aesthetic problem, and it had two costs. The Dockerfile
* said `-trimpath` and an empty buildid "keep the output reproducible", and
* that sentence had been false for a year — the flags did their job and the
* frontend embedded in the binary carried a millisecond. And the Dockerfile
* builds one binary into two images, verdande and urd, where the claim is
* that they are the same program rather than a fork; with a timestamp in the
* build, that claim could not be checked, because no two builds ever
* matched. CI checks it now.
*
* Measured by building twice and diffing the tree, before and after.
*
* `dev` when nothing sets it, so a local build is stable too. The cost is
* that a local `npm run build` no longer looks like a new version to the
* service worker — which only matters on a deployed instance, where the
* release workflow passes the real tag.
*/
version: {
name: process.env.VERDANDE_VERSION ?? 'dev'
}
}
};