Compare commits

..
378 Commits
Author SHA1 Message Date
Kristian Wind 95e02b567b Merge pull request #104 from kristianwind/billedvisning-stoerre
CI / OpenAPI is valid (push) Successful in 18s
CI / End-to-end (push) Failing after 4m58s
CI / Go (push) Failing after 5m30s
CI / Docker image builds (push) Successful in 2m39s
fix(noter): et åbnet billede fylder skærmen og kan ses i fuld størrelse
2026-10-18 23:46:13 +02:00
kristianwindandClaude Opus 5.5 93470259d2 fix(noter): et åbnet billede fylder skærmen og kan ses i fuld størrelse
Billedvisningen havde kun max-width/max-height, så den kunne gøre et billede
mindre og aldrig større. Målt i den rigtige app: et 600×400-billede stod 600 px
i noten og 600 i visningen, og et højt telefon-skærmbillede stod 701 px bredt i
noten og 357 i visningen. På en telefon var der ingen forskel overhovedet.

Nu fylder billedet skærmen (også når det skal forstørres), og et klik på
billedet — eller knappen "Fuld størrelse" — viser det i egne pixels, aldrig
smallere end skærmen, med rulning hen til det sted, man klikkede. Knapperne
ligger fast nederst, så de kan nås, uanset hvor man har rullet hen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:37:10 +02:00
Kristian Wind 6a5228aed8 Merge pull request #103 from kristianwind/alarm-i-saetningen
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m53s
CI / Docker image builds (push) Successful in 3m38s
Release / Build and publish image (push) Failing after 9s
CI / Go (push) Failing after 27m13s
feat(opgaver): alarmen kan tales ind, og kan være standard
2026-10-08 20:40:44 +02:00
kristianwindandClaude Opus 5 9155e799b1 fix(prøver): markør-prøverne venter på editoren, før de taster
Runde 3 og 4 af de røde End-to-end var samme årsag. Det faste ophold, jeg
fjernede i forrige commit, skjulte den bag en anden påstand.

`page.keyboard.type` kørte, før editoren var tegnet. `click()` på "Ny note"
vender tilbage, så snart klikket er sendt, mens noten først skal oprettes
på serveren og tegnes bagefter. Så gik det tastede ind i den FORRIGE note
eller ingen steder, og noten blev liggende tom:

* runde 3 fejlede på, at noten ikke fandtes efter genindlæsningen
* runde 4 fejlede på, at der ikke kom nogen PATCH at vente på

To forskellige påstande, én årsag.

Og svaret stod skrevet over `nyNote` i samme fil, før prøverne blev
skrevet:

    Et klik på "Ny note" efterfulgt af tastetryk med det samme skriver
    derfor i den forrige note — teksten havner det forkerte sted, og den
    nye note bliver liggende tom. Efter en genindlæsning findes den titel,
    prøven leder efter, slet ikke.

Det er runde 3's fejl ord for ord. Men `nyNote` KLIKKER i feltet, og det er
netop det, markør-prøverne ikke må: de måler, at markøren står der, uden at
nogen har sat den. Derfor `nyTomNote` — samme ventetid, uden klikket.

Ankret på <h1> frem for blot et tomt felt: <h1> skrives af samme effekt,
der sætter markøren, så når den er der, er markøren sat. Et tomt felt kan
være et felt, effekten ikke har rørt endnu.

Kontrolleret i begge retninger med 1500 ms plantet på POST /notes: det rå
klik fejler med runde 4's ordlyd, `nyTomNote` består.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 20:29:59 +02:00
kristianwindandClaude Opus 5 5764271579 fix(prøver): kontrollen venter på gemningen, ikke på et tal
Fjerde røde End-to-end, fjerde reelle årsag, og denne stod skrevet i
forvejen.

"en note med indhold stjæler ikke markøren" brugte `waitForTimeout(1200)`
til at vente på, at noten blev gemt. På en travl runner var 1200 ms ikke
nok: genindlæsningen smed det skrevne væk, og prøven faldt på at noten
ikke FANDTES — `waiting for getByRole({ name: /Allerede skrevet/ })` —
frem for på markøren, som er det den måler.

Kommentaren over `gemt` i toppen af den samme fil siger det ord for ord:

    En fast ventetid er den samme fejl med et tal foran:
    `waitForTimeout(1200)` er et gæt om, hvor hurtig maskinen er, og det
    gæt holder indtil CI er travl.

Jeg skrev præcis det kald, med præcis det tal, under en kommentar der
navngiver begge. Lektien var ikke ny — den var skrevet ned, i filen, lige
over den hjælper der findes for at erstatte det.

Bruger nu `noteGemt(page)`, sat op FØR der skrives: svaret på PATCH'en er
det eneste, der beviser, at serveren har hørt om det.

Kontrolleret i begge retninger med 2500 ms plantet på PATCH /notes/* —
mere end dobbelt så længe som det gamle ophold ventede. Det gamle kald
fejler med CI's ordlyd; `noteGemt` består.

Der står 24 faste ophold tilbage i filen. De er ældre end denne gren, og
nogle venter måske på noget uden et netværkskald, så de hører i deres egen
rettelse — men mønsteret er nu dokumenteret to gange i samme fil.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 20:12:07 +02:00
kristianwindandClaude Opus 5 8a6329bfc9 fix(prøver): markør-prøverne venter på markøren i stedet for at håbe
CI's End-to-end faldt på "en ny note åbner med markøren i overskriften" med
`Expected: "h1", Received: null`, på en gren hvis eneste ændring siden
forrige grønne kørsel var docs/openapi.yaml. Den kan ikke påvirke en
editor, så fejlen lå i prøven.

Den klikkede "Ny note" og læste markøren i ÉT snapshot med det samme.
`click()` vender tilbage, så snart klikket er sendt, mens markøren sættes
efter en netværksrundtur OG en gentegning. Lokalt er rundturen under et
millisekund, så snapshottet ramte altid; på en travl runner ramte det null.
main er grøn på netop den prøve, så den bestod ved held frem for ved regel
— og den havde ligget og ventet på en travl runner i en fremmed PR, hvor
den ville have lignet dén PRs fejl.

Målt frem for ræsonneret: med 900 ms plantet på POST /notes fejler den
gamle læsning med CI's ordlyd og `expect.poll` består.

Tre af de fire havde samme svaghed. Kontrollen — "en note med indhold
stjæler ikke markøren" — er bevidst IKKE lavet om: den forventer null, så
en poll ville bestå på første læsning uanset hvad koden gjorde. Den ankrer
sig på overskriftens tekst i stedet. Det står som kommentar i filen, så den
ikke bliver "rettet" tilbage.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 19:53:12 +02:00
kristianwindandClaude Opus 5 fb8abcaa74 fix(docs): nullable er ikke 3.1, og eksemplet hørte til nabofeltet
To fejl i samme blok, begge mine.

`nullable: true` er OpenAPI 3.0. Specen er 3.1, hvor det blev fjernet til
fordel for en type-union, og den havde allerede fortilfældet stående:
`type: [integer, "null"]`. Redocly afviste de to steder som struktur-fejl.

Og min lokale kontrol stillede et svagere spørgsmål end CI's: jeg læste
filen med `yaml.safe_load` og fik "gyldig" tilbage. Den svarede på, om det
er YAML — ikke om det er en gyldig OpenAPI. CI's egen validator kører nu
lokalt, før den kører der.

Den anden fejl fandt kun en ADVARSEL: `example: ["labels"]` tilhørte
sidebar_collapsed, og min indsættelse landede før den, så en strengliste
hang på et heltalsfelt. Ankeret dækkede ikke hele blokken, jeg skød ind
efter. CI fejler kun på fejl, så den var sluppet igennem — på to felter ad
gangen, for sidebar_collapsed stod samtidig uden sit eget eksempel.

Advarsler: 253, som før ændringen. Specen er gyldig.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 19:34:05 +02:00
kristianwindandClaude Opus 5 956e57f234 feat(opgaver): alarmen kan tales ind, og kan være standard
To halvdele af det samme: at en opgave med et klokkeslæt også kan få en
alarm, uden at man går en omvej hver gang.

Påmindelser har virket længe og skulle sættes én ad gangen. En opgave
dikteret som "i morgen kl 9" fik en tid og INGEN alarm — `CreateReminder`
blev kun kaldt fra sit eget endepunkt, så intet skabte en påmindelse af sig
selv. Derefter skulle man åbne opgaven, finde Påmindelser og vælge et
klokkeslæt, og den omvej er længere end at skrive opgaven.

SÆTNINGEN

Hurtig tilføjelse forstår nu alarmen:

    ring til tandlægen i morgen kl 9 #hjem mind mig en time før

Formerne er `mind mig` / `påmind` / `remind me` / `med alarm`, efterfulgt af
en varighed (`en time før`, `10 min før`, `et kvarter før`, `en halv time
før`, `dagen før`, `to dage før`) eller et klokkeslæt (`mind mig kl 8`).
Bar "mind mig" betyder på selve tidspunktet. Dansk og engelsk i samme
linje, som resten af parseren.

Det gælder uanset hvor stemmen kommer ind: tastaturets diktering, en
genvej, eller noget tredje ender som tekst gennem den samme parser. Derfor
er syntaksen forudsætningen for dem alle frem for én af dem.

Trinnet ligger FØR klokke og dato, og den rækkefølge er hele tricket: "mind
mig en time før" har en varighed i sig og "mind mig kl 8" har en klokke, og
begge læst af tidsparseren først bliver opgavens egen forfaldstid i stedet
for dens alarm.

STANDARDEN

En indstilling under Notifikationer giver opgaver med både dato og
klokkeslæt en alarm uden at blive bedt om det, med et valgfrit varsel.

Den rammer alle tre veje, en opgave kan få et klokkeslæt — oprettet,
redigeret og hurtig-tilføjet. Redigeringsvejen er den halvdel, løftet om
"alle opgaver med en dato og tid" ellers taber: en opgave skrevet nu og
givet en tid i eftermiddag er den samme opgave. CalDAV er bevidst udenfor:
en opgave fra Apple Påmindelser bærer sin egen alarm, og to alarmer for én
opgave er værre end ingen.

Slået FRA for alle, der findes i dag. At tænde den i en migrering ville
give hver eksisterende bruger alarmer, de aldrig har bedt om, på opgaver de
har liggende — og det her er software, andre selv driver.

Og standarden skabte et behov for en udvej, så den fik en: "ingen alarm" i
linjen springer en enkelt opgave over, mens man skriver den, frem for at
oprette den, åbne den og slette alarmen der lige blev lavet. En udtrykkelig
alarm slår også standarden — den lægger sig ikke oven på.

TRE TING LÆSNINGEN FANGEDE

* Fortegnet. Lageret LÆGGER forskydningen til forfaldstiden
  (`due_datetime + offset_min * 60`), så "en time før" er -60. Gættet
  havde givet en alarm en time for sent, og det er den ene retning en
  påmindelse ikke må tage fejl i. Samme konvention hele vejen —
  indstillingen, parseren og kolonnen — for to fortegnsregler for det
  samme begreb viser sig kun som en alarm på et forkert tidspunkt.

* Den stille døde alarm. `DueReminders` vælger forskydningsformen kun
  `AND t.due_datetime IS NOT NULL`. Så "mind mig en time før" på en opgave
  med en dag men ingen klokketid ville have lagt en række, der aldrig
  fyrer. Nu bliver opgaven oprettet, alarmen ikke, og det bliver sagt.
  Det er den ene slags fejl en påmindelse ikke må have, fordi intet
  bagefter afslører den: der står ingen række at savne.

* "-60 min." på skærmen. TaskDetail viste forskydninger råt. Før nu kunne
  de kun laves gennem API'et, så grenen stod reelt urørt — dette er det
  første, der bruger den i praksis. Den skriver nu "1 time før".

PRØVET

Prøverne spørger `DueReminders` frem for om rækken findes: en række med
forkert fortegn findes lige så overbevisende og ringer en time for sent. Et
minut før den er forfalden må alarmen ikke være i listen, et minut efter
skal den.

Hver plantet fejl er kontrolleret til at falde i præcis den prøve, der
findes for den — fortegnet vendt, ordgrænsen fjernet, trinnet flyttet efter
dato, umulig klokke accepteret, dobbelt-vagten slået fra, "ingen alarm"
ignoreret, positivt tal accepteret, råt fortegn vist.

Og prøvens eget navn er en rettelse: de første fejlede i hele suiten men
bestod isoleret. Suiten kører med én arbejder mod ÉN database, og "ring til
tandlægen" findes fire steder i filen, så `.first()` ramte en anden prøves
opgave uden alarm.

Go-suiten grøn. E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 19:16:14 +02:00
Kristian Wind 661e47ebdf Merge pull request #102 from kristianwind/markoer-i-titlen
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 15m22s
CI / Go (push) Failing after 26m42s
Release / Build and publish image (push) Failing after 9s
CI / Docker image builds (push) Successful in 2m36s
fix(noter): en ny note åbner med markøren i overskriften
2026-10-08 10:17:18 +02:00
kristianwindandClaude Opus 5 6178cbbdd8 Merge branch 'main' into markoer-i-titlen
Begge grene føjede prøver til i enden af smoke.spec.js, og begge beholdes:
fire markør-prøver og fire fra tal-en-opgave-ind. Den fælles `/**` ovenfor
dækkede kun den første, så den anden blok har fået sin egen.

Suiten på den løste fletning: 129 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 10:06:42 +02:00
Kristian Wind 43bd405e36 Merge pull request #100 from kristianwind/tal-en-opgave-ind
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m50s
CI / Docker image builds (push) Successful in 3m21s
CI / Go (push) Failing after 26m57s
feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen
2026-10-08 09:59:41 +02:00
Kristian Wind b73f55ad0b Merge pull request #101 from kristianwind/afsnit-efter-retur
fix(noter): afsnit skrevet hurtigt mister ikke deres afstand
2026-10-08 09:59:22 +02:00
kristianwind 475ecbe7ef Merge branch 'afsnit-efter-retur' into markoer-i-titlen 2026-10-08 07:55:04 +02:00
kristianwind 159029675c Merge branch 'afsnit-efter-retur' into tal-en-opgave-ind 2026-10-08 07:49:10 +02:00
kristianwindandClaude Opus 5 b18d9a26e6 fix(noter): afsnit skrevet hurtigt mister ikke deres afstand
Retur-handleren udskyder rettelsen ét hak, fordi der før delingen ikke er
nogen ny blok at rette. Men rettelsen spurgte, hvor markøren stod *nu*, og
et hak er nok: skriver man hurtigt, er næste retur nået igennem først, og så
stod markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
blok, rettelsen var ment for, stå tilbage som browserens bare <div>. En
<div> læses som brødtekst og er ikke formateret som en, så afstanden mellem
afsnit forsvandt stille, hos netop de brugere der skriver hurtigst.

Målt frem for gættet: "flere markerede afsnit bliver til én monotypeblok"
faldt på en CI-runner med alle tre linjer skrevet rigtigt, hvor "linje et"
var en <div> og "linje to" og "linje tre" var <p>.

Rettelsen er delt efter hvad der er sikkert at røre:

* Der hvor markøren står, gør execCommand det fortsat. Det er den, der
  lægger ændringen i browserens fortryd-historik, og filen vælger den
  bevidst andre steder af samme grund — ⌘Z skal tage afsnittet tilbage,
  ligesom den tager et ord tilbage.
* Den blok, delingen lige skabte, får samme regel uden markør at låne.
  Den dækker overskriften: retur for enden af en overskrift efterlader
  endnu en overskrift i de fleste browsere.
* Og hver <div> i noten bliver fejet op. En <div> er altid browserens egen
  beholder og aldrig nogens valg, så den kan rettes hvor som helst. En <h1>
  bliver IKKE fejet: menuen har en Titel-stil, så en overskrift midt i en
  note kan være en linje, nogen selv har valgt, og den er deres at beholde.

Fejning frem for et håndtag til blokken, og det er målt: når den første
udskudte rettelse bytter tagget, peger den næstes håndtag på en node uden
for dokumentet, og blokken midt i kæden bliver aldrig rettet. Prøven planter
to efterladte blokke og er kontrolleret i tre retninger — ingen rettelse: 2
efterladte <div>, håndtag til blokken: 1, fejning: 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 07:37:49 +02:00
kristianwindandClaude Opus 5 3768cf946e fix(noter): en ny note åbner med markøren i overskriften
Oprettelse af en note satte ingen markør. Siden kaldte
`document.querySelector('.editor textarea')?.focus()` — og der er ingen
textarea; editoren blev lavet om til en contenteditable, så kaldet har ramt
ingenting lige siden, og `?.` sørgede for, at det aldrig larmede.

Rettelsen ligger i NoteEditor frem for på siden, så begge veje er dækket:
knappen på /noter og den på et projekt, der navigerer til /noter?note=<id>.
Reglen er, at en TOM note sætter markøren i sin overskrift — en note med
indhold rører den ikke, for der er markøren brugerens eget valg.

Fire prøver, og de er kontrolleret i begge retninger: uden kaldet giver den
nye note `Expected: "h1", Received: null`, og med kaldet på enhver note
giver kontrollen `Expected: null, Received: "h1"`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 07:16:22 +02:00
kristianwindandClaude Opus 5 60795126a5 fix(opgaver): skuffens knapper kan ikke tabbes til, mens den er usynlig
`inert` på skuffen, når den er lukket, så dens felt og knapper er ude af
tabrækkefølgen og ude af tilgængelighedstræet.

Det støder mod funktionen selv: et inert element kan ikke fokuseres, og
fokus i samme hug som klikket er præcis det, iOS kræver for at åbne
tastaturet. Attributten sættes reaktivt af `inert={!open}`, og Sveltes
opdateringer er asynkrone, så den er der stadig i det øjeblik handleren
kører.

Derfor fjernes den med hånden i `start()`, før fokus sættes, og den
reaktive binding lægger den tilbage ved lukning. En imperativ DOM-ændring
midt i en deklarativ komponent ser ud som sjusk, og uden en grund skrevet
ved siden af ville den næste læser helt rimeligt rydde den op.

Plantet ved at fjerne linjen: prøven går rød med "markøren skal stå i
skuffens felt efter trykket — Expected: true, Received: false". Linjen
bærer noget.

Luk-knappens `tabindex` er fjernet som overflødig; inert dækker hele træet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 07:04:27 +02:00
kristianwindandClaude Opus 5 664614b924 feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen
En stor rund knap nederst til højre, inden for tommelfingerens rækkevidde.
Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.

VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
egen diktering kører på enheden på nyere telefoner. En talegenkendelse
bygget ind her ville sende lyden fra et selvhostet program videre til Apple
eller Google — en datavej, der skulle forklares på privatlivssiden, for at
spare ét tryk. Af de tre AI-udbydere, instansen understøtter, har anthropic
i øvrigt slet ingen lyd-API, så serverside-transskription ville også dele
brugerne op efter hvem de har en nøgle hos.

Teksten lander i det felt, der i forvejen viser, hvad parseren forstod, så
"ring til tandlægen i dag kl 14 p1" bliver læst mens man ser på det, og
intet oprettes før man siger ja. Derfor hedder komponenten diktat og ikke
tale: den gør ikke selv noget ved lyden, den stiller feltet frem.

## Det ene, funktionen hænger på

Safari på iOS åbner kun tastaturet, hvis focus() kaldes INDE i den
brugerhandling, der bad om det. QuickAdds `autofocus` sker i en $effect,
altså efter klikket er returneret — det giver feltet en markør og intet
tastatur, og der er ingen fejl nogen steder.

Så skuffen er ALTID monteret og bare skubbet ud af syne med transform og
opacity. Ikke display:none, ikke visibility:hidden, ikke inert — alle tre
gør feltet ufokuserbart. Prøven påstår direkte, at feltet findes FØR
klikket, fordi det er den eneste del af iOS-kravet, en browser kan måles på;
plantet som `{#if open}` går den rød med Expected: 1, Received: 0.

## To kollisioner, hvor jeg kun så den ene

Skuffen lægger et felt mere på siden, og det rammer to ting:

- `data-quickadd`, som skriv-hvor-som-helst-genvejen slår op med
  querySelector — altså FØRSTE match. Set på forhånd og løst med en
  `marked`-prop.

- Det tilgængelige navn. Det så jeg IKKE, og otte prøver gik røde på
  `getByLabel('Ny opgave') resolved to 2 elements`. Det er heller ikke kun
  et prøveproblem: to felter med samme navn læses op to gange af en
  skærmlæser, uden at man kan høre forskel. De to ER forskellige ting — det
  ene er kassen på siden, det andet er det, knappen åbner — så de har hver
  sit navn nu.

## Og en fejl, skærmbilledet fandt

Fremhævningen bag teksten lå på en linje, inputtet ikke havde — en bleg
blok under sætningen. Spejlet stod på `white-space: pre-wrap`, men et
<input type="text"> er enkeltlinje per specifikation og kan ikke ombryde,
uanset hvad reglen siger. Komponentens egen kommentar forudser det ordret:
"same wrapping. Any difference shows up as highlighting that drifts out of
alignment as the line grows."

Sjældent synligt på et bredt felt, konstant på 390px med en dikteret
sætning. Spejlet står nu på `pre` og rulles med inputtets scrollLeft.

Knappen tegnes ikke over 820px — der er feltet fremme hele tiden og et
tastatur at skrive på — og ikke i urd-ansigtet, hvor der ingen opgaver er.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 06:50:56 +02:00
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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
kristianwindandClaude Opus 5 0c45080dc2 feat(udgaver): VERDANDE_EDITION afgør, om instansen er et opgaveprogram
Første skridt mod noter som et selvstændigt produkt. Kun routeren: fladen,
navnet og runen kommer bagefter.

--- hvorfor én kodebase og ikke en fork ---

Målt før noget blev skrevet. Noternes Go-kode er 2.386 af 49.407 linjer,
og den nævner `Task` nul gange, `quickadd` nul, `sections` nul. Den anden
vej er også nul. De to halvdele rører hinanden to steder: en `note_links`-
række, hvis `kind` tilfældigvis er "task", og rettigheder, hvor en note i
et projekt arver projektets rolle.

Delingen findes allerede uden projekter. `note_shares` er en tildeling pr.
note — note, person, rolle — med del, fjern, rolleopslag, publikum og
`SyncLinkedShares`. Og adgangsreglen har allerede grenen for en note uden
projekt: `WHERE project_id IS NULL AND created_by = ?`. Et selvstændigt
noteprogram er de regler, der allerede er der, med projektgrenen fjernet.

Det, der IKKE kan skilles ad, er fundamentet: konti, sessioner, 2FA,
invitationer, beskeder, vedhæftninger, søgning, sprog, PWA, runen,
beaconen, OpenAPI-dokumentet, e2e-stilladset. Begge programmer skal bruge
det hele. To kopier af det er den fejl, der står flest gange i vores egen
playbook — de syv plugins med et kopieret ci/-kit, den frysede delte fil.

Projekter bliver derfor stående i begge udgaver. I noter-udgaven er et
projekt en notesbog: samme kolonne, samme deling, andet ord. En anden
grupperingsmodel ved siden af `note_shares` ville være én ting mere at
holde i trit, for en forskel ingen kan se.

--- skakten ---

Opgaveruterne er ikke skjult i fladen. De er fraværende fra routeren, så
en instans, der ikke er et opgaveprogram, heller ikke kan bedes om at være
det gennem sit API.

En ukendt værdi stopper programmet ved opstart. "note", "Notes", "none" —
alle afvist. Det er den stille retning af fejlen: ellers ville en instans,
der blev sat op uden opgaver, servere hver eneste opgaverute, og den
eneste måde at opdage det på ville være at bemærke, at noget virker, som
ikke burde.

--- prøven ---

Den tjekker ikke, at der STÅR en skakt. Den vandrer begge routere og
sammenligner, hvad de faktisk serverer: 200 ruter mod 171, 29
tilbageholdt. De 29 er skrevet ned enkeltvis frem for som en præfiks-
regel — et præfiks beskriver, hvad nogen mente, og det her skal beskrive,
hvad der er. En rute tilføjet senere, der er forskellig mellem udgaverne,
fejler i begge retninger, til nogen siger hvilken halvdel den hører til.

Og den anden halvdel: noter-udgaven skal STADIG servere noter, eksport,
søgning, projekter og /auth/me. En skakt, der også slukker for det, den er
til for, er en sletning.

Efterprøvet ved at plante fejlen: /labels-skakten åbnet, prøven fejlede og
navngav alle seks ruter. Tallet står også på det grønne — "udgaverne er
forskellige" over nul tilbageholdte ruter og over 29 er det samme pass.

Go-suiten grøn. Fuld chromium-suite: 104 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-06 22:44:35 +02:00
Kristian Wind bba2dab209 Merge pull request #93 from kristianwind/eksport-af-det-hele
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 6m16s
CI / Docker image builds (push) Successful in 3m14s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 16m34s
feat(data): hele kontoens opgaver kan hentes, og en indsat liste bliver til opgaver
2026-10-06 12:41:15 +02:00
kristianwindandClaude Opus 5 438095e252 fix(noter): ⌘B gør teksten fed i stedet for at folde sidebjælken
Meldt ind: "command+b viser og skjuler menuen - ikke bold".

Fladen tog ⌘B til sidebjælken, og begrundelsen stod i koden: "works while
typing as well — it moves nothing in the field". Det passer for et
`<input>`, hvor ⌘B ikke gør noget. I en contenteditable ER ⌘B fed tekst,
og `preventDefault()` stoppede browseren i at gøre den.

Sætningen var sand, da den blev skrevet. Så kom en rig editor til, og
begrundelsen holdt op med at passe uden at nogen rørte linjen — samme form
som flere af fælderne i den her kodebase.

Feltet, markøren står i, har førsteret til en tast, det selv bruger. Uden
for et skrivefelt folder genvejen stadig sidebjælken, og prøven måler
begge halvdele: en rettelse, der bare holdt op med at folde, ville være
halvt færdig.

Efterprøvet mod den gamle kode — ordet blev aldrig fedt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-06 12:28:23 +02:00
kristianwindandClaude Opus 5 3132bba7e5 feat(data): hele kontoens opgaver kan hentes, og en indsat liste bliver til opgaver
To ønsker, meldt sammen.

--- eksport af det hele ---

Eksporten fandtes allerede — konto som JSON, noter som Markdown i en zip,
og pr. projekt CSV i Todoist-format og ICS. Hullet var, at opgaver kun
kunne hentes ÉT PROJEKT AD GANGEN. Med femten projekter er det femten
downloads for at flytte sine egne opgaver, hvilket er en eksport, der
teknisk set findes, og som ingen bruger.

`GET /export/projects.zip` er én CSV pr. projekt. En zip og ikke én bred
fil med en projektkolonne: Todoists egen eksport er en fil pr. projekt, så
det er den form, der importeres i den anden ende — og den form, man kan
hive ét projekt ud af uden at åbne et regneark. Samme begrundelse som
noteeksporten, der er en mappe med Markdown og ikke én lang fil.

`GET /export/tasks.ics` er alle daterede opgaver i én kalender. Ét fil og
ikke en zip, fordi en kalender er lavet til at rumme mange ting, og
projektet følger med hver opgave. Udaterede udelades — en VTODO uden dato
er en linje, de fleste læsere taber på gulvet.

Prøven ser I filerne frem for på statuskoden: en tom zip og en kalender
uden VTODO'er er begge 200 med en troværdig Content-Type, og det er
præcis den form, en fejl i en eksport har. To projekter, ikke ét, så en
løkke der kun eksporterer det sidste ikke består.

--- en indsat liste bliver til opgaver ---

Quick-add er ét felt på én linje, og browseren smider linjeskiftene væk
ved indsætning i et `<input>` — så en kopieret liste blev til én opgave
med punkterne mast sammen.

Nu læses den. Indrykning bærer niveauet, og bredden læses af teksten frem
for at være gættet til to eller fire: en liste fra Apple Noter rykker ind
med ét antal mellemrum, en fra et Markdown-dokument med et andet, og
begge skal give ét niveau. `- `, `*`, `1.` og `1)` fjernes som den
notation, de er. `- [x]` er derimod indhold: en halvt afkrydset liste,
der kommer ind som lutter uløste opgaver, har tabt det, man indsatte den
for.

Teksten røres ikke ud over mærket. Datoer, `#projekt` og `@etiket` læses
af quickadd på serveren, præcis som når linjen var skrevet i hånden — ét
sted at forstå en opgavelinje, ikke to. Derfor tager `/tasks/quick-add`
nu et `parent_id`: en indrykket linje skal parses som enhver anden.

En undertask lever i sin forælders projekt. Et `#projekt` på en indrykket
linje honoreres derfor ikke, og det siges højt som `unknown_project` frem
for at ordet bare forsvinder fra titlen.

Indsætter man i en opgaves undertask-felt, bliver linjerne undertasks
dér. Det er "enten opgaver eller undertasks" afgjort af hvor man
indsætter, uden at nogen skal vælge noget i en menu.

Listen vises, før den oprettes. Fyrre opgaver fra et fejlklik er ikke
noget, man skal opdage bagefter — og visningen er også svaret på "forstod
den indrykningen", stillet mens der stadig er en Fortryd-knap.

--- rettet undervejs ---

Rækkens undertask-tæller stod stille, når en undertask blev tilføjet fra
ruden: rækken sagde 1/2, mens ruden foran viste fire. Tallene bor på
forælderen og regnes ud ved hentning, og serveren sender ikke forælderen
igen, fordi et barn blev lavet. Det gjaldt også den gamle vej, ikke kun
den nye.

Go-suiten grøn. Fuld chromium-suite: 103 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-06 12:27:46 +02:00
Kristian Wind 44b28f1cd9 Merge pull request #92 from kristianwind/monotype-over-hele-markeringen
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 6m4s
CI / Docker image builds (push) Successful in 3m54s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 17m31s
fix(noter): monotype tager hele markeringen, og farvelægningen taber ingen linjer
2026-10-02 23:07:05 +02:00
kristianwindandClaude Opus 5 b497b76be4 fix(noter): monotype tager hele markeringen, og farvelægningen taber ingen linjer
Meldt ind: "kun den øverste sætning af det jeg markerede blev lavet om til
monotype". Det førte til en anden fejl, der var værre.

--- den meldte ---

Monotype går uden om execCommand, fordi formatBlock pakker et <p> ind i
<pre>'en i stedet for at lave den om. Den vej spurgte efter
`currentBlock()`, og det er ét blok — markørens. Så af en markering over ti
linjers terminaludskrift blev den første linje til kode og resten stod
tilbage som brødtekst.

`selectedBlocks()` læser hele markeringen, og de markerede blokke lægges
sammen til ÉN blok. Ikke én pr. linje: en udskrift, man indsætter, er ét
stykke kode, og tre blokke ved siden af hinanden ser næsten ens ud på
skærmen og bliver til tre hegn i filen.

Vejen ud af monotype tager nu også hele markeringen. `swapTag()` bytter
tagget uden at røre markøren — tekstknuderne flytter med over, så den range
browseren holder på stadig peger på noget i dokumentet, og formatBlock kan
klare resten bagefter.

--- den anden, som prøven fandt ---

`watchForTrouble` meldte "ReferenceError: codeText is not defined" fra et
rekursivt kald, jeg havde glemt at omdøbe. Ved siden af det lå en KOPI af
"hvad er teksten i en blok", inde i editoren, med præcis den <br>-fejl, der
blev rettet i #91: `textContent` på et <br> er den tomme streng.

Den kopi bruges af farvelægningen, som maler resultatet TILBAGE ind i
blokken. Så en kodeblok skrevet med shift+retur fik sine linjer skrevet
sammen i det øjeblik, man klikkede væk fra den — på skærmen, ikke kun i
filen, og uden at nogen havde rørt teksten. Målt mod den gamle kode:
"echo enecho to".

Kommentaren over kopien sagde ordret, at de to læsninger skulle være ét
sted, FORDI en uenighed ville ende i noten. Den var sand, da den blev
skrevet; kopien kom senere. `blockText` i richtext.js er nu det ene sted,
eksporteret og brugt begge steder.

Begge prøver er efterprøvet mod den gamle kode.

Fuld chromium-suite kørt to gange: 94 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 22:58:08 +02:00
Kristian Wind dba99b7ecc Merge pull request #91 from kristianwind/noter-lister-blokke-og-soegning
Release / Build and publish image (push) Failing after 6s
fix(noter): niveauer i lister, flere linjer i en blok, og en søgning man kan vælge i
2026-09-22 14:10:13 +02:00
kristianwindandClaude Opus 5 f34690f41a fix(noter): niveauer i lister, flere linjer i en blok, og en søgning man kan vælge i
Tre ting meldt ind sammen. De to første manglede; den tredje var i stykker.

--- man kunne søge og ikke vælge ---

Regression fra #84. Siden holdt op med at SKRIVE adressen, men blev ved med
at læse den ved hver ændring:

    let asked = $derived($page.url.searchParams.get('note'));
    $effect(() => { if (!asked || selectedId === asked) return; … });

Kom man fra ⌘K med `?note=<id1>`, stod `asked` fast på id1 for altid.
Hvert klik på en anden række satte `selectedId`, effekten så uenigheden og
åbnede id1 igen. Man blev trukket tilbage til den første træffer.

En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
Den læses nu ved ankomsten — og der er TO ankomster, hvilket den første
udgave af rettelsen ikke tog højde for: layoutet tegner ingenting, mens
kontoen hentes, så siden monteres efter den første navigation er forbi, og
en lytter registreret der hører den aldrig. `/noter?note=<id>` tastet eller
fulgt udefra åbnede så ingenting. `onMount` og `afterNavigate`, samme
funktion, med `id === selectedId` som værn mod at begge fyrer.

--- en blok kunne ikke gemme flere linjer ---

Shift+retur bliver i blokken, men det browseren lægger ind er et <br>, og
`textContent` på et <br> er den tomme streng. Blokken blev skrevet til
Markdown som én linje: "first linesecond line". Usynligt indtil en
genindlæsning, for på skærmen står linjerne rigtigt hele tiden.

`codeText()` læser blokken rekursivt og gør <br> til et linjeskift.
Rekursivt, fordi indholdet ligger i et <code> inde i <pre>: en filtrering
over <pre>'ens egne børn spørger det ene element om en textContent, der
allerede har tabt linjeskiftet.

--- lister havde ingen vej til niveau to ---

Tab var ikke bundet til noget uden for forslagslisten, så den gjorde det,
Tab gør: flyttede fokus ud af noten. Den rykker nu ind og shift+Tab ud, og
kun inde i en liste — ellers er noten en boks, man ikke kan tabbe ud af.

Og skriveren ville have tabt resultatet: `list()` læste kun en underliste,
der ligger INDE i sit punkt. Lægger browseren den som søskende, og de gør
det uens, blev grenen sprunget over uden lyd. At binde Tab uden den her
halvdel ville have lavet en fejl, hvor indrykkede punkter forsvandt ved
gemningen.

Begge nye prøver måler EFTER en genindlæsning. Før den ser begge dele
rigtige ud på skærmen; det er turen gennem Markdown, der afgør, om det
blev skrevet ned.

Fuld chromium-suite kørt to gange: 92 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 14:01:15 +02:00
Kristian Wind 1bb2c9cda6 Merge pull request #90 from kristianwind/klokken-paa-telefon
fix(beskeder): klokkens panel bliver inden for skærmen på en telefon
2026-09-17 19:06:03 +02:00
kristianwindandClaude Opus 5 cac6c14a23 fix(beskeder): klokkens panel bliver inden for skærmen på en telefon
Panelet hang under klokken med `right: 0` og en fast bredde på 340 px.
Klokken sidder ikke yderst — der er en temaknap, et mellemrum og en
kantafstand til højre for den — så der er kun omkring 330 px tilbage, og
resten blev skubbet ud over venstre kant og klippet af. Målt til -10 px ved
en skærmbredde på 390; på en smallere telefon mere. Overskriften "Beskeder"
klæbede til kanten uden luft, hvilket er det synlige tegn.

På en telefon hører panelet nu til skærmen i stedet for til klokken: det
spænder fra kant til kant med en margen i hver side, under toplinjen. Samme
knæk ved 820 px som toplinjens egen menuknap, så de to skifter form
samtidig. Det får også `overscroll-behavior: contain`, så det at rulle i
listen ikke trækker siden bagved med.

Toplinjens underkant måles frem for at blive regnet ud. Bredden KUNNE
trækkes fra i CSS, men så ville den her fil kende sine naboers størrelse, og
den næste knap i toplinjen ville lave fejlen om igen uden at sige noget.

--- og fire ustabile prøver mere ---

Fladen tegner hver ændring med det samme og sender kaldet bagefter. Alt, man
kan se, er altså sandt, før serveren har hørt om det, og en prøve, der
genindlæser eller klikker videre i det hul, måler den gamle tilstand.

`en monospace-linje kan forlades` ventede 1200 ms før en genindlæsning.
Datoprøven ventede 400 ms. Et gæt om, hvor hurtig maskinen er, holder indtil
CI er travl — begge fejlede i en fuld kørsel her.

`en gruppe er en side med sine projekter` er den mest lærerige: trækket
virkede, og `toPass` blev grøn på den lokale tilstand, hvorefter klikket
videre hentede en gruppe uden projekter i. Prøvens egen kommentar gætter på
et upålideligt HTML5-træk; det var gemningen, der ikke var nået frem.

Monospace-prøven havde OVENI en anden fejl, som først viste sig, da den
første var lukket: den klikkede "Ny note" og skrev med det samme. `create()`
venter på serverens svar, og imens står den forrige notes editor stadig på
skærmen, så teksten gik ind i den forrige note og den nye blev tom. Ny
hjælper `nyNote()` venter på et tomt felt. Omkring 14 andre steder i filen
har samme mønster; de er ikke skrevet om, fordi de består, og en samlet
omskrivning ville bytte kendte prøver for ukendte.

Det hul findes også for et menneske: klikker man Ny note og skriver straks,
lander de første anslag i den forrige note. Det er millisekunder lokalt og
sekunder på en dårlig forbindelse. Samme familie som #86, men tiden FØR
svaret frem for efter. Noteret som et selvstændigt punkt, ikke rettet her.

`noteGemt()` fra #88 er nu `gemt(side, sti)` med noteGemt, opgaveGemt og
projektGemt ovenpå. Der står 23 faste ventetider tilbage, men ingen af dem
lige før en genindlæsning eller en navigation.

Fuld chromium-suite kørt to gange: 90 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 18:55:45 +02:00
Kristian Wind f632fb8643 Merge pull request #89 from kristianwind/to-vaern-mere
test(e2e): to genindlæsninger mere venter på gemningen
2026-09-17 13:12:07 +02:00
kristianwindandClaude Opus 5 eabb00ecc2 test(e2e): to genindlæsninger mere venter på gemningen
Samme form som de fire "Gemt"-ventetider, to andre steder — begge lige før
en `page.reload()`, hvilket er der, hvor det koster.

`en note kan skrives fed, og den kommer hel tilbage` havde `blur()` direkte
efterfulgt af en genindlæsning uden noget værn overhovedet. Nåede PATCH'en
ikke frem først, målte prøven en note, der aldrig blev skrevet — og den
prøve findes netop for at fange en omformer, der er uenig med sig selv.

`[[henvisning]]`-prøven havde `waitForTimeout(900)`. Tallet var gættet til
at overleve tastepausen på 700 ms; et gæt om tid måler maskinen frem for
programmet. Det er den konstruktion, den her fil allerede advarer imod.

Begge venter nu på PATCH-svaret gennem noteGemt().

Der står 24 faste ventetider tilbage i filen. De er ikke rørt: flere af dem
venter med vilje (at skrive i et menneskeligt tempo er en rigtig grund), og
at skrive dem om samlet ville bytte kendte prøver for ukendte.

Fuld chromium-suite: 89 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 13:02:31 +02:00
Kristian Wind d2b5821e57 Merge pull request #88 from kristianwind/vaern-der-venter-paa-noget
test(e2e): værn, der kan vente på noget
2026-09-17 12:59:26 +02:00
kristianwindandClaude Opus 5 4874f7bbd8 test(e2e): værn, der kan vente på noget
Fire steder ventede prøverne på teksten "Gemt" i notefoden. Den er
hvilestanden: feltet siger "Gemmer" mens en gemning kører og "Gemt"
ellers — altså også før den første gemning overhovedet er begyndt. Alle
fire ventetider var grønne med det samme og ventede på ingenting.

To af dem dækkede over et rigtigt kapløb:

- `en delt note tager de noter med, den peger på` skriver i en note og
  navigerer væk umiddelbart efter. Nåede PATCH'en ikke frem først, var
  teksten væk, da siden kom igen.

- `en gruppe er en side med sine projekter` har samme form et andet sted:
  beskrivelsen tegnes optimistisk — siden sætter teksten på gruppen straks
  og sender PATCH'en bagefter — så kontrollen var grøn, før serveren havde
  hørt om den, og `page.reload()` lige efter hentede den gamle gruppe
  tilbage. Prøven fejlede altså på noget, der ikke var i stykker.

De to sidste ventede på ingenting uden at skade noget, men var stadig
vildledende at læse.

Der er nu én hjælper, `noteGemt(side)`, der venter på selve PATCH-svaret,
med forklaringen på hvorfor "Gemt" ikke duer skrevet ét sted.
Gruppeprøven venter tilsvarende på sin egen PATCH. Hvor rækken i listen
allerede var det rigtige værn, er den overflødige linje væk.

Samme form som reglen i CLAUDE.md om at læse, hvad en kontrol skrev, da
den BESTOD: "venter på gemning" og "matchede den tekst, der altid står
der", er samme grønne.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 12:49:15 +02:00
Kristian Wind 8bf4f8fbe4 Merge pull request #87 from kristianwind/et-udfald-siger-det
feat(net): et udfald siger "ingen forbindelse" frem for at bede om kodeordet
2026-09-17 12:38:19 +02:00
kristianwindandClaude Opus 5 84af63316d feat(net): et udfald siger "ingen forbindelse" frem for at bede om kodeordet
Opstarten spørger serveren, hvem man er. Når svaret aldrig kommer, står
`user` som null — og null betyder "logget ud" alle andre steder i
programmet. Så et totalt udfald blev vist som login-skærmen: appen gav
sessionen skylden for, at nettet var nede, og tilbød et kodeordsfelt, der
umuligt kunne virke.

Det er værre end en fejlbesked, fordi det ligner noget, der kunne virke.
Man taster kodeordet, der sker ingenting, og man konkluderer, at kontoen
er i stykker. Det skete i virkeligheden 17. september 2026, da instansens
DNS-post blev slettet: det, appen viste, var en langsom login-side.

Forskellen var der allerede i forespørgselslaget. Et netværkssvigt, der
har brugt sit forsøgsvindue op, kastes som `ApiError(0, 'offline')` —
koden laves af klienten selv, netop fordi der aldrig kom en status. En 401
er serveren, der svarer, og det er en rigtig udlogning. Butikken skelner nu
på den kode, og fladen viser NoConnection i stedet for SignIn.

Teksten siger, at data er i behold, for det er det spørgsmål, man faktisk
har, når ens noter forsvinder bag en login-boks.

Prøven afbryder API-kaldene frem for at stoppe serveren: en afbrudt
forespørgsel er præcis det, en manglende DNS-post giver browseren.
Efterprøvet mod den gamle kode — den fejler på "Ingen forbindelse".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 12:29:57 +02:00
Kristian Wind 930522ecf6 Merge pull request #86 from kristianwind/ny-note-taber-ikke-tekst
fix(noter): en ny note taber ikke det, man skriver i den
2026-09-17 12:29:49 +02:00
kristianwindandClaude Opus 5 9ed6f49589 fix(noter): en ny note taber ikke det, man skriver i den
Serveren tømmer `body` på listerækker med vilje, så en tom krop var sidens
tegn på "det her er en række, hent noten hel". En nyoprettet note er også
tom — og hel — så den blev sendt samme vej: editoren blev pillet af
skærmen, noten hentet igen, og editoren sat op på ny.

I det hul er der ingen valgt note, og save() går tavst tilbage uden en.
Skrev man der, forsvandt teksten sporløst: ingen fejl, ingen toast, bare en
tom note. Svaret på oprettelsen var hele noten hele tiden, så opslaget var
også overflødigt.

Det er samtidig grunden til, at søgeprøven var ustabil. Dens værn var en
ventetid på "Gemt", og den tekst er hvilestanden — feltet siger "Gemmer"
mens en gemning kører og "Gemt" ellers, altså også før den første gemning
er begyndt. Prøven fik grønt med det samme og målte ingenting; i CI var
maskinen langsom nok til at ramme hullet bagefter. Den venter nu på et tomt
felt før den skriver, og på rækken i listen bagefter.

Den nye prøve tæller opslag frem for at måle et blink: et blink ville kræve
en prøve, der er hurtigere end programmet, hvilket er præcis den fejl den
lukker. Efterprøvet mod den gamle kode — den fejler på
`expect(fetched).toEqual([])`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 12:20:02 +02:00
kristianwindandClaude Opus 5 de673260a3 docs(site): besøgstælling på verdandetodo.com
Plausible på vores egen maskine. Ingen cookie, ingen gemt adresse, ingen
tredjepart — og derfor ingen samtykkebjælke på en side, der skal sælge noget.

`defer`, så tælleren aldrig er det, der holder siden tilbage.

Det gælder siden, ikke programmet. En installation melder det, der står under
"It counts itself, out loud", og ikke mere; de to ting deler ikke noget.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 12:31:00 +02:00
Kristian Wind 804c81f74e Merge pull request #84 from kristianwind/soegefeltet-taber-ikke-bogstaver
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m21s
CI / Docker image builds (push) Successful in 1m47s
Release / Build and publish image (push) Failing after 4s
CI / Go (push) Failing after 14m17s
fix(noter): søgefeltet taber ikke bogstaver, og det nyeste svar vinder
2026-09-16 08:34:59 +02:00
kristianwindandClaude Opus 5 87054ac8ca fix(noter): søgefeltet taber ikke bogstaver, og det nyeste svar vinder
Fra en skærmoptagelse, to billeder i sekundet:

    2,5 s  feltet "verd"      listen ufiltreret
    3,0 s  feltet "nde"       listen filtreret
    6,0 s  feltet "verdande"  listen ufiltreret
    6,5 s  feltet tomt        listen filtreret

To fejl oven i hinanden, og den ene er min fra #79.

Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk, så en
søgning kunne gemmes og deles. `replaceState` opdaterer `$page.url` et øjeblik
efter, den kaldes, så to hurtige tastetryk gav en effekt, der læste den *forrige*
adresse, fandt den uenig med det skrevne, og skrev den gamle værdi tilbage i
feltet. `untrack` fjernede sløjfen mellem de to effekter; den fjernede ikke
kapløbet med browserens egen forsinkelse.

En markør, der skal holde to retninger enige, taber kapløbet et sted. Adressen
skrives derfor ikke længere af siden — kun af navigation — og der er én effekt
tilbage, som læser den. Prisen er, at et ryddet søgefelt ikke rydder `q` af
adressen. Det er en mindre løgn end at æde bogstaver.

Den anden fejl har ligget der hele tiden: `load()` havde ingen vagt mod forældede
svar. To hentninger kunne være undervejs på én gang, og den langsomste vandt — så
listen viste svaret på et spørgsmål, der ikke længere blev stillet. Pausen på
250 ms gjorde det sjældnere og kunne ikke gøre det umuligt; det stod endda i
kommentaren ved siden af. Hver hentning har nu et nummer, og kun det nyeste
skrives i listen.

Prøven skriver i et menneskeligt tempo, for det var hastigheden, der afslørede
det, og holder den ene af to hentninger tilbage for at tvinge rækkefølgen om.
Kørt mod den gamle kode fejler den med `Received: ""` — feltet var tømt helt.

Bemærket: "en gruppe er en side med sine projekter" fejlede to fulde kørsler i
træk og bestod på den tredje, og alene. Den er ikke rørt her. Sammen med "en delt
note tager de noter med" i går er der nu to prøver, der flakker.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 08:25:10 +02:00
Kristian Wind c62dcc3d7c Merge pull request #83 from kristianwind/opgavens-tekst-kan-laeses
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m47s
CI / Docker image builds (push) Successful in 2m1s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m57s
fix(opgave): teksten kan læses færdig, også på en telefon
2026-09-15 20:22:38 +02:00
kristianwindandClaude Opus 5 0110a86858 fix(opgave): teksten kan læses færdig, også på en telefon
Rapporteret med et billede af en beskrivelse, der manglede sit første bogstav på
hver linje: "hermostat RU0910301440" og "atteries". Det er ikke manglende tegn —
det er et felt, der er rullet til højre. Og det ligner ikke rulning; det ligner,
at bogstaverne ikke er der.

To ting på én skærm.

Titlen var et <input>, og et input kan ikke ombryde. "tado°: Your Smart
Thermostat needs new batteries" er en almindelig opgavetitel, og på 390 px var
feltet 490 px bredt i en kasse på 356 — halvdelen af overskriften stod uden for
kanten. Den er nu et <textarea>, der vokser med sin tekst. Retur gemmer frem for
at lave en ny linje: en titel er én linje, uanset hvor mange den fylder på
skærmen. Trækkehåndtaget er væk, for højden sættes af teksten, og et håndtag
ville love noget, det næste bogstav retter tilbage.

Beskrivelsen er tit et klippet stykke mail med en URL i. Et <textarea> ombryder
ved mellemrum, og en URL har ingen.

`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan stå på
en linje alene, mens den anden bryder ved først givne lejlighed og delte
"GarageRisteriet" midt i en stavelse. Den lektion står allerede ved h1 på
projektsiden og er betalt én gang.

Prøven måler frem for at beskrive: et felt, der ruller vandret, har en
scrollWidth større end sin clientWidth. Kørt mod den gamle komponent fejler den
med "Opgavens tekst: 490 > 356".

Men kun den ene halvdel. Beskrivelsen løber ikke over i Chromium, heller ikke
uden reglen — det var Safari, den blev set i. Prøven måler derfor den regel frem
for dens virkning, og det står skrevet i prøven, så den næste ved, hvad den
vogter og hvad den ikke gør.

Bemærket undervejs: "en delt note tager de noter med, den peger på" fejlede én
gang ud af to fulde kørsler med "Delt med Mikkel." uden antallet. Den består
alene og i næste fulde kørsel. Den er ikke rørt her, men den flakker.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 20:12:28 +02:00
Kristian Wind c868be8f89 Merge pull request #82 from kristianwind/hjemmesiden-er-med
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 10m19s
CI / Docker image builds (push) Successful in 2m3s
CI / Go (push) Failing after 14m45s
docs(site): landingssiden når op til v0.47 (+ en flakkende prøve)
2026-09-15 14:21:35 +02:00
kristianwindandClaude Opus 5 145fefea02 docs(site): landingssiden når op til v0.47
Den udrullede side på verdandetodo.com er fra 20.-22. august — den matcher en
commit fra før v0.42.15. Repoets egen kopi er fra 4. september. Begge stopper,
hvor "Lately" stopper, og den sagde 0.42.

Ni nye poster: billederne i noter, søgningen der bliver stående, deling af et
projekt ved navn, besked og udgang, søgning i indstillinger, dagens plan som
kort, browservalget, hele AI-laget, versionsbeskeden til administratorer, og
krogen der lader hvad som helst lægge en opgave i indbakken.

De to ældste ryger. Med ni nyere poster er 0.34 og 0.33 ikke "for nylig" længere,
og hele historien står på udgivelsessiden, som afsnittet selv henviser til.

Siden udrulles i hånden — der er ingen arbejdsgang, der gør det. Det er derfor,
den er tre en halv uge bagud, og det er værd at tage stilling til separat.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 14:11:26 +02:00
kristianwindandClaude Opus 5 5d911d40fe test(noter): vent på at noten er gemt, før den næste laves
Prøven fra i går fejlede i CI på en PR, der kun rørte dokumentation — altså
flakkede den, og det er min.

Løkken laver tre noter og klikkede "Ny note" igen, uden at vente på at den
forrige gemning var kvitteret. Her på maskinen nåede den altid med; på en
belastet runner gjorde den ikke, og listen manglede noten. Fem sekunders
tålmodighed hjalp ikke, for det var ikke ventetid, der manglede — det var et
signal.

Der ventes nu på "Gemt". Kørt tre gange i træk grønt.

En prøve, der er hurtigere end programmet, måler den maskine, den kører på.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 14:11:26 +02:00
kristianwindandClaude Opus 5 9788bbaaf8 docs: siden bliver udgivet, og den siger det, der er bygget
Hjemmesiden har vist en Verdande fra før v0.43. /links/, /beacon/ og
/notifications/ svarede alle 404, måneder efter de blev skrevet.

Årsagen: docs-jobbet byggede på hvert push og udgav kun på en manuel udløsning,
med en note om at det ville køre, "once Pages is enabled". Pages BLEV slået til
— og betingelsen blev aldrig rettet. Siden er dermed bygget fyrre gange og
udgivet nul, uden at noget fejlede, for at bygge var præcis det, jobbet blev
bedt om.

Lektien er ikke den linje. Den er, at "udgiv senere" har brug for noget, der
fejler, når senere kommer. En kontakt, der er slukket og tavs, ligner en kontakt,
der er tændt.

Udgives nu fra main og ved håndsoprækning, aldrig fra en pull request — en fork
kunne ellers udgive hen over siden ved at åbne en.

Dokumentationen manglede ⌘K-ændringen fra i går: at vælge en note fra paletten nu
efterlader de andre træffere i listen ved siden af den. Den står i "Finding",
hvor de to søgefelter i forvejen er beskrevet hver for sig uden at sige, at de nu
er enige.

README'en manglede tolv ting, der er bygget siden tabellen sidst blev rørt — og
forseglingen af noter, som kun stod i docs. At en kopi af databasefilen ikke er
en kopi af noterne, er en overskrift for et selvhostet program, ikke en detalje
på side tre.

GitHub-repoet havde ingen hjemmeside og ingen emner, og beskrivelsen nævnte
hverken noter, kalender eller AI. Alle tre er sat.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 14:00:22 +02:00
Kristian Wind f68d6a8c15 Merge pull request #80 from kristianwind/billeder-kan-ses-og-kopieres
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 12m56s
CI / Docker image builds (push) Successful in 2m29s
CI / Go (push) Failing after 15m40s
Release / Build and publish image (push) Failing after 7s
feat(noter): et indsat billede kan ses helt og kopieres videre
2026-09-14 22:04:38 +02:00
kristianwindandClaude Opus 5 ff0447317b feat(noter): et indsat billede kan ses helt og kopieres videre
I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
ikke ser ud som et. Der var ingen vej fra det til billedet selv.

Et klik åbner det nu i fuld størrelse, med en knap, der kopierer det, og et link,
der åbner det i sit eget faneblad. Escape lukker og sætter markøren tilbage i
teksten — ellers står fokus på en knap, der ikke findes længere, og den næste
tast går ingen steder.

Klikket ligger i onEditorClick ved siden af notelinket, som er det andet, man kan
klikke på inde i et felt, man ellers skriver i. Billedet bliver stadig markeret
af klikket, og det skal det: det er sådan, det slettes.

To ting, der ikke er til at gætte:

Udklipsholderen tager kun PNG i de fleste browsere, og et foto fra en telefon er
en JPEG. Så et billede, der ikke allerede er PNG, tegnes om over et lærred —
ellers lykkes skrivningen enten ikke, eller den lykkes med noget, ingen kan
indsætte. Prøven indsætter derfor med vilje en JPEG; med en PNG var den kørt
forbi netop den kode.

Og løftet gives videre til ClipboardItem frem for at blive ventet af først.
Safari kræver, at clipboard.write kaldes i selve klikket, og en hentning
imellem gør klikket til fortid.

Ruden ligger uden for .wrap: editoren har sin egen rulning og sin egen
stablingskontekst, og et lag inde i den ville blive klippet af netop det ark, det
skal lægge sig over.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-14 21:54:48 +02:00
Kristian Wind a2f6e9bda3 Merge pull request #79 from kristianwind/soegningen-foelger-med
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 10m33s
CI / Docker image builds (push) Successful in 2m1s
CI / Go (push) Failing after 14m48s
fix(noter): en søgning i ⌘K efterlader de træffere, den fandt
2026-09-14 11:19:56 +02:00
kristianwindandClaude Opus 5 1bbd36c79b fix(noter): en søgning i ⌘K efterlader de træffere, den fandt
Paletten viste de noter, der passede, i sin egen liste — og når man valgte en,
åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var
væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var
lukket.

Feltet i selve notelisten har filtreret hele tiden, og det er prøvet to steder.
Men den søgning, man finder, er den store i toppen, og den er palettens. Så det
er ikke "søgning i noter virker ikke" — det er, at den søgning, man bruger, ikke
efterlod noget.

Adressen bærer nu søgningen. ⌘K sender `?note=…&q=…`, notesiden læser begge, og
listen ved siden af den åbne note er de andre træffere. Den skrives tilbage, når
feltet ændres, så adressen kan gemmes og deles — og frem for alt ikke lyver, når
feltet er tømt.

To effekter, der deler en markør, og hvorfor de læser den med `untrack`:

Den nederste sætter `settled` og kalder `replaceState`. Det vækker den øverste,
og på det tidspunkt har `$page.url` endnu ikke skiftet — så den læste den gamle
adresse, fandt en søgning, der ikke var enig med markøren, og skrev den tilbage
i feltet. Man tømte søgefeltet, og ordet kom igen. Hver effekt skal reagere på
sin egen kilde, adressen og feltet, og aldrig på den fælles markør.

Den fejl fandt jeg kun, fordi prøven fejlede på den sidste linje og
fejlbilledets tilgængelighedstræ stadig viste "Fyrreskov" i feltet. En prøve,
der kun havde tjekket adressen, havde været grøn.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-14 11:10:09 +02:00
Kristian Wind 54359f8d68 Merge pull request #78 from kristianwind/tagbeskeden-naar-frem
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m46s
CI / Docker image builds (push) Successful in 1m21s
CI / Go (push) Failing after 13m54s
fix(release): tagbeskeden når frem til udgivelsen
2026-09-14 08:31:10 +02:00
kristianwindandClaude Opus 5 f400705bb7 fix(release): tagbeskeden når frem til udgivelsen
Den skulle have gjort det siden #72, og gjorde det ikke. v0.45.0 og v0.46.0 fik
begge flettecommittens overskrift som udgivelsestekst — uden at noget fejlede,
og uden at nogen kunne se det andet end på siden bagefter.

`fetch-depth: 0` er ikke nok. actions/checkout henter først alle tags korrekt og
overskriver derefter netop den ene, der udløste kørslen:

    git rev-parse refs/tags/v0.46.0
    git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0

Den henter commit-SHA'en ind i tag-refen. Den annoterede tag bliver dermed til
en letvægtstag, der peger direkte på commit'en, og `%(contents)` giver så
commitbeskeden. Kommentaren i filen sagde, at en overfladisk checkout var
problemet; det var rigtigt og utilstrækkeligt.

Tagget hentes derfor som *ref* igen, lige før det læses.

Og vagten: hver tagbesked begynder med sin egen version, så holder det ikke,
læser vi noget andet end tagget, og kørslen fejler frem for at udgive en forkert
tekst. Det er det, der manglede — fejlen var ikke svær at rette, den var svær at
opdage.

Målt i et klonet arbejdstræ i alle tre tilstande: annoteret giver den rigtige
tekst med selvlinket fjernet, letvægt giver flettecommitten og udløser vagten,
og en re-fetch af refen bringer den annoterede tilbage.

Teksten på de to udgivelser, der allerede stod forkert, er rettet i hånden.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-14 08:21:27 +02:00
Kristian Wind 0dc4c2f9b8 Merge pull request #77 from kristianwind/besked-og-en-udgang
CI / OpenAPI is valid (push) Successful in 8s
CI / End-to-end (push) Successful in 8m56s
CI / Docker image builds (push) Successful in 1m39s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m6s
feat(projekter): en deling siger til, og der er en vej ud af den
2026-09-14 08:17:53 +02:00
kristianwindandClaude Opus 5 7f09b35db5 feat(projekter): en deling siger til, og der er en vej ud af den
Spørgsmålet var, om modtageren ikke burde godkende først. Svaret er nej — men
det, spørgsmålet fik øje på, var rigtigt, og værre end det lød.

Tre ting gjaldt samtidig: man blev tilføjet uden at blive spurgt, man fik ikke
besked, og man kunne ikke gå ud igen. En delt note gav en klokkebesked; et delt
projekt gav ingenting — det stod bare i sidebjælken næste gang, man kiggede,
uden at noget sagde hvornår eller fra hvem. Og handleRemoveMember lå bag
ejer-kravet, så kun ejeren kunne fjerne nogen, også en selv.

Hvert enkelt af de tre er til at leve med. Alle tre sammen gør en deling til
noget, der sker for en.

Ikke et godkendelsestrin: en invitation, der venter på et ja, betyder, at den,
der delte, tror det er gjort, mens arbejdet ligger stille — og noter deles
allerede uden. Det, der manglede, var besked og en udgang.

`project.shared` er ny. Klokkens follow() kunne allerede føre et project_id hen
til projektet, så beskeden bærer sit eget mål og krævede ingen særregel.

Ruten til at fjerne et medlem er flyttet ud af ejer-gruppen, og handleren skiller
nu de to ting ad: ejeren, der fjerner nogen, og den, der selv går. At pege på en
andens medlemskab uden at eje projektet svarer 404 og ikke 403 — "det må du
ikke" bekræfter, at medlemskabet findes. Ejeren kan stadig ikke gå fra sit eget
projekt; det ville være en overdragelse, og den findes ikke endnu.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-14 08:07:46 +02:00
Kristian Wind 0f4048d8f2 Merge pull request #76 from kristianwind/del-projekt-med-en-der-er-her
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m53s
CI / Docker image builds (push) Successful in 1m39s
CI / Go (push) Failing after 14m7s
feat(projekter): del et projekt med en, der allerede er her
2026-09-13 18:00:04 +02:00
kristianwindandClaude Opus 5 9b21b3eac5 feat(projekter): del et projekt med en, der allerede er her
Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
vej at spørge om en kollega, man kender navnet på og sidder i samme rum som —
og beslutningen om, hvorvidt en instans' konti må vises for hinanden, var
allerede taget: `UsersForSharing` har gjort det for noter hele tiden, med
begrundelsen skrevet ved siden af. Der er ingen åben oprettelse, så konti er en
lukket, inviteret mængde, og det er en adressebog frem for en udlevering.
Projekterne bruger nu den samme.

`GET /members` svarer med tre lister frem for én, så panelet åbner færdigt i ét
kald — samme form som notens delingspanel. `POST /invites` tager `user_id` ved
siden af `email`, og de to veje mødes ét sted: en adresse, der viser sig at høre
til en konto, ER den konto, og skal ikke gennem en oprettelsesside, den ikke kan
komme igennem.

De ventende invitationer kom med, fordi den nye liste skabte behovet for dem:
den, der er inviteret og ikke kommet, har ingen konto og står derfor hverken
blandt medlemmerne eller blandt dem, man kan vælge. Før betød det, at man ikke
kunne se sin egen invitation; nu ville det betyde, at man leder efter en person,
der mangler i listen, og inviterer igen. Det andet link virker lige så godt som
det første. De kan trækkes tilbage, for ellers er den ventende liste kun til at
se på.

Tre ting, der kostede en kørsel hver:

`OpenAPI is valid` fangede den nye DELETE-rute, før jeg selv gjorde.

To felter hed "Rolle" på den samme side. Det er ikke kun en prøve, der brokker
sig — en skærmlæser siger "Rolle" to gange uden at kunne skelne. De har nu hver
sin tilstand og hvert sit navn, og den gamle prøves `getByLabel('Rolle')` er
blevet `exact`, fordi den matchede som understreng.

Og e2e-prøven lånte først sin anden person fra prøven ovenfor. Det så jeg kun,
fordi jeg kørte den alene: basen tømmes for hver kørsel, så en prøve, der kun
består, når hele filen kører i rækkefølge, består ikke af sine egne grunde. Den
laver nu selv sin — og viser dermed skiftet: den samme person er først en
ventende adresse og bagefter et navn, der kan vælges.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-13 17:52:03 +02:00
Kristian Wind a39f655565 Merge pull request #75 from kristianwind/soeg-i-indstillinger
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m38s
CI / Docker image builds (push) Successful in 1m38s
CI / Go (push) Failing after 14m6s
Release / Build and publish image (push) Failing after 4s
feat(indstillinger): en indstilling kan findes ved at søge efter den
2026-09-12 10:04:17 +02:00
kristianwindandClaude Opus 5 7e981354c2 feat(indstillinger): en indstilling kan findes ved at søge efter den
Indstillingerne er ti faner med syvogtredive afsnit, og den eneste vej til et
bestemt af dem var at huske, hvilken fane det lå under. ⌘K kunne opgaver,
projekter og noter — alt det, man selv har skrevet, og intet af det,
programmet består af.

Ét indeks, to steder det bruges: et felt øverst i indstillingerne og paletten.
To lister over de samme afsnit ville være to steder at glemme et nyt et.

Overskrifterne står ikke i indekset. Hver post peger på den nøgle, afsnittets
egen overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af
sig selv, og søgningen kan begge sprog uden at kende nogen af dem.

`words` er derimod skrevet i indekset, og det er med vilje: det er søgeord,
ingen får at se. Det danske afsnit hedder "Tælling af installationer", men det
ord, man kender det ved, er "beacon" — og på samme måde 2fa, imap, webcal,
webhook. De er de samme på begge sprog, hvilket er hele grunden til, at de
virker som søgeord.

Ankrene er nye id'er på afsnittene. Tre af dem stødte sammen med et felt inde i
afsnittet — to ens id'er gør <label for> tvetydig og getElementById tilfældig —
og hedder derfor to-faktor, kalenderfeed og caldav-server.

Springet dertil kan ikke overlades til browseren: siden hentes af ruteren, og
afsnittet findes først, når dens data er hjemme. Der prøves i to sekunder og
gives så op. Blinket bagefter er ikke pynt — man er landet midt i en side med
otte afsnit, man ikke selv rullede hen til.

Prøverne læser filerne frem for at klikke: halvdelen af afsnittene vises kun
under en betingelse, så en prøve, der ledte efter dem i en browser, ville melde
fejl på det, der med rette var skjult.

Og palettens pladsholder sagde "Søg i opgaver og projekter". Den har kunnet
noter længe, og kan nu også indstillinger.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-12 09:56:00 +02:00
Kristian Wind 1f0a43ac90 Merge pull request #74 from kristianwind/kalender-url-ud-over-kanten
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m13s
CI / Docker image builds (push) Successful in 1m30s
CI / Go (push) Failing after 13m56s
fix: URL'er kan læses helt på en telefon
2026-09-12 09:43:40 +02:00
kristianwindandClaude Opus 5 61f8887319 fix(integrationer): en adresse kan læses helt, også på en telefon
Feed'et, mailadressen, krogen, curl-eksemplet, CalDAV-serveren og de to
redirect-URI'er lå i skrivebeskyttede <input>. Et input ombryder ikke — det
ruller indeni — så på en telefon stoppede adressen midt i kassen, og resten
fandtes kun ved at trække inde i feltet. Der var ikke engang et rullepanel at
se: feltet så ud som om det indeholdt det, der stod i det. Målt ved 390 px var
kassen 306 px og krogens adresse 634.

De er nu <output>. Det er stadig et felt, en <label for> kan pege på, så
navnene på felterne bliver ved med at høre sammen med dem, og det er tekst,
der ombryder. `user-select: all` beholder det ene, inputtet gjorde bedre: ét
tryk tager hele adressen.

Det samme gjaldt invitationslinket i en delt note, som vises netop når der
ikke er nogen post at sende det med — altså når det er det eneste, der findes.

Prøven nævner felterne ved navn frem for at forespørge på en klasse. Den, der
måler `.field output` og finder nul, består også den dag felterne er lavet om
til noget andet klippet.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-12 09:18:49 +02:00
kristianwindandClaude Opus 5 72637a153a fix(kalender): en abonnementsadresse skubber ikke siden ud over skærmen
Adressen på et kalenderabonnement blev vist i en punktliste uden noget, der
lod den ombryde. En URL er ét ord på et par hundrede tegn uden et mellemrum i,
så den kan ikke bryde af sig selv — og det bredeste element på en side
bestemmer sidens bredde. På en telefon betød det, at hele indholdsspalten fik
et vandret rullepanel, ikke bare den ene linje.

`overflow-wrap: anywhere` er det, resten af indstillingerne allerede bruger til
et token og en hemmelighed; listen her var den, der blev glemt. Samtidig får
den layout frem for at ligge som løse inline-spans: navn, adresse og knap
under hinanden, uden punkttegnets indrykning.

Prøven måler `.content` og ikke dokumentet. Skallen holder selv siden på
skærmens bredde, så en måling på documentElement består med fejlen i behold —
den blev skrevet først og bestod, hvilket er grunden til at kommentaren står
der.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-12 09:11:26 +02:00
Kristian Wind f06a25b43e Merge pull request #73 from kristianwind/dagens-plan-kan-ses
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Failing after 10m29s
CI / Docker image builds (push) Successful in 1m54s
CI / Go (push) Failing after 14m45s
feat(plan): dagens plan står på I dag, og ikke kun i en besked
2026-09-09 10:32:28 +02:00
kristianwindandClaude Opus 5 6cf184caa6 feat(plan): dagens plan står på I dag, og ikke kun i en besked
Planen var en notifikation og ikke andet. Klokken klippede den af efter
én linje — "2 forfalder i dag, 1 er sprun…" — og klikket førte ingen
steder hen, fordi en plan hverken er en note, en opgave eller et projekt.
Det eneste sted, den landede hel, var push-beskeden på telefonen.

Nu står den som et kort øverst på I dag, dér hvor man kigger, når man
starter dagen. Beskeden er påmindelsen; kortet er stedet, den bliver.

Teksten er gemt for dagen frem for regnet forfra, hver gang nogen kigger.
En model pr. sidevisning er dyr for noget, der ikke har ændret sig — og
vigtigere: en plan, der siger noget andet, når man kigger på den, end den
sagde, da den kom, er værre end begge dele hver for sig. Kortet siger
også, hvornår den blev lavet: en plan fra klokken syv er en anden slags
oplysning klokken fire end klokken otte.

En plan fra i går sendes ikke med. Et kort, der viser gårsdagens plan
under overskriften "i dag", er værre end et tomt kort.

"Forny planen" laver en ny, når dagen har flyttet sig, og sender ikke en
besked om noget, man står og kigger på — den slags lærer folk at ignorere
klokken. Og for den, der har morgenbeskeden slået fra, tilbyder kortet at
lave planen på stedet, så funktionen ikke kun findes for dem, der vil
vækkes.

Klokken fører nu til I dag, og planen fylder de tre linjer, den er, i
stedet for at blive klippet af.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 10:22:27 +02:00
Kristian Wind 3c14906db2 Merge pull request #72 from kristianwind/dokumenter-det-der-blev-bygget
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m44s
CI / Docker image builds (push) Successful in 1m50s
CI / Go (push) Failing after 14m28s
docs: skriv det ned, der er blevet bygget
2026-09-09 09:02:58 +02:00
kristianwindandClaude Opus 5 70d7398d5a docs: skriv det ned, der er blevet bygget
API-dokumentationen har fulgt med hele vejen — der er en prøve, der fejler,
hvis en rute ikke står i openapi.yaml, og den fangede mig fire gange i går.
Brugervejledningen har ikke fulgt med, og det er den, folk læser.

Nyt: en side om beskeder — hvad der udløser en, hvorfor gentagne
rettelser bliver slået sammen, push pr. apparat og mærket på ikonet. En
om at åbne links i en anden browser, som mest af alt handler om, hvad en
webside ikke kan: hverken se hvilke browsere der findes eller vælge
mellem dem. Og en om tællingen af installationer, som ikke stod nogen
steder overhovedet — en melding, der er slået til som udgangspunkt, har
en pligt til at være skrevet ned, også når ingen har spurgt.

Rettet: AI-siden beskrev to funktioner, hvor der nu er syv, og
listen over hvad der sendes hvorhen var derfor forkert som en påstand om
noget privat. Noter-siden manglede deling med en e-mailadresse og hvad
`@Sofie` gør. Mail-siden manglede den anden vej ind i indbakken.
Konfigurationen sagde stadig, at versionstjekket kun kunne se efter.

Og tagbeskeden når nu frem. Den ER annonceringen — skrevet på produktets
eget sprog til dem, der bruger det — og udgivelsen på GitHub blev fyldt
med en autogenereret liste over commit-overskrifter i stedet, som er
skrevet til dem, der læser koden. Nu står teksten øverst og listen under
den. Linjen med linket til udgivelsen selv tages af: en side, der linker
til sig selv, hjælper ingen.

Dokumentationen bygges nu også på en PR. `mkdocs build --strict` fejler
på et link til en side, der ikke findes, og at bygge først efter merge er
at få det at vide på main.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 08:52:54 +02:00
Kristian Wind 1cabbb7a78 Merge pull request #71 from kristianwind/hvilken-browser-aabner-linket
feat(links): vælg hvilken browser et link åbner i
2026-09-09 08:50:30 +02:00
kristianwindandClaude Opus 5 5eb689e4c2 feat(links): vælg hvilken browser et link åbner i
Ønsket var en liste over de installerede browsere. Den findes ikke: en
webside kan hverken se, hvilke browsere der er på maskinen — listen er
lukket land, fordi den er et fingeraftryk, der ville følge folk rundt på
nettet — eller bede systemet om at bruge en bestemt af dem.

Det eneste håndtag er de URL-skemaer, browserne selv registrerer:
firefox://open-url, googlechrome://, microsoft-edge:. Dem kan man prøve,
og man kan ikke spørge, om de findes. Så indstillingen er skrevet som
det, den er — et forsøg med et faldback — frem for som et løfte, der
ville holde på telefonen og ikke på en Mac.

Valget står i localStorage, ikke på kontoen. Samme grund som
sidebjælkens bredde: det er en egenskab ved maskinen, man sidder ved.
firefox:// virker på telefonen og gør ingenting på Mac'en, så et valg,
der fulgte kontoen, ville være forkert det ene af de to steder hver gang.

Ét sted i koden frem for ét pr. komponent. Links står i opgavetitler, i
beskrivelser, i noter og i indstillingerne, og en regel, der skulle
gentages hvert af de steder, ville mangle det ene sted, ingen tænkte på.
Kun udadgående links; programmets egne ruter går gennem SvelteKit som før.

To ting, målingen rettede undervejs:

Det skjulte iframe — den kendte opskrift — bliver blokeret af appens egen
Content-Security-Policy, som siger default-src 'self'. Uden e2e-prøven,
der fangede CSP-fejlen i konsollen, ville skemaet aldrig være blevet
forsøgt i produktion, og indstillingen ville have set ud til at virke,
fordi faldbacket åbnede linket alligevel. Nu sættes location i stedet:
et ukendt skema afbryder navigationen frem for at forlade siden.

Og faldbacket troede sig blokeret hver eneste gang. window.open med
noopener returnerer altid null — det står i specifikationen — så
kontrollen af, om fanen blev åbnet, faldt altid ud til "blokeret", og
den aktuelle fane blev navigeret væk i stedet. Håndtaget er det eneste,
der kan kende en blokeret fane fra en åbnet, så det bruges nu, og opener
ryddes bagefter.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 08:41:21 +02:00
Kristian Wind e931ddc313 Merge pull request #70 from kristianwind/ny-version-siger-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m50s
CI / Docker image builds (push) Successful in 1m54s
CI / Go (push) Failing after 14m45s
Release / Build and publish image (push) Failing after 5s
feat(opdatering): en ny version siger til, i stedet for at vente på at blive fundet
2026-09-08 17:05:52 +02:00
kristianwindandClaude Opus 5 2210dc082a feat(opdatering): en ny version siger til, i stedet for at vente på at
blive fundet

Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på
indstillingssiden for beskeder. Det er en oplysning, man kun får, hvis
man i forvejen havde mistanke om, at der var noget at se efter — og "der
er kommet en rettelse af noget, du er ramt af" er ikke noget, man gætter
sig til at kigge efter.

Nu går den i klokken, i push og på mærket på ikonet, som alt andet, der
er værd at vide. Og den fører hen, hvor knappen til at genstarte sidder,
frem for ud af programmet.

Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
samme er den slags besked, folk slår fra — og så er den næste, der
betyder noget, også slået fra. Versionen, der er sagt til om, skrives
først, når beskederne er sendt: den anden vej rundt ville en fejl
midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.

Kun administratorer, af samme grund som versionsfeltet allerede er det:
en besked om, at serveren er bagud, er en besked om en andens arbejde,
hvis man ikke selv kan gøre noget ved den.

Og et tjek, operatøren har slået fra, bliver ikke til en besked ad
bagvejen. VERDANDE_UPDATE_CHECK styrer det hele, som før.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:55:53 +02:00
Kristian Wind 9fa47822c1 Merge pull request #69 from kristianwind/ai-dagens-plan-2
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m57s
CI / Docker image builds (push) Successful in 2m19s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 15m3s
feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål
2026-09-08 15:15:48 +02:00
Kristian Wind 366bed3a15 Merge pull request #68 from kristianwind/ai-foreslaar-du-bestemmer
feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
2026-09-08 15:06:21 +02:00
Kristian Wind 07fc170b91 Merge pull request #67 from kristianwind/beacon-siger-det-hoejt
feat(beacon): sig det uopfordret, og lad være med at fejle i tavshed
2026-09-08 15:06:17 +02:00
kristianwindandClaude Opus 5 8139003b35 feat(ai): dagens plan, mails der giver mening, og svar på et spørgsmål
De tre sidste af de fem. Alle tre samme sted hen: det, man ellers selv
skulle sidde og gøre klokken syv om morgenen.

Dagens plan. Én besked om morgenen om det, der forfalder i dag, og det,
der er sprunget over. Tallene tælles i basen og gives til modellen som
kendsgerninger, den ikke må regne om — en plan, der siger fire opgaver,
hvor der er tre, er værre end ingen plan. Er der ingen model, står
tallene alene, og det er stadig det, man havde brug for at vide. Er der
hverken noget, der forfalder, eller noget, der er sprunget over, kommer
der ingen besked: en morgen uden noget at gøre er en god morgen, og en
besked om ingenting lærer folk at lade være med at åbne beskeder.

Timen er personens egen — klokken syv er syv forskellige steder — og
jobbet kører hver time frem for på et klokkeslæt, så en instans, der sov,
ikke springer dagen over. Flytter man timen, ryger dagens mærke, så man
kan se virkningen i dag i stedet for i morgen.

Mails, der giver mening. Opgaven hed, hvad mailen hed: "Anders Jensen:
SV: SV: Vedr. levering uge 12". Nok til at genkende, ikke nok til at
gøre noget ved. Nu skriver modellen den handling, mailen beder om, og
lader være, hvis den ikke beder om nogen. Ét kald pr. gennemløb frem for
ét pr. mail — femogtyve mails er ellers femogtyve gange ventetid — og
emnet bliver stående i beskrivelsen, så en misforstået mail stadig kan
læses. Falder modellen ud, hedder opgaven afsender og emne som før: en
model, der er nede, må ikke kunne stoppe posten.

Og svar på et spørgsmål. "Hvad lovede jeg Anders i august?" — svaret står
spredt over tre noter, og ingen af dem indeholder ordet "lovede".
Søgningen kræver, at alle ord optræder, hvilket er rigtigt for et
søgefelt og forkert for et spørgsmål, så kandidaterne hentes ord for ord
og de noter, flere ord rammer, kommer øverst. Modellen får kun dem: den
slår ikke selv noget op, så et svar kan ikke handle om nogen andens
noter. Og svaret bærer, hvad det står på — en model, der siger "det
gjorde du den 14.", er kun brugbar, hvis man kan slå op i det, den læste
det i.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 15:05:36 +02:00
kristianwindandClaude Opus 5 96e1fa251b fix(beacon): beskeden skubber i stedet for at dække
Den lå som en rude i hjørnet, oven på arbejdet, og dækkede knapperne i
notens fod og i delepanelet — seks e2e-prøver klikkede forgæves på noget,
der lå under den.

Det var ikke kun et prøveproblem. En besked, der er i vejen, bliver
klikket væk uden at blive læst, og så er den uopfordrede oplysning en
formalitet frem for noget, nogen har set. Nu står den øverst i indholdet
og skubber siden ned, indtil den bliver besvaret.

Og e2e beviser den nu frem for at klikke den væk: den første konto skal
møde beskeden, og der skal stå et instance_id og en version i den.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 14:51:47 +02:00
kristianwindandClaude Opus 5 40102d6038 feat(ai): forslag, man siger ja til — indbakken og noternes opgaver
To steder, hvor arbejdet ligger og venter på at blive skrevet færdigt.

Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive
dem ordentligt — og derfor også der, hvor de bliver liggende. At åbne
hver enkelt og sætte projekt, dato og prioritet er ti klik pr. linje. Nu
er det ét spørgsmål og et ja pr. linje.

Og et referat er fuldt af sætninger, der ligner opgaver, og af tre, der
er det. Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig
på, og om at citere de ord, den tog dem fra — så et forkert punkt kan
gennemskues frem for bare at være forkert.

Modellen skriver ikke noget. Den foreslår en *linje* i quick-add-syntaks,
og forslaget bliver til noget, når nogen siger ja. Det er ikke
forsigtighed for forsigtighedens skyld: en model, der retter i folks
opgaver direkte, skal have ret hver gang for at være til gavn, hvor en,
der foreslår, skal have ret tit nok til at spare et par tastetryk.

Linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
findes. Der er én fortolkning af "i morgen" i dette program, og forslaget
går gennem den samme som alt andet — også når linjen er blevet rettet i
hånden, inden den blev accepteret. Og et forslag, skrevet i det sprog man
selv skriver i, kan læses og rettes af den, det bliver stillet til.

Modellen får listen over de projekter, der findes, og må ikke nævne
andre. Et `#Nyt projekt`, den fandt på, ville lave et projekt ved et
uheld, første gang nogen sagde ja — og et gæt må ikke kunne flytte en
opgave hen, hvor ejeren ikke leder efter den. Testet.

Forslagene til indbakken bærer et nummer tilbage til den opgave, de
handler om. Rækkefølgen alene ville også kunne bruges og gør det som
reserve, men en model, der springer en opgave over, ville så flytte alle
de følgende forslag én plads — og "sæt en dato på den her" ville lande på
en anden.

Noten bliver ikke rørt, når der laves opgaver ud af den. Det er
fristende at skrive linket ind i den, så de to peger på hinanden, men en
tekst, et program skriver i uden at blive bedt om det, er en tekst, man
holder op med at stole på. Opgaven bærer i stedet, hvor den kom fra.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 14:48:56 +02:00
kristianwindandClaude Opus 5 0cf5c8c281 feat(beacon): sig det uopfordret, og lad være med at fejle i tavshed
Beaconet er slået til som udgangspunkt. Det kan kun forsvares, hvis det
bliver sagt af sig selv — og det blev det ikke. Det stod rigtigt og
fyldestgørende under Indstillinger → Data, som er en side, der bliver
åbnet af dem, der i forvejen har en mistanke om, at der er noget at læse.
For alle andre var "du kan slå den fra" et tilbud til nogen, der ikke
vidste, der var noget at slå fra.

Nu møder en administrator beskeden første gang: hvad der bliver sendt,
med de rigtige værdier i — det rigtige id, den rigtige version, den
rigtige adresse — og to knapper. Værdierne står der frem for ordet
"anonymt", fordi et løfte om telemetri er præcis så meget værd som
læserens mulighed for at efterprøve det.

`keep` er en peger og ikke en bool. En tom krop måtte ikke kunne betyde
"slå det fra": et fravalg, ingen har truffet, er lige så meget et brudt
løfte som et tilvalg, ingen har truffet.

Og tre ting mere, som Yggdrasil har lært på den hårde måde:

Et beacon, der ikke kan nå sin collector, fejlede i fuldstændig tavshed.
Det prøvede igen i morgen, siden sagde "sidst sendt" med en dato, der
bare blev ældre, og ingen opdagede det. Grunden bliver nu skrevet ned og
vist.

En installation, der opdaterer sig selv om natten, meldte gårsdagens
version i op til et døgn, fordi døgnet blev talt fra sidste vellykkede
melding. En version, der er skiftet, sender nu med det samme.

Og det offentliggjorte tal har fået et gulv. Et tal, der falder, kan ikke
offentliggøres som en kendsgerning — en dårlig udrulning, en
DNS-ændring, der stille stopper meldingerne, og rækker, der er væk, ser
alle sammen ud som "færre bruger det nu".

Adressen bliver stående: Verdande melder til verdande.yggdrasilpanel.com,
ikke til panelets egen collector. De to tæller hver sin slags
installation, og en fælles tabel har intet felt til at holde dem ude fra
hinanden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 14:39:14 +02:00
Kristian Wind 445d9de57a Merge pull request #66 from kristianwind/indbakken-har-en-adresse-apps-kan-skubbe-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 11m3s
CI / Docker image builds (push) Successful in 2m17s
Release / Build and publish image (push) Failing after 9s
CI / Go (push) Failing after 15m6s
feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
2026-09-08 14:12:17 +02:00
Kristian Wind d5ad459eba Merge pull request #65 from kristianwind/maerket-paa-ikonet
feat(beskeder): appens ikon bærer tallet på ulæste
2026-09-08 14:04:12 +02:00
Kristian Wind c297d256e4 Merge pull request #64 from kristianwind/tag-en-person-i-en-note
feat(noter): nævn en person i en note, og den er delt med dem
2026-09-08 14:04:08 +02:00
kristianwindandClaude Opus 5 d063425cae feat(indbakke): en adresse, andre programmer kan skubbe en opgave til
Der var to veje ind i forvejen, og ingen af dem passer til "gem den her,
mens jeg står med telefonen i hånden". API-tokenet giver adgang til alt
og skal have en JSON-krop skrevet rigtigt; mailadressen virker først, når
nogen har sat en mailserver op, og går gennem en indbakke, der kan være
minutter om det. Det, der manglede, var en URL, man kan smide en linje
tekst efter — fra en genvej på telefonen, et script, en tjeneste, der kan
kalde en URL.

Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en
genvej, der bare sender det, man har markeret. Første linje bliver til
opgaven, resten til noten under den. Linjen læses med quick-add-parseren,
så "Ring til Anders i morgen p1 #Firma" betyder det samme her som i
feltet øverst i fladen — dét er hele pointen med at have én parser: den
vej, teksten kom ind ad, ændrer ikke, hvad den betyder.

Sit eget token frem for at låne mailens. De to er den samme slags
hemmelighed, men de slipper ud hver for sig: en URL, der har ligget i en
genvej på en telefon, der blev væk, skal kunne skiftes uden at
mailadressen — som står i andres adressebøger — skifter med.

Og ikke et API-token. Et token herfra kan én ting: lave en opgave i den
persons indbakke. Det er hele forskellen på en hemmelighed, man kan lægge
i et script på en anden maskine, og en, man ikke kan.

Kun POST. En GET ville være nemmere at kalde, og det er præcis
problemet: forudindlæsning i en browser, en linkkontrol i en chat og en
historik, der bliver besøgt igen, ville alle lave opgaver, ingen har
bedt om. Sin egen kvote frem for postens, så en travl integration ikke
kan lukke for den indgående post.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 14:03:32 +02:00
Kristian Wind 0764fbd566 Merge pull request #63 from kristianwind/del-note-med-en-uden-konto
feat(noter): del en note med en, der ikke har en konto endnu
2026-09-08 12:13:34 +02:00
kristianwindandClaude Opus 5 b041b32aa2 feat(beskeder): appens ikon bærer tallet på ulæste
Klokken i hovedet siger, hvad der er nyt — men kun til den, der kigger på
fanen. Det halve af pointen med at få besked er at få den, mens man laver
noget andet, og det eneste sted, appen kan sige noget dér, er på sit eget
ikon: mærket i Dock'en på en Mac, på proceslinjen på en pc.

Sat to steder, fordi der er to tilstande. Appen selv, mens den er åben og
kender tallet, og service-workeren, når den ikke er — og det er netop
dér, mærket er det, der siger, at der er sket noget.

Tallet kommer med i push-beskeden frem for at blive talt op af
service-workeren. Den husker ingenting mellem to opvågninger og kunne
derfor kun lægge én til noget, den ikke ved hvad er: et mærke, der tæller
sig selv op og aldrig kommer ned igen. Serveren ved det i forvejen, og en
tælling, der fejler, udelader feltet frem for at sende et nul — så står
mærket, som det stod, i stedet for at blive tørret af en optælling, der
ikke lykkedes.

Ét tal, aldrig en optælling: mærket sættes til hvad der *er*, så det går
ned af sig selv, når beskederne bliver læst. Og det ryddes ved log ud —
det står i Dock'en, også når ingen er logget ind.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 12:12:54 +02:00
kristianwindandClaude Opus 5 d8f5f6e236 feat(noter): nævn en person i en note, og den er delt med dem
At skrive nogens navn i en note *er* at ville have dem til at læse den.
Indtil nu var det to handlinger — skriv navnet, åbn panelet, find
personen, del — og den anden blev glemt, så noten stod med en besked til
en, der ikke kunne se den.

Nu deler `@Navn` noten med den, der bliver nævnt, og siger det til dem.
Editoren foreslår personer efter `@`, ligesom `#` foreslår projekter.

Slået op i navnene frem for tolket ud af teksten. Et navn kan have
mellemrum i sig, og et udtryk, der skulle fange dem alle, ville også
fange sætningen bagefter: `@Sofie i morgen` ville blive til en person,
der hed noget i den retning. Her er der ikke noget at gætte — der står et
navn, eller der gør ikke. Det er også dét, der gør det forsvarligt at
dele på: adgang må ikke afhænge af, hvor langt et regulært udtryk valgte
at læse. En mailadresse er ikke en omtale, det længste navn vinder, og
ejefaldets s hører til sætningen.

Kun nye omtaler, sammenlignet med teksten før gemningen på samme måde som
links: en note gemmes ved hver tastepause, så uden sammenligningen var
hver gemning en ny deling og en ny besked om det samme navn.

Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke
give den videre — ellers kunne den, ejeren har delt med, skrive et navn
og dermed dele ejerens note med en, ejeren aldrig har hørt om. Det er
den samme regel, panelet følger.

Læser, ikke medredaktør: en omtale siger "se på det her", og det mindste,
der opfylder sætningen, er det rigtige at give. En rolle, ejeren har
hævet i panelet, sænkes ikke igen af en omtale.

Og én besked pr. handling: den, der lige er blevet nævnt, får "nævnte
dig" og ikke også "rettede en note" om den samme gemning.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 12:08:12 +02:00
Kristian Wind 61634a6bb5 Merge pull request #62 from kristianwind/noter-og-opgaver-giver-besked
feat(beskeder): klokken siger til, når nogen rører noget, der er dit
2026-09-08 11:59:04 +02:00
kristianwindandClaude Opus 5 8ee9fbedfb feat(noter): del en note med en, der ikke har en konto endnu
En note kunne deles med dem, der allerede var på instansen — og kun med
dem. Skulle en anden læse med, skulle man først invitere dem ind, vente
på at de oprettede sig, finde noten frem og så dele den. Fire skridt og
en ventetid for det, der er én sætning: lad Sofie se den her.

Nu er der ét felt. Man skriver adressen, og serveren ved, om der er en
konto bag: er der det, bliver det en deling med det samme, og ellers en
invitation, der bliver til delingen i samme øjeblik, kontoen oprettes.
Hvem der har en konto, er ikke noget, man skal vide for at dele en note.

Rækken er den, der allerede fandtes. En invitation bar i forvejen en
e-mail, en rolle og et forseglet token; det eneste, der manglede, var at
kunne sige, hvad den var en invitation *til*. note_id ved siden af
project_id, med fremmednøglen — dén, der rydder invitationen væk, når
noten slettes, inden nogen når at tage imod en invitation til noget, der
ikke er der længere.

Det er ikke ny magt til nogen: enhver, der ejer et projekt, kunne i
forvejen invitere en fremmed ind på instansen pr. e-mail. Kun ejeren kan
invitere til en note, som kun ejeren kan dele den.

De inviterede står i panelet ved siden af dem, der er kommet, og kan
trækkes tilbage. Uden det er en invitation sendt i går usynlig, og så
sender man den igen — endnu et link til den samme indbakke, og kun det
ene af dem til at gøre ugyldigt.

Og delingen siger selv til: den, der får en note delt, får besked om, at
den er der. Klokken sorterer nu på id efter tid, fordi tiden er sekunder
— en note, der bliver delt og rettet i samme håndbevægelse, stod ellers
i den rækkefølge, basen nu engang fandt dem i.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 11:58:47 +02:00
kristianwindandClaude Opus 5 ab77024ec9 feat(beskeder): klokken siger til, når nogen rører noget, der er dit
En delt note blev rettet af en anden, og man opdagede det kun ved at åbne
den. En opgave blev givet til en, og den lå bare der. Det, andre gør ved
det, man deler, skal man kunne se uden at lede efter det.

Klokken i hovedet: ulæste beskeder om noter, der er blevet rettet, delt
med en, eller opgaver, man har fået. Sætningen skrives i browseren, ikke
på serveren, så et engelsk interface får en engelsk klokke.

Beskeder om den samme note bliver slået sammen, mens de er ulæste. En
note gemmes på en tastepause, så en række pr. gem ville lave én person,
der skriver i fem minutter, om til tyve ens linjer. Er beskeden læst, er
den næste rettelse en ny ting at få at vide — ikke en gentagelse.

note_id får sin egen søjle ved siden af project_id og task_id frem for et
generisk (kind, id)-par: de tre er ikke det samme slags nul, og
fremmednøglen — dén, der rydder beskeden op, når noten slettes — kan et
generisk par ikke have.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 11:40:43 +02:00
Kristian Wind 48509e3073 Merge pull request #61 from kristianwind/delte-noter-tager-links-med
Release / Build and publish image (push) Failing after 8s
feat(noter): en delt note tager de noter med, den peger på
2026-09-07 22:59:47 +02:00
kristianwindandClaude Opus 5 916a0c0224 feat(noter): en delt note tager de noter med, den peger på
Den anden halvdel af den melding, der begyndte det her: linkene i en delt note
førte ingen steder hen, fordi det, de pegede på, ikke var delt. At dele en note
er at dele det, den *siger*, og et link er en del af det.

note_shares får en `linked`-søjle. 0 er en deling, nogen har valgt; 1 er en, der
er udledt af et link. Skellet er hele modellen. Fladen kan sige *hvorfor* en
person har en note — "fulgte med" er en anden sætning end "du delte den" — og de
udledte rækker kan regnes forfra efter hver ændring, uden at røre dem, ejeren
selv har sat.

SyncLinkedShares regner billedet om fra bunden efter enhver af ejerens egne
handlinger: en deling, en fjernet deling, en gemning der har ændret links eller
titel, en sletning. Hele kæden følger med, ikke kun første led, med et loft på
hundrede noter — "del denne ene note" må ikke kunne blive til fire hundrede.
Rollen arves fra den note, man kom fra, og stærkest vinder, når en note kan nås
ad to veje.

Kun ejerens egne noter følger med, og det er ikke en optimering: et link peger
på en titel, og en medredaktør må skrive titler. Kunne et link hente hvad som
helst, kunne den, man har delt med, skrive [[Bankoplysninger]] og læse med.
Derfor slås der kun op blandt ejerens egne noter, og derfor regner kun ejerens
egne gemninger om. Begge dele har en Go-prøve.

Det lukker ikke hullet helt: skriver medredaktøren linket, og gemmer ejeren
bagefter sin egen note, følger den note med dér. Det, der står imod, er
synlighed — linket står som tekst i ejerens egen note, og panelet viser hver
note, der er fulgt med, og hvem der har den.

Udregningen åbner hver af ejerens noter, fordi titlerne er forseglet. Derfor to
værn: har man ikke delt noget, svares der med det samme ud fra ét EXISTS, og en
gemning regner kun om, når links eller titel faktisk har flyttet sig. Uden det
første kostede "Ny note" halvfems millisekunder, og i det hul nåede e2e-prøven
for monospace at taste sin titel ind i den forrige note, som editoren så tegnede
væk.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
2026-09-07 22:28:58 +02:00
Kristian Wind ff75302cf8 Merge pull request #60 from kristianwind/note-links-og-kodeknap
fix(noter): følg note-links i en delt note, og lad </> være kode
2026-09-07 21:58:29 +02:00
kristianwindandClaude Opus 5 08d703e3af fix(noter): følg note-links i en delt note, og lad </> være kode
To ting meldt af en, der havde en note delt med sig.

Note-linkene i den delte note gjorde ingenting. Opslaget faldt tilbage på det
første søgeresultat, når ingen titel passede — og i en delt note er det første
resultat næsten altid noten selv, fordi den nævner den titel, den peger på. Så
åbnede linket den note, man allerede stod i. Kun en titel, der passer helt,
tæller nu; og rammer den forbi, siger beskeden den rigtige grund: en note, der
deles, deler ikke de noter, den peger på, så modtageren kan se linket uden at
kunne åbne det.

Og `</>` sad på kildevisningen med teksten "Vis som kode" på. Tegnet betyder
inline kode i ethvert andet program, så et markeret ord plus et tryk gav hele
noten som Markdown. Nu gør `</>` det, det siger — ⌘E, en kontakt, og ordet under
markøren når der ikke er markeret noget — og kilden har fået Markdown-mærket og
sin egen tekst.

Kode indsættes som en node frem for med execCommand: Chromium renser den HTML
gennem indsætningsvejen og gav et <span> med skrifttypen skrevet ind i sig, som
htmlToMarkdown ikke kan skrive ned som kode.

To e2e-prøver, som begge fejler på koden før den her.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
2026-09-07 21:50:12 +02:00
Kristian Wind d4784fd174 Merge pull request #59 from kristianwind/upcoming-horizon
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 9m37s
CI / Docker image builds (push) Successful in 2m9s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 14m45s
Kommende: en horisont på 7, 14 eller 30 dage
2026-09-07 15:08:06 +02:00
kristianwindandClaude Opus 5 094ea5cdd8 feat(upcoming): look up to 30 days ahead, and choose how far
Kommendes liste has always been the next seven days and nothing else. Seven days
is the right walk to take in the morning, and it is still what the view opens on
— but "when does this month get busy" is a different question, and the month grid
answers it with shape rather than in order. So the list now takes a horizon: 7,
14 or 30 days, chosen with a switcher beside the view buttons.

Only the list gets one. A week grid is a week and a month grid is a month; there
is no fortnight for them to show, so the switcher is hidden in those modes.

The choice lives in localStorage next to the mode, under its own key. It is the
same kind of choice as list-or-grid — how you want to look, not a fact about the
work — and it should survive a reload without following you to a phone where
thirty day headings is a very long scroll.

Once the strip is longer than a week the day headings carry the week number.
Thirty days repeat every weekday four times, and "mandag 6. okt" alone does not
say which mandag.

Server side the endpoint already took `?days=` and capped it at 31, so the window
needed nothing — but its row limit was a constant 1000 written for a week. A
month asked for with a week's ceiling would truncate the last days silently, and
a day cut off the end of the answer looks exactly like a day with nothing due, so
the limit now follows the window.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SA1TDmgQZw3AufJqkbwezx
2026-09-07 14:46:49 +02:00
Kristian Wind f60758b770 Merge pull request #58 from kristianwind/site-all-themes
docs(site): show all six themes, and put the demo line in English
2026-09-04 09:56:28 +02:00
kristianwindandClaude Opus 4.8 9d390d021f docs(site): show all six themes, and put the demo line in English
Adds a gallery of the same Today view in each of the six themes — Northern lights,
Charcoal, Dusk, Daylight, Paper, Contrast — so the range is shown rather than
described, on the landing page and (as the swatch panel) the docs home. The lone
Charcoal Today capture is folded into it.

And the quick-add demo that leads the docs — "betal moms i morgen kl 10 p1 #Firma
@regnskab" — is now English, "file the VAT return tomorrow at 10am p1 #Accounts
@tax", on every page it appears as the line to type: the home, the quick-add page,
the API and MCP examples, and the OpenAPI blurb. The Danish date words on the
quick-add page stay, because documenting the Danish parser is their job.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 09:46:50 +02:00
Kristian Wind 18db79c063 Merge pull request #57 from kristianwind/site-charcoal-features
docs(site): Charcoal theme, offline, and the star that comes off
2026-09-04 09:27:50 +02:00
kristianwindandClaude Opus 4.8 1612f53966 docs(site): the Charcoal theme, offline, and the star that comes off
Refreshes the whole screenshot set to the app as it ships now — the three-line
note rows, the lightened calendar — and adds two Charcoal captures, the theme
modelled on Apple Notes, since it is the one worth showing. The landing page and
the docs home both gain it, a "holds through a short network drop" line, and the
correction that a starred or flagged mail now loses its mark once it is a task
rather than keeping it. Six themes now, not five.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 09:20:11 +02:00
Kristian Wind 4d5f1a56b1 Merge pull request #56 from kristianwind/gmail-unstar
CI / OpenAPI is valid (push) Successful in 2m55s
CI / End-to-end (push) Successful in 8m3s
CI / Docker image builds (push) Successful in 1m46s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 13m23s
feat(gmail): take the star off a mail once it has become a task
2026-09-04 09:06:31 +02:00
Kristian Wind 274e392708 Merge pull request #55 from kristianwind/offline-cache
feat(offline): hold through a short network drop with a caching worker
2026-09-04 08:57:38 +02:00
kristianwindandClaude Opus 4.8 84ca506961 feat(gmail): take the star off a mail once it has become a task
A starred Gmail message became a task and kept its star, so a starred inbox only
grew — the IMAP side has always unflagged what it imports, and Gmail could not,
because the scope was gmail.readonly. This gives Gmail the same one write: after
the task is made, the STARRED label comes off the message, in one batchModify for
the whole sweep. Nothing else is touched — not the body, not other labels, and a
label trigger's label stays where it is, since that label is the person's own.

The scope goes from gmail.readonly to gmail.modify, which is the narrowest scope
Google offers that can remove a label. An existing connection keeps working on its
old read-only grant — the sync still reads, the unstar is a best-effort 403 that is
logged, not fatal — and starts clearing stars once the account is reconnected and
the new grant is in place. It self-heals: a mail that could not be unstarred stays
starred, stays in the next query, and is tried again.

The consent screen now asks for more than it used to; the settings copy, the docs
and the scope table all say modify rather than read-only in the same change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 08:57:07 +02:00
kristianwindandClaude Opus 4.8 6db61b0cc7 test(e2e): block the service worker except where the test is about it
The caching worker controls every page once it registers, and Playwright's
page.route() does not see the fetches a worker makes — so the failed-load test,
which forces a 500 to check the error path, got the real 200 through the worker
and read as a broken feature. Block the worker by default and turn it back on only
for the offline test, which is the one that needs a real one.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-04 08:12:15 +02:00
kristianwindandClaude Opus 4.8 57689ddfbb feat(offline): hold through a short network drop with a caching worker
The service worker did Web Push and nothing else, with a note that a cache
serving yesterday's data was worse than an honest "cannot connect". This adds a
cache that does not do that: network-first for everything that can go stale, so a
reader online always gets the live answer and the cache is reached only when the
network cannot answer. The task and note lists stay live; they just no longer go
blank the moment the signal does — a reload through a tunnel, a lift or a
base-station handover comes back to the app instead of the browser's offline page.

Writes are untouched and still go straight to the network; api.js already retries
them across the same short drop. The shell cache is versioned so a deploy lands in
a fresh cache and the old one is dropped on activate; the data cache is cleared on
sign-out so the next person on the browser does not inherit it.

The old hand-registered /sw.js becomes SvelteKit's src/service-worker.js, which it
registers itself, and the push handlers move across unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 22:46:35 +02:00
Kristian Wind 7770ba9844 Merge pull request #54 from kristianwind/tasks-first-tap-rounded
CI / OpenAPI is valid (push) Successful in 14s
CI / End-to-end (push) Successful in 11m2s
CI / Docker image builds (push) Successful in 2m40s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 15m27s
Rows: open on the first tap on iPhone, round the hover highlight
2026-09-03 22:23:08 +02:00
kristianwindandClaude Opus 4.8 55b8e438ad fix(rows): open on the first tap on iPhone, and round the hover highlight
On a touch screen a :hover on the tap target makes iOS spend the first tap
applying the hover and only open on the second — the double-tap Kristian hit
on tasks (and notes). The row hover is gated behind @media (hover: hover)
now, so a touch has no hover to apply and the first tap opens. The note
row's action buttons, which the hover used to reveal, are shown outright on
touch instead. And the task-row hover highlight is a rounded box (border
radius on the row), the same corner as a note in the list.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 22:15:23 +02:00
Kristian Wind 1607a22f0c Merge pull request #53 from kristianwind/charcoal-separators
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m56s
CI / Docker image builds (push) Successful in 2m2s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 15m10s
Charcoal: lighter day separators in the note list
2026-09-03 20:54:56 +02:00
kristianwindandClaude Opus 4.8 8a5dfef41a fix(theme): lighter day separators in Charcoal
In Charcoal the note-list day separators used --ground, the darkest colour
in the theme, so they read as dark bars on the lighter list. They take the
menu's own resting grey (#2d3133) now — the colour "Projekter" sits on —
via a --note-group-bg fallback token, so every other theme keeps --ground.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 20:46:51 +02:00
Kristian Wind 1a610eaac4 Merge pull request #52 from kristianwind/notes-single-tap
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m25s
CI / Docker image builds (push) Successful in 1m58s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 15m11s
Notes: open on the first tap, not the second
2026-09-03 20:35:30 +02:00
kristianwindandClaude Opus 4.8 c2d4ce0179 fix(notes): open a note on the first tap, not the second
On a phone the list/editor switch keyed on the loaded note, which arrives a
moment after the tap — so the first tap looked like nothing happened and
people tapped again. It keys on the selected id now, set the instant a row
is tapped, so the pane switches at once; the back button is there
immediately and the body fills in when it arrives, with a quiet pane rather
than the "pick a note" prompt in the half-second between.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 20:27:28 +02:00
Kristian Wind a1b8cc6ddc Merge pull request #51 from kristianwind/notes-mobile-detail
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m51s
CI / Docker image builds (push) Successful in 1m58s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m24s
Notes on a phone: show the note (master-detail layout)
2026-09-03 19:15:05 +02:00
kristianwindandClaude Opus 4.8 8a2c46f5ea fix(notes): show the note on a phone — one pane at a time
On a phone the note list and the editor stacked in one column, so a tapped
note opened below a full-height list, out of sight — "kan vælge en note men
kan ikke se indholdet". Now it is master-detail: the list fills the screen
until a note is opened, the editor takes over once one is, and an iOS-style
"‹ Noter" at the top of the editor goes back. The cards drop their border
and corners on one column, where there is nothing beside them.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 19:07:02 +02:00
Kristian Wind 8d42f34065 Merge pull request #50 from kristianwind/charcoal-apple-colors
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m47s
CI / Docker image builds (push) Successful in 1m48s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m30s
Match Charcoal theme to Apple Notes' exact colours
2026-09-03 17:51:16 +02:00
kristianwindandClaude Opus 4.8 905ceebc06 fix(theme): match Charcoal to Apple Notes' sampled colours
Kristian sent the exact values; the first Charcoal was a neutral grey guess
and missed them. Now: three panel greys, lightest sidebar (#2d3133) to
darkest editor (#1e1e1e) with the list (#242b2e) between — the reverse of
the usual sunken-is-darkest order, so the editor takes its own --editor-bg
rather than sharing the list's surface. The accent is amber: the selected
menu item is solid #ffce35 with dark text, a selected note is gold #a78c26
with dark text, and the section headings drop to a quiet grey (#8a8e90).

Each per-element colour is a fallback token only Charcoal sets, so every
other theme reads through to the shared token unchanged. Verified the
computed values land on Kristian's samples exactly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 17:42:55 +02:00
Kristian Wind 87c557941a Merge pull request #49 from kristianwind/theme-charcoal-polish
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m47s
CI / Docker image builds (push) Successful in 1m47s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m25s
Charcoal theme, elegant selection, rounded note list, confirm sign-out
2026-09-03 16:37:44 +02:00
Kristian Wind 0e1e803486 Merge pull request #48 from kristianwind/totp-qr
2FA: show a scannable QR code when enabling two-factor auth
2026-09-03 16:37:36 +02:00
kristianwindandClaude Opus 4.8 1dc9751a33 test(e2e): sign out with the two-step confirm
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 16:29:18 +02:00
kristianwindandClaude Opus 4.8 b81f8f67cc feat(ui): Charcoal theme, elegant selection, rounded note list, confirm sign-out
- A new Charcoal theme — neutral dark greys on the menu, note list and
  editor instead of the default's faint green cast, with matching inks;
  the green accent stays. It joins the theme picker on its own (the picker
  is data-driven from THEMES).
- The text selection is a translucent wash of the accent, not the old
  near-invisible tint and not a solid slab — visible on white and on the
  dark surfaces, keeping the text's own colour.
- The note list is a rounded card now, the same corner and border as the
  editor beside it, with a few pixels more air around the menu and the list.
- Signing out asks first: a second click within a few seconds confirms, so
  a button the pointer passes over does not drop you at the login screen.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 16:27:55 +02:00
kristianwindandClaude Opus 4.8 f62ffc8d92 feat(2fa): show a scannable QR code when enabling two-factor auth
The setup screen showed the secret and the otpauth URI as text and asked
you to scan — with nothing to scan. It renders the URI as a QR now, an SVG
(one dark path on a white card, so it reads in either theme) generated from
the barcode encoder already in the module graph via pquerna/otp. The secret
stays below it for a device that cannot scan.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 16:27:11 +02:00
Kristian Wind 73ee93deab Merge pull request #47 from kristianwind/offline-blip-resilience
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m0s
CI / Docker image builds (push) Successful in 1m56s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m34s
Offline: ride out short connection drops instead of losing the save
2026-09-03 15:35:03 +02:00
kristianwindandClaude Opus 4.8 b2fcc0b104 feat(offline): ride out short connection drops instead of losing the save
A save made while the connection blips for a few seconds is retried rather
than reverted: the request layer keeps trying a network failure for a
fifteen-second window, waking the instant the OS reports a link again, and
only falls back to the existing error path if the drop outlasts it. A quiet
"reconnecting" pill appears if the wait passes about a second, and leaves
the moment it heals — so a two-second blip is felt as nothing at all.

This is deliberately not offline mode: the app is a live view of a shared
list, so there is no cache to work in with no server and no write log to
reconcile. It is only the blip, ridden out. A server error (4xx/5xx) is the
server answering and is not retried; an aborted request (a superseded
search) is left to propagate.

Kristian: an offline function for when the net drops for short periods,
seconds. Shared items stay server-authoritative — nothing is edited against
a stale copy for longer than the blip.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 15:26:46 +02:00
Kristian Wind 8dc601d09a Merge pull request #46 from kristianwind/app-icon-refresh
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m19s
CI / Docker image builds (push) Successful in 1m56s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m31s
Centre and strengthen the app icon
2026-09-03 15:01:15 +02:00
Kristian Wind 85e769a603 Merge pull request #45 from kristianwind/notes-three-line-rows
Notes: three-line rows with a filed line and dividers
2026-09-03 15:01:06 +02:00
kristianwindandClaude Opus 4.8 0363d5f422 feat(icon): centre and strengthen the app icon
The Wunjo rune sat left of centre and thin. It is heavier now (a 6.5
stroke), bigger, and nudged right of the geometric centre so the heavy
stave and the light flag balance about the middle of the tile — so it
reads clearly as a home-screen icon.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 14:53:21 +02:00
kristianwindandClaude Opus 4.8 05b7dac713 feat(notes): three-line rows like Apple Notes, with a filed line and dividers
Each note is three lines now: the title, then the date and the excerpt,
then where the note is filed — a folder like the menu's groups and the
project's name, the owner's when it is someone else's, or simply "Noter"
when it belongs to no project. The date leads the second line, left
aligned, instead of being pushed to the right of the title. A faint
hairline separates one note from the next, and a month heading is
capitalised ("August 2026", not "august 2026").

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 14:52:56 +02:00
Kristian Wind cfd382e3df Merge pull request #44 from kristianwind/settings-link-style
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m3s
CI / Docker image builds (push) Successful in 2m8s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m58s
Style the unstyled "open completed tasks" link in settings
2026-09-03 14:35:20 +02:00
Kristian Wind bbcff40401 Merge pull request #43 from kristianwind/notes-header-new-design
Notes list: headers in the new heading design, rounded boxes
2026-09-03 14:35:10 +02:00
kristianwindandClaude Opus 4.8 01001d0980 fix(settings): the "open completed tasks" link was unstyled
It carried class="ghost", which nothing in the data page styles — so it
fell back to the browser's blue underline while every link beside it is
the accent-coloured .link. Now it matches them.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 14:27:20 +02:00
kristianwindandClaude Opus 4.8 4ac7180cda feat(notes): drop the disclosure arrow from the note-list headers too
Same as the sidebar: the box folds on a click, the star and people icon
are the marks that mean something, and a date group keeps an empty spacer
so every label still starts at the same place.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 14:25:55 +02:00
kristianwindandClaude Opus 4.8 e1683ef09d feat(notes): match the note-list headers to the new heading design
The note list still wore the old faint-uppercase group header beside the
sidebar's bold plain-case one, so the two columns read as two apps. The
group headers are bold, plainly cased and in the reading ink now, at
menu size, and their separator boxes carry the same rounded corner as an
active menu item — the hard rule underneath is gone, the box is the
separation. The disclosure mark stays: unlike a sidebar section, a
time-group has no icon, so it is the only thing that says "this folds".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 14:22:19 +02:00
Kristian Wind f89fb21502 Merge pull request #42 from kristianwind/sidebar-drop-chevrons
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 8m44s
CI / Docker image builds (push) Successful in 1m49s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m26s
Drop the fold arrows from sidebar headers and groups
2026-09-03 13:39:35 +02:00
kristianwindandClaude Opus 4.8 ec147cd1ba feat(sidebar): drop the fold arrows from headers and groups
The chevron beside every section and group was saying "clickable" a
second time next to an icon and a bold name that already said it. Gone
now: a system section folds from anywhere on its heading, and a group
folds from its folder icon while the name still opens the group's page.
No arrow on the rail.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 13:34:33 +02:00
Kristian Wind 1f9f14099b Merge pull request #41 from kristianwind/calendar-lighter-pastels
CI / OpenAPI is valid (push) Successful in 53s
CI / End-to-end (push) Successful in 9m3s
CI / Docker image builds (push) Successful in 2m10s
Release / Build and publish image (push) Failing after 7s
CI / Go (push) Failing after 15m11s
Calendar: lighter grid, per-calendar pastels, today marked at top
2026-09-03 13:17:31 +02:00
kristianwindandClaude Opus 4.8 6cf63113df feat(calendar): lighter grid, per-calendar pastels, today marked at top
The calendar was a grey grid where every event was the same sunken block
edged in a colour you had to already know. Now the day itself is the
brightest surface and each event is a soft, dusty wash of its own
calendar's colour — so a screen of them reads as colour laid on white,
and two calendars are told apart at a glance. Tasks stay outlined cards,
the thing you can pick up, against the solid washes you cannot.

Today is marked where the day is named: its number sits in a filled disc
at the top of the column, the same one the month grid gives it, over a
faint wash down the whole column.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 13:09:07 +02:00
Kristian Wind e24a0fa849 Merge pull request #40 from kristianwind/sidebar-header-unify
Unify section and group headers with icons; scrollbar flush to the divider
2026-09-03 13:02:15 +02:00
kristianwindandClaude Opus 4.8 1c223a2c26 fix(sidebar): scrollbar flush to the divider, and match the group test
The scrollbar thumb centred a 4px bar in a 10px track behind a
transparent border, so it floated three pixels shy of the right edge.
The thumb is the track now — a thin 6px rounded bar sitting flush
against the divider line, where a scrollbar belongs.

The group heading is a heading now, not a project row: the smoke test
measured a group against the projects filed under it and read the
section icon as a dot. It measures a group against "Projekter" — the
tier it shares — and reads the icon both headings carry.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 12:54:05 +02:00
kristianwindandClaude Opus 4.8 a9c90ee6f1 feat(ui): unify section and group headers with icons
System sections (Projects, Filters, Labels) and user groups (Work,
Home) now share one design: a bold label at menu size, a leading
folder/funnel/tag/people icon, and an always-visible fold chevron.
Groups drop their colour dot for the same folder icon, so the sidebar
reads as one consistent hierarchy instead of two visual languages.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 12:34:07 +02:00
Kristian Wind dc4a584d5f Merge pull request #39 from kristianwind/sidebar-scrollbar-flush
fix(sidebar): scrollbar flush to the edge, thin and dim
2026-09-03 12:22:58 +02:00
kristianwindandClaude Opus 4.8 378980de0c fix(sidebar): the scrollbar sits at the edge, thin and dim
It sat a pad-width inside the right edge because the scroller lives inside the
sidebar's padding, so it read as floating in the middle. Pull the scroller out
to the border and pad the content back — the rows do not move — and make the
bar thin and dim so it is present without drawing the eye.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 12:14:39 +02:00
Kristian Wind 04c7a52a5a Merge pull request #38 from kristianwind/sidebar-color-polish
Release / Build and publish image (push) Failing after 5s
feat(ui): colour fixed-view icons, lift notes surfaces, pin the create header
2026-09-03 12:08:32 +02:00
kristianwindandClaude Opus 4.8 52df528994 test(e2e): retry the project→group drop until it nests
A native HTML5 drag occasionally does not register under CI load, leaving
the group empty and the group-page assertion failing for a reason unrelated
to the test. Retry the drop until the project actually sits under the group.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 12:00:02 +02:00
kristianwindandClaude Opus 4.8 2f00246d6f feat(ui): colour the fixed-view icons, lift the notes surfaces, fix the create header
Following the Things pass, four bits of polish:

- The fixed views carry their own colour — amber star for Today, blue tray
  for the Inbox, red Upcoming, violet Waiting on others, teal Notes, indigo
  Calendar — and keep it whether or not they are the active row.
- The active row was all but invisible on the dark sidebar (a half-step of
  surface); it is now an accent-tinted selection that reads in both themes.
- The Projekter heading — the only place to add a project or a group — no
  longer scrolls away with a long list; it is pinned to the top of the
  scroller.
- The note list and the editor lift off the page: white in light, a nuance
  lighter than the ground in dark, so the two read as one writing surface.

Screenshots regenerated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 11:47:17 +02:00
Kristian Wind acec77a20c Merge pull request #37 from kristianwind/sidebar-things-nav
Release / Build and publish image (push) Failing after 6s
feat(sidebar): icons for fixed views, bold areas, one straight edge
2026-09-03 10:17:56 +02:00
kristianwindandClaude Opus 4.8 59413a8b64 test(e2e): a grouped project now shares the left margin, not indented
The sidebar redesign puts every project at the same left edge; flip the
assertion from "indented more than a fixed view" to "in line with it".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 10:09:32 +02:00
kristianwindandClaude Opus 4.8 cf69cac410 feat(sidebar): icons for the fixed views, bold areas, one straight edge
Toward Things' calm: the fixed views carry icons instead of dots — a star
for Today, a calendar for Upcoming, a person for Waiting on others, a tray
for the Inbox. Areas (the user's groups) are set in bold, plainly cased, so
the eye lands on them the way it lands on an area in Things; the system
sections (Projekter, Filtre, Etiketter) stay quiet. And projects sit at the
same left margin whether or not they are in an area — one straight edge down
the sidebar rather than a staircase, with the bold heading doing the
grouping the indent used to.

Screenshots regenerated to match.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-03 10:02:53 +02:00
Kristian Wind b0998fe524 Merge pull request #36 from kristianwind/settings-font-sizes
Release / Build and publish image (push) Failing after 11s
feat(settings): menu and body-text font size options
2026-09-02 15:39:06 +02:00
kristianwindandClaude Opus 4.8 8dfab618f7 feat(settings): menu and body-text font size, and a bolder menu by default
The sidebar read as muted 13px; the menu items now use their own size at
medium weight so they carry at a glance (Todoist-ish). Both sizes are
choices under Settings → Appearance:

- Menu font size drives a dedicated --menu-size the sidebar uses.
- Body text size re-scales the shared type tokens (the list and the notes),
  the same mechanism a look uses.

The two are independent on purpose — one is a glance down a list of names,
the other a paragraph you read. Kept in localStorage like theme and look,
and applied in app.html before first paint so a chosen size does not flash
in and resize. Four steps each (Small/Default/Large/Largest); Default
carries no attribute so the base tokens stand.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-02 15:30:30 +02:00
Kristian Wind c0c1281415 Merge pull request #35 from kristianwind/capitalise-user-names
Release / Build and publish image (push) Failing after 5s
feat(users): always capitalise a display name's first letter
2026-08-30 09:32:33 +02:00
kristianwindandClaude Opus 4.8 247277e139 feat(users): always capitalise a display name's first letter
Andreas signed up as "andreas" and was shown that way everywhere. A name
is now capitalised on the way in — the first letter of each word raised,
the rest left as typed — in CreateUser (signup and invite-accept) and
UpdateProfile, so display, mail and export all agree.

Existing accounts are fixed by an idempotent startup pass
(NormalizeUserNames), the same shape as SealNotes: the rule is Unicode-aware
and per word, which SQLite's UPPER cannot spell, so it lives in Go rather
than a migration.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 09:25:50 +02:00
Kristian Wind f3b037c7f4 Merge pull request #34 from kristianwind/task-linkify-urls
Release / Build and publish image (push) Failing after 8s
feat(tasks): make a URL in a task clickable (title and description)
2026-08-27 16:40:25 +02:00
kristianwindandClaude Opus 4.8 4103e1b0ca fix(tasks): open the task by clicking its text, links carved out
The first cut put an open-button overlay over the row, which intercepted
every click on the title text — nine smoke tests (and real users) open a
task by clicking its text, and the overlay stole those clicks. Make the
body a role=button instead, with each link stopping its own click from
bubbling: plain text opens the task, a link opens the page.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 16:33:30 +02:00
kristianwindandClaude Opus 4.8 9d4a13c2c2 feat(tasks): make a URL in a task's title or description clickable
A task is plain text — the title is an input, the description a textarea —
so a pasted URL just sat there as words. It is now caught at display time
and shown as a link in the list and on the board, without the stored text
ever being anything but plain text.

The link cannot live inside the row's open-button (an <a> in a <button> is
invalid), so the body is a container with the open action as an overlay
behind it: plain text and the meta row still open the task, links are
raised above and take their own click. Conservative about what counts —
an explicit http(s):// or a bare www. host, not every dotted word.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 16:17:56 +02:00
Kristian Wind 923f911472 Merge pull request #33 from kristianwind/settings-buy-me-a-coffee
Release / Build and publish image (push) Failing after 6s
feat(settings): a quiet Buy me a coffee link
2026-08-27 14:55:59 +02:00
kristianwindandClaude Opus 4.8 272c4918b0 feat(settings): a quiet Buy me a coffee link
A donate link belongs in Settings and nowhere in the way — so it sits last,
understated, below the device list. A plain anchor with an inline SVG cup:
no third-party script, no webfont, no tracking, so it works offline and
cannot phone home. Wording says the app is MIT with nothing held back and
that nothing changes if you don't pay.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 14:49:24 +02:00
Kristian Wind ff866cc514 Merge pull request #32 from kristianwind/drop-todoist-pro-claim
docs(site): drop the Todoist-Pro claim, lead with the Rune
2026-08-27 14:12:21 +02:00
kristianwindandClaude Opus 4.8 e94d731c30 docs(site): lead with the Rune, and drop the Todoist comparison from filters
Yggdrasil is self-hosting too, so labelling only the Docker page
"Self-hosting" was misleading and put it above the Rune it was built for.
Reorder "As a Rune" above the Docker page, rename that page "With Docker",
and make the landing hero's primary call to action the Rune, with Docker
beside it.

Also drop the "the grammar is Todoist's" line from the filters page — the
factual import/export interoperability keeps naming Todoist, but the
positioning and soft comparisons do not.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 14:05:18 +02:00
kristianwindandClaude Opus 4.8 0aa0ec2989 docs: stop describing verdande as "Todoist Pro"
Claiming to be Todoist Pro is both inaccurate and an invitation to a
trademark complaint. Describe verdande on its own terms — a fast,
self-hosted task manager — and keep the Todoist references to what they
factually are: import/export interop, and a filter grammar close enough
that people coming from there already know it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 13:59:54 +02:00
Kristian Wind 21b60b007a Merge pull request #31 from kristianwind/site-refresh-delegate-snooze
docs(site): new features, refreshed screenshots and text
2026-08-27 13:08:34 +02:00
kristianwindandClaude Opus 4.8 7066e2e06e docs(site): new features, refreshed screenshots and text
The landing page and the docs now show and describe what has landed since
the last site update:

- the delegated-task marker (an arrow to whoever has it) — visible now in
  the board, list and Waiting-on-others shots
- snooze — one seeded task is parked so the board shows a card greyed at
  the foot of its column
- Web Push reminders to iPhone and Mac, dated tasks in a subscribed
  calendar, task durations on the calendar, and per-person note sharing

Screenshots regenerated against the current UI (PNG for docs/README, WebP
for the landing page, from the one capture). "Lately" brought current.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 13:00:05 +02:00
Kristian Wind 2b76a172e8 Merge pull request #30 from kristianwind/add-delegated-marker
Release / Build and publish image (push) Failing after 10s
feat(tasks): mark a delegated task with a hand-off arrow
2026-08-27 12:43:13 +02:00
kristianwindandClaude Opus 4.8 15495255ef test(e2e): the delegated chip's title is now "Uddelegeret til"
The assignee chip reads as a delegation now, so its title changed from
"Ansvarlig: {name}" to "Uddelegeret til {name}". Point the role-edit
smoke test at the new title.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 12:36:48 +02:00
Kristian Wind 3b9b68f4b1 Merge pull request #29 from kristianwind/fix-mobile-priority-overflow
fix(mobile): stop the task drawer's priority/due row overflowing
2026-08-27 12:35:11 +02:00
kristianwindandClaude Opus 4.8 7dcd475036 feat(tasks): mark a delegated task with a hand-off arrow
A task assigned to somebody who is not you is out of your hands and into
theirs, but the row only showed their name — the same weight as any
other meta. The assignee is now a bordered chip led by an outgoing arrow
in the accent colour, so a list reads at a glance as which tasks are
yours to do and which you are waiting on. Covers the board too: its
cards render a TaskRow.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 12:27:52 +02:00
kristianwindandClaude Opus 4.8 fc4877c3bd fix(mobile): stop the task drawer's priority/due row overflowing
On a phone the detail drawer's field grid stayed two columns, so each
field got half the viewport. Prioritet collapsed to "Ing" and the due
row — a date input plus fixed-width time and duration fields — ran off
the right edge. Below 560px the grid now stacks to one column per field
and the due row wraps.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 12:24:29 +02:00
Kristian Wind 2da679d629 Merge pull request #28 from kristianwind/task-snooze
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 8m11s
CI / Docker image builds (push) Successful in 1m35s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m17s
Snooze: park a task at the bottom until a time
2026-08-25 11:07:39 +02:00
kristianwindandClaude Opus 5 9e3f20b050 feat(tasks): snooze — park a task at the bottom until a time
Snooze puts a task out of the way without finishing it or filing it: it sinks to
the bottom of its list and greys, and comes back on its own when its time passes.
No job wakes it — "snoozed" is just snoozed_until being still in the future, read
against the clock each time the list is ordered.

- `snoozed_until` on tasks (migration 0031); the list order sinks a still-snoozed
  task below everything, and it rejoins its place the moment the time passes.
- POST /tasks/{id}/snooze {until} — an RFC3339 time to snooze to, or empty to wake.
  Nothing else about the task moves; a snooze is about when you want to see it, not
  when it is due.
- The task drawer gains quick picks (this evening / tomorrow / next week) and a
  custom time, and a Wake now once it is snoozed. The row dims and carries a
  "snoozed until X" mark, and the list keeps snoozed tasks at the foot — the client
  sorts the same way the server does, so a drag-sorted list does not jump on reload.

Tests: snooze sinks a task below one made after it and waking returns it (Go); and
from the drawer the row greys, shows the mark, and un-greys on wake (e2e). Full
suites green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 10:59:34 +02:00
Kristian Wind fa816b2e41 Merge pull request #27 from kristianwind/ics-alldays-for-dated-tasks
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m52s
CI / Docker image builds (push) Successful in 1m19s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 13m49s
Fix: date-only tasks now appear in a subscribed calendar (ICS)
2026-08-25 08:48:47 +02:00
kristianwindandClaude Opus 5 60d66b728e fix(ics): a date-only task now shows in a subscribed calendar
The calendar feed emitted every task as a VTODO and, on top of that, an event only
for tasks that carried a clock time. But a subscribed calendar in Apple Calendar or
Google ignores VTODO entirely and draws only events — so a task with just a due
date, which is most of them, appeared in the verdande app's own calendar and
nowhere in the calendar it had been subscribed into. "Verdande does not update in my
calendar, only in the verdande app."

Every dated, unfinished task is an event now: a clock time makes a timed event as
before, a bare date an all-day one (a DATE start and an exclusive next-day end, so
it stays on its own day). Finished tasks stay off the calendar, as they were.

Test updated: the sample's timed task and its two date-only tasks all become events
now, the all-day ones in the DATE form, and the finished one still makes none.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 08:42:25 +02:00
Kristian Wind bae3b3505f Merge pull request #26 from kristianwind/fix-apple-web-push-subject
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m12s
CI / Docker image builds (push) Successful in 1m18s
CI / Go (push) Failing after 14m7s
Fix: Apple rejected all push notifications (VAPID subject)
2026-08-25 08:30:45 +02:00
kristianwindandClaude Opus 5 7b1e7ba7c9 fix(push): Apple rejected the notifications; use the site's own URL as VAPID subject
Every Web Push to an iPhone or a Mac was refused, silently as far as the person
waiting for the reminder could tell. The cause was the VAPID "sub" claim: on an
instance with no mail configured it fell back to mailto:[email protected],
and Apple answers 403 Forbidden to a mailto whose domain is made up. Verified
against web.push.apple.com on the live subscriptions — that mailto gets a 403, the
instance's https URL a 201.

So the subject is now a real mailto when SMTP.From is a real address, and otherwise
the instance's own https base URL — which is a valid VAPID subject, points at
something that exists, and every push service including Apple accepts.

Two things found alongside it, both fixed:

- A successful push did not clear the failure count. A subscription drops at ten
  failures, and all four live ones sat at nine from this bug — one flaky push from
  being deleted despite working. MarkPushDelivered zeroes the count and moves
  last_used_at on every delivery, so a subscription that recovers is not still
  carrying its old failures toward the cliff.

- A reminder with no project made a notification whose URL was "/projekt/" — a
  dead link. Empty project now points at the app root.

Test: the subject is a real mailto when there is one, and the https URL when there
is not — including for the localhost default, which is not a real address.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 08:24:20 +02:00
Kristian Wind c0dbea10c5 Merge pull request #25 from kristianwind/note-footer-archive
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m8s
CI / Docker image builds (push) Successful in 1m49s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m6s
Add an Archive button to the open note's footer
2026-08-24 15:45:14 +02:00
kristianwindandClaude Opus 5 a70ca93102 feat(notes): an Archive button in the open note's footer
Archiving a single note meant finding its row in the list and its hover-only
button. With a note open to read or write, the one place you would reach for it —
its own footer, beside Gem and Slet — had no way to put it away.

There is one now: Arkivér between Save and Delete, which reads "bring it back" once
the note is already archived. Owner only, like delete, and it runs the same archive
the row button does — the note leaves the list without leaving the account.

e2e: archiving from the footer takes the note off the list, the archive holds it
with a bring-back button, and bringing it back returns it. The existing bulk-archive
test is scoped to the selection bar, now that "Arkivér" also lives in the footer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 15:37:17 +02:00
Kristian Wind 2bda3a26db Merge pull request #24 from kristianwind/inline-note-links
CI / Go (push) Failing after 13m26s
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 6m45s
CI / Docker image builds (push) Successful in 1m38s
Release / Build and publish image (push) Failing after 7s
Make [[note]] an inline clickable link, not bracketed text
2026-08-24 13:48:59 +02:00
kristianwindandClaude Opus 5 8022502015 feat(notes): [[note]] is an inline link, not bracketed text
Choosing a note from the [[ completion wrote [[Title]] as plain text. It made a
real link behind the scenes — it showed in the panel under the note — but in the
note itself it stayed brackets, and "it doesn't become a link" is exactly how that
reads. A wiki link is a link where it is written.

Now a [[note]] renders inline as the title, in the accent colour, and a click on it
opens the note it points at. It is atomic — contenteditable="false" — so it moves
and deletes as one thing and a click is a click, not a caret landing mid-word. And
it appears the moment it is chosen: the completion drops the finished link in,
brackets and all swallowed, rather than text waiting to become a link when the note
is next opened.

The seam holds both ways: markdownToHtml lifts [[title]] into the link the same way
it already lifts an image, and htmlToMarkdown reads the link back to [[title]] by
the title it carries — so what is on disk is still the same Markdown, and a note
opened, saved and reopened is unchanged.

e2e: choosing a note writes an inline link, it survives the round-trip through
Markdown and a reload, and clicking it opens the note it names. Full suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 13:41:04 +02:00
Kristian Wind b07938e351 Merge pull request #23 from kristianwind/clickable-note-links
CI / Go (push) Failing after 14m28s
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m18s
CI / Docker image builds (push) Successful in 1m29s
Release / Build and publish image (push) Failing after 6s
Fix: a [[note]] link is one you can actually follow
2026-08-24 13:19:40 +02:00
kristianwindandClaude Opus 5 de95df44c1 fix(notes): a [[note]] link is one you can follow
Picking a note from the [[ completion wrote [[Title]] and the server made a real
link from it — but the link only ever showed in the panel under the note as plain
text. It said the note pointed somewhere and gave no way to go there, which reads
as "it didn't become a link at all".

The links in that panel are buttons now. A [[note]] resolves by its title to the
note — the one already on the list, or looked up if not — and opens it in place; a
#project goes to its page; a task link to the task. Titles with punctuation work,
brackets and parentheses included, because the match is on the whole title, not a
parse of it.

e2e: after linking a note, clicking its chip opens the note it names.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 13:11:46 +02:00
Kristian Wind 2d7b6a1d5e Merge pull request #22 from kristianwind/fix-notes-export-order
CI / OpenAPI is valid (push) Successful in 10s
CI / Go (push) Failing after 13m11s
CI / Docker image builds (push) Successful in 1m40s
Release / Build and publish image (push) Failing after 6s
CI / End-to-end (push) Successful in 7m10s
Fix: export notes in a stable order (flaky export test)
2026-08-24 12:44:36 +02:00
kristianwindandClaude Opus 5 4bbc49fe7e fix(notes): export the notes in a stable order
AllNotes returns newest-first by the second, so two notes written in the same
second have no order between them — and the notes export walks that list assigning
filenames, giving the first note of a name the plain filename and the next the
"(2)". With the order undecided, which of two same-titled notes got which name was
a coin toss that landed differently on each export, and the export test failed
roughly one run in three.

Sort the export by when each note was made, with the id to break a same-second
tie. The first note of a name keeps the name; the archive is the same twice
running. Ids are time-ordered, so the tie-break agrees with creation order rather
than fighting it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 12:39:11 +02:00
Kristian Wind db2bf619ce Merge pull request #21 from kristianwind/note-link-autocomplete
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m42s
CI / Docker image builds (push) Successful in 1m41s
CI / Go (push) Failing after 13m24s
Fix: [[ completes a note link, like # completes a project
2026-08-24 12:30:04 +02:00
kristianwindandClaude Opus 5 6116ed2e21 fix(notes): [[ completes a note link, the way # completes a project
Typing [[ did nothing. The toolbar hint offered "[[ another note" and the parser
turned a fully-typed [[Title]] into a link on save, but the completion that makes
it usable — the one # has for projects — was never there, so you had to know and
type a note's title exactly, brackets and all.

Now [[ opens the same picker # does, over the notes the page has loaded: the
current note is left out (a note linking to itself is a loop nobody meant), and
one without a title yet cannot be pointed at. Choosing a note closes its own
brackets — the opener is already in the text — and leaves a space, so the next
word is a word and not more of the tag. On save the link is real and shows in the
note's links, not merely as text.

The picker's two kinds are told apart where a project wears its colour: a note
suggestion wears the brackets that open it.

e2e: [[ offers a note by title, choosing it writes [[Title]] into the note, and
the link lands in the note's links. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 12:22:38 +02:00
Kristian Wind 1ba26ee723 Merge pull request #20 from kristianwind/site-features-light-shots
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m50s
CI / Docker image builds (push) Successful in 1m44s
CI / Go (push) Failing after 14m5s
Release / Build and publish image (push) Failing after 6s
Website: new features and light-theme screenshots
2026-08-24 10:37:07 +02:00
kristianwindandClaude Opus 5 fcedab0b19 docs(site): add the new features and light screenshots of them
The landing page did not mention two things the app grew — sharing a note with a
person, and task durations — and its screenshots were all in the dark theme.

- Copy: a cell for sharing a note or a page (to a person, or by filing it in a
  project, with the Shared with me group), and one for how long a task takes (drawn
  to length on the calendar, dragged by its foot, kept when moved).

- Two new screenshots in the light theme, at the top of the gallery: the calendar
  drawing timed tasks to their real length, and a note somebody shared gathered
  under Shared with me. English throughout, like the rest of the site — the capture
  account is English, so the interface is.

The generator grew the demo data these need: a handful of timed tasks with lengths
across the week, and a note the colleague shares back to the owner. Every other
screenshot was recaptured from the current build in the same pass, so the whole set
reflects what ships today (the tidied note footer, and the rest).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 10:30:00 +02:00
Kristian Wind 72b6ea3803 Merge pull request #19 from kristianwind/task-durations-calendar
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 7m58s
CI / Docker image builds (push) Successful in 1m40s
CI / Go (push) Failing after 13m55s
Task durations: draw, resize, and edit a task's length
2026-08-24 10:22:37 +02:00
kristianwindandClaude Opus 5 4f25905d81 feat(calendar): a task's length is drawn, resizable, and editable
A timed task was drawn at a fixed half hour whatever its `duration_min` said, so a
length the data model already carried was invisible and uneditable. Three parts:

- The calendar draws a task at its real length — a quarter-hour floor so a
  zero-minute task is still grabbable, the day's end as the ceiling — instead of a
  flat thirty minutes. A ninety-minute task now looks like ninety minutes.

- A grip along the foot of a timed task drags its length. Pointer events, not the
  HTML5 drag the body uses, and stopPropagation between them — otherwise making a
  task longer would pick it up and move it instead. It snaps to the quarter hour,
  follows the handle live, and writes once on release through a new store `resize`.
  Moving a task (the existing day/time drag) leaves the length alone.

- The task drawer gains a length field beside the time — minutes, shown only with
  a time to hang it on, since a duration without a start is a length of nothing and
  the calendar has nowhere to draw it. So a length can be set off the calendar too.

e2e: dragging the foot of a task makes it longer both on screen and in what the
server stores, and the time does not move. Full Chromium suite green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 10:14:39 +02:00
Kristian Wind e3eeb3037c Merge pull request #18 from kristianwind/tidy-note-share-footer
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m51s
CI / Docker image builds (push) Successful in 1m37s
CI / Go (push) Failing after 14m7s
Tidy the note sharing UI: gather it behind a Del button
2026-08-24 10:05:08 +02:00
kristianwindandClaude Opus 5 97e6be3afa fix(notes): gather sharing behind a Del button so the footer stays tidy
The sharing controls were laid out across the note's footer — the project select,
the whole "Del med personer" panel and the Save/Delete buttons all on one row. It
crammed: "Læg i projekt" wrapped to two lines and the people panel ran into the
rest.

Now one Del button in the footer opens a popover that holds both ways a note is
shared — a project and named people — stacked in a small card. The footer is a
status on the left and Del / Gem / Slet on the right, and nothing wraps. The
button carries a count when the note is shared with someone, so "shared" is
legible without opening it.

The popover closes on a click outside, on Escape, and on switching to another
note — the last tracked by note id, so a save returning a fresh copy of the same
note does not slam it shut mid-use. The inner "add someone" button is now Tilføj,
so it does not read as a second Del.

e2e updated to open the popover before reaching the controls; full Chromium suite
green.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 09:58:02 +02:00
Kristian Wind d67b61d3c6 Merge pull request #17 from kristianwind/fix-mono-block-escape
CI / Go (push) Failing after 13m12s
CI / OpenAPI is valid (push) Successful in 9s
CI / Docker image builds (push) Successful in 1m52s
Release / Build and publish image (push) Failing after 6s
CI / End-to-end (push) Successful in 6m13s
Fix: let a monospace line be left — on return and through the menu
2026-08-24 09:34:38 +02:00
kristianwindandClaude Opus 5 f0d18bb800 fix(notes): let a monospace line be left — on return and through the menu
A <pre> is preformatted, so the browser answered Enter by inserting a <br> and
keeping the caret inside it. A monospace last line therefore trapped everything
typed after it in monospace, with no way back to body by typing — and the style
menu could not get out either: `formatBlock('p')` on a line inside a <pre> nests a
<p> *inside* the block (`<pre>…<p>body</p></pre>`), so the text stayed monospace
and the note was left malformed. Reported as "monospace keeps going and I can
neither delete it nor switch to body".

Two fixes, both in the editor:

- Plain Enter in a <pre> now steps out: the tail after the caret becomes a
  paragraph below the block, and that is where the caret lands. Shift+Enter is
  untouched, so a soft line break still writes multi-line code inside the block —
  Enter to leave, Shift+Enter to stay.

- The style menu converts anything touching a <pre> by hand, replacing the element
  rather than reformatting inside it, so a monospace line goes cleanly back to body
  (or to a heading) instead of nesting.

The split can leave an empty text node in the new paragraph, and a block with no
element and no text is not a caret host in Chromium — the next keystroke falls back
into the block above. So an empty paragraph is given a <br> to stand in, which was
the difference between the caret landing in the new line and the text reappearing
in the code.

e2e: a monospace line left on Enter is body after it, Shift+Enter stays monospace,
the menu returns it to body, and the whole thing survives the round-trip through
Markdown and a reload.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 09:27:44 +02:00
Kristian Wind 7f8d925299 Merge pull request #15 from kristianwind/share-notes-between-users
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 6m39s
CI / Docker image builds (push) Successful in 1m41s
CI / Go (push) Failing after 13m33s
Release / Build and publish image (push) Failing after 5s
Share notes with people, not only through a project
2026-08-24 08:33:06 +02:00
kristianwind cdab817141 Merge main: pick up the calendar e2e flake fix 2026-08-24 08:25:51 +02:00
Kristian Wind 2e76ced5fa Merge pull request #16 from kristianwind/fix-flaky-calendar-drag
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 7m45s
CI / Docker image builds (push) Successful in 1m47s
CI / Go (push) Failing after 14m5s
Fix flaky calendar e2e: count the test's own events, not the whole day
2026-08-24 08:25:41 +02:00
kristianwindandClaude Opus 5 711fb4838b test(e2e): the calendar test counted other tests' tasks as its own
The calendar test asserted `[data-date=day] .tevent` had a count of exactly one —
the mocked meeting — after dragging its task to the next day. That count assumed
the day held nothing but this test's own events. It does not: the suite shares one
database, and an earlier test drags a task across a month boundary, which on some
dates lands a timed task on this very day. A timed task renders as a `.tevent`
too, so the count was two, and the test failed — but only in the full run, and
only when the date arithmetic put the two tests on the same cell. Alone it passed,
which is exactly the shape of a shared-state collision rather than a real bug.

Named, not counted: assert the meeting is still there and the task is no longer in
the day's column, which is what "the events stayed, the task moved" actually
means — and is true however full another test left the day. Same for the all-day
band. No product code changed; the drag and everything it checks are untouched.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 08:19:17 +02:00
kristianwindandClaude Opus 5 643b30555e test(e2e): follow the renamed share label, and cover person-to-person sharing
The project-filing control was relabelled "Del i" → "Læg i projekt" and its empty
option "Kun mig" → "Intet projekt" (a note can now be shared with people while
under no project, so "kun mig" was no longer true). The two e2e tests that drove
that control by its old label are updated to the new one.

And a new test for the feature itself: an owner shares a note with a second
account from the Del med personer panel, the recipient's own list grows a Delt
med mig group with the note in it, a viewer's row carries no star or archive
control, and unsharing takes it back off their list. The recipient is navigated
on the origin the invite signed her in on — VERDANDE_BASE_URL is localhost while
the suite drives 127.0.0.1, and a session cookie set on one host is not sent to
the other.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 07:59:57 +02:00
kristianwindandClaude Opus 5 91671a4f97 feat(notes): share a note with a person, not only through a project
Until now a note was shared exactly one way: file it in a shared project, and
everyone who could read the project could read the note. That is right when the
note belongs with a body of work and wrong when it is just "let Sofie see this" —
it made you stand up a project to hand over a single page.

A note can now be shared with named people directly, at viewer or editor — the
same two roles a project membership uses, so there is one idea of a role in the
program and not two. The paths stack: a note can be loose, filed, shared with
people, or any mix, and access is the most any of them allow.

The store:
  - note_shares (migration 0030): note ↔ user ↔ role, one row per person, owner
    recorded. Visibility (AllNotes, and search via mayTouchNote) widens to include
    notes shared directly with the reader.
  - the effective role folds a direct share on top of the owner and project paths.

The rules, drawn where they protect the owner:
  - read = viewer, edit text = editor, on any path.
  - MOVE a note (change its project) is owner-only: an editor filing it into a
    project the owner is not in would take the owner's own note away from them.
  - DELETE is owner or project-editor — a direct share, however high its role,
    does not reach to throwing the note away.
  - SHARE and UNSHARE are the owner's alone, like a project's invitations.
  - the picker is everyone on the instance (a closed, invited set) minus who is
    already on the note; a made-up id and the owner themselves are refused.

The list:
  - a "Delt med mig" group at the top, collapsible, that appears only when it has
    something in it — the same rule Favoritter follows — so it costs nothing when
    you share nothing. A shared note lives there, not also in a time bucket.
  - every shared note carries a small chip in its owner's avatar colour, the
    project mark's shape said for a person.
  - no star or archive button on a note somebody else owns: those change the note
    itself, and it is not yours to put away.
  - the editor grows a "Del med personer" panel for the owner: who can see it, at
    what role, and an add row drawn from the instance's other people.

The old comment in the notes footer argued against a note having an access list
of its own. That was the right call until the case it did not cover — sharing
without a project — turned out to be the one actually asked for. Updated to match.

Person now carries json tags, so a note's owner serialises as id/name/avatar_color
like every other person in the API rather than as Go field names.

Tests: a shared note reaches the other person marked and owned; a viewer reads but
cannot write; an editor writes but cannot re-share or delete; unshare removes
access and is owner-only; the candidate list excludes who is already on and the
owner; and a made-up id and self are refused. OpenAPI covers the three new routes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 07:36:55 +02:00
Kristian Wind 15be5d2165 Merge pull request #14 from kristianwind/security-hardening-headers
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 8m30s
CI / Docker image builds (push) Successful in 1m27s
CI / Go (push) Failing after 14m29s
Security hardening: HSTS, forwarded-IP trust, single-use TOTP
2026-08-23 23:41:44 +02:00
kristianwindandClaude Opus 5 77994eff09 feat(security): a TOTP code can be used once
RFC 6238 says the verifier must reject a code it has already accepted: a code
stays valid for its whole thirty-second window, so one seen in flight — a
phishing page, a proxy, over the shoulder — could be replayed until it expired.

Tasks now record the counter of the last accepted step (totp_last_step), and a
login accepts a code only when its step is strictly greater. The guard is in the
UPDATE, not a read-then-write, so two logins racing with the same stolen code
cannot both find the old value and both proceed — exactly one moves the row.

auth.TOTPStep is VerifyTOTP that also reports which step matched, walking the
same one-step window; the code entered a little early or late spends the step it
was issued at, not the step it was checked at, so a late-but-valid code is still
single-use. Verified end to end: the same code presented to two logins in one
window is accepted once and refused the second time.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 23:34:34 +02:00
kristianwindandClaude Opus 5 b4038356f0 feat(security): trust a forwarded IP only from a proxy, and set COOP
The rate limiter and the audit log key on the caller's address, and that address
was read with chi's middleware.RealIP — which believes X-Forwarded-For from
anyone. So a caller could set it to a new value on every request and get a fresh
password-guess bucket each time, and could write any address they liked into the
log the panel's watchers read. The one control meant for when a password is
already gone was keyed on a number the attacker got to pick.

Now the header is believed only when the machine that *connected* is a trusted
proxy — by default anything on a private network, which is the shape of a reverse
proxy or tunnel sharing the host or the docker bridge. A connection straight off
the internet is trusted for nothing and is keyed on the address it actually came
from. VERDANDE_TRUSTED_PROXIES overrides the set (CIDRs, or "none" to trust no
proxy at all); VERDANDE_REAL_IP_HEADER names the header.

Verified in the current deployment before the change: behind the Cloudflare
Tunnel a spoofed X-Real-IP already got no fresh bucket, because cloudflared
overwrites it. This makes that safety a property of the code, not of the
deployment — expose the instance directly, or put a header-passing proxy in
front, and it would have been a live hole.

Also COOP: Cross-Origin-Opener-Policy: same-origin on every response, isolating
the browsing context. verdande scripts no other window, so there is nothing to
lose and a class of cross-window attacks to close.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 23:34:34 +02:00
kristianwindandClaude Opus 5 8a87cfeb44 feat(security): HSTS og en lukket Permissions-Policy på hvert svar
To headere, appen ikke satte, og som begge følger af, hvad den allerede er.

Sessionen bor i en __Host-cookie, der kun findes over HTTPS — hele modellen
forudsætter TLS. Uden HSTS holder den forudsætning ikke mod en netværksangriber:
den allerførste navigation kan strippes til http://, og cookien rækkes over i
klartekst, før Secure-flaget nogensinde kommer i spil. HSTS lukker vinduet, og
den er sat på nøjagtig samme betingelse som cookiens Secure-flag (secureCookies),
så en dev-instans på ren HTTP aldrig bliver bedt om et certifikat, den ikke har.
To år, underdomæner med; ingen preload — at blive hard-kodet ind i browsere er en
beslutning for domænets ejer, ikke for en selv-hostet app.

verdande beder aldrig om kamera, mikrofon eller placering, så den sikreste
Permissions-Policy er at slå dem alle fra: et indsat element kan ikke bede om det,
siden selv har afvist. Navngivet felt for felt frem for med et jokertegn, fordi
jokertegns-formen ikke forstås ens af alle browsere, og en regel, en browser
ignorerer, er ingen regel.

Prøve på begge: HSTS følger HTTPS og udebliver over HTTP, og Permissions-Policy
lukker for kameraet uanset skema.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 23:15:33 +02:00
Kristian Wind 3e52eea2da feat(kvasir): én mail bliver til én opgave, uanset hvem der læser den
CI / OpenAPI is valid (push) Successful in 15s
CI / End-to-end (push) Successful in 9m46s
CI / Docker image builds (push) Successful in 1m46s
CI / Go (push) Failing after 15m23s
Release / Build and publish image (push) Failing after 5s
Fundamentet under Kvasir, og det er værd at have uden ham.

Intet kunne svare på "er denne mail allerede blevet til en opgave?". To læsere
af samme postkasse — en anden assistent, en gentaget kørsel, et menneske —
laver derfor to opgaver. Det er ikke teoretisk: en briefing lavede "Betal
faktura fra Browns" to gange i samme kørsel.

Opgaver har nu en `source_key`, navngivet med et rum foran: `gmail:thread:…`,
`imap:<vært>:<mappe>:<uid>`. Et Gmail-tråd-id og et IMAP-uid ligner ikke
hinanden, lige indtil den dag de kolliderer, og et kolon koster ingenting. Det
betyder også, at den næste kilde ikke skal bruge en ny kolonne — pointen med en
nøgle frem for en fremmednøgle. Der findes ingen messages-tabel at pege på, og
det her skal virke uden.

Et delvist unikt indeks er selve låsen, ikke en optimering: to agenter, der
læser samme indbakke, SKAL være ude af stand til at lave to opgaver, og "skal
være ude af stand til" er en betingelse frem for en aftale, nogen husker.
Afgrænset til den, mailen kom til — to personer på samme instans, der begge
modtager tråden, får hver sin opgave, og det er rigtigt.

CreateTask svarer ErrDuplicate frem for at oprette nummer to, og begge
mail-veje springer over i stedet for at fejle. IMAP-vejen tæller den stadig som
læst — ellers rykker last_uid sig aldrig forbi den, og hver eneste kørsel fra da
af standser samme sted.

`TaskIDBySource` er spørgsmålet den anden vej, så en assistent, der ikke ejer
triagen, kan se, at mailen er håndteret. Slettede opgaver tæller med: en opgave,
der blev lavet af en mail og smidt væk, er også et svar.
2026-08-22 09:19:41 +02:00
Kristian Wind acd6784333 test(e2e): kalendertesten afhang af, hvilken ugedag den blev kørt på
CI / OpenAPI is valid (push) Successful in 11s
CI / Go (push) Failing after 13m58s
CI / Docker image builds (push) Successful in 2m43s
CI / End-to-end (push) Successful in 9m12s
Grøn i månedsvis, rød i morges — på en commit, der ikke var i nærheden af en
kalender. Det kostede en runde at tro, det var min egen ændring.

Testen lægger opgaven på i morgen og trækker den til dagen efter. Er i morgen en
søndag, er kilden sidste kolonne i sin række og målet første celle i den næste —
nede i den del af gitteret, et 720 px vindue skærer af. Et syntetisk træk slipper
i målets midtpunkt uden den auto-scroll, en rigtig markør får af browseren, så et
mål under folden bliver aldrig ramt. I går lå begge celler i samme række.

To ting rettet, og den anden er den vigtigste:

- Vinduet er højt nok til alle seks rækker under månedsdelen.
- Målet tages fra gitteret frem for at blive regnet ud af datoen. Dagen efter i
  morgen ligger ikke altid i den måned, der vises — i enden af en kort måned
  falder den udenfor — mens cellen ved siden af altid gør. Med et fald tilbage
  til cellen før, for den ene dag om måneden hvor der ikke er en efter.

Appen fejler ikke noget: en rigtig bruger ruller.
2026-08-22 08:48:31 +02:00
Kristian Wind 3e8d609316 docs: imaget hentes fra GHCR, og prøven for 'offentlig' var forkert
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 8m44s
CI / Docker image builds (push) Successful in 1m28s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 14m22s
Adressen rettet fem steder tilbage til ghcr.io. Og rune-siden lærte folk at
teste synlighed med `docker --config /tmp/empty pull`, hvilket er den fejl,
der tog instansen ned: en tom config stopper ikke maskinens credential-helper,
så pullet autentificeres uden at se sådan ud og melder succes for et image,
ingen fremmed kan hente. Prøven er nu registrets token-endepunkt med curl.
2026-08-22 00:29:57 +02:00
Kristian Wind fcd2443007 fix(rune): tilbage til GHCR — Gitea kan ikke servere imaget uden login
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Failing after 8m20s
CI / Docker image builds (push) Successful in 1m31s
CI / Go (push) Failing after 14m24s
v5 flyttede imaget til gitea.nolimit.dk og tog instansen ned. Panelets
PullImage sætter aldrig RegistryAuth, så registret skal kunne læses uden
legitimation, og det kan Gitea her ikke: token-endepunktet svarer 401 for
kw/verdande, også efter at repoet blev gjort offentligt. Instansen kræver login
på alt, registret inklusive.

Jeg påstod det modsatte, og beviste det med en test, der ikke kunne bevise
noget: `docker --config <tom-mappe> pull`. En tom config stopper ikke maskinens
credential-helper, så pullet blev autentificeret uden at se sådan ud. Den
rigtige prøve er registrets token-endepunkt med curl — GHCR svarer 200 med et
token, Gitea svarer 401. Begge dele målt.

Fejlen er værd at kunne genkende: panelet river containeren ned for at anvende
runen, pullet fejler, pullImageRetry kasserer fejlen, og ContainerCreate slår
derefter op lokalt og siger `No such image`. Serveren står som "running" uden
container_id, udadtil en 502, og intet sted nævnes det manglende login.

Imaget ligger nu begge steder — samme digest, sha256:f7b23efd… — så koden bliver
i Gitea, mens containeren hentes fra GHCR, indtil Gitea kan servere anonymt.
2026-08-22 00:28:06 +02:00
Kristian Wind 27a7b04ff3 test: kassens rigtige tailnet-adresse ud af testen
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 9m34s
CI / Docker image builds (push) Successful in 1m33s
CI / Go (push) Failing after 14m37s
Jeg skrev instansens faktiske Tailscale-adresse ind som prøvedata. Den er
ubrugelig uden adgang til tailnettet, men den er stadig et kort over én persons
opsætning — og af præcis den grund er arbejdspapiret om flytningen gitignoreret.
Så skal en test ikke bære adressen i stedet. En neutral adresse fra samme
område prøver det samme.
2026-08-22 00:05:27 +02:00
Kristian Wind b6cbc6cbc2 fix(ai): din egen model kunne ikke sættes op — tre fejl i én oplevelse
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 9m31s
CI / Docker image builds (push) Successful in 1m56s
CI / Go (push) Failing after 14m40s
Rapporteret som "AI gemmer ikke mine oplysninger". Anden gang det symptom
meldes, og en helt anden årsag end sidst — dengang delte scopes i user_settings
én række og slettede hinanden.

Udbyderen hedder "OpenAI-kompatibel (Ollama, LM Studio, …)" og feltet foreslår
selv http://localhost:11434/v1. safedial nægter præcis den slags adresser. Så
den ene udbyder, der findes for at køre sin egen model, kunne ikke sættes op,
og formularen foreslog en adresse, serveren ville afvise.

Serveren svarede 422 med en fejl på base_url. Formularen havde ingen plads til
at vise en fejl på det felt, og fordi fejlobjektet ikke var tomt, blev der
heller ikke vist en toast. Beskeden forsvandt, og det så ud, som om der ikke
skete noget ved at trykke Gem.

Tre rettelser:

- Private adresser tillades for admins. Muren står, fordi en server, der henter
  en brugerangivet adresse, lader den, der angiver den, nå ting han ellers ikke
  kan. En admin på en selvhostet instans ejer allerede værten og dens netværk,
  så det giver ham intet nyt. For en inviteret bruger er den lukket som før.
  Tilladelsen regnes ud pr. forespørgsel, ikke gemt ved siden af adressen — den,
  der ikke længere er admin, skal ikke kunne nå indenfor på et gammelt flag.

- Tailnet er ikke "indenfor". Halvdelen virkede allerede ved et tilfælde: Gos
  IsPrivate dækker RFC1918 og ikke det CGNAT-område, Tailscale låner, så 100.x
  slap igennem uden at nogen havde besluttet det. IPv6-halvdelen gjorde ikke —
  fd7a:115c:a1e0::/48 ligger inde i fc00::/7. Samme maskine var altså
  tilgængelig eller ej alt efter adressefamilie. Begge områder er nu navngivet,
  hvilket gør IPv4-halvdelen bevidst frem for heldig.

- Feltfejl kan ikke længere forsvinde. base_url og model har fået et sted at
  vise en, og en fejl på et felt, der ikke har et, råbes op som en toast frem
  for at blive tabt.
2026-08-22 00:03:45 +02:00
Kristian Wind 223a1df85e ci: checkout prøver begge auth-former og siger hvilken der virkede
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Failing after 9m52s
CI / Docker image builds (push) Successful in 1m32s
CI / Go (push) Failing after 14m53s
Serverens side er bekræftet — Gitea tillader fetch ved hash, målt direkte mod
instansen. Tilbage stod kun, om act_runners auto-token duer som kodeord i
basic auth, og hvilket brugernavn den vil have. Det kan kun en kørende runner
svare på.

Feedback-sløjfen går gennem en kø og et andet menneske, så en kørsel, der kun
siger 'checkout fejlede', er for dyr. Trinnet prøver nu x-access-token først —
den form actions/checkout bruger, og som økosystemet beviser virker mod Gitea —
derefter $GITHUB_ACTOR, og skriver hvilken der blev godkendt. Én kørsel
besvarer spørgsmålet, uanset om den ender grøn eller rød.
2026-08-21 23:51:06 +02:00
Kristian Wind c85bdd5076 ci: CI kan udløses i hånden
CI / OpenAPI is valid (push) Successful in 14s
CI / End-to-end (push) Failing after 9m43s
CI / Docker image builds (push) Successful in 1m41s
CI / Go (push) Failing after 14m53s
Køen på Gitea kan kun ryddes ved at slå Actions fra og til igen, og det
dropper alt, også den kørsel man ville beholde. Uden en dispatch-udløser er
den eneste vej tilbage at skubbe en tom commit — en løgn om, at der skete
noget. Nu kan en kørsel startes mod uændret kode.
2026-08-21 23:32:45 +02:00
Kristian Wind 28825dc7cb chore: arbejdspapiret om Gitea-flytningen bliver på maskinen
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 9m51s
CI / Docker image builds (push) Successful in 1m40s
CI / Go (push) Failing after 15m4s
Samme grund som HANDOFF.md: GITEA-FLYTNING.md navngiver kassen, runneren,
registret og hvordan man kommer til dem. Det er et kort over én persons
opsætning og hører ikke til i et repo, der kan blive offentligt.
2026-08-21 23:17:19 +02:00
Kristian Wind c930e6163e ci: Gitea bygger selv — og uden at hente handlinger fra GitHub
CI / OpenAPI is valid (push) Successful in 51s
CI / Go (push) Failing after 15m36s
CI / Docker image builds (push) Successful in 2m55s
CI / End-to-end (push) Failing after 9m26s
DEFAULT_ACTIONS_URL står ikke i app.ini på gitea.nolimit.dk, og standarden er
github.com. Et `uses: actions/checkout@v4` i et Gitea-workflow bliver derfor
hentet fra GitHub, hver gang, også når både koden, runneren og registret er
vores egne — så et workflow, der bruger markedspladsen, ser uafhængigt ud uden
at være det.

Begge Gitea-workflows er skrevet om uden `uses:`. Værktøjskæden kommer fra
jobbets container, hvilket er det, setup-go ellers ville bruge et trin på;
checkout, login, metadata og buildx er de rå kommandoer, handlingerne pakker.

Det, der ikke fulgte med: rapporten fra en fejlet e2e-kørsel. upload-artifact
har ingen `run:`-form, så grunden står i loggen frem for i en zip.

Udgivelsen fik samtidig en rettelse, GitHub-udgaven ikke har: en forudgivelse
(v1.4.2-rc1) får kun sit eget mærkat. At lade en rc overskrive :latest eller :1
ville sende den ud til alle, der troede, de fulgte det stabile spor.

Issue-skabeloner i .gitea/ for sig: Gitea kræver `about`, hvor GitHub skriver
`description`, og afviser en skabelon uden.
2026-08-21 23:14:12 +02:00
Kristian WindandClaude Opus 5 20b8c12b4a build: imaget hentes fra vores egen Gitea, og runen peger derind
GitHub er utilgængeligt i ti dage. Koden ligger allerede begge steder; det, der
manglede, var imaget og et sted at bygge det.

**Imaget ligger i Gitea nu**, begge arkitekturer. Kopieret med
`docker buildx imagetools create`, som flytter hele manifest-indekset mellem to
registre uden at hente lagene ned — en `docker pull` på denne maskine ville have
hentet arm64 og efterladt serveren uden en amd64 at køre.

**Og det kan hentes uden legitimation, mens repoet bliver ved med at være
privat.** Det var ikke givet: panelets `PullImage` sætter aldrig `RegistryAuth`,
så et privat register er udelukket, og Gitea gør normalt en container-pakke privat
sammen med det repo, den hører til. Men en pakke bindes kun til et repo, når den
skubbes med et repo-link — og denne er ikke, så dens synlighed følger brugeren
`kw`, som er offentlig.

Bevist frem for antaget: `docker --config /tmp/tom pull` hentede den med samme
digest som GHCR havde. En bar `curl` mod `/v2/…/manifests/…` svarer 401 også for
et læsbart image, fordi registret uddeler et anonymt token først — så den er ikke
prøven, og den kostede en runde at gennemskue.

**Runen er bumpet til version 5.** Ikke fordi felterne ændrede sig, men fordi en
rune ikke opdaterer sig selv: panelet skriver rune-rækken fra en UI-handling, så
ændringen kræver **Runes → verdande → Update**, og katalog-listen caches ti
minutter.

**Et Actions-workflow til Gitea** ligger i `.gitea/workflows/`. Sin egen fil frem
for en delt: `secrets.GITHUB_TOKEN` findes ikke der, registret er et andet, og der
er ingen Releases-side at skrive til. En delt fil ville være fire betingede led,
der hver kun gælder ét af de to steder. Den laver ingen release, fordi
opdateringsnotitsen i appen læser GitHubs — skal den flyttes med, er det
`internal/update`, der skal have en ny adresse.

**Det virker ikke endnu:** der er ingen runner registreret på Gitea. Indtil der er,
bygges og skubbes imaget i hånden herfra.

Dokumentationen og landingssiden siger den nye adresse. Rune-siden er skrevet om,
hvor den fortalte om GHCR-pakkeindstillinger, og siger nu, hvordan man prøver
efter på den måde, der faktisk svarer på spørgsmålet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 16:53:18 +02:00
Kristian WindandClaude Opus 5 8d5dd1f1c3 fix(notes): grupperne stod tomme — hver liste rullede for sig
Release / Build and publish image (push) Failing after 9m59s
Meldt ind som "noter vises ikke i den nye listeform": hver eneste overskrift stod
der med sit tal, og der var ikke en eneste note under nogen af dem.

Før var der ét `<ul>`, og *det* rullede. Da listen blev delt i grupper, blev hver
gruppe sit eget `<ul>` — og dermed sit eget flex-element med `overflow-y: auto`.
Flex-elementer skrumper som udgangspunkt, og et element, der skrumper og klipper,
viser ingenting. Med tyve måneder i søjlen fik hver liste nul at være høj i.

Rulningen hører til kassen om dem alle, ikke til hver af dem.

**Prøven fangede det ikke, og det er værd at forstå hvorfor.** Den lavede to noter,
altså tre grupper, i et vindue på 720 pixels — og med så få grupper er der plads
nok til, at skrumpningen ikke bider. Fejlen kræver pres. Prøven gør vinduet lavt
nu og kigger efter, at rækkerne kan *ses* og ikke bare findes, og den siger
desuden hvor rulningen ligger: på kassen, ikke på listerne. Den er set fejle med
den gamle stil.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 13:00:15 +02:00
Kristian WindandClaude Opus 5 c8d67dd07d feat(notes): notekroppe forsegles, og søgningen åbner dem selv
CI / OpenAPI is valid (push) Successful in 38s
CI / Go (push) Failing after 18m36s
CI / End-to-end (push) Failing after 18m54s
CI / Docker image builds (push) Successful in 7m27s
Documentation / build (push) Failing after 7m7s
Documentation / deploy (push) Skipped
Release / Build and publish image (push) Failing after 9m59s
Grunden er den samme som for postkassers kodeord og OAuth-tokens: en
sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem hver
eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler ned — det
ved vi, fordi der ligger sådan nogle i den installation, det her blev bygget til.

**Både titel og krop.** Titlen *er* første linje, så en forseglet krop ved siden af
en læselig titel ville efterlade begyndelsen af hver eneste note i klartekst.

**Og de to kopier gik med.** Det var derfor det ikke var gjort for længe siden:
`notes_fts` indekserede kroppen, og den genererede `fold`-søjle bar en
translittereret kopi af den. At forsegle kroppen og lade dem stå ville have været
teater — en angriber med filen kunne læse hver note ud af indekset. Begge er væk,
og tabellen er bygget om, fordi SQLite ikke kan fjerne en genereret søjle på plads.

**Søgningen åbner teksten i Go.** Målt frem for gættet: tolv hundrede noter à 3,3 kB
koster 33–43 ms pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den
spørger — så det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne;
fire megabyte AES-GCM er få millisekunder. Den går med at folde og småskrive hver
krop, og det er lineært: regn med omtrent 350 ms ved ti tusind noter. Dér er det den
forkerte form, og svaret er da et nøglet indeks — hvis pris er, at præfiks- og
delordssøgningen falder bort.

bm25 forsvandt med indekset. Det, der er beholdt, er den halvdel, der betød mest:
titlen vejer ti gange kroppen. Med et loft pr. ord, så en note, der nævner ordet
fyrre gange, ikke slår en, der nævner alle ordene én gang — det er den fejl, en ren
optælling laver, og den er let at lave og svær at få øje på.

**En bagudfyldning ved opstart, ikke efterhånden.** Instansens indstillinger
forsegles, når de skrives, og det er rigtigt for dem. For et arkiv på tolv hundrede
importerede noter, som ingen redigerer igen, betyder "når de skrives" aldrig — og
det er præcis dét arkiv, der er værd at forsegle. Idempotent, så den koster et
gennemløb af id'er hver anden gang, og `updated_at` røres ikke: at lukke en
konvolut om en note er ikke at skrive den, og et stempel med i aften ville kaste
den rækkefølge væk, importen brugte en dag på at hente hjem.

En fejl under bagudfyldningen stopper opstarten. Halvdelen af noterne lukket er
ikke i stykker — `v1:`-præfikset gør, at begge slags kan læses — men en instans,
der starter og lader som om den beskytter noget, den ikke gør, er værre end en, der
siger fra.

Hvad der stadig ligger åbent, står i SECURITY.md og på dokumentationssiden:
opgavetitler, beskrivelser, kommentarer, og `note_links`, som røber hvilke
projekter og opgaver en note peger på, men ikke hvad der står.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 22:40:55 +02:00
Kristian WindandClaude Opus 5 23bd784b07 docs: SECURITY.md sagde noget, dagen havde gjort forkert
"Ingen ratebegrænsning ud over login og nulstilling" var sandt i morges. Det er
det ikke længere: andenfaktoren, beaconet og indgående post har fået deres eget
loft i løbet af dagen — det sidste tilføjede jeg selv og skrev ikke det her.

En SECURITY.md, der siger noget forkert, er værre end de fleste fejl. Den er
netop den fil, nogen læser for at afgøre, om de tør køre det her, og hvert eneste
punkt i den er en påstand, de ikke selv kan efterprøve uden at læse koden.

Samme sted mangler den største åbne ting: notekroppe, opgavetitler, beskrivelser
og kommentarer er ikke krypteret. Forseglingen dækker legitimation — postkassers
kodeord, OAuth-tokens, andenfaktorens hemmelighed, AI-nøgler — og intet andet. En
note med et kodeord i har det i klartekst i hver sikkerhedskopi, man kan hente.
Det stod i overleveringen og på dokumentationssiden for noter, og det hørte
hjemme her.

README's "ikke færdigt"-liste havde Gmail med. Den kører i produktion, inklusive
flagningen og dubletbehandlingen, der tog en dag at få rigtig.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 22:16:52 +02:00
Kristian WindandClaude Opus 5 264f48ae11 fix(quickadd): "hver anden tirsdag" blev til en dato og to ord i titlen
Release / Build and publish image (push) Failing after 10m3s
Vendingen er den, folk bruger, og recurrence-pakken har kunnet den siden den blev
skrevet — den oversætter "hver anden" til "hver 2." netop for at slippe for at
skrive hvert eneste mønster i to udgaver.

Sætningen nåede bare aldrig frem til den. Mønsteret i hurtig tilføjelse tog et tal
efter "hver", men ikke et ord, så det, der blev givet videre, var "hver anden".
Det er ikke en regel. Så blev de to ord stående i titlen, "tirsdag" blev læst som
en forfaldsdato, og opgaven gentog sig ikke — uden at noget sagde det.

Sløjfen, der prøver sig frem, kunne ikke redde den: den fjerner ord fra enden, én
ad gangen, og kan ikke hente et ord, mønsteret aldrig tog med.

Fundet af en prøve, jeg skrev til noget andet, hvor jeg brugte vendingen som en
selvfølge og opdagede, at den ikke var det.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 22:11:01 +02:00
Kristian WindandClaude Opus 5 2219f6af13 fix(i18n): en gentagelse siges nu på læserens sprog
Release / Build and publish image (push) Failing after 10m2s
`recurrence.Describe` skriver dansk. Fladen kunne navngive de fem almindelige
regler selv, og alt andet — "hver mandag", "hverdage", "hver anden uge tirsdag" —
faldt tilbage til serverens tekst, som er dansk uanset hvad kontoen er sat til.

Serveren kan ikke bare tage en locale med, og det er ikke dovenskab: `toTaskJSON`
har niogtyve kaldesteder, og mange af dem er WebSocket-udsendelser, hvor én
nyttelast når alle i et projekt på én gang. Der findes ikke ét sprog at skrive den
i.

Men reglen er den samme, uanset hvem der læser den. Den ligger allerede i
nyttelasten, så beskrivelsen hører hjemme dér, hvor læseren er — i fladen.

Serverens tekst bliver stående som reserve. Den er stadig den, et API-kald og en
MCP-forbindelse får, og de har ingen flade at oversætte i.

Ugedagen kommer fra `Intl` og ikke fra en tabel — ikke for at spare syv strenge pr.
sprog, men for at få de navne, sproget faktisk bruger. Dagene sorteres, som ugen
går, frem for som reglen tilfældigvis skrev dem: "mandag og torsdag" og "torsdag og
mandag" er den samme regel, og den ene af dem er den, folk siger.

Og to har sit eget ord på begge sprog — "hver anden", "every other". Det er ikke
tallet to sat ind i den samme sætning, så det er sin egen nøgle. Et sprog, der
bøjer enheden efter tallet, kan ikke sættes sammen af stumper; derfor står tallet
inde i teksten frem for at blive limet på.

Prøven bruger `hver mandag` og `hverdage`, fordi de netop *ikke* står på listen
over de fem, fladen altid har kunnet. En tredje vending, jeg først skrev prøven
med — "hver anden tirsdag" — viste sig ikke at være en, hurtig tilføjelse
overhovedet læser: den lagde "hver anden" i titlen og gjorde "tirsdag" til en dato.
Det er en anden sag, og den står nu skrevet ét sted, hvor den kan tages op.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 22:00:05 +02:00
Kristian WindandClaude Opus 5 f64b7c1f01 docs: Noter og Kalender havde ingen sider, mens forsiden lovede begge
Landingssiden reklamerer for noter og for en kalender, og dokumentationen havde
hverken en side om den ene eller den anden. Det hul blev skrevet ned i morges og
er det her.

**Noter** siger, hvad der er værd at vide, og hvad der ikke virker: at første linje
*er* titlen og udledes ved hver gemning, at arkivet og papirkurven er to
forskellige ting, at søgningen finder `grøn` når man skriver `gron` og
`keepInventory` når man skriver `inventory` — og at notekroppe ligger i klartekst i
hver sikkerhedskopi, man kan hente. Det sidste hører hjemme dér, hvor nogen står
med en note og overvejer at skrive et kodeord i den.

**Kalender** samler det, der før var spredt: gitteret, ugen som et døgn, træk der
sætter et klokkeslæt, abonnementer på ICS og webcal, og Google-forbindelsen med
det forbehold, den skal have — den er skrevet efter dokumentationen og har aldrig
kørt mod Google. `caldav.md` er nu den anden retning alene: verdandes egne opgaver
læst af noget andet.

En ting, byggeriet lærte: `</>` i inline-kode vælter Pythons html-parser på 3.9
med "we should not get here!". CI kører 3.12 og er ligeglad, men en side, der ikke
kan bygges lokalt, er en side ingen retter. Knappen er beskrevet med ord i stedet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 21:50:27 +02:00
Kristian WindandClaude Opus 5 868588de10 feat(projects): et projekts kalender kan vises som en uge
Ugen fandtes på Kommende og på Kalender, men ikke dér hvor et projekt bor — og det
er dér, "hvornår sker de her fire ting" oftest bliver spurgt.

Inde i kalendervisningen frem for som en fjerde `view_mode`. `view_mode` er
projektets og delt med alle, der kan se det, mens uge eller måned er et spørgsmål
om hvor bred skærmen er, foran hvilken der sidder én person — samme grund som
sidebjælkens bredde ligger i localStorage af. Og en fjerde værdi ville kræve en
tabelombygning: `CHECK (view_mode IN ('list','board','calendar'))` kan SQLite ikke
ændre på plads.

**Og så fandt den en fejl, der har ligget og ventet.** `cursor` blev sat med
`span === 'week' ? … : …`, hvilket læser `span` i det øjeblik komponenten bliver
til og fanger den værdi for altid. Byggeriet har advaret om det lige så længe. Så
længe hver visning havde sin egen komponent, gjorde det ingen forskel — og det
gjorde det i samme sekund, en vælger begyndte at skifte `span` på den samme.
Markøren stod på den 1. i måneden, og et skift til uge landede på ugen omkring den
1.: "27. jul.–2. aug." for en person, der kiggede på august.

Rettet, med den ene undtagelse skrevet ned: en månedsvisnings markør *er* den
første i måneden, så et skift til uge, mens man står i den indeværende måned,
skal lande på den uge, i dag ligger i — ikke på ugen omkring den 1.

Og en anden fælde, jeg selv gik i undervejs: en underklasses felter sættes *efter*
basens konstruktør er kørt. Nøglen til localStorage som et felt på en underklasse
betød, at valget blev gemt ét sted og læst et andet — en uge, man havde valgt, var
en måned igen efter en genindlæsning. Nøglen er et argument nu; et argument har
ingen rækkefølge at tage fejl af.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 21:45:38 +02:00
Kristian WindandClaude Opus 5 dcb71f4c14 feat(calendar): abonnement på flere kalendere, ICS og webcal
Release / Build and publish image (push) Failing after 10m3s
Bedt om som "abonnering af flere kalendere (ics og webcal?)". Det er også den
eneste vej for en privat konto: instansens OAuth-klient er Internal, og Google
afviser en konto udefra med `org_internal`, uanset hvad der ellers er sat op.

**Samme tabeller.** Et abonnement er én kalender med begivenheder i, og det er
præcis det, `calendars` og `calendar_events` allerede beskriver — med vinduet,
farven, `shown` og hele fejemaskinen omkring sig. En tabel til ville være en anden
vej til det samme sted, og to veje til ét sted er to steder at rette den dag noget
om begivenheder ændrer sig.

Det, der skulle laves om, var identiteten. En OAuth-konto er én pr. person og
udbyder — tokenet er identiteten — mens et abonnement er én pr. adresse, og man har
flere. `calendar_accounts_once` er derfor delvist nu: det holder for rækkerne uden
adresse, og et andet indeks holder adresserne unikke. Delvise indekser er den ene
ting, SQLite kan her, som ellers krævede en tabelombygning.

**`webcal://` skrives om til `https://`.** Det er ikke en protokol, noget taler —
det er en konvention, der får et skrivebord til at give adressen til
kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres links
sådan. At afvise den ville være at afvise folk på en teknikalitet.

**Adressen gennem `safedial`, begge halvdele.** Afvist hvor den skrives ned, så
den, der står med feltet, får svaret med det samme — og igen på den *opslåede*
adresse hver gang den hentes, fordi et navn, der blev godkendt ved indtastningen,
kan svare 127.0.0.1, når fejemaskinen kører klokken tre om natten. Det er den
sætning, `internal/safedial` selv står og advarer om, og den blev skrevet efter et
SSRF-fund i morges.

**Hentet én gang, før den gemmes.** En adresse, der ikke kan læses, er en tastefejl
langt oftere end en server, der tilfældigvis er nede, og at sige det, mens nogen
står med feltet, er de to sekunder værd. Et abonnement, der lydløst aldrig fyldes,
er den samme tavshed, notifikationerne havde.

Læseren er ny i `internal/ics`, som hidtil kun kunne skrive. To ting, den med vilje
ikke gør, står skrevet ved den: den udfolder ikke gentagelser, og den opløser ikke
tidszoner. Begge dele er større end de ser ud, og begge har ét sted at høre til
den dag de skal.

En ting, formatet lærte: DTEND er eksklusiv for en heldagsbegivenhed. Gemmes den,
som filen skriver den, dækker hver eneste helligdag også dagen efter sig.

Én adresse, der fejler, stopper ikke de andre. Et abonnement er en anden persons
server, og den ordinære tilstand for en anden persons server er, at den er i orden
indtil en morgen hvor den ikke er.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 21:26:13 +02:00
Kristian WindandClaude Opus 5 968a29ca67 feat(notes): listen står i grupper, og projektet har sin egen farve
Release / Build and publish image (push) Failing after 9m56s
Meldt ind som "den er for tæt på en eller anden måde". Grunden var ikke afstanden
mellem rækkerne: hver række sagde tre ting, og to af dem gentog sig selv nedad.
Datoen stod på hver eneste række — også når tyve noter deler dag — og projektet
fik en linje for sig selv. Tre linjer pr. note, gange tolv hundrede.

Gentagelserne er flyttet derhen, hvor de siger noget.

**Datoen er blevet til overskriften.** I dag · I går · Denne uge · August 2026 …
og så er den væk fra rækken. Noten går fra tre linjer til to, og luften kommer af,
at der er mindre at skrive — ikke af at listen bliver længere.

**Favoritterne har fået deres egen gruppe.** De lå allerede først; der stod bare
ikke hvorfor.

**Grupperingen følger sorteringen.** Sorteret på "Oprettet" grupperes der på
oprettelsesdatoen, på "Senest rørt" på den — ellers ville listen stå i én
rækkefølge og være grupperet efter en anden, og så er overskrifterne løgn.
Sorteret på navn er der ingen dato at gruppere på, så det bliver forbogstavet, og
datoen kommer tilbage i rækken, hvor den så er det eneste tilbage, der siger noget.

**Arkivet grupperes med.** Det er dér, de tolv hundrede importerede ligger, og
dér, det betyder mest.

**Projektet har sin egen farve** — den samme prik som i sidebjælken. Mærkaten sagde
det samme som alle de andre i en grå, der var lysere end uddraget, og projektet er
dét, man skanner efter, når man leder i tolv hundrede noter. Prikken bærer farven
og ikke teksten: fem projektfarver som skriftfarve er fem forskellige grader af
læsbarhed, og den lyseste er ikke læsbar.

Fire ting, der blev fundet ved at se på det:

- Uddraget gentog titlen, fordi titlen *er* første linje. Det var skjult, så længe
  en dato stod imellem dem.
- Uddraget klippes ikke af sig selv. Det lå før i en flex-boks, der ejede
  afkortningen; da datoen flyttede op, forsvandt boksen, og sætningen løb videre
  ind under favoritstjernen.
- Overskriftens stjerne hed `.star` ligesom rækkens favoritknap og arvede dens
  gennemsigtighed. Den stod der aldrig. To ting, der ligner hinanden, er ikke det
  samme.
- En `aria-label` på foldeknappen erstatter knappens indhold som dens navn — og
  knappen *er* overskriften, så overskriften kom til at hedde "Fold I dag sammen".
  Sidebjælkens gruppehoveder gør det rigtigt, og gør det nu begge steder.

Sammenfoldning huskes ikke på tværs af besøg: en gruppe, der åbner foldet uden at
nogen bad om det, er en liste, der ser tom ud.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 21:01:40 +02:00
Kristian WindandClaude Opus 5 fcd6dca852 fix(push): "der kommer ikke nogen" kunne ikke besvares
Release / Build and publish image (push) Failing after 10m0s
Alt om det var usynligt. Indstillingssiden sagde "til for denne enhed", så snart
der fandtes et abonnement — også når hver eneste push blev afvist. Og en afvist
push gik i en logline på **Debug**, som ikke skrives på en instans, der kører på
standardniveau. Så en levering, der fejler lydløst, og en levering, der aldrig
sker, så ens ud, og forskellen er hele diagnosen.

Tre forskellige problemer havde ét udseende: browseren tilmeldte sig aldrig,
tjenesten afviste den, eller der var ikke noget forfaldent endnu.

**En prøveknap** under Indstillinger → Notifikationer sender én notifikation
gennem den rigtige rute — ikke en genvej, så det, den beviser, er det, der
faktisk skal virke — og siger hvad hver enhed gjorde ved den. Kom den ikke frem,
og afviste ingen den, står der, at det så er browseren eller styresystemet, der
holder den tilbage. Nu peger svaret på ét af de tre steder.

Loglinjen er hævet fra Debug til Warn. Det var den ene linje, der ville have sagt
hvorfor.

Teksten kommer fra klienten, fordi ordbøgerne gør. En server, der bærer sin egen
kopi af "det her er en prøve", bærer den på ét sprog.

**Og en lækage, prøven fandt.** Go pakker en transportfejl som `*url.Error`, og
dens tekst er `Post "https://fcm.googleapis.com/fcm/send/dQw4…": dial tcp: …` —
hele endepunktet, som er den legitimation, push-tjenesten udstedte til den enhed.
Den var på vej ud i svaret, i loglinjen og i ethvert skærmbillede af nogen af dem.
Prøven var skrevet for at bevise, at kun værten blev nævnt, og det var sådan den
blev fundet. Fejlen pakkes ud, og adressen skrubbes bagefter alligevel: den næste
fejltype, der bærer den med, kommer ikke med en advarsel.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:41:33 +02:00
Kristian WindandClaude Opus 5 ae673086bc feat(tasks): tid på en opgave, og et træk i kalenderen sætter den
Release / Build and publish image (push) Failing after 9m58s
Bedt om: "jeg vil have tid på opgaver, og jeg vil kunne justere dem i kalenderen."

Serveren kunne det hele i forvejen — `due_date` og `due_time` læses sammen og
bliver til et `due_datetime` i kontoens tidszone. Det var fladen, der ikke havde
nogen vej til at sige det.

**Et felt i opgaveruden.** Tomt, indtil nogen sætter det: en opgave uden
klokkeslæt er det almindelige, og et felt, der åbner på 00:00, påstår, at noget
skal ske ved midnat.

**Og en fejl, der lå og ventede.** Serveren læser en dato uden en tid som "ryd
tiden". Så et gem af datoen alene tog klokkeslættet med sig — man rettede dagen og
mistede timen — og det samme gjorde hvert eneste træk i kalenderen. Ingen af
delene sagde noget.

`reschedule` siger nu tiden højt, tre måder, fordi de to første ligner hinanden i
JavaScript og derfor skal skilles ad ét sted frem for at blive forvekslet fire:

    undefined   behold den, opgaven har   — månedens ruder, hvor der ingen tid er at læse
    ''          ryd den                   — heldagsbåndet
    'HH:MM'     sæt den                   — et sted på døgnet

**Hvor på søjlen man slipper, er klokkeslættet.** Målt mod søjlens egen kasse frem
for mod siden, så det bliver rigtigt uanset hvor gitteret sidder og hvor langt der
er rullet. Rundet til kvarter: minuttet under markøren er ikke en oplysning, nogen
har — pegefingeren er bredere end et minut — og 14:07 er et tal, der ser ud som om
det blev valgt.

Prøven går hele vejen: sæt tiden, genindlæs og se at serveren tog den, ret datoen
og se at timen bliver, find opgaven på sit klokkeslæt i ugegitteret, og træk den op
i båndet igen. To ting, den lærte undervejs, står skrevet ved den: et træk over
fire hundrede pixels skal gå i skridt, ellers har browseren for få mellemstationer
til at holde gesten i live — og klokken skal være tidlig, for klokken fjorten
ligger under skærmkanten, og at måle noget dernede ruller siden væk under målet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:29:03 +02:00
Kristian WindandClaude Opus 5 e29fd04abd docs: noternes opbygning, skrevet til at blive løftet ud
Hvordan noter er bygget her: modellen, syntaksen, søgningens tre lag,
editorkontrakten, vedhæftninger, ind- og udførsel — og begrundelserne, som er den
del, der ikke kan læses ud af skemaet.

Den begynder med fjorten spørgsmål, der skal besvares før der skrives kode, og
siger det til den, der læser den, med rene ord: gæt ikke en standard. Grunden
står i filen — omtrent en tredjedel af designet her er arvet fra de omgivelser,
noterne bor i, og løftet ind i et program med andre omgivelser uden at afgøre
hvad der skal med, giver noget, der virker en uge og derefter slås med én.

Den siger også, hvad der ikke virker: notekroppe i klartekst i hver
sikkerhedskopi, import der aldrig fletter, ingen sync. En specifikation, der kun
beskriver det, der virker, lyver ved fortielse.

I repoet frem for uden for det, som HANDOFF.md og NOTES-PLAN.md er. De to er
arbejdspapirer om *dette* program; den her er beskrivelsen af en del af det, og
den bliver kun forkert, hvis koden flytter sig uden at den følger med — hvilket
er lettere at opdage, når den ligger ved siden af.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:02:59 +02:00
Kristian WindandClaude Opus 5 be581eb8ae fix(search): et ord inde i et ord kunne ikke findes
Release / Build and publish image (push) Failing after 9m57s
Meldt ind som "søgning har ikke det store held": en note med
`gamerule keepInventory true` kunne ikke findes ved at skrive "keep inventory".

Et indeks er bygget af tokens, og et token er det, en tokenizer besluttede var et
ord. `keepInventory` er ét. Så "keep inventory" spørger efter to ord, der ikke
findes nogen steder, og søgningen svarer, at der ikke er noget — mens noten står
lige der.

Præfikssøgningen tager halvdelen af det. `keep*` rammer faktisk
`keepinventory`; `inventory*` kan ikke, for det er ikke et præfiks af noget. Og
de to led er bundet sammen med AND, så den halvdel, der virker, bliver kasseret af
den, der ikke kan.

Det er ikke et sært tilfælde i en notesapp. Ti års noter er fulde af kommandoer,
identifikatorer og filnavne, og hver eneste af dem er flere ord, indekset tror er
ét: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.

Indekset svarer først. Har det ingenting, læses teksten: hvert ord skal stå et
sted i noten, hvor som helst, i hvilket som helst ord. `fold` er
`title || ' ' || body` med de danske bogstaver translittereret, så én søjle dækker
titlen, brødteksten og "gron" for "grøn" på én gang.

Opgavesøgningen har fået den samme arm. At have den i den ene af de to ville være
værre end at have den i ingen af dem: "den finder det i mine noter, men ikke i
mine opgaver" er den næste melding, og svaret på den ville være denne linje.

Hvad det koster, og hvornår formen er forkert, står skrevet ved koden: en LIKE med
et indledende % kan ikke bruge et indeks, så den læser hver note. Ved tolv hundrede
noter er det fire megabyte og et par millisekunder, og den kører kun, når den
hurtige vej allerede kom tomhændet tilbage — altså netop den søgning, der var ved
at skuffe nogen. Ved hundrede tusind er svaret et FTS5-`trigram`-indeks, ikke en
større scanning.

Prøverne er set fejle uden rettelsen, og de dækker begge veje: "inventory" alene,
som ingen præfikssøgning kan nå, og "Bedrock", som stadig skal besvares af
indekset — ellers er rangeringen, der blev bygget til tolv hundrede noter, stille
forsvundet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 18:33:22 +02:00
Kristian WindandClaude Opus 5 9d34b57166 feat(calendar): ugen er et døgn, og dagene er lige brede
Release / Build and publish image (push) Failing after 9m58s
To ting, meldt ind fra en uge, der ikke svarede på det, man åbnede den for.

**Dagene var ikke lige brede.** Gitteret stod på `repeat(7, 1fr)`, og `1fr` er
`minmax(auto, 1fr)`: `auto` som minimum betyder, at en søjle ikke kan blive
smallere end sit længste ord. Én opgave, der hed "Gårdbutikken på Møllerup Gods —
opfølgning på mail", gjorde onsdag dobbelt så bred som mandag og klemte resten af
ugen sammen om den. `minmax(0, 1fr)` lader søjlen blive så smal, den skal, og så
klipper brikken sin tekst af — hvilket den allerede kunne.

**Ugen var syv punktopstillinger.** Den svarede på "hvad skal der ske torsdag" og
ikke på "hvornår", og det andet er det, man åbner en uge for. To møder klokken ti
er en konflikt, man skal kunne se, ikke læse sig til.

Nu et gitter med en tidsrende og syv dage: timerne ned ad siden, begivenheder
placeret efter klokkeslæt, og et bånd foroven til det, der ikke har et — en
heldagsbegivenhed lagt klokken nul ville påstå, den holdt fra midnat til midnat.

Overlappende ting deler bredden i sammenhængende klynger frem for parvist:
overlapper A med B og B med C, skal alle tre dele, også når A og C ikke rører
hinanden. Ellers ville A og C hver tro, de havde halvdelen, og lægge sig oven på
hver sin halvdel af B.

Hvilke timer der tegnes, kommer fra ugens egne begivenheder med arbejdsdagen som
gulv. Et helt døgn er fireogtyve rækker, hvoraf de otte altid er tomme, og så er
gitteret noget man ruller i frem for noget man ser — men en vagt klokken fem skal
med, uden at alle andre uger betaler for den med tomme rækker.

Måneden er ikke rørt ud over søjlebredden. En dag i en månedsrude er halvanden
centimeter høj; et døgn tegnet i den er ikke en visning, det er en streg.

Brikkerne er flyttet ud i to snippets, som begge visninger tegner med. De viser
de samme to ting og skal vise dem ens; det er placeringen, der er forskellig, og
så er det kun placeringen, der står to steder.

`data-date` følger med over på ugens timesøjler — det er sådan noget uden for
komponenten peger på en dag. Båndet får sit eget `data-allday`, så de to ikke
bliver til det samme svar på ét spørgsmål.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 17:01:58 +02:00
Kristian WindandClaude Opus 5 4b3abc44fb feat(site): hvad der er landet, og hvad man tager på sig ved at køre det
To dele.

**Lately** siger, hvad der er kommet til, fem linjer med versionsnummer i egen
søjle. En landingsside kan påstå, at et projekt er i live; en liste over hvad der
faktisk landede siger det bedre. Ikke en changelog — den ville gå i stå — så den
peger på release-noterne, som skrives alligevel.

**At your own risk** siger det, MIT-licensen siger, og som ingen læser: uden
garanti af nogen art. Dertil det, der er sandt om netop dette: én persons projekt,
version 0.x, du hoster det selv, så data og sikkerhedskopier er dine — og en
sikkerhedskopi, ingen har gendannet, er ikke en sikkerhedskopi endnu. At der er
lavet to gennemgange af koden, at hvert fund er skrevet ned og lukket, og at
**ingen udefra har auditeret det**.

Sat i samme skriftstørrelse som alt andet på siden. Et forbehold med mindre skrift
end resten er skrevet for at kunne peges på bagefter, og det er den ene ting,
siden ikke skal gøre.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 16:19:44 +02:00
Kristian WindandClaude Opus 5 b87c9ea6e2 fix(auth): en afvist passkey sagde "forkert e-mail eller adgangskode"
Release / Build and publish image (push) Failing after 10m0s
Der er hverken en e-mail eller en adgangskode i et passkey-login. Loginet er
discoverable — enheden ved selv, hvilken konto nøglen hører til, og der bliver
ikke tastet noget. Alligevel fik en afvisning fladens generiske 401-tekst, og den
nævner begge dele.

Det sendte en person ud at lede efter en tastefejl i sin konto, der aldrig havde
været der. Det, der faktisk var sket, var at instansen var flyttet fra http til
https: en passkey hører til præcis den adresse, den blev lavet på — `RPOrigins`
bygges af `VERDANDE_BASE_URL` med skema og alt — så et login på det ene skema mod
en server, der venter det andet, kan ikke verificeres.

Beskeden siger nu det, og siger også hvor man skal kigge. Serveren har hele tiden
svaret "that key was not accepted"; det var oversættelsen i fladen, der gjorde
den til noget andet.

Aflysning bliver behandlet som før — den er ikke en fejl, og en fejl for den ville
læse som om nøglen blev afvist.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 15:59:34 +02:00
Kristian WindandClaude Opus 5 ce1d27f8ca fix(notes): én note, der ikke kunne tegnes, låste hele ruden
Release / Build and publish image (push) Failing after 9m53s
Meldt ind som "jeg kan ikke skifte mellem noter — uanset hvilken jeg trykker på,
åbner de ikke".

Editoren indlæste med `note.body.trim()` uden værn. `body` er en streng fra
serveren hver eneste gang — indtil den ikke er, og så kaster den her inde i et
`$effect`. En effekt, der kaster, tager komponentens opdateringer med sig: den
forrige note bliver stående, og *ingen* note kan åbnes bagefter. Klikket kan ikke
engang lande, hvilket er derfor det ikke ligner en fejl i noten, man klikkede på,
men i hele ruden.

To ting, ikke én. Værnet, så en manglende krop er en tom note. Og en ramme om
tegningen, så det næste, der en dag ikke kan tegnes, heller ikke bliver til det
her: en note, der fejler, viser sin egen tekst uden formatering, og resten af
ruden virker videre.

`loadedId` sættes nu før tegningen frem for efter. Ellers ville effekten prøve
igen ved hver eneste ændring bagefter og kaste hver gang.

`textContent` og ikke `innerHTML` i faldbakken: teksten er ikke tegnet, og at
lægge den ind som markup ville være at køre præcis det, tegningen nægtede.
Gemningen er ikke i fare — den skriver `draft`, som kommer fra svaret og ikke fra
det, der står i editoren, indtil nogen taster i den.

Prøven tvinger formen frem i stedet for at vente på den nyttelast, der gjorde
det. Hvad der end fik `body` til at mangle, er svaret det samme: én note må ikke
kunne låse de tolv hundrede andre ude. Den er set fejle uden rettelsen — med
netop den fejl, konsollen viste.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 15:32:36 +02:00
Kristian WindandClaude Opus 5 cab4e703f5 fix(sikkerhed): CalDAV lod en viewer skrive, og Web Push kunne ringe indad
Release / Build and publish image (push) Failing after 10m0s
To huller fundet ved en gennemgang efter dagens arbejde. Begge har en prøve, der
er set fejle før rettelsen.

CalDAV PUT afgjorde adgang på det projekt, klienten *nævnte i adressen*, og tog
derefter opgavens id fra den samme adresse. Intet bandt de to sammen. En viewer
på et delt projekt kunne pege på et projekt, de selv ejer — indbakken, som alle
har og alle må redigere — og lægge den delte opgaves id i filnavnet. Projekt-
tjekket gik godt på indbakken, og skrivningen landede på en opgave, de kun må
læse. GetTask lukkede det ikke: den svarer "må denne person se det", og det må en
viewer. `CanEdit` er spørgsmålet, og det er det samme, handleUpdateTask altid har
stillet — API-døren har spurgt hele tiden, CalDAV-døren spurgte ikke.

Opgaven skal nu også ligge, hvor adressen siger. Uden det kan en redaktør på to
projekter nå det ene gennem det andets adresse; intet eskaleres af det i sig
selv, men en adresse, der har lov at lyve om hvor noget bor, er sådan sagen
ovenfor kom ind.

Web Push blev leveret med en almindelig http.Client. Adressen kommer fra
browserens abonnement — altså fra den, der er logget ind — og serveren POSTer til
den på et skema, de selv vælger: sæt en påmindelse et minut ude, og jobbet går
igen hvert minut. Tilmeldingen afviser en privat adresse, og den kontrol er ikke
nok alene: den kører på URL'en som skrevet, og et navn, den godkender, kan svare
127.0.0.1 næste gang det slås op. Det er præcis den sætning, `internal/safedial`
selv står og advarer om. Leveringen går gennem safedial nu, som tjekker den
opslåede adresse ved forbindelsen og hvert viderestillingshop på samme måde.
Kontrollen ved tilmelding bliver, fordi den siger nej, mens nogen kigger på
skærmen.

Og en hærdning: /inbound/mail har fået et loft. Det er den svagere af de to
offentlige skriveveje — beaconet skriver en række for en fremmed, denne skriver
ingenting uden en 256-bit adresse, man skal kende i forvejen — men adressen deles
med mailservere og står på en indstillingsside, og når den først er kendt, var der
intet loft på, hvad der kunne laves med den. Tallet er sat generøst: modtageren er
mailserveren, én adresse der leverer det hele.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 15:15:28 +02:00
Kristian WindandClaude Opus 5 43f8c0ea85 feat(site): landingssiden siger hvad der findes nu, og siger det med kanter
Release / Build and publish image (push) Failing after 10m0s
Siden stod fra før kalenderen, noternes billeder og arkiv, sikkerhedsarbejdet og
beaconet. Nu står de der: søgning der læser dansk, kalenderen, Venter på andre,
gentagelser, AI slået fra som udgangspunkt, andenfaktor, at ingen skriver sig
selv op, og beaconet — sagt som det er, tændt fra start og med ét klik til at
slukke.

Google Kalender står med sit forbehold på selve kortet. Den er skrevet efter
dokumentationen og har aldrig kørt mod Google, og en landingsside, der siger
noget andet, er den ene sætning, resten af siden ikke kan bære.

Ikke-målene løj om posten: "No mail integration beyond Gmail and the forwarding
address" har været forkert, siden IMAP kom til. iCloud og Fastmail virker. Det,
der faktisk ikke virker, er Outlook, og det er nu det, der står.

Designet: en bjælke der bliver stående, fordi siden er 5.600 px og vejen ud kun lå
i toppen. Fire fakta under knapperne, hvor en landingsside plejer at sætte
logoer, den ikke har gjort sig fortjent til. Kort med egne kanter frem for celler
i ét gitter — ti celler i tre søjler er et hul i tegningen, atten kort i tre
grupper er ikke. To paneler under "Running it", fordi de er alternativer og ikke
trin. Ikke-målene som en liste, der kan tælles. Og tre skærmbilleder, der lå i
site/screenshots uden at være brugt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:57:15 +02:00
Kristian WindandClaude Opus 5 8ecefbbdc2 fix(notes): rullebjælken svævede midt i arket
Det er `.page`, der ruller — og det var også `.page`, der blev smallere for at
holde linjelængden nede. Så bjælken fulgte med ind til tekstsøjlens kant og stod
med en håndflade tom rude til højre for sig, en tredjedel inde i ruden.

Grænsen sættes som polstring i stedet for som bredde. Elementet er lige så bredt
som ruden — bjælken bliver ved kanten, hvor den hører til — og teksten er
nøjagtig lige så bred som før. `max()` mod nul, fordi en smal rude ellers ville få
negativ polstring, og `border-box` er sat globalt, så bredden er hele boksen og
ikke boksen plus polstringen; uden det ville det give en vandret rullebjælke.

Kildevisningen deler `.page` med editoren og havde samme fejl. Den følger med.

Målt: elementet slutter nu på rudens indholdskant i stedet for 74 px inde, og
`scrollWidth - clientWidth` er nul.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:57:15 +02:00
Kristian WindandClaude Opus 5 6b4448b245 fix(settings): AI, Gmail og kalenderen delte én række og slettede hinanden
Meldt ind som "AI-informationerne gemmes ikke". De blev gemt. De blev taget
bagefter.

`user_settings` blev oprettet med `user_id` som hele sin primærnøgle — én række
per person, uanset scope — og upserten skrev `scope` om ved konflikt. Der er tre
scopes i live: `ai`, `gmail` og `calendar`. De delte den ene række, så hver
skrivning slettede de to andre, og hver eneste svarede 204. Det virker begge
veje: at forbinde kalenderen slettede Gmail-forbindelsen, og Gmail slettede
kalenderen.

Fælden var set komme to gange. 0011 gav instansens indstillinger deres egen
tabel delvis af den grund og skrev advarslen ned med rene ord; 0014 flyttede
postkasserne ud og skrev, at advarslen "nær var trådt i for anden gang". Tredje
gang var kalenderen, som lagde et `calendar`-scope ved siden af de to andre.

Lektien værd at holde fast i: en advarsel i en migration bliver læst af den, der
redigerer *den* migration, og det var ingen. Formen er rettet her, så der ikke er
noget tilbage at advare om — `PRIMARY KEY (user_id, scope)`, og upserten rammer
den nøgle.

Tabellen bygges om, fordi SQLite ikke kan ændre en primærnøgle på plads. De rækker,
der findes, kopieres som de er: hver person har højst én, og den holder det scope,
der vandt sidst. Det, de tidligere skrivninger tog, er overskrevet og kan ikke
hentes tilbage her — efter opdateringen skal AI-udbyderen sættes op igen, og
Gmail-forbindelsen tjekkes.

To prøver: én mod store-laget, hvor formen var fejlen, og én gennem handlerne,
hvor den blev set.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:56:48 +02:00
Kristian WindandClaude Opus 5 e46aaa462a feat(projects): sektioner kan trækkes op og ned, og båndet står ikke længere skævt
Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi det
er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette en
sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede
område på vejen.

Hele overskriften er håndtaget. En sektionsoverskrift indeholder ikke noget, der
selv bliver trukket, så der er ingen geste at stjæle — hvilket er grunden til, at
sidebjælkens rækker skulle have et greb, og grunden til at disse ikke skal. Ikke
draggable mens den omdøbes: et draggable element lader ikke markøren vælge tekst
i det felt, det indeholder.

Serversiden er hele listen på én gang som projekter og grupper, og af samme
grund: et projekt har en håndfuld sektioner, så 0, 1, 2 … er én lille transaktion
uden præcision at løbe tør for, og en liste kan ikke ende halvt anvendt.
`ReorderSections` er scopet til projektet og ikke til en ejer — en sektion har
ingen ejer, den hører til et projekt, og om du må rykke rundt er et spørgsmål om
det projekt, som handleren allerede har stillet. Id'er fra et andet projekt
springes over af WHERE-klausulen; der er en prøve på præcis det, fordi ingenting
ellers kontrollerer id'erne enkeltvis.

Båndet stod fire pixels skævt. Det var trukket ud med --s2 og polstret med --s3 —
samme idé gjort med to forskellige tal — så det blev otte pixels bredere end
listen i hver side, og teksten landede fire pixels uden for den kolonne, rækkerne
holder. Til venstre var det usynligt. Til højre var det Omdøb og Slet, der stod
fire pixels forbi alt over og under sig, hvilket er præcis langt nok til at ligne
en fejl og ikke langt nok til at se hvorfor. En række er `.row` med
`padding: var(--s2)` inde i en boks uden, så båndet har nu samme polstring og
ingen margen.

Board-visningen er ikke rørt: der er kolonnerne vandrette, og det er en anden
geste.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:56:26 +02:00
Kristian WindandClaude Opus 5 98bfa216d6 fix(look): mærket og de faste visninger fulgte med op, når listen rullede
Hele sidebjælken rullede, så runen og I dag forsvandt over kanten, så snart der
var projekter nok — og de er dem, man er på vej hen til oftest. Alt under de
faste visninger ligger i en rulleboks nu, så rullebjælken begynder under dem.

En boks indeni frem for `position: sticky` udenom: et klæbende hoved efterlader
rullebjælken i fuld højde ved siden af sig, og det var netop den, der skulle
starte længere nede. Snippet-erklæringerne bliver liggende udenfor — de tegner
ikke noget selv, men de kaldes oppe fra .views, og en snippet erklæret indeni
hører kun til indeni.

Chevronen for en mappe dækkede gruppens prik med et felt i --surface. På den
sænkede bjælke er det en lys firkant, og en foldet gruppe har sin chevron fremme
permanent, så klodsen stod der hele tiden. Prikken tones ud i stedet.

Det kostede en fælde: en `opacity` under 1 laver en stakkontekst og maler
elementet, som var det placeret med `z-index: 0`. Prikken lagde sig dermed oven
på knappen, den lige var forsvundet for, og tog klikket — usynlig og stadig i
vejen, så gruppen ikke kunne foldes. Ét lag op på knappen, og rækkefølgen er
som før.

Prøven for vandret rul måler nu begge bokse. Bjælken selv er `overflow: hidden`,
så en for bred række ville blive klippet dér og aldrig nå at tælle med — den
ville bestå af den forkerte grund.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 14:56:01 +02:00
Kristian WindandClaude Opus 5 6c3d66108b fix(look): stregen under hurtig tilføjelse havde ingen luft under sig
Release / Build and publish image (push) Failing after 9m56s
Den skiller "skriv en ny" fra "det, der allerede er". Uden afstand sad den første
sektion klods op ad den, så den læste som en kant om sektionen frem for som en
afslutning på feltet over. En streg skal have plads på begge sider for at kunne
ses som en streg.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 11:36:23 +02:00
Kristian WindandClaude Opus 5 1437667483 fix(notes): stjerne og arkiv står oven på hinanden
Release / Build and publish image (push) Failing after 9m56s
To knapper ved siden af hinanden er toogfyrre pixels taget fra en liste, der er
tre hundrede bred — og de er det, man rører en gang imellem, mens titlen og
uddraget er det, man læser hele tiden. I en søjle koster de toogtyve.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 11:23:21 +02:00
Kristian WindandClaude Opus 5 035d079a8e fix(notes): teksten fulgte ikke med, når ruden blev bred
Release / Build and publish image (push) Failing after 9m59s
46rem var sat for at holde linjelængden i skak, og det er stadig det rigtige
hensyn — men det blev sat som ét tal, og på en skærm halvanden gang så bred stod
teksten i en smal søjle med en håndflade tom plads ved siden af. Kodeblokke og
billeder, som er de to ting i en note, der *vil* have bredde, blev klemt sammen
uden grund.

clamp frem for et tal: en smal rude får det hele, en bred får en linjelængde,
øjet kan finde tilbage i. På 1920 er tekstsøjlen nu 1088 px af arkets 1304 i
stedet for 736.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 11:08:26 +02:00
Kristian WindandClaude Opus 5 aadfb197b6 feat(notes): arkiv, flere ad gangen, og en kopiér-knap på koden
Release / Build and publish image (push) Failing after 9m55s
**Arkivet, fordi papirkurven er det forkerte redskab.** Den siger "det her var en
fejl, og om tredive dage er det væk" — så alt, der er værd at beholde, kan ikke
komme i den. Et arkiv på tolv hundrede importerede noter er præcis det tilfælde,
hvor de fleste er værd at beholde og næsten ingen er værd at kigge på; uden et
sted at lægge dem er listen hele arkivet for evigt, og at finde ugens note
betyder at læse forbi et årti.

En kolonne, ikke en mappe. Noterne afviste et andet hierarki i 0018, og
arkivering er ikke arkivering i den forstand: det er én oplysning om, hvorvidt en
note er aktuel, og den skal kunne besvares ved siden af `deleted_at`, som er den
anden af samme slags. `updated_at` røres ikke — at rydde op er ikke at skrive.

**Flere ad gangen.** ⌘-klik lægger til, skift-klik tager intervallet i den
rækkefølge, listen faktisk står i — ikke notens egen, som er en anden, når der er
sorteret eller søgt. Trækker man en note, der er markeret, følger hele
markeringen med; trækker man en, der ikke er, er det den ene, for ellers ville et
træk i en tilfældig række flytte halvtreds noter, man havde glemt var markeret.

Ét kald frem for et pr. note: efter en import af tolv hundrede er det at vælge
halvtreds det almindelige tilfælde. Hver note kontrolleres stadig for sig — et sæt
er en bekvemmelighed for den, der kalder, og aldrig en vej til noget, de ikke
kunne have rørt én ad gangen.

En kant *og* en tone på det markerede: `--accent-sunken` er meget lys i lyst tema,
og tre markerede rækker var svære at skelne fra tre, der ikke var — en dårlig ting
at være i tvivl om lige inden man trykker Slet.

**Kopiér-knappen spiste næsten den kode, den skulle kopiere.** Den ligger inde i
`<pre>` for at kunne stå i hjørnet af blokken, og `colourCode` læste
`pre.textContent` og malede resultatet tilbage — så et klik på "Kopiér" skrev
"Kopieret" ind i koden, og næste gemning lagde det i filen. Nu ét sted, der siger
hvad en kodeblok indeholder, brugt af både farvelægningen og oversættelsen til
Markdown: var de to uenige, ville forskellen ende i noten. Prøven klikker,
læser udklipsholderen og læser filen bagefter.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 11:03:02 +02:00
Kristian WindandClaude Opus 5 8aad7c13b0 perf(notes): listen sendte hele noter for at vise halvfems tegn af dem
Release / Build and publish image (push) Failing after 9m55s
Chrome frøs på notesiden, og der var to grunde.

**Nyttelasten.** Listen viser en titel og omkring halvfems tegn. Den fik hele
noter for at gøre det: med tolv hundrede blev svaret 3,9 MB, hvoraf otteoghalvfems
procent var brødtekst, ingen ville læse på den skærm — hentet, tolket og holdt i
hukommelsen hver gang siden blev åbnet. Nu 763 KB.

Kroppen tømmes frem for at blive forkortet, og det er den vigtige halvdel. Et felt,
der hedder `body` og indeholder et *stykke* af kroppen, er en fælde: editoren ville
åbne det, pausen ville gemme det, og noten ville være skåret ned til sit eget
uddrag af ingenting andet end at være blevet kigget på. Tom er en form, klienten
kan se efter — og den gør det og henter hele noten, når den åbner en.

Det kostede en adskillelse mere: hvilken række der er *valgt*, og hvilken note der
er *indlæst*, er nu to ting. Editoren indlæser sin tekst, når notens id skifter, så
hvis den fik uddraget først og hele noten bagefter, ville id'et være det samme
begge gange og den anden aldrig blive vist. Man ville sidde med en tom note, der
lige havde haft en titel.

**Tegningen.** Alle tolv hundrede rækker fik layout og maling, også de tusind
ingen kan se: 2,1 sekunder til første tegning. `content-visibility: auto` lader
browseren springe det over og tage det, når man ruller derhen — 0,95 sekunder, og
uden en eneste linje JavaScript, ingen rullematematik og ingen liste, der kan komme
ud af trit med sig selv.

**Og søgefeltet.** Det var en pille med et forstørrelsesglas tegnet ind som et
baggrundsbillede med farven skrevet i hånden — #8b918d, uanset tema — hvilket
gjorde det til det ene sted på siden, der ikke fulgte resten. Nu samme form som
søgefeltet i toppen, et rigtigt ikon i `currentColor`, en ryd-knap og et antal
fundne.

Det havde heller ingen pause: hver tast sendte en søgning gennem tolv hundrede
noter, så "kaffe" var fem forespørgsler, hvoraf de fire var forældede inden de kom
tilbage — og svarene kunne lande i den forkerte rækkefølge. Nu én.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 10:36:35 +02:00
Kristian WindandClaude Opus 5 3a02bc8304 fix(notes): søgningen svarede med det forkerte først, og et træk gjorde ingenting
Release / Build and publish image (push) Failing after 9m55s
**Søgningen sorterede efter dato.** Usynligt med ti noter og ubrugeligt med tolv
hundrede: de blev alle importeret samme aften, så `updated_at` siger ingenting, og
loftet skar listen af ved halvtreds vilkårlige rækker. At søge "Tesla" svarede med
en note om nginx først og noten, der hedder "Tesla Model Y", bagefter — hvilket
læses som en søgning, der ikke virker, fordi det fra brugerens plads er præcis
dét.

Nu bm25, med titlen vægtet ti gange kroppen: et ord i titlen er, hvad noten
*handler om*, mens det samme ord én gang i en lang krop er en henvisning. `fold`
vejer som kroppen, ellers ville et dansk match slå et præcist. Sammenføjet frem
for `rowid IN (…)`, fordi rangeringen hører til matchet, og en underforespørgsel
smider den væk. Fladen sorterer ikke om, når der er søgt — det ville kaste netop
det væk, der gør en søgning i tolv hundrede noter brugbar.

**Et træk af en note hen på et projekt gjorde ingenting.** `ondragover` tog imod
både opgaver og noter, så rækken lyste op — og `ondrop` sendte kun opgaver videre,
mens noten faldt ned i grenen, der omarrangerer projekter, hvor den ikke betyder
noget. Den værst mulige form for i stykker: den så ud, som om den ville virke,
helt indtil man slap.

Det står i overleveringen som "virker i koden, men ikke fastholdt i prøver, fordi
prøven blev upålidelig". Den er skrevet igen, og den ser på resultatet — ligger
noten i projektet bagefter — frem for på om rækken blev fremhævet, hvilket var
netop det, den gamle kunne se og som blev ved med at være sandt, mens funktionen
var i stykker.

**Og datoen i listen var altid `updated_at`.** For tolv hundrede importerede noter
er det i aftes, så en note fra 2017 stod som "i går", og den dato, man sorterede
efter, var ingen steder at se. Rækken viser nu den dato, listen er sorteret efter,
og har begge i sit værktøjstip.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 09:58:41 +02:00
Kristian WindandClaude Opus 5 4aa8bd6a40 fix(sikkerhed): resten af gennemgangen — SSRF, tre ubegrænsede veje, og fire hemmeligheder
Release / Build and publish image (push) Failing after 9m54s
**H2 — SSRF gennem AI-udbyderens adresse.** `base_url` blev gemt uden kontrol og
hentet med en almindelig klient, og udbyderens svar blev givet tilbage ordret.
Det var en fungerende skanner af det interne net for enhver bruger: peg den på en
vært, læs de første tre hundrede bytes af det, der svarede. På en homelab er det
det meste af, hvad der er værd at nå.

Nu gennem `safedial`, og svarets krop går i loggen frem for i svaret. Operatøren
har stadig brug for beskeden — for en lokal model er den som regel selve
problemet — så den er der, bare kun for den, der kører instansen.

**M2 — Web Push havde tre.** Endepunktet blev gemt ukontrolleret og POSTet til på
en tidsplan, den, der ringer, selv vælger: sæt en påmindelse et minut ude, og
serveren henter adressen, hvert minut. Upserten flyttede desuden ejerskabet med
`user_id = excluded.user_id`, så enhver, der kendte en andens endepunkt, kunne
overtage det — offerets telefon ville få angriberens beskeder og ingen af sine
egne. Og `DeletePushSubscription` havde ingen ejer-kontrol.

**H3 — tre veje uden loft.** FTS-udtrykket byggede omkring halvtreds bytes pr.
ord uden grænse; en søgning på en kvart megabyte blev til to megabyte
MATCH-udtryk og seksogtyve sekunder inde i SQLite, mens den holdt på én af kun
fire til otte forbindelser. Nogle få af dem samtidig, og databasen er utilgængelig
for alle på instansen, logins inklusive. `filterquery.Parse` er kvadratisk —
halvtreds tusind led er syv sekunders CPU og seksogfyrre gigabyte allokering i én
forespørgsel — og havde hverken loft på antal eller på dybde. Og noteimporten
læste hver fil i arkivet uden grænse: 64 MB komprimeret kan blive til
seksogtres gigabyte skrevet ned på den disk, databasen ligger på.

**M1 — `comment_id` blev taget på tro.** Rettighedskontrollen så på opgaven i
adressen, mens filen blev hængt på den kommentar, formularen bad om. En viewer på
et delt projekt kan liste dets kommentarer, så hun kunne læse et id og lægge en
fil under en andens kommentar i et projekt, hun kun må læse.

**M4 — fire hemmeligheder lå i klartekst i hver sikkerhedskopi.** TOTP-koden og
AI-nøglen forsegles nu; begge konverterer sig selv næste gang de skrives.
Kalenderfeedets og mailadressens tokens gør ikke — de slås op på deres præcise
værdi og vises i fladen mere end én gang, hvilket en hash ikke kan. Det står nu
skrevet i `SECURITY.md`, som påstod det modsatte. At rette det betyder, at
adressen kun kan vises én gang, og det er en beslutning om, hvordan funktionen
virker.

Og en prøve på, at politikken overhovedet bliver sendt. Det var den, der gjorde
XSS-en i nat til indsat HTML frem for kørt script, og der var ingen prøve, der
rørte den — en foranstaltning, ingen prøve rører, kan forsvinde i en oprydning,
uden at nogen opdager det. Målt på script-src alene: `style-src 'unsafe-inline'`
står der med vilje, og en indsat stil kan gøre en side grim, ikke køre kode.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 08:39:52 +02:00
Kristian WindandClaude Opus 5 ce3fc466b2 fix(auth): andenfaktoren kunne gættes igennem, og et token kunne tilmelde sin egen
To huller fra sikkerhedsgennemgangen, og de er samme historie fortalt to gange:
den ene kontrol, der findes til når adgangskoden allerede er tabt, kunne omgås af
netop den, der havde adgangskoden.

**H1 — spanden blev fyldt af adgangskoden alene.** `handleLogin` nulstillede
ratebegrænseren ved hver korrekt adgangskode, *før* den så på, om der var en
andenfaktor. Det gav én, der havde kodeordet, ubegrænsede portioner på ti gæt:
log ind, få en frisk spand og en frisk ventende session, brug ti gæt på koden, log
ind igen. Med tre gyldige koder i ethvert øjeblik og tyve forespørgsler i
sekundet er det lige odds inden for en eftermiddag.

Spanden ryddes nu først, når login er *færdigt*, og der er kommet en spand mere:
koder tælles pr. konto og ikke kun pr. adresse. Adressespanden er slået af enhver
med mere end én adresse, hvilket er det almindelige — en telefon og en bærbar er
allerede to. Det, der gør en sekscifret kode sikker, er at gæt er en knap
ressource, og knapheden skal tælles imod det, der bliver angrebet.

Den ventende session får lov at leve. At afslutte den ved første forkerte ciffer
blev overvejet og valgt fra: det ville koste en, der taster ét tal forkert, sin
adgangskode igen, og det køber næsten ingenting oven i loftet pr. konto.

**M3 — et lækket token kunne tilmelde sin egen andenfaktor.** `/auth/totp/setup`
lå kun bag `requireAuth` og spurgte om ingenting, mens `disable` altid har
krævet adgangskoden: døren åbnede med et token og lukkede kun med et kodeord. Et
stjålet token kunne hente hemmeligheden, lægge den i sin egen godkender, bekræfte
den og beholde den eneste kopi af gendannelseskoderne. Ejerens næste login ville
stoppe ved en kode, de ikke havde, og de kunne ikke slå den fra uden at logge ind
først. Et tyveri bliver til et lejemål — den sætning står allerede i filen ved
siden af om passkeys, og det er den samme regel.

Nu bag `requireSession` sammen med passkeys og sessionerne, og `setup` spørger om
adgangskoden ligesom `disable`. Symmetrien er pointen: at slå den til er mindst
lige så indgribende som at slå den fra.

Og en note kan oprettes inde fra et projekt. At ligge i et projekt *er* måden en
note deles på, så det er én handling frem for to — man skulle før oprette noten
under Noter og bagefter finde projektet frem i "Del i". Ruden findes nu, også før
der er en eneste note, for ellers er der ingen knap dér, hvor man opdager at der
skal skrives noget ned.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 08:29:43 +02:00
Kristian WindandClaude Opus 5 bdde9cc6a5 merge: Google Kalender som menupunkt, med Verdandes egen kalender lagt over
Release / Build and publish image (push) Failing after 9m58s
Læs bredt, skriv snævert — decision 5 i planen, og kun den første halvdel:
kalenderen læses, og der skrives ingenting tilbage.

**OAuth-blokeringen er reel og blev ikke gået udenom.** En klient registreret som
*Internal* tager kun konti i sit eget workspace — også til kalendere, ikke kun
Gmail. Andreas' private konto bliver afvist med `org_internal`, og det er
klienten, der virker efter hensigten, ikke en fejl. Fladen siger det nu med ord,
man kan handle på, frem for at vise Googles egen skærm, der ikke gør.

Vejen udenom, som ikke er bygget: kalenderens egen hemmelige ICS-adresse. Ingen
OAuth, virker for enhver konto, og `internal/ics` findes allerede. Det er det
næste stykke, og det eneste, der låser den private konto op.

`internal/google` er flyttet ud af `internal/gmail`, fordi de to er det samme
flow, der er uenige om én streng. Egen callback-adresse frem for at dele Gmails:
én callback, der finder ud af hvilken token-butik den skal skrive i, går galt ved
at overskrive en Gmail-forbindelse, der virkede.

Egne tabeller frem for `mailboxes`. `trigger_kind`, `seen` og `last_uid` svarer
alle på "hvad gør en besked til en opgave", og ingenting her bliver til en
opgave; én konto har mange kalendere, og `mailboxes` har ikke plads til en
en-til-mange. Afgørende i praksis: `mailboxes.kind` har en CHECK, og SQLite kan
ikke ændre en — en tredje værdi koster den tabelombygning, som migration 0021
skulle igennem.

Begivenheder erstattes pr. kalender pr. vindue frem for at flettes med et
sync-token: et vindue, der slettes og skrives forfra, kan ikke komme ud af trit,
og et sync-token er endnu en ting, der skal være rigtig for altid, for at gitteret
ikke stille viser noget forkert.

`CalendarView` er udvidet, ikke kopieret. En begivenhed er et fyldt bånd, hvor en
opgave er et kort med en farvet kant, så de to læses forskelligt før et ord er
læst — og den kan ikke trækkes og er aldrig en knap: der er ikke noget at gøre
færdigt ved en andens møde.

To værn på det, der kommer fra Google: farven skal matche et hex-mønster, før den
må stå i en style, og linket bliver kun til et href, hvis det begynder med https.

Konflikten i jobs.go var to nye jobs skrevet på samme linje. Begge skal med.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:39:12 +02:00
Kristian WindandClaude Opus 5 6b2cd0f259 merge: engelske skærmbilleder, og otte steder hvor fladen blev ved med at være dansk
Release / Build and publish image (push) Failing after 9m54s
Skærmbillederne er engelske, fjorten i alt, og der er to mennesker i dem. Det
sidste er ikke pynt: halvdelen af det, programmet er til, findes ikke med én
konto — en ansvarlig, et navn i historikken, en medlemsliste, og "Venter på
andre" som andet end en tom side.

Sproget kommer fra kontoens locale og ikke fra strenge skrevet i hånden, hvilket
er dét, der fandt fejlene: otte steder, hvor grænsefladen blev ved med at være
dansk, uanset hvad kontoen sagde. "Forsinket" og "I dag" skrevet ind i markup,
"Uden sektion" som boardets første kolonne, gentagelsesreglerne halvt oversat,
alle nitten navne på hændelser i historikken, og et `da-DK` hårdkodet i Kommende.
Gentagelsesreglerne er flyttet til `when.js`, så menuen og mærket ikke kan blive
uenige igen — det er præcis, hvad den fil er til.

README's billeder havde været brudte siden `37605ba`, som lavede PNG'erne om til
WebP og slettede dem, uden at teksten fulgte med: forsidebilledet og alle seks i
tabellen var 404 på GitHub. Og noterne — hele funktionen — stod ikke nævnt hverken
i README, på forsiden eller i dokumentationen.

Nu skrives PNG til docs og WebP til site, altså præcis dét, de to steder læser.
Før efterlod hver kørsel tolv usporede PNG'er i site, som intet pegede på.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:34:16 +02:00
Kristian Wind dcd66b415e docs(web): registreringsteksten fortæller nu, at klienten dækker begge dele
Hjælpeteksten sagde "slå Gmail API til", og det er kun det halve nu. Den samme
klient bærer både Gmail og Google Kalender og skal have begge redirect-URI'er —
og kalenderpanelet siger, hvor dets egen kommer fra, så ingen leder efter et
felt til en klienthemmelighed, der allerede står ovenfor.
2026-08-20 07:33:24 +02:00
Kristian WindandClaude Opus 5 3991ec9445 feat(notes): et billede kan indsættes og trækkes ind i en note
Rapporteret fra brug: man kunne ikke indsætte billeder eller trække filer ind i
noter. Det passede — importen kunne tage billeder med, og intet andet kunne.
Indsætningen tog kun `text/plain` og smed resten væk, og der var slet ingen
håndtering af et træk. At indsætte et skærmbillede er det mest almindelige, der
findes i et noteprogram, og det gjorde ingenting overhovedet.

Der manglede også et sted at lægge filen. Vedhæftninger på en note fandtes i
databasen, siden migration 0021, men kun importen kunne skrive dem; der var ingen
rute. Nu er der en, med editor-rettigheder på noten og samme svar for "findes
ikke" og "ikke din", så et id ikke kan afprøves.

Skrevet ind som `![](…)` og ikke som en data-URL: en note er Markdown i en
database, og et fotografi fra en telefon som base64 midt i teksten ville gøre
noten til fire megabyte tekst, der skal læses hver gang listen tegnes.

`execCommand` frem for at bygge noden selv, fordi det er dét, der lægger
handlingen i browserens egen fortryd-historik — ⌘Z tager billedet ud igen,
ligesom det tager et ord ud.

`preventDefault` på både dragover og drop. Uden den første kommer der intet drop;
uden den anden åbner browseren filen i stedet, og man mister det, man var i gang
med at skrive, til en fane med et billede i. Kun filer: et træk inde fra appen
selv bærer vores egen nyttelast og skal falde igennem til den, der lyttede.

Prøven går begge veje ind, ser at billederne bliver hentet og tegnet, og læser
filen bagefter for at sikre, at der står to bilagsadresser og ingen data-URL.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:32:03 +02:00
Kristian Wind a5f2f769c4 fix(web): begivenhederne i en celle sorteres, hvor de tegnes
Serveren sorterer allerede, men en celle, der viser 14:00 over 09:15, ligner
ikke en liste i forkert rækkefølge — den ligner et forkert klokkeslæt på
chippen, og det er langt værre at skulle udelukke. Heldags først: en heldags
begivenhed er et bånd hen over dagen, ikke noget der sker på et punkt i den.
2026-08-20 07:30:55 +02:00
Kristian WindandClaude Opus 5 9d743a5d46 fix(tools): shots skrev PNG'er ned i mappen med WebP
Standardstien var site/screenshots, som siden landingssiden gik over til WebP
kun indeholder WebP. En kørsel lagde tolv PNG'er ved siden af dem, som ingen
tekst peger på. Den skriver nu til docs/, hvor de andre PNG'er ligger.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:27:48 +02:00
Kristian Wind 031bb18067 fix(calendar): en tom kalender skal sige hvorfor, ikke bare være tom
At vælge en kalender er nogen, der siger "vis mig den her". At gemme valget og
lade gitteret stå tomt, til fejeren kommer forbi et kvarter senere, er en
kalender, der påstår, at fjorten dage er fri — og personen står på siden med
knappen under hånden. Gemningen henter nu selv.

Og er der forbundet en konto uden at der er valgt nogen kalendere, siger
visningen dét. "Der er ikke valgt en kalender" og "der er ikke booket noget"
ser ens ud, og kun den ene er noget at gøre noget ved.
2026-08-20 07:27:42 +02:00
Kristian WindandClaude Opus 5 71a9fe63d6 docs: noterne findes også i teksten, og hvert billede har et sted at høre til
README'ets billeder havde været brudte siden landingssiden gik over til WebP:
site/screenshots/*.png blev slettet dengang, og markup'en pegede stadig på
dem. Den peger nu på PNG'erne i docs/, som er dem, der findes.

Noter er en af de største ting i appen og stod ikke ét sted i teksten —
hverken i README'et, på landingssiden eller i dokumentationen. Nu gør de,
med de to nye billeder.

Fem billeder pegede ingen tekst på: upcoming, sections, delegated, settings
og admin. De har fået et afsnit i docs/index.md, hvor hvert enkelt beviser
noget, siden siger. Landingssidens gitter viste den lyse udgave to gange —
den ene af dem er blevet til noterne.

Og to billedtekster passede ikke længere på billedet: temaerne viser nu også
de fire udseender, og opgaven i skuffen viser ikke sin samtale.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:25:43 +02:00
Kristian WindandClaude Opus 5 eeaaf7506b docs: skærmbillederne er engelske, og der er to mennesker i dem
Landingssiden og dokumentationen læses på engelsk, og billederne var danske
hele vejen — både grænsefladen og indholdet. Kontoen bliver nu oprettet med
engelsk sprog frem for at oversætte noget her: det er sådan appen selv gør
det, og det er også dét, der får en glemt oversættelse til at stå i et
billede i stedet for at vente på en bruger.

Demodataene er skrevet om til engelsk arbejde og husholdning. Og der er en
person mere: en invitation, en konto, tre opgaver hun sidder på og en
kommentar. Halvdelen af det, appen er til for, findes først, når der er to i
den — en ansvarlig på en opgave, et navn i historikken, og "Venter på andre"
som andet end en tom side.

Tre billeder viste ikke det, teksten sagde. `detail` var en tom formular, så
opgaven har fået beskrivelse, undertasks, en fil, en påmindelse og en samtale.
`themes` viste profilfeltet over temaerne, så den ruller nu ned til dem.
`delegated` var en tom tilstand.

Noterne er kommet til — listen med en note åben, og den samme note som kilde.
Fjorten billeder i alt.

WebP'en skrives af den browser, der i forvejen står åben, frem for af cwebp:
et program mere at installere hører ikke til i et script, hvis hele pointe er,
at der ikke er noget at installere. PNG'erne ligger nu kun i docs/ og WebP'en
kun i site/, hvilket er præcis dét, de to steder læser — før lagde et kørsel
tolv usporede PNG'er i site/, som ingen brugte.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:24:12 +02:00
Kristian Wind 1b1e57df29 docs: kalenderen den anden vej, og én registrering til begge Google-ting
`caldav.md` beskrev to måder at sende opgaver *ud*. Den tredje er den modsatte
retning og hører til samme sted, fordi det er dér, man ville lede.

`configuration.md` hed "Gmail" og handler nu om Google: samme klient, to
redirect-URI'er, to API'er, to scopes. Bemærkningen om en Internal-app står
begge steder, fordi den gælder begge veje — og for én privat konto er
kalenderens egen hemmelige ICS-adresse den kortere vej. Intet i verdande
abonnerer på sådan en endnu.
2026-08-20 07:24:01 +02:00
Kristian WindandClaude Opus 5 dce8800f93 fix(web): otte steder hvor grænsefladen blev ved med at være dansk
Skærmbillederne skulle tages på engelsk, og det var dét, der fandt dem:
en konto sat til engelsk fik stadig "Forsinket" over de forsinkede opgaver,
"Uden sektion" over den første kolonne på tavlen, "Aldrig" i gentagelses-
menuen, "hver dag" på rækken, "til instansen · inviteret af" på invitationen,
"3 projekter" på kontoen, og hele historikken — nitten hændelser, alle på
dansk. En funktion, der kun virker på det ene sprog, er en funktion, der er i
stykker.

Gentagelsen er den eneste, der ikke bare manglede en nøgle. Serveren skriver
`recurrence_text` på dansk og skal blive ved med det: én besked går over
WebSocket til alle i projektet på én gang, og de læser ikke det samme sprog.
Så de regler, der har et navn, får det i when.js — på den side, der ved, hvis
skærm det er — og alt mere specifikt falder tilbage til serverens ord.

Mellemrummet i historikken er skrevet ud. Svelte fjerner det, der åbner en
blok, så "inviterede" og adressen løb sammen til ét ord.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:23:42 +02:00
Kristian Wind c3f2181efe feat(web): Kalender som menupunkt, med Googles begivenheder lagt over egne opgaver
Samme gitter som Kommende, givet en liste mere at tegne. Ikke et gitter nummer
to, og grunden er den del, der er nem at få næsten rigtig: et mål skal afgøre
ud fra `dataTransfer.types` alene, om det tager imod, og to kopier af det er to
steder at være næsten rigtig. En opgave trukket her flyttes præcis som alle
andre steder, fordi det er den samme kode, der gør det.

En begivenhed er ikke en opgave, og cellen siger det før et eneste ord er
læst: en flade af farve, hvor en opgave er et kort med en farvet kant. Den kan
ikke trækkes — forbindelsen er læsende, og en chip, der letter fra siden og så
ikke vil lande, læses som en fejl frem for som en regel — og den er ikke en
knap, for der er ikke noget at lukke ved en andens møde. Farven er Googles egen
for den kalender, så to kalendere i ét gitter kendes fra hinanden på den farve,
personen allerede kender dem på.

Farven tjekkes frem for at blive troet: den ender i en inline `style`, og en
værdi, der ikke er en farve, kan lukke erklæringen og åbne en anden. Linket
tegnes kun, hvis det begynder med `https://` — et href er dér, hvor en
`javascript:`-adresse bliver til et klik, der kører.

Prøven bruger uge-visningen med vilje: en månedscelle skæres af ved tre chips,
og resten af suiten lægger opgaver på andre dage. Datoen læses ud af siden, ikke
ud af testprocessen, som de øvrige har lært at gøre.
2026-08-20 07:22:25 +02:00
Kristian WindandClaude Opus 5 241c5ed37d fix(mail): den samme mail blev en opgave igen og igen
Release / Build and publish image (push) Failing after 9m58s
Rapporteret fra drift med et skærmbillede af den samme mail seks gange i træk.

**IMAP: flaget kommer af, når mailen er blevet en opgave.** Markøren var et uid —
husk det højeste, begynd over det næste gang — og den holdt kun, hvis uids var,
hvad serveren sagde. Den brød på et fetch-svar uden uid, på en UIDVALIDITY, der
skiftede under en genopbygget postkasse, og på to postkasse-rækker, der pegede
samme sted og hver især førte deres eget regnskab. Alle tre gav det samme
symptom.

Så regnskabet flytter derhen, hvor spørgsmålet bliver stillet. "Flaget" betyder
"lav det her om til en opgave"; når den er en opgave, har flaget gjort sit
arbejde og er ikke sandt længere. Så er der ingenting at huske, og dermed
ingenting, der kan komme ud af trit — en anden synkronisering, en anden
postkasse-række eller en database gendannet fra en kopi finder alle det samme
svar, fordi svaret ligger i postkassen.

Flaget tages af *efter* opgaven er skrevet. Et flag fjernet fra en mail, hvis
opgave aldrig blev gemt, er en mail, ingen ser igen.

**Gmail havde den samme fejl af en anden grund.** Listen over allerede behandlede
beskeder blev bygget om ved hver kørsel af *kun de id'er, Gmail svarede med den
gang* — for at den ikke skulle vokse uendeligt. Men forespørgslen er begrænset,
så på en konto med mere stjernemarkeret post, end der er plads til i ét svar,
glemte hver kørsel det, den forrige havde husket. De kom tilbage som ubehandlede
og blev opgaver igen. Det var ikke en grænse, det var en måde at glemme på.
Listen har nu et loft på antal i stedet, nyeste først.

Gmail kan ikke få taget stjernen af: scopet er `gmail.readonly`, med vilje, fordi
det er dét, der gør samtykkeskærmen til noget, et menneske kan sige ja til. Derfor
skal regnskabet blive på vores side dér — men det skal så være et regnskab, der
kan holde.

Prøven kører hele vejen mod en rigtig IMAP-server: find de flagede, tag flaget af,
og spørg så forfra med markøren sat til nul — altså det værst tænkelige tilfælde,
hvor alt, vi selv havde husket, er væk. Der kommer ingenting tilbage.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 07:04:40 +02:00
Kristian Wind 987d4edfce feat(calendar): en læst Google-kalender, med sit eget endpoint og sin egen sweep
Samme registrering som Gmail, et andet scope og en anden redirect-URI. Google
udsteder en refresh-token pr. godkendelse og holder op til hundrede i live pr.
konto, hver med sine egne scopes — så en forbundet kalender rører ikke en
Gmail, der virker, og at afbryde den ene tager ikke den anden med.

Callbacken har sin egen sti. Én callback, der ud fra gemt tilstand afgjorde,
hvilket af de to flows der kom tilbage, ville lægge valget af *hvilket
token-lager der skrives* bag et opslag — og går det galt, går det galt ved at
overskrive en virkende Gmail-forbindelse med kalender-tokens.

Begivenhederne caches i et vindue på 90 dage tilbage og et år frem. Et
månedsgitter bladrer, og et kald til Google pr. piletast er en kalender,
der er langsommere end den kalender, den viser. Vinduet har en kant, og
endpointet siger hvor den er: et tomt svar uden forklaring er en kalender,
der lydløst påstår, at en dag er fri.
2026-08-20 07:01:14 +02:00
Kristian WindandClaude Opus 5 78891bae6f feat(beacon): tæl installationer, sig præcis hvad der sendes, og lad folk sige nej
Spørgsmålet er ét: hvor mange bruger det her. Svaret er to værdier én gang i
døgnet — et tilfældigt id, installationen selv fandt på, og versionen. Ikke andet.
Beskeden er lille nok til at stå i fuld længde på indstillingssiden, og den gør
det, fordi et løfte om telemetri er præcis så meget værd som læserens mulighed
for at efterprøve det.

**Ikke obligatorisk, og grunden er praktisk frem for principiel:** det kan ikke
håndhæves. Domænet kan blokeres, binæren kan patches, og en fork fjerner den i
første commit — så obligatorisk køber intet over slået-til-som-udgangspunkt og
koster den tillid, resten af programmet arbejder for. Hemmeligheder forsegles
her, nøglen holdes uden for databasen, og sikkerhedskopisiden advarer om, hvad en
kopi indeholder. Telemetri, man ikke må sige nej til, er den ene sætning i den
historie, der ikke passer.

Uenigheden med `config.UpdateCheck`, som er slået fra med den modsatte
begrundelse, står skrevet i pakkens egen dokumentation frem for at blive glattet
ud. Den er reel, og læseren fortjener at se begge halvdele.

Id'et findes på og gemmes ved første brug frem for at blive udledt af værtsnavn
eller adresse. En hash af noget om maskinen ville være stabil, bekvem og
samtidig en værdi, modtageren kunne holde op mod et gæt — og det er forskellen
mellem anonym og pseudonym.

Modtagerens side er den samme binary med en kontakt: `POST /api/v1/beacon` uden
login, fordi den, der ringer, er en fremmed installation. Den svarer 404, når
instansen ikke er tælleren, så en almindelig installation ikke er en spamspand,
der svarer 200 på alt. Ratebegrænset som login-ruterne, og tabellen har et loft:
endepunktet indsætter en række på et id, den, der ringer, har fundet på, og uden
et loft kunne enhver fylde disken.

Tallet er "set inden for tredive dage" og ikke et løbende total. Et total stiger
kun og holder op med at betyde "hvor mange bruger det" og begynder at betyde
"hvor mange har prøvet det".

`internal/safedial` kom til undervejs: modtagerens adresse kan skrives i fladen,
og en adresse, en bruger angiver, må ikke kunne pege indad. Kontrollen ligger på
den opslåede adresse og ikke på URL'en, fordi et navn kan svare 127.0.0.1 — og
svare noget andet anden gang, efter en kontrol ved parsning er gået godt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 06:59:50 +02:00
Kristian Wind 7828c1e6ed feat(gcal): Google Calendar kan læses, og kalenderen får sine egne tabeller
Klienten henter kalenderlisten og begivenhederne i et vindue, og laver Googles
to former om til den ene, et gitter kan spørge om: dage i begge ender, begge
med. Googles heldags-slutdato er eksklusiv, og et møde, der slutter ved midnat,
slutter aftenen før — begge dele rettes ét sted frem for hos hver læser.

Egne tabeller frem for `mailboxes`. De to ligner hinanden på afstand og er
uenige om hver eneste kolonne bagefter: `trigger_kind`, `seen`, `last_uid` og
`project_id` svarer alle på "hvad laver en besked om til en opgave", og intet
her bliver til en opgave. Én konto rummer desuden mange kalendere, og en
en-til-mange er der ikke plads til. Det afgørende er mindre og mere praktisk:
`mailboxes.kind` har en CHECK, og SQLite kan ikke ændre en CHECK — en tredje
værdi koster en tabelombygning med fremmednøgler slået fra. Derfor er der ingen
CHECK på `provider` her: CalDAV skal koste en række, ikke en ombygning.
2026-08-20 06:50:57 +02:00
Kristian Wind a46d9158e1 refactor(google): OAuth-flowet flytter ud af gmail, så to ting kan dele det
Gmail og Google Calendar logger ind gennem den samme registrering med den
samme PKCE og den samme fornyelse. Det eneste, der adskiller dem, er ét ord:
scopet. En kopi mere ville være et sted mere, hvor `access_type=offline` kan
blive glemt — og det er dén fejl, der får en forbindelse til at dø lydløst en
time efter, den blev lavet.

internal/gmail beholder sit API og sit scope; navnene på flowet er aliaser, så
hverken kaldere eller prøver ændrer sig. Prøverne for PKCE, AuthURL og
Configured flytter med koden.
2026-08-20 06:46:10 +02:00
Kristian WindandClaude Opus 5 4b5f3f0864 chore: agenternes arbejdstræer hører ikke i repoet
Release / Build and publish image (push) Failing after 9m56s
Et `git add -A` fangede .claude/worktrees/ som et indlejret repo. Det er en
agents arbejdskopi og skal ingen steder.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 06:39:45 +02:00
Kristian WindandClaude Opus 5 052f8a43fb fix(notes): en note kunne smugle et script ind gennem et billedes alt-tekst
Indført i aften, sammen med billedvisningen, og fundet ved at læse den igen.

`escapeHtml` tager &, < og > — ikke anførselstegn, og det er rigtigt for tekst
mellem to tags, hvor et anførselstegn ikke betyder noget. `alt` er ikke tekst
mellem to tags. Det står inde i en attribut, og der lukker det første
anførselstegn den, hvorefter resten står som markup:

    !["  onerror="alert(1)](/api/v1/attachments/aaaa)

blev til et <img> med en onerror på. Adressen var i forvejen bundet til vores
eget bilagsmønster, så den var ikke vejen ind — teksten ved siden af var.

Det er ikke en teoretisk vej. En note deles ved at ligge i et projekt, så den
læses af alle, der kan læse projektet, og scriptet ville køre på vores eget
domæne med sessionscookien, hos den der åbnede noten.

En værdi i en attribut har brug for sin egen undslipning, og har nu en. Prøven
skriver noten, åbner den og ser efter, at hændelsen står som tekst i alt og ikke
som en attribut på elementet — og at billedets 404, som er selve vejen en
onerror ville blive kaldt ad, ikke kalder noget.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 06:39:19 +02:00
Kristian WindandClaude Opus 5 dee2d5e30b fix(faerdige): en opgave lukket efter midnat lå under "I går"
Release / Build and publish image (push) Failing after 9m56s
Fundet af prøverne, som for første gang kørte forbi midnat.

Dagen blev læst som `completed_at.slice(0, 10)` — de ti første tegn af et
tidsstempel, der er i UTC. Overskriften sammenlignede med *lokal* dato. I dansk
sommertid er de to ikke den samme dag i de første to timer efter midnat, så en
opgave lukket 00.30 blev sat under "I går", indtil klokken blev to. Tyve timer i
døgnet er de ens, hvilket er derfor prøven har været grøn hver eneste gang før.
Dagen udregnes nu lokalt, som den overskrift den skal svare til.

Og prøven for invitationslinket kunne kun bestå seks dage om ugen. Den ledte
efter "males inden fredag" som én tekst, men hurtig tilføjelse læser "fredag" som
forfaldsdatoen og tager ordet ud af titlen — tilbage står "males inden" plus et
mærke, hvis ord skifter med ugedagen. Kørt på en torsdag hedder mærket "I
morgen", og så fandt den ingenting. Prøven handler om invitationer, ikke om
datoer, så den ser nu på titlen alene.

Uddraget i notelisten viste `![](/api/v1/attachments/01a0…` for hver note med et
billede i — den eneste tekst i listen, ingen havde skrevet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 00:17:50 +02:00
Kristian WindandClaude Opus 5 2bd8e48aba fix(notes): en note med tre prikker i titlen blev smidt væk ved importen
Release / Build and publish image (push) Failing after 9m54s
`skipFile` afviste ethvert navn, der indeholdt "..", for at stoppe en zip i at
skrive uden for sin egen mappe. Det er den rigtige idé rettet mod det forkerte:
det beskriver ikke en sti, der klatrer ud af arkivet, men ethvert filnavn med to
prikker ved siden af hinanden. En ellipse i en overskrift er almindelig, og noten
"Så blev det endelig jul! Stay tuned... ☕️" blev derfor aldrig oprettet.

Lydløst, oven i købet: filen blev sprunget over, før den blev talt, så svaret
sagde det samme, som hvis den var kommet med. Én note ud af tolv hundrede, og den
blev kun fundet ved at tælle efter, hvor mange der kom ud i den anden ende — 1140
hvor der skulle stå 1141.

Nu læses ".." som et stiled, ikke som to tegn, og en Markdown-fil, filteret
afviser, tælles som sprunget over. En import, der taber noget, skal sige det.

Og move-ikonet i sidebjælken er væk. ⠿ sad i den to pixels brede rende til
venstre for teksten og lå oven i rækkens egen kant — idéen var rigtig, pladsen
var det ikke. Elementet bliver: det er dét, der ejer trækket, og gjorde man i
stedet hele rækken draggable, ville alt indeni også blive det, og en opgave
trukket hen på I dag holdt op med at lande. Nu er håndtaget usynligt og lidt
bredere, og hånden ved hover er hele forklaringen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 00:04:08 +02:00
Kristian WindandClaude Opus 5 9b4e7686f9 feat(notes): datoerne følger med, billederne bliver tegnet, og listen kan sorteres
Release / Build and publish image (push) Failing after 9m54s
Fire ting, som en rigtig import af tolv hundrede noter fandt på en time.

**Billederne blev vist som ord.** `markdownToHtml` havde ingen regel for
`![](…)` overhovedet, så en note fra Apple Noter viste sin egen billedadresse som
tekst. Filen var hentet, gemt og hæftet på noten; det eneste, der manglede, var
at nogen tegnede den. Kun vores egen adresse bliver til et billede — et
`![](http://…)` fra en indsat tekst ville ellers hente fra en fremmed vært, når
noten åbnes, og en note er det sidste sted, man vil have en sporingspixel. Og
vejen tilbage skulle med: uden `IMG` i inlineToMarkdown ville den første gemning
have taget hvert billede ud af noten igen.

**Titlen blev revet i stykker.** Noter deler en linje op i så mange stumper, den
har brug for — "Aalborg Kommune" står som
`<h1>A</h1><h1>alb</h1><h1>org Kom</h1><h1>mune</h1>`, fordi der er en
attributkørsel bag hver stump — og eksporten læste et linjeskift ved hver af dem.
Noten kom ud som fire overskrifter oven på hinanden, altså ødelagt på den mest
synlige måde der findes: i sin egen titel. Samme rod gav `**M****øde**` for fed
og `*M**øde*` for kursiv, hvor de to stjerner i midten læses som fed. Nu brydes
der kun, når blokken skifter, og et mærke, der lukkes og straks åbnes igen, er
ingenting.

**Datoerne fandtes ikke.** Eksporten læste dem aldrig, og Markdown havde ingen
plads til dem — så en flytning af tolv hundrede noter blev en bunke, der alle var
skrevet samme aften. Rækkefølgen er halvdelen af, hvad et arkiv er. Nu står de i
et YAML-hoved, som Obsidian og Bear allerede skriver, og importen læser det.
`SetNoteTimes` frem for gennem SaveNote: `updated_at` betyder "hvornår skrev
*dette* program noten sidst", og lod man enhver kalder sætte den, ville kolonnen
betyde det, den sidste skriver havde lyst til.

**Og listen kan sorteres** — senest rørt, oprettet, navn. Favoritter står øverst
uanset hvad; det er hele meningen med at gøre en note til favorit.

Eksporten henter nu i klumper. Ét kald pr. note tog halvanden time for tolv
hundrede, ikke fordi Noter er langsom, men fordi hvert kald er en proces, der
skal startes — og det gjorde det umuligt at rette en fejl i oversættelsen og se
resultatet, for hver runde var en frokostpause. Antallet af bilag kom med i
klumpen af samme grund: ni ud af ti kald var en proces startet for at få svaret
nul.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 22:58:19 +02:00
Kristian WindandClaude Opus 5 2ffbd9533b fix(notes): billederne i en importeret note pegede på ingenting
Release / Build and publish image (push) Failing after 9m54s
Fundet ved at importere rigtige noter frem for ti prøvenoter.

Linket blev skrevet halvt om. attachToNote kendte to måder, en note kan nævne en
fil på — den bare filnavn og stien i forhold til noten — men ikke den, en Apple
Noter-eksport faktisk skriver: `vedhaeftninger/751-2-IMG_0615.heic`, med noten i
roden ved siden af mappen. Kun det bare navn matchede, så kun det blev erstattet,
og hver eneste billede i hver eneste importerede note endte som
`vedhaeftninger//api/v1/attachments/…`, som er ingenting. Filerne var gemt og
hæftet rigtigt på; det var linket, der var forkert. Længste match først, ellers
bliver mappen stående foran adressen.

Og billederne kunne alligevel ikke vises. Alt blev serveret som
application/octet-stream med Content-Disposition: attachment, hvilket er en god
regel med en god grund — en uploadet SVG eller HTML vist inline ville køre sit
eget script på dette domæne med sessionscookien på. Men en note bærer sine
billeder i teksten som `![](…)`, og under den regel er hvert eneste af dem et
brudt billede. Hele importen findes for at tage dem med.

En kort liste af rasterformater vises nu inline med deres egen type. Det er ikke
en opblødning: listen er navngivet ét format ad gangen frem for matchet på
"image/", fordi præfikset ville lukke image/svg+xml ind — et dokument, der kan
køre script — og en liste, der vokser ved et uheld, er præcis sådan det sker.
nosniff står, så browseren skal læse filen som netop det billede eller slet ikke.

Titlen stod to gange i fjorten noter. Noter kalder noten det, der står på de
første linjer sat sammen med mellemrum, så en note, hvis to første linjer er
"CPU" og "CAN", hedder "CPU CAN" — og sammenligningen med kun den første linje
gik fri. Overskrifterne øverst tages nu med, så længe de tilsammen stadig er
begyndelsen på titlen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 22:02:20 +02:00
Kristian WindandClaude Opus 5 f6c6bdccbb fix(notes): projektmenuen står ved det # der bliver skrevet
Den var lagt fast øverst i ruden, med den begrundelse at en menu, der følger
markøren, rykker sig et par pixels for hvert bogstav. Det er rigtigt, og det var
alligevel forkert: i en note på en skærmfuld skriver man `#` nederst og får en
menu i toppen — fire hundrede pixels fra det, øjet ser på, og langt nok væk til
at man ikke opdager, at den er der.

Begge dele kan lade sig gøre. Positionen måles, når menuen åbner, og ikke igen
mens listen snævres ind: den står ved tegnet, og den står stille.

Målt på `#term` frem for på markøren, fordi et sammenfaldet område ikke har nogen
kasse på en tom linje. Klemt inden for ruden, og vendt op over linjen, når der
ikke er plads under — ellers ville en note, der er skrevet helt ned i bunden,
lægge menuen uden for skærmen.

Og pak-noter-i-portioner.py, fordi en Apple Noter-eksport er hundrede og
enoghalvfems megabyte, hvoraf teksten er fire: importen tager 64 MB ad gangen, og
tunnelen foran den tager mindre. Delt efter hvad en note vejer med sine billeder,
og altid med billederne i samme portion som den note, der peger på dem.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 21:44:03 +02:00
Kristian WindandClaude Opus 5 9e687185fb feat(notes): vis noten som kode, og giv teksten en margen at ånde i
Release / Build and publish image (push) Failing after 9m59s
Kildevisningen, fordi den rige flade ikke kan tage imod Markdown. Indsat i den
er "## Overskrift" fem tegn og ikke en overskrift, og der var ingen vej ind for
tekst skrevet et andet sted — hvilket er en mærkelig mangel i et program, hvis
hele lager er Markdown. Knappen </> viser filen, som den ligger i databasen, og
det, man skriver der, går tilbage gennem den samme oversætter som altid.

Fyldt fra kilden og ikke fra note.body, når man går tilbage: gemningen venter på
sin pause, så den vej ville kaste det nyeste væk. Og kildevisningen hører til den
note, den blev slået til på — skifter man note, er det den rige flade, man møder.

Margenerne. Teksten lå helt ude ved kanten, og på en bred skærm blev linjerne
hundrede og fyrre tegn — dér mister øjet hvilken linje det var på, når det
springer tilbage. Arket har nu marginer, der vokser med ruden, og teksten en
grænse på 46rem. Kun teksten: værktøjslinjen og foden hører til ruden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 21:28:16 +02:00
Kristian WindandClaude Opus 5 8229800767 fix(notes): lister overlevede ikke en gemning, og tallene blev klippet af
Seks ting fundet ved at bruge appen, i den rækkefølge de gjorde ondt.

En liste inde i en blok blev læst som tekst. htmlToMarkdown havde én gren for
ul og ol og lod alt andet falde ned i inlineToMarkdown, som samler efterkommere
til én streng — så et blokcitat med en liste i kom tilbage som "> PunktPunkt 2",
og en note fra Apple Noter, der er fuld af citater, mistede hvert punktmærke ved
den første gemning. Ingen havde rørt de linjer. Nu læses en blok, der rummer
blokke, igennem frem for flad, og et citat sætter "> " foran det, der kommer
indefra.

Indlejrede lister fandtes ikke. Den flade `list`-variabel kunne kun beskrive ét
niveau, så et indrykket punkt blev lagt ved siden af sit ophav. Nu en stak, og
underlisten lægges inde i sit <li> — det er den forskel, der kan læses tilbage.

En liste, der begyndte ved ti, begyndte ved ét bagefter. `start` skrives nu med
og læses med, så en gemning uden ændringer ikke gør noten om.

Tallet blev klippet af. Mærket sættes uden for tekstkanten, og 16px rakte til et
punkttegn og ikke til "10." — resten hang ud over kanten, hvor .page beskærer.
Renden er nu 2.2em og deles af begge slags, så punkter og tal flugter.

# viste seks vilkårlige projekter ud af tyve og så færdig ud. Loftet er væk,
listen kan rulle, og det, der begynder med det tastede, står øverst.

Og to ting om, hvordan det ser ud. Noteruden har fået sin egen flade, fordi et
ark, der holder op et sted, skiller det, man skriver, fra det program, man
skriver det i — `--surface` frem for `#fff`, så de fem temaer hver får deres.
Noter stod for sig selv over en streg, hvilket sagde det rigtige og kostede
halvfjerds pixels; den står nu i rækken med de faste visninger og bærer et ark i
stedet for en prik, hvilket siger det samme gratis.

Prøven skriver de fem listeformer, gemmer gennem den rigtige editor og læser
filen bagefter.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 21:24:43 +02:00
Kristian WindandClaude Opus 5 a4b12f3601 feat(notes): listen som Apple Noters, ⌘K finder noter, og de kan trækkes
Release / Build and publish image (push) Failing after 9m52s
Rækken har nu titel i samme størrelse som en opgavetitel — en note og en opgave er
to ting af samme slags, og hver sin størrelse får den ene til at se mindre vigtig
ud. Under den står dato og begyndelse på samme linje, som Apple Noter gør det: to
linjer pr. note frem for tre, og datoen først, fordi det er den, man skanner efter.
Ligger noten i et projekt, står det nedenunder.

⌘K søgte kun i opgaver og projekter. Feltet ser ud til at kunne finde alt, så det
er svært at gætte, at halvdelen af programmet ikke var med. Og mens jeg var der:
"Projekt" og "Opgave" stod hårdkodet på dansk i paletten.

En note kan trækkes ind på et projekt i sidebjælken — samme mål som en opgave,
fordi det er samme gestus: læg denne her.

To fejl i søgefeltet, begge Safaris egen type="search":
Fokusringen tvang sin egen hjørneradius og tegnede en firkantet bue uden om en
rund kant — to streger, hvor der skulle være én. Den følger nu elementets form,
overalt i programmet. Og feltet skiftede udseende ved fokus: forstørrelsesglasset
forsvandt og pilleformen blev firkantet. Det tegnes nu af os.

Trækket og palettens noter er ikke fastholdt i en prøve. ⌘K, tastning og en
debounce oven på hinanden gjorde den upålidelig, og en flakkende prøve siger
mindre end ingen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 20:46:05 +02:00
Kristian WindandClaude Opus 5 43ad60a84a feat(notes): formateringen overlever, og monospace bliver til en terminal
Konverteringen læser nu Apple Noters HTML frem for at flade den ud med textutil.
Overskrifter, fed, kursiv, understreget, lister og monospace kommer med. En note er
ikke kun sine ord — et referat med overskrifter er ikke det samme referat som en
klump — og ti prøvenoter viste hvad det kostede.

Skrevet om til Python, fordi HTML skal læses og ikke gættes med regulære udtryk.

Den alvorligste fejl, prøvekørslen fandt: navn og krop blev hentet i hvert sit
kald, og Noter sorterer listen efter ændringsdato. Flyttede noget sig imellem de
to kald, fik en note én titel og en andens indhold. På ti noter er det synligt; på
tolv hundrede ville det være ubemærket rod. Der hentes nu ved id, og navn og krop
kommer i samme kald.

Sammenhængende monospace-linjer bliver til én blok. Hver linje i Noter er pakket
ind i sin egen div, hvis starttag tømmer den forrige — så mellem to tt-linjer kom
der altid en tom flush forbi, og hver linje i en terminaludskrift blev sin egen
blok.

Og i editoren ser en kodeblok ud som en terminal: mørk flade, monospace, sproget i
hjørnet, farver på strenge, tal, kommentarer, nøgleord og prompten. Monospace ER
kode i praksis, og en mørk flade siger det på afstand.

Farverne lægges på de blokke, markøren IKKE står i. At farve mens nogen skriver
betyder at bygge indholdet om ved hvert tastetryk og sætte markøren tilbage
bagefter — det virker ni gange ud af ti og flytter markøren midt i en sætning den
tiende.

Et API-token er ét ord, der er bredere end skærmen. Uden min-width: 0 skubbede en
sådan note hele listen bredere og klemte editoren sammen — på en note, man ikke
engang havde åben.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 20:31:10 +02:00
Kristian WindandClaude Opus 5 08fc355bf9 fix(tools): tre fejl, en prøvekørsel på ti noter fandt med det samme
Kodningen. textutil gætter på inddataets kodning og gætter forkert, så
"Ansøgning" kom ud som "Ansøgning". Det ser ud som én underlig note, indtil man
opdager, at det er hver eneste note med et dansk bogstav i.

Titlen stod to gange. Kroppen af en Apple-note begynder med notens egen titel, så
en overskrift ovenover gav den dobbelt overalt.

Og listerne var ikke lister. Apple skriver dem som tabulator, tegn, tabulator —
en liste for et menneske og ingenting for en Markdown-læser, så en huskeliste
ankom som en klump linjer. Punkttegnet er flerbyte, og BSD sed hakkede det midt
over, så hver linje begyndte med to halve tegn; perl med -CSD gør det rigtigt.

Scriptet tager nu et antal som andet argument, så en prøvekørsel er til at
gentage. Det er billigere at opdage den slags på ti noter end på tolv hundrede.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 19:37:52 +02:00
Kristian WindandClaude Opus 5 414a35eaba feat(notes): billeder og bilag følger med ind — og ud
Release / Build and publish image (push) Failing after 9m58s
Du spurgte, hvad der sker med bilag og indsatte billeder. Svaret var: de forsvandt
stille. Scriptet konverterede til ren tekst, og en note fra Apple Noter, der kommer
over som ord alene, er ikke den note — den er et referat af den.

En note kan nu bære filer, i samme tabel som opgaver og grupper allerede bruger.
Filerne er indholdsadresserede og deduplikeres på disken, upload-vejen er skrevet
og afprøvet, og oprydningen af forældreløse filer går allerede den tabel igennem.
En tabel mere ville være et alt-sammen mere.

Importen læser arkivet i to omgange. Alt, der ikke er Markdown, gemmes først, så
en note, der bliver læst, allerede har adresser at få sine links rettet til —
noter og billeder ligger i den rækkefølge, arkivet nu har dem, og ét gennemløb
ville efterlade halvdelen af henvisningerne pegende på ingenting.

Kun de filer, noten faktisk nævner. Et arkiv er én mappe til mange noter, og at
hænge hvert billede på hver note ville gøre hundrede noter til ti tusind bilag.

Hentningen af en note-fil gav 404, fordi adgangskontrollen ikke kendte noter som
forælder. Et billede midt i den tekst, der navngav det, svarede altså "findes
ikke". Den følger nu noten, som følger sit projekt — samme kæde som alt andet.

AppleScriptet gemmer nu hver vedhæftning og skriver en henvisning til den i noten.
Vedhæftninger uden fil bag sig — links til websider, kort — springes over frem for
at vælte kørslen.

Migration 0021.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 19:12:54 +02:00
Kristian WindandClaude Opus 5 11fdbefe1b feat(notes): hver note som en Markdown-fil, i et zip
Det er løftet, hele designet blev lagt til rette omkring: noten på disken ER den
fil, man ville eksportere. Der konverteres ingenting på vejen ud, så der er
ingenting, konverteringen kan tage fejl af — filerne åbner i Obsidian, i en
teksteditor, i hvad som helst.

To ting, en eksport kan tabe stille, og som prøven holder fast:

Et skråstreg i en titel ville lave en mappe på Mac og Linux og blive afvist på
Windows. De tegn, ingen af de tre vil have, bliver til bindestreger.

Og to noter med samme titel er to noter — et arkiv med den samme post to gange er
et arkiv, der har mistet den ene. Den anden får et nummer.

Fejler skrivningen midtvejs, stopper den frem for at rydde op. Svaret er allerede
på vej ud, så der er ingen statuskode tilbage at sende: et afkortet zip siger
selv, at det er afkortet, hvilket er bedre end en komplet fil med en note færre.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 19:03:25 +02:00
Kristian WindandClaude Opus 5 6e83291444 feat(notes): opgaven viser de noter, der nævner den — og har fået en adresse
Den anden vej af samme forbindelse. En note, der nævner en opgave, viste den; nu
viser opgaven noten. Uden det virker forbindelsen kun, hvis man tilfældigvis
sidder og læser noten, og det er den halvdel, ingen har brug for hjælp til.

Undervejs viste det sig, at en opgave slet ikke havde en adresse. Skuffen åbner
over den liste, man står på, og ændrer ingenting i URL'en — hvilket er rigtigt for
et klik og ubrugeligt for et link: man kan ikke sende nogen en opgave, og en note,
der nævner en, har ingen steder at sende dem hen. Min egen henvisningsform pegede
altså på noget, der ikke fandtes.

/opgave/{id} findes nu. Den åbner skuffen og træder til side — erstatter sig selv
med opgavens eget projekt, så man lander hvor opgaven bor, med den åben, og
tilbage-knappen ikke går gennem en side, der kun nogensinde viderestillede.

Prøven henter opgavens id fra API'et frem for fra adresselinjen, netop fordi den
ikke står der.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:59:34 +02:00
Kristian WindandClaude Opus 5 f6c60bdaf9 fix(notes): #garageristeriet og #GarageRisteriet er samme projekt
Release / Build and publish image (push) Failing after 9m58s
De var det ikke. Nøglen blev gemt præcis som skrevet og slået op på samme måde,
så en note, der nævnte et projekt med lille begyndelsesbogstav, dukkede simpelthen
ikke op på det — stille, og uden noget der antydede hvorfor.

Foldet på vej ind og foldet i opslaget, så sammenligningen bliver ved med at være
en nøjagtig lighed og bruger sit indeks. Havde man i stedet sænket kolonnen i
forespørgslen, skulle hele tabellen læses.

Kun projekter. En [[notetitel]] er en titel, ikke en nøgle: to noter, der hedder
"Møde" og "møde", er to noter, og at folde dem sammen ville slå noget sammen,
nogen havde holdt adskilt. Der er en prøve for begge dele.

Migrationen samler de rækker, der allerede er skrevet. Primærnøglen er
(note_id, kind, target_id), så en note, der skrev begge stavemåder, ville kollidere
med sig selv — derfor bygges sættet forfra med INSERT OR IGNORE frem for en
opdatering, der ville fejle på parret.

Fladen viser projektets egen stavemåde og ikke den foldede nøgle. En etiket, ingen
har skrevet, og som står hverken i noten eller på projektet, ville være værre end
ingen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:48:00 +02:00
Kristian WindandClaude Opus 5 5e670119db feat(notes): titlen er første linje, og # foreslår projektet
Release / Build and publish image (push) Failing after 9m59s
Titlen var udledt én gang og derefter ladt i fred, så den gik i stå i det øjeblik
nogen skrev begyndelsen om — en note med overskriften "Ny note" stod i listen som
"fdgfgfgh", hvilket var, hvad dens første linje havde været nogle rettelser
tidligere. Den udledes nu ved hver gemning, og ingen kan sætte den ved siden af
teksten: kolonnen er en kopi af kroppen, ikke et felt derudover. Feltet er væk fra
både HTTP-fladen og MCP-værktøjerne, for et felt, serveren ignorerer, er et løfte,
den ikke kan holde.

Skriver man #Kaf, foreslås #Kaffebaren. Ordet læses fra den tekstknude, markøren
står i, og ikke fra hele dokumentet — en note kan nævne tyve projekter, og kun det
under markøren er ved at blive skrevet. Listen ejer piletasterne og retur, mens
den er åben, som enhver anden fuldførelse.

Og under feltet står nu, hvad teksten forstår: # projekt, [[en anden note]], ⌘B,
⌘U. Samme idé som linjen under hurtig tilføjelse — en note er det andet sted i
programmet, hvor det, man skriver, bliver læst for betydning.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:36:31 +02:00
Kristian WindandClaude Opus 5 3223989a9d feat(notes): første linje er titlen, resten er brødtekst
Release / Build and publish image (push) Failing after 9m57s
En ny note åbner på sin titel, og linjen efter er brødtekst — uden at man vælger
noget. Det er det, Apple Notes gør, og det er også hvad listen har brug for:
første linje bliver notens navn, så en note, der begynder i brødtekst, hedder sin
egen første sætning.

To ting undervejs. Reserven for en tom note blev aldrig brugt, fordi en tom tekst
ikke gengives som ingenting men som ét tomt afsnit — den prøves nu på teksten og
ikke på resultatet. Og Chromium svarer på retur med et bart <div>, som læses som
brødtekst men ikke er formateret som det: afstanden mellem afsnit forsvandt
stille i det øjeblik, man trykkede retur frem for at vælge Brødtekst i menuen.

Listen viste desuden den rå Markdown i forhåndsvisningen — "# Møde med Anders" og
"**fed**" — hvilket er præcis det, editoren blev bygget for at holde op med at
vise. Den viser nu sætningen frem for notationen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:23:42 +02:00
Kristian WindandClaude Opus 5 f557d319ca feat(sidebar): de faste visninger kan sorteres, og rækkefølgen følger kontoen
Release / Build and publish image (push) Failing after 9m57s
I dag, Kommende, Venter og Indbakken kan trækkes i den rækkefølge, man vil have
dem. På kontoen frem for i browseren: sidebjælkens bredde er en kendsgerning om
skærmen, og svaret på en telefon er ikke svaret på en skærm — men hvilken
rækkefølge man vil have dem i, er en kendsgerning om personen.

En liste af nøgler frem for et tal pr. visning. Ukendte nøgler droppes og
manglende føjes til sidst, så en visning kan tilføjes eller fjernes uden at
strande nogens rækkefølge — hvilket allerede er sket én gang, da Færdige
forlod menuen.

To ting, prøverne fandt undervejs, og begge var mine:

Rækken som trækgreb gjorde alt indeni trækbart, og sidebjælken er allerede
modtager for opgaver — en opgave trukket til I dag holdt op med at lande, fordi
rækken havde taget gestussen. Et lille greb tager ingenting, før man griber det.
Og drop-håndteringen slugte begivenheden, før den tjekkede, om den overhovedet
var en menuflytning.

Migration 0019.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 17:19:46 +02:00
Kristian WindandClaude Opus 5 90bdf4b725 feat(look): en akse mere — hvordan det læses, ikke hvor lyst det er
Fire udseender ved siden af de fem temaer: Verdande, Rolig (serif, mere luft),
Tæt (flere rækker, mindre luft) og Terminal (én bredde til det hele). De ganges ud
til tyve udseender fra otte små blokke CSS frem for at kræve en blok hver.

To akser, fordi det er to spørgsmål. Den, der vil have en serif om natten, skal
ikke opgive den mørke palet for at få den — og prøven holder netop det fast: at
vælge en skrift må ikke trække temaet med sig.

Udseendet sættes før første optegning, ligesom temaet. Ellers bliver siden sat op
i én skrift og derefter i en anden, og det er et synligt spring.

Det er også det ærlige svar på at ligne noget andet. Standarden er bevidst
genkendelig — det var det, der blev bedt om — men et program med ét muligt
udseende har lånt en form, det ikke kan give tilbage. Med udseender er ligheden en
indstilling blandt flere frem for det eneste, det kan være.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 17:07:39 +02:00
Kristian WindandClaude Opus 5 1bbae698d5 feat(notes): en note deles ved at ligge i et projekt
Ikke en adgangsliste af sin egen. Projekter har allerede medlemmer, roller og
invitationer — alt sammen afprøvet, og alt sammen noget folk har lært her i
forvejen. En anden måde at give adgang på er et andet sted at gøre det forkert,
og de to ville være uenige den dag, den ene blev ændret.

To ting, prøven fandt, som begge var rigtige fejl.

Projektsiden viste kun noter, der NÆVNTE projektet, ikke dem, der lå i det — så
en note, man lige havde delt derind, dukkede ikke op. Nu vises begge slags.

Og noteoversigten viste kun dem uden projekt, så en note forsvandt fra listen i
det øjeblik, den blev delt. Det fik deling til at se ud som sletning. Listen viser
nu alt, man kan se: sine egne løse, og dem i de projekter, man er med i.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 17:03:28 +02:00
Kristian WindandClaude Opus 5 1b02eecdab feat(notes): rich text, med Apple Notes' egne stilarter
Man skriver nu i formateret tekst frem for i Markdown-kildekode. Titel,
Overskrift, Underrubrik, Brødtekst, Monotype, Blokcitat, punkt- og talliste, plus
fed, kursiv, understreget og gennemstreget — samme ordforråd som Apple Notes, for
det er det, en, der kommer derfra, allerede kan.

Filen under er stadig Markdown. Det er ikke et kompromis, nogen skal leve med i
stilhed: det er grunden til, at eksporten er til at stole på — filen, en note
bliver til, er den fil, den allerede var. Understregning er den ene undtagelse;
den findes ikke i noget Markdown-dialekt og gemmes som <u>, som enhver læser
sender uændret igennem. Det er bedre end at fjerne en stilart, kilden har.

contenteditable med execCommand. Den er forældet og også det eneste, alle browsere
er enige om; at skrive en markeringsmodel fra bunden er ugers arbejde og en ny
slags fejl netop dér, hvor tabt tekst er utilgiveligt.

Tre ting, prøverne fandt, som alle ville have kostet tekst:
Værktøjslinjen stjal markeringen, når man klikkede — knapperne tager ikke fokus
længere. Browseren skrev <span style="font-weight: normal"> for en ophævet fed,
hvilket en oversætter, der læser tags, taber; der bedes nu om tags, og styles
læses alligevel, fordi det ikke honoreres alle steder. Og markeringen med
Shift+Home tog hele dokumentet, ikke linjen.

Favoritter er stjernen på hver note. Noteoversigten har fået luft mod venstre
kant, og Gem og Slet er knapper frem for bare ord.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 16:58:53 +02:00
Kristian WindandClaude Opus 5 5757f06e7a feat(notes): man kan se hvad man skriver
Release / Build and publish image (push) Failing after 10m0s
Formatering vises, mens man skriver. Overskrifter, fed, kursiv, kode, citater,
punkter, #tags og [[links]].

Et spejl bag et gennemsigtigt tekstfelt — samme greb som hurtig tilføjelse bruger
til at vise, hvad parseren forstod. En contenteditable ville lade teksten style
direkte og ville også ødelægge fortryd, autokorrektur og IME. Det er en dårlig
byttehandel i det ene sted i programmet, hvor nogen skriver en halv time i træk.

Mærkerne bliver stående, dæmpede. Obsidian skjuler dem uden for den linje,
markøren står i; at dæmpe dem altid er en mindre idé, der ikke flytter tekst under
markøren mens den bevæger sig — og den overlever mødet med et tekstfelt.

Overskrifter skifter vægt og farve, aldrig størrelse: en større skrift ville fylde
flere linjer end tekstfeltet gør, og så skred alt nedenunder.

Hele risikoen ved grebet er, at de to lag skal flugte tegn for tegn. Prøven måler
det frem for at stole på det: skrifttype, linjehøjde, ombrydning, polstring og den
faktiske højde af de to lag med samme tekst i.

Undervejs: kursiv-passet slugte " og " efter et fed-udtryk, fordi den løse stjerne
stadig var synlig for det. Hvert pas læser nu en kopi, hvor de tidligere pas'
tegn er blændet ud.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:38:18 +02:00
Kristian WindandClaude Opus 5 5f04fe56bc feat(notes): note-værktøjer i MCP, og sidebjælken ryddet op
Claude kan nu skrive og søge i noter gennem MCP. Det er også svaret på Apple
Noter: der findes ingen eksport, der er værd at bruge, men en model, der kører på
maskinen, kan læse Notes.app og skrive det, den finder, ind herigennem — én note
ad gangen, med teksten som Markdown. En flytning, ingen skal bygge en parser til.

Værktøjet melder tilbage, hvilke links noten fik. En note, hvis #tag var stavet
forkert, ser ellers præcis ud som en, hvor det ramte, og i en chat ser ingen den
række, der blev skrevet.

Noter står nu for sig selv med en streg over. De er ikke en femte måde at se de
samme opgaver på, hvilket er hvad alt ovenfor er — de er den anden halvdel af
programmet, og i den række læste de som endnu et filter.

Færdige er ude af menuen og ligger under Indstillinger → Data ved siden af
papirkurven. Argumentet for at have den fremme var, at man ikke ved, hvilken
visning man lukkede noget i — det er sandt, og det er også et sjældent ærinde ved
siden af fire punkter, der læses hver dag. Den er der stadig, med en beskrivelse
af hvad den er til.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:33:54 +02:00
Kristian WindandClaude Opus 5 a2a39c4e17 feat(notes): en flade at skrive i, og noten dukker op i projektet
Release / Build and publish image (push) Failing after 9m55s
Tredje etape. En liste til venstre, noten til højre — ikke et gitter af kort: en
note læses langt oftere på sin første linje end i sin helhed, og en liste viser
tyve af dem, hvor et gitter viser seks.

Gemmes når man holder op med at skrive. En note er det ene sted i programmet, hvor
en times skrivning kan gå tabt på ét fejltryk, og en Gem-knap er en knap, der
findes for at blive glemt. Teksten holdes adskilt fra den note, den kom fra, så en
gemning, der lander sent, ikke kan overskrive det, der er skrevet siden.

Og udbetalingen: skriver man #GarageRisteriet i en note, står den på projektets
side. Ingen har filet den derhen — den dukker op, fordi nogen nævnte projektet
mens de skrev. Slået op på navnet og ikke på et id, for et #tag skrives i hånden,
og et menneske skriver navnet.

Markdown i et almindeligt tekstfelt indtil videre. Live preview, hvor mærkerne kun
vises i den linje markøren står i, er næste stykke og det dyre; det her er den
form, den skal sidde inde i, og alt andet er allerede sandt uden den.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:07:49 +02:00
Kristian WindandClaude Opus 5 fbfc98e217 feat(notes): noter kan nås — listet, søgt og fundet baglæns
Anden etape. Handlere, ruter og OpenAPI.

Adgang følger projektet, for det er der, noten bor: en note i et delt projekt kan
læses af alle, der kan læse projektet, og ændres af en redaktør. En note uden
projekt er sin forfatters alene — samme svar som en opgave i indbakken får, nået
frem til på samme måde frem for ved en regel, noter fandt på for sig selv.

Listning og søgning er samme endepunkt, fordi de svarer på det samme spørgsmål
med forskellige ord. Filtreringen sker EFTER indekset: FTS5 svarer om tekst, og
at lære indekset om roller ville lægge en adgangsregel et sted, ingen ville kigge
den dag den var forkert. Der er en prøve for, at en fremmeds søgning ikke finder
noget, og at en fremmed får 404 og ikke 403 — så et id ikke kan afprøves.

Den baglæns rute hænger på det, der spørges om: et opgave-id giver hver note, der
nævner den; et projektnavn giver hver note med det #tag.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 13:00:41 +02:00
Kristian WindandClaude Opus 5 98ea08b0bb feat(notes): fundamentet — Markdown, søgning og links begge veje
Release / Build and publish image (push) Failing after 10m3s
Første etape af noter. Tabel, CRUD, fuldtekstsøgning og de links, en note bærer.

Markdown i en kolonne, ikke rich text og ikke et format af vores eget. Eksport
var et krav, før der var skrevet en linje, og er den gemte form den eksporterede
form, er der ingen konvertering tilbage at tabe noget i — noten kan læses af
enhver anden editor den dag programmet ikke findes.

Et #tag i en note er DET SAMME tag som i en opgave. Ikke en anden slags, der
ligner. Det er hele svaret på "tag en note og se den i projektet": referencen er
den samme, så der ikke opstår to systemer, brugeren selv skal holde i sync.

Links gemmes som rækker, udledt af teksten ved hver gemning. Teksten er stadig
sandheden; tabellen er et indeks over den. Uden det ville "hvad peger på denne
opgave" betyde en gennemsøgning af al prosa, og den rude ville være for langsom
til at lade stå åben. De rives ned og bygges op frem for at blive sammenlignet:
en sammenligning er en anden beskrivelse af samme kendsgerning, og den kan komme
til at være uenig med teksten.

Noter ligger under projekter og arver deling, roller, papirkurv og historik. En
mappestruktur ved siden af ville være ét hierarki for meget.

MatchExpr fik navngivne kolonner. Den var skrevet til opgavernes og gav
"no such column" mod noternes — hvilket i det mindste er højlydt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:47:50 +02:00
Kristian WindandClaude Opus 5 c44099cac0 feat(site): vælg lys eller mørk, og skærmbilledet følger med
Siden fulgte systemet og kunne ikke andet. Nu er der en knap, valget huskes, og
skærmbilledet øverst skifter med — der ligger både en mørk og en lys udgave af
det samme, og det er kejtet at vise et mørkt skærmbillede på en lys side.

Farverne står to gange med vilje. Mediaforespørgslen er hvad en førstegangsbesøgende
får; attributten er hvad en, der har valgt, får — og den skal vinde begge veje, både
et valgt lyst tema på et mørkt system og omvendt. Én regel kan ikke gøre begge dele.

Begge skærmbilleder ligger i markuppen og skjules med CSS, så ingen af dem skal
hentes i det øjeblik nogen trykker på knappen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:36:11 +02:00
Kristian WindandClaude Opus 5 86ec0ce2d9 fix(imap): markøren er vores, ikke serverens
Samme mail blev en opgave hver tiende minut, og markøren stod stille på 1271.
Målt i produktion: en manuel synk lavede én opgave og flyttede ikke last_uid en
eneste plads.

Årsagen er ikke vores regnestykke, men hvad søgningen svarer. Vi beder om
1272:* — og en rigtig server læser det som 0:1272 og sender hele postkassen
tilbage. Alt, hvad den finder, ligger så under markøren, og markøren kan derfor
aldrig komme videre.

Uid'er ved eller under markøren frasorteres nu hos os. Det koster ét gennemløb
af nogle hundrede tal og afhænger ikke af nogen andens læsning af specifikationen.

De to tidligere forsøg var ikke forkerte, de var bare ikke årsagen: at tage
uid'en fra søgningen frem for fra fetch-svaret, og at give hver postkasse sin
egen lås, var begge reelle fejl. Denne er den, der fik den til at gentage sig.

Prøven læser en postkasse med markøren over alt i den og kræver nul tilbage —
og gentager det for hver enkelt besked.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:33:30 +02:00
Kristian WindandClaude Opus 5 62498c2765 feat(sidebar): tallet ved et projekt er opgaver, og folden har en tast
"Hvorfor står der 2 ud for et tomt projekt?" Fordi tallet var member_count — hvor
mange mennesker. Den samme grå kolonne betød tre ting: personer ved projekter,
projekter ved grupper, opgaver ved etiketter. I en opgaveapp læses et tal ved
siden af et projekt som opgaver, og det gjorde manden, der har skrevet appen.

Nu er det åbne opgaver, og det vises når der er noget tilbage frem for når
projektet er delt — som var den gamle betingelse, og grunden til at netop de tre
delte havde et tal. Hvem der ellers er med står på projektets egen side.

Tællingen hentes fra serveren igen, når opgaver flytter sig. Det ville være
hurtigere at lægge til og trække fra lokalt, og det ville være forkert inden for
en dag: en opgave flyttet mellem projekter, en lukket i en anden fane, en
gentagende der lukker og åbner sig selv.

Og sidebjælken kunne allerede foldes — der var bare kun en knap med et rektangel
med en streg i, og det er ikke et ord. Nu gør ⌘B det samme, knappen siger det, og
genvejssiden nævner det.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:28:43 +02:00
Kristian WindandClaude Opus 5 997dffe01c docs: skriv det, der blev bygget, og ret det, der ikke passer længere
To nye sider. Postkasser: at stjerne i Gmail eller flage i iCloud og Fastmail
bliver til opgaver, at hver postkasse hører til den, der forbandt den, at
app-kodeord er noget andet end ens rigtige, og at Office 365 ikke kan nås ad den
vej — sidstnævnte skrevet ned frem for at blive opdaget ved at prøve. Og at logge
ind: kodeord, totrinskode og passkeys, med hvorfor en passkey ikke kan laves på
en bar IP-adresse.

Konfigurationen manglede VERDANDE_SECRET_KEY, som er den, det koster mest ikke at
vide noget om: mister man nøglen, skal postkasserne forbindes forfra. Begrundelsen
står med — det er kopierne, ikke serveren, der er problemet, for de kan hentes
gennem fladen.

Og genvejsafsnittet i quick-add beskrev q, t og u, som ikke findes længere. En
dokumentation, der lover taster, der ikke gør noget, er værre end ingen: man
prøver, der sker intet, og så tror man appen er i stykker.

Bygget med --strict; alle krydshenvisninger rammer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:25:54 +02:00
Kristian WindandClaude Opus 5 d02d8edfe9 feat(site): klik et skærmbillede og se det i læsbar størrelse
Release / Build and publish image (push) Failing after 10m5s
Et skærmbillede af en brugerflade siger ingenting ved 281 pixels, og det er der,
de fleste af dem sidder i gitteret. Et klik åbner nu det samme billede omkring
tre en halv gang større.

<dialog> frem for et selvbygget lag: Escape lukker, fokus bliver inde i den,
resten af siden bliver inert — tre ting browseren gør gratis og som et selvbygget
overlay skal have kode for hver af. Billedet får rolle og tabindex, så det også
kan åbnes fra tastaturet; en kontrol, kun en mus kan nå, er ikke en kontrol alle
har.

To ting rettet undervejs, som først kunne ses ved at kigge: billedet fik lov at
blive højere end skærmen, så billedtekst og lukkeknap røg op over kanten — og
lukkeknappen er den synlige vej ud. Og dialogen fyldte hele bredden i stedet for
at slutte om billedet, så skærmbilledet stod op ad venstre kant med knappen ude i
det modsatte hjørne. Nu er den fit-content og centreret, og alt er inden for
vinduet ned til en telefonbredde.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:14:13 +02:00
Kristian WindandClaude Opus 5 134fe8918e chore: hold overleveringen uden for repoet
Den navngiver panelet, server-id'et og produktionsadressen — et kort over én
persons opsætning, og det hører ikke til i et offentligt repo. Ingen adgangskoder,
men et server-id og en panel-adresse er præcis det, et lækket token skal bruge for
at være farligt.

Filen er fjernet fra hele historikken, ikke kun fra spidsen: at slette den i en ny
commit ville have efterladt den læselig i alle de gamle. Den ligger stadig på
disken, for det er sådan en session afleverer til den næste.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:08:08 +02:00
Kristian WindandClaude Opus 5 3fd16f71df fix(reminders): en påmindelse, ingen modtager, er ikke en påmindelse
Påmindelser blev sendt i appen og på mail. Har man fanen lukket og ingen
mailserver sat op — hvilket er præcis det, docker-linjen i dokumentationen giver
— kom der ingenting, mens indstillingssiden sagde at notifikationer var slået til
for enheden.

Push-halvdelen var aldrig hægtet på. Notifikationer fra andres handlinger gik ud;
kun de tidsstyrede gjorde ikke, og det er dem, man har slået til for at slippe for
at holde øje selv. Jobbet har nu en vej til at sende, leveret af HTTP-laget, som
ejer VAPID-nøglerne og listen over enheder — samme mønster som synkroniseringerne.

Prøven ser efter, at den når frem, og at den kun når frem én gang: en påmindelse,
der ankommer ved hver fejning, er værre end en, der ikke ankommer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 11:00:48 +02:00
Kristian WindandClaude Opus 5 37605ba684 feat(site): landingssiden siger hvad der findes, og vejer en femtedel
Skærmbillederne lå som PNG i fuld skærmopløsning — 2,1 MB for syv billeder på en
side, der viser dem under 900 pixels bred. Som WebP i dobbelt visningsbredde
fylder de 424 kB. Samme billeder, samme skarphed, en femtedel af vægten.

Indholdet fangede aldrig op på det, der blev bygget: postkasser over IMAP ved
siden af Gmail, passkeys, at man kan skrive hvor som helst for at fange noget, at
hemmeligheder ligger krypteret med nøglen uden for databasen, og historikken.
Ni felter nu, hvilket også fylder gitteret ud i stedet for at efterlade et tomt.

Og en fejl, jeg selv lavede undervejs: jeg lagde en description-meta ind, fordi
min søgning ikke så den, der lå i forvejen — den var delt over flere linjer.
Dubletten er væk, og den oprindelige er opdateret i stedet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 10:52:20 +02:00
Kristian WindandClaude Opus 5 6e941f9928 fix(mailboxes): to kørsler af samme postkasse må ikke læse den samme markør
Fyrre kopier af én mail ankom i samme sekund. Én kørsel kan højst lave femogtyve,
så der var to i gang: et tryk på "Hent nu" mens ti-minutters-fejningen allerede
var inde i den samme postkasse. Begge læste markøren som den stod, begge hentede
de samme beskeder, begge lavede opgaver ud af dem.

Markør-rettelsen fra i går var altså rigtig, men ikke nok — den virker, når der
kun er én læser. Rækken læses nu igen under låsen, og låsen holdes hele vejen
igennem: slap man den, før markøren var skrevet, ville hullet stå åbent præcis
der, hvor det skulle lukkes.

Et kort pr. postkasse frem for én lås. To menneskers postkasser har intet at sige
hinanden, og én fælles lås ville gøre én langsom vært til alles problem.

Prøven kører to kørsler oven i hinanden med vilje og tæller, hvor mange der er
inde ad gangen — en lås er kun værd at have, hvis noget prøver døren. Den holder
også øje med, at to forskellige postkasser ikke venter på hinanden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 08:57:59 +02:00
Kristian WindandClaude Opus 5 993822f6fd fix(panel): bed om en planlagt genstart, ikke en der dør med afsenderen
Release / Build and publish image (push) Failing after 10m6s
Knappen tog produktionen ned og lod den ligge. Panelets almindelige restart
udfører arbejdet mens forespørgslen står åben: den stopper containeren — hvilket
slår denne proces ihjel — og panelet, der ser afsenderen forsvinde, når aldrig
til den halvdel, der starter igen. Fire minutters nedetid den 18. august, og det
kan ikke lappes herfra: enhver knap, der synkront beder om sin egen død, har
samme form.

safe-restart planlægges i stedet. Panelet svarer "Restart scheduled" og gør
arbejdet bagefter for egen regning, så intet afhænger af, at denne proces stadig
er i live til at høre svaret.

Prøven hang bagefter, og det var prøvens skyld: dens falske panel læste aldrig
forespørgslens krop, og der er en nu. En server, der ikke læser hvad den fik,
opdager aldrig at afsenderen har lagt på — så httptest ventede på en handler, der
aldrig vendte tilbage, og hele binæren løb tør for tid i stedet for den ene
forespørgsel.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 23:15:10 +02:00
Kristian WindandClaude Opus 5 b0f78d1549 feat(gmail): Gmail flytter ind i postkasse-tabellen
Ét sted at spørge "hvilke postkasser har den her person". Gmails tokens, udløser,
etiket, set-liste og målprojekt ligger nu som en række i mailboxes ved siden af
dem, der læses over IMAP, og baggrundsjobbet fejer én gang i stedet for to og
skifter vej efter rækkens kind.

PKCE-værdierne bliver i user_settings. De hører til ét forsøg på at logge ind,
ikke til en postkasse, og de skal væk igen når forsøget er ovre.

Målprojektet fik sin egen kolonne, som IMAP-postkasser også får glæde af — en
arbejdspostkasse, der filer i arbejdsprojektet, er det åbenlyse at ville have.
Ikke en fremmednøgle: sletter man projektet, skal destinationen stille holde op
med at gælde, ikke afvise sletningen eller tage postkassen med sig.

Flytningen kunne først køre nu. Den tømmer indstillingsrækken, og havde den ligget
i v0.14.0, mens handlerne stadig læste den, var en fungerende postkasse blevet
koblet fra. Værdierne kopieres som chiffertekst — begge tabeller forsegler med
samme nøgle — så migrationen behøver ikke nøglen, hvilket en migration heller
ikke har noget at gøre med at holde.

Der er en prøve for præcis dét: en Gmail forbundet før flytningen, med et forseglet
token skrevet som indstillingstabellen skrev dem, kommer ud på den anden side med
token, udløser, adresse, målprojekt og set-liste i behold.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:59:40 +02:00
Kristian WindandClaude Opus 5 f3fecbd0b4 fix(imap): samme mail blev en ny opgave hver gang, og uddraget var rå MIME
To fejl fra ét skærmbillede.

Markøren stod stille. Den blev sat ud fra uid'en i fetch-svaret, og den kommer
ikke fra alle servere — så next-run læste fra nul hver gang. Den sættes nu ud fra
hvad SØGNINGEN fandt, når kørslen kom hele vejen igennem: så er alt op til det
punkt betragtet, uanset hvad fetch-svaret bar med. En afbrudt kørsel flytter kun
til den sidste, den faktisk lavede en opgave af. Prøven mod hukommelsesserveren
ville aldrig have fanget det, for den svarer altid med uid.

Og uddraget hentede BODY[TEXT], som ved multipart er grænser og dele-hoveder og
det hele — derfor stod der "--=_be71cb39… Content-Transfer-Encoding: 8bit" foran
teksten i hver eneste opgave. Nu hentes hele beskeden og MIME-strukturen gås
igennem: text/plain foretrækkes, HTML er reserven med mærkerne pillet af. Groft,
for det er linjen under en overskrift og ikke en gengivelsesmotor.

Multipart-prøven er sat på prøve: med den gamle hentning fejler den med præcis
den tekst, skærmbilledet viste.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:44:00 +02:00
Kristian WindandClaude Opus 5 30adc69cb5 feat(mailboxes): plads til Gmails udløser og dens set-liste
Kolonnerne, ikke flytningen. Gmail-handlerne læser stadig user_settings, og en
migration, der tømte den tabel før de var skrevet om, ville koble en fungerende
postkasse fra. Det additive først; flytningen når koden på den anden side er klar
til den, og den ligger som PENDING_gmail_move.sql.txt uden for .sql-mønstret,
så den ikke kan køre ved et uheld.

Udløseren fik sin egen kolonne frem for at blive pakket ind i label med et kolon.
En kolonne, der rummer to ting adskilt af et tegn, er en kolonne, nogen læser
forkert senere — og en etiket med kolon i navnet ville gøre det på første dag.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:38:45 +02:00
Kristian WindandClaude Opus 5 c9dfffd95a feat(mailboxes): postkasser kan forbindes fra fladen
Release / Build and publish image (push) Failing after 9m59s
Panelet under Integrationer: tilføj, hent nu, kobl fra. Sit eget frem for en
tilstand mere inde i Gmail-panelet, for de to er ikke det samme i forskellige
klæder — det ene er en app, man logger ind igennem, det andet et kodeord, man
selv holder.

Feltet hedder IMAP-server og ikke Server: der stod i forvejen Server på CalDAV
længere nede, og to felter med samme navn på én side er tvetydige også for en
skærmlæser. Testen fandt det, men det var ikke kun testens problem.

To vagter fangede mangler undervejs, og begge havde ret: mailbox_failed skulle
have en tekst i fladen, ellers ville nogen få serverens engelske logsprog at se,
og de tre ruter skulle i openapi.yaml. Der er nu et Mailbox-skema samme sted, som
siger hvad der ikke kommer tilbage — kodeordet og eventuelle tokens.

E2E-prøven forbinder til noget, der ikke lytter, og ser efter værtens egen
afvisning på skærmen. Den er samtidig prøven på at 422 var det rigtige valg: var
det stadig 502, ville et mellemled kunne kaste beskeden væk, og der ville stå
"noget gik galt".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:22:31 +02:00
Kristian WindandClaude Opus 5 6b4ef5b2f2 feat(imap): postkasser kan læses, og det er bevist mod en rigtig server
Handlere til at forbinde, koble fra og hente nu, plus baggrundssynk hvert tiende
minut ved siden af Gmails. Forbindelsen prøves FØR den gemmes: en postkasse, der
blev taget imod og så fejler stille hvert tiende minut, er værre end en, der blev
afvist ved døren, mens man stadig sidder med kodeordet i hånden.

Ingen "set"-liste til deduplikering som Gmail har. Uid'er er monotone, så
last_uid er hele svaret — og den skrives også, når budgettet løb ud, for en
kørsel, der lavede opgaver og glemte hvor langt den nåede, laver dem alle igen
næste gang.

Rundturen køres nu mod go-imap's egen server i processen: flaget post kommer med,
uflaget gør ikke, og fortsættelsen fra et uid henter kun det nyere. De hidtidige
prøver dækkede de rene dele og ville alle have bestået med selve hentningen i
stykker.

Testen kræver et selvsigneret certifikat, og klienten nægter dem. Account.RootCAs
lader en test TILFØJE tillid; der er stadig ingen måde at fjerne den på. Forskellen
er en søm og ikke et hul — en prøve viser, at samme server afvises uden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:15:50 +02:00
Kristian WindandClaude Opus 5 1e59886d86 feat(mailboxes): en tabel, så I kan have hver sin postkasse — og flere hver
Gmail lå i user_settings under en scope. Den tabel har user_id som hele
primærnøglen, så den rummer præcis én scope pr. person, og dens upsert skriver
scope-navnet om ved konflikt: at lægge IMAP ved siden af ville have slettet
Gmail-forbindelsen uden at sige det. Migration 0011 advarer ordret mod netop det,
og advarslen var tæt på at blive gået i for anden gang.

Formen var forkert alligevel. Man har postkasser, flertal — en arbejds-Gmail og
en privat iCloud er det almindelige tilfælde, ikke det sære. En række hver siger
det. Og den svarer på spørgsmålet, der satte det i gang: hver postkasse hører til
en bruger, så to mennesker på samme instans forbinder hver sin og ser kun sin
egen. Det eneste, der bliver fælles, er Googles OAuth-klient, og det er rigtigt:
den er appen, ikke kontoen.

Kodeord og tokens skrives forseglet. Forseglingen i notifications.go dækker kun
indstillingstabellerne, så denne tabels metoder kalder den i hånden — værd at
vide, før der kommer en tredje hemmelig kolonne.

Gmail bliver hvor den er indtil videre. Den virker, og at lægge en flytning af en
kørende forbindelse i samme ombæring som en ny integration gør begge dele sværere
at efterprøve. Skemaet har allerede plads til den.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:10:20 +02:00
Kristian WindandClaude Opus 5 cbe66d75a5 feat(capture): skriv hvor som helst, og se hvad feltet forstår
Release / Build and publish image (push) Failing after 10m4s
To ting fra Doda. Begynder man at skrive et sted i appen, åbner hurtig tilføjelse
med bogstavet allerede i feltet — ingen knap at ramme først, og tanken skal ikke
overleve en rejse hen for at finde et sted at ligge. Og under feltet står nu, hvad
parseren kan læse: #projekt, /sektion, @etiket, p1, en dato. Det stod i
pladsholderen før, som er det ene sted en hjælp ikke kan stå: den forsvinder ved
første anslag, altså præcis når den begynder at være nyttig.

Prisen er de enkelte bogstaver som genveje. Ét tastatur kan ikke både stave "tal
med Anders" og springe til I dag på samme t, og det, man gør halvtreds gange om
dagen, er at fange. Navigation ligger i paletten, som kan mere og koster ét
anslag. Genvejssiden er rettet, så den ikke lover q, t og u.

Undervejs, i den rækkefølge testene fandt dem: reglen var først "er det et
tekstfelt?", hvilket stjal bogstaver fra en fokuseret knap eller liste — den er nu
"har noget som helst fokus?". Hjælpelinjen lå i flowet og skubbede hele listen ned,
når feltet fik fokus, så klik landede en række forkert; tolv tests fangede det.
Den svæver nu, og efter at have svævet ovenpå den første opgave og slugt dens klik
tager den ikke længere imod pegeredskaber.

Og en fejl, der allerede lå der: q slog feltet op på aria-label="Ny opgave", så
genvejen gjorde stille ingenting for enhver, der kørte fladen på engelsk. Feltet
findes nu på et mærke, ikke på en oversat tekst.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 21:45:31 +02:00
Kristian WindandClaude Opus 5 a38a79e9b6 feat(secrets): tokens og kodeord forlader ikke længere huset med en kopi
Gmails refresh-token lå i klartekst i databasen. Det er ikke filen på serveren,
der er problemet, men kopierne af den: sikkerhedskopier kan hentes ned gennem
fladen, så hver eneste kopi, der er taget indtil nu, bar et levende token.

Derfor ligger nøglen uden for databasen — i miljøet, eller i en fil ved siden af
dataene med tilstand 0600. VAPID-parret er præcedens for en hemmelighed,
instansen selv laver og gemmer i basen, men det mønster dur ikke her: en nøgle i
samme fil som det, den låser, er ingen lås. Backup er VACUUM INTO af selve
databasen, så filen følger ikke med.

Kun de navngivne felter forsegles — refresh_token, access_token, password,
client_secret. Resten af klatten er det, man læser, når en postkasse driller:
hvilken mappe, hvilken etiket, hvilken adresse. At kryptere det med ville ikke
købe noget og ville koste muligheden for at kigge.

Ingen migration, og ingen der skal forbinde forfra: en værdi uden mærket læses
som sig selv, og næste skrivning lægger den forseglet på plads. Kolonnen
konverterer sig selv efterhånden som den bruges, i stedet for i én omgang, der
skal have ret om hver eneste række på én gang. Der er en prøve for netop det.

Fladen siger det på siden, hvor man henter kopien — for den dag nøglen betyder
noget, er den dag, man gendanner et andet sted og undrer sig over, at postkassen
er tavs. Runen er på v4; en ny variabel uden et versionsspring er usynlig.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 21:18:48 +02:00
Kristian WindandClaude Opus 5 aabbbac583 feat(imap): en postkasse, der ikke er Gmail
Første del af IMAP-understøttelsen: selve klienten, som sit eget hjørne uden
tråde ud i noget bestående. Den logger ind over TLS, henter de flagede beskeder
nyere end et givet uid, og giver dem tilbage i samme form som Gmail-klienten —
afsender, emne, uddrag — så den, der senere kalder dem, kan få at vide hvor der
skal kigges og ikke hvordan.

Flagede og ikke ulæste: ulæst er en tilstand, ejeren stadig bruger, og en synk,
der brugte den op, ville tage noget fra ham. Det er samme begrundelse som stjerner
på Gmail-siden.

TLS er ikke valgfrit, og der er ingen kontakt til at slå det fra. Det her bærer et
kodeord, der åbner en hel postkasse, og en kontakt til at fjerne beskyttelsen er
en kontakt, der en dag bliver fundet allerede slået fra.

iCloud og enhver almindelig udbyder taler det her. Office 365 gør ikke længere —
Microsoft har lukket for kodeord på IMAP og vil have sit eget OAuth — så det er
ikke en vært, man kan nå herfra, og at lade som om ville kun fejle senere og
mindre tydeligt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 20:21:45 +02:00
Kristian WindandClaude Opus 5 c151d6c98c feat(groups): omdøb med dobbeltklik, og slet der hvor man kan se hvad man sletter
Release / Build and publish image (push) Failing after 9m59s
De to ord "Omdøb" og "Slet" lå oven på gruppens navn, hver gang musen var i
nærheden. De er væk fra sidebjælken. Omdøbning starter nu ved dobbeltklik på
rækken — eller F2, hvis man har den i fokus og ikke vil bruge musen. Sletning er
flyttet til gruppens egen side, hvor projekterne, den ikke tager med sig, står på
skærmen ovenover knappen.

Dobbeltklik på et navn, der også er et link, er ikke gratis. Første forsøg lod
det første klik navigere, og så åbnede siden mens omdøbningsformularen kom frem —
farveprikkerne flyttede sig ud under markøren, hvilket testen fangede som "element
is not stable". Navigationen venter derfor to tiendedele på et muligt andet klik.
Det er under grænsen for et dobbeltklik og over grænsen for at mærke det.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 20:08:24 +02:00
Kristian WindandClaude Opus 5 bd5e86dd03 fix(errors): sig fejlen i en kode, mellemleddet ikke kaster væk
Serveren svarede 502 med en forklaring, og Cloudflare erstattede kroppen med sin
egen side. Bevist i produktion: handleren skrev 122 bytes JSON på 38 ms med
teksten "the panel refused: HTTP 401", og browseren fik en HTML-fejlside. Det
gjaldt også Gmail, som bruger samme hjælper — derfor har fladen hele tiden kunnet
sige "noget gik galt" og ikke andet, selv om serveren vidste præcis hvad der var
sket.

En proxy har lov til at gøre det ved en origin-5xx. Så det er ikke Cloudflare,
der skal laves om: 502 var den pæne kode, ikke den brugbare. Upstream-afvisninger
er nu 422, som går uændret igennem. Navnet StatusUpstreamRefused står ét sted med
grunden, så det ikke bliver rullet tilbage af en, der med rette synes 502 lyder
mere rigtigt.

Fladen læste altid fejlkoden i kroppen og ikke statuskoden, så der var intet at
rette dér.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 19:17:49 +02:00
Kristian WindandClaude Opus 5 bb25b94f96 fix(sidebar): en gruppe står nu hvor projekterne står, og hedder hele sit navn
To fejl i samme række. "Omdøb" og "Slet" lå i flowet og optog deres bredde også
usynlige, så ARBEJDE blev tegnet som "ARBE"; de ligger nu over rækkens højre
ende og dukker op ved hover. Og chevronen sad før navnet og skubbede en gruppe
toogtyve pixels længere ind end de projekter, den indeholder — så en gruppe så
ud til at ligge under dem. Den deler nu plads med gruppens prik: prikken hviler,
chevronen tager over ved hover, og bliver stående mens gruppen er foldet.

De sidste otte pixels sad i a-reglen, som giver hvert link i sidebjælken sin
egen venstrepolstring oven i rækkens.

Prøven, der holdt øje med rækken, målte at alt lå side om side — hvilket var
netop den geometri, der skulle laves om. Den måler nu det, der blev bedt om:
at navnet kan læses helt, og at gruppens prik står præcis hvor et projekts gør.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 18:10:34 +02:00
Kristian WindandClaude Opus 5 dab1827c14 fix(i18n): en engelsk flade sagde "2 opgave(r) hentet"
Release / Build and publish image (push) Failing after 10m6s
Beskeden efter "Fetch now" stod på dansk uanset sprog, og med et "(r)" i stedet
for rigtig bøjning. Den bruger nu t() og plural() som resten.

Vagten burde have fanget den og gjorde det ikke: den krævede et anførselstegn
lige efter app.toast(, og denne stod som en skabelonstreng inde i en ternær hen
over to linjer. Den læser nu hele argumentlisten frem til den matchende
slutparentes, så det er formen på kaldet og ikke tilfældig placering, der afgør,
om en streng bliver set. Sat på prøve ved at lægge den gamle tekst tilbage:
begge strenge blev fanget.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 15:14:37 +02:00
Kristian WindandClaude Opus 5 fe7ce5c853 test(panel): vis hvad genstart-kaldet gør, når panelet tier eller siger nej
Et 502 i produktion så ud som om kaldet til panelet hang. Det gør det ikke: et
panel, der aldrig svarer, giver 202 efter ti sekunder, og et panel, der afviser,
giver 502 med sin egen statuskode i teksten. Begge dele står nu som prøver, så
næste gang mistanken falder på handleren, kan den frikendes uden at gætte.

Hvad der så gav Cloudflares 502 i produktion, er stadig uafklaret.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 15:05:10 +02:00
Kristian WindandClaude Opus 5 a00cf39558 fix(gmail): "hent nu" svarer selv, i stedet for at lade Cloudflare gøre det
Release / Build and publish image (push) Failing after 9m59s
En postkasse, der ikke svarer, holdt hele forespørgslen åben. Efter hundrede
sekunder gav Cloudflare op og sendte sin egen 502-side tilbage — HTML uden
fejlkode, så fladen kunne kun sige "Noget gik galt", og fejlloggen var tom,
fordi handleren aldrig nåede at vende tilbage.

Synk får nu sit eget budget på femogtyve sekunder. Løkken tjekker fristen mellem
hver besked, og hver opgave gemmes efterhånden som den laves, så en overskredet
frist er en kortere kørsel og ikke en tabt. Baggrundsjobbet beholder den fulde
tid; det har ingen, der venter.

Endepunkterne kan nu pege et andet sted hen, så testen kan stille en postkasse
op, der aldrig svarer, og se hvor lang tid der går. Budgettet kan sættes, så
den prøve koster et sekund frem for femogtyve.

Et budget på nul betyder standarden — ikke ingen tid. Uden det ville en
opsætning, der ikke satte feltet, lade fristen udløbe før første kald og gøre
hver eneste synk til et stilfærdigt nul.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 13:43:48 +02:00
Kristian WindandClaude Opus 5 c4e480a0c4 fix(gmail): stop hiding why it failed, twice over
Release / Build and publish image (push) Failing after 10m6s
Reported from use: "Fetch now" said "something went wrong" and the error log was
empty. Both halves were the same bug wearing two hats.

`handleGmailSyncNow` answered 502 with the code `internal_error`, which the
interface maps to a generic sentence — so Google's own words, which are the whole
diagnosis, were replaced by one that says nothing. And `writeError` records
nothing; only `s.internal` does. So there was no message to read and nowhere to
look it up afterwards.

`s.upstream()` is the answer: 502 because this is somebody else's service saying
no rather than this server breaking, a code of its own so the interface can quote
what was said, and a row in the error log — which is the entire reason that log
exists. A Gmail sync failing quietly for a week is otherwise invisible: the
background job writes a line the next restart erases.

The same connection also left the mailbox unnamed. The address is learned at
connect time and that call can fail; a warning went to the log and an empty string
to the database, so the page said "an unknown account" and kept saying it. The only
way back was to disconnect and reconnect, which nobody would guess. The status
endpoint asks again when it finds none, so a wrong answer lasts until the next look
rather than until somebody reconnects.

Ten Danish strings were hiding in `confirm()` and `app.toast()` calls, including
every destructive confirmation in the app — the worst possible set to leave in one
language, since it is what somebody reads in the half-second before agreeing to
lose something. The i18n guard reads markup and attributes, and a string argument
lives in the script block. It reads those now, and found four my own grep missed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 13:28:44 +02:00
Kristian WindandClaude Opus 5 95378a1199 docs: hand over the day, and what is still open
The list this session started with is empty, and so is the one that grew during it
— eleven items reported by somebody using the app while it was being built, taken
in the order they arrived.

Two traps worth the next session's time, both found the hard way. A Rune's manifest
`version` has to be bumped when its variables change: the panel keys its catalogue
on that number, so new fields with an unchanged version are invisible, the upload
succeeds, and nothing reports it. And the restart button's `confirm()` cannot be
driven from automation — a browser modal blocks the renderer — which is the point,
but it means an agent testing it has to ask a person to press OK.

Three things are reported and not fixed: Gmail showing "an unknown account" because
the profile call fails into a log line that a restart erases, an unidentified
"something went wrong" on the integrations page, and three Danish strings in a
script block that the i18n guard cannot see because it only reads markup.

"Det næste, jeg selv ville tage" was three items that are now done. It says what is
actually next.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 13:21:56 +02:00
Kristian WindandClaude Opus 5 e004696362 fix(rune): bump the manifest version, or the new variables never appear
The three panel settings were added to the manifest in v0.12.0 and `version` was
left at 2. The panel keys its catalogue on that number, so a manifest with new
fields and an unchanged version is one it has no reason to re-read — the server's
settings page simply does not offer them, and nothing reports it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:30:43 +02:00
Kristian WindandClaude Opus 5 2b45aa33e1 feat(admin): ask the panel to restart, from verdande's own settings
Release / Build and publish image (push) Failing after 10m2s
A container cannot replace its own image from the inside, so "update" is really
"ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on the
way, which is what makes a restart an update at all. This removes the second
browser tab, not the panel's authority: the panel still does the work and still
records who asked.

Three settings, needed together, and the page names whichever are missing — an
operator reading "not configured" should not have to go and find out which of
three. The token is never sent to a browser; the page is told only whether it is
set.

Answers 202 rather than waiting. The panel stops this container as part of
answering, so the reply may never arrive, and a request cut off mid-flight is the
successful case wearing a failure's clothes. The interface believes neither answer
and polls /healthz until it responds.

Administrators only, and sessions only. A leaked API token that could restart the
server is a denial of service in one request.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:18:35 +02:00
Kristian WindandClaude Opus 5 19aa02e80c feat(projects): section headings you can count at a glance, and an Inbox that speaks the language
Two from use.

Section headings were small uppercase text over a hairline — a heading you have to
look for. A project with four sections is four groups, and the eye should be able
to count them without reading a word, so the heading gets a ground of its own and
the rows sit against the page. `--surface` rather than a tint of the accent: the
colour on that page belongs to the project, and a section is not a second thing to
colour. A step in lightness separates them in all five themes without anybody
measuring anything.

And the Inbox. The server names it at account creation, before anybody has seen a
settings page, so it is the one project name nobody chose — and an account made in
Danish went on saying "Indbakke" inside an English interface, which reads as a
string somebody forgot to translate. It was. `projectName()` translates it while it
still carries the name the server gave it; rename it and that is your name for it,
in every language, like any other project. The rename field keeps the raw name, or
editing a translated label would save the translation.

The screenshot set gains the list view, which is where sections read as bands
rather than as columns — and the shape most people actually work in.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:02:56 +02:00
Kristian WindandClaude Opus 5 fe52818ab6 feat(auth): passkeys, as a way in and as a second factor
A password and a TOTP code are both shared secrets: the server keeps something
that can be stolen from it, and the person keeps something that can be talked out
of them. A passkey is neither. The private half never leaves the device, and the
signature is bound to the origin that asked for it — so a convincing copy of the
sign-in page at another address gets a signature it cannot use.

Logging in asks for no email. The device knows which account its key belongs to,
which is not only convenience: it means the sign-in page cannot be used to find out
who has an account here. A key that verified its owner is both factors at once and
completes the login; one that only proved possession still meets the TOTP step,
exactly as a password does.

The relying party id is derived from VERDANDE_BASE_URL rather than configured
separately — a second setting that has to agree with the first is one that will
one day disagree, and the failure is a login nobody can complete. An IP address is
refused up front: no browser accepts one, and without the check the server issues
a challenge happily and the browser throws SecurityError long afterwards. The list
endpoint reports whether this deployment can offer them at all, so the interface
never shows a button that cannot work.

A challenge is stored server-side, answerable exactly once, and taken rather than
read — two requests racing the same one would both succeed, which is the shape of
a replay. A sign count that has gone backwards means the credential was cloned and
is refused rather than logged.

The Go tests cover who may reach these endpoints and that challenges are spent.
The ceremony itself needs an authenticator, so the smoke test drives Chromium's
virtual one over CDP — the only thing that can tell whether the base64url
conversions are right, since one wrong byte produces a signature that will not
verify and reads as a broken key rather than a broken encoder.

That test runs at localhost in its own context while the suite stays on the
loopback address, because WebAuthn refuses an IP and resolving localhost is slow
enough here to cost the suite minutes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:56:54 +02:00
Kristian WindandClaude Opus 5 982c3aab18 test(i18n): commit the guard that was written and never added
Release / Build and publish image (push) Failing after 10m6s
It was created alongside the three untranslated strings it catches and left
untracked — `git add -u` stages tracked files only. CI has been running without
it since.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:19:26 +02:00
Kristian WindandClaude Opus 5 a52ad9817f feat(tasks): undo a completion, and a place to find what was closed
Reported from use: "I just closed a task by accident, and there is no undo and no
list of closed tasks." Both halves were true.

Completing is one click on a small circle beside a row you were only reading, and
it takes the row off the screen — so the mistake and the evidence of it leave
together. There was no name left to search for and nowhere to look: the project
page can show its own closed tasks, but only if you already know which project it
was in.

The toast covers the mistake you notice at once. It names the task, so you can see
which one you hit, and stands for twelve seconds rather than five — five is enough
to read "could not save" and not enough to notice, decide and reach the mouse. A
toast with an action also gets two targets: the message dismisses and the action
acts, which were one button before and would otherwise put "yes, put it back" and
"go away" in the same place.

`/faerdige` covers the one you notice tomorrow. A place rather than a toggle in
every view, because somebody hunting for a task they closed does not know which
view they closed it in. Newest first — a record of what is finished is read
backwards from now, not by a due date the task no longer has — and grouped by the
day, because "I dag" and "I går" is how anybody would describe it.

A recurring task is offered no undo. Completing it moves it to its next date rather
than closing it, so there is nothing to reverse, and an undo that quietly rolled
the date back would be a different act from the one it claims to be.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:18:01 +02:00
Kristian WindandClaude Opus 5 0dbf7a40bf feat(tasks): write a task straight into a section
The box at the top puts things in the project with no section, which is right for
capturing. But a project has sections because the work belongs somewhere, and
dragging every new task down into the right one is a second gesture for something
you already knew when you typed it.

Two ways in, because they answer different moments. A field at the foot of each
section and each board column — standing there is enough, no syntax to know. And
`/section` in quick add, for the box at the top and for Today and Upcoming, where
you are not standing in a section at all.

The field goes through quick add rather than createTask, so a line written there
still parses its dates, priorities and labels.

The sigil needed care: '#' and '@' never turn up in a Danish task line by accident
and '/' turns up constantly. It has to follow whitespace and be followed
immediately by the name, which "km/t", "3/4" and "20 kr / stk" all fail. The bare
form is greedy, because "Kunder/Ordrer" is a real section name and the sigil is
inside it.

A section id from the client is checked against the project the task actually
landed in. A `#project` in the same line can move the task elsewhere, and the id
would otherwise follow it into a project that section does not belong to.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:13:10 +02:00
Kristian WindandClaude Opus 5 9cc7df69f0 fix(projects): the header stops crushing the project's name
"GarageRisteriet" rendered as "GarageRist / eriet" — broken mid-word. A name split
mid-syllable does not read as a wrap, it reads as a rendering fault.

Two causes. The row carried eight controls beside the heading — colour, show-done,
three view buttons, share, history, delete — and the heading was the only flexible
thing in it, so it was what gave way. And `overflow-wrap: anywhere` breaks at the
first opportunity rather than the last necessary one, so what gave way, gave way
inside a word.

Everything but the view switcher is behind one button now. What stays out is what
you use while working — which view you are in. What moves in is what you do *to* a
project rather than in it, and most of it once ever. The heading gets the row back,
and `break-word` only splits a word that could not fit on a line of its own.

The menu closes on Escape and on a click anywhere else, listened for in the capture
phase so a click whose own handler stops propagation still closes it. A menu you
can only leave by pressing its own button again is one you click twice to get out
of.

Three more untranslated strings turned up on the way: "Fjern {member.name}", "Luk
menu" and "En slettet konto". The guard missed them because none of its words
covered them — it knows fjern, opret, vælg, luk, tilføj, ryd, gem, slet and omdøb
now.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 11:00:32 +02:00
Kristian WindandClaude Opus 5 ab5e3e35a6 fix(i18n): three Danish strings that had no æ, ø or å in them
Spotted on the running instance: the version line read "Running v0.11.0. Der
bliver ikke set efter opdateringer." — half translated, on one line.

The sweep that was supposed to catch these grepped for Danish letters, and none of
the three survivors contain one: "Ingen nyere version fundet.", "Ikke mere i dag."
and a token hint split across two source lines so neither half looked like prose.

So there is a test for it now. It reads the templates and looks for the words that
give Danish away whatever letters they are spelled with — a word list rather than a
language detector, because this ships as one static binary and thirty common words
catch the sentences a person actually writes. It fails on the exact three that got
through.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 10:50:25 +02:00
Kristian WindandClaude Opus 5 d6d5bf590b fix(sections): a section can hold more than one task
Reported as "sections can only have one task", and that is exactly what it looked
like.

TaskList read the dragged task from its own `draggingId`, which is only set when
the drag *started* in that list. A task dragged in from the unsectioned rows above,
or from another section, arrived as null and the handler returned early — after it
had already called `stopPropagation`, so the section around it never got the drop
either.

So a section could only be dropped into while it was empty, because an empty one
has no row in the way to swallow the drop. Put one task in and there was no route
to a second. The id comes out of `dataTransfer` at drop time now, and `dragover`
decides with `carries()` rather than local state — which is the only thing a target
can read while a drag is in the air.

The stuck frame was the same cause: the section's own cleanup never ran, so the
highlight stayed lit after the drop. It is cleared on `dragend` now, which fires
once per drag on the source and bubbles to the window — the one place that is true
however a drag ends, including one abandoned with Escape, which produces no drop at
all and left the frame standing too.

The smoke test drops a second task onto the row already in the section, which is
the gesture that did nothing, and runs in WebKit as well.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 10:35:06 +02:00
Kristian WindandClaude Opus 5 6853897d00 ci: install WebKit as well, for the one test that needs it
The `webkit-forms` project was added to the Playwright config and CI was not told,
so it installed Chromium and the one WebKit test died on a missing executable —
26 passed, 1 failed, and the failure said nothing about the app.

It is one test on purpose. The suite stays Chromium, because running it in three
engines finds the same breakage three times; focus is the exception, and Safari
ignoring `autofocus` on anything inserted after load is what made "sections have
no function" true from inside Safari.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 10:05:29 +02:00
Kristian WindandClaude Opus 5 726a489a67 fix(migrations): say why foreign keys go off, accurately
Release / Build and publish image (push) Failing after 10m0s
The note on 0009 and 0012 said the drop would cascade and take every row with it.
It would not: nothing references `activity` or `attachments`, so dropping either
takes nothing along. That claim was carried over from 0008, where seven tables do
reference `tasks` and it is exactly right.

The real reason is the check that comes with the marker. With foreign keys on, the
copy is validated a row at a time against a half-rebuilt schema, and the first bad
row aborts with no idea how many others are like it. With them off,
`PRAGMA foreign_key_check` runs once before the commit and names what is actually
wrong — which matters, because these run against a live database on somebody's
next restart and a failed migration is a server that does not start.

The upgrade test now carries activity rows, an attachment and a group across all
five migrations rather than stopping at 0008, and checks that the columns added by
ALTER arrived with their defaults rather than as NULL.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 10:01:18 +02:00
Kristian WindandClaude Opus 5 626ea9aaec feat(tasks): finished work can be looked at, and is out of the way by default
A task list is what is left to do, so closing something has to take it off the
plan. But "what did I get done" is a real question and the answer was nowhere:
once a task was closed it left the interface entirely.

They come back in one list at the bottom rather than back among the open ones in
their sections. A closed task has stopped being work and started being a record,
and putting the record in the middle of the plan makes the plan longer without
making it say more.

Off by default, and deliberately: a list that opens full of things already done is
a list you have to read past before you can use it.

In localStorage rather than on the account, like the sidebar's width and unlike a
folded group. It is a way of *looking*, not a statement about the work — somebody
reviewing their week wants it on for ten minutes, and a setting that followed them
to every device is one they would have to turn off twice.

`loadTasks` asks for closed tasks when the setting is on, rather than each caller
deciding. A view added later cannot then forget and quietly ignore it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:49:58 +02:00
Kristian WindandClaude Opus 5 4d8b70918b feat(i18n): the interface speaks both languages
The account has had a `locale` since the beginning and it only chose which grammar
quick add read a line with — so picking English changed nothing anybody could see,
and the field said so honestly: "Sprog i hurtig tilføjelse". It says "Sprog" now,
because it means it.

Every component goes through `t()`. Danish is the source: a key missing from
English falls through to Danish rather than to the key, because a Danish sentence
in an English screen can be read and `settings.notifications.title` cannot.

`tag()` is the single place `Intl` is asked. Before, `'da-DK'` was written into a
dozen components, and that is the half of a translation that gets left behind — an
English interface still saying "24. aug." is exactly the shape of a job half done.
There is a smoke test that switches to English and refuses to see a Danish month
name in the calendar's heading.

Colour and theme names are keys now. `colors.js` keeps its ids untouched — they
are what the database stores, and `internal/store` reads them out of that file —
but `name` is `'color.tomato'`, resolved where it is drawn.

The Inbox keeps its name. It is a project row the server creates at signup, not a
string in the interface, and it can be renamed like any other project.

Four copies of "3 hours ago" became one, in `web/src/lib/when.js`. They had
already drifted: one said "aldrig logget ind", another "aldrig", and two stopped
at days where the others carried on to a date.

Routes stay Danish. `/indstillinger`, `/projekt`, `/uddelegeret` are bookmarks and
they are in every smoke test; translating them buys nothing and costs both.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:46:08 +02:00
Kristian WindandClaude Opus 5 e3c7fc792a feat(i18n): the machinery, and the shell translated
The account has had a `locale` since the beginning, and it only chose which
grammar quick add parsed a line with — so setting it to English changed nothing
anybody could see. This is the other half of that field, started.

`t()` reads `i18n.locale`, which is `$state`, so every component that calls it
redraws when the language changes; that is what makes the setting take effect
without a reload, and why the module is `.svelte.js`. Danish is the source and the
fallback: a key missing from English falls through to Danish rather than to the
key, because a Danish sentence in an English screen can be read and
`settings.notifications.title` cannot. `tag()` is the single place `Intl` is
asked, so dates follow the language instead of being `da-DK` in a dozen files.

`internal/i18n` is a Go package that exists only to fail the build when the two
dictionaries drift — the same trick `colors_test.go` plays on the palette. It
checks the placeholders as well as the keys: a translation that spells `{n}` as
`{antal}` leaves `{antal}` on the screen, which is worse than an untranslated
sentence because it looks like a bug in the app rather than a gap in a dictionary.

Converted so far: the shell. Sidebar, layout, quick add, task row, command
palette — fifty-two keys, and the surfaces people look at constantly. Twenty
files remain, the settings pages being the heavy ones.

The language picker is deliberately not wired up yet. It still says "Sprog i
hurtig tilføjelse" and still only changes the parser. Wiring it before the sweep
is done would hand anybody who picks English a bilingual screen — the
half-finished-theme failure `app.css` already taught us. As it stands Danish is
unchanged and English is not offered, which is the honest state for something
half done.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:25:58 +02:00
Kristian WindandClaude Opus 5 804963b222 feat(groups): a group becomes somewhere you can go
It was a heading over some rows: a name, a colour, and whether it was folded.
Enough to tidy a list, and not enough to be what people mean by it — "Arbejde" is
a body of work with context of its own, and documents that belong to all of it
rather than to any one project inside it.

`/gruppe/{id}` carries three things a heading cannot. The projects in it, each
with what is still open — open tasks, not all of them, because "12" beside a
project somebody finished last year is a number that means nothing. A description,
written in place and saved on blur, the same shape a task's description has. And
the files that govern the whole group: the contract, the drawing, the agreement
everything else rests on.

The name in the sidebar is a link to it and the chevron beside it still folds.
Two intentions, two targets — the single one they used to share could only ever
serve the smaller.

Migration 0012 rebuilds `attachments`, because its CHECK spelled out exactly which
parent columns may be set and SQLite cannot alter a CHECK. The rule is still
"exactly one parent", counted rather than written out as pairwise comparisons:
with three columns that is three clauses, with four it is six, and the version
that is wrong looks exactly like the one that is right.

A group's file is reachable by its owner and nobody else. A group belongs to one
person and is never shared, so `reachAttachment` asks about ownership rather than
a project role — both old callers resolved straight to a task, so a group's
attachment answered 404 for the person who uploaded it.

Three tests also stopped racing their own navigation. Creating a project navigates
and `goto` is a promise nobody was holding, so a quick add fired straight after
could land in the Inbox instead — intermittent, and it read as the quick add
having failed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:20:03 +02:00
Kristian WindandClaude Opus 5 0b467beb3e docs: the API and the handover, brought up to what is here
The OpenAPI document gains the audit log, the backups, the Gmail client and the
sidebar sections, and loses the paragraph that said deleting an account takes
every task the person ever wrote.

The handover gains the day: why an account can be deleted without taking other
people's work, why the audit log pages with a keyset, why a week view exists at
all, why form fields are 16px on touch, and why `autofocus` is gone.

And the trap that cost the most, which was not in the code: the build hung on 0%
CPU because iCloud had evicted node_modules and a stalled download blocks a read
forever. The fix is a repo path outside a synced folder.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:36 +02:00
Kristian WindandClaude Opus 5 1a6adfd9d3 docs: screenshots that regenerate themselves, and eleven of them
They were many rounds behind — no settings, no task detail, no themes, no groups,
no colours, no calendar, no admin pages. The reason is in how they were made:
`tools/shots` needs a running instance and a session cookie pasted in by hand, so
it ran about as often as somebody felt like doing that.

`node scripts/generate-screenshots.js` starts the real binary, creates its own
account, fills it with Danish demo data and photographs eleven surfaces into both
`site/screenshots` and `docs/screenshots`. Nothing to set up and nothing to
remember.

A script rather than a test, for the same reason `generate-icons.js` is one: CI
must never rewrite a tracked binary file.

`tools/shots` stays. It is still the way to photograph an instance that is
already running rather than a fresh one.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:36 +02:00
Kristian WindandClaude Opus 5 01d498be20 fix(projects): group_id was accepted on create and thrown away
The field was declared on the request type, commented on it, and applied by the
update handler — and the create handler never read it. Creating a project into a
group answered 201 and put it nowhere. A field that is accepted and discarded is
worse than one that is refused: nothing reports it.

Found by looking at a screenshot. Both groups stood empty while the projects sat
loose above them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:36 +02:00
Kristian WindandClaude Opus 5 31bf0557df feat(gmail): the OAuth client can be entered instead of deployed
The one click that opens Google's consent screen already existed. What it needs
behind it is a registered client, and no app can conjure one: Google issues no
Gmail access to an unregistered client, and `gmail.readonly` is a restricted
scope, so an id shipped inside a public image would be both extractable from it
and unusable without Google's security review of every deployment using it.

The registration is a one-off. Having to edit a Rune's manifest and recreate the
container to paste the result was not.

`VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` still win when set. A value in the
environment is a deliberate deployment decision, applied again on every recreate,
and a form that could quietly override it would make the manifest a lie — so the
page says where the value came from, and PUT answers 409.

The redirect URI is spelled out on the page rather than left to be reconstructed.
It is derived from VERDANDE_BASE_URL, it has to match Google's copy exactly, and
the error Google returns when it does not names neither value.

Its own table rather than `user_settings`: an OAuth client is the instance's, not
one person's — and `user_settings` has `user_id` as its whole primary key, so a
second scope there would silently overwrite the AI provider's key.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:18 +02:00
Kristian WindandClaude Opus 5 101ca33069 feat(tasks): a row says what is underneath it, and what is attached
A task with three sub-tasks looked exactly like one without, and so did a task
with a drawing on it. The whole reason to attach something is that somebody
should find it.

Counted in the same query as the tasks, as correlated subqueries: a list of fifty
would otherwise be a hundred more lookups, and the point is that you see this
without asking.

Said as a fraction rather than a total. "3" tells you something is there; "1/3"
tells you whether there is anything left to do, which is the question a list is
for. All-done goes accent-coloured, because "3/3" and "1/3" are the two answers
you actually look for and only one of them is good news.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:18 +02:00
Kristian WindandClaude Opus 5 eaff890860 fix(forms): Safari focused none of them, and a board could not make a section
Two reports, one sentence between them: "sections have no function, because you
cannot create more than one."

`autofocus` does not work in Safari for anything inserted after the page has
loaded, and every inline form here is inserted after the page has loaded — rename
a project, add a section, name a group. In Safari they all opened with no cursor
in them and what you typed went nowhere. That does not look like a bug. It looks
like the feature does not work. `use:focusOnMount` replaces all seventeen.

And the board had no way to add a section at all. The button lived only in the
list view, which is the one place nobody looks for it: a column on a board *is* a
section, so that is where you go to make one.

Chasing this turned up a third. The task drawer's three fields save on blur, and
closing the drawer *is* a blur — the input is being unmounted and the browser
fires blur on the way out. If the task left the store in the same moment, `task`
was already null and the handler threw `Cannot read properties of null`, stopping
the page. It only became reproducible once focus actually worked.

Fields are 16px on a touch device now, whatever the component asked for. Below
16px, iOS Safari zooms in when a field takes focus and does not zoom back out —
so the whole interface is left too large and has to be pinched back by hand, once
per field. The type scale tops out at 15px, so every input in the app did it.

The sidebar's resize handle sat at `right: -3px`. An absolutely positioned child
counts towards its container's scrollable overflow, and `overflow-y: auto`
resolves the other axis to `auto` too — so those three pixels gave the whole menu
a horizontal scrollbar on every desktop, always. The account name could do it as
well: `overflow: hidden` with no `min-width: 0` cannot shrink, so the row grew
instead of ellipsing.

There is a WebKit pass in the suite now, over one test. The rest stays Chromium
on purpose — a smoke test exists to catch a build that does not work, and three
engines find the same bug three times — but focus is the exception, and it took a
person using the app to find that out.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:05:02 +02:00
Kristian WindandClaude Opus 5 1da7ea64bf feat(admin): the nightly backup, made visible
It has run since the beginning and nothing showed it. A backup nobody can see is
a backup nobody knows has been failing, and the failure is quiet: a volume gone
read-only, a disk that filled, a container that has not been up at midnight all
week. The first anybody hears of it is the day they need one.

`ListBackupRuns` was already in the store and exposed nowhere. Now there is a
list, a download, and a button that takes one now — the same work the nightly job
does without its "one a day" check, because waiting until tonight to learn whether
backups work at all is how somebody finds out on the day they need one, and it is
the only way to get a snapshot from *before* something you are about to do.

Restoring is deliberately not a button. Swapping a database under a server that
is writing to it means stopping every writer, replacing the file and reopening,
and a half-done version of that destroys the thing it was restoring. The panel
says what to do instead.

`RunBackupNow` turns out to have been a second copy of the backup logic, written
for a button that did not exist yet, and the two had already drifted: it never
created the backups directory and logged nothing. One implementation now.

Administrators only, and sessions only. A backup is a complete copy of the
database, so this hands out everybody's data in one file.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:04:43 +02:00
Kristian WindandClaude Opus 5 04a7ba1024 feat(sidebar): headings that fold, a group head that is not indented, and colour where it is edited
Three things reported from use, all in the sidebar.

Projekter, Delt med mig, Filtre and Etiketter fold, the way a project group
already does. Kept on the account rather than in localStorage, and for the same
reason the group's own `collapsed` is: folding a heading says "this is the part
of my work I am not in right now", and that is as true on the laptop as on the
desktop. The sidebar's *width* is the opposite case and stays per browser. A JSON
array of keys rather than a column each, so a renamed heading is not a migration.

The group heading is no longer indented — only what is filed under it. The colour
dot sat as a sibling before the fold button and pushed the whole row in, and a
heading that starts further in than the rows above it says the heading is inside
something.

And the colour is chosen while renaming. The dot was itself the button, which
gave the heading four controls on a line narrow enough to crowd the name; both
are edits to the same thing, so they belong in the same moment. The form closes
on `focusout` and only when focus has left it entirely, and the swatches refuse
focus on mousedown — Safari and Firefox do not focus a button on click, so
`relatedTarget` would be null and the form would shut under the pointer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:04:30 +02:00
Kristian WindandClaude Opus 5 b169591209 feat(upcoming): a week at a time, so a drag can cross a month boundary
A month grid is anchored to a month, so the two days on either side of its edge
sit in different grids — and you cannot page mid-drag, because a drag in flight
swallows the click that would page. A week that straddles the 31st and the 1st
has both days in the same row.

The same component as the month, with `span="week"`, rather than a second one:
the part that is easy to get almost right is the same in both. A drop target has
to decide from `dataTransfer.types` alone whether it accepts, and two copies of
that are two places to be almost right.

Seven cells instead of forty-two, so the height a month spends on six rows goes
into one and the cells hold a real list rather than a hint. On a phone the row
stacks and each day carries its own weekday name, because a column of bare
numbers is not a week.

`calendar` still means the month in localStorage. It is the value already stored
for everybody who chose it, and renaming it would silently reset them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:04:30 +02:00
Kristian WindandClaude Opus 5 85f7b2488d feat(admin): what has been done on this server, across every project
The other half of the error log. That one is what broke; this is what was done,
and nobody could ask it: `ListActivity` takes one project id, and an
administrator is not necessarily a member of the project they need to look into.

Paged with a keyset cursor rather than an offset. The log is written to
constantly and read rarely, so a page fetched a minute after the one before it
would be shifted by everything written in between — one row shown twice and the
row behind it skipped, which is the single failure an audit log must not have.
Both halves of the cursor matter: the log is written in whole seconds, so a
timestamp alone cannot separate two rows written in the same one.

Migration 0009 makes `activity.user_id` SET NULL for the same reason 0008 did it
to `created_by`. A log whose rows vanish with the account they describe answers
"what has happened here" with everything except the part somebody had a reason to
remove. `project_id` keeps cascading on purpose: the trash is the undo, and past
it the deletion was deliberate.

The event names moved to `web/src/lib/events.js`, because two surfaces read the
same log now and two copies of nineteen strings is two places for them to drift.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:04:12 +02:00
Kristian WindandClaude Opus 5 ebdc8eacf2 feat(admin): deleting an account stops deleting other people's work
`tasks.created_by` was ON DELETE CASCADE, so retiring a colleague removed every
task they had ever written — including in projects somebody else owns. The
interface said so in plain words before you pressed the button, which is a
warning rather than a fix. It was the last remaining way to lose other people's
work by accident.

SET NULL instead: the task stays and loses its author, the same shape
`assignee_id` and `completed_by` already have.

SQLite cannot alter a foreign key action, so the table is rebuilt — and with
foreign keys on, the drop in the middle of a rebuild is a cascading delete that
takes every row referencing `tasks` with it. `PRAGMA foreign_keys` is a no-op
inside a transaction, so migrations now run on a connection of their own, and a
migration can opt out of enforcement with `-- verdande:rebuild-tables`.
`PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a
dangling reference fails the migration rather than writing it.

The test fills a database at 0007, upgrades it, and counts. Without the marker it
loses a task, a label link and a comment — which is exactly what it is for.

The admin list now says two numbers instead of one: what disappears, and what
stays behind unattributed. They are different sentences and the second is a
reassurance.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:03:59 +02:00
Kristian WindandClaude Opus 5 a29534c811 build: lift Vite to 8, and get the tree out of iCloud's way
The frontend build hung on 0% CPU, roughly every other run: blocked in read()
inside libuv, no output, forever. `svelte-kit sync` alone did it too, so it was
not the bundler.

The cause was not the toolchain. `~/Documents` is iCloud-managed, and iCloud
evicts what it thinks is cold — which for a dependency tree is nearly all of it:
359 of 400 sampled files under node_modules were `dataless`. Reading an evicted
file blocks until it is downloaded again, and a download that stalls hangs the
build for good. The tell was a different error entirely: Playwright once reported
`ETIMEDOUT: connection timed out, read` while loading jsconfig.json, and
ETIMEDOUT on a local file read does not exist.

Vite 5 and esbuild 0.21 predate Node 22 and this machine runs Node 26, so they
went up anyway — to Vite 8 with @sveltejs/vite-plugin-svelte 7, which SvelteKit
2.70 takes. `package-lock.json` had to be deleted for npm to resolve it; it held
plugin 4 and rejected the whole tree with ERESOLVE. That made the build faster.
It was not what was hanging.

`engines` says Node >= 22.12 now, so the next person is told rather than left
watching a process do nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 09:03:45 +02:00
Kristian WindandClaude Opus 5 494640c1a8 fix(mobile): the drawer no longer pushes the page sideways
Release / Build and publish image (push) Failing after 10m8s
The task drawer was `width: 100vw`. A viewport unit includes the scrollbar
gutter and a percentage does not, so the drawer came out a few pixels wider
than the page it was pinned to and the whole document could be shoved
sideways. It is `100%` now, and at phone width it is stated as four insets
instead — a box defined by the viewport's own edges cannot be a rounding
error wider than it.

The scrolling body gets `min-width: 0` and `overflow-x: hidden` too, so a
pasted URL or a long unbroken word in a comment cannot stretch the flex
column and take the page with it.

Writing the test for that found another one: creating a project from the
sidebar navigated to it and left the drawer covering the screen. Every link
in there closes it on the way out; the form did not, because it calls
`goto` directly. Now it does the same thing for the same reason.

The phone test checks the document cannot be scrolled horizontally, both
with the drawer shut and with it open.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 23:24:49 +02:00
Kristian WindandClaude Opus 5 6d27d730d2 fix(mobile): the project header wraps instead of crushing the title
Release / Build and publish image (push) Failing after 10m4s
On a phone, a project called "Skovvænget" rendered as a vertical column of
single letters while the buttons ran off the right edge. It looked like a
broken font.

The header is a flex row of a title and four controls, and 390px does not
have room for them. Nothing wrapped, so the heading was shrunk to its
min-content — which with `overflow-wrap: anywhere` is one character. Adding
Historik this evening is what tipped it over.

It wraps now, and the title keeps a floor of 8ch so flex stops squeezing at
the point where wrapping is the better answer. Kommende had the same shape
and gets the same fix.

Every other smoke test runs at desktop width, which is exactly why this
shipped. There is one at 390 now: it measures the heading's box — wide
enough to hold a word, short enough not to be a column of letters — and
checks the page cannot be scrolled sideways.

Also stops an empty project saying both "Uden sektion" and "Ingenting her
endnu": that label is a heading for the tasks outside a section, and with no
sections there is nothing for them to be outside of.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 23:09:08 +02:00
Kristian WindandClaude Opus 5 b0ab6ee5c0 fix(docker): build the frontend and the binary once, and cross-compile
Two of the last five releases died at exit 132 — SIGILL, an instruction
QEMU does not implement — partway through `npm run build`. It reads as a
broken build and is a broken emulator.

The cause is that neither builder stage was pinned to the build platform,
so buildx ran both once per target: Node and esbuild under emulation for
arm64, and the entire Go toolchain too. Nothing in either stage needs to be
the target architecture. The frontend produces HTML, CSS and JavaScript,
which are the same bytes wherever the image runs, and Go cross-compiles
natively with CGO already off.

So both are `--platform=$BUILDPLATFORM` now, and the binary is built with
`GOARCH=${TARGETARCH}`. The frontend is built once instead of twice, and
nothing is emulated at all — which should also take most of the fifteen
minutes off a release.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 23:04:09 +02:00
Kristian WindandClaude Opus 5 c2e2d66e76 feat(admin): keep server errors where a restart cannot take them
Release / Build and publish image (push) Failing after 12m56s
The panel's watcher reported four 5xx today — twice from one address at
11:49, twice from another at 10:45 — and there is no way to find out what
any of them were. verdande logs the real cause of every 500, but on a Rune
the log belongs to the container and the container is replaced on every
restart. So an operator is told that something broke and given nothing to
act on, which is the worst of both: alarming and useless.

Every 500 now writes a row: what the handler was attempting in its own
words, the error it hit, the method and path, who was asking, and the
request id that ties it back to the log line for whoever still has it. The
"what" is the part a status code cannot give — "list projects" and "move
task" are the difference between a fault you can find and one you can only
count.

Written best-effort and never checked. This is the path that is already
reporting a failure, and a diagnostic that can itself fail the request
would turn one broken screen into two.

Threading the request through `internal()` and `storeError()` touched 108
call sites across 19 files, which is the price of not having had it there
from the start. It is mechanical and the compiler checked all of it.

Kept for thirty days by the nightly sweep: long enough to answer "what
happened last week", short enough that a fault loop cannot fill the volume
the tasks live on.

Read at Indstillinger → Fejl, administrators only — it carries paths,
account names and the server's own error strings. The empty state says
"Ingen fejl registreret. Det er den rigtige tilstand", because a blank
panel should not read as a broken one.

The test drops a table and asks for the endpoint that reads it: the same
code path a disk error takes, and the row that comes back has to name the
operation, the account and the request.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 22:58:02 +02:00
Kristian WindandClaude Opus 5 4be934ccc1 feat(pwa): the PNG icons the manifest and iOS have always wanted
The manifest pointed at icon.svg and nothing else. Chrome and Firefox
install happily on that; Safari does not read the manifest's icons at all
and looks for /apple-touch-icon.png by name, so an installed PWA on iOS got
a screenshot of the page as its home-screen icon.

This was written down as blocked: there is no SVG renderer on this machine,
which is why the references were removed rather than left pointing at files
that did not exist. Playwright is an SVG renderer, and it has been a
dependency the whole time.

scripts/generate-icons.js renders the same SVG at 192, 512 and 180. 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 already fetches every icon
the manifest names, and now asks for the Apple one by name too, so a
reference with no file behind it fails there rather than on somebody's
phone.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 22:37:52 +02:00
Kristian WindandClaude Opus 5 7094e05ddd feat(sidebar): drop a task on "I dag"
"Do it today" is the most-made rescheduling there is, and the sidebar is
where the pointer already is when you have picked a task up.

Only that one of the two date views. "Kommende" is a range rather than a
day — it starts today and runs a week — so a drop on it would have to
invent a date its own label does not name. The month grid inside it takes a
drop per cell, which is where a specific day is actually on offer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 22:35:40 +02:00
Kristian WindandClaude Opus 5 35d9e3bdac feat: an assignee on the row, and the activity log made visible
Release / Build and publish image (push) Failing after 10m2s
Two more things the database has been recording and the interface has
never shown.

**Who a task is waiting on, on the row.** Only when it is not you: your own
list would otherwise carry your own face on every line, which is a column
of one initial telling you nothing. The point of showing an assignee is
that it is somebody else.

That needed names, and a task carries only an `assignee_id`. `GET /people`
is the primitive: everybody you share a project with, plus yourself,
fetched once per session. Not per row, and not per project either — a list
would have to know which project each task belongs to before it could ask
for that project's members. Deliberately *not* the instance's user list,
which is `/users` and administrators only: a task list has no business
enumerating everybody with an account here.

It sits first in the meta line, because who changes what the rest of the
line means — a date on somebody else's task is their deadline, not yours.

**And the activity log.** Nineteen kinds of entry have been written since
the beginning — every project, section, member, task and comment event —
and `GET /projects/{id}/activity` has answered honestly the whole time with
nothing on the other end. There is a Historik panel on the project now,
loaded when it is opened rather than with the page, and the event keys
become Danish in exactly one place. An unknown key falls through to itself
rather than being hidden: a build that records something this table has not
learned should still show that it happened.

One test assertion had to change, and it is worth saying why. It matched
"males inden fredag" against a row whose title is "males inden" — quick add
read "fredag" as the due date — so it was really matching the title
concatenated with the metadata beside it. Putting the assignee between them
broke it. The assertion now names the task's own text.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 22:31:43 +02:00
Kristian WindandClaude Opus 5 b6ea87ac19 feat(projects): change a member's role from the member list
An invite sent with the wrong role could not be corrected. The only way
back was to remove the person and invite them again — and removing a member
unassigns every task they were responsible for, so fixing a dropdown cost
somebody their work. This is the other side of "an external user cannot
create tasks": the rule was right, the role was wrong, and there was no way
to say so.

PATCH against a membership that already exists, not the upsert AddMember
does. Somebody who is not a member is not silently made one: that is an
invite, it belongs to the invite flow, and doing it here would let a typo
in a user id add a stranger to a project without anybody being asked.

The owner is refused, with the real reason rather than the true but useless
one. Ownership lives on the project rather than in project_members, so the
plain answer would have been "not a member" — which is correct and tells
nobody anything. Ownership is transferred, not granted, and two owners is a
state with no way to settle a disagreement about who may remove whom.

In the list, the owner's standing is text and everybody else's is a
dropdown. A disabled control would be a thing that exists to say no.

And the roles are finally in Danish. The API's words are `owner`, `editor`
and `viewer`, and they were being printed straight into a Danish page; the
list now uses the same three phrases the invite form beside it already did.

The smoke test drives both browsers, because the interesting part is that
the other one's permissions really change: a viewer is told the project is
read-only and has no quick-add box, the owner promotes them from the list,
and after a reload the box is there and the notice is gone.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 22:14:33 +02:00
Kristian WindandClaude Opus 5 fc32fea607 fix(projects): a section can be dropped into, and a failed load says so
Release / Build and publish image (push) Failing after 9m53s
Two things from the same report — an external member who could see a
project but could not get work into it.

**A section with no tasks could not be filled by dragging.** Only the task
rows accepted a drop, so the target was always another row; a section with
none had nothing to aim at, and a section you have just made is empty by
definition, which is exactly when you want to put something in it. The
board has taken a drop on the column since it was written. The list now
does the same: the section is the target, and a drop on it puts the task at
the end. Dropping on a row still means "here exactly" and stops there
rather than bubbling to the section behind it.

The unsectioned block at the top is a target too, which is how a task comes
back *out* of a section — previously only possible if some other loose task
happened to be there to aim at.

**And the page said "Projektet findes ikke, eller du har ikke adgang til
det" for three different things.** `project` started null with no loading
state, so that sentence was what you saw before the first request returned,
what you saw when it failed, and what you saw when you genuinely had no
access — and the effect never asked again, so the wrong one stayed until
somebody reloaded. That is very likely what was reported: the container was
restarted twice while people had the app open, and a request that lands
mid-restart leaves the page insisting the project does not exist.

Four states now. Loading renders nothing, as the shell does. 404 keeps the
old sentence, since the server deliberately answers 404 for both "no such
project" and "not yours" and the interface must not guess between them.
Anything else says the request failed and offers to try again.

What is *not* a bug: a viewer cannot create or move tasks. Checked at the
API — an editor invited by link gets 201 on create, 201 on quick add and
200 on a move into a section; a viewer gets 404 on all of them, which is
the rule working. If the person reporting this cannot add tasks, their
membership is viewer rather than editor.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 22:04:55 +02:00
Kristian WindandClaude Opus 5 8b1f374855 test(e2e): drive a member opening a project, not just seeing it in the sidebar
The invite test proved the shared project reached the invitee's sidebar and
stopped there. A member's project page is a different path from an owner's
— different role, different guards — and it was never opened by anything.

It also matters that the page shows "Projektet findes ikke, eller du har
ikke adgang til det" while it is still loading, because `project` starts
null and there is no loading state. A failed load and a slow one look
identical, which makes this exactly the kind of thing a person reports and
a test suite cannot see.

Signs in as the invited account in its own context and clicks through to
the project, asserting the heading renders and that message is absent.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 21:54:26 +02:00
Kristian WindandClaude Opus 5 550f420c59 feat(sidebar): colours you can choose, and projects indented under their group
`projects.color` has been in the schema since the first migration, has
defaulted to 'graphite' on every row ever written, and nothing has ever
read it. There was no palette, no name-to-colour mapping, and the dot in
the sidebar was hardcoded to a border grey. So this is not "add a picker";
it is implementing colour for the first time.

Names, not hex values. A hex in ten thousand rows is a decision nobody can
revisit; a name is resolved when the interface paints and can be retuned
for a new theme or a new display. It is also the difference between a
database that stores a fact and one that stores a rendering. The set is
closed and refused at the edge — an unknown name would be stored happily
and painted as the default, which looks like a colour that did not save.

One palette across all five themes, and this is the one place in app.css
that is not redefined per theme. Everything else there is text, a ground or
a border and has to be measured against what sits behind it. These are
small solid marks beside a label that already carries the meaning: what
they owe is to be told apart, and to be visible on both a near-black and a
white ground, which is why all ten sit in the mid-tone band. It also keeps
them out of the trap the themes are in — a colour added to four blocks and
forgotten in the fifth looks finished and falls back silently. There is no
fifth block to forget.

The list is written down three times: Go for validation, colors.js for the
picker, app.css for the tokens that paint. So a test reads the other two —
the same small-scanner approach openapi_test.go takes to the spec — and
fails if the server accepts a colour nothing can draw, or the picker offers
one the server refuses.

Groups get a colour of their own (migration 0006) and projects inside a
group are indented. The indent is to the group's *name*, not to its dot:
the eye follows the text, and lining the projects up under the dot leaves
them looking a step short of where the heading starts. The drop line is
indented with them, so a drop inside a group reads as landing in the group.

The group's dot is the control as well as the preview — you press the thing
you are about to change, as on the project page. That was not the first
design: a third text action, "Farve", sat beside Omdøb and Slet, and those
carry `opacity: 0` rather than `display: none`, so they take their width
whether or not you can see them. A group called "Arbejde" rendered as
"ARBEJD". Moving the control onto the dot gave the name its room back.

The smoke test measures the indent at the content edge rather than the
element's, because the indent is padding and the box does not move — which
is how the first version of that assertion passed 12 against 12.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 21:50:20 +02:00
Kristian WindandClaude Opus 5 e8ebd0fa78 feat(admin): a page for who is on the instance, and how somebody else gets on it
Release / Build and publish image (push) Failing after 10m0s
The README has promised "internal users are created by an admin" since the
beginning and there was no such thing. There are exactly two ways an
account has ever come into existence: the first admin at setup, and
somebody accepting an invite to a project. So the only way to add a person
was to share a project with them first.

The store has been ready the whole time. CreateInvite already documents
that "projectID may be empty, which invites somebody to the instance
rather than to a particular project", and AcceptInvite already has the
branch that grants no membership when there is none. requireAdmin has
existed in the middleware and was used nowhere. This wires those three
together and puts a page on them.

An invite rather than an account with a password the administrator picks.
A password typed here would be known to two people before it was ever
used, and would then have to travel to the second one by some channel
nobody has thought about. No name is asked for either — the signup form
asks the person, and a name an administrator guesses is a name somebody
has to correct.

Two refusals keep the instance administrable: the last administrator
cannot be demoted or deleted, and nobody can delete the account they are
signed in as. Both are enforced on the server; the interface simply does
not draw the buttons on your own row, because a button whose only purpose
is to be refused wastes a click to teach a rule. The last-admin refusal
gets its own error code, because "that already exists" is not what
happened and this is the one refusal a person needs explained.

Deleting an account is the only hard delete in this database, and building
the confirmation is what turned up how wide it goes. `projects.owner_id`
cascades, which is expected. But `tasks.created_by` cascades too — so
removing a colleague deletes every task they ever wrote, including in
projects somebody else owns. The count in the list says so, and the
confirmation says it in words. Changing that to ON DELETE SET NULL, so the
work stays and loses its author, means rebuilding the tasks table and its
FTS triggers: worth doing, not worth doing quietly while adding a screen.
It is written down in the store instead.

Pending invites are listed and can be withdrawn. Only the token's hash is
stored, so a link sent to the wrong address cannot be looked up and
resent — withdrawing it is the only way to stop it before it expires.

Six API tests for the guards and the counts, and two smoke tests: the
whole loop from invite to a second browser creating the account and
appearing in the list as somebody who is not an administrator, and the
absence of buttons on your own row.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 21:10:46 +02:00
Kristian WindandClaude Opus 5 a8b453843d fix(sidebar): Omdøb og Slet lå oven i hinanden i et gruppehoved
Release / Build and publish image (push) Failing after 9m53s
The "+" beside "Projekter" is a 20px square icon button, and the rule that
makes it one was written as `.group-head > button`. A group's heading is
also a `.group-head`, and it has two buttons in it that are words rather
than icons — so Omdøb and Slet were each squeezed into a 20px box, where
they overflowed and were drawn on top of each other.

They still worked. Every assertion in the smoke test clicks by accessible
name, which is unaffected by a button being the wrong size, so the whole
suite passed on a heading that read "0 Om…Slet". It took a screenshot from
somebody actually using it.

The selector now asks for the class instead of for "a button in a
heading", so the next text button added there is safe by default rather
than by remembering.

And the smoke test measures the row: each button must start at or after
the previous one's right edge. A layout assertion is unusual here on
purpose — the rest of the file deliberately tests behaviour — but this is
a failure mode behaviour cannot see.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 20:53:14 +02:00
Kristian WindandClaude Opus 5 a2f4de02fc chore: stop tracking the compiled shots binary
Release / Build and publish image (push) Failing after 10m0s
8.9 MB of macOS binary in the repository root, committed by accident in
a8e523b. The source in tools/shots is the thing that matters, and it is
still there.

Ignored as well as removed, because `go build ./tools/shots` without -o
writes the binary to exactly this path — which is how it got committed the
first time, and would be how it got committed the next.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 19:44:19 +02:00
Kristian WindandClaude Opus 5 4276892590 feat(auth): see where you are signed in, and end one of them
`last_seen_at` has been written on every request since sessions existed —
throttled to once a minute, deliberately, so a list could one day say
"denne enhed, lige nu" — and nothing has ever read it. The store even had
ListSessions. What was missing was two routes and a panel.

GET /auth/sessions lists them, most recently used first, with the current
one marked so nobody has to work out which row is the browser they are
reading it in. DELETE /auth/sessions/{id} ends one. Ending the current
session is allowed and is simply logging out: refusing it would be a rule
with no reason behind it, and somebody clearing every device wants this
one gone too.

Both are behind requireSession rather than the ambient auth, the same as
the API tokens. A leaked token that could list its owner's devices and end
their sessions turns a theft into a lockout.

The delete is scoped in the statement rather than checked and then run.
An id read in one step and deleted in the next is a window, and the whole
reason somebody is on this screen is that they think another person is in
their account.

The device names are a handful of substring tests, not a parsing library.
The question this answers is "is one of these me?", and for that a wrong
guess between two Chromium forks costs nothing while a dependency that has
to be kept current costs something every year. Order is the whole
correctness of it: an iPhone's user agent says Mac OS X, Edge says Chrome,
and Chrome says Safari. The raw string is sent alongside and sits in the
row's title, so anything the summary gets wrong is still visible — and
something that is not a browser at all shows its own name rather than
"Ukendt", because a CalDAV client is a real session.

Times are relative. The question is "was that me, just now?", and an
absolute timestamp makes the reader do the subtraction; past a week it
switches to a date, because "for 23 dage siden" is a number nobody can
place.

The smoke test signs in a second time in a second browser context, ends
that session from the first, and checks the other browser is really signed
out — and that this one is not.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 19:43:49 +02:00
Kristian WindandClaude Opus 5 6b6b838167 feat(views): "Venter på andre", and a project you share stays yours
`assignee_id` has been on tasks since sharing was built and delegating has
been possible from the drawer since yesterday. What was missing was
anywhere to see it: work handed to somebody else left your lists and
appeared in nothing.

GET /delegated returns everything assigned to somebody who is not you, in
the projects you can see, grouped by the person. Grouped on the server
rather than flat, because the question is about people — "what is Anders
sitting on" — and a client cannot group it itself: it would need the name
behind an assignee id from a project it does not happen to have open, and
there is no way to ask for that. The names come back with the tasks, so
one request is one screen.

The filter is a field of its own rather than a sentinel in AssigneeID.
"What is on my plate" and "what have I handed over" are different
questions, and one string that means an id most of the time and a magic
word the rest is a field somebody eventually passes a real id to.

An assignee whose account is gone is named "Ukendt" rather than dropped.
Losing the row would make work disappear from the one view that exists to
stop work disappearing.

The sidebar entry only appears where delegating is possible at all. On an
instance with one person it could never have anything in it, and a
permanent empty entry is a question the sidebar keeps asking and answering
"no".

Building it turned up a bug in the split above it. "Projekter" and "Delt
med mig" were divided on `shared`, which only means the project has more
than one member — so inviting somebody to a project of your own moved it
under "Delt med mig", where it lost its place in the order, stopped being
draggable, and could not be filed under a group. It is divided on
ownership now, which is what the two headings have always said. Sharing
your own project is not the same event as somebody sharing theirs with
you.

The smoke test now follows the whole loop in one go: invite, accept in a
second browser, assign a task to the person who just joined, and find it
under their name — with the shared project still under "Projekter" on the
way past.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 19:36:37 +02:00
Kristian WindandClaude Opus 5 34b718529c fix(auth): the two links sent by email now go somewhere
An invite has always emailed `/invite?token=…` and "Glemt adgangskode?"
has always emailed `/reset?token=…`. Neither address existed. The Go
binary serves the app shell for anything it does not recognise, the shell
found no session, and both links opened the plain sign-in form — asking an
invited person to log into an account that did not exist yet, and asking
somebody who had forgotten their password for their password.

Both APIs were finished. `POST /auth/signup` takes the invite token, reads
the email address off the invite rather than the form — so a link cannot
be redirected to somebody else — creates the account, accepts the invite
and starts the session, all in one transaction. `POST
/auth/password/reset` takes the reset token and ends every session the
account had. Nothing was missing but two pages.

They are branches of SignIn rather than screens of their own, which is
what that component already says it is for: one decision tree, so a person
is never redirected mid-authentication. The routes exist because a link
has to be an address, and each renders the same component; it reads the
token out of the URL itself.

The reset does not sign you in afterwards. The whole point of ending every
session is that somebody may have been in the account, and quietly opening
a new one here would undo the half of that which matters.

Two states that are easy to leave out, and both are reachable in one click
from an old email: a link with no token says so instead of showing a form
that cannot succeed, and a link opened while already signed in as somebody
else offers to log out rather than creating a second account for a person
who is right there in the first one.

The smoke test walks the whole invite: share a project, read the link out
of the panel that shows it when there is no mail server, open it in a
second browser context, create the account, and find the shared project in
the sidebar. A second context and not a second page — inheriting the
session would test the one case that never happens, which is what it did
on the first run.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 19:30:21 +02:00
Kristian WindandClaude Opus 5 c14559ed0c feat: project groups, a month on Kommende, and dragging things where they go
The three the handoff named as next. They are one commit because they are
one mechanism: all three end in something being dragged, and a drop target
cannot tell what is coming without the module they now share.

Groups are foldable headings over the projects in the sidebar. A table
rather than a `group_name` column on the project, because a group has to
be renamable in one write, has to survive being emptied, and has to
remember whether it is folded — none of which a string repeated on every
project row can do.

Folded state lives on the row, not in localStorage. The sidebar's *width*
is a property of the screen you are sitting at, which is why that one is
per browser; a folded group is a statement about the group — "this is the
part of my work I am not in right now" — and it is true on the laptop and
on the desktop. There is nobody else's answer to disagree with either: a
group belongs to exactly one person.

A shared project cannot be filed. `group_id` is a column on the project
row, so a member grouping somebody else's project would move it in the
owner's sidebar too — the same reason `sort_order` is owner-scoped. The
listing reports the column only to the owner, so a viewer is never told
where somebody else keeps it.

Deleting a group takes the heading and leaves the projects, and the
sidebar decides where a project renders by whether its group still
exists rather than by the id alone. A group deleted in another tab
arrives as one event and not one per project, so for a moment the
projects still name a heading that is no longer drawn — filing by the raw
id would drop them out of the sidebar entirely.

Kommende gains the month grid the project pages already had, behind a
Liste/Kalender toggle. The two views ask for different things — seven days
from today against six whole weeks around an arbitrary month — so each
loads its own, and the grid reports which dates it is showing rather than
having the page guess. Which view is remembered in localStorage: a month
of seven columns needs room, and the answer on a phone is not the answer
on a wide monitor.

And a task can now be dropped on a project in the sidebar, on a day in
Kommende, or on a cell in any month grid. Onto a project it is a *move*,
not a field: `sort_order` belongs to the project, so it has to find a
place among that project's tasks and leave its old section behind.

Each drag now carries its own MIME type. `getData` is deliberately
unreadable while a drag is in the air — the browser hands the payload over
on drop and not before, so a page cannot read what is being dragged across
it — and `dataTransfer.types` is all a target has to decide with. Deciding
is the point: calling preventDefault on a drag you cannot accept is what
turns "no" into a silent, wrong drop. The sidebar takes a project and a
task on the same row and has to tell them apart.

Two things fell out of building it. The month grid has always had one cell
narrower than the other six: `.today` styled the "I dag" button in the
header, and the day cell for today wears the same class, so `margin-left:
auto` was shrinking a grid item to its content. It is scoped to the header
now. And a project page listed whatever was in the store rather than what
belonged to that project, which did not matter until a task could leave a
project while you were looking at it.

Covered by eight new API tests and two new smoke tests, and the smoke tests
drive the drags for real — the type check they exercise exists only while
a drag is in flight, so nothing else can reach it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 19:25:10 +02:00
Kristian WindandClaude Opus 5 2300f1dc3b fix(theme): the original two themes were the least readable of the five
Adding three themes meant measuring them, and measuring them meant
measuring the two that shipped. They came last.

--ink-faint is the tier that decides whether an interface is readable:
dates, counts, hints and placeholders all live there, and it is the first
thing to disappear. Dark sat at 3.8:1 against its ground and light at
3.1:1, where AA asks for 4.5. Light's accent — which is text as often as
it is a background, on links and on "open the project" — sat at 3.8:1
too.

All five now clear 4.5:1 at every tier. The numbers are in the code
beside the colours, because the next person to nudge a hex should measure
rather than look: this is exactly the kind of change that looks fine to
whoever makes it.

Left alone earlier today as a deliberate scope call — recolouring
somebody's app as a side effect of adding themes is not a side effect
anyone asked for — and done now that it was asked for.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 18:29:56 +02:00
Kristian WindandClaude Opus 5 1529c87231 feat(theme): five themes, and a picker that shows them
Release / Build and publish image (push) Failing after 9m58s
Two themes and a toggle between them. Now five, chosen from a grid under
Settings → Konto: the two that existed, a warm dark (Skumring) with amber
where the green was, a warm light (Papir), and a high-contrast one.

Each swatch is painted in its own theme rather than described, so the
choice is made by looking. data-theme sits on the swatch and not on the
card — it redefines --ink too, and a card wearing a dark theme's ink on a
light page has a name nobody can read.

Every theme states its full palette rather than inheriting and patching.
A partial theme looks finished until somebody adds a token, at which
point four themes silently fall back to a colour from a fifth. That is
also why the dark palette is repeated under [data-theme='dark']: :root
carries it as the default and :root only ever matches <html>, so a swatch
asking for it by name would otherwise show whatever the page was wearing.

The theme-color meta is updated too, so an installed PWA's frame and a
phone's address bar follow rather than staying whichever colour shipped
in the markup.

Contrast measured for all five. The new ones clear 4.5:1 down to the
quietest tier — which is where dates and counts live, and the first thing
to disappear. Kontrast reaches 8.9:1 there. The two original themes are
left as they are: their faint ink sits at 3.1–3.8, which is worth
revisiting, but not as a side effect of adding colours.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 17:27:18 +02:00
Kristian WindandClaude Opus 5 f15c440942 feat(realtime): keep projects and labels current too, not only tasks
The socket carried task events and nothing else, so a project or a label
created anywhere — another tab, another device, Claude through the
connector — did not appear until a reload. Tasks updated live and the
sidebar did not, which reads as the sidebar being broken rather than as a
gap in what is published.

Projects and labels go to the *user* rather than to a project. A project
that has just been created has no subscribers, because nobody was
watching a thing that did not exist a moment ago.

Labels are announced as "something changed" and re-read, rather than sent
as a payload. The sidebar wants each label's task count, which no single
event carries and which moves whenever a task gains or loses a label —
including through quick add, where "@regnskab" invents the label inside a
task write and no label event is sent at all. Comparing the label lists
on a task change is what catches that, without re-reading on every ticked
checkbox.

And projects now go through an upsert, because the same row arrives twice
— once as the response to the request that created it, once over the
socket. Appending both puts two ids into a keyed {#each}, which throws
and stops the sidebar rendering. The tasks already had that guard; adding
the project event without it reintroduced the identical bug, and the
smoke test caught it on the first run.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 17:27:18 +02:00
Kristian WindandClaude Opus 5 d37d240625 feat(search): find a task by its label
Search covered task text only, so "regnskab" found nothing with the task
labelled @regnskab sitting in front of you — which reads as the search
box having lost it.

A label is often the only thing you remember. "That thing about the
accounts" is a search for @regnskab, not for any word in the title.

The label side is a plain lowercase match rather than a second FTS index.
Labels are short, and their names are typed by the same person now
searching for them, so there is no spelling to be generous about — the
diacritic folding that earns its keep on task text does not earn a second
index here.

The join sits inside the WHERE the visibility rules already build, so
somebody else's label cannot pull their task into your results. There is
a test for that, because it is the kind of thing an OR is good at
breaking.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 17:27:18 +02:00
Kristian WindandClaude Opus 5 3b521b463c feat(projects): add, rename and delete sections
The board has drawn a column per section since it was written and there
was no way to make one. Sections could only be created by the Todoist
importer or a template — so an ordinary project could never have a
heading in it at all.

Add, rename and delete, in the list view where the headings already are.
The controls appear on hover rather than sitting beside the heading
permanently: a heading with two buttons always next to it stops reading
as a heading.

Deleting says how many tasks are in the section and that they will
survive it, because the API keeps them and drops their section — which is
the right behaviour and not what "delete section" sounds like. The list
is re-read afterwards, since those tasks move to the unsectioned group
and nothing else would tell the view they had.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 17:27:18 +02:00
Kristian WindandClaude Opus 5 9056c44c9a feat(tasks): move, repeat, delegate and re-read a task from its drawer
Four things you could not do to a task you had open, and one that had
never worked at all.

**Project, recurrence and assignee** are now fields in the drawer. A task
created in the wrong place could only be fixed by dragging it, a repeat
could only be set by typing it into the title when the task was created,
and delegation had no interface whatsoever despite assignee_id being in
the API from the start. The assignee only appears on a project with
somebody else in it, because on your own there is nobody to choose.

**The title is read the way quick add reads it.** Typing "i morgen kl 14
p1" into a title used to leave it as those words, so where you typed a
sentence decided what it meant. The parse runs on the server — the
grammar is Danish and English and lives there — and applies only when it
consumed something. What it took is said out loud, and the fields on
screen are updated to match: the drawer otherwise seeds its inputs once
per task so a save cannot overwrite what you are typing, and that rule
has to bend exactly here, where the save changed the very fields in front
of you.

**And the member list has never worked.** The share panel, and now the
assignee field, ask for it. Every call errored: the ORDER BY named an
expression, and SQLite only accepts result-set columns across a UNION, so
it failed at query time with "1st ORDER BY term does not match any column
in the result set". Found by the smoke test that opens a task, which is
the second real bug that suite has caught by watching for failed requests
rather than only for what renders.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 17:27:18 +02:00
Kristian WindandClaude Opus 5 afc46a5c20 fix(api): do the things the API says yes to
Release / Build and publish image (push) Failing after 9m58s
Three reports from somebody driving the app through the MCP connector,
all the same shape: an argument accepted, an OK returned, and nothing
done.

**project_id on an update never moved the task.** Both the REST handler
and the MCP tool read it, both checked the caller's rights on the
destination — which reads as "understood" — and then neither passed it
on, because store.TaskUpdate has no such field: a project change is a
move, since sort_order is per project. So the task stayed where it was
and the answer said OK. Moving a task between projects was impossible
except by creating it again and closing the old one, which is what the
person ended up doing. Both paths now go through one helper, so they
cannot drift apart again.

**An unknown #project vanished without a word.** The parser reads
"#3Dekoration" correctly; the lookup then fails, the task goes to the
Inbox, and the name is gone from the title with nothing anywhere saying
why. Still not an error — a thought should not be thrown away over a typo
— but quick-add now returns unknown_project, and the MCP tool returns a
note written for a model to relay. The old behaviour was kind in the web
interface, where you see the row appear in the wrong place and fix it. In
a chat nobody sees the row.

**Sub-tasks were unreachable over MCP.** create_task had no parent_id,
though the REST API has had it all along, so importing a parent with
seven children produced eight unrelated tasks with the parent's name
prefixed onto each. A sub-task joins its parent's project — the two
cannot disagree — and a second level is refused rather than silently
flattened.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 16:24:58 +02:00
Kristian WindandClaude Opus 5 8c748c2a88 feat(sidebar): drag it wider, fold it away, and have it stay that way
The sidebar was 268px and always there. On a wide monitor that wastes the
room; on a laptop it takes room the list needs.

The width drags from a handle on the border — 7px of target over a 1px
line, because a 1px target is a target you miss — and the whole column
folds away from a button in the topbar. Both are remembered.

In localStorage rather than on the account: this is a property of the
screen you are sitting at, not of you. The same person on a laptop and a
wide monitor wants different answers, and syncing it would make one of
them wrong every time they switch.

The handle is also a slider for the keyboard. Arrow keys move it, Home
puts it back, and so does a double-click — a drag handle with no keyboard
equivalent is a setting some people simply cannot reach.

Folding is desktop-only, and the media query says so rather than leaving
it to chance: below the breakpoint the sidebar is a drawer that opens over
the page, and a collapsed drawer would open to nothing. Clamped to
200–480px in the store, so a stored value from an older build cannot make
it unusable either.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 16:09:16 +02:00
Kristian WindandClaude Opus 5 c4e9e1519b feat(projects): drag the sidebar into the order you want
Projects have carried sort_order since the first migration and nothing
ever set it, so the sidebar was ordered by creation date for good.

POST /projects/reorder takes the whole list rather than one move. A
person has a handful of projects, so sending the order you want is
simpler to reason about than midpoints between neighbours — and it cannot
run out of precision, which is the failure the task ordering has to
respace a section to recover from. It also cannot land half-applied the
way a sequence of individual moves can.

Projects you do not own are skipped rather than refused. sort_order is a
column on the project, not a preference per viewer, so a shared project
sits where its owner put it and a member rearranging their own sidebar
does not rearrange somebody else's. The sidebar only makes your own
projects draggable, so that rule never shows up as a row that springs
back.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 15:59:31 +02:00
Kristian WindandClaude Opus 5 3f4af6c0ab fix: stop the app shell answering for /.well-known/
Release / Build and publish image (push) Failing after 9m56s
Adding the connector to Claude failed with "Couldn't register with
Verdande's sign-in service", on a server that has no sign-in service and
needs none.

Claude probes /.well-known/oauth-authorization-server before connecting to
an MCP server. The SPA fallback answered 200 with a page of HTML — which
reads as "yes, there is an authorization server here" — so it went on to
dynamic client registration and failed there instead of concluding the
obvious thing, which is that this server does not use OAuth and the key in
the URL is the whole credential.

/.well-known/ is reserved by RFC 8615 and is never a route in this app. A
404 is the true answer, and it is also the useful one: the client stops
asking and uses the URL as given.

This is the third time the catch-all has caused a confusing failure by
saying 200 to something that does not exist — /mcp itself was the second.
The fallback should hand back the shell for routes the app might have, not
for every path on the server.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 15:21:35 +02:00
Kristian WindandClaude Opus 5 7ab68b0b58 fix(projects): keep the heading, and make the sidebar test mean what it says
Renaming replaced the project page's h1 with an input, so a page an owner
opened had no heading at all. The sidebar smoke test caught it on CI — and
it was right to: every other view here has a heading, and it is what a
screen reader announces on arrival.

The heading stays and becomes a field when clicked. Same rename, one more
click, and the page keeps its outline.

The more interesting half is why that passed locally. The test clicked a
link and immediately asserted a heading was visible — and `toBeVisible`
was being satisfied by the heading of the page it had just left, before
the SPA swapped it out. On this laptop the old h1 lingered long enough to
answer; on a slower runner it did not. So the test did not check that the
new page rendered, it checked that some page had at some point.

waitForURL closes that. Verified by putting the broken heading back with
the strengthened test in place: it now fails locally with the same message
CI gave.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 15:10:00 +02:00
Kristian WindandClaude Opus 5 8a6e1bc39e feat(projects): rename, delete, and a trash you can actually reach
A project could be created and shared and never renamed or deleted. Both
have worked through the API since the beginning; there was simply nothing
to click.

The heading is now the field — a heading that happens to be editable
rather than a form that looks like one — and saves on blur, with Escape
putting the old name back. Owner only, and the Inbox keeps its plain
heading because it cannot be either renamed away or deleted.

Deleting needed more than a button. It is soft, with thirty days to
change your mind, and restoring has been possible the whole time — from
an id, which is exactly what you no longer have once the project has gone
from the screen. A recovery window nobody can reach is not one. So
GET /trash/projects lists what you have deleted, with how many tasks went
with it and how long is left, and Settings → Data has the button.

Owned, not shared: a member of a project somebody else deleted has no
business bringing it back, and there is a test that says so.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 15:02:41 +02:00
Kristian WindandClaude Opus 5 0dbea0ea4b feat: make it possible to see which version is actually running
Release / Build and publish image (push) Failing after 9m56s
"I have updated and restarted, but I cannot tell whether it worked" is
not a question the panel could answer. The version was on the startup
line and in the settings page, and nowhere you can reach from outside.

/healthz carries it now. That is the one endpoint answering without a
session, which makes it the only way to ask the question from a terminal
or from the panel itself. The "listening" line repeats it too, because
that is the line the Rune's done_regex matches and therefore the one
sitting at the bottom of the console after a deploy.

The update notice could also never have worked. It reads GitHub's
releases/latest, and this repository had only tags — a tag is not a
release, and that endpoint answers 404 for a repository without any. The
release workflow creates one now, after the image is pushed so a release
never announces a version that failed to build.

VERDANDE_UPDATE_CHECK is a Rune variable, so it can be switched on
without editing the container. Still off by default: it is an outbound
request, and that is a choice to make rather than a default to inherit.

Rune version 1 -> 2, so the panel offers the update.

Note that the check still cannot see anything while the repository is
private — releases/latest is 404 anonymously, exactly as the GHCR package
was. Making the repo public, or teaching the check to carry a token, is a
separate decision.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:35:36 +02:00
Kristian WindandClaude Opus 5 78ec9b08a1 fix: say the true thing when Gmail, AI or mail is not set up
Two bugs with one shape: the interface confidently told you something
that was not so.

Connecting Gmail without a registered OAuth client said "Det er der
allerede." Thirteen different situations answered a plain `conflict`, and
the frontend renders that as "that already exists" — which for four of
them is not vague but wrong. An unconfigured Gmail is not a Gmail that is
already connected; a second factor that is off is not one that is on; the
Inbox is not a project that already exists somewhere else. Those four
have their own codes now, and their own sentences.

The mail-to-task address came out as todo+…@localhost. VERDANDE_SMTP_FROM
defaults to verdande@localhost and the domain was taken from it
unconditionally, so an instance with no mail server handed out an address
that looks real, can never receive anything, and gives no hint which of
the two it is. The default no longer wins over the public hostname, and
the response now carries whether a mail server exists at all — because
even with the right domain, nothing routes to it until one does.

errors.go has always claimed the code list is kept in one place "so the
frontend's mapping can be checked against it". Nothing checked it, and
the mapping was missing bad_request. Now a test reads both files and
fails on a code with no message.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:35:36 +02:00
350 changed files with 68708 additions and 2948 deletions
+46
View File
@@ -0,0 +1,46 @@
name: Bug
# Gitea kalder feltet `about`, hvor GitHub kalder det `description`, og Gitea
# *kræver* det: en skabelon uden bliver afvist frem for at blive vist. Derfor
# ligger skabelonerne her for sig frem for at være delt med `.github/`.
about: Something does not work
labels: [bug]
body:
- type: textarea
id: what
attributes:
label: What happened
description: And what you expected instead.
validations:
required: true
- type: textarea
id: steps
attributes:
label: How to reproduce it
placeholder: |
1. …
2. …
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: The image tag, or the commit you built from.
validations:
required: true
- type: dropdown
id: how
attributes:
label: How are you running it
options:
- As a Rune in Yggdrasil Panel
- Docker directly
- From source
validations:
required: true
- type: textarea
id: logs
attributes:
label: Anything in the logs
description: Please remove tokens and email addresses first.
render: text
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security issue
url: https://gitea.nolimit.dk/kw/verdande/src/branch/main/SECURITY.md
about: Please report vulnerabilities by email, not as a public issue.
+24
View File
@@ -0,0 +1,24 @@
name: Feature
about: Suggest something
labels: [enhancement]
body:
- type: markdown
attributes:
# Absolut adresse, ikke GitHubs `../../blob/main/…`: Gitea lægger en fil
# under `/src/branch/<gren>/`, så den relative form peger ingen steder her.
value: |
Please check the [non-goals](https://gitea.nolimit.dk/kw/verdande/src/branch/main/README.md#non-goals)
first — some things are deliberately not being built.
- type: textarea
id: problem
attributes:
label: What are you trying to do
description: The problem, not the solution. What does the current version make hard?
validations:
required: true
- type: textarea
id: idea
attributes:
label: What you had in mind
validations:
required: false
+293
View File
@@ -0,0 +1,293 @@
name: CI
# Gitea-tvillingen til .github/workflows/ci.yml. Den prøver at nå samme resultat
# ad en anden vej, og forskellen er hele pointen med filen:
#
# INGEN `uses:`. Gitea Actions kan læse GitHubs format, men `DEFAULT_ACTIONS_URL`
# står ikke i app.ini på gitea.nolimit.dk, og standarden er github.com — så et
# `uses: actions/checkout@v4` her ville blive hentet FRA GitHub, hver gang, også
# selvom både koden, runneren og registret er vores egne. Et workflow, der bruger
# markedspladsen, er derfor ikke uafhængigt af GitHub; det ser bare sådan ud.
#
# Prisen er, at værktøjskæden skal komme et sted fra. Den kommer fra jobbets
# container, hvilket er billigere end det lyder: `container: golang:1.26` er
# nøjagtig det, setup-go ellers ville bruge et trin på at installere.
#
# Alternativerne, hvis nogen senere vil skrive filen om: sæt DEFAULT_ACTIONS_URL
# til gitea.com, som spejler de gængse handlinger — men det flytter kun
# afhængigheden fra én andens server til en andens, og det er en indstilling for
# HELE instansen, altså også for alt andet, der bygger der. Denne fil rører kun
# dette repo.
on:
push:
branches: [main]
pull_request:
# Så en kørsel kan udløses uden at skulle opfinde en commit. Det er ikke
# bekvemmelighed: uden den er den eneste måde at få CI til at køre igen mod
# uændret kode at skubbe noget tomt, og en tom commit i historikken er en
# løgn om, at der skete noget.
workflow_dispatch:
# Ingen `concurrency:`-blok. GitHub-udgaven aflyser en kørsel, når en nyere
# kommer, og det er stadig det rigtige at gøre — men understøttelsen kom sent til
# Gitea Actions, og et felt, runneren ikke kender, er en fejl ved oversættelsen
# frem for en ignoreret linje. Den kan sættes på, når instansen er en version,
# der kan.
jobs:
go:
name: Go
runs-on: ubuntu-latest
# Containeren ER værktøjskæden. `-race` kræver en C-oversætter, og den er med
# i det officielle golang-image — et slankere ville koste et apt-trin.
container:
image: golang:1.26
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
# Håndrullet, fordi actions/checkout ville blive hentet fra GitHub.
# Det er de samme fire greb, den handling tager: init, remote, en
# fetch af præcis den commit, der udløste kørslen, og et checkout.
#
# Legitimationen sættes med -c på selve fetch-kommandoen frem for i
# remotens adresse. En adresse med token i havner i .git/config, i
# `git remote -v` og i enhver fejlbesked, der citerer den; en header
# gør ikke.
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# To former for basic auth, fordi kun en kørende runner kan svare på,
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
# ét bit at vide er for dyrt.
godkendt=
for bruger in x-access-token "$GITHUB_ACTOR"; do
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
godkendt=$bruger
break
fi
done
if [ -z "$godkendt" ]; then
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
exit 1
fi
echo "checkout: godkendt som $godkendt"
git checkout -q FETCH_HEAD
- name: Formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt'd:"
echo "$unformatted"
exit 1
fi
- name: Vet
run: go vet ./...
# -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
# -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 ./...
spec:
name: OpenAPI is valid
runs-on: ubuntu-latest
container:
image: node:22
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# To former for basic auth, fordi kun en kørende runner kan svare på,
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
# ét bit at vide er for dyrt.
godkendt=
for bruger in x-access-token "$GITHUB_ACTOR"; do
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
godkendt=$bruger
break
fi
done
if [ -z "$godkendt" ]; then
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
exit 1
fi
echo "checkout: godkendt som $godkendt"
git checkout -q FETCH_HEAD
# Structural validity only. That the spec matches the *router* is checked by
# a Go test, which is the half that actually goes stale.
- run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
e2e:
name: End-to-end
runs-on: ubuntu-latest
# node frem for golang her: Playwrights browsere trækker et lag
# Debian-pakker, som npm-imaget er vant til at have, mens Go til gengæld er
# fire linjer at hælde ind. Den anden vej rundt er den dyre.
container:
image: node:22
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# To former for basic auth, fordi kun en kørende runner kan svare på,
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
# ét bit at vide er for dyrt.
godkendt=
for bruger in x-access-token "$GITHUB_ACTOR"; do
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
godkendt=$bruger
break
fi
done
if [ -z "$godkendt" ]; then
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
exit 1
fi
echo "checkout: godkendt som $godkendt"
git checkout -q FETCH_HEAD
- name: Go
run: |
set -eu
# Versionen læses ud af go.mod frem for at stå her. To steder at rette
# er ét for mange, og den slags glemmes lige indtil CI bygger med en
# anden Go end udviklerne.
version=$(awk '/^go /{print $2; exit}' go.mod)
arch=$(dpkg --print-architecture)
curl -fsSL "https://go.dev/dl/go${version}.linux-${arch}.tar.gz" | tar -xz -C /usr/local
echo /usr/local/go/bin >> "$GITHUB_PATH"
- name: Install
working-directory: web
run: npm ci --no-audit --no-fund
# Chromium for the suite, WebKit for one test.
#
# A smoke test exists to catch a build that does not work at all, and running
# the whole thing in three engines finds the same breakage three times for
# three times the cost. Focus is the exception, and it took a real report to
# learn it: Safari ignores `autofocus` on anything inserted after the page has
# loaded, so every inline form in the app opened with no cursor in it. WebKit
# here is the same engine Safari is, and `webkit-forms` in the Playwright
# config greps a single test out of the suite to run in it.
- name: Install browsers
working-directory: web
run: npx playwright install --with-deps chromium webkit
# The suite builds the frontend, embeds it and starts the real binary — see
# playwright.config.js. There is nothing to build here first.
- name: Test
working-directory: web
run: npm run test:e2e
# Ingen rapport at hente ned bagefter. GitHub-udgaven lægger
# web/playwright-report/ op som et artefakt, og det trin ER en handling fra
# markedspladsen — der er ikke en `run:`-form af det. Går suiten i stykker
# her, står grunden i loggen frem for i en zip. Skal rapporten tilbage, er
# det upload-artifact spejlet ind lokalt, ikke en linje mere i denne fil.
docker:
name: Docker image builds
runs-on: ubuntu-latest
# Ingen `container:` her: jobbet skal bruge docker-klienten og buildx, og dem
# har runnerens eget ubuntu-image. En golang- eller node-container ville have
# sproget og mangle værktøjet.
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# To former for basic auth, fordi kun en kørende runner kan svare på,
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
# ét bit at vide er for dyrt.
godkendt=
for bruger in x-access-token "$GITHUB_ACTOR"; do
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
godkendt=$bruger
break
fi
done
if [ -z "$godkendt" ]; then
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
exit 1
fi
echo "checkout: godkendt som $godkendt"
git checkout -q FETCH_HEAD
# 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.
#
# `type=cacheonly` frem for `--load`: resultatet skal ingen steder hen, og
# at hente et image ind i daemonen for straks at glemme det er et halvt
# minut hver kørsel. Én arkitektur er nok til at fange en Dockerfile, der
# ikke bygger; de to rigtige bygges på et mærkat.
- name: Build
run: |
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.
+126
View File
@@ -0,0 +1,126 @@
name: Release
# Samme udløser som GitHub-workflowet: et semver-tag, og ikke andet.
#
# Gitea Actions læser samme format som GitHub Actions, men det er *ikke* det
# samme sted. Filen ligger derfor for sig frem for at være delt: der er intet
# `secrets.GITHUB_TOKEN`, registret er et andet, og der er ingen Releases-side at
# skrive til. En delt fil ville være fire betingede led, der hver kun gælder ét
# af de to steder.
#
# Og som i ci.yml: ingen `uses:`. Handlinger hentes fra github.com, så længe
# DEFAULT_ACTIONS_URL ikke er sat — se den lange forklaring der.
on:
push:
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
jobs:
image:
name: Build and publish image
# `ubuntu-latest` er runnerens egen etiket (`ovh-1`, docker-mode). Ingen
# `container:`: jobbet skal bruge docker-klienten og buildx, og dem har
# runnerens image.
runs-on: ubuntu-latest
env:
REGISTRY: gitea.nolimit.dk
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
# To former for basic auth, fordi kun en kørende runner kan svare på,
# hvilken Gitea tager imod. actions/checkout bruger x-access-token, og
# det er den, økosystemet beviser virker; $GITHUB_ACTOR er reserven.
# Løkken skriver, hvilken der blev godkendt — så ÉN kørsel besvarer
# spørgsmålet, uanset om den ender grøn eller rød. Feedback-sløjfen her
# går gennem en kø og et andet menneske; at bruge en hel runde på at få
# ét bit at vide er for dyrt.
godkendt=
for bruger in x-access-token "$GITHUB_ACTOR"; do
header="Authorization: Basic $(printf '%s:%s' "$bruger" "$TOKEN" | base64 | tr -d '\n')"
if git -c http.extraheader="$header" fetch -q --depth=1 origin "$GITHUB_SHA"; then
godkendt=$bruger
break
fi
done
if [ -z "$godkendt" ]; then
echo "checkout: hverken x-access-token eller $GITHUB_ACTOR blev accepteret"
exit 1
fi
echo "checkout: godkendt som $godkendt"
git checkout -q FETCH_HEAD
# setup-qemu-action i rå form. Uden den bygger buildx kun værtens egen
# arkitektur, og arm64 er ikke valgfrit: et hjemmelab er lige så tit en
# Raspberry Pi eller en Apple silicon-VM som en x86-kasse.
- name: Emulation for the other architecture
run: docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64
- name: Builder
run: docker buildx create --use --name release-verdande 2>/dev/null || docker buildx use release-verdande
# `GITEA_TOKEN` er automatisk til stede i et Actions-job og har lov at
# skrive pakker for den, der ejer repoet. Ingen hemmelighed at sætte op.
# --password-stdin frem for --password: det sidste ville stå i processens
# kommandolinje, som enhver på maskinen kan læse.
- name: Log in to the registry
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: printf '%s' "$TOKEN" | docker login "$REGISTRY" -u "$GITHUB_ACTOR" --password-stdin
# metadata-action i rå form.
#
# v1.4.2 udgiver 1.4.2, 1.4, 1 og latest — så en rune, der er bundet til
# :1, får rettelser uden nogensinde at krydse et hovedversionsskifte.
#
# En forudgivelse (v1.4.2-rc1) får KUN sit eget nøjagtige mærkat. At lade
# en rc overskrive :latest eller :1 ville sende den ud til alle, der troede
# de fulgte det stabile spor — og mærkatmønsteret ovenfor lukker dem ind.
- name: Work out the tags
id: tags
run: |
set -eu
version=${GITHUB_REF_NAME#v}
image="$REGISTRY/$GITHUB_REPOSITORY"
args="--tag $image:$version"
case "$version" in
*-*) echo "Forudgivelse: kun $version." ;;
*)
minor=${version%.*}
major=${version%%.*}
args="$args --tag $image:$minor --tag $image:$major --tag $image:latest"
;;
esac
echo "args=$args" >> "$GITHUB_OUTPUT"
echo "Mærkater: $args"
- name: Build and push
run: |
set -eu
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
${{ steps.tags.outputs.args }} \
--build-arg "VERSION=$GITHUB_REF_NAME" \
--label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
--label "org.opencontainers.image.version=$GITHUB_REF_NAME" \
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
.
# Ingen `cache-from: type=gha`. act_runner har en cache-tjeneste, der
# svarer på det navn, men den er ikke prøvet her, og en kold bygning,
# der tager nogle minutter længere, er billigere end en udgivelse, der
# fejler på noget, som ikke har med bygningen at gøre.
# Ingen Release oprettes.
#
# GitHub-workflowet laver en Release, fordi opdateringsnotitsen i appen læser
# `releases/latest` derfra. Gitea har sit eget Releases-begreb, men notitsen
# peger på GitHub — så en release her ville ingen læse. Skal notitsen flyttes
# med, er det `internal/update`, der skal have en ny adresse, og det er en
# ændring for sig. (Bemærk også, at GitHub-udgavens softprops-handling taler
# mod api.github.com og ikke kan genbruges her.)
+68 -6
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 ./...
@@ -79,12 +85,18 @@ jobs:
working-directory: web
run: npm ci --no-audit --no-fund
# Chromium only. A smoke test exists to catch a build that does not work at
# all; running it three times in three engines finds the same breakage three
# times and costs three times as much.
- name: Install Chromium
# Chromium for the suite, WebKit for one test.
#
# A smoke test exists to catch a build that does not work at all, and running
# the whole thing in three engines finds the same breakage three times for
# three times the cost. Focus is the exception, and it took a real report to
# learn it: Safari ignores `autofocus` on anything inserted after the page has
# loaded, so every inline form in the app opened with no cursor in it. WebKit
# here is the same engine Safari is, and `webkit-forms` in the Playwright
# config greps a single test out of the suite to run in it.
- name: Install browsers
working-directory: web
run: npx playwright install --with-deps chromium
run: npx playwright install --with-deps chromium webkit
# The suite builds the frontend, embeds it and starts the real binary — see
# playwright.config.js. There is nothing to build here first.
@@ -111,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'
+24 -6
View File
@@ -1,8 +1,21 @@
name: Documentation
# The docs are built on every push so a broken build is caught immediately, but
# only published once GitHub Pages has been enabled for the repository. Until the
# owner decides to publish, this job builds and stops.
# Built on every push, and published from main.
#
# The publishing used to wait for a manual dispatch, with a note saying it would
# run "once Pages is enabled". Pages was enabled — and the condition was never
# changed, so the site was built forty times and published none of them. What it
# showed was a Verdande from before v0.43: /links/, /beacon/ and /notifications/
# all answered 404, months after they were written. Nothing failed, because
# building *is* what the job was asked to do.
#
# The lesson is not about this line. It is that "publish later" needs something
# that fails when later arrives — a switch that is off and silent looks exactly
# like a switch that is on.
#
# Pull requests still build and never publish: `mkdocs build --strict` fails on a
# link to a page that does not exist, and building only after the merge means
# finding that out on `main`.
on:
push:
branches: [main]
@@ -10,6 +23,11 @@ on:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
pull_request:
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
workflow_dispatch:
permissions:
@@ -44,9 +62,9 @@ jobs:
deploy:
needs: build
runs-on: ubuntu-latest
# Only runs once Pages is enabled. Until then the build above still guards
# against a broken docs tree.
if: github.event_name == 'workflow_dispatch'
# From main, and by hand. Never from a pull request — a fork could otherwise
# publish over the site by opening one.
if: github.event_name != 'pull_request'
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
+72 -1
View File
@@ -6,7 +6,9 @@ on:
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
permissions:
contents: read
# write rather than read: the job creates the GitHub Release the update notice
# reads. packages: write is for pushing the image.
contents: write
packages: write
jobs:
@@ -15,6 +17,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Taggen selv, ikke kun den commit den peger på: udgivelsesteksten
# nedenfor står i tagbeskeden, og en overfladisk checkout henter
# commit'en uden den.
fetch-depth: 0
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
@@ -50,3 +57,67 @@ jobs:
VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
# A tag is not a release. The in-app update notice reads GitHub's
# releases/latest, which answers 404 for a repository that only has tags —
# so without this step the check can never find anything, however correctly
# it is switched on.
#
# Created after the image, so a release never announces a version that
# failed to build.
# Tagbeskeden ER annonceringen. Den skrives, når der tagges, på produktets
# eget sprog og til dem, der bruger det — og indtil nu nåede den aldrig
# frem: udgivelsen på GitHub blev fyldt med en autogenereret liste over
# commit-overskrifter, som er skrevet til dem, der læser koden.
#
# Den sidste linje er et link til udgivelsen selv, og den tages af: en
# side, der linker til sig selv, hjælper ingen.
#
# Og `fetch-depth: 0` er ikke nok, hvilket kostede to udgivelser at opdage.
# actions/checkout henter først alle tags korrekt — og overskriver derefter
# netop den ene, der udløste kørslen:
#
# git rev-parse refs/tags/v0.46.0
# git fetch --no-tags origin +0dc4c2f9b8…:refs/tags/v0.46.0
#
# Den henter commit-SHA'en ind i tag-refen, så den annoterede tag bliver til
# en letvægtstag, der peger direkte på commit'en. `%(contents)` giver så
# commitbeskeden, og det var flettecommittens overskrift, der stod som
# udgivelsestekst på v0.45.0 og v0.46.0 — uden at noget fejlede.
#
# Derfor hentes tagget som *ref* igen her, før det læses.
- id: notes
run: |
git fetch --force origin \
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
body=$(git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" \
| sed '\#^https://github.com/.*/releases/tag/#d' \
| sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
# Hver tagbesked begynder med sin egen version. Holder det ikke, læser vi
# noget andet end tagget — og en forkert udgivelsestekst skal fejle her
# frem for at blive opdaget på siden bagefter.
case "$body" in
"${GITHUB_REF_NAME}"*) ;;
*)
echo "::error::Tagbeskeden begynder ikke med ${GITHUB_REF_NAME}." \
"Læste vi en letvægtstag? Første linje: $(printf '%s' "$body" | head -1)"
exit 1
;;
esac
{
echo 'body<<VERDANDE_EOF'
printf '%s\n' "$body"
echo VERDANDE_EOF
} >> "$GITHUB_OUTPUT"
- uses: softprops/action-gh-release@v2
with:
body: ${{ steps.notes.outputs.body }}
# Stadig sat: den autogenererede liste lægges *under* teksten ovenfor,
# så både den, der vil vide hvad der er nyt, og den, der vil se hvilke
# ændringer det var, kan læse det samme sted.
generate_release_notes: true
make_latest: true
+33
View File
@@ -11,6 +11,16 @@
# Frontend
node_modules/
# iCloud syncs ~/Documents, and it evicts what it thinks is cold — which for a
# dependency tree is nearly all of it. An evicted file blocks on read until it is
# downloaded again, and a download that stalls hangs the build for good: 0 % CPU,
# no output, forever. macOS never syncs anything ending in .nosync, so the tree
# lives there and `node_modules` is a symlink to it. Redo it after a fresh clone:
#
# mv node_modules node_modules.nosync && ln -s node_modules.nosync node_modules
#
# The real fix is a repo path outside a synced folder.
node_modules.nosync/
/web/.svelte-kit/
/web/build/
@@ -34,6 +44,11 @@ node_modules/
# 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/
@@ -45,3 +60,21 @@ node_modules/
# the process is killed, which during a hung build is exactly what happens. One
# was committed by accident.
/web/vite.config.js.timestamp-*.mjs
# A compiled `go build ./tools/shots` in the repo root. It was committed once by
# accident — 8.9 MB of macOS binary — and `go build ./tools/shots` without -o puts
# it right back.
/shots
# The working handover. It names the panel, the server id and the production
# address — a map of one person's setup, which does not belong in a public repo.
# Kept on disk because it is how a session hands over to the next one.
HANDOFF.md
NOTES-PLAN.md
# Samme grund: instruktionen til flytningen navngiver kassen, runneren, registret
# og hvordan man kommer til dem. Arbejdspapir, ikke produktdokumentation.
GITEA-FLYTNING.md
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
.claude/worktrees/
+55 -4
View File
@@ -5,11 +5,29 @@
# manager and no libc — the attack surface of the running container is the binary.
# --- 1. the web interface ----------------------------------------------------
FROM node:22-alpine AS web
#
# Pinned to the *build* platform, not the target. What this stage produces is
# HTML, CSS and JavaScript, which are the same bytes whatever the image will run
# on — so building it once and copying it into both architectures is not an
# optimisation, it is the correct thing.
#
# Without the pin, buildx runs this stage once per target, which means running
# Node and esbuild under QEMU for arm64. That crashes: exit 132, SIGILL, an
# instruction the emulator does not implement, roughly one release in three. It
# looks like a broken build and is a broken emulator.
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
@@ -24,7 +42,12 @@ RUN if [ -f package.json ]; then \
fi
# --- 2. the binary -----------------------------------------------------------
FROM golang:1.26-alpine AS build
#
# Also pinned to the build platform, and cross-compiled instead. Go does that
# natively and CGO is already off, so there is nothing to emulate — where
# building *in* the target architecture means running the whole Go toolchain
# under QEMU for one of the two.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
WORKDIR /src
@@ -37,19 +60,46 @@ COPY . .
COPY --from=web /src/build ./cmd/verdande/webbuild
ARG VERSION=dev
# Supplied by buildx per target. The default keeps a plain `docker build` working.
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.
RUN CGO_ENABLED=0 GOOS=linux go build \
#
# "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 \
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
-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
@@ -61,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
+437
View File
@@ -0,0 +1,437 @@
# Notes, as verdande builds them
A description of one working notes system: the data model, the syntax, the search,
the rendering contract, the import and export formats, and — more useful than any
of it — the decisions that look arbitrary until you know what they cost.
It is written to be lifted into another program. Nothing here depends on Go,
SQLite or Svelte except where it says so; where an answer was forced by the tool,
that is called out so you can pick a different one on purpose rather than by
accident.
---
## Read this part first: what to ask before you build
**This document is not a drop-in. It describes a notes system that lives inside a
task app with projects, roles, a trash and a search box, and about a third of its
design is inherited from that.** Lifting it into a program with different
surroundings without deciding which parts to keep produces something that works
for a week and then fights you.
**If you are an assistant working from this file: ask these questions and get
answers before writing code.** Do not guess a default. Each one changes what you
build, not just how you build it.
### About the surroundings
1. **Does the host app already have a place to file things** — projects, folders,
notebooks, tags? verdande deliberately gave notes no hierarchy of their own and
filed them under the projects that already existed. If your app has nothing,
you need an answer for where a note lives, and "a folder tree" is the expensive
one.
2. **Is there more than one person?** Sharing, roles and "who wrote this" run
through the whole model below. Single-user changes the schema and removes about
half the permission surface.
3. **Is there already a trash, and does it expire?** Notes lean on one. Without
it, deleting is either permanent or you are building a trash.
4. **Is there an existing full-text index?** The search here is FTS5-shaped. On
Postgres it is `tsvector`, on a client-side app it is probably a scan, and the
ranking section below changes accordingly.
### About the notes themselves
5. **Markdown, rich text, or blocks?** verdande stores Markdown and renders it.
That decision is defended below; it is also the one that most constrains
everything else.
6. **Is the first line the title, or is there a title field?** Both work. Deriving
it means one less thing to keep in step and one less thing a person can leave
blank, and it forces the rule "the first line is a heading" on the editor.
7. **What links to what?** `#project`, `[[note]]` and task references exist here
because the app has projects and tasks. Yours may have none of them, or others.
8. **Do notes carry files?** Attachments bring content-type handling, a store, an
authorisation path and an XSS surface. Skipping them is a legitimate answer.
### About scale and language
9. **How many notes, realistically?** Several choices below flip at around ten
thousand: the list payload, the search fallback, rendering the whole list.
10. **Which languages?** The folding described under *Search* is Danish. Every
language has its own version of that problem and the generic answer (strip
diacritics) is wrong for several of them.
11. **Where does the text end up besides the screen?** Search indexes, AI
features, exports and backups each read it. Plaintext makes all of them cheap
and makes a stolen backup expensive — see *What is still open*.
### About integration, specifically
12. **Who owns the note's identity — you or the other system?** If notes will sync
with something else, ids and timestamps are the whole problem, and the
`updated_at` rule below becomes the first thing you have to change.
13. **Is this a read of somebody else's notes, or the store of record?** A copy
needs conflict rules; a store of record needs export.
14. **What happens to a note whose project, task or link target is deleted?**
Answered here as "the link goes dead and the note is untouched". If your host
app cascades instead, say so before the first delete.
---
## The shape of a note
```
id an opaque string, unique
project_id nullable — where it is filed, or loose
title derived, never set by a caller
body Markdown, the whole note
created_by nullable — survives the account being deleted
pinned favourite; the list puts these first
archived_at nullable timestamp — put away, not thrown away
deleted_at nullable timestamp — the trash
created_at
updated_at
```
### Markdown in a column
Not rich text, and not a format of our own.
Export was a requirement before a line was written, and **if the stored form is
the exported form there is no conversion left to lose anything in.** A note stays
readable by any editor on the day the program is gone. It is also what makes
search and anything an AI does over notes cheap: both read text.
The cost is real and you should know it before choosing: everything the editor can
do has to survive a round trip through Markdown, and anything Markdown cannot say,
the editor must not offer. There is no place to hide a feature.
### The title is the first line
Always, and re-derived on every save — not "when it is empty".
Derived once and then left alone, a title goes stale the moment somebody rewrites
the opening of a note, and the list ends up calling a note by a name that is
nowhere in it. **The column is a cache of the body, not a field beside it**, and no
caller can set it, so every caller gets the same answer.
This forces one editor rule: a new note opens on its title, and the first line is
styled as a heading. Otherwise a note whose first line is a sentence is a note
named by its first sentence.
### Archive and trash are two different bits
The trash says *this was a mistake, and in thirty days it is gone*. The archive
says *this is finished, and I do not want to read past it.*
A twelve-hundred-note import is exactly the case where most notes are worth
keeping and almost none are worth looking at. Without an archive, the list is the
whole archive forever, and finding this week's note means reading past a decade.
Both are nullable timestamps rather than booleans: *when was this put away* answers
questions a flag cannot, and it costs the same byte.
---
## Filing: no hierarchy of its own
A note belongs to a project, or to nobody.
The reasoning is worth repeating because it is easy to talk yourself out of:
projects already exist, are already shared, already have roles, already have a
trash. **A second hierarchy to file things in is one too many**, and a note filed
under a project inherits every one of those answers instead of needing its own.
- A note with a project: everybody who can read the project can read the note; an
editor can change it.
- A note without one: the author's, and nobody else's.
Sharing a note *is* filing it in a project. There is no access list of its own —
a second way to give somebody access is a second place to get it wrong, and the
two would disagree the day one of them changed.
---
## Links: three kinds, parsed out of the text
The body is the truth. Links are an index over it, rebuilt on every save.
| Syntax | Means | Notes |
|---|---|---|
| `#project` | a project | Same characters the task quick-add parser uses, on purpose |
| `[[a note]]` | another note, by title | The spelling every note app has settled on |
| `/opgave/<uuid>` or `task:<uuid>` | a task | What a link pasted out of the app carries |
Patterns as used here:
```
project (?:^|\s)#([\p{L}\p{N}_-]{1,64})
note \[\[([^\]\n]{1,200})\]\]
task (?:/opgave/|task:)([0-9a-fA-F-]{36})
```
Three decisions inside that table:
- **`#Firma` in a note means the same project as `#Firma` in the task box.** Not a
second kind of tag that happens to look alike. That is the whole of "tag a note
and see it in the project".
- **The link table is torn down and rebuilt on save, not diffed.** A body is small
and the set is small; a diff is a second description of the same fact, and it
can disagree with the text. That is the one thing this must never do.
- **The target is not a foreign key.** A link to something deleted is a fact about
the note. It should read as a dead link rather than vanish, and it must not
block the delete.
The index exists so the question can be asked **backwards**: *what has been written
about this task?* Answering that by searching every note's prose would be too slow
to leave a panel open on.
---
## Search
Three layers, in the order they answer.
### 1. Full-text, ranked
FTS5 over `title`, `body` and a folded copy, with `unicode61 remove_diacritics 2`.
Ranking is bm25 with **the title weighted ten times the body**. A word in the title
is what the note is *about*; the same word once in a long body is a mention.
This matters more than it sounds. Ordering by "last touched" is invisible with ten
notes and useless with twelve hundred imported the same evening: searching "Tesla"
answered with a note about nginx first and the note actually called "Tesla Model Y"
second — which reads as a search that does not work, because from where the person
is sitting it is one.
### 2. Folding, for the language you are in
`remove_diacritics` does not touch `ø`, `æ` or `å` — Unicode considers them
letters in their own right, not accented forms. So a Dane cannot find "grøn" by
typing "gron", which is how people type when the keyboard is not theirs.
The answer here is a generated column carrying a transliterated copy:
```
ø,Ø → o æ,Æ → ae å,Å → aa
```
indexed alongside the body and **weighted the same as the body** — otherwise a
folded match outranks an exact one.
> **Ask, do not copy.** This mapping is Danish. German wants `ü → ue`, Swedish
> disagrees with Danish about `ä`, and Turkish has a dotless `ı` that breaks
> naive lowercasing entirely. Get the mapping from somebody who types the
> language.
### 3. A word inside a word
An index is built of tokens, and a token is whatever the tokenizer decided was a
word. `gamerule keepInventory true` is three of them, and the middle one is
`keepinventory` — so a search for "keep inventory" asks for two words that are
nowhere and gets nothing, while the note sits right there.
A prefix query gets half of it: `keep*` matches, `inventory*` cannot, and the two
are ANDed, so the half that works is thrown away by the half that cannot.
**This is not exotic in a notes app.** A decade of notes is full of commands,
identifiers and file names, and every one of them is several words the index
thinks is one.
So: the index answers first; when it has nothing, the text is read, and every term
must appear somewhere in the note, in any word. A `LIKE '%term%'` over the folded
column covers title, body and language folding at once.
What it costs: a leading `%` cannot use an index, so it scans. At twelve hundred
notes that is a few megabytes and a millisecond or two, and it only runs when the
fast path already came back empty — which is exactly the search that was about to
disappoint somebody. **At a hundred thousand notes it is the wrong shape**, and the
answer then is a trigram index, not a bigger scan.
It does not rank. There is no match to rank — that is the whole situation.
---
## The list, and why the body is empty in it
A list showing a title and ninety characters of preview must not fetch whole
notes. Twelve hundred notes came to 3.9 MB, of which 98 % was body nobody could
see.
The important half is *how* it is trimmed: **the body is emptied, not shortened.**
A field called `body` holding a *piece* of the body is a trap. The editor opens
it, the autosave pause writes it back, and the note is now cut down to its own
excerpt — destroyed by nothing more than having been looked at. Send `preview` as
its own field and leave `body` empty; the client sees the empty body and fetches
the whole note.
That costs one more separation, and it is not optional:
> **Which row is selected and which note is loaded are two different things.**
The editor loads its text when the note's id changes. Hand it the excerpt first
and the whole note second and the id is the same both times — so the second one
never renders, and somebody sits looking at a note that had a title a moment ago.
Highlight the row immediately from the list; give the editor the note only when it
is whole.
Rendering cost, separately: with twelve hundred rows, first paint took 2.1 s. CSS
`content-visibility: auto` with an intrinsic-size guess took it to 0.95 s with no
virtual list, no scroll maths, and no list that can fall out of step with itself.
Try that before building windowing.
---
## The editor contract
Rich text on top, Markdown underneath, and a source view that shows the file.
The supported subset — deliberately small, because everything in it has to survive
the round trip:
| Block | Markdown |
|---|---|
| Title | `# ` |
| Heading | `## ` |
| Subheading | `### ` |
| Quote | `> ` |
| Code block | ```` ``` ```` with an optional language |
| Bullet and numbered lists | `- `, `1. `, nested |
Inline: `**bold**`, `*italic*`, `~~strikethrough~~`, `` `code` ``, `<u>underline</u>`,
and images as `![alt](url)`.
Five rules that were each learned the hard way:
1. **Every line becomes exactly one block.** Markdown normally joins consecutive
lines into a paragraph. Here it must not: the person typed a line and expects
to get it back, and an editor that silently reflows what somebody wrote is an
editor they stop trusting.
2. **Load on id change, never while typing.** Writing to the editor's HTML puts
the caret back at the start, which mid-sentence is unusable.
3. **One definition of what a code block contains.** A copy button inside the
`<pre>` and a syntax highlighter reading `textContent` will disagree, and the
difference ends up in the file — a click on "Copy" wrote the word *Copied* into
the code and the next save put it on disk. Have one function that says what a
block holds, used by the highlighter and the Markdown serialiser both.
4. **A note that cannot be rendered must not take the pane with it.** One note
arriving in an unexpected shape threw inside the editor's reactive effect, and
from then on *no* note would open — the previous one just stayed on screen. Put
a guard around the render, fall back to showing the raw text, and let the rest
of the pane keep working.
5. **Escaping between tags and escaping inside an attribute are different jobs.**
An escaper written for text between two tags handles `&`, `<`, `>` — and not
quotes, because between tags they are harmless. Inside an attribute they close
it. `![" onerror="alert(1)](…)` is the whole exploit.
---
## Attachments
Images pasted or dragged into a note, stored content-addressed by hash, referenced
from the body as ordinary Markdown images.
The security shape, which is the part worth copying:
- **Served as `application/octet-stream` with `nosniff` by default.** Attachments
are the one place a user supplies content another user opens. An uploaded SVG or
HTML file rendered inline runs its own script on your origin with the session
cookie attached.
- **One exception, as a named allowlist:** a short list of raster image types
served inline as their exact type — `png`, `jpeg`, `gif`, `webp`, `avif`, `heic`.
A note carries its pictures in the text, and served as octet-stream every one of
them is a broken image.
- **The list is named one type at a time, never matched on an `image/` prefix.**
The prefix lets `image/svg+xml` through, and an SVG is a document that can run
script.
- **The stored path is checked against the files directory again at read time.** A
traversal that somehow got into the database must not become one that reads
`/etc/passwd`.
- **Reaching an attachment follows the note, which follows its project.** Same
chain as everything else. Do not let a client hand you the parent id and take it
on trust.
---
## Import and export
The same format both ways: **a zip of Markdown files**, one per note, with a YAML
front matter block.
```markdown
---
created: 2019-04-02T10:15:00Z
modified: 2024-11-30T21:04:00Z
---
# Møde med Anders
Han vil gerne have kaffe hver uge.
```
- `created` / `created_at` / `date` and `modified` / `updated` / `updated_at` are
all accepted on the way in. Obsidian and Bear write this block; so should you.
- Images travel in the zip beside the notes, and the links in the text are
relative — **a note and its pictures must end up in the same archive**, which
matters the moment an export is split into parts.
- File names collide: two notes called "Møde" are two notes, and a zip with one
entry twice is a zip that loses one.
Limits that a real import taught, all of them cheap to add and expensive to omit:
- A ceiling on the upload, on the number of entries, and on the **unpacked** size.
64 MB of zip expanded to 66 GB is a normal shape for an attack.
- A per-file limit as well as a whole-archive budget: one enormous photograph and
ten thousand small ones are different attacks with the same ending.
- The entry name is never used as a path. Store by hash; the name is only a key
for rewriting links.
- **Count what you skip, and say so.** A note whose name contained an ellipsis was
dropped silently by a filter looking for `..` — one out of 1141, found only by
counting at both ends.
### Timestamps on the way in
An importer needs to write `created_at` and `updated_at` from the file, and the
normal save path must refuse to let it.
`updated_at` means *when did this program last write this note*. Let any caller set
it and the column means whatever the last writer felt like. Give the importer its
own narrow entry point instead, and say in the comment why it exists.
Without those dates, a move of twelve hundred notes is a pile that was all written
the same evening — and the order they were written in is half of what an archive
is.
---
## What is still open here
Written down because a specification that only lists what works is a specification
that lies by omission.
- **Note bodies are plaintext at rest.** They hold API keys and passwords, and
they are in every backup anybody can download. Encrypting them is bigger than it
sounds: the body is in the full-text index and the folded column is generated
from it, so both are a second plaintext copy in the same database. Encrypt and
both have to go, and search has to be rewritten to decrypt in application code —
fine at a thousand notes, a full scan per keystroke at ten thousand, at which
point the answer is a keyed index (an HMAC per word).
- **Import never merges.** Every run creates new notes. Say so out loud in the UI.
- **There is no sync.** Export and the API are the way out.
---
## A short list of the traps, for the person in a hurry
1. A `body` field holding an excerpt will be saved over the real one.
2. Selected row and loaded note are two different pieces of state.
3. The title must be re-derived on every save or it goes stale.
4. Diacritic stripping is not folding, and folding is language-specific.
5. A tokenizer cannot see a word inside a word; decide what you do about it.
6. An attachment served inline is a script on your origin unless you allowlist by
exact type and send `nosniff`.
7. Escaping for text and escaping for attributes are different functions.
8. One render failure must not be allowed to freeze the whole pane.
9. An unpacked-size limit is not optional on an archive import.
10. Let the importer set timestamps; let nobody else.
+105 -13
View File
@@ -6,7 +6,7 @@
</p>
<p align="center">
<img src="site/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
<img src="docs/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
</p>
---
@@ -15,18 +15,37 @@
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
tested and what is not.
Documentation: [`docs/`](docs/), built with Material for MkDocs.
Documentation: **<https://kristianwind.github.io/verdande/>** — the source is in
[`docs/`](docs/), built with Material for MkDocs and published from `main`.
## Why this exists
Todoist Pro, self-hosted, on your own hardware, with your own data — and with the
integrations that a hosted product will not build for you: a real CalDAV server, a
mail-to-task address, and an MCP endpoint so Claude can work your task list directly.
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
lists — on your own hardware, with your own data, and with the integrations that a
hosted product will not build for you: a real CalDAV server, a mail-to-task address,
and an MCP endpoint so Claude can work your task list directly.
## What it does
@@ -34,10 +53,38 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
@regnskab` becomes a task called "betal moms", due tomorrow at 10:00, priority 1,
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
views.
- **Sharing.** Internal users are created by an admin; anyone else joins through an
invite link sent to their email. Owner, editor and viewer roles.
views, and foldable groups over the projects in the sidebar.
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
lists, quotes, fenced code. A note is shared by filing it in a project, by
handing it to one person — including somebody who has no account yet, who gets an
invitation that becomes the share when they sign up — or by writing `@Sofie` in
the text, which shares it with her and tells her. `#project` and
`[[another note]]` are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
the exporter in [`tools/`](tools/) — and comes back out the same shape.
- **Notes are sealed at rest.** Title and body are encrypted in the database under
a key in a file beside it — the title too, because a title beside a sealed body
leaves the beginning of every note in the open. A copy of the file is not a copy
of the notes.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
everybody arrives through an invite link — to a project, to a single note, or
from **Settings → Brugere** to the instance itself. Nobody's password is ever
chosen by somebody else.
- **Live sync.** A change made by somebody else appears without a refresh.
- **Notifications that are worth reading.** A bell for what other people did to what
you share — folded, so one person typing for five minutes is one line — pushed to
your devices and counted on the app's icon in the Dock.
- **AI that suggests and never writes.** Optional, off by default, your own key and
your own model if you want. It tidies the inbox, pulls the tasks out of a meeting
note, writes a morning plan, turns *"SV: SV: Vedr. levering uge 12"* into the
action the mail asks for, and answers questions about your own notes with links to
what the answer rests on. Every one of them arrives as a line you accept, edit or
refuse — a model that edits your tasks directly has to be right every time to be
worth having.
- **Anything can put a task in the inbox.** A mail to your own forwarding address,
or a POST of one line of text from a shortcut on your phone, a script or any
service that can call a URL.
- **Recurring tasks** as RRULE (RFC 5545), reminders, comments and attachments.
- **Standards, not homegrown formats.** iCalendar and CalDAV, ICS feeds, OAuth2 with
PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
@@ -60,12 +107,24 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Project roles and permission checks | Done, tested |
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
| Today, Upcoming, search, activity log | Done, tested |
| Waiting on others: what you have delegated, by person | Done, tested |
| Assignee shown on every task that is somebody else's | Done, smoke-tested |
| Activity log, visible per project | Done, smoke-tested |
| Change a member's role without removing them | Done, tested |
| Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
| Task detail: description, sub-tasks, comments, files, reminders | Done |
| Settings: account, notifications, integrations, AI, tokens, data | Done |
| Device list: where you are signed in, and signing one out | Done, tested |
| User administration: invite to the instance, promote, remove | Done, tested |
| Server errors kept in the database, readable after a restart | Done, tested |
| PWA icons: PNG for launchers, apple-touch-icon for iOS | Done, smoke-tested |
| Invite and password-reset links that open the right page | Done, smoke-tested |
| Drag to reorder, in both the list and the board | Done |
| Drag a task to another project or another day | Done, smoke-tested |
| Foldable project groups in the sidebar | Done, tested |
| Month view on Upcoming, not only per project | Done, smoke-tested |
| Recurring tasks (RRULE, Danish + English) | Done, tested |
| Reminders, absolute and relative | Done, tested |
| Saved filters with a query language | Done, tested |
@@ -74,21 +133,41 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Project templates | Done, tested |
| Nightly backups with rotation | Done, tested |
| Comments, attachments, notifications, Web Push | Built |
| Notes: rich text, Markdown on disk, links to projects, notes and tasks | Done, tested |
| Notes in and out: a zip of Markdown, both directions | Done, tested |
| Todoist CSV import and export, account export | Done, tested |
| MCP server for Claude | Done, tested |
| CalDAV server (two-way VTODO) | Done, tested |
| Mail-to-task | Done, tested |
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
| Gmail: OAuth2 with PKCE, polling to tasks, unstars what it imports | Done, tested |
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
| Calendar subscriptions: ICS and webcal, several per person | Done, tested |
| Times on tasks, set by dragging in the week grid | Done, tested |
| Notes: archive, grouped list, images, source view | Done, tested |
| Update notice for administrators | Done, tested |
| Personal API tokens | Done, tested |
| OpenAPI 3.1 spec, checked against the router | Done, tested |
| End-to-end smoke tests (Playwright) | Done |
| Documentation, landing page, licence | Done |
| Notes sealed at rest: title and body encrypted in the database | Done, tested |
| `@name` in a note shares it with that person, and tells them | Done, tested |
| Share a project with somebody who already has an account, by name | Done, tested |
| Invitations that have not been taken up stay visible, and can be withdrawn | Done, tested |
| Told when a project is shared with you — and able to leave it | Done, tested |
| Push from other programs: a webhook address per person | Done, tested |
| Today's plan as a card on **I dag**, not only as a notification | Done, tested |
| Search the settings, from the page or from ⌘K | Done, tested |
| Choose which browser a link opens in | Done, tested |
| Images in notes: open full size, copy to the clipboard | Done, tested |
| Install count: two values, once a day, one click to stop | Done, tested |
**Not finished:** Web Push, the AI providers and the Gmail API calls are
**Not finished:** Web Push, the AI providers and the Google Calendar API calls are
implemented against their specifications and have only ever been exercised there,
not against live services. Everything else has an interface: opening a task shows
not against live services. Gmail is no longer in that list — it runs in production,
including the flagging and de-duplication that took a day to get right. Calendar
subscriptions over ICS and webcal are proven; the Google half of the calendar is
not. Everything else has an interface: opening a task shows
its description, sub-tasks, comments, files and reminders, and the settings pages
cover the account, notifications, integrations, AI, API tokens, import, export and
templates.
@@ -100,10 +179,20 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
| | |
|---|---|
| ![Board view](site/screenshots/board.png) | ![Light theme](site/screenshots/today-light.png) |
| ![Board view](docs/screenshots/board.png) | ![Light theme](docs/screenshots/today-light.png) |
| Board view — one column per section. | The light theme. |
| ![A week on Upcoming](docs/screenshots/week.png) | ![A task, opened](docs/screenshots/detail.png) |
| A week at a time, so a task can be dragged across a month boundary. | A task, opened: description, priority, labels, sub-tasks and its files. |
| ![Notes](docs/screenshots/notes.png) | ![The same note as Markdown](docs/screenshots/notes-source.png) |
| Notes, filed in a project to share them. | The same note as its source — which is what the export writes to disk. |
| ![Themes and looks](docs/screenshots/themes.png) | ![Instance history](docs/screenshots/history.png) |
| Five themes and four looks: how bright, and how it reads. | What has been done on the server. |
Regenerate these with `go run ./tools/shots` against a running instance.
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
the real binary, creates its own account and a second one to share with, fills them
with demo data and photographs fourteen surfaces — nothing to set up and nothing to
remember. (`go run ./tools/shots` still works, and is the way to photograph an
instance that is already running.)
## Running it
@@ -174,6 +263,9 @@ to put one that the operator would ever see.
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
## What lives in `/data`
+44 -9
View File
@@ -10,13 +10,28 @@ reply within a week.
- **Passwords** are Argon2id with the parameters recorded in the hash, so they can
be raised later without locking anyone out.
- **Every token** — session, invite, reset, API, calendar feed, mail address — is
stored only as a SHA-256 hash. A database dump contains nothing that can be
presented.
- **Session, invite, reset and API tokens** are stored only as a SHA-256 hash. A
database dump contains nothing that can be presented.
- **The calendar feed address and the mail-to-task address are not hashed.** They
are looked up by their exact value, and they are shown in the interface more
than once, which a hash cannot do. Anybody holding a backup file therefore holds
those two URLs: the feed reads that person's tasks without a login, and the mail
address files tasks into their inbox. Rotating either from **Settings →
Integrations** invalidates the old one. This is written down rather than fixed
because fixing it means the address can only ever be shown once, which is a
decision about how the feature works and not only about how it is stored.
- **Mailbox passwords, OAuth tokens, TOTP secrets and AI keys** are sealed with
AES-GCM under a key kept outside the database — see `internal/secret`. That is
what makes a stray copy of the database less than a stray copy of everything:
the backup does not carry the key.
- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS.
- **Uploads** are stored content-addressed by hash and always served as
`application/octet-stream` with `nosniff`, so an uploaded SVG cannot run script
on this origin.
- **Uploads** are stored content-addressed by hash and served as
`application/octet-stream` with `nosniff` — with one exception, added so that a
note can show the pictures in it: a short allowlist of raster image types is
served inline as its own type. The list is named one format at a time rather
than matched on an `image/` prefix, because the prefix would let `image/svg+xml`
through, and an SVG is a document that can run script. `nosniff` stays, so the
browser must read the file as that image or not at all.
- **The filter language** compiles to parameterised SQL. No typed text is ever
concatenated into a statement.
- **Permission checks** are route middleware, and anything you may not see answers
@@ -24,8 +39,28 @@ reply within a week.
- **The CSP** is computed at startup by hashing the inline scripts in the built
page, rather than allowing `unsafe-inline`.
- **Note titles and bodies are sealed** under the same key, since 0027. Both, not
only the body: a note's title *is* its first line, so a readable title beside a
sealed body would leave the beginning of every note in the clear.
The two plaintext copies went with it. `notes_fts` indexed the body and the
generated `fold` column held a transliterated copy of it — sealing the body while
leaving those would have been theatre, since a stolen file could be read straight
out of the index. Search opens the text in Go instead, which costs 33–43 ms
across twelve hundred notes and is the wrong shape somewhere around ten thousand.
- **Task titles, descriptions and comments are still in the clear**, and so is
`note_links` — ids derived from a note's body, so a stolen file still shows which
projects and tasks a note points at, though not what it says.
## What it does not do
verdande has no rate limiting beyond the login and password-reset endpoints, and
no audit log of reads. It is built to be run behind something — a Cloudflare
Tunnel, a reverse proxy, a VPN — by somebody who knows what they exposed.
Rate limiting covers the endpoints a stranger can reach: signing in, resetting a
password, the second factor, the installation beacon, and inbound mail. **Nothing
else is limited** — an authenticated caller can ask as often as they like, and a
signed-in account that wanted to be a nuisance can be one.
There is no audit log of reads. Who *changed* something is recorded per project;
who looked at it is not.
It is built to be run behind something — a Cloudflare Tunnel, a reverse proxy, a
VPN — by somebody who knows what they exposed.
+53 -2
View File
@@ -17,6 +17,7 @@ import (
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/httpapi"
"github.com/kristianwind/verdande/internal/jobs"
"github.com/kristianwind/verdande/internal/secret"
"github.com/kristianwind/verdande/internal/store"
)
@@ -25,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)
}
@@ -36,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
@@ -54,6 +60,41 @@ func run() error {
defer db.Close()
log.Info("database ready", "path", db.Path())
// Before anything reads a setting. The key lives outside the database on
// purpose: backups can be downloaded through the interface, so a token stored
// in the clear leaves with every copy anyone takes.
box, err := secret.Open(cfg.SecretKey, cfg.DataDir)
if err != nil {
return err
}
db.UseSecrets(box)
// Noter, der stadig ligger åbne, lukkes nu.
//
// Ved opstart og ikke efterhånden som de skrives: et arkiv på tolv hundrede
// importerede noter, som ingen redigerer igen, ville ellers aldrig blive
// forseglet — og det er præcis dét arkiv, der er værd at forsegle. Idempotent,
// så den koster et gennemløb af id'er hver anden gang.
//
// En fejl her stopper opstarten. Halvdelen af noterne lukket er ikke i stykker,
// men en instans, der starter og lader som om den beskytter noget, den ikke gør,
// er værre end en, der siger fra.
if sealed, err := db.SealNotes(context.Background()); err != nil {
return fmt.Errorf("seal notes: %w", err)
} else if sealed > 0 {
log.Info("notes sealed", "count", sealed)
}
// Names created before the app capitalised them on the way in — "andreas"
// rather than "Andreas". Idempotent, so it is a cheap pass of names once it has
// caught up. A failure here does not stop the start: a lower-case first name is
// a blemish, not a broken instance.
if fixed, err := db.NormalizeUserNames(context.Background()); err != nil {
log.Error("normalize user names", "err", err)
} else if fixed > 0 {
log.Info("user names normalized", "count", fixed)
}
httpapi.Version = version
api := httpapi.New(cfg, db, log, webAssets(log))
@@ -64,6 +105,12 @@ func run() error {
defer stopJobs()
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
runner.SyncGmail = api.SyncGmail
runner.SyncMailbox = api.SyncMailbox
runner.SyncCalendars = api.SyncCalendars
runner.Push = api.PushToUser
runner.SendBeacon = api.SendBeacon
runner.DailyPlans = api.DailyPlans
runner.NotifyOfUpdates = api.NotifyOfUpdates
runner.Start(jobCtx)
srv := &http.Server{
@@ -77,7 +124,11 @@ func run() error {
errc := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.Addr)
// The version is repeated here rather than only on the "starting" line
// above: this is the line the Rune's done_regex matches, so it is the one
// sitting at the bottom of the panel console after a deploy — which is
// exactly when somebody wants to know what actually started.
log.Info("listening", "addr", cfg.Addr, "version", version)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
+78 -9
View File
@@ -43,23 +43,92 @@ The key is stored on your account and is **never sent back** to the settings pag
a page that repopulates a password field is one that will eventually leak it into a
screenshot.
## The shape of all of it
**The model proposes; you decide.** Nothing is written until you say yes, and the
proposal is a line in the same syntax you type yourself — `Ring til Anders i morgen
p1 #Firma` — sitting in a field you can edit before you accept it.
That is not caution for its own sake. A model that edits your tasks directly has to
be right *every* time to be worth having. One that suggests only has to be right
often enough to save you some typing, and when it is wrong you have already seen it.
It is also why the suggestion is a line rather than a set of fields: there is one
reading of "i morgen" in this program, and a suggestion goes through the same
[quick add](quick-add.md) parser as anything you type — including after you have
edited it by hand.
The model is given the names of the projects that exist and may name no others. A
`#Projekt` it invented would create one by accident the first time you accepted it,
and a guess must not be able to move a task somewhere you will not look for it.
## What it can do
**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to
seven concrete actions and creates them as sub-tasks. Written in your language.
**Tidy up the inbox.** In the Inbox, the menu → *Ryd op i indbakken*. For each loose
line it suggests how you would have written it if you had finished it — project,
date, priority — with one sentence on what it read out of the task. Accept, edit, or
skip, one line at a time.
**Find the tasks in a note.** Open a note → *Find opgaver i noten*. Only what
somebody committed to or was asked to do; a heading is not a task and a topic that
was discussed is not a task. Each suggestion quotes the words it came from, so a
wrong one can be seen through rather than merely being wrong. The note itself is
never edited — the task carries `[[the note's title]]` in its description instead.
**Today's plan, in the morning.** **Settings → Beskeder → Dagens plan**, off by
default; pick the hour yourself. One notification about what is due today and what
has been missed, and what to start with — and the same text as a card at the top of
**I dag**, where you are looking anyway when you start the day. The notification is
the reminder; the card is where it stays.
The card shows the plan *as it was sent*, with the time it was made, rather than a
new one each time you look: a plan that says something different when you look at it
than it said when it arrived is worse than either. **Forny planen** makes a new one
when the day has moved, and it does not send a notification about something you are
already looking at. With the morning notification off, the card offers to make one
on the spot.
The counts are done in the database and handed to the model as facts it may not
recompute — a plan that says four tasks where there are three is worse than no plan.
With no model configured the counts stand on their own, which is what you needed to
know at seven in the morning anyway. Nothing due and nothing overdue sends nothing.
**Mail that becomes a task you can act on.** Where a
[mailbox](mailboxes.md) used to produce *"Anders Jensen: SV: SV: Vedr. levering uge
12"*, the model writes the action the mail asks for — and leaves the mail alone if
it asks for nothing. The subject stays in the description, so a mail it misread can
still be read. If the model is unavailable the task is named sender and subject as
before: a model that is down must not be able to stop the post.
**Ask your own notes and tasks.** ⌘K, type the question, ⌘⏎. *"Hvad lovede jeg
Anders i august?"* — search finds what contains the words, this answers the
question, and the answer links to the notes and tasks it rests on. If nothing
matches, it says so; a guess would be useless here, because you are asking precisely
because you cannot remember.
**Split a task into sub-tasks.** On any task: *Split with AI*. Two to seven concrete
actions, created as sub-tasks.
**A weekly summary.** A short note over what is outstanding: what looks urgent and
what appears to be slipping. At most six lines — it is a prompt to think, not a
report.
what appears to be slipping. At most six lines — a prompt to think, not a report.
## What is sent
Only what the feature needs: for a split, the task's title and notes; for a
summary, the titles, dates and priorities of your open tasks. Never comments,
attachments, or anything from a project the feature was not invoked on.
Only what the feature needs, and only when you invoke it:
Nothing is sent to anybody until you configure a provider. With a local model,
nothing leaves your machine at all.
| Feature | What leaves the instance |
| --- | --- |
| Tidy the inbox | The titles of the loose tasks in your inbox, the first words of their descriptions, and your project names |
| Tasks in a note | That one note's text, and your project names |
| Today's plan | The titles and dates of what is due or overdue today |
| Mail to task | Sender, subject and the snippet of the mails in that sweep |
| Ask | Your question, and the notes and tasks the search matched |
| Split a task | That task's title and notes |
| Weekly summary | The titles, dates and priorities of your open tasks |
Never comments, never attachments, never a project the feature was not invoked on,
and never anything belonging to somebody else. Nothing is sent to anybody until you
configure a provider, and with a local model nothing leaves your machine at all.
## Turning it off
+12 -5
View File
@@ -52,6 +52,10 @@ what a person reads is decided by the client, which knows what language they are
| `forbidden` | 403 | Cross-site request, or admin needed. |
| `not_found` | 404 | Does not exist, or you cannot see it. |
| `conflict` | 409 | Already exists, or the state does not allow it. |
| `gmail_not_configured` | 409 | No Google OAuth client is registered on this server. |
| `ai_not_configured` | 409 | No AI provider has been set up. |
| `totp_not_enabled` | 409 | Two-factor is off, and the request needs it on. |
| `inbox_protected` | 409 | The Inbox cannot be deleted. |
| `validation_failed` | 422 | Check `fields`. |
| `rate_limited` | 429 | Too many attempts. |
| `internal_error` | 500 | Our fault. |
@@ -81,7 +85,7 @@ what a person reads is decided by the client, which knows what language they are
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
-H "Authorization: Bearer vrd_..." \
-H "Content-Type: application/json" \
-d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}'
-d '{"text": "file the VAT return tomorrow at 10am p1 #Accounts @tax"}'
```
### Projects and sections
@@ -92,17 +96,20 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
| `GET` `POST` | `/projects/{id}/sections` | |
| `PATCH` `DELETE` | `/sections/{id}` | |
| `GET` | `/projects/{id}/members` | |
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/members` | Who is on it, who can be added (`candidates`), and who has been invited and not arrived (`pending`). |
| `POST` | `/projects/{id}/invites` | Share it. `{"user_id": …}` for somebody who already has an account here, `{"email": …}` for anybody else — plus `"role": "editor"\|"viewer"`. An address that turns out to belong to an account is that account. |
| `DELETE` | `/projects/{id}/invites/{inviteID}` | Withdraw an invitation before it is used. |
| `PATCH` `DELETE` | `/projects/{id}/members/{userID}` | Change somebody's role, or take them off. |
| `GET` | `/projects/{id}/activity` | |
| `GET` | `/trash/projects` | What you have deleted and can still bring back, with how long is left. |
| `POST` | `/trash/projects/{id}/restore` | Brings it back with the tasks that went with it. |
### Views, filters and labels
| Method | Path | |
|---|---|---|
| `GET` | `/today` | Overdue and due today, in your timezone. |
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. |
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. Up to 31; over that is capped. |
| `GET` | `/search?q=` | Across everything you can see. |
| `GET` `POST` | `/filters` | |
| `GET` | `/filters/{id}/tasks` | Run one. |
+77
View File
@@ -0,0 +1,77 @@
# The install count
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
{
"instance_id": "01a0806d-3601-7000-ad76-057f1f684881",
"version": "v0.44.1"
}
```
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 — 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.
## On by default, off in one click
The alternative that was considered first was compulsory, and the argument against
it is practical rather than principled: it cannot be enforced. The domain can be
blocked, the binary can be patched, and a fork removes it in its first commit. So
mandatory buys nothing over on-by-default while costing the trust the rest of this
program works for.
Which is why the first administrator to sign in is **told, unprompted**, with the
real id, the real version and the real address in front of them, and two buttons.
On by default is only defensible if it is said out loud without being asked; a
settings page where it is stated fully is not the same thing, because that page is
opened by people who already suspect there is something to read.
**Settings → Data** afterwards: one switch. An explicit "off" survives upgrades.
!!! note "This disagrees with the update check, on purpose"
`VERDANDE_UPDATE_CHECK` is off unless you ask for it, and its reasoning is that
a self-hosted app reaching out unbidden has broken the deal its operator made by
self-hosting. That is the same situation as this one, and the two defaults do
not agree. The case for treating them differently: the update check reveals an
instance to a third party (GitHub) and returns something you can act on, while
this returns nothing and goes to the project. Whether that is a distinction or
an excuse is a judgement call, and it was made deliberately.
## What the collector cannot see, and what it can
A request has a source address whether or not anybody writes it down. The collector
stores none and logs none, and the code is arranged so the address never reaches a
function that could — the table has nowhere to put one. But the request passes
through a reverse proxy and a tunnel on the way, and those keep their own logs.
"We send no IP" would be a half-truth; what is true is that nothing here records
one.
The ping is not authenticated either, so anybody can post a made-up id and inflate
the count. That is an accepted trade: a token would have to ship inside every
install anyway.
## Running your own count
Somebody running a fleet points **Settings → Data → Modtagerens adresse** at their
own instance, and ticks **Denne instans er tælleren** there. Until that is ticked,
`POST /api/v1/beacon` answers **404** rather than accepting quietly — an instance
nobody made a collector must not become a bucket anyone can post into.
The collector's page then shows totals, active in 7 and 30 days, and a breakdown by
version. Publishing that number at `/api/v1/beacon/count` is a second, separate
opt-in, and it has a floor under it (25 by default): a number that *falls* cannot be
published as a fact — a bad release, a DNS change that quietly stops the pings, or
lost rows all look like "fewer people use this now" — and a small number mostly says
how little it takes to move it.
## When it cannot reach
The reason is written down and shown on the settings page. A beacon that cannot
reach its collector otherwise fails in complete silence: it tries again tomorrow,
the page says "last sent" with a date that quietly gets older, and nobody notices.
+16 -7
View File
@@ -1,13 +1,15 @@
# Calendar and CalDAV
Two ways to get your tasks into a calendar, and they are not the same thing.
Two ways to get your tasks into a calendar. Getting somebody else's calendar
*into* verdande is the other direction and has its own page — see
[Calendar](calendar.md).
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) |
|---|---|---|
| Direction | Read only | Two-way |
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ |
| You can tick things off | No | Yes |
| Setup | Paste a URL | Username and token |
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Calendars in](calendar.md) |
|---|---|---|---|
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
| You can tick things off | No | Yes | No — an event is not a task |
| Setup | Paste a URL | Username and token | Connect, then pick calendars |
## Subscribing to a feed
@@ -39,6 +41,13 @@ most people actually keep.
**Settings → Calendar feed → Rotate** issues a new one and breaks the old
immediately.
## Showing a Google calendar in verdande
Moved. Connecting a Google account, subscribing to an address, and what the grid
does with either now live on their own page — see [Calendar](calendar.md).
This page is the other direction: verdande's own tasks, read by something else.
## Connecting a CalDAV client
This is the one where ticking something off in Apple Reminders ticks it off here.
+91
View File
@@ -0,0 +1,91 @@
# Calendar
Your dated tasks as a grid, with other people's calendars laid under them.
**Calendar** in the menu shows a month or a week. The week is a day: hours down the
side, and everything with a time on it in its place. Two things at ten o'clock sit
beside each other, because that is a clash you should be able to see rather than
read.
## What is in it
- **Your tasks**, on the day they are due — and at the time, if they have one.
- **Events from calendars you have connected**, read-only.
Drag a task to another day to move it. Drop it on the hour column and it takes that
time; drop it in the band across the top and it keeps the day and loses the time.
Events cannot be dragged: verdande holds a copy of somebody else's calendar, and a
chip that lets itself be moved is a promise the server cannot keep.
A project's own calendar is under **Calendar** on the project page, with the same
week and month.
## Subscribing to a calendar
**Settings → Integrations → Subscriptions.** Paste an address and it is fetched at
once.
This works with anything that publishes an `.ics` file: public holidays, a club's
fixtures, a shared calendar from work, your own calendar's secret address from
Google, Apple or Fastmail.
- **`webcal://` addresses work.** They are the same URL over https with a scheme
that makes a desktop hand it to the calendar app; it is rewritten for you.
- **Several at a time.** Unlike the Google connection, which is one account, a
subscription is one address and you can have as many as you like.
- **Read-only, refreshed every fifteen minutes.**
- The address is fetched once before it is stored, so a typo tells you now rather
than by silently never filling.
!!! note "This is the way in for a private account"
If the instance's Google registration is *Internal*, Google refuses accounts
from outside the organisation with `org_internal` — no setting on your side
changes that. Your calendar's own secret ICS address goes through this panel
and works regardless.
## Connecting Google Calendar
**Settings → Integrations → Google Calendar.** It shares the OAuth client Gmail
uses, so the instance needs that registration first — see
[Configuration](configuration.md#google).
Two things have to be true in Google Cloud, and only the owner of the registration
can do them:
1. **Google Calendar API** is enabled for the project.
2. The calendar callback is listed as an authorised redirect URI. The exact string
is printed in the panel — copy it from there rather than typing it. Google
compares it exactly: scheme, host, path, no trailing slash. `redirect_uri_mismatch`
almost always means one of those four differs.
Once connected, tick the calendars you want to see. The list is refreshed on every
sync, so a calendar added or renamed since last time appears under its new name.
!!! warning "Written to the specification, not yet proven against Google"
The Google Calendar half is implemented from Google's documentation and tested
against a stand-in server. It has not been exercised against Google itself.
The subscription route above and the outgoing feed below have.
## Your own calendar, elsewhere
Two ways out, for two different jobs:
- **[The ICS feed](caldav.md#subscribing-to-a-feed)** — a read-only address for Apple Calendar,
Google or Thunderbird. Your tasks appear as dated entries.
- **[CalDAV](caldav.md)** — two-way. Tick something off in Apple Reminders and it
is ticked off here.
The feed address is a credential in itself: anybody holding it can read your tasks
without logging in, because a calendar client cannot sign in. Rotate it from
**Settings → Integrations** if it gets out.
## What it does not do
- **It does not write to a connected calendar.** Events are a copy.
- **It does not expand repeating events** from a subscribed file. A weekly meeting
in somebody else's calendar is stored as its first occurrence.
- **It does not resolve named time zones** from a subscribed file. An event carrying
`TZID=Europe/Copenhagen` shows the clock the file wrote, rather than one this
server has moved.
- **Only Google, and only reading**, for account connections. Everything else goes
through a subscription.
+99 -8
View File
@@ -11,6 +11,26 @@ 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
verdande keys its login rate limit and its audit log on the caller's address, so
that address has to be one the caller cannot forge. A forwarded-for header is
believed only when the machine that connected is a trusted proxy; a connection
straight off the internet is keyed on the address it really came from.
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_TRUSTED_PROXIES` | private networks | Whose `X-Forwarded-For` to believe, as comma-separated CIDRs. The default trusts loopback and the private ranges — the shape of a reverse proxy or tunnel sharing the host or the docker bridge. Set to `none` for an instance exposed directly with no proxy, so no forwarded header is ever believed. |
| `VERDANDE_REAL_IP_HEADER` | `X-Forwarded-For` | The header the client address is read from, for a trusted proxy. |
!!! warning "A proxy that does not overwrite the header"
The default is safe when your proxy replaces a client-supplied
`X-Forwarded-For` with the real address — nginx, Caddy and Cloudflare Tunnel all
do. If yours passes the client's own header through, narrow
`VERDANDE_TRUSTED_PROXIES` to just your proxy's address so a visitor cannot
choose the address they are rate-limited and logged as.
## Sessions and links
@@ -39,12 +59,24 @@ Any Go duration: `720h`, `30m`, `45s`.
yourself, password resets are written to the log, and reminders arrive in the
app and as Web Push rather than by email.
## Gmail
## Google
Only needed if anyone will connect a mailbox. Register an OAuth client in Google
Cloud (**APIs & Services → Credentials → OAuth client ID → Web application**) with
the redirect URI `https://your-address/oauth/gmail/callback`, enable the Gmail API,
and add the `gmail.readonly` scope.
Only needed if anyone will connect a Gmail mailbox or a Google calendar. One
registration covers both: register an OAuth client in Google Cloud
(**APIs & Services → Credentials → OAuth client ID → Web application**) and add
**both** redirect URIs:
```
https://your-address/oauth/gmail/callback
https://your-address/oauth/calendar/callback
```
Then enable the APIs and scopes for whichever you want:
| For | Enable | Scope |
|---|---|---|
| Mailboxes | Gmail API | `gmail.modify` |
| Calendar | Google Calendar API | `calendar.readonly` |
| Variable | What it does |
|---|---|
@@ -52,14 +84,73 @@ and add the `gmail.readonly` scope.
| `VERDANDE_GMAIL_CLIENT_SECRET` | The client secret. |
One registration serves every user on the instance; each person authorises their
own mailbox from **Settings → Gmail**. The redirect URI is derived from
`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered.
own mailbox from **Settings → Integrations → Gmail** and their own calendar from
**Settings → Integrations → Google Calendar**. Both redirect URIs are derived from
`VERDANDE_BASE_URL`, so they cannot drift out of step with what you registered.
!!! note "Two connections, two grants"
Connecting a calendar does not disturb a working Gmail connection, and
disconnecting one does not take the other with it. Google issues a refresh
token per authorisation and keeps up to a hundred of them live per account,
each carrying only the scopes it was granted with.
## Secrets
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_SECRET_KEY` | — | Encrypts the mail tokens and mailbox passwords in the database. 32 bytes of base64. |
Mail tokens and [mailbox](mailboxes.md) passwords are encrypted at rest, and the
key does not live in the database. That is the whole point: **backups can be
downloaded through the interface**, so a copy that ends up somewhere it should
not would otherwise open your mail.
Leave the variable empty and verdande writes a key to `secret.key` in the data
directory on first start, mode `0600`. Backups are a `VACUUM INTO` of the
database alone, so the key file is not in them.
Generate one yourself if you would rather keep it out of the volume entirely:
```
openssl rand -base64 32
```
!!! warning "Lose the key and the mailboxes must be reconnected"
Nothing else is lost — tasks, projects, files and history are not encrypted —
but a token that cannot be decrypted cannot be used, and there is no way back
from a key that is gone. If you restore a database somewhere new, bring
`secret.key` with it, or set `VERDANDE_SECRET_KEY` to the same value.
A database written before there was a key is read as it stands and encrypted the
next time each value is written, so turning this on costs nobody a reconnection.
## Update checks
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists, and tell the administrators when one does. |
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
With the check on, administrators are told **once per version** — in the bell, in
push and on the app's badge — rather than having to open the settings page and
look. The notification leads to the page where the restart button is. With the
check off nothing is fetched and nothing is said; see
[Notifications](notifications.md).
All three together let **Settings → Notifications** restart this instance,
which is also how it picks up a new version: a container cannot replace its own
image, so the panel recreates it and pulls `:latest` on the way. The request asks
the panel for a *scheduled* restart rather than an immediate one — an immediate
one is carried out while the request is still open, and stopping the container
kills the very process waiting for the answer, so the panel never gets to the
half that starts it again. Any one of the
three alone does nothing, and the page names the ones that are missing.
The token is a panel credential with control over that server. Anybody who can
read this environment can restart it — which is true of anybody with the host
anyway — and it is never sent to a browser.
Off unless you turn it on. When on, verdande asks the public GitHub releases API
at most every six hours and sends nothing about your instance — no identifier, no
+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.
+2 -2
View File
@@ -1,7 +1,7 @@
# Saved filters
A filter is a small expression describing a slice of your tasks. The grammar is
Todoist's, so if you are coming from there you already know it.
A filter is a small expression describing a slice of your tasks. The grammar will
feel familiar if you have used query-based filters in other task apps.
```
today & p1
+90 -10
View File
@@ -11,23 +11,103 @@ of. Which is what a to-do list is.
## What it is for
Todoist Pro, on your own hardware, with your own data. And with the integrations a
A fast, self-hosted task manager — projects, quick add, filters, notes and shared
lists — on your own hardware, with your own data. And with the integrations a
hosted product will not build for you: a real CalDAV server, an address you can
forward mail to, and an MCP endpoint so Claude can work your task list directly.
## What it does
- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **[Quick add](quick-add.md) that reads what you type.** `file the VAT return
tomorrow at 10am p1 #Accounts @tax` becomes a task due tomorrow at 10:00,
priority 1, in Accounts, labelled tax. Danish and English, mixed freely in one
line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
board and calendar views, and foldable groups over the projects in the sidebar.
The calendar draws each task to its length — drag the foot to resize, the body
to move it.
- **Find it by searching for it.** ⌘K searches tasks, notes and projects — and the
settings themselves, by what they *do* rather than only by what they are called,
so `webhook` finds *Push from other programs* and `2fa` finds *Two-factor*. The
settings pages carry the same field at the top of their own list.
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
single note with a person — by name, by email address, or by writing `@Sofie` in
the text — or file it in a project; `#project` and `[[another note]]` are links,
and a task shows what has been written about it.
- **Delegate, and snooze.** Hand a task to somebody and it is marked with an
arrow to them, gathered on a *Waiting on others* page. Snooze one to park it,
greyed, at the foot of the list until a moment you pick — without touching when
it is due.
- **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Somebody who already has an
account here you pick by name — the accounts on an instance are a closed,
invited set, so they are an address book rather than a directory to protect.
Anyone outside your instance joins through an invite link, and the invitation
stays visible until they arrive, so it does not get sent twice.
- **Live sync** — a change made by somebody else appears without a refresh.
- **Repeating tasks** as RRULE, reminders, comments and attachments.
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
open standards rather than private formats.
- **[Told when it matters](notifications.md).** A bell for what other people did to
what you share, pushed to your devices and counted on the app's icon in the Dock.
- **[AI that suggests and never writes](ai.md).** Optional, your own key. It tidies
the inbox, pulls the tasks out of a meeting note, writes a morning plan, turns
*"SV: SV: Vedr. levering uge 12"* into what the mail actually asks for, and
answers questions about your own notes with links to what the answer rests on —
every one of them as a line you accept, edit or refuse.
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
out as Web Push to an iPhone or a Mac.
- **[Anything can put a task in your inbox](mail.md)** — a mail to your own
address, or a POST from a shortcut on your phone, a script, or any service that
can call a URL.
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
comes off once it has, so a starred inbox empties as the tasks are made instead
of only filling up.
- **[Calendar](caldav.md) and [Claude](mcp.md)** connected over open standards
rather than private formats.
- **Six themes**, three dark and three light — including *Charcoal*, a warm
near-black after Apple Notes — and a size and typeface chosen apart from them.
- **Holds through a short network drop.** A save rides out a few seconds of no
signal and goes through when it comes back; a reload opens from what it last saw
rather than the browser's offline page. It installs as a PWA.
## What it looks like
![Upcoming as a month](screenshots/upcoming.png)
*Upcoming, as a month. A week and a plain list are the other two, and a task can be
dragged from one day onto another.*
![A project as a list](screenshots/sections.png)
*The same project as a list, where sections read as bands. As a board they are
columns.*
![Waiting on others](screenshots/delegated.png)
*What you have handed to somebody else, grouped by whoever has it — and each row
marked with an arrow to them, so a delegated task reads as delegated wherever you
meet it.*
![Notes in the Charcoal theme](screenshots/notes-charcoal.png)
*Charcoal, the newest of six themes — a warm near-black after Apple Notes, with
amber where the accent green usually is.*
![The six themes as swatches](screenshots/themes.png)
*Six themes — three dark, three light — chosen on the settings page, with the
typeface and how tightly it sits set apart from them.*
![Settings, integrations](screenshots/settings.png)
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
[mail-to-task address](mail.md) are connected.*
![Settings, people](screenshots/admin.png)
*Inviting somebody to the instance. There is no open registration — everybody
arrives through a link, and picks their own password. See
[signing in](signing-in.md).*
## Getting it running
+52
View File
@@ -0,0 +1,52 @@
# Opening links
A link in a task or a note opens in whichever browser your system opens links in.
**Settings → Links** can try to change that — and the honest description of what it
does is *try*.
## What a web page cannot do
Two things, and they set the limits of this feature:
- **It cannot see which browsers are installed.** The list is deliberately closed:
it would be a fingerprint, distinctive enough to follow somebody from site to
site.
- **It cannot ask the system to use a particular one.** A link goes to the default
browser, and there is no API that says otherwise.
The only handle that exists is the URL schemes browsers register for themselves —
`firefox://open-url?url=…`, `googlechrome://…`, `microsoft-edge:…` — and there is no
way to ask whether one exists. You can only try it.
## So the setting is an attempt
Pick Safari, Chrome, Firefox or Edge, or write your own scheme. When you click a
link, that scheme is tried first; if nothing has happened within about half a
second, the link opens the ordinary way.
The fallback is the whole reason this is safe to turn on. Without it, a setting
that works on your phone would leave links doing nothing on your desktop.
In practice: **it works on a phone**, where browsers register these schemes, and
**rarely on a Mac or a PC**, where Chrome and Firefox do not. Edge on Windows is
the usual exception. There is a **Prøv det** button for exactly this reason — you
cannot ask whether a scheme exists, so it is better to find out on example.dk than
on a link you meant to follow.
## Writing your own
Three placeholders, because browsers disagree about the shape they want the address
in:
| | |
| --- | --- |
| `{url}` | The address as it is — `x-safari-https://example.dk` |
| `{encoded}` | The address as a parameter value — `firefox://open-url?url=https%3A%2F%2F…` |
| `{stripped}` | The address without the `https://` — `googlechrome://example.dk` |
## It belongs to the machine, not the account
The choice is kept in the browser you are sitting at, not on your account — the
same rule the sidebar's width follows. `firefox://` works on the phone and does
nothing on the Mac, so a choice that followed the account would be wrong in one of
the two places every time.
+33
View File
@@ -67,3 +67,36 @@ immediately.
!!! tip "Keep the address to yourself"
Treat it like a secret. If you publish it somewhere, rotate it.
## Pushing from another program
The address above needs a mail server. When what you want is a shortcut on your
phone, a script, or any service that can call a URL, there is a second way in:
**Settings → Integrationer → Skub fra andre programmer** gives you an address to
POST a line of text to.
```bash
curl -X POST https://todo.example.dk/inbound/hook/<token> \
-H "Content-Type: text/plain" \
-d "Ring til Anders i morgen p1 #Firma"
```
The body is read three ways, because those are the three you meet: plain text from
a shortcut that just sends what was selected, JSON with a `text` field from a
service, or a form field from a webhook form. The first line becomes the task and
the rest sits underneath it, and the line goes through the [quick add](quick-add.md)
parser — so "i morgen", "p1" and "#Firma" mean here what they mean in the box at the
top of the app.
**The address is a key.** It has its own token rather than sharing the mail one:
the two are the same kind of secret but they leak separately, and a URL that has
been sitting in a shortcut on a lost phone has to be replaceable without changing
the address other people have in their address books. A token here can do one
thing — put a task in your inbox — which is why it is not an
[API token](api.md), which can do everything. Change it under the same setting; the
old one stops working at once.
**POST only.** A GET would be easier to call, and that is exactly the problem: a
browser prefetch, a link check in a chat, and a revisited history entry would all
create tasks nobody asked for.
+87
View File
@@ -0,0 +1,87 @@
# Mailboxes
Star a mail and it becomes a task.
This is the other direction from [mail to task](mail.md): instead of forwarding
something, Verdande reads a mailbox you connect and makes a task out of anything
you have flagged. Nothing else in the mailbox is touched, and nothing is ever
sent.
Each mailbox belongs to the person who connected it. Two people on one instance
connect their own and see only their own — there is no shared inbox and no
administrator who can read yours.
## Gmail
**Settings → Integrations → Gmail → Connect.**
Gmail needs an OAuth client, which is the instance's registration with Google
rather than anybody's account — see [Configuration](configuration.md#google).
Once it is set up, each person connects their own mailbox through it.
Choose what makes a task:
| Trigger | What it reads |
|---|---|
| Starred | Anything you have starred |
| Label | Anything carrying a label you name |
!!! note "The app stays Internal if your client is Internal"
An OAuth client registered as *Internal* in a Google Workspace can only be
used by accounts in that workspace. A personal `@gmail.com` address will be
refused with `org_internal`, and that is the client working as configured.
Switching to *External* means refresh tokens that expire every seven days
until Google has verified the app — for one personal account, forwarding to
your [mail-to-task address](mail.md) is the shorter road.
## iCloud, Fastmail and anything else over IMAP
**Settings → Integrations → Mailboxes → Add a mailbox.**
You need three things:
| Field | Example |
|---|---|
| IMAP server | `imap.mail.me.com:993` |
| Username | your address |
| App password | not your ordinary password — see below |
The connection is tested before it is saved. A mailbox that cannot be read is
refused while you are still looking at the form, rather than accepted and then
failing quietly every ten minutes.
Flag a message — the flag, not unread — and it becomes a task on the next sweep.
Unread is a state you are still using yourself, and a sync that consumed it would
be taking something away.
### App passwords
Every host worth naming issues a separate password for applications, which you
can withdraw on its own without changing the one you sign in with. Use that.
- **iCloud** — [appleid.apple.com](https://appleid.apple.com) → Sign-In and
Security → App-Specific Passwords
- **Fastmail** — Settings → Privacy & Security → Integrations → New app password
The password is [encrypted at rest](configuration.md#secrets), and it is never
returned by the API once saved.
### Office 365 does not work here
Microsoft has closed IMAP to passwords; it wants its own OAuth flow. There is no
app password that will get you in, and a mailbox pointed at `outlook.office365.com`
will be refused. Forward to your [mail-to-task address](mail.md) instead.
## How often
Every ten minutes, in the background. **Fetch now** does it immediately, which is
for when you have just connected something and want to see it work.
A run reads at most twenty-five messages, so a mailbox with years of flagged mail
in it fills up over several sweeps rather than in one.
## Disconnecting
**Disconnect** on the mailbox. The tasks it made stay — they are your work now,
not the mailbox's. Nothing is read after that, and the stored credential is
deleted rather than merely disabled.
+3 -3
View File
@@ -57,9 +57,9 @@ Both endpoints run the same server and reach the same things.
| `complete_task` | Tick one off. |
| `add_comment` | Comment on a task. |
`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1
#Firma"* goes through the same parser the app uses — dates, priorities, projects,
labels and recurrence in either language.
`create_task` prefers natural language, so *"add file the VAT return tomorrow at
10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities,
projects, labels and recurrence in either language.
## What it can reach
+212
View File
@@ -0,0 +1,212 @@
# Notes
Markdown in, Markdown out. Nothing in between.
A note is written as rich text — headings, lists, quotations, code — and stored as
the Markdown it looks like. That is not a detail about the database: it is what
makes a note yours. Export gives you the same text you were looking at, and on the
day this program is gone, every note opens in any editor.
## Writing
Start typing. **The first line is the title** — always, and re-derived every time
you save, so a note you rewrite the top of is called by what it now says rather
than by what it once did.
| Style | Written as | Shortcut |
|---|---|---|
| Title | `# ` at the start of the line | pick it in **Aa** |
| Heading | `## ` | |
| Subheading | `### ` | |
| Quote | `> ` | |
| Code block | three backticks, with an optional language | |
| Bullet and numbered lists | `- ` and `1. `, nested with Tab | |
| Bold | two asterisks around the words | ⌘B |
| Italic | one asterisk around the words | ⌘I |
| Underline | a `u` tag around the words | ⌘U |
| Strikethrough | two tildes around the words | |
| Code | one backtick around the words | |
Every line becomes exactly one block. Markdown would normally join consecutive
lines into a paragraph; here it does not, because you typed a line and expect to
get it back.
Press the source button in the toolbar to see the note as the file it is. Edits there are edits to the
note.
### Pictures
Paste one, or drag it onto the note. It is stored with the note and travels with
it in an export. Every raster format works; SVG is stored but not shown inline —
it is a document that can run script, and a note is not a place to run somebody
else's.
**Click one to see it whole.** In the note it is scaled down to the width of the
column — a photo off a phone is four thousand pixels across — so what you are
looking at is a thumbnail that does not look like one. The full-size view has a
button that copies the picture to the clipboard, and a link that opens it in its
own tab. Escape closes it and puts the cursor back where it was.
Copying converts to PNG on the way out when it has to. Clipboards take PNG and
little else, and a photo off a phone is a JPEG — without the conversion the copy
either fails or succeeds with something nothing will paste.
### Code blocks
A fenced block is coloured for the language it names, and guesses when it does not.
Each one carries a **Copy** button in its corner.
## Linking
Three kinds of reference, parsed out of the text as you write it:
| You type | It means |
|---|---|
| `#Firma` | The project — the same `#Firma` quick add understands, not a second kind of tag |
| `[[Møde med Anders]]` | Another note, by its title |
| A pasted task link | That task |
Links are an index over the text, rebuilt every time the note is saved. The text
stays the truth.
**The interesting direction is backwards.** A task shows what has been written
about it, and a project shows the notes filed in it — without anybody having to
remember to attach anything. Write `#Firma` in a note and it appears on that
project's page.
A link to something that has been deleted reads as a dead link. It is a fact about
the note, and it should not vanish because something it mentioned did.
## Filing and sharing
A note belongs to a project, or to nobody.
- **In a project:** everybody who can read the project can read the note, and an
editor can change it. Choose it under **Share in** at the foot of the note.
- **In no project:** yours, and nobody else's.
There is no folder tree. Projects, groups and labels already exist, are already
shared and already have roles — a second hierarchy to file things in is one too
many, and a note in a project inherits every one of those answers.
### Sharing one note with one person
Filing a note in a project is the right tool when the note belongs with a body of
work, and the wrong one when it is just *let Sofie see this*. **Del med personer**
at the foot of the note does the second: this note, this person, as reader or
editor. Only the note's owner can share it.
Type an **email address** rather than picking a name, and one of two things
happens. An address that already has an account is shared with straight away. One
that does not gets an invitation — a link that creates the account and the share
together, so the note is theirs the moment they sign up. Invitations wait in the
panel under the note, where they can be withdrawn; that is the only way back, since
only the token's hash is stored and a link sent to the wrong address cannot be
looked up.
### Naming somebody in a note
Writing `@Sofie` in a note shares it with her and tells her. Naming somebody in a
note *is* wanting them to read it, and doing it as two acts — write the name, then
open the panel and find the person — meant the second was forgotten, and the note
sat there addressing somebody who could not see it. The editor suggests names after
`@`, the same way `#` suggests projects.
- **Only the owner's mentions share.** An editor may write the name; giving
somebody else's note away is the owner's alone.
- **As a reader**, which is the least that satisfies "look at this". A role you
have raised in the panel is never lowered by a mention.
- **Only a name that has just appeared** counts. A note is saved on every typing
pause, so without that every save would be another share and another notification
about the same name.
## Finding
The list groups itself: favourites first, then **Today**, **Yesterday**, **This
week**, and then by month. Sort by created, last touched or name, and the grouping
follows — sorted by name it groups by first letter instead.
Search finds a word wherever it is:
- **`gron` finds `grøn`.** Unicode does not consider `ø` an accented `o`, so
stripping diacritics is not enough for Danish. A folded copy of every note is
indexed beside the original.
- **`inventory` finds `keepInventory`.** An index is built of whole words, and a
decade of notes is full of commands and identifiers that are several words in
one. When the index has nothing, the text itself is read.
- Results are ranked by how well they match, with the title weighted ten times the
body. A word in the title is what the note is about; the same word once in a long
body is a mention.
Two places to search from, and they now agree. The field above the list filters it
in place. **⌘K** searches everything at once — tasks, projects, notes and the
settings — and picking a note from it opens that note *with the rest of the matches
still in the list beside it*. It used to drop you on the ordinary list, so the
other results existed only in the popup you had just closed. The address carries
the search, which also means a search worth keeping can be bookmarked or sent to
somebody.
## Putting notes away
Two different things, and the difference matters:
- **Archive** — this is finished and I do not want to read past it. It stays
findable and comes back whenever you ask.
- **Trash** — this was a mistake. It is gone in thirty days.
Select several with ⌘-click and shift-click to archive or delete them together.
## In and out
**Export** gives you a zip of Markdown files, one per note, with the dates in a
YAML front matter block that Obsidian and Bear also read:
```markdown
---
created: 2019-04-02T10:15:00Z
modified: 2024-11-30T21:04:00Z
---
# Møde med Anders
Han vil gerne have kaffe hver uge.
```
**Import** takes the same shape back. Pictures travel in the zip beside the notes,
and the links in the text are relative — so a note and its pictures must be in the
same archive, which matters when a large export is split into parts.
Two things to know before importing:
- **Nothing is merged.** Every run creates new notes. Importing the same archive
twice gives you two of everything.
- **The dates are read from the front matter.** Without them, a move of a thousand
notes is a pile that was all written the same evening — and the order they were
written in is half of what an archive is.
### From Apple Notes
`tools/apple-notes-til-markdown.py` in the repository exports Apple Notes to
exactly this shape, front matter and pictures included. It is macOS-only, because
Apple Notes is.
For a large library, `tools/pak-noter-i-portioner.py` splits the export into
uploadable parts, keeping each note with its own pictures.
## What is encrypted, and what is not
**A note's title and body are sealed** in the database, under a key kept in a file
beside it rather than inside it. A backup you download does not carry the key, so
it does not carry your notes.
Both halves, not only the body: a note's title is its first line, so a readable
title beside a sealed body would leave the beginning of every note in the open.
**What is still in the clear:** task titles, descriptions and comments — and the
list of what a note links to, so a stolen file shows which projects and tasks a
note points at, though not what it says.
Sealing the notes cost the search its index — a full-text index over a note's body
is another copy of that body — so search reads the notes themselves now. At twelve
hundred notes that is about forty milliseconds, and the box waits a quarter of a
second after you stop typing, so you will not see it.
+52
View File
@@ -0,0 +1,52 @@
# Notifications
What other people did that you need to know about, and what the day has in it.
Verdande tells you in three places at once, and they are the same thing said three
ways: the **bell** in the header, a **push** to your devices, and the **badge** on
the app's icon.
## What causes one
| | |
| --- | --- |
| Somebody edited a note shared with you | Repeat edits fold into one line while it is unread |
| Somebody shared a note with you | Once, when it happens |
| Somebody shared a project with you | Once, when it happens — the entry opens the project, and its share panel has a *Leave the project* button |
| Somebody named you in a note with `@` | Instead of "edited a note" — one act, one message |
| Somebody gave you a task | Only on the change, and only to the person who got it |
| Somebody commented | |
| [Today's plan](ai.md), if you turned it on | In the morning, at the hour you chose — it opens the card on **I dag** |
| A new version is out | Administrators only, once per version |
Not your own actions: the server does not tell anybody what they just did
themselves. And not everything that happens — a bell that rings at every change in
a shared project is a bell people stop looking at.
**Repeat edits are folded.** A note is saved on every typing pause, so one person
writing for five minutes would otherwise be twenty identical lines. While the
message is unread, a further edit moves it back to the top and says who touched it
last. Once you have read it, the next edit is a new thing to be told.
## Push, and the badge
**Settings → Beskeder** turns on Web Push for the device you are sitting at, per
device: the phone in your pocket and the laptop you have open are different
answers, and a permission granted on one is not the other's.
The **badge** — the number on the icon in the Dock on a Mac, on the taskbar on a
PC — needs the app to be installed (added to the Dock, or installed as an app in
Chrome or Edge). Half the point of being told is being told while you are doing
something else, and the icon is the only place the app can say anything then. It
counts unread messages and goes down by itself as you read them. Firefox does not
support it; nothing else changes there.
The sentence you see is written in your browser from *what happened* and *who did
it*, so an English interface gets an English bell. The server's own wording is
Danish and is used only for push, which has no dictionary to look a phrase up in.
## Telling you about a new version
If the [update check](configuration.md) is on, administrators get one notification
per version — never one a day about the same one — and it leads to
**Settings → Beskeder**, where the button that restarts the instance to update it
sits. With the check off, nothing is fetched and nothing is said.
+3125 -17
View File
File diff suppressed because it is too large Load Diff
+23 -8
View File
@@ -3,11 +3,11 @@
One line in, a fully-formed task out.
```
betal moms i morgen kl 10 p1 #Firma @regnskab
file the VAT return tomorrow at 10am p1 #Accounts @tax
```
becomes a task called **betal moms**, due tomorrow at 10:00, priority 1, in the
Firma project, labelled regnskab.
becomes a task called **file the VAT return**, due tomorrow at 10:00, priority 1,
in the Accounts project, labelled tax.
The parts it understands are tinted underneath the text as you type, so you can see
it being read before you commit to it.
@@ -16,8 +16,8 @@ it being read before you commit to it.
| You type | It means |
|---|---|
| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. |
| `@regnskab` | Add that label. As many as you like. |
| `#Accounts` | Put it in that project. `#"Q3 report"` for a name with spaces. |
| `@tax` | Add that label. As many as you like. |
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
| `mandag`, `på fredag` | The next such weekday. |
@@ -70,7 +70,22 @@ an error message and a lost thought.
write it, and guessing month-first would silently move a date by months rather
than failing visibly.
## The shortcuts
## Just start typing
Press ++q++ to jump to the quick-add box from anywhere, ++t++ for Today, ++u++ for
Upcoming, ++cmd+k++ to search, and ++question++ for the full list.
There is no shortcut to remember. Press a letter anywhere in the app and you are
writing a task, with the letter you pressed already in the field. The thought does
not have to survive a journey to find somewhere to put it.
It only fires when nothing else has focus — a key pressed at a field, a button or
a link belongs to that thing.
Under the field is a line saying what the parser reads: `#` project, `/` section,
`@` label, `p1` priority, and an example date. It appears while you are typing,
which is the one moment a placeholder cannot help you.
++cmd+k++ opens the palette — search, and navigation to anywhere in the app.
!!! note "The single letters used to navigate"
++t++ for Today and ++u++ for Upcoming are gone. One keyboard cannot both
spell *tal med Anders* and jump to Today on the same ++t++, and capturing is
the thing done fifty times a day. The palette does the rest.
+26 -8
View File
@@ -7,14 +7,14 @@ verdande ships with a Rune manifest for
```
create container: Error response from daemon:
No such image: ghcr.io/kristianwind/verdande:latest
No such image: gitea.nolimit.dk/kw/verdande:latest
```
This message is a liar. It does not mean the tag is missing from the registry —
it means the image is not on the host, and the panel's attempt to fetch it failed
without saying so.
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
The usual cause is a **private** package. Yggdrasil pulls anonymously: its
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
looks locally — then reports the image as missing.
@@ -24,10 +24,28 @@ looks locally — then reports the image as missing.
sends them itself with each request. Yggdrasil is a different client and
sends none, so logging in changes nothing for the panel.
**Make the package public.** Under
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
→ *Change visibility*. The repository stays private; only the built image becomes
readable, which is what the manifest and these pages have always assumed.
**The package has to be readable without credentials**, and the only way to know
is to ask the registry for a token as a stranger:
```bash
curl -s "https://ghcr.io/token?scope=repository:kristianwind/verdande:pull&service=ghcr.io"
```
A 200 with a `token` in it means anyone can pull, which means the panel can. A
401 means it cannot, whatever the package page says.
!!! danger "Do not test this with `docker --config /tmp/empty pull`"
It looks like the honest test and is not. An empty config directory does not
stop the machine's credential helper, so the pull is quietly authenticated
with credentials you forgot you had — and reports success for an image no
stranger can fetch. This cost a production instance an hour of downtime: the
image was declared public on that evidence, the rune was pointed at it, and
the host — which had never logged in — could not pull a thing.
A self-hosted Gitea is where this bites. An instance with `REQUIRE_SIGNIN_VIEW`
turned on refuses anonymous tokens for its registry too, and no per-package
setting overrides it. Making the source repository public does not change it
either; that was tried.
Once it is public, press **Start** on the server you already have. There is no
need to delete it and make another: the image reference is read from the rune at
@@ -37,8 +55,8 @@ To keep the package private you have to put the image on the host yourself,
before creating the server, and again after every release:
```bash
sudo docker login ghcr.io -u kristianwind
sudo docker pull ghcr.io/kristianwind/verdande:latest
sudo docker login gitea.nolimit.dk -u kw
sudo docker pull gitea.nolimit.dk/kw/verdande:latest
```
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

+8 -6
View File
@@ -1,14 +1,16 @@
# Self-hosting
# With Docker
verdande is one static binary and one directory. There is no database server, no
cache, no queue and no second process.
Running it yourself outside a panel: one static binary and one directory. There is
no database server, no cache, no queue and no second process. (Yggdrasil is
self-hosting too — for that route see [As a Rune](rune.md).)
## Docker
!!! note "If the pull is refused"
The GHCR package is public, so this needs no credentials. If it is ever set
back to private, `docker login ghcr.io` with a token carrying the
`read:packages` scope before pulling — and see
The image is published from a self-hosted Gitea and is readable without
credentials — the package is not linked to a repository, so its visibility
follows the account rather than the (private) source repo. If it is ever set
back to private, `docker login gitea.nolimit.dk` before pulling — and see
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
fails differently and much more confusingly under Yggdrasil.
+71
View File
@@ -0,0 +1,71 @@
# Signing in
Three ways in, and you can have all of them at once.
## A password
What the first account is created with. Nothing is emailed, nothing is registered
anywhere: the first person to open a fresh instance creates the administrator
account, and after that people arrive by invite link.
## Being let into a project
Two different situations, one panel — **the project's ⋯ menu → Share**.
Somebody who **already has an account here** you pick from a list by name. You do
not need to know their email address to share a project with a colleague whose
name you know. The accounts on an instance are a closed set — there is no open
signup, every one of them was invited — so listing them to each other is the
address book the feature needs, not a disclosure.
Somebody who is **not here yet** you invite by email. If the address turns out to
belong to an account it becomes an ordinary share straight away; otherwise it
becomes a link that creates their account and their membership at once. With no
mail server configured the link is shown in the panel instead of being sent, which
is the correct behaviour on a one-person instance rather than an error.
**You are told, and you can leave.** Being added to a project is not something
that happens to you quietly: the bell says who shared what, and the entry takes
you to the project. In the share panel, anybody who is not the owner has a *Leave
the project* button. That is deliberate rather than an approval step — an
invitation waiting on a yes means the person who shared thinks it is done while
the work sits still. What was wrong was the other three-quarters of it: you were
added without being asked, without being told, and with no way out except asking
the owner. The owner cannot leave their own project; that is a transfer of
ownership, and there is no transfer yet.
An invitation that has not been taken up stays listed under the members, greyed,
with a × to withdraw it. Without that it is invisible — the person has no account,
so they are neither a member nor somebody you can pick — and the usual next move
is to send it a second time. The second link works exactly as well as the first,
and then there are two to keep track of.
## A code on top of it
**Settings → Account → Two-factor.** An ordinary TOTP secret — any authenticator
app reads the QR code. Once it is on, the password alone is not enough.
Recovery codes are shown once, when you turn it on. They are the way back if the
phone is gone, and they are not shown again.
## A passkey
**Settings → Account → Passkeys → Add a passkey.**
A key on a device instead of a secret in your head. The private half never leaves
the device — a phone's secure element, a laptop's keychain, a hardware key — and
the signature is bound to the address it was made for, so a convincing copy of the
sign-in page at a different address gets nothing.
Give each one a name you will recognise later. *min bærbare* is what makes a list
of keys reviewable, and a list nobody can read is a list nobody revokes from.
Passkeys need the address in `VERDANDE_BASE_URL` to be a hostname. Browsers refuse
to make one for a bare IP address, so an instance reached at `http://192.168.1.10:8080`
will show the passkey section as unavailable rather than failing at the moment
somebody tries.
!!! note "One is enough, two is better"
Lose the only device holding your only passkey and you are back to the
password. Keep the password, or register a second key — a phone as well as a
laptop.
+15 -3
View File
@@ -3,21 +3,33 @@ module github.com/kristianwind/verdande
go 1.26.3
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
github.com/coder/websocket v1.8.15
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/go-chi/chi/v5 v5.3.1
github.com/go-webauthn/webauthn v0.17.4
github.com/pquerna/otp v1.5.0
github.com/teambition/rrule-go v1.8.2
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.56.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/teambition/rrule-go v1.8.2 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
+65 -2
View File
@@ -2,12 +2,33 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -18,6 +39,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -25,20 +48,60 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+309 -14
View File
@@ -19,6 +19,8 @@ import (
"fmt"
"io"
"net/http"
"github.com/kristianwind/verdande/internal/safedial"
"strings"
"time"
)
@@ -45,6 +47,12 @@ type Config struct {
// ProviderCompatible and useful for a proxy in front of the others.
BaseURL string
Model string
// AllowPrivate lets BaseURL be on this machine or its private network.
//
// Set by the caller from who is asking, never read from storage: it is the
// answer to "may this person reach the LAN", and that answer can change after
// the address was saved. See the handler for who gets it and why.
AllowPrivate bool
}
func (c Config) Configured() bool {
@@ -71,12 +79,31 @@ type Client struct {
}
func New(cfg Config) *Client {
dial := safedial.Client
if cfg.AllowPrivate {
dial = safedial.AllowPrivate
}
return &Client{
cfg: cfg,
// Generous: a local model on modest hardware can take a while to produce
// its first token, and the alternative is a timeout that only ever fires
// for the people running their own.
http: &http.Client{Timeout: 120 * time.Second},
// safedial by default. `base_url` is typed into a field in the interface,
// so it is a user-supplied address — and a server that fetches a
// user-supplied address is a way to ask it to reach things the caller
// cannot: the panel next door, a database on the same bridge, the cloud
// metadata endpoint. On a homelab that is most of what is worth reaching.
//
// The check is on the resolved address rather than on the URL, because a
// name answers whatever its owner says — and can answer differently the
// second time, after a parse-time check has passed.
//
// And then the exception, which is the whole reason this provider exists:
// a model you run yourself IS on that network. Blocking it made the two
// lines above true and the feature useless — the comment about a local
// model being slow to its first token was written for a case the dialler
// refused to reach. AllowPrivate is granted per request, not per address.
http: dial(120 * time.Second),
}
}
@@ -268,11 +295,35 @@ func (c *Client) do(req *http.Request, out any) error {
// The provider's own message is included: for a local model it is usually
// the actual problem ("model not found"), and hiding it would leave the
// operator with nothing to go on.
return fmt.Errorf("ai: %s said %s: %s", req.URL.Host, resp.Status, truncate(string(raw), 300))
// The provider's body is no longer passed back to the caller.
//
// It was, and combined with a free-text base URL that made a working
// internal scanner: point it at a host, read the first three hundred bytes
// of whatever answered. The operator still needs the message — for a local
// model it is usually the actual problem — so it goes to the log, where it
// is available to the person running the instance and to nobody else.
return &upstreamError{Host: req.URL.Host, Status: resp.Status, Body: truncate(string(raw), 300)}
}
return json.Unmarshal(raw, out)
}
// upstreamError keeps the provider's own words for the log and away from the
// response. Error() is what reaches a caller; Detail() is what is written down.
type upstreamError struct {
Host string
Status string
Body string
}
func (e *upstreamError) Error() string {
return fmt.Sprintf("ai: %s said %s", e.Host, e.Status)
}
// Detail is the whole of it, for the instance's own error log.
func (e *upstreamError) Detail() string {
return fmt.Sprintf("ai: %s said %s: %s", e.Host, e.Status, e.Body)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
@@ -344,22 +395,266 @@ back, do not congratulate, and do not offer productivity advice.`, language)
return c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
}
// Suggestion er én foreslået opgave, skrevet som en linje, man selv kunne have
// tastet.
//
// `line` er quick-add-syntaks — "Ring til Anders i morgen p1 #Firma" — og ikke et
// felt pr. egenskab. To grunde, og den anden er den vigtige. Parseren findes
// allerede og er den samme, uanset hvor teksten kom fra, så der er ingen anden
// fortolkning at holde i takt med den. Og et forslag, der er skrevet i det sprog,
// man selv skriver i, kan læses og rettes af den, det bliver stillet til — hvor
// et sæt felter, en model har udfyldt, er noget, man skal tage eller lade være.
//
// `why` er én sætning om, hvorfor den ser sådan ud. Den står ved siden af
// forslaget, så et forkert gæt kan gennemskues frem for bare at være forkert.
type Suggestion struct {
Line string `json:"line"`
Why string `json:"why,omitempty"`
}
// TidyInbox foreslår, hvordan løse opgaver i indbakken kunne skrives færdig.
//
// Modellen får de projekter, der findes, og må kun bruge dem: et `#Nyt Projekt`,
// den fandt på, ville lave et projekt ved et uheld, første gang nogen sagde ja.
func (c *Client) TidyInbox(ctx context.Context, tasks, projects []string, today, locale string) ([]Suggestion, error) {
language := languageOf(locale)
known := "none"
if len(projects) > 0 {
known = "#" + strings.Join(projects, ", #")
}
system := fmt.Sprintf(`You tidy up a task inbox.
For each task you are given, write the line the person would have typed if they
had filed it properly. Keep their own words for what the task is — you are filing
it, not rewriting it.
The line may carry, in this syntax and no other:
#Project one of these existing projects, and never one that is not: %s
p1..p4 priority, where p1 is most urgent
a date in plain %s, such as "i morgen", "on friday", "15/3"
Leave out anything the task does not tell you. A guessed deadline is worse than
none: the person can add one, but they cannot see a wrong one you invented.
Today is %s.
Reply with a JSON array and nothing else — no prose, no code fence. Each element
is {"line": "...", "why": "..."} where why is at most one short sentence in %s
saying what you read out of the task. Give one element per task, in the order you
were given them, and leave a task out entirely if it is already filed well.`,
known, language, today, language)
prompt := "Tasks:\n" + strings.Join(tasks, "\n")
return c.suggestions(ctx, system, prompt)
}
// ActionsInNote trækker det, nogen har lovet, ud af en note.
//
// Beskrivelsen er stram med vilje. En model, der får lov at "finde opgaver" i en
// tekst, finder også emner, overskrifter og gode idéer — og så er resultatet en
// liste, man skal luge i, hvilket er dyrere end at skrive de tre punkter selv.
func (c *Client) ActionsInNote(ctx context.Context, note string, projects []string, today, locale string) ([]Suggestion, error) {
language := languageOf(locale)
known := "none"
if len(projects) > 0 {
known = "#" + strings.Join(projects, ", #")
}
system := fmt.Sprintf(`You read a note and pull out what somebody has to do.
Only things somebody committed to or was asked to do. A heading is not a task, a
topic that was discussed is not a task, and an idea nobody agreed to is not a
task. If the note contains none, reply with an empty array — that is a useful
answer and a made-up list is not.
Write each one as the line the person would have typed, in %s, in this syntax and
no other:
#Project one of these existing projects, and never one that is not: %s
p1..p4 priority, where p1 is most urgent
a date in plain %s, only when the note actually gives one
Today is %s.
Reply with a JSON array and nothing else — no prose, no code fence. Each element
is {"line": "...", "why": "..."} where why quotes the few words in the note that
the task came from, so it can be checked.`, language, known, language, today)
return c.suggestions(ctx, system, "Note:\n"+note)
}
// DailyPlan skriver morgenens to-tre linjer.
//
// Tallene er talt i basen og gives med som kendsgerninger, modellen ikke må regne
// om. En plan, der siger fire opgaver, hvor der er tre, er værre end ingen plan —
// og en model, der tæller en liste, tæller den forkert af og til.
//
// Det ene sted, den må mene noget, er hvad man skulle begynde med, og hvad der
// kunne vente. Det er dét, der gør det til en plan frem for en optælling.
func (c *Client) DailyPlan(ctx context.Context, tasks []string, due, overdue int, locale string) (string, error) {
language := languageOf(locale)
system := fmt.Sprintf(`You write somebody's morning note about their own day.
Write in %s. At most three short lines, no greeting, no sign-off, no
encouragement. Say what to start with and why. Name at most one thing that could
wait or be dropped, and only if something obviously can.
These numbers are counted for you and are facts: %d due today, %d overdue. Do not
recount them, do not contradict them, and do not list every task back.`,
language, due, overdue)
return c.Complete(ctx, system, []Message{
{Role: "user", Content: "The tasks:\n" + strings.Join(tasks, "\n")},
})
}
// Ask svarer på et spørgsmål ud fra de tekststykker, den får med — og kun dem.
//
// Svaret skal bære sine kilder, og det er derfor svaret er JSON og ikke prosa: en
// model, der skriver "[3]" midt i en sætning, gør det af og til, og en, der
// afleverer nummeret i et felt, gør det hver gang. Fladen skal kunne lave et link,
// ikke lede efter kantede parenteser i en tekst.
//
// "Det står der ikke" er et rigtigt svar. En model, der hellere gætter end
// skuffer, er ubrugelig til netop det her: man spørger om sine egne noter, fordi
// man ikke selv kan huske det, og har derfor ingen mulighed for at se, at svaret
// var opfundet.
func (c *Client) Ask(ctx context.Context, question string, passages []string, locale string) (string, []int, error) {
language := languageOf(locale)
system := fmt.Sprintf(`You answer a question from somebody's own notes and tasks.
Use only the passages given. If they do not answer the question, say so plainly —
that is a useful answer, and a guess is not: the person is asking because they
cannot remember, so they cannot tell an invented answer from a real one.
Write in %s, at most four short lines. Reply with a JSON object and nothing else:
{"answer": "...", "sources": [1, 3]} where sources are the numbers of the
passages the answer actually rests on, and nothing else.`, language)
prompt := "Question: " + question + "\n\nPassages:\n" + strings.Join(passages, "\n\n")
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
if err != nil {
return "", nil, err
}
var out struct {
Answer string `json:"answer"`
Sources []int `json:"sources"`
}
if err := json.Unmarshal([]byte(extractObject(reply)), &out); err != nil {
// Et svar, der ikke er JSON, er stadig et svar. Teksten er det, personen
// spurgte om; henvisningerne er det, der går tabt, og det er en dårligere
// dag end en fejlmeddelelse ville være.
return strings.TrimSpace(reply), nil, nil
}
return strings.TrimSpace(out.Answer), out.Sources, nil
}
// TasksFromMail skriver én opgavelinje pr. mail.
//
// Emnefeltet er skrevet til at blive genkendt i en indbakke, ikke til at blive
// gjort noget ved: "SV: SV: Vedr. levering uge 12" siger, hvilken tråd det er, og
// intet om, hvad man skal. Modellen skriver den handling, mailen beder om — og
// lader være, hvis den ikke beder om nogen.
func (c *Client) TasksFromMail(ctx context.Context, mails []string, today, locale string) ([]Suggestion, error) {
language := languageOf(locale)
system := fmt.Sprintf(`You turn emails into the tasks they ask for.
For each numbered email, write the line the recipient would have typed for
themselves — what they have to DO, in %s, starting with a verb. Keep the sender's
name in it when it matters who it is about. Not a summary of the email: the
action it asks for.
The line may carry a date in plain %s, and p1..p4 for priority, but only when the
email itself says so — "before friday" in the text is a date, an email that merely
sounds urgent is not. Today is %s.
Reply with a JSON array and nothing else — no prose, no code fence. Each element
is {"line": "N. ..."} keeping the number the email was given, so the line can be
put back on the right one. Leave an email out entirely if it asks for nothing.`,
language, language, today)
return c.suggestions(ctx, system, "Emails:\n"+strings.Join(mails, "\n"))
}
// suggestions er den fælles halvdel: spørg, læs svaret som JSON, og smid det væk,
// der ikke er en linje.
//
// Et tomt svar er ikke en fejl. "Der er ikke noget at foreslå" er et rigtigt svar
// på begge spørgsmål, og en fejlmeddelelse ville lære folk, at funktionen er i
// stykker, når den i virkeligheden var enig med dem.
func (c *Client) suggestions(ctx context.Context, system, prompt string) ([]Suggestion, error) {
reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}})
if err != nil {
return nil, err
}
var out []Suggestion
if err := json.Unmarshal([]byte(extractJSON(reply)), &out); err != nil {
return nil, fmt.Errorf("ai: the model did not return a list: %s", truncate(reply, 200))
}
clean := make([]Suggestion, 0, len(out))
for _, s := range out {
s.Line = strings.TrimSpace(s.Line)
s.Why = strings.TrimSpace(s.Why)
if s.Line != "" {
clean = append(clean, s)
}
}
return clean, nil
}
// languageOf er det sprog, svaret skal skrives i. Ét sted, fordi det ellers er
// den slags, der bliver rettet ét sted ud af fire.
func languageOf(locale string) string {
if strings.HasPrefix(strings.ToLower(locale), "en") {
return "English"
}
return "Danish"
}
// unfence tager svaret ud af den kodeblok, modeller pakker det ind i, uanset hvor
// omhyggeligt de bliver bedt om at lade være.
func unfence(reply string) string {
reply = strings.TrimSpace(reply)
fence := strings.Index(reply, "```")
if fence < 0 {
return reply
}
rest := reply[fence+3:]
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
rest = rest[newline+1:]
}
if end := strings.Index(rest, "```"); end >= 0 {
return strings.TrimSpace(rest[:end])
}
return reply
}
// extractObject er extractJSON for et objekt frem for en liste. Samme problem,
// samme løsning: modeller pakker svar ind i en kodeblok, hvor omhyggeligt de end
// bliver bedt om at lade være.
func extractObject(reply string) string {
reply = unfence(reply)
start := strings.IndexByte(reply, '{')
end := strings.LastIndexByte(reply, '}')
if start >= 0 && end > start {
return reply[start : end+1]
}
return reply
}
// extractJSON pulls an array out of a reply that may have been wrapped in a code
// fence or preceded by a sentence, which models do however firmly they are asked
// not to.
func extractJSON(reply string) string {
reply = strings.TrimSpace(reply)
if fence := strings.Index(reply, "```"); fence >= 0 {
rest := reply[fence+3:]
if newline := strings.IndexByte(rest, '\n'); newline >= 0 {
rest = rest[newline+1:]
}
if end := strings.Index(rest, "```"); end >= 0 {
reply = strings.TrimSpace(rest[:end])
}
}
reply = unfence(reply)
start := strings.IndexByte(reply, '[')
end := strings.LastIndexByte(reply, ']')
if start >= 0 && end > start {
+63
View File
@@ -265,6 +265,39 @@ func TestRecoveryCodes(t *testing.T) {
}
}
// TOTPStep returns the step a code matched, and it must be the same step across
// the tolerance window — that number is what makes a code single-use, so a code
// entered a little early or late has to spend the same step, not a new one.
func TestTOTPStepIsStableAcrossTheWindow(t *testing.T) {
secret, _, err := NewTOTPSecret("verdande", "[email protected]")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 3, 10, 10, 0, 15, 0, time.UTC)
code := codeAt(t, secret, now)
want, err := TOTPStep(secret, code, now)
if err != nil {
t.Fatalf("the current code did not match: %v", err)
}
// Verified one step late — the code is still valid, and must report the step it
// was issued at, not the step it was checked at.
got, err := TOTPStep(secret, code, now.Add(30*time.Second))
if err != nil {
t.Fatalf("code rejected one step late: %v", err)
}
if got != want {
t.Errorf("same code reported step %d then %d; a replay check keyed on that would not catch it", want, got)
}
if _, err := TOTPStep(secret, "000000", now); err == nil {
t.Error("a wrong code returned a step")
}
if _, err := TOTPStep("", code, now); err != ErrNoTOTPSecret {
t.Errorf("empty secret: %v, want ErrNoTOTPSecret", err)
}
}
// codeAt produces the code an authenticator app would display at that moment,
// using the same settings VerifyTOTP checks against.
func codeAt(t *testing.T, secret string, now time.Time) string {
@@ -280,3 +313,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string {
}
return code
}
// The relying party is derived from the instance's own base URL, and the two ways
// that goes wrong are both silent until a browser refuses.
func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
for _, c := range []struct {
base string
ok bool
why string
}{
{"https://verdande.example.dk", true, "a real domain"},
{"http://localhost:5173", true, "localhost, which every browser excepts"},
{"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
// An IP address is not a domain. The browser throws SecurityError long
// after the server has happily issued a challenge, so it is refused here
// and the endpoints answer 503 instead.
{"http://127.0.0.1:8080", false, "an IPv4 literal"},
{"http://[::1]:8080", false, "an IPv6 literal"},
{"", false, "no address at all"},
{"not a url", false, "not an address"},
} {
_, err := NewWebAuthn(c.base, "verdande")
if c.ok && err != nil {
t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
}
if !c.ok && err == nil {
t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
}
}
}
+71
View File
@@ -2,6 +2,7 @@ package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base32"
"errors"
"fmt"
@@ -9,6 +10,7 @@ import (
"strings"
"time"
"github.com/boombuler/barcode/qr"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
@@ -49,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) {
return key.Secret(), key.URL(), nil
}
// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can
// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no
// base64 blob to the page — one dark path on a white square, which is what a scanner
// needs whatever theme the page is in.
//
// The barcode encoder is already in the module graph (pquerna/otp draws on it), so
// this borrows a dependency rather than adding one.
func TOTPQRSVG(uri string) (string, error) {
code, err := qr.Encode(uri, qr.M, qr.Auto)
if err != nil {
return "", fmt.Errorf("auth: encode totp qr: %w", err)
}
b := code.Bounds()
n := b.Dx()
// A four-module quiet zone all round, which the spec requires and scanners rely
// on — without it the code runs to the edge and a reader cannot find its border.
const quiet = 4
size := n + quiet*2
var sb strings.Builder
fmt.Fprintf(&sb, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
// Dark modules are black; the red channel is 0 for those and full for the
// light ones. One 1×1 rect per dark module, merged into a single path.
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
}
}
}
sb.WriteString(`"/></svg>`)
return sb.String(), nil
}
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
// code again before enrolment is confirmed.
func TOTPURI(secret, issuer, account string) string {
@@ -82,6 +119,40 @@ func VerifyTOTP(secret, code string, now time.Time) error {
return nil
}
// TOTPStep verifies a code and returns the counter of the step it matched, so the
// caller can refuse to accept the same step twice.
//
// ValidateCustom answers only yes or no, and "yes" for a code inside the skew
// window is not enough to stop a replay: a code seen in flight stays valid for the
// rest of its period, and the fix is to record which step was spent and never
// accept that step or an earlier one again. That needs the number, so this walks
// the same window ValidateCustom would — one step either side — and reports the
// counter of the match. The comparison is constant time for the same reason the
// password check is: a code is a secret being checked against a computed value.
func TOTPStep(secret, code string, now time.Time) (int64, error) {
if secret == "" {
return 0, ErrNoTOTPSecret
}
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
for offset := -int64(totpSkew); offset <= int64(totpSkew); offset++ {
at := now.Add(time.Duration(offset) * totpPeriod * time.Second)
want, err := totp.GenerateCodeCustom(secret, at, totp.ValidateOpts{
Period: totpPeriod,
Skew: 0,
Digits: totpDigits,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return 0, ErrInvalidCode
}
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return at.Unix() / totpPeriod, nil
}
}
return 0, ErrInvalidCode
}
// NewRecoveryCodes returns single-use codes for someone who has lost their phone,
// and the hashes to store. Without these, losing a device means losing the account,
// and the only fix left is editing the database by hand.
+98
View File
@@ -0,0 +1,98 @@
package auth
import (
"encoding/base64"
"fmt"
"net"
"net/url"
"strings"
"github.com/go-webauthn/webauthn/webauthn"
)
// Passkeys, wrapped so the rest of the app never touches the protocol types.
//
// A password and a TOTP code are both shared secrets: the server keeps something
// that can be stolen from it, and the person keeps something that can be talked
// out of them. A passkey is neither. The private half never leaves the device, and
// the signature is bound to the origin that asked for it — so a convincing copy of
// the sign-in page at another address gets a signature it cannot use.
//
// That binding is also the one thing that can be got wrong here, and it is
// invisible when it is: the RP ID has to be the registrable domain of the address
// people actually visit. Get it wrong and registration works, login works on your
// machine, and everybody behind a different hostname is quietly locked out.
// WebAuthn is the relying party — this instance, as an authenticator sees it.
type WebAuthn struct{ *webauthn.WebAuthn }
// NewWebAuthn derives the relying party from the instance's own base URL.
//
// Derived rather than configured, because a second setting that has to agree with
// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and
// the failure is a login that cannot be completed, reported as "the button does
// nothing".
func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL)
}
// The RP ID is the host without the port. A port in it is rejected by every
// browser, and localhost:5173 during development is exactly where somebody
// would first put one.
rpID := parsed.Hostname()
// An IP address is not a domain, and no browser will accept one as an RP ID —
// `SecurityError: This is an invalid domain`, thrown in the browser, long after
// the server has happily issued a challenge. Refused here instead, so the
// endpoints answer 503 and the interface never offers a button that cannot
// work. `localhost` is the exception every browser makes, and it is what
// development runs on.
if ip := net.ParseIP(rpID); ip != nil {
return nil, fmt.Errorf(
"auth: passkeys need a domain name, and %q is an IP address — "+
"set VERDANDE_BASE_URL to the address people actually visit", rpID)
}
w, err := webauthn.New(&webauthn.Config{
RPID: rpID,
RPDisplayName: displayName,
// The origin is the full scheme://host:port, which is what the browser
// sends and what the signature is checked against.
RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")},
})
if err != nil {
return nil, fmt.Errorf("auth: passkeys: %w", err)
}
return &WebAuthn{w}, nil
}
// A PasskeyUser is what the library needs to know about an account.
//
// The handle is the account id rather than anything a person chose. It is written
// into the credential on the device and cannot be changed afterwards, so it must
// not be an email address: changing your email would orphan every key you own.
type PasskeyUser struct {
ID string
Email string
Name string
Credentials []webauthn.Credential
}
func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) }
func (u *PasskeyUser) WebAuthnName() string { return u.Email }
func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name }
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes
// go in a BLOB well enough, but the id is also compared against what the browser
// sends back as base64url, and one encoding on both sides is one thing to be
// wrong about.
func EncodeCredentialID(raw []byte) string {
return base64.RawURLEncoding.EncodeToString(raw)
}
func DecodeCredentialID(encoded string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(encoded)
}
+139
View File
@@ -0,0 +1,139 @@
// Package beacon answers one question for the project, and refuses to answer any
// other: how many installations are out there.
//
// It sends two values once a day — an anonymous id the installation made up for
// itself, and the version it is running — and nothing else. No IP is stored by
// the collector, no hostname, no domain, no account, no usage. The payload is
// small enough to be printed in full on the settings page, and it is, because a
// promise about telemetry is worth exactly as much as the reader's ability to
// check it.
//
// # On by default, and off in one click
//
// The alternative that was asked for first was mandatory, and the argument
// against it is practical rather than principled: it cannot be enforced. The
// domain can be blocked, the binary can be patched, and a fork removes it in its
// first commit — so mandatory buys nothing over on-by-default, while costing the
// trust the rest of this program works for. Secrets are sealed here, the key is
// kept out of the database, and the backup page warns you what a copy contains.
// Telemetry you are not allowed to refuse is the one sentence in that story that
// does not fit.
//
// So: on by default, said plainly, one switch to turn it off. That collects
// essentially the same number and leaves nobody feeling tricked.
//
// # Where this disagrees with the rest of the program
//
// `config.UpdateCheck` is off unless asked for, and its comment gives the reason:
// "a self-hosted app that reaches out without being told to has broken the deal
// its operator made by self-hosting." That is the same situation as this one, and
// the two defaults do not agree.
//
// The disagreement is written down here rather than smoothed over, because it is
// a real one and the reader deserves to see both halves. The case for treating
// them differently is that the update check reveals an instance to a third party
// (GitHub) and returns something the operator can act on, while this returns
// nothing and goes to the project itself — and that a count nobody sends is a
// count that does not exist, whereas an update nobody checks for is one you can
// still go and look up. Whether that is a distinction or an excuse is a judgement
// call, and it was made deliberately by the person whose instance this is.
//
// If it is ever decided the other way, the change is one word: the default in
// `beaconSettings`. Nothing else in this package assumes which way it points.
//
// # What the collector can still see
//
// A request has a source address whether or not anybody writes it down. The
// collector does not store or log it, and the code is arranged so that it never
// reaches a function that could — but it passes through a reverse proxy and a
// tunnel on the way, and those keep their own logs. Saying "we send no IP" would
// be a half-truth; what is true is that nothing here records one.
package beacon
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"time"
)
// DefaultCollector is where a fresh installation reports, unless it is told
// otherwise. Somebody running their own fleet points it at their own instance;
// the field is on the settings page for exactly that.
const DefaultCollector = "https://verdande.yggdrasilpanel.com/api/v1/beacon"
// Payload is the entire message. Two fields, and adding a third is a change to
// what this program promises, not a change to a struct.
type Payload struct {
InstanceID string `json:"instance_id"`
Version string `json:"version"`
}
// InstanceID and Version are checked on the way in as well as written on the way
// out, because the collector's endpoint is unauthenticated: anything that reaches
// it is a stranger's bytes, and it should be able to store only the two shapes it
// expects.
var (
idShape = regexp.MustCompile(`^[0-9a-fA-F-]{16,64}$`)
versionShape = regexp.MustCompile(`^v?[0-9]{1,4}(\.[0-9]{1,5}){0,3}(-[0-9A-Za-z.]{1,20})?$`)
)
// ValidID reports whether an id is the shape this program writes.
func ValidID(s string) bool { return idShape.MatchString(s) }
// ValidVersion allows a version number and the word this binary uses when it was
// built outside a release.
func ValidVersion(s string) bool { return s == "dev" || versionShape.MatchString(s) }
// Send delivers one ping. It is deliberately dull: a short timeout, no retry, no
// queue, and a failure that is returned rather than made anybody's problem.
//
// No retry, because the next pass is tomorrow and a missed day changes a count of
// installations by nothing. A telemetry ping that retries is a telemetry ping
// that can hammer somebody's server on the day it is down.
func Send(ctx context.Context, client *http.Client, collector string, p Payload) error {
if !ValidID(p.InstanceID) {
return fmt.Errorf("beacon: refusing to send a malformed id")
}
// Versionen kontrolleres også her, ikke kun hos modtageren. Kommentaren ved
// mønstrene lovede begge veje, og gjorde det ikke — og en installation, der
// sender noget mærkeligt, skal opdage det hos sig selv frem for at være noget,
// en fremmed collector skal filtrere fra.
if !ValidVersion(p.Version) {
return fmt.Errorf("beacon: refusing to send a malformed version")
}
body, err := json.Marshal(p)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, collector, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
// A plain user agent with the version already in the payload. Nothing is
// learned from it that the body does not already say, and it makes the request
// legible in a log rather than anonymous-looking.
req.Header.Set("User-Agent", "verdande-beacon/1")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Read and discard, so the connection can be reused rather than dropped.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode >= 400 {
return fmt.Errorf("beacon: collector answered %d", resp.StatusCode)
}
return nil
}
+114
View File
@@ -0,0 +1,114 @@
package beacon
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// Hvad der bliver sendt, tegn for tegn.
//
// Prøven er skrevet imod *hele* kroppen og ikke imod enkelte felter, fordi det er
// den, der er løftet: siden siger "præcis det her og ikke mere", og et felt lagt
// til senere skal vælte noget. En prøve, der kun så efter, at instance_id var med,
// ville lade en ny nøgle glide igennem uden at nogen opdagede det.
func TestSendsExactlyTwoFields(t *testing.T) {
var got map[string]any
var contentType string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
contentType = r.Header.Get("Content-Type")
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &got); err != nil {
t.Errorf("kroppen er ikke JSON: %v", err)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a",
Version: "v0.26.3",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
if contentType != "application/json" {
t.Errorf("Content-Type = %q", contentType)
}
if len(got) != 2 {
t.Fatalf("der blev sendt %d felter, ikke to: %v", len(got), got)
}
if got["instance_id"] != "01a01b16-4e54-7000-9eaa-2f8eb0629c3a" {
t.Errorf("instance_id = %v", got["instance_id"])
}
if got["version"] != "v0.26.3" {
t.Errorf("version = %v", got["version"])
}
}
// En collector, der er nede, er ikke denne installations problem.
func TestSendReportsAFailureRatherThanRetrying(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a", Version: "v1.0.0",
})
if err == nil {
t.Fatal("en 500 skal give en fejl")
}
if calls != 1 {
t.Errorf("der blev prøvet %d gange; en telemetri-ping må ikke hamre på en server, der er nede", calls)
}
}
// Et id, der ikke er id-formet, sendes slet ikke — så en fejl her ikke bliver til
// noget, en fremmed collector skal filtrere fra.
func TestSendRefusesAMalformedID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("der blev sendt noget, som ikke skulle sendes")
}))
defer srv.Close()
if err := Send(context.Background(), srv.Client(), srv.URL, Payload{
InstanceID: "; DROP TABLE beacon_installs", Version: "v1.0.0",
}); err == nil {
t.Fatal("et misformet id skal afvises før det forlader maskinen")
}
}
func TestShapes(t *testing.T) {
ok := []string{"01a01b16-4e54-7000-9eaa-2f8eb0629c3a", "89df44d7-3742-4611-9a43-db0b07733c6d"}
for _, s := range ok {
if !ValidID(s) {
t.Errorf("ValidID(%q) = false", s)
}
}
bad := []string{"", "kort", "../../etc/passwd", "01a01b16-4e54-7000-9eaa-2f8eb0629c3a'; --",
"<script>alert(1)</script>"}
for _, s := range bad {
if ValidID(s) {
t.Errorf("ValidID(%q) = true", s)
}
}
for _, s := range []string{"v0.26.3", "0.26.3", "v1.2.3-rc.1", "dev"} {
if !ValidVersion(s) {
t.Errorf("ValidVersion(%q) = false", s)
}
}
for _, s := range []string{"", "v<script>", "v" + string(make([]byte, 200))} {
if ValidVersion(s) {
t.Errorf("ValidVersion(%q) = true", s)
}
}
}
+175 -3
View File
@@ -7,6 +7,7 @@ package config
import (
"fmt"
"net/netip"
"os"
"path/filepath"
"strconv"
@@ -33,14 +34,95 @@ type Config struct {
GmailClientID string
GmailClientSecret string
// SecretKey seals the tokens and passwords in the database, 32 bytes of base64.
// Empty means a key file beside the data — see internal/secret.
SecretKey string
// Edition decides which half of the program this instance serves.
//
// "full" is tasks, projects and notes together — what Verdande has always
// been. "notes" mounts the notes half and the base underneath it, and leaves
// the task routes unmounted: not hidden in the interface, absent from the
// router, so an instance that is not a task manager cannot be asked to be one
// through its API.
//
// Notes already stand nearly alone — they reference no task type anywhere in
// the store, and the one link between the halves is a `note_links` row whose
// kind happens to be "task". What is NOT separable is everything underneath
// both: accounts, sessions, invites, sharing, notifications, attachments,
// search. Hence one program with two faces rather than two programs: the
// alternative is two copies of the base, and two copies of a thing is how the
// copies start to disagree.
//
// Projects stay mounted in both. In the notes edition a project is a
// notebook — the same column, the same sharing, a different word — because a
// second grouping concept beside `note_shares` would be a second thing to keep
// in step for a difference nobody can see.
Edition string
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
// for: a self-hosted app that reaches out without being told to has broken
// the deal its operator made by self-hosting.
UpdateCheck bool
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
// from its own interface rather than from another browser tab.
//
// A container cannot replace its own image from the inside. Restarting is the
// panel's job — it recreates the container and pulls `:latest` on the way — so
// what this needs is a way to ask. All three are required together; any one
// alone cannot do anything.
//
// The token is a panel credential with control over that server. Anybody who
// can read this environment can restart it, which is true of anybody with the
// host anyway. It is never sent to a browser: the settings page is told only
// whether it is set.
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
// front of this server. Configurable so a test can prove the budget is applied
// without spending the real one on every run.
GmailSyncBudget time.Duration
// CalendarSyncBudget is the same bound for one calendar refresh, and exists for
// the same reason: a slow Google held the request open past what Cloudflare is
// willing to wait, and the browser got Cloudflare's HTML instead of an error
// this server could explain.
CalendarSyncBudget time.Duration
// Where Google is. Empty in production; a test points them at a server it
// controls, which is the only way to exercise a Google that is slow or refuses.
// The token endpoint is shared — Gmail and Calendar sign in through the same
// registration — and the two APIs are not.
GoogleTokenURL string
GmailAPIURL string
CalendarAPIURL string
PanelURL string
PanelToken string
PanelServerID string
// TrashRetention is how long a soft-deleted task or project stays recoverable.
TrashRetention time.Duration
// TrustedProxies are the peers whose forwarded-for header may be believed.
//
// The rate limiter and the audit log key on the caller's address, and that
// address is only as honest as wherever it was read. Read it from a header that
// anybody can set, and an attacker rotates it to get a fresh guess-bucket for
// every request and to write someone else's IP into the log. So the header is
// believed only when the machine that *connected* is one of these — a reverse
// proxy or tunnel the operator put there — and ignored when the connection came
// straight off the internet.
//
// The default is "the peer is on a private network", which is exactly the shape
// of a homelab behind Caddy, nginx or a Cloudflare Tunnel: the proxy shares the
// host or the docker bridge, so its address is loopback or RFC1918. A directly
// exposed instance sees a public peer, trusts nothing, and keys on the real
// connection. Override with VERDANDE_TRUSTED_PROXIES (comma-separated CIDRs),
// or "none" to trust no proxy at all.
TrustedProxies []netip.Prefix
// RealIPHeader is where the client address is read from, for a trusted peer.
RealIPHeader string
Dev bool
}
@@ -68,9 +150,16 @@ func Load() (*Config, error) {
TrashRetention: 30 * 24 * time.Hour,
Dev: envBool("VERDANDE_DEV", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
GmailSyncBudget: 25 * time.Second,
CalendarSyncBudget: 25 * time.Second,
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
SecretKey: env("VERDANDE_SECRET_KEY", ""),
Edition: env("VERDANDE_EDITION", EditionFull),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
var err error
@@ -104,9 +193,83 @@ func Load() (*Config, error) {
if c.DataDir, err = filepath.Abs(c.DataDir); err != nil {
return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err)
}
// Afvist frem for gætte. En stavefejl — "note", "Notes" — ville ellers falde
// tilbage til den fulde udgave og servere opgaveruterne på en instans, der
// blev sat op for ikke at have dem. Det er den stille retning af fejlen, og
// den eneste måde at opdage den på ville være at bemærke, at noget virker,
// som ikke burde.
switch c.Edition {
case EditionFull, EditionNotes:
case "":
c.Edition = EditionFull
default:
return nil, fmt.Errorf("VERDANDE_EDITION: %q is not %q or %q", c.Edition, EditionFull, EditionNotes)
}
c.RealIPHeader = env("VERDANDE_REAL_IP_HEADER", "X-Forwarded-For")
if c.TrustedProxies, err = parseTrustedProxies(env("VERDANDE_TRUSTED_PROXIES", "")); err != nil {
return nil, err
}
return c, nil
}
// defaultTrustedProxies is "the peer is not on the public internet": loopback,
// the three RFC1918 ranges, Tailscale's CGNAT borrow, IPv6 unique-local and both
// families' link-local. A proxy an operator stands up in front of this server is
// on one of these; a stranger connecting directly is on none.
var defaultTrustedProxies = mustPrefixes(
"127.0.0.0/8", "::1/128",
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"100.64.0.0/10",
"fc00::/7",
"169.254.0.0/16", "fe80::/10",
)
// parseTrustedProxies reads the override. Empty keeps the private-network default;
// the literal "none" trusts no proxy at all, for an instance that is exposed
// directly and must never believe a forwarded header.
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return defaultTrustedProxies, nil
}
if strings.EqualFold(raw, "none") {
return nil, nil
}
var out []netip.Prefix
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// A bare address is a host route: 10.0.0.5 means 10.0.0.5/32, so an operator
// naming one proxy does not have to know CIDR notation to do it.
if !strings.Contains(part, "/") {
addr, err := netip.ParseAddr(part)
if err != nil {
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not an address or CIDR", part)
}
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
continue
}
p, err := netip.ParsePrefix(part)
if err != nil {
return nil, fmt.Errorf("VERDANDE_TRUSTED_PROXIES: %q is not a CIDR", part)
}
out = append(out, p.Masked())
}
return out, nil
}
func mustPrefixes(cidrs ...string) []netip.Prefix {
out := make([]netip.Prefix, 0, len(cidrs))
for _, c := range cidrs {
out = append(out, netip.MustParsePrefix(c))
}
return out
}
// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume.
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") }
func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") }
@@ -117,6 +280,15 @@ func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups"
// configured separately and left to drift out of step with it.
func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" }
// CalendarRedirectURL is a second registered URI rather than a reuse of Gmail's.
//
// One callback that decided from server-side state which of the two flows had come
// back would save the operator one line in Google Cloud, and would put the choice
// of *which token store to write* behind a lookup. When that goes wrong it goes
// wrong by overwriting a working Gmail connection with calendar tokens — a failure
// nobody would think to look for. Two paths cannot do that.
func (c *Config) CalendarRedirectURL() string { return c.BaseURL + "/oauth/calendar/callback" }
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
+46
View File
@@ -0,0 +1,46 @@
package config
// The two faces of the program. Strings rather than a bool, because a bool named
// `NotesOnly` answers one question and this one will be asked again — and a
// third edition would mean changing every reading of it rather than adding a case.
const (
EditionFull = "full"
EditionNotes = "notes"
)
// NotesOnly is the one question the router asks. Written as a method so the
// comparison lives in one place: `cfg.Edition == "notes"` spelled out at twenty
// call sites is twenty chances to typo a string the compiler does not check.
func (c *Config) NotesOnly() bool { return c.Edition == EditionNotes }
// 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"
}
+44
View File
@@ -0,0 +1,44 @@
package config
import (
"testing"
)
// A typo in VERDANDE_EDITION must stop the program, not fall through to the full
// edition. That is the silent direction of this fault: an instance set up to be a
// notes program would serve every task route, and the only way to notice would be
// to spot that something works which should not.
func TestUnknownEditionIsRefused(t *testing.T) {
for _, tc := range []struct {
value string
want string // the edition we expect, or "" when Load must fail
}{
{"", EditionFull}, // unset is the full program, as it has always been
{EditionFull, EditionFull},
{EditionNotes, EditionNotes},
{"note", ""}, // a letter short
{"Notes", ""}, // the right word, the wrong case
{"none", ""}, // plausible and meaning nothing
} {
t.Setenv("VERDANDE_EDITION", tc.value)
t.Setenv("VERDANDE_DATA_DIR", t.TempDir())
got, err := Load()
if tc.want == "" {
if err == nil {
t.Errorf("VERDANDE_EDITION=%q was accepted and gave %q", tc.value, got.Edition)
}
continue
}
if err != nil {
t.Errorf("VERDANDE_EDITION=%q: %v", tc.value, err)
continue
}
if got.Edition != tc.want {
t.Errorf("VERDANDE_EDITION=%q gave %q, want %q", tc.value, got.Edition, tc.want)
}
if notes := got.NotesOnly(); notes != (tc.want == EditionNotes) {
t.Errorf("VERDANDE_EDITION=%q: NotesOnly() = %v", tc.value, notes)
}
}
}
+30
View File
@@ -60,6 +60,20 @@ func Parse(query string, ctx Context) (Compiled, error) {
if len(p.tokens) == 0 {
return Compiled{}, fmt.Errorf("filteret er tomt")
}
// Refused before it is parsed, not while.
//
// `combine` rebuilds the whole accumulated SQL string and re-copies the
// argument slice per operator, so the cost is quadratic in the number of
// terms: fifty thousand of them — three hundred kilobytes, which fits in a
// query string — took seven seconds of CPU and forty-six gigabytes of
// allocation in one request. The preview endpoint runs this on whatever is
// typed, and there is no write timeout on the server to cut it off.
//
// Two hundred terms is a filter nobody has ever written and far below where it
// costs anything.
if len(p.tokens) > maxTokens {
return Compiled{}, fmt.Errorf("filteret er for langt")
}
node, err := p.parseExpression()
if err != nil {
@@ -140,10 +154,19 @@ func isOperator(r rune) bool {
// --- parser -------------------------------------------------------------------
// The ceilings on one filter. See Parse for why.
const (
maxTokens = 200
maxDepth = 32
)
type parser struct {
tokens []token
pos int
ctx Context
// depth guards against a filter that is only parentheses. Each level is a
// stack frame, and nothing else stops somebody sending a megabyte of them.
depth int
}
func (p *parser) done() bool { return p.pos >= len(p.tokens) }
@@ -187,6 +210,13 @@ func (p *parser) parseTerm() (Compiled, error) {
if p.done() {
return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk")
}
// Nesting is stack depth. A filter made of half a million opening parentheses
// is a megabyte of text and a stack frame for each.
p.depth++
defer func() { p.depth-- }()
if p.depth > maxDepth {
return Compiled{}, fmt.Errorf("filteret har for mange niveauer")
}
switch t := p.peek(); t.kind {
case tokNot:
+339
View File
@@ -0,0 +1,339 @@
// Package gcal reads a Google Calendar. It never writes one.
//
// The scope is calendar.readonly, and that is the whole of the decision: verdande
// already is a CalDAV server, so a Google event shown here is a second calendar
// laid over the first, not a copy Google and verdande now both have to agree
// about. Writing needs `calendar.events`, and it needs an answer to what happens
// when both sides moved the same meeting — which is a synchronisation model, not a
// scope. Read first; write later and to one calendar at a time.
//
// What the API gives back and what this returns are deliberately different shapes.
// Google answers with RFC 3339 instants for a timed event and bare dates for an
// all-day one, and with an *exclusive* end date for the second. A calendar grid
// asks one question — "which cells does this event cover" — so that is what an
// Event answers, in days, worked out once here rather than in every caller.
package gcal
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/kristianwind/verdande/internal/google"
)
// Scope is read-only, and only the calendar.
//
// Google also offers calendar.calendarlist.readonly and calendar.events.readonly,
// which together are strictly less than this — they leave out access rules and
// account settings, which verdande has no use for. They are the tighter pair and
// worth moving to; they are also newer, and this one has been documented and
// accepted unchanged for a decade. A consent screen that fails with `invalid_scope`
// is not a place to be adventurous on somebody else's Google account.
const Scope = "https://www.googleapis.com/auth/calendar.readonly"
const defaultAPIBase = "https://www.googleapis.com/calendar/v3"
// The OAuth flow, which is Google's rather than the calendar's — the same one
// Gmail signs in through, asking for the scope above. Aliased so a caller reads
// one package for one feature.
type (
Config = google.Config
PKCE = google.PKCE
Token = google.Token
)
var NewPKCE = google.NewPKCE
// ErrUnauthorized means the tokens are no longer good — access was revoked, or the
// password changed. Re-exported so a caller need not know the flow lives elsewhere.
var ErrUnauthorized = google.ErrUnauthorized
type Client struct {
accessToken string
// base is where the Calendar API is. A field rather than a constant so a test
// can point it at a server it controls; empty is Google. There is no other way
// to exercise a calendar that is slow, empty, or refuses.
base string
http *http.Client
}
func NewClient(accessToken string) *Client {
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
}
// At points the client at another server. Tests only; the zero value is Google.
func (c *Client) At(base string) *Client {
c.base = base
return c
}
func (c *Client) api() string {
if c.base != "" {
return c.base
}
return defaultAPIBase
}
// Calendar is one entry in the person's calendar list.
type Calendar struct {
ID string
// Name is the summary Google holds. A calendar somebody renamed for themselves
// carries summaryOverride instead, and that is the name they will look for.
Name string
// Colour is Google's own hex value for the calendar, which is what makes two
// calendars in one grid tellable apart. Their own colour rather than one picked
// here: it is the colour they already know the calendar by.
Colour string
TimeZone string
Primary bool
// Owner means the connected account can write to it. Nothing writes today —
// it is stored so the day something does, the interface knows which calendars
// could even be offered.
Owner bool
}
// Calendars lists what the connected account can see.
func (c *Client) Calendars(ctx context.Context) ([]Calendar, error) {
var out []Calendar
page := ""
// Bounded rather than "until Google stops": a page token that never empties is
// a loop that never ends, and the difference between a bug at Google and a
// hung sync should not be this server hanging.
for range 10 {
v := url.Values{}
v.Set("minAccessRole", "reader")
v.Set("maxResults", "250")
if page != "" {
v.Set("pageToken", page)
}
var parsed struct {
Items []struct {
ID string `json:"id"`
Summary string `json:"summary"`
SummaryOverride string `json:"summaryOverride"`
BackgroundColor string `json:"backgroundColor"`
TimeZone string `json:"timeZone"`
Primary bool `json:"primary"`
AccessRole string `json:"accessRole"`
Deleted bool `json:"deleted"`
} `json:"items"`
NextPageToken string `json:"nextPageToken"`
}
if err := c.get(ctx, c.api()+"/users/me/calendarList?"+v.Encode(), &parsed); err != nil {
return nil, err
}
for _, item := range parsed.Items {
if item.Deleted {
continue
}
name := item.SummaryOverride
if name == "" {
name = item.Summary
}
out = append(out, Calendar{
ID: item.ID, Name: name, Colour: item.BackgroundColor,
TimeZone: item.TimeZone, Primary: item.Primary,
Owner: item.AccessRole == "owner" || item.AccessRole == "writer",
})
}
if parsed.NextPageToken == "" {
break
}
page = parsed.NextPageToken
}
return out, nil
}
// Event is one occurrence, in the shape a calendar grid asks about.
type Event struct {
ID string
Summary string
// StartsAt and EndsAt are RFC 3339 for a timed event and empty for an all-day
// one — an all-day event has no instant, only a day, and inventing midnight for
// it is how an event moves a day every time a server changes time zone.
StartsAt string
EndsAt string
// StartDay and EndDay are the first and last day the event covers, inclusive at
// both ends. Google's all-day end date is exclusive; that is corrected here,
// once, rather than in every reader.
StartDay string
EndDay string
AllDay bool
Location string
// URL opens the event in Google's own interface. Same reasoning as the Gmail
// link on a task made from a mail: what verdande shows is a pointer to the
// event, not a second copy of it that somebody could try to edit.
URL string
}
// Events lists what falls inside a window, one calendar at a time.
//
// `singleEvents` expands a recurrence into its occurrences, which is the only form
// a grid can draw: an RRULE is an instruction, and a Tuesday cell needs the Tuesday
// it produced. It also means verdande does not have to grow a second recurrence
// engine that agrees with Google's about Danish summer time.
//
// from and to are dates, inclusive. The window is the caller's business; this
// turns it into the half-open interval Google wants.
func (c *Client) Events(ctx context.Context, calendarID, from, to string) ([]Event, error) {
start, err := time.Parse(dateLayout, from)
if err != nil {
return nil, fmt.Errorf("gcal: from: %w", err)
}
end, err := time.Parse(dateLayout, to)
if err != nil {
return nil, fmt.Errorf("gcal: to: %w", err)
}
var out []Event
page := ""
for range 20 {
v := url.Values{}
v.Set("singleEvents", "true")
v.Set("orderBy", "startTime")
v.Set("maxResults", "2500")
// UTC, and a day of slack at each end. An event at 23:30 on the last day in
// Copenhagen is 21:30 UTC that day, but one at 00:30 on the first day is
// 22:30 UTC the day *before* — so a window cut exactly at the requested
// dates loses the events at both edges. The extra day is thrown away again
// by the day fields below, which are computed from the event's own offset.
v.Set("timeMin", start.AddDate(0, 0, -1).Format(time.RFC3339))
v.Set("timeMax", end.AddDate(0, 0, 2).Format(time.RFC3339))
if page != "" {
v.Set("pageToken", page)
}
var parsed struct {
Items []rawEvent `json:"items"`
NextPageToken string `json:"nextPageToken"`
}
url := c.api() + "/calendars/" + url.PathEscape(calendarID) + "/events?" + v.Encode()
if err := c.get(ctx, url, &parsed); err != nil {
return nil, err
}
for _, item := range parsed.Items {
// A cancelled occurrence of a recurring event still comes back, so that
// a client keeping its own copy knows to drop it. verdande replaces the
// window wholesale, so it only has to not add it.
if item.Status == "cancelled" {
continue
}
event, ok := item.event()
if !ok {
continue
}
if event.EndDay < from || event.StartDay > to {
continue // the slack above, thrown away
}
out = append(out, event)
}
if parsed.NextPageToken == "" {
break
}
page = parsed.NextPageToken
}
return out, nil
}
const dateLayout = "2006-01-02"
type rawEvent struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Location string `json:"location"`
HTMLLink string `json:"htmlLink"`
Start when `json:"start"`
End when `json:"end"`
}
type when struct {
Date string `json:"date"`
DateTime string `json:"dateTime"`
}
// event turns Google's two shapes into the one a grid can read.
//
// The second return is false for an event with neither a date nor a dateTime —
// which Google does return, for an event somebody is still being asked about. It
// belongs on no day, so there is no cell to draw it in.
func (r rawEvent) event() (Event, bool) {
e := Event{
ID: r.ID, Summary: strings.TrimSpace(r.Summary),
Location: r.Location, URL: r.HTMLLink,
}
if e.Summary == "" {
// Google's own interface says this for an event with no title, and a blank
// chip in a grid reads as a rendering fault rather than as an empty title.
e.Summary = "(uden titel)"
}
switch {
case r.Start.Date != "":
e.AllDay = true
e.StartDay = r.Start.Date
// Google's all-day end is the day *after* the last one, so a one-day event
// ends tomorrow. Subtracting a day is what makes "covers this cell" a
// comparison rather than a special case at every call site.
e.EndDay = r.Start.Date
if day, err := time.Parse(dateLayout, r.End.Date); err == nil {
if last := day.AddDate(0, 0, -1); !last.Before(mustDay(r.Start.Date)) {
e.EndDay = last.Format(dateLayout)
}
}
case r.Start.DateTime != "":
e.StartsAt = r.Start.DateTime
e.EndsAt = r.End.DateTime
// The day is the date part of the timestamp Google returned, which carries
// the event's own offset. Parsing it into a time.Time and formatting it
// back would ask *this process* what day it is — and a container running in
// UTC would then move a 23:30 meeting in Copenhagen to the day before.
e.StartDay = dayOf(r.Start.DateTime)
e.EndDay = dayOf(r.End.DateTime)
if e.EndDay == "" {
e.EndDay = e.StartDay
}
// An event ending at midnight ends the evening before, as far as a grid is
// concerned. Without this, every meeting that runs to 24:00 puts a chip in
// the following day as well.
if e.EndDay > e.StartDay && strings.Contains(r.End.DateTime, "T00:00:00") {
if day, err := time.Parse(dateLayout, e.EndDay); err == nil {
e.EndDay = day.AddDate(0, 0, -1).Format(dateLayout)
}
}
default:
return Event{}, false
}
if e.EndDay < e.StartDay {
e.EndDay = e.StartDay
}
return e, true
}
// dayOf takes the date out of an RFC 3339 timestamp without interpreting it.
func dayOf(stamp string) string {
if len(stamp) < len(dateLayout) {
return ""
}
day := stamp[:len(dateLayout)]
if _, err := time.Parse(dateLayout, day); err != nil {
return ""
}
return day
}
func mustDay(s string) time.Time {
day, _ := time.Parse(dateLayout, s)
return day
}
func (c *Client) get(ctx context.Context, url string, out any) error {
return google.Get(ctx, c.http, c.accessToken, url, out)
}
+255
View File
@@ -0,0 +1,255 @@
package gcal
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeGoogle answers the two calls this package makes, out of whatever the test
// hands it. There is no other way to exercise this: the real Calendar needs an
// account, a registered client and somebody's consent, and none of the three can be
// had inside a test — so the shapes Google returns are written down here and the
// parsing is checked against them.
func fakeGoogle(t *testing.T, calendarList any, events map[string]any) *Client {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
_ = json.NewEncoder(w).Encode(calendarList)
case strings.HasPrefix(r.URL.Path, "/calendars/"):
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
body, ok := events[id]
if !ok {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(body)
default:
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return NewClient("token").At(srv.URL)
}
func TestCalendarsPrefersTheNameThePersonGaveIt(t *testing.T) {
client := fakeGoogle(t, map[string]any{
"items": []map[string]any{
{"id": "primary", "summary": "[email protected]", "backgroundColor": "#3f51b5",
"timeZone": "Europe/Copenhagen", "primary": true, "accessRole": "owner"},
{"id": "fam", "summary": "Family", "summaryOverride": "Familien",
"backgroundColor": "#0b8043", "accessRole": "reader"},
// A calendar removed from the list still arrives, flagged.
{"id": "gone", "summary": "Væk", "deleted": true, "accessRole": "reader"},
},
}, nil)
got, err := client.Calendars(t.Context())
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d calendars, want 2 — a deleted one is not one you can show", len(got))
}
if got[1].Name != "Familien" {
t.Errorf("name = %q; summaryOverride is the name the person will look for", got[1].Name)
}
if !got[0].Primary || !got[0].Owner {
t.Errorf("the primary calendar came back as %+v", got[0])
}
if got[1].Owner {
t.Error("a reader was reported as an owner; nothing may offer to write to it")
}
if got[0].Colour != "#3f51b5" {
t.Errorf("colour = %q; two calendars in one grid are told apart by it", got[0].Colour)
}
}
// The three shapes an event arrives in, and the one shape a grid can read.
func TestEventsBecomeDaysAGridCanAskAbout(t *testing.T) {
client := fakeGoogle(t, nil, map[string]any{
"primary": map[string]any{
"items": []map[string]any{
{
"id": "timed", "summary": "Bestyrelsesmøde", "htmlLink": "https://cal/1",
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"},
},
{
// All-day, one day. Google's end is the day after.
"id": "allday", "summary": "Fridag",
"start": map[string]string{"date": "2026-08-21"},
"end": map[string]string{"date": "2026-08-22"},
},
{
// All-day, three days.
"id": "trip", "summary": "Aarhus",
"start": map[string]string{"date": "2026-08-24"},
"end": map[string]string{"date": "2026-08-27"},
},
{
// Runs to midnight, which is the evening before as far as a
// grid is concerned.
"id": "late", "summary": "Nattevagt",
"start": map[string]string{"dateTime": "2026-08-22T20:00:00+02:00"},
"end": map[string]string{"dateTime": "2026-08-23T00:00:00+02:00"},
},
{
// A cancelled occurrence of a recurring event.
"id": "off", "status": "cancelled", "summary": "Aflyst",
"start": map[string]string{"date": "2026-08-25"},
"end": map[string]string{"date": "2026-08-26"},
},
{
// Neither a date nor a dateTime: belongs on no day at all.
"id": "nowhere", "summary": "Uden tid",
},
},
},
})
got, err := client.Events(t.Context(), "primary", "2026-08-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
byID := map[string]Event{}
for _, e := range got {
byID[e.ID] = e
}
if len(got) != 4 {
t.Fatalf("got %d events, want 4: %+v", len(got), got)
}
if _, ok := byID["off"]; ok {
t.Error("a cancelled occurrence was kept; it is an event that is not happening")
}
if _, ok := byID["nowhere"]; ok {
t.Error("an event with no time was kept; there is no cell to draw it in")
}
timed := byID["timed"]
if timed.AllDay || timed.StartDay != "2026-08-20" || timed.EndDay != "2026-08-20" {
t.Errorf("timed = %+v", timed)
}
if timed.StartsAt != "2026-08-20T14:00:00+02:00" {
t.Errorf("the instant was rewritten: %q", timed.StartsAt)
}
allday := byID["allday"]
if !allday.AllDay || allday.StartDay != "2026-08-21" || allday.EndDay != "2026-08-21" {
t.Errorf("allday = %+v; Google's end date is exclusive and has to be corrected here",
allday)
}
if allday.StartsAt != "" {
t.Errorf("an all-day event was given an instant: %q — it has a day, not a moment",
allday.StartsAt)
}
if trip := byID["trip"]; trip.StartDay != "2026-08-24" || trip.EndDay != "2026-08-26" {
t.Errorf("trip = %+v, want 24th to 26th inclusive", trip)
}
if late := byID["late"]; late.EndDay != "2026-08-22" {
t.Errorf("late ends on %q; an event running to midnight ends the evening before, "+
"or every one of them puts a chip in the following day", late.EndDay)
}
}
// An event with no title has to say so. A blank chip in a grid reads as a
// rendering fault rather than as an empty title.
func TestAnEventWithNoTitleStillSaysSomething(t *testing.T) {
client := fakeGoogle(t, nil, map[string]any{
"c": map[string]any{"items": []map[string]any{{
"id": "x",
"start": map[string]string{"date": "2026-08-20"},
"end": map[string]string{"date": "2026-08-21"},
}}},
})
got, err := client.Events(t.Context(), "c", "2026-08-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Summary == "" {
t.Fatalf("got %+v", got)
}
}
// The window is asked for with a day of slack at each end, because an event late
// in the evening in Copenhagen is the next day in UTC. The slack must not leak
// out: an event on the day before the window is not in the window.
func TestTheSlackAtTheEdgesIsThrownAwayAgain(t *testing.T) {
client := fakeGoogle(t, nil, map[string]any{
"c": map[string]any{"items": []map[string]any{
{"id": "before", "summary": "For tidligt",
"start": map[string]string{"date": "2026-08-09"},
"end": map[string]string{"date": "2026-08-10"}},
{"id": "inside", "summary": "Med",
"start": map[string]string{"date": "2026-08-10"},
"end": map[string]string{"date": "2026-08-11"}},
{"id": "after", "summary": "For sent",
"start": map[string]string{"date": "2026-08-21"},
"end": map[string]string{"date": "2026-08-22"}},
}},
})
got, err := client.Events(t.Context(), "c", "2026-08-10", "2026-08-20")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != "inside" {
t.Fatalf("got %+v, want only the one inside the window", got)
}
}
// A page token has to be followed, or a busy calendar silently shows its first
// page and nothing else — which is a calendar that lies about a day being clear.
func TestEveryPageIsRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body := map[string]any{
"items": []map[string]any{{"id": "a", "summary": "Første",
"start": map[string]string{"date": "2026-08-11"},
"end": map[string]string{"date": "2026-08-12"}}},
"nextPageToken": "more",
}
if r.URL.Query().Get("pageToken") == "more" {
body = map[string]any{"items": []map[string]any{{"id": "b", "summary": "Anden",
"start": map[string]string{"date": "2026-08-12"},
"end": map[string]string{"date": "2026-08-13"}}}}
}
_ = json.NewEncoder(w).Encode(body)
}))
defer srv.Close()
got, err := NewClient("t").At(srv.URL).Events(t.Context(), "c", "2026-08-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d events, want both pages: %+v", len(got), got)
}
}
// A 401 is not a failure to retry; it is a connection that has ended. Telling the
// two apart is what stops a revoked account being polled every ten minutes for ever.
func TestARefusedTokenIsItsOwnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
}))
defer srv.Close()
_, err := NewClient("spent").At(srv.URL).Calendars(t.Context())
if err != ErrUnauthorized {
t.Fatalf("err = %v, want ErrUnauthorized", err)
}
}
// Read-only, and only the calendar. The consent screen is what somebody agrees to,
// and a scope that quietly grew is a promise quietly broken.
func TestTheScopeIsReadOnly(t *testing.T) {
if !strings.HasSuffix(Scope, "calendar.readonly") {
t.Errorf("scope = %q; nothing here writes a calendar", Scope)
}
}
+78 -186
View File
@@ -1,183 +1,86 @@
// Package gmail implements the OAuth2 authorisation-code flow with PKCE against
// Google, and the small slice of the Gmail API verdande needs.
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
// it asks for.
//
// The connection is one-way on purpose: a starred or labelled message becomes a
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
// message does not delete the task, completing the task does not unstar the
// message. Two-way would mean deciding what to do when both sides changed, and the
// only honest answer to that is a synchronisation model far bigger than the feature.
// The one thing verdande writes back is the star. A starred message becomes a
// task, and once it has, the star comes off — exactly as the IMAP side takes the
// flag off a mail it has imported, and for the same reason: the star meant "turn
// this into a task", the task now exists, so the star is no longer true. Nothing
// else is touched. Completing the task does not reach back to the mail; the mail
// is a pointer the task was made from, not a copy the two have to be kept in step.
//
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
// asking for less is what makes the consent screen something a person can agree to.
// That one write is why the scope is gmail.modify rather than gmail.readonly.
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
// can do it — so the consent screen asks for more than it used to, and the trade
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
// than one that fills with stars nothing ever clears. Still not send, still not
// delete — modify is labels, and the only label verdande removes is the one that
// triggered the task.
//
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
// The names below are aliases so that every call site — and this package's own
// tests — still read as Gmail's, which is what they are: one registration, one
// flow, one scope each.
package gmail
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/kristianwind/verdande/internal/google"
)
const (
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
tokenEndpoint = "https://oauth2.googleapis.com/token"
apiBase = "https://gmail.googleapis.com/gmail/v1"
// Read mail and change its labels, and only mail. Not profile, not contacts, not
// send, not delete. The single write is removing the STARRED label from a message
// that has become a task; see the package comment for why that costs modify.
const Scope = "https://www.googleapis.com/auth/gmail.modify"
// Read-only, and only mail. Not profile, not contacts, not send.
Scope = "https://www.googleapis.com/auth/gmail.readonly"
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
// The OAuth flow, which is Google's rather than Gmail's.
type (
Config = google.Config
PKCE = google.PKCE
Token = google.Token
)
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
// the instance rather than to a person: one registration, any number of users.
type Config struct {
ClientID string
ClientSecret string
RedirectURL string
}
func (c Config) Configured() bool {
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
}
// PKCE is the proof-key pair for one authorisation attempt.
//
// PKCE on a server-side client is not strictly required — the client secret already
// authenticates the token exchange — but it costs nothing and closes the window in
// which an authorisation code intercepted from the redirect could be replayed
// before verdande gets to it.
type PKCE struct {
Verifier string
Challenge string
State string
}
func NewPKCE() (PKCE, error) {
verifier, err := randomString(64)
if err != nil {
return PKCE{}, err
}
state, err := randomString(32)
if err != nil {
return PKCE{}, err
}
sum := sha256.Sum256([]byte(verifier))
return PKCE{
Verifier: verifier,
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
State: state,
}, nil
}
// AuthURL is where the browser is sent to ask the person for consent.
func (c Config) AuthURL(p PKCE) string {
v := url.Values{}
v.Set("client_id", c.ClientID)
v.Set("redirect_uri", c.RedirectURL)
v.Set("response_type", "code")
v.Set("scope", Scope)
v.Set("state", p.State)
v.Set("code_challenge", p.Challenge)
v.Set("code_challenge_method", "S256")
// Without both of these Google returns no refresh token on a re-authorisation,
// and the connection silently stops working an hour later.
v.Set("access_type", "offline")
v.Set("prompt", "consent")
return authEndpoint + "?" + v.Encode()
}
type Token struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
// Exchange trades the authorisation code for tokens.
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
form := url.Values{}
form.Set("code", code)
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
form.Set("redirect_uri", c.RedirectURL)
form.Set("grant_type", "authorization_code")
form.Set("code_verifier", p.Verifier)
return c.token(ctx, form)
}
// Refresh gets a fresh access token. Google does not return a new refresh token
// here, so the caller keeps the one it has.
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
form := url.Values{}
form.Set("refresh_token", refreshToken)
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
form.Set("grant_type", "refresh_token")
token, err := c.token(ctx, form)
if err != nil {
return Token{}, err
}
token.RefreshToken = refreshToken
return token, nil
}
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
strings.NewReader(form.Encode()))
if err != nil {
return Token{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return Token{}, err
}
defer resp.Body.Close()
var parsed struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return Token{}, fmt.Errorf("gmail: reading token response: %w", err)
}
if parsed.Error != "" {
return Token{}, fmt.Errorf("gmail: %s: %s", parsed.Error, parsed.ErrorDescription)
}
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
return Token{}, fmt.Errorf("gmail: token exchange failed with %s", resp.Status)
}
return Token{
AccessToken: parsed.AccessToken,
RefreshToken: parsed.RefreshToken,
// A minute of slack, so a token that is about to expire is refreshed
// rather than used and rejected.
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
}, nil
}
var (
NewPKCE = google.NewPKCE
ErrUnauthorized = google.ErrUnauthorized
)
// --- the API ---------------------------------------------------------------------
type Client struct {
accessToken string
http *http.Client
// base is where the Gmail API is. A field rather than a constant so a test can
// point it at a server it controls — and "the sync comes back before whatever
// sits in front of this server gives up on it" is exactly the kind of promise
// worth a test and impossible to make against the real thing. Empty is Google.
base string
http *http.Client
}
func NewClient(accessToken string) *Client {
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
}
// At points the client at another server. Tests only; the zero value is Google.
func (c *Client) At(base string) *Client {
c.base = base
return c
}
func (c *Client) api() string {
if c.base != "" {
return c.base
}
return defaultAPIBase
}
// Message is the part of a Gmail message a task is made from.
type Message struct {
ID string
@@ -196,7 +99,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
var parsed struct {
EmailAddress string `json:"emailAddress"`
}
if err := c.get(ctx, apiBase+"/users/me/profile", &parsed); err != nil {
if err := c.get(ctx, c.api()+"/users/me/profile", &parsed); err != nil {
return "", err
}
return parsed.EmailAddress, nil
@@ -220,7 +123,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
ID string `json:"id"`
} `json:"messages"`
}
if err := c.get(ctx, apiBase+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
if err := c.get(ctx, c.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
return nil, err
}
@@ -251,7 +154,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
} `json:"headers"`
} `json:"payload"`
}
if err := c.get(ctx, apiBase+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
if err := c.get(ctx, c.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
return Message{}, err
}
@@ -271,30 +174,27 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
}
func (c *Client) get(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return ErrUnauthorized
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("gmail: %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
return google.Get(ctx, c.http, c.accessToken, url, out)
}
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
// or changed their password. The caller disconnects rather than retrying.
var ErrUnauthorized = fmt.Errorf("gmail: authorisation was refused")
// Unstar takes the star off the given messages — one call for all of them, the way
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
// not ten.
//
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
// label would need one, and verdande removes none. batchModify answers 204 with no
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
// an error on Google's side and nothing worth making a round trip to hear.
func (c *Client) Unstar(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
body := struct {
IDs []string `json:"ids"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
}
// SenderName pulls "Anders Jensen" out of "Anders Jensen <[email protected]>",
// falling back to the address when there is no display name.
@@ -338,11 +238,3 @@ func Query(trigger, label string) string {
terms = append(terms, "newer_than:30d")
return strings.Join(terms, " ")
}
func randomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
}
+57 -86
View File
@@ -1,110 +1,81 @@
package gmail
import (
"crypto/sha256"
"encoding/base64"
"net/url"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
// Getting this wrong means Google refuses every exchange with a message that does
// not say which end is at fault.
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
p, err := NewPKCE()
if err != nil {
t.Fatal(err)
// The scope is the one thing verdande chooses about the connection, and it is the
// one the person reads on the consent screen. The flow itself is exercised in
// internal/google; what belongs here is that Gmail asks for modify — read plus the
// one write it makes, taking the star off an imported message — and nothing wider.
func TestTheScopeIsModifyMail(t *testing.T) {
if !strings.HasSuffix(Scope, "gmail.modify") {
t.Errorf("scope = %q; want gmail.modify — read, plus removing the star", Scope)
}
sum := sha256.Sum256([]byte(p.Verifier))
want := base64.RawURLEncoding.EncodeToString(sum[:])
if p.Challenge != want {
t.Errorf("challenge = %q, want %q", p.Challenge, want)
}
// RFC 7636 requires 43–128 characters, and no padding.
if n := len(p.Verifier); n < 43 || n > 128 {
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
}
if strings.ContainsAny(p.Verifier, "=+/") {
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
}
if p.State == "" {
t.Error("no state was generated")
}
// Two attempts must not share anything.
other, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
if other.Verifier == p.Verifier || other.State == p.State {
t.Error("two authorisation attempts produced the same secrets")
// Modify is the ceiling. Send, or the full account, would be a different promise
// to the person on the consent screen than the one this feature needs.
for _, wider := range []string{"gmail.send", "mail.google.com", "gmail.settings"} {
if strings.Contains(Scope, wider) {
t.Errorf("scope = %q; must not include %q", Scope, wider)
}
}
}
func TestAuthURL(t *testing.T) {
cfg := Config{
ClientID: "client-123",
ClientSecret: "secret",
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
// Unstar removes the star from a batch in one call, and it addresses the messages
// it was given and only the STARRED label.
func TestUnstarRemovesOnlyTheStar(t *testing.T) {
var gotPath string
var gotBody struct {
IDs []string `json:"ids"`
AddLabelIDs []string `json:"addLabelIds"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}
p, err := NewPKCE()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &gotBody)
w.WriteHeader(http.StatusNoContent) // what batchModify answers on success
}))
defer srv.Close()
err := NewClient("token").At(srv.URL).Unstar(context.Background(), []string{"a", "b"})
if err != nil {
t.Fatal(err)
t.Fatalf("Unstar: %v", err)
}
raw := cfg.AuthURL(p)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("the URL does not parse: %v", err)
if !strings.HasSuffix(gotPath, "/users/me/messages/batchModify") {
t.Errorf("path = %q, want the batchModify endpoint", gotPath)
}
q := parsed.Query()
checks := map[string]string{
"client_id": cfg.ClientID,
"redirect_uri": cfg.RedirectURL,
"response_type": "code",
"scope": Scope,
"code_challenge": p.Challenge,
"code_challenge_method": "S256",
"state": p.State,
// Without both of these Google returns no refresh token on a
// re-authorisation, and the connection dies silently an hour later.
"access_type": "offline",
"prompt": "consent",
if strings.Join(gotBody.IDs, ",") != "a,b" {
t.Errorf("ids = %v, want [a b]", gotBody.IDs)
}
for key, want := range checks {
if got := q.Get(key); got != want {
t.Errorf("%s = %q, want %q", key, got, want)
}
if strings.Join(gotBody.RemoveLabelIDs, ",") != "STARRED" {
t.Errorf("removeLabelIds = %v, want [STARRED]", gotBody.RemoveLabelIDs)
}
// The verifier itself must never leave the server.
if strings.Contains(raw, p.Verifier) {
t.Error("the code verifier was put in the authorisation URL")
}
// Read-only, and only mail.
if !strings.HasSuffix(Scope, "gmail.readonly") {
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
if len(gotBody.AddLabelIDs) != 0 {
t.Errorf("addLabelIds = %v, want none — verdande adds no label", gotBody.AddLabelIDs)
}
}
func TestConfigured(t *testing.T) {
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
if !full.Configured() {
t.Error("a complete config is not reported as configured")
// An empty batch is not a request: Gmail rejects a batchModify with no ids, and
// there is nothing to say to it anyway.
func TestUnstarOfNothingMakesNoCall(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
called = true
}))
defer srv.Close()
if err := NewClient("token").At(srv.URL).Unstar(context.Background(), nil); err != nil {
t.Fatalf("Unstar(nil): %v", err)
}
for _, partial := range []Config{
{},
{ClientID: "a"},
{ClientID: "a", ClientSecret: "b"},
{ClientSecret: "b", RedirectURL: "c"},
} {
if partial.Configured() {
t.Errorf("%+v was reported as configured", partial)
}
if called {
t.Error("Unstar(nil) made a request; it should make none")
}
}
+258
View File
@@ -0,0 +1,258 @@
// Package google implements the OAuth2 authorisation-code flow with PKCE against
// Google.
//
// It was written inside internal/gmail, because Gmail was the only thing that
// needed it. Calendar needs the same flow — the same endpoints, the same PKCE, the
// same refresh — and the only thing that differs between them is one string: the
// scope. A second copy would be a second place for "access_type=offline" to be
// forgotten, which is the mistake that makes a connection die silently an hour
// after it was made.
//
// What is *not* here is the API calls. Those differ completely, so gmail and gcal
// keep their own clients and share only the way in.
package google
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const (
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
)
// Config is the OAuth client the operator registered in Google Cloud, plus the
// scope one feature is asking for.
//
// The client belongs to the instance rather than to a person: one registration,
// any number of users. The scope belongs to the feature: Gmail and Calendar sign
// in through the same registration and ask for different things, and Google issues
// a refresh token per authorisation — so connecting one does not disturb the other.
type Config struct {
ClientID string
ClientSecret string
RedirectURL string
Scope string
// TokenURL is where Google is. A field rather than a constant so a test can
// point it at a server it controls: there is no other way to exercise what
// happens when the token endpoint is slow or refuses. Empty is Google, so
// nothing outside a test has to know it exists.
TokenURL string
}
func (c Config) Configured() bool {
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
}
func (c Config) tokenURL() string {
if c.TokenURL != "" {
return c.TokenURL
}
return defaultTokenEndpoint
}
// PKCE is the proof-key pair for one authorisation attempt.
//
// PKCE on a server-side client is not strictly required — the client secret already
// authenticates the token exchange — but it costs nothing and closes the window in
// which an authorisation code intercepted from the redirect could be replayed
// before verdande gets to it.
type PKCE struct {
Verifier string
Challenge string
State string
}
func NewPKCE() (PKCE, error) {
verifier, err := randomString(64)
if err != nil {
return PKCE{}, err
}
state, err := randomString(32)
if err != nil {
return PKCE{}, err
}
sum := sha256.Sum256([]byte(verifier))
return PKCE{
Verifier: verifier,
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
State: state,
}, nil
}
// AuthURL is where the browser is sent to ask the person for consent.
func (c Config) AuthURL(p PKCE) string {
v := url.Values{}
v.Set("client_id", c.ClientID)
v.Set("redirect_uri", c.RedirectURL)
v.Set("response_type", "code")
v.Set("scope", c.Scope)
v.Set("state", p.State)
v.Set("code_challenge", p.Challenge)
v.Set("code_challenge_method", "S256")
// Without both of these Google returns no refresh token on a re-authorisation,
// and the connection silently stops working an hour later.
v.Set("access_type", "offline")
v.Set("prompt", "consent")
return authEndpoint + "?" + v.Encode()
}
type Token struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
// Exchange trades the authorisation code for tokens.
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
form := url.Values{}
form.Set("code", code)
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
form.Set("redirect_uri", c.RedirectURL)
form.Set("grant_type", "authorization_code")
form.Set("code_verifier", p.Verifier)
return c.token(ctx, form)
}
// Refresh gets a fresh access token. Google does not return a new refresh token
// here, so the caller keeps the one it has.
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
form := url.Values{}
form.Set("refresh_token", refreshToken)
form.Set("client_id", c.ClientID)
form.Set("client_secret", c.ClientSecret)
form.Set("grant_type", "refresh_token")
token, err := c.token(ctx, form)
if err != nil {
return Token{}, err
}
token.RefreshToken = refreshToken
return token, nil
}
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL(),
strings.NewReader(form.Encode()))
if err != nil {
return Token{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return Token{}, err
}
defer resp.Body.Close()
var parsed struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return Token{}, fmt.Errorf("google: reading token response: %w", err)
}
if parsed.Error != "" {
return Token{}, fmt.Errorf("google: %s: %s", parsed.Error, parsed.ErrorDescription)
}
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
return Token{}, fmt.Errorf("google: token exchange failed with %s", resp.Status)
}
return Token{
AccessToken: parsed.AccessToken,
RefreshToken: parsed.RefreshToken,
// A minute of slack, so a token that is about to expire is refreshed
// rather than used and rejected.
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
}, nil
}
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
// or changed their password. The caller disconnects rather than retrying.
var ErrUnauthorized = fmt.Errorf("google: authorisation was refused")
// Get performs an authorised GET and decodes the JSON body into out.
//
// Shared by the two API clients because the interesting part is the same in both:
// a 401 is not a failure to retry, it is a connection that has ended, and telling
// those apart is what stops a revoked account being polled every ten minutes for
// ever.
func Get(ctx context.Context, client *http.Client, accessToken, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return ErrUnauthorized
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("google: %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// Post sends a JSON body to a Google API and, unlike Get, does not insist on a
// body coming back. A write call — modifying labels on a message — answers with
// 204 No Content when it worked, so anything 2xx is success and only the body is
// decoded, and only when the caller asked for one.
func Post(ctx context.Context, client *http.Client, accessToken, url string, body, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return ErrUnauthorized
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("google: %s", resp.Status)
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
func randomString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
}
+127
View File
@@ -0,0 +1,127 @@
package google
import (
"crypto/sha256"
"encoding/base64"
"net/url"
"strings"
"testing"
)
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
// Getting this wrong means Google refuses every exchange with a message that does
// not say which end is at fault.
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
p, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256([]byte(p.Verifier))
want := base64.RawURLEncoding.EncodeToString(sum[:])
if p.Challenge != want {
t.Errorf("challenge = %q, want %q", p.Challenge, want)
}
// RFC 7636 requires 43–128 characters, and no padding.
if n := len(p.Verifier); n < 43 || n > 128 {
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
}
if strings.ContainsAny(p.Verifier, "=+/") {
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
}
if p.State == "" {
t.Error("no state was generated")
}
// Two attempts must not share anything.
other, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
if other.Verifier == p.Verifier || other.State == p.State {
t.Error("two authorisation attempts produced the same secrets")
}
}
func TestAuthURL(t *testing.T) {
cfg := Config{
ClientID: "client-123",
ClientSecret: "secret",
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
Scope: "https://www.googleapis.com/auth/gmail.readonly",
}
p, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
raw := cfg.AuthURL(p)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("the URL does not parse: %v", err)
}
q := parsed.Query()
checks := map[string]string{
"client_id": cfg.ClientID,
"redirect_uri": cfg.RedirectURL,
"response_type": "code",
"scope": cfg.Scope,
"code_challenge": p.Challenge,
"code_challenge_method": "S256",
"state": p.State,
// Without both of these Google returns no refresh token on a
// re-authorisation, and the connection dies silently an hour later.
"access_type": "offline",
"prompt": "consent",
}
for key, want := range checks {
if got := q.Get(key); got != want {
t.Errorf("%s = %q, want %q", key, got, want)
}
}
// The verifier itself must never leave the server.
if strings.Contains(raw, p.Verifier) {
t.Error("the code verifier was put in the authorisation URL")
}
}
// The scope is the one thing that differs between the two features signing in
// through this, so it has to come out of the config rather than out of a constant
// somebody could forget to change.
func TestTheScopeComesFromTheConfig(t *testing.T) {
p, err := NewPKCE()
if err != nil {
t.Fatal(err)
}
calendar := "https://www.googleapis.com/auth/calendar.readonly"
raw := Config{ClientID: "a", Scope: calendar}.AuthURL(p)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if got := parsed.Query().Get("scope"); got != calendar {
t.Errorf("scope = %q, want %q — asking for the wrong one is a consent screen "+
"that grants the wrong thing", got, calendar)
}
}
func TestConfigured(t *testing.T) {
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
if !full.Configured() {
t.Error("a complete config is not reported as configured")
}
for _, partial := range []Config{
{},
{ClientID: "a"},
{ClientID: "a", ClientSecret: "b"},
{ClientSecret: "b", RedirectURL: "c"},
} {
if partial.Configured() {
t.Errorf("%+v was reported as configured", partial)
}
}
}
+203
View File
@@ -0,0 +1,203 @@
package httpapi
import (
"net/http"
"sort"
"strings"
"time"
"github.com/kristianwind/verdande/internal/store"
)
// Spørg om sine egne noter og opgaver i almindeligt sprog.
//
// Søgningen finder det, der indeholder ordene. Det her svarer på spørgsmålet —
// "hvad lovede jeg Anders i august?" — og det er ikke det samme: svaret står
// spredt over tre noter og en opgave, og ingen af dem indeholder ordet "lovede".
//
// To halvdele, og rækkefølgen er hele sikkerheden i det. Basen finder kandidaterne
// med den søgning, der allerede findes, og som allerede kun kan se det, personen
// selv må se. Modellen får kun dem — den slår ikke selv noget op og kan ikke bede
// om mere. Et svar, der ikke står i det udleverede, kan derfor ikke handle om
// nogen andens noter.
//
// Og svaret bærer, hvad det er bygget på. En model, der svarer "det gjorde du den
// 14.", er kun brugbar, hvis man kan slå op i den note, den læste det i.
type askRequest struct {
Question string `json:"question"`
}
type askSourceJSON struct {
Kind string `json:"kind"` // "note" eller "task"
ID string `json:"id"`
Title string `json:"title"`
}
// handleAIAsk svarer på et spørgsmål ud fra personens egne noter og opgaver.
func (s *Server) handleAIAsk(w http.ResponseWriter, r *http.Request) {
var req askRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
question := strings.TrimSpace(req.Question)
if question == "" {
writeFieldErrors(w, map[string]string{"question": "required"})
return
}
user := userFrom(r.Context())
client, ok := s.aiClientFor(w, r)
if !ok {
return
}
// Ord for ord, ikke hele spørgsmålet på én gang.
//
// Søgningen kræver, at *alle* ord optræder, og det er den rigtige regel for et
// søgefelt: skriver man to ord, mener man begge. Men et spørgsmål er ikke en
// søgning — "hvad lovede jeg Anders i august" indeholder ét ord, der står i
// noten, og fem, der ikke gør, så et krav om dem alle finder ingenting hver
// eneste gang.
//
// Så: et groft net her, og modellen læser fangsten. Alternativet — at lade
// modellen skrive søgningen — lægger et led mere ind, der kan tage fejl,
// mellem spørgsmålet og de noter, der faktisk findes.
notes, tasks, err := s.candidatesFor(r, user.ID, question)
if err != nil {
s.internal(w, r, "search for the question", err)
return
}
if len(notes) == 0 && len(tasks) == 0 {
writeJSON(w, http.StatusOK, map[string]any{
"answer": "", "sources": []askSourceJSON{},
})
return
}
// Nummereret, fordi svaret skal kunne pege tilbage. En henvisning til "noten
// om leveringen" er ikke noget, fladen kan lave et link ud af; et nummer er.
sources := make([]askSourceJSON, 0, len(notes)+len(tasks))
passages := make([]string, 0, len(notes)+len(tasks))
for _, n := range notes {
sources = append(sources, askSourceJSON{Kind: "note", ID: n.ID, Title: n.Title})
passages = append(passages, "["+itoaSource(len(sources))+"] Note: "+n.Title+"\n"+
firstWords(n.Body, 300))
}
for _, t := range tasks {
sources = append(sources, askSourceJSON{Kind: "task", ID: t.ID, Title: t.Content})
line := "[" + itoaSource(len(sources)) + "] Task: " + t.Content
if t.DueDate != "" {
line += " (" + t.DueDate + ")"
}
if t.Description != "" {
line += "\n" + firstWords(t.Description, 60)
}
passages = append(passages, line)
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
answer, used, err := client.Ask(ctx, question, passages, user.Locale)
if err != nil {
s.log.Warn("ai ask", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
// Kun de kilder, svaret faktisk brugte. Alle tolv ville gøre henvisningen
// værdiløs: en liste over alt, der blev kigget i, er ikke det samme som det,
// svaret står på.
cited := make([]askSourceJSON, 0, len(used))
for _, n := range used {
if n >= 1 && n <= len(sources) {
cited = append(cited, sources[n-1])
}
}
writeJSON(w, http.StatusOK, map[string]any{"answer": answer, "sources": cited})
}
func itoaSource(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return string(rune('0'+n/10)) + string(rune('0'+n%10))
}
// candidatesFor henter det, spørgsmålet kunne handle om.
//
// Ét opslag pr. ord, lagt sammen og med det, der rammes af flere ord, øverst: et
// spørgsmål har ét eller to bærende ord og en håndfuld, der bare er dansk, og den
// note, der indeholder begge de bærende, er den, det handler om.
//
// Korte ord springes over. "i", "på" og "the" står i alt, og en note, der blev
// hentet, fordi den indeholder "i", fortrænger en, der faktisk svarer.
func (s *Server) candidatesFor(r *http.Request, userID, question string) ([]store.Note, []store.Task, error) {
terms := store.SearchTerms(question)
type scored struct {
note store.Note
task store.Task
hits int
}
noteHits := map[string]*scored{}
taskHits := map[string]*scored{}
for _, term := range terms {
if len([]rune(term)) < 3 {
continue
}
found, err := s.db.SearchNotes(r.Context(), term, 10)
if err != nil {
return nil, nil, err
}
for _, n := range found {
if at, ok := noteHits[n.ID]; ok {
at.hits++
continue
}
noteHits[n.ID] = &scored{note: n, hits: 1}
}
tasks, err := s.db.ListTasks(r.Context(), userID, store.TaskFilter{
Search: term, Limit: 10, IncludeCompleted: true,
})
if err != nil {
return nil, nil, err
}
for _, t := range tasks {
if at, ok := taskHits[t.ID]; ok {
at.hits++
continue
}
taskHits[t.ID] = &scored{task: t, hits: 1}
}
}
notes := make([]store.Note, 0, len(noteHits))
for _, at := range noteHits {
notes = append(notes, at.note)
}
sort.SliceStable(notes, func(i, j int) bool {
return noteHits[notes[i].ID].hits > noteHits[notes[j].ID].hits
})
tasks := make([]store.Task, 0, len(taskHits))
for _, at := range taskHits {
tasks = append(tasks, at.task)
}
sort.SliceStable(tasks, func(i, j int) bool {
return taskHits[tasks[i].ID].hits > taskHits[tasks[j].ID].hits
})
// Loftet er, hvad der kan stå i én prompt uden at koste mere, end svaret er
// værd — og hvad et menneske ville have gidet læse igennem selv.
return capped(notes, 12), capped(tasks, 20), nil
}
func capped[T any](in []T, n int) []T {
if len(in) > n {
return in[:n]
}
return in
}
+119
View File
@@ -0,0 +1,119 @@
package httpapi
import (
"context"
"strconv"
"strings"
"time"
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
)
// En mail, der bliver til en brugbar opgave.
//
// Uden det her hedder opgaven, hvad mailen hed: "Anders Jensen: SV: SV: Vedr.
// levering uge 12". Det er nok til at genkende den og ikke nok til at gøre noget
// ved den — og en indbakke af den slags linjer er præcis den bunke, man havde
// mail for at slippe for.
//
// Ét kald pr. gennemløb frem for ét pr. mail. Femogtyve mails er femogtyve gange
// ventetid og femogtyve gange betaling for det samme spørgsmål, og et gennemløb,
// der tager fem minutter, er et, der overlapper med det næste.
//
// Falder det ud, er der ikke sket noget: opgaven hedder afsender og emne, som den
// altid har gjort. En model, der er nede, må ikke kunne stoppe posten.
// mailForAI er én mail, som modellen får den at se.
type mailForAI struct {
From string
Subject string
Snippet string
}
// mailTaskLines beder modellen om én linje pr. mail, i quick-add-syntaks.
//
// Returnerer en tabel fra mailens plads i listen til linjen. Ikke en liste, fordi
// en model, der springer en mail over, ellers ville flytte alle de følgende
// linjer én plads — og så ville en opgave hedde noget, der stod i en anden mail.
// Det er den samme forsigtighed som ved oprydningen i indbakken, og den samme
// grund: en forskydning på én er den slags fejl, ingen opdager.
func (s *Server) mailTaskLines(ctx context.Context, user *store.User, mails []mailForAI) map[int]string {
if len(mails) == 0 {
return nil
}
cfg, err := s.aiConfigFor(ctx, user)
if err != nil || !cfg.Configured() {
return nil
}
lines := make([]string, 0, len(mails))
for i, m := range mails {
line := strconv.Itoa(i+1) + ". " + m.From + " — " + m.Subject
if m.Snippet != "" {
line += "\n " + firstWords(m.Snippet, 40)
}
lines = append(lines, line)
}
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
out, err := ai.New(cfg).TasksFromMail(ctx, lines, today, user.Locale)
if err != nil {
s.log.Info("mail tasks without the model", "err", err, "user", user.ID)
return nil
}
byIndex := map[int]string{}
for i, sug := range out {
n, line := numberedLine(sug.Line)
if n == 0 {
n = i + 1
}
if n < 1 || n > len(mails) || strings.TrimSpace(line) == "" {
continue
}
byIndex[n-1] = line
}
return byIndex
}
// applyMailLine lægger modellens linje ind i opgaven, hvis der er en.
//
// Linjen går gennem quick-add-parseren som alt andet, så en mail, hvor der står
// "inden fredag", får en dato — og "#Firma" i en linje, modellen fandt på, flytter
// ingenting, for projektet slås op blandt dem, der findes.
//
// Emnet bliver stående i beskrivelsen. Modellen har skrevet en titel ud fra det,
// og hvis den har misforstået mailen, skal det oprindelige stadig kunne læses ved
// siden af — ellers er den eneste vej tilbage at finde mailen frem igen.
func (s *Server) applyMailLine(ctx context.Context, user *store.User, task *store.Task, line, subject string) {
if strings.TrimSpace(line) == "" {
return
}
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
if strings.TrimSpace(parsed.Content) == "" {
return
}
task.Content = parsed.Content
if parsed.Priority != 0 {
task.Priority = parsed.Priority
}
if parsed.DueDate != "" {
task.DueDate = parsed.DueDate
}
if parsed.Project != "" {
if id, err := s.db.ProjectByName(ctx, user.ID, parsed.Project); err == nil {
if _, err := store.RequireProjectRole(ctx, s.db, id, user.ID, store.RoleEditor); err == nil {
task.ProjectID = id
}
}
}
if subject != "" {
task.Description = strings.TrimSpace(subject + "\n\n" + task.Description)
}
}
+409
View File
@@ -0,0 +1,409 @@
package httpapi
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/store"
)
// Dagens plan: én besked om morgenen om det, der venter i dag.
//
// Klokken siger, hvad andre har gjort. Det her siger, hvad der ligger foran en —
// og det er en anden slags besked: den kommer, fordi det er blevet morgen, ikke
// fordi nogen har rørt noget. Uden den skal man selv åbne listen for at opdage,
// at der lå tre ting og forfaldt i går.
//
// Sendt som en besked frem for en mail, fordi røret allerede findes: klokken,
// push til telefonen og mærket på ikonet er ét kald, og en mail mere i indbakken
// er præcis det, dette program findes for at slippe for.
const planScope = "dailyplan"
// planSettings er, hvad én person har bedt om.
//
// Slået fra som udgangspunkt. En besked, man ikke har bedt om, klokken syv om
// morgenen, er ikke en hjælp — og modsat beaconet er der ingen, der har brug for,
// at det her er tændt for at kunne gøre sit arbejde.
type planSettings struct {
Enabled bool `json:"enabled"`
// Hour er lokal tid hos personen selv. Et tal og ikke et klokkeslæt: planen
// sendes på timen, og et minuttal ville love en præcision, en time-tikkende
// baggrundsjob ikke har.
Hour int `json:"hour"`
// LastSent er datoen, den sidst blev sendt, som "2006-01-02" i personens egen
// tidszone. En dato og ikke et tidsstempel, fordi spørgsmålet er "har de fået
// dagens plan i dag" — og det er et spørgsmål om dagen dér, hvor de er.
LastSent string `json:"last_sent,omitempty"`
// Plan er teksten, som den blev sendt, og PlanDate den dag den gælder.
//
// Gemt frem for regnet forfra, hver gang nogen kigger. To grunde, og den anden
// er den vigtige: en model pr. sidevisning er dyr, og en plan, der siger noget
// andet end den besked, man fik i morges, er værre end begge dele hver for
// sig. Det er den samme tekst — ellers er det ikke *dagens plan*, men et nyt
// gæt hver gang man kigger.
//
// PlanAt er klokkeslættet, den blev lavet, så kortet kan sige det: en plan fra
// klokken syv er en anden slags oplysning klokken fire end klokken otte.
Plan string `json:"plan,omitempty"`
PlanDate string `json:"plan_date,omitempty"`
PlanAt int64 `json:"plan_at,omitempty"`
}
func (s *Server) planSettings(ctx context.Context, userID string) (planSettings, error) {
out := planSettings{Hour: 7}
stored, err := s.db.UserSettings(ctx, userID, planScope)
if err != nil {
return out, err
}
if v, ok := stored["enabled"].(bool); ok {
out.Enabled = v
}
if v, ok := stored["hour"].(float64); ok && v >= 0 && v <= 23 {
out.Hour = int(v)
}
if v, ok := stored["last_sent"].(string); ok {
out.LastSent = v
}
if v, ok := stored["plan"].(string); ok {
out.Plan = v
}
if v, ok := stored["plan_date"].(string); ok {
out.PlanDate = v
}
if v, ok := stored["plan_at"].(float64); ok {
out.PlanAt = int64(v)
}
return out, nil
}
func (s *Server) savePlanSettings(ctx context.Context, userID string, p planSettings) error {
return s.db.SetUserSettings(ctx, userID, planScope, map[string]any{
"enabled": p.Enabled, "hour": p.Hour, "last_sent": p.LastSent,
"plan": p.Plan, "plan_date": p.PlanDate, "plan_at": p.PlanAt,
})
}
// handleGetPlanSettings er både indstillingen og dagens plan.
//
// Ét endepunkt, fordi det er én ting: planen *er* ressourcen, og indstillingen er,
// hvornår den bliver lavet. To ville betyde to kald fra siden, der viser den, og
// to steder at huske at rydde en plan fra i går.
//
// En plan fra i går sendes ikke med. Den er ikke forkert, den er bare ikke dagens
// — og et kort, der viser gårsdagens plan under overskriften "i dag", er værre end
// et tomt kort.
func (s *Server) handleGetPlanSettings(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
p, err := s.planSettings(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "plan settings", err)
return
}
if p.PlanDate != time.Now().In(userLocation(user.Timezone)).Format("2006-01-02") {
p.Plan, p.PlanDate, p.PlanAt = "", "", 0
}
writeJSON(w, http.StatusOK, p)
}
type planRequest struct {
Enabled *bool `json:"enabled"`
Hour *int `json:"hour"`
}
func (s *Server) handleSetPlanSettings(w http.ResponseWriter, r *http.Request) {
var req planRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
p, err := s.planSettings(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "plan settings", err)
return
}
if req.Enabled != nil {
p.Enabled = *req.Enabled
}
if req.Hour != nil {
if *req.Hour < 0 || *req.Hour > 23 {
writeFieldErrors(w, map[string]string{"hour": "must be between 0 and 23"})
return
}
p.Hour = *req.Hour
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu.
// Uden det her ville en, der flytter planen fra 07 til 16, først få den i
// morgen — hvilket ligner, at indstillingen ikke virkede.
p.LastSent = ""
}
if err := s.savePlanSettings(r.Context(), user.ID, p); err != nil {
s.internal(w, r, "save plan settings", err)
return
}
writeJSON(w, http.StatusOK, p)
}
type planNowRequest struct {
// Silent er kortet på I dag, der beder om en plan, mens man kigger på den.
//
// En besked om noget, man står og ser på, er en besked, der lærer folk at
// ignorere klokken. Indstillingssidens knap sender derimod rigtigt — det er
// hele dens formål: at vise, at push virker på det her apparat.
Silent bool `json:"silent"`
}
// handlePlanNow laver dagens plan med det samme, til den der beder om det.
//
// Findes, fordi en indstilling, man skal vente til i morgen for at se virkningen
// af, er en indstilling, ingen tør slå til — og fordi kortet på I dag skal kunne
// lave en plan for den, der har beskeden slået fra.
func (s *Server) handlePlanNow(w http.ResponseWriter, r *http.Request) {
var req planNowRequest
if r.ContentLength > 0 {
if err := decodeJSON(w, r, &req); err != nil {
return
}
}
user := userFrom(r.Context())
plan, err := s.buildPlan(r.Context(), user)
if err != nil {
s.internal(w, r, "build plan", err)
return
}
if plan == "" {
writeJSON(w, http.StatusOK, map[string]any{"plan": "", "sent": false})
return
}
if err := s.rememberPlan(r.Context(), user, plan); err != nil {
s.internal(w, r, "save plan", err)
return
}
sent := false
if !req.Silent {
s.notify(r, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
sent = true
}
writeJSON(w, http.StatusOK, map[string]any{
"plan": plan, "sent": sent,
"plan_at": time.Now().Unix(),
})
}
// rememberPlan gemmer dagens plan, så den kan ses igen uden at blive lavet igen.
func (s *Server) rememberPlan(ctx context.Context, user *store.User, plan string) error {
p, err := s.planSettings(ctx, user.ID)
if err != nil {
return err
}
now := time.Now()
p.Plan = plan
p.PlanDate = now.In(userLocation(user.Timezone)).Format("2006-01-02")
p.PlanAt = now.Unix()
return s.savePlanSettings(ctx, user.ID, p)
}
// DailyPlans er timeslaget: hvem er der morgen hos, og hvem har ikke fået planen
// endnu i dag.
//
// Kaldt hver time frem for lagt på et klokkeslæt — samme form som backup og
// beacon, og af samme grund: en instans, der sov klokken syv, ville ellers
// springe dagen over. Og fordi klokken syv er syv forskellige steder: personens
// egen tidszone afgør, hvornår det er morgen hos dem.
func (s *Server) DailyPlans(ctx context.Context) error {
users, err := s.db.ListUsers(ctx)
if err != nil {
return err
}
for _, u := range users {
user, err := s.db.UserByID(ctx, u.ID)
if err != nil {
continue
}
p, err := s.planSettings(ctx, user.ID)
if err != nil || !p.Enabled {
continue
}
now := time.Now().In(userLocation(user.Timezone))
today := now.Format("2006-01-02")
if p.LastSent == today || now.Hour() < p.Hour {
continue
}
// Kun samme dag. En instans, der har været slukket i tre dage, skal ikke
// sende tre planer, når den kommer op — de to af dem er om dage, der er
// gået, og en plan for i forgårs er ikke en plan.
if now.Hour() > p.Hour+3 {
p.LastSent = today
_ = s.savePlanSettings(ctx, user.ID, p)
continue
}
plan, err := s.buildPlan(ctx, user)
if err != nil {
s.log.Warn("build daily plan", "err", err, "user", user.ID)
continue
}
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god
// morgen, og en besked, der siger "der er ingenting", lærer folk at lade
// være med at åbne beskeder.
if plan != "" {
s.notifyDirect(ctx, &store.Notification{
UserID: user.ID, Kind: "daily.plan",
Title: planTitle(user.Locale), Body: plan,
})
// Gemt, og gemt her frem for i rememberPlan: rækken er allerede
// læst ind i `p`, og to gemninger oven i hinanden ville lade den
// ene overskrive den andens `last_sent`.
p.Plan, p.PlanDate, p.PlanAt = plan, today, time.Now().Unix()
}
p.LastSent = today
if err := s.savePlanSettings(ctx, user.ID, p); err != nil {
s.log.Warn("save plan settings", "err", err, "user", user.ID)
}
}
return nil
}
// buildPlan skriver dagens plan.
//
// Tallene kommer fra basen og sætningen fra modellen — i den rækkefølge. En model,
// der selv skulle tælle, ville af og til tælle forkert, og en plan, der siger fire
// opgaver, hvor der er tre, er værre end ingen plan. Er der ingen model sat op,
// står tallene alene, og det er stadig det, man havde brug for at vide.
func (s *Server) buildPlan(ctx context.Context, user *store.User) (string, error) {
loc := userLocation(user.Timezone)
now := time.Now().In(loc)
today := now.Format("2006-01-02")
// I går, ikke i dag: grænserne i filteret er inklusive, så "før i dag" skrevet
// som DueBefore: today ville tælle dagens egne opgaver med som sprunget over —
// og en plan, der klokken syv om morgenen siger, at dagens opgaver allerede er
// overskredet, er en plan, man holder op med at læse.
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
overdue, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{DueBefore: yesterday, Limit: 50})
if err != nil {
return "", err
}
due, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
DueFrom: today, DueBefore: today, Limit: 50,
})
if err != nil {
return "", err
}
if len(overdue) == 0 && len(due) == 0 {
return "", nil
}
plain := plainPlan(len(due), len(overdue), user.Locale)
cfg, err := s.aiConfigFor(ctx, user)
if err != nil || !cfg.Configured() {
return plain, nil
}
lines := make([]string, 0, len(due)+len(overdue))
for _, t := range overdue {
lines = append(lines, "- "+t.Content+" ("+overdueWord(user.Locale)+" "+t.DueDate+")")
}
for _, t := range due {
lines = append(lines, "- "+t.Content)
}
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
written, err := ai.New(cfg).DailyPlan(ctx, lines, len(due), len(overdue), user.Locale)
if err != nil {
// Modellen er ikke det, planen står og falder med. Tallene er rigtige uden
// den, og en morgen uden besked, fordi en API-nøgle er udløbet, er en
// dårligere handel end en besked uden en velskrevet sætning.
s.log.Info("daily plan without the model", "err", err, "user", user.ID)
return plain, nil
}
return strings.TrimSpace(written), nil
}
// aiConfigFor er aiConfig uden en request bag sig — baggrundsjobbet har ingen.
//
// AllowPrivate er falsk her, og det er med vilje: retten til at nå det private
// net blev givet til en administrator, der sad og skrev adressen ind, og et job,
// der kører klokken syv om morgenen, er ikke nogen, der sidder og skriver. En
// model på maskinen selv skal derfor bruges gennem fladen, ikke af planen.
func (s *Server) aiConfigFor(ctx context.Context, user *store.User) (ai.Config, error) {
values, err := s.db.UserSettings(ctx, user.ID, "ai")
if err != nil {
return ai.Config{}, err
}
str := func(key string) string {
v, _ := values[key].(string)
return v
}
return ai.Config{
Provider: ai.Provider(str("provider")),
APIKey: str("api_key"),
BaseURL: str("base_url"),
Model: str("model"),
}, nil
}
// notifyDirect er notify uden en request. Baggrundsjobbet har ingen bruger i
// konteksten og ingen actor — planen er ikke noget, nogen har gjort ved en.
func (s *Server) notifyDirect(ctx context.Context, n *store.Notification) {
if err := s.db.CreateNotification(ctx, n); err != nil {
s.log.Warn("create notification", "err", err)
return
}
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
ID: n.ID, Kind: n.Kind, Title: n.Title, Body: n.Body,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
go s.pushToUser(n.UserID, n.Title, n.Body, "")
}
func planTitle(locale string) string {
if strings.HasPrefix(strings.ToLower(locale), "en") {
return "Today"
}
return "I dag"
}
func overdueWord(locale string) string {
if strings.HasPrefix(strings.ToLower(locale), "en") {
return "was due"
}
return "forfaldt"
}
// plainPlan er planen uden en model: to tal, og ikke et ord mere.
//
// Det er ikke en nødløsning, det er bunden. Det, man har brug for at vide klokken
// syv, er om der er noget, der er sprunget over — og det tal er rigtigt, uanset
// om nogen har lagt en API-nøgle ind.
func plainPlan(due, overdue int, locale string) string {
english := strings.HasPrefix(strings.ToLower(locale), "en")
var parts []string
if due > 0 {
if english {
parts = append(parts, strconv.Itoa(due)+" due today")
} else {
parts = append(parts, strconv.Itoa(due)+" forfalder i dag")
}
}
if overdue > 0 {
if english {
parts = append(parts, strconv.Itoa(overdue)+" overdue")
} else {
parts = append(parts, strconv.Itoa(overdue)+" er sprunget over")
}
}
return strings.Join(parts, ", ")
}
+157
View File
@@ -0,0 +1,157 @@
package httpapi
import (
"net/http"
"testing"
"time"
)
// Planen er tal fra basen, ikke fra modellen. En plan, der siger fire opgaver,
// hvor der er tre, er værre end ingen plan — så tallene tælles her, og modellen
// får dem udleveret som kendsgerninger.
func TestTheDailyPlanCountsForItself(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "ring til anders i dag")
ts.quickTask(t, "køb mælk i dag")
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("send planen: %d %v", resp.StatusCode, out)
}
if out["sent"] != true {
t.Fatalf("planen blev ikke sendt: %v", out)
}
// Uden en model står tallene alene, og det er stadig det, man havde brug for
// at vide klokken syv.
if plan, _ := out["plan"].(string); plan != "2 forfalder i dag" {
t.Errorf("plan = %q", plan)
}
// Og den lander i klokken som en besked.
list, unread := notifications(t, ts)
if len(list) != 1 || unread != 1 {
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
}
if n := firstOf(t, list); n["kind"] != "daily.plan" {
t.Errorf("besked = %v", n)
}
}
// Ingen plan er ikke en besked. En morgen uden noget at gøre er en god morgen, og
// en besked, der siger "der er ingenting", lærer folk at lade være med at åbne
// beskeder.
func TestAnEmptyDaySendsNoPlan(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("send planen: %d", resp.StatusCode)
}
if out["sent"] != false {
t.Errorf("der blev sendt en plan om ingenting: %v", out)
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("beskeder = %d, want 0", len(list))
}
}
// Timen er flyttet, så dagens plan er ikke sendt på den nye tid endnu. Uden det
// ville en, der flytter planen fra 07 til 16, først få den i morgen — hvilket
// ligner, at indstillingen ikke virkede.
func TestMovingThePlanHourClearsTodaysMark(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{
"enabled": true, "hour": 7,
}); resp.StatusCode != http.StatusOK {
t.Fatal("slå planen til")
}
if _, out := ts.do(t, "POST", "/api/v1/ai/plan/now", nil); out["sent"] != true {
t.Fatalf("planen blev ikke sendt: %v", out)
}
resp, out := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 16})
if resp.StatusCode != http.StatusOK {
t.Fatalf("flyt timen: %d", resp.StatusCode)
}
if out["hour"] != float64(16) {
t.Errorf("time = %v", out["hour"])
}
if out["last_sent"] != nil && out["last_sent"] != "" {
t.Errorf("mærket for i dag blev stående: %v", out["last_sent"])
}
}
// En time, der ikke findes på et ur, er en fejl i feltet og ikke en 500.
func TestAnImpossiblePlanHourIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
if resp, _ := ts.do(t, "PUT", "/api/v1/ai/plan", map[string]any{"hour": 25}); resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("time 25 gav %d, want 422", resp.StatusCode)
}
}
// Planen gemmes for dagen, så kortet på I dag kan vise nøjagtig den tekst,
// beskeden bar — og ikke et nyt gæt, hver gang nogen kigger.
func TestTodaysPlanIsKeptForTheDay(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
// Ingen plan endnu: kortet viser en knap frem for gårsdagens tekst.
if _, before := ts.do(t, "GET", "/api/v1/ai/plan", nil); before["plan"] != nil && before["plan"] != "" {
t.Fatalf("der lå en plan, før nogen havde lavet en: %v", before["plan"])
}
resp, made := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("lav planen: %d", resp.StatusCode)
}
if made["sent"] != false {
t.Errorf("en stille plan sendte en besked: %v", made["sent"])
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("kortets egen plan gav %d beskeder, want 0", len(list))
}
_, after := ts.do(t, "GET", "/api/v1/ai/plan", nil)
if after["plan"] != made["plan"] {
t.Errorf("den gemte plan = %v, den lavede = %v", after["plan"], made["plan"])
}
if after["plan_at"] == nil {
t.Error("planen bærer ikke, hvornår den blev lavet")
}
}
// En plan fra i går er ikke dagens. Et kort, der viser gårsdagens plan under
// overskriften "i dag", er værre end et tomt kort.
func TestYesterdaysPlanIsNotTodays(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.quickTask(t, "noget i dag")
if resp, _ := ts.do(t, "POST", "/api/v1/ai/plan/now", map[string]any{"silent": true}); resp.StatusCode != http.StatusOK {
t.Fatal("lav planen")
}
// Sat en dag tilbage, som om den var lavet i går.
user := userID(t, ts, "[email protected]")
stored, err := ts.db.UserSettings(t.Context(), user, "dailyplan")
if err != nil {
t.Fatal(err)
}
stored["plan_date"] = time.Now().AddDate(0, 0, -1).Format("2006-01-02")
if err := ts.db.SetUserSettings(t.Context(), user, "dailyplan", stored); err != nil {
t.Fatal(err)
}
_, out := ts.do(t, "GET", "/api/v1/ai/plan", nil)
if plan, _ := out["plan"].(string); plan != "" {
t.Errorf("gårsdagens plan blev vist som dagens: %q", plan)
}
}
+370
View File
@@ -0,0 +1,370 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
)
// AI'ens forslag, og hvad der sker med dem.
//
// Fælles for det hele: modellen skriver ikke noget. Den foreslår en *linje* i den
// syntaks, man selv skriver i — "Ring til Anders i morgen p1 #Firma" — og
// forslaget bliver til noget, når nogen siger ja til det. Det er ikke
// forsigtighed for forsigtighedens skyld: en model, der retter i folks opgaver
// direkte, skal have ret hver gang for at være til gavn, hvor en, der foreslår,
// skal have ret tit nok til at spare et par tastetryk.
//
// Og linjen er quick-add-syntaks frem for et felt pr. egenskab, fordi parseren
// findes: der er én fortolkning af "i morgen" i dette program, og forslaget går
// gennem den samme som alt andet.
// aiSuggestionJSON er ét forslag på vej til fladen.
type aiSuggestionJSON struct {
// TaskID er sat, når forslaget handler om en opgave, der allerede findes —
// altså ved oprydningen i indbakken. Tomt, når forslaget er en ny opgave.
TaskID string `json:"task_id,omitempty"`
Line string `json:"line"`
Why string `json:"why,omitempty"`
}
// aiClientFor er de tre linjer, hver eneste AI-funktion ellers ville gentage:
// hent opsætningen, og sig pænt fra, når der ikke er nogen.
func (s *Server) aiClientFor(w http.ResponseWriter, r *http.Request) (*ai.Client, bool) {
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "ai settings", err)
return nil, false
}
if !cfg.Configured() {
// Ikke en fejl, nogen har lavet: funktionen er slået fra, indtil man
// lægger sin egen nøgle ind.
writeError(w, http.StatusConflict, CodeAINotConfigured, "no AI provider is configured")
return nil, false
}
return ai.New(cfg), true
}
// projectNames er de projekter, modellen må nævne.
//
// Givet med frem for udeladt, fordi alternativet er, at den finder på et: et
// `#Nyt projekt`, ingen har, ville lave et projekt ved et uheld, første gang
// nogen sagde ja til forslaget.
func (s *Server) projectNames(r *http.Request) []string {
projects, err := s.db.ListProjects(r.Context(), userFrom(r.Context()).ID, false)
if err != nil {
s.log.Warn("projects for ai", "err", err)
return nil
}
out := make([]string, 0, len(projects))
for _, p := range projects {
if p.IsInbox {
continue
}
out = append(out, p.Name)
}
return out
}
// handleAITidyInbox foreslår, hvordan det, der ligger løst i indbakken, kunne
// skrives færdig.
//
// Indbakken er der, hvor ting lander, når man ikke havde tid til at skrive dem
// ordentligt — og den er derfor også der, hvor de bliver liggende. At åbne hver
// enkelt og sætte projekt, dato og prioritet er ti klik pr. linje; det her er ét
// spørgsmål og et ja pr. linje.
func (s *Server) handleAITidyInbox(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
client, ok := s.aiClientFor(w, r)
if !ok {
return
}
inbox, err := s.db.InboxID(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "inbox", err)
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
ProjectIDs: []string{inbox}, Limit: 50,
})
if err != nil {
s.internal(w, r, "list inbox", err)
return
}
if len(tasks) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
return
}
// Nummereret, så svaret kan lægges tilbage på den rigtige opgave. Modellen
// bliver bedt om at svare i rækkefølge, men en model, der springer en over,
// må ikke kunne flytte alle de andres forslag én plads — derfor står
// nummeret i teksten og bliver læst tilbage.
lines := make([]string, 0, len(tasks))
for i, t := range tasks {
line := strconv.Itoa(i+1) + ". " + t.Content
if t.Description != "" {
line += " — " + firstWords(t.Description, 30)
}
lines = append(lines, line)
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
suggestions, err := client.TidyInbox(ctx, lines, s.projectNames(r), today, user.Locale)
if err != nil {
s.log.Warn("ai tidy inbox", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
out := make([]aiSuggestionJSON, 0, len(suggestions))
for i, sug := range suggestions {
n, line := numberedLine(sug.Line)
// Uden et nummer falder vi tilbage på rækkefølgen, som er det, der blev
// bedt om. Med et nummer, der ikke findes, springes forslaget over —
// et forslag, der peger på en opgave, der ikke er der, er ikke et forslag.
if n == 0 {
n = i + 1
}
if n < 1 || n > len(tasks) {
continue
}
if strings.TrimSpace(line) == "" {
continue
}
out = append(out, aiSuggestionJSON{TaskID: tasks[n-1].ID, Line: line, Why: sug.Why})
}
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
}
type applyLineRequest struct {
TaskID string `json:"task_id"`
Line string `json:"line"`
}
// handleAIApplyToTask skriver ét accepteret forslag ind i den opgave, det handler
// om.
//
// Parsningen sker her og ikke i browseren, så "i morgen" betyder det samme, som
// det gør alle andre steder i programmet — og så et forslag, der er blevet rettet
// i hånden, inden det blev sagt ja til, bliver læst på nøjagtig samme måde som et,
// der ikke blev rørt.
func (s *Server) handleAIApplyToTask(w http.ResponseWriter, r *http.Request) {
var req applyLineRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
if strings.TrimSpace(req.Line) == "" {
writeFieldErrors(w, map[string]string{"line": "required"})
return
}
task, err := s.db.GetTask(r.Context(), req.TaskID, user.ID)
if err != nil {
s.storeError(w, r, "get task", err)
return
}
loc := userLocation(user.Timezone)
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
if strings.TrimSpace(parsed.Content) == "" {
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
return
}
// Kun det, linjen faktisk siger. En tom dato i et forslag betyder "der stod
// ikke noget om en dato" og ikke "fjern den, der er" — modellen er blevet bedt
// om at lade være med at gætte datoer, og så må et manglende gæt heller ikke
// slette noget.
update := store.TaskUpdate{Content: &parsed.Content}
if parsed.Priority != 0 {
update.Priority = &parsed.Priority
}
if parsed.DueDate != "" {
update.SetDue, update.DueDate = true, parsed.DueDate
}
if parsed.Recurrence != "" {
update.RecurrenceRule = &parsed.Recurrence
}
if len(parsed.Labels) > 0 {
update.Labels, update.SetLabels = parsed.Labels, true
}
if err := s.db.UpdateTask(r.Context(), task.ID, user.ID, update); err != nil {
s.storeError(w, r, "update task", err)
return
}
// Et projekt, modellen fandt på, flytter ingenting. Den fik listen over dem,
// der findes, og et navn uden for den er et gæt — og et gæt må ikke kunne
// lave et projekt eller flytte en opgave hen, hvor ejeren ikke leder.
if parsed.Project != "" {
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil && id != task.ProjectID {
if _, err := store.RequireProjectRole(r.Context(), s.db, id, user.ID, store.RoleEditor); err == nil {
if _, err := s.db.MoveTask(r.Context(), task.ID, id, "", "", ""); err != nil {
s.internal(w, r, "move task", err)
return
}
}
}
}
updated, err := s.db.GetTask(r.Context(), task.ID, user.ID)
if err != nil {
s.storeError(w, r, "get task", err)
return
}
s.publish(updated.ProjectID, "task.updated", toTaskJSON(*updated))
writeJSON(w, http.StatusOK, toTaskJSON(*updated))
}
// handleAINoteActions trækker det, nogen har lovet, ud af en note.
//
// Et referat er fuldt af sætninger, der ligner opgaver, og af tre, der er det.
// Modellen bliver bedt om kun at tage dem, nogen har forpligtet sig på, og om at
// citere de ord, den tog dem fra — så et forkert punkt kan gennemskues frem for
// bare at være forkert.
func (s *Server) handleAINoteActions(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
client, ok := s.aiClientFor(w, r)
if !ok {
return
}
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if strings.TrimSpace(n.Body) == "" {
writeJSON(w, http.StatusOK, map[string]any{"suggestions": []aiSuggestionJSON{}})
return
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
suggestions, err := client.ActionsInNote(ctx, n.Body, s.projectNames(r), today, user.Locale)
if err != nil {
s.log.Warn("ai note actions", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
out := make([]aiSuggestionJSON, 0, len(suggestions))
for _, sug := range suggestions {
out = append(out, aiSuggestionJSON{Line: sug.Line, Why: sug.Why})
}
writeJSON(w, http.StatusOK, map[string]any{"suggestions": out})
}
// handleAICreateFromNote laver én accepteret opgave fra en note.
//
// Noten bliver ikke rørt. Det er fristende at skrive linket ind i den, så de to
// peger på hinanden, men en tekst, et program skriver i uden at blive bedt om
// det, er en tekst, man holder op med at stole på. I stedet bærer opgaven, hvor
// den kom fra — og noten kan findes på sin titel.
func (s *Server) handleAICreateFromNote(w http.ResponseWriter, r *http.Request) {
var req applyLineRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleViewer) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
loc := userLocation(user.Timezone)
parsed := quickadd.Parse(req.Line, time.Now().In(loc), user.Locale)
if strings.TrimSpace(parsed.Content) == "" {
writeFieldErrors(w, map[string]string{"line": "there is no task in that"})
return
}
// Notens eget projekt som udgangspunkt: står referatet i et projekt, hører
// det, der blev aftalt i det, som regel til samme sted. Et `#projekt` i
// linjen vinder, fordi det er valgt frem for arvet.
projectID := n.ProjectID
if parsed.Project != "" {
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
projectID = id
}
}
if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, r, "inbox", err)
return
}
}
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
task := &store.Task{
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
Description: "[[" + n.Title + "]]", CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
s.internal(w, r, "create task from note", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
writeJSON(w, http.StatusCreated, toTaskJSON(*task))
}
// numberedLine skiller "3. Ring til Anders" ad i nummeret og linjen.
//
// Nummeret er, hvordan et forslag finder tilbage til den opgave, det handler om.
// Rækkefølgen alene ville også kunne bruges, og gør det som reserve — men en
// model, der springer en opgave over, ville så flytte alle de følgende forslag
// én plads, og så bliver "sæt dato på den her" sat på en anden.
func numberedLine(s string) (int, string) {
s = strings.TrimSpace(s)
dot := strings.IndexByte(s, '.')
if dot <= 0 || dot > 3 {
return 0, s
}
n := 0
for _, c := range s[:dot] {
if c < '0' || c > '9' {
return 0, s
}
n = n*10 + int(c-'0')
}
return n, strings.TrimSpace(s[dot+1:])
}
// firstWords klipper en beskrivelse ned til noget, der kan stå i en prompt uden at
// fylde den med en hel note.
func firstWords(s string, n int) string {
fields := strings.Fields(s)
if len(fields) <= n {
return strings.Join(fields, " ")
}
return strings.Join(fields[:n], " ") + " …"
}
+234
View File
@@ -0,0 +1,234 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// fakeModel er en OpenAI-formet endpoint, der svarer med det, prøven har brug for.
//
// Den rigtige vej hele vejen igennem: indstillingerne, prompten, parsningen af
// svaret og skrivningen bagefter. Det, der er værd at prøve her, er ikke om en
// model kan svare — det er, hvad der sker med svaret, når den har svaret noget.
func fakeModel(t *testing.T, reply string) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{"message": map[string]string{"content": reply}},
},
})
}))
t.Cleanup(srv.Close)
return srv.URL
}
// useModel peger kontoen på den falske model. Kun en administrator må pege AI'en
// på en adresse i det private net, og bootstrap-kontoen er en.
func (ts *testServer) useModel(t *testing.T, url string) {
t.Helper()
resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
"provider": "compatible", "base_url": url, "model": "prøvemodel",
})
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
t.Fatalf("gem ai-indstillinger: %d", resp.StatusCode)
}
}
// Forslaget peger tilbage på den opgave, det handler om — også når modellen
// springer en over. Uden nummeret ville "sæt en dato på den her" lande på en
// anden opgave end den, den blev skrevet om.
func TestTidyingTheInboxSuggestsPerTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[
{"line": "2. Ring til Anders i morgen p1", "why": "der står et navn og et telefonnummer"}
]`))
first := ts.quickTask(t, "noget som helst")
second := ts.quickTask(t, "anders 12345678")
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ryd op: %d %v", resp.StatusCode, out)
}
list, _ := out["suggestions"].([]any)
if len(list) != 1 {
t.Fatalf("forslag = %v, want ét", list)
}
sug := list[0].(map[string]any)
if sug["task_id"] != second {
t.Errorf("forslaget peger på %v, want den anden opgave (%v)", sug["task_id"], second)
}
if sug["line"] != "Ring til Anders i morgen p1" {
t.Errorf("nummeret blev ikke taget af linjen: %v", sug["line"])
}
if sug["why"] == "" {
t.Error("forslaget siger ikke hvorfor")
}
_ = first
}
// Et ja skriver forslaget ind i opgaven — gennem den samme parser som alt andet,
// så "i morgen" betyder det samme her som i feltet øverst i fladen.
func TestAcceptingASuggestionWritesItIntoTheTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[]`))
taskID := ts.quickTask(t, "anders")
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
"task_id": taskID, "line": "Ring til Anders i morgen p1",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("accepter: %d %v", resp.StatusCode, out)
}
if out["content"] != "Ring til Anders" {
t.Errorf("indhold = %v", out["content"])
}
if out["priority"] != float64(1) {
t.Errorf("prioritet = %v, want 1", out["priority"])
}
if out["due_date"] == nil || out["due_date"] == "" {
t.Errorf("datoen blev ikke sat: %v", out["due_date"])
}
}
// Et projekt, modellen har fundet på, flytter ingenting. Den fik listen over dem,
// der findes, og et navn uden for den er et gæt — et gæt må ikke kunne flytte en
// opgave hen, hvor ejeren ikke leder efter den.
func TestAnInventedProjectDoesNotMoveTheTask(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[]`))
taskID := ts.quickTask(t, "anders")
_, before := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
resp, after := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
"task_id": taskID, "line": "Ring til Anders #EtProjektDerIkkeFindes",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("accepter: %d", resp.StatusCode)
}
if after["project_id"] != before["project_id"] {
t.Errorf("opgaven blev flyttet til %v (var %v)", after["project_id"], before["project_id"])
}
}
// En note bliver ikke rørt, når der bliver lavet opgaver ud af den. En tekst, et
// program skriver i uden at blive bedt om det, er en tekst, man holder op med at
// stole på.
func TestTasksFromANoteLeaveTheNoteAlone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `[
{"line": "Send prisliste til Anders på fredag", "why": "“jeg sender prislisten fredag”"}
]`))
body := "# Møde med Anders\njeg sender prislisten fredag"
noteID := ts.createNote(t, body)
resp, out := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("find opgaver: %d %v", resp.StatusCode, out)
}
list, _ := out["suggestions"].([]any)
if len(list) != 1 {
t.Fatalf("forslag = %v, want ét", list)
}
line := list[0].(map[string]any)["line"].(string)
resp, task := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions/apply",
map[string]any{"line": line})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("accepter: %d %v", resp.StatusCode, task)
}
if task["content"] != "Send prisliste til Anders" {
t.Errorf("opgave = %v", task["content"])
}
// Opgaven bærer, hvor den kom fra, som et notelink.
if task["description"] != "[[Møde med Anders]]" {
t.Errorf("beskrivelse = %v, want et link til noten", task["description"])
}
_, note := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
if note["body"] != body {
t.Errorf("noten blev rettet:\n%v", note["body"])
}
}
// Uden en model er funktionen slået fra — ikke i stykker. Svaret skal kunne
// kendes fra en fejl, så fladen kan sige "læg din nøgle ind" frem for "der skete
// noget".
func TestSuggestionsAreOffWithoutAModel(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, path := range []string{"/api/v1/ai/inbox/tidy"} {
if resp, _ := ts.do(t, "POST", path, nil); resp.StatusCode != http.StatusConflict {
t.Errorf("%s gav %d, want 409", path, resp.StatusCode)
}
}
}
// quickTask lægger en linje i indbakken gennem den samme vej som feltet øverst.
func (ts *testServer) quickTask(t *testing.T, text string) string {
t.Helper()
resp, out := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": text})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quickadd %q: %d %v", text, resp.StatusCode, out)
}
id, _ := out["id"].(string)
if id == "" {
t.Fatalf("quickadd gav intet id: %v", out)
}
return id
}
// Svaret på et spørgsmål bærer, hvad det står på — og kun det, det faktisk brugte.
// En liste over alt, der blev kigget i, er ikke en henvisning.
func TestAskingAnswersFromYourOwnNotes(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `{"answer": "Du lovede Anders prislisten fredag.", "sources": [1]}`))
ts.createNote(t, "# Møde med Anders\njeg sender prislisten fredag")
ts.createNote(t, "# Noget helt andet\nanders er ikke nævnt her")
resp, out := ts.do(t, "POST", "/api/v1/ai/ask", map[string]any{"question": "hvad lovede jeg Anders?"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("spørg: %d %v", resp.StatusCode, out)
}
if out["answer"] != "Du lovede Anders prislisten fredag." {
t.Errorf("svar = %v", out["answer"])
}
sources, _ := out["sources"].([]any)
if len(sources) != 1 {
t.Fatalf("kilder = %v, want én", sources)
}
src := sources[0].(map[string]any)
if src["kind"] != "note" || src["title"] != "Møde med Anders" {
t.Errorf("kilde = %v", src)
}
}
// Et spørgsmål, der ikke rammer noget, får ikke et opfundet svar. Man spørger om
// sine egne noter, fordi man ikke selv kan huske det, og kan derfor ikke se, at
// svaret var gættet.
func TestAskingAboutNothingAnswersNothing(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.useModel(t, fakeModel(t, `{"answer": "det burde du huske", "sources": []}`))
resp, out := ts.do(t, "POST", "/api/v1/ai/ask",
map[string]any{"question": "kvadratrodenafenbanan"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("spørg: %d", resp.StatusCode)
}
if out["answer"] != "" {
t.Errorf("der blev svaret uden noget at svare ud fra: %v", out["answer"])
}
}
+111
View File
@@ -0,0 +1,111 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/kristianwind/verdande/internal/store"
)
// What has happened on this server, across every project.
//
// The per-project history under /projects/{id}/activity is the same rows read by
// the people who work in that project. This is the administrator's version of the
// question, and it is a different one: an administrator is not necessarily a member
// of the project they need to look into, and "which account did that" cannot be
// asked one project at a time.
//
// It sits beside the error log for a reason. That one is the half about what broke;
// this is the half about what was done, and an instance had only ever had the first.
type auditEntryJSON struct {
ID string `json:"id"`
At string `json:"at"`
ProjectID string `json:"project_id"`
ProjectName string `json:"project_name"`
TaskID string `json:"task_id,omitempty"`
// UserID and UserName are absent when the account has been deleted. The row
// stays — an audit log that lost its rows when somebody left would be missing
// exactly the part worth keeping.
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
Event string `json:"event"`
Payload map[string]any `json:"payload,omitempty"`
}
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := store.AuditFilter{
UserID: q.Get("user_id"),
ProjectID: q.Get("project_id"),
Event: q.Get("event"),
Limit: parseLimit(q.Get("limit"), 100, 500),
}
if cursor := q.Get("before"); cursor != "" {
at, id, ok := parseAuditCursor(cursor)
if !ok {
writeError(w, http.StatusBadRequest, CodeBadRequest,
"before must be <unix>:<id>, as next_cursor returns it")
return
}
f.BeforeAt, f.BeforeID = at, id
}
entries, err := s.db.ListAuditLog(r.Context(), f)
if err != nil {
s.internal(w, r, "list audit log", err)
return
}
out := make([]auditEntryJSON, 0, len(entries))
for _, a := range entries {
out = append(out, auditEntryJSON{
ID: a.ID, At: a.CreatedAt.Format(time.RFC3339),
ProjectID: a.ProjectID, ProjectName: a.ProjectName, TaskID: a.TaskID,
UserID: a.UserID, UserName: a.UserName, Event: a.Event, Payload: a.Payload,
})
}
body := map[string]any{"activity": out}
// A cursor only when the page was full. A short page is the end of the log, and
// a cursor there invites one more request that can only come back empty.
if len(entries) == f.Limit && f.Limit > 0 {
last := entries[len(entries)-1]
body["next_cursor"] = strconv.FormatInt(last.CreatedAt.Unix(), 10) + ":" + last.ID
}
writeJSON(w, http.StatusOK, body)
}
// handleAuditEvents is what the event filter is drawn from: the names that occur,
// with counts. Offered rather than typed — "member.role_changed" is an internal
// vocabulary, and a text field for it is a field that returns nothing until you
// have read the source.
func (s *Server) handleAuditEvents(w http.ResponseWriter, r *http.Request) {
events, err := s.db.AuditEvents(r.Context())
if err != nil {
s.internal(w, r, "list audit events", err)
return
}
out := make([]map[string]any, 0, len(events))
for _, e := range events {
out = append(out, map[string]any{"event": e.Event, "count": e.Count})
}
writeJSON(w, http.StatusOK, map[string]any{"events": out})
}
// parseAuditCursor reads "<unix>:<id>" — the pair the keyset walk needs. Both
// halves are required: a cursor carrying only a timestamp cannot separate two rows
// written in the same second, which is the case it exists for.
func parseAuditCursor(s string) (int64, string, bool) {
at, id, found := strings.Cut(s, ":")
if !found || id == "" {
return 0, "", false
}
unix, err := strconv.ParseInt(at, 10, 64)
if err != nil || unix <= 0 {
return 0, "", false
}
return unix, id, true
}
+264
View File
@@ -0,0 +1,264 @@
package httpapi
import (
"fmt"
"net/http"
"testing"
)
// The point of the instance-wide log is that it crosses projects the caller is not
// a member of. The per-project endpoint cannot answer this question, and an
// administrator asking "what has happened on this server" was previously told to
// go and look in each project they happen to belong to.
func TestTheAuditLogCrossesProjectsTheAdminIsNotInAllOfThem(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
// A project the administrator is not a member of.
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres eget"})
theirID := theirs["id"].(string)
other.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "noget i deres eget", "project_id": theirID,
})
// The administrator cannot read that project's own history at all.
if resp, _ := ts.do(t, "GET", "/api/v1/projects/"+theirID+"/activity", nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("a project the admin is not in: status %d, want 404", resp.StatusCode)
}
// The instance log has it.
resp, body := ts.do(t, "GET", "/api/v1/activity", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("audit log: status %d", resp.StatusCode)
}
entries, _ := body["activity"].([]any)
var found map[string]any
for _, raw := range entries {
e := raw.(map[string]any)
if e["project_id"] == theirID && e["event"] == "task.created" {
found = e
}
}
if found == nil {
t.Fatalf("nothing from a project the admin is not in: %v", entries)
}
if found["project_name"] != "Deres eget" {
t.Errorf("project_name = %v — a row that does not name its project says nothing",
found["project_name"])
}
if found["user_name"] != "Anden" {
t.Errorf("user_name = %v", found["user_name"])
}
}
// Administrators only, and sessions only. An audit log readable with a bearer token
// would let a stolen token read the record of the theft — and of everything else on
// the instance.
func TestTheAuditLogIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} {
if resp, _ := other.do(t, "GET", path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} {
req, _ := http.NewRequest("GET", ts.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s with an admin's API token: status %d, want 403", path, resp.StatusCode)
}
}
}
// The cursor is keyed on (created_at, id) because the log is written in whole
// seconds. A page boundary that landed inside a busy second would, with a
// timestamp-only cursor, either repeat those rows or skip them — and an audit log
// that skips rows is worse than no audit log.
func TestTheAuditLogPagesWithoutRepeatingOrSkipping(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Meget"})
projectID := project["id"].(string)
// Written as fast as the loop goes, so they share timestamps.
const written = 25
for i := range written {
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": fmt.Sprintf("opgave %d", i), "project_id": projectID,
})
}
seen := map[string]bool{}
cursor := ""
for page := 0; page < 20; page++ {
path := "/api/v1/activity?limit=4"
if cursor != "" {
path += "&before=" + cursor
}
resp, body := ts.do(t, "GET", path, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("page %d: status %d", page, resp.StatusCode)
}
entries, _ := body["activity"].([]any)
for _, raw := range entries {
id := raw.(map[string]any)["id"].(string)
if seen[id] {
t.Fatalf("row %s came back on two pages", id)
}
seen[id] = true
}
next, _ := body["next_cursor"].(string)
if next == "" {
break
}
if next == cursor {
t.Fatalf("the cursor did not move: %s", next)
}
cursor = next
}
// Everything that was written is in the walk exactly once: the task creations,
// plus the project creation.
if len(seen) != written+1 {
t.Errorf("the walk saw %d rows, want %d — a page boundary lost some",
len(seen), written+1)
}
}
// The filters, and the list the event filter is drawn from.
func TestTheAuditLogFiltersAndListsItsEvents(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
mineID := mine["id"].(string)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min", "project_id": mineID})
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"})
theirID := theirs["id"].(string)
other.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "deres", "project_id": theirID})
_, byUser := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID, nil)
for _, raw := range byUser["activity"].([]any) {
if got := raw.(map[string]any)["user_id"]; got != otherUser.ID {
t.Errorf("user_id filter returned a row by %v", got)
}
}
if len(byUser["activity"].([]any)) == 0 {
t.Error("the user filter returned nothing")
}
_, byProject := ts.do(t, "GET", "/api/v1/activity?project_id="+mineID, nil)
for _, raw := range byProject["activity"].([]any) {
if got := raw.(map[string]any)["project_id"]; got != mineID {
t.Errorf("project_id filter returned a row from %v", got)
}
}
_, byEvent := ts.do(t, "GET", "/api/v1/activity?event=project.created", nil)
rows := byEvent["activity"].([]any)
if len(rows) != 2 {
t.Errorf("event filter found %d project.created rows, want 2", len(rows))
}
for _, raw := range rows {
if got := raw.(map[string]any)["event"]; got != "project.created" {
t.Errorf("event filter returned %v", got)
}
}
// And the vocabulary the filter is built from.
_, events := ts.do(t, "GET", "/api/v1/activity/events", nil)
counts := map[string]float64{}
for _, raw := range events["events"].([]any) {
e := raw.(map[string]any)
counts[e["event"].(string)] = e["count"].(float64)
}
if counts["project.created"] != 2 || counts["task.created"] != 2 {
t.Errorf("event counts = %v", counts)
}
// A malformed cursor is a 400, not a silent first page: paging that quietly
// restarts is how an audit walk reads the same rows forever.
if resp, _ := ts.do(t, "GET", "/api/v1/activity?before=igår", nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("a malformed cursor: status %d, want 400", resp.StatusCode)
}
}
// A deleted account leaves its trail behind. This is the reason migration 0009
// exists: an audit log that lost its rows when somebody left would be missing
// exactly the part worth keeping.
func TestTheAuditLogOutlivesTheAccountItDescribes(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// In a project that outlives them, so the row is not carried off by the
// project cascade instead.
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
sharedID := mine["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
"email": "[email protected]", "role": "editor",
})
other.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "deres bidrag", "project_id": sharedID,
})
_, before := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID+"&event=task.created", nil)
if len(before["activity"].([]any)) != 1 {
t.Fatalf("expected one row before the delete: %v", before["activity"])
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
_, after := ts.do(t, "GET", "/api/v1/activity?project_id="+sharedID+"&event=task.created", nil)
rows := after["activity"].([]any)
if len(rows) != 1 {
t.Fatalf("the row went with the account: %v", rows)
}
row := rows[0].(map[string]any)
if _, ok := row["user_id"]; ok {
t.Errorf("user_id is still there after the account went: %v", row["user_id"])
}
if _, ok := row["user_name"]; ok {
t.Errorf("user_name is still there after the account went: %v", row["user_name"])
}
// And the project's own history still shows it, rather than dropping the row
// on an inner join to a user that is gone.
_, project := ts.do(t, "GET", "/api/v1/projects/"+sharedID+"/activity", nil)
var kept int
for _, raw := range project["activity"].([]any) {
if raw.(map[string]any)["event"] == "task.created" {
kept++
}
}
if kept != 1 {
t.Errorf("the project history dropped the authorless row: %d", kept)
}
}
+262 -22
View File
@@ -8,6 +8,8 @@ import (
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5/middleware"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/store"
)
@@ -25,17 +27,133 @@ type userJSON struct {
Locale string `json:"locale"`
IsAdmin bool `json:"is_admin"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt string `json:"created_at"`
// SidebarCollapsed is which sidebar headings this person has folded away. On
// the account rather than in the browser, because folding one is a statement
// about the work and not about the screen — the sidebar's width is the
// opposite case and stays in localStorage.
SidebarCollapsed []string `json:"sidebar_collapsed"`
NavOrder []string `json:"nav_order"`
// DefaultReminderMin is the account's standing alarm on tasks that have a
// clock, as minutes relative to the due moment and negative for before it.
// Null is off, which is where every account starts.
//
// A pointer rather than a number with a sentinel, so that "off" and "at the
// due moment" stay different answers: zero is a real setting here.
DefaultReminderMin *int `json:"default_reminder_min"`
CreatedAt string `json:"created_at"`
}
func toUserJSON(u *store.User) userJSON {
collapsed := u.SidebarCollapsed
// Never null in the response: a client that has to check for both an absent
// array and an empty one will one day check for only one of them.
if collapsed == nil {
collapsed = []string{}
}
order := u.NavOrder
if order == nil {
order = []string{}
}
return userJSON{
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt.Format(time.RFC3339),
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
DefaultReminderMin: u.DefaultReminderMin,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
// handleSetSidebarSections records which sidebar headings are folded away.
//
// Its own route rather than a field on the profile: that one backs a form with a
// save button and validates three fields together, and this writes on every click
// of a chevron. Sharing the path would mean a fold sending a name and a timezone
// it was never asked to change — and a validation error on one of those failing a
// fold.
//
// The keys are the sidebar's and are not checked here. An unknown one costs
// nothing: the sidebar folds the headings it recognises and ignores the rest, so
// a heading that is renamed or removed leaves a harmless entry rather than a
// migration.
func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request) {
var req struct {
Sections []string `json:"sections"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
// A cap, because this is a list the client decides the length of. Twenty is far
// more headings than the sidebar has ever had.
if len(req.Sections) > 20 {
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many sections")
return
}
user := userFrom(r.Context())
if err := s.db.SetSidebarCollapsed(r.Context(), user.ID, req.Sections); err != nil {
s.storeError(w, r, "set sidebar sections", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleSetNavOrder records the order of the fixed views in the sidebar.
//
// Its own route for the same reason as the folds: this writes on every drop, and
// sharing the profile's path would make a reorder send a name and a timezone it
// was never asked to change.
func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
var req struct {
Order []string `json:"order"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
if len(req.Order) > 20 {
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many entries")
return
}
user := userFrom(r.Context())
if err := s.db.SetNavOrder(r.Context(), user.ID, req.Order); err != nil {
s.storeError(w, r, "set nav order", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleSetDefaultReminder turns the account's standing alarm on or off.
//
// `minutes` is relative to the due moment and negative for before it, which is the
// convention reminders.offset_min uses because that is the column it lands in. Null
// is off. A positive number is refused: an alarm AFTER the thing it is warning
// about is not a reminder, and allowing it here would make the one mistake this
// value can make — a sign the wrong way round — look like a choice.
func (s *Server) handleSetDefaultReminder(w http.ResponseWriter, r *http.Request) {
var req struct {
Minutes *int `json:"minutes"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
if req.Minutes != nil {
switch {
case *req.Minutes > 0:
writeFieldErrors(w, map[string]string{"minutes": "must be zero or negative — an alarm comes before"})
return
// A week is the outer edge of "remind me ahead of this"; beyond it the
// number is far more likely to be a unit mistake than an intention.
case *req.Minutes < -7*24*60:
writeFieldErrors(w, map[string]string{"minutes": "must be within a week of the due time"})
return
}
}
user := userFrom(r.Context())
if err := s.db.SetDefaultReminder(r.Context(), user.ID, req.Minutes); err != nil {
s.storeError(w, r, "set default reminder", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- sign in -----------------------------------------------------------------
type loginRequest struct {
@@ -78,7 +196,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
}
s.loginLimiter.reset(clientIP(r))
// The bucket is cleared only when the login is *finished*.
//
// It used to be cleared here, on the strength of the password alone — before
// the second factor had been asked for at all. That handed somebody who
// already had the password an unlimited supply of ten-guess batches: log in,
// get a fresh bucket and a fresh pending session, spend ten guesses on the
// code, log in again. At three valid codes in any moment and twenty requests a
// second, that is even odds inside an afternoon — against the one control that
// exists precisely for when the password is already gone.
//
// So an account with a second factor clears nothing yet. handleLoginTOTP does
// it, once the code is right.
if !user.TOTPEnabled {
s.loginLimiter.reset(clientIP(r))
}
// A session that still needs a code gets a short life of its own: long enough
// to fetch a phone, not long enough to be worth stealing.
@@ -88,7 +220,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
if err != nil {
s.internal(w, "create session", err)
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, ttl)
@@ -115,13 +247,52 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
session := sessionFrom(r.Context())
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
// Counted per account as well as per address.
//
// The address bucket alone is defeated by anybody with more than one, which is
// the ordinary case — a phone and a laptop is already two. A code is six
// digits: the only thing that makes it safe is that guesses are scarce, and
// scarcity has to be counted against the thing being attacked.
if ok, retry := s.totpLimiter.allow(user.ID); !ok {
w.Header().Set("Retry-After", retryAfterSeconds(retry))
writeError(w, http.StatusTooManyRequests, CodeRateLimited,
"too many verification codes; wait a moment and try again")
return
}
step, err := auth.TOTPStep(user.TOTPSecret, req.Code, time.Now())
if err == nil {
// The code is right; now it must also be unused. A TOTP code stays valid for
// its whole window, so one observed in flight — a phishing page, a proxy,
// over the shoulder — could otherwise be replayed until it expires. Spending
// the step here makes it single-use: a second presentation of the same code,
// or an earlier one, finds the step already gone.
fresh, cerr := s.db.ConsumeTOTPStep(r.Context(), user.ID, step)
if cerr != nil {
s.internal(w, r, "consume totp step", cerr)
return
}
if !fresh {
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
"that verification code has already been used")
return
}
}
if err != nil {
// A recovery code is accepted in the same field. Making somebody find a
// different form while locked out of their account is a poor time to
// introduce a second concept.
used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code)
if rerr != nil || !used {
// The pending session is left alive on purpose.
//
// Ending it on the first wrong digit was considered and rejected: it
// would make somebody who mistyped one number type their password again,
// and it buys almost nothing. What makes a six-digit code safe is that
// guesses are scarce, and the per-account bucket above is what makes
// them scarce — ten an hour, however many times the password is
// re-entered. The attack this closes was never "one session, many
// guesses"; it was "a fresh bucket for every password check".
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
"that verification code is not valid")
return
@@ -129,8 +300,13 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
s.log.Info("recovery code used", "user", user.ID)
}
// Finished. Now the buckets may be cleared — both of them, so somebody who
// fumbled the code twice is not still waiting once they get it right.
s.loginLimiter.reset(clientIP(r))
s.totpLimiter.reset(user.ID)
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
s.internal(w, "promote session", err)
s.internal(w, r, "promote session", err)
return
}
// The cookie is reissued so its lifetime matches the session's new one.
@@ -210,13 +386,13 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
s.storeError(w, "update profile", err)
s.storeError(w, r, "update profile", err)
return
}
updated, err := s.db.UserByID(r.Context(), user.ID)
if err != nil {
s.internal(w, "reload user", err)
s.internal(w, r, "reload user", err)
return
}
writeJSON(w, http.StatusOK, toUserJSON(updated))
@@ -263,7 +439,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
@@ -277,14 +453,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
"an account already exists for that email address")
return
}
s.internal(w, "create user", err)
s.internal(w, r, "create user", err)
return
}
// Accepting the invite is what adds the membership and burns the token, in one
// transaction: a signup that half-succeeded would leave a usable invite behind.
if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil {
s.internal(w, "accept invite", err)
s.internal(w, r, "accept invite", err)
return
}
@@ -328,7 +504,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
@@ -341,7 +517,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
return
}
s.internal(w, "create first admin", err)
s.internal(w, r, "create first admin", err)
return
}
@@ -355,16 +531,23 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
n, err := s.db.UserCount(r.Context())
if err != nil {
s.internal(w, "count users", err)
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) {
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
if err != nil {
s.internal(w, "create session", err)
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, s.cfg.SessionTTL)
@@ -393,7 +576,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
if err == nil {
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
if err != nil {
s.internal(w, "create password reset", err)
s.internal(w, r, "create password reset", err)
return
}
link := s.cfg.BaseURL + "/reset?token=" + token
@@ -431,13 +614,13 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.Password)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
// Every session ends: a password reset is what somebody does when they think
// another person has been in their account.
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil {
s.internal(w, "update password", err)
s.internal(w, r, "update password", err)
return
}
@@ -469,7 +652,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
s.internal(w, "hash password", err)
s.internal(w, r, "hash password", err)
return
}
keep := ""
@@ -477,7 +660,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
keep = session.ID
}
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil {
s.internal(w, "update password", err)
s.internal(w, r, "update password", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -520,7 +703,64 @@ func inboxName(locale string) string {
// internal logs the real error and tells the client nothing about it. The detail
// belongs in the log, where the operator can see it; in the response it would be a
// description of the server's internals handed to whoever asked.
func (s *Server) internal(w http.ResponseWriter, what string, err error) {
//
// It is also written to the database. On a Rune the log is the container's, and
// every restart begins a new one — so the panel reports "HTTP 5xx, twice, at
// 11:49" long after the line explaining it has gone, which tells an operator that
// something broke and gives them no way to find out what. The row outlives the
// container; Indstillinger → Fejl is where it is read.
// upstream reports a failure that came from somebody else's service.
//
// Separate from `internal` because the cause is different and so is the fix: a 500
// is this server broken, and this is Google — or whoever — saying no. The status
// says so (502), and the message is passed through rather than replaced, because
// "invalid_grant" or "insufficient authentication scopes" is the whole diagnosis
// and a generic sentence in its place throws it away.
//
// It is recorded like a 500 for exactly the reason the error log exists: a Gmail
// sync that has been failing for a week is invisible otherwise. The background job
// writes a log line the next restart erases, and nobody is watching the one screen
// that would have said so.
// StatusUpstreamRefused is what this server answers when a service it depends on
// says no. Not 502, though that is what it means: a proxy is entitled to replace
// the body of an origin 5xx with a page of its own, and Cloudflare does. That
// swallowed every explanation this server produced — "gmail: 403 Forbidden",
// "the panel refused: HTTP 401" — and left the interface with nothing to show but
// "something went wrong". A 4xx passes through untouched, and the truth reaching
// the person who has to act on it beats the tidier status code.
const StatusUpstreamRefused = http.StatusUnprocessableEntity
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
s.log.Warn(what, "err", err)
userID := ""
if u := userFrom(r.Context()); u != nil {
userID = u.ID
}
s.db.RecordError(r.Context(), store.ServerError{
Method: r.Method, Path: r.URL.Path, Status: StatusUpstreamRefused,
What: what, Message: err.Error(), UserID: userID,
RequestID: middleware.GetReqID(r.Context()),
})
writeError(w, StatusUpstreamRefused, code, err.Error())
}
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
s.log.Error(what, "err", err)
userID := ""
if u := userFrom(r.Context()); u != nil {
userID = u.ID
}
// Best effort, and deliberately not checked: this is the path that is already
// reporting a failure, and a diagnostic that can fail the request would turn
// one broken screen into two.
s.db.RecordError(r.Context(), store.ServerError{
Method: r.Method, Path: r.URL.Path, Status: http.StatusInternalServerError,
What: what, Message: err.Error(), UserID: userID,
RequestID: middleware.GetReqID(r.Context()),
})
writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong")
}
+257 -4
View File
@@ -27,6 +27,9 @@ type testServer struct {
*httptest.Server
db *store.DB
client *http.Client
// api er den samme server, routeren hænger på. Et par prøver har brug for at
// nå ind i den — se `icsFetch` — og uden den her er der kun håndtaget udefra.
api *Server
}
func newTestServer(t *testing.T) *testServer {
@@ -46,7 +49,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
BaseURL: "http://127.0.0.1",
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
// party id, and every browser makes the same exception for localhost.
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
@@ -60,7 +65,8 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
// Discard: a passing test should print nothing, and these handlers log freely.
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(New(cfg, db, log, nil))
api := New(cfg, db, log, nil)
srv := httptest.NewServer(api)
t.Cleanup(srv.Close)
jar, err := cookiejar.New(nil)
@@ -73,6 +79,7 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
// The cookie jar is what makes this a session test rather than a
// sequence of unrelated requests.
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
api: api,
}
}
@@ -299,7 +306,11 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
// not lock the account.
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
// Med adgangskoden: at slå andenfaktoren til er mindst lige så indgribende som
// at slå den fra, og den har altid spurgt.
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
}
@@ -361,12 +372,58 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
}
}
// A TOTP code works once. A code seen in flight and replayed inside its window
// must be refused the second time, or the second factor is only as good as the
// thirty seconds an attacker has to reuse what they captured.
func TestTOTPCodeIsSingleUse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
}
secret, _ := body["secret"].(string)
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
}
// One code, captured once and presented twice.
code := codeFor(t, secret)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "[email protected]", "password": "et langt kodeord",
})
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
if resp.StatusCode != http.StatusOK {
t.Fatalf("first use of the code failed: %d %v", resp.StatusCode, body)
}
// A second login, same code, same window.
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "[email protected]", "password": "et langt kodeord",
})
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("the same code was accepted twice: %d %v", resp.StatusCode, body)
}
}
// A recovery code is accepted in the same field as a TOTP code, and works once.
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
secret := body["secret"].(string)
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
@@ -761,3 +818,199 @@ func TestUnknownSessionTokenIsUnauthorized(t *testing.T) {
t.Errorf("status %d, want 401", resp.StatusCode)
}
}
// last_seen_at has been written on every request since sessions existed, for
// exactly this list, and nothing read it. A session you cannot see is a session
// you cannot end.
func TestSessionsCanBeListedAndEnded(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// A second sign-in from "another device": same account, its own cookie jar.
other := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, _ := other.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "[email protected]", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("second login: status %d", resp.StatusCode)
}
resp, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list sessions: %d %v", resp.StatusCode, body)
}
sessions, _ := body["sessions"].([]any)
if len(sessions) != 2 {
t.Fatalf("want two sessions, got %v", sessions)
}
// Exactly one is this one, or the interface cannot tell somebody which row is
// the browser they are reading it in.
current := ""
for _, raw := range sessions {
s := raw.(map[string]any)
if s["current"] == true {
if current != "" {
t.Fatal("two sessions both claim to be the current one")
}
current = s["id"].(string)
}
if s["device"] == "" {
t.Errorf("no device summary: %v", s)
}
if s["last_seen_at"] == nil {
t.Errorf("no last_seen_at, which is the whole reason the column is written: %v", s)
}
}
if current == "" {
t.Fatal("none of the sessions is marked as the current one")
}
// End the other one.
var otherID string
for _, raw := range sessions {
if s := raw.(map[string]any); s["id"] != current {
otherID = s["id"].(string)
}
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/sessions/"+otherID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete session: status %d", resp.StatusCode)
}
// That device is signed out, and this one is not.
if r, _ := other.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusUnauthorized {
t.Errorf("the ended session still works: status %d", r.StatusCode)
}
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
t.Errorf("ending another session logged this one out: status %d", r.StatusCode)
}
}
// Somebody else's session is not yours to end, even with its id in hand.
func TestASessionCanOnlyBeEndedByItsOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
_, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
mine := body["sessions"].([]any)[0].(map[string]any)["id"].(string)
if resp, _ := other.do(t, "DELETE", "/api/v1/auth/sessions/"+mine, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
// And it still works.
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
t.Errorf("somebody else ended my session: status %d", r.StatusCode)
}
}
// An API token is accepted almost everywhere, and must not be accepted here: a
// leaked token that could end its owner's sessions, or even list their devices,
// turns a theft into a lockout.
func TestSessionsAreNotReachableWithAToken(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, user.ID)
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/sessions", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status %d, want 403", resp.StatusCode)
}
}
func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
cases := []struct {
ua string
want string
}{
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36", "Chrome på macOS"},
// An iPhone's user agent says Mac OS X too, so the order of the tests is
// the whole correctness of this function.
{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", "Safari på iPhone"},
// And Edge claims to be Chrome, which claims to be Safari.
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36 Edg/131.0", "Edge på Windows"},
{"Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", "Firefox på Linux"},
{"", "Ukendt enhed"},
// Not a browser at all. Its own name beats a word that says the server
// gave up: a CalDAV client is a real session.
{"curl/8.4.0", "curl/8.4.0"},
}
for _, c := range cases {
if got := describeUserAgent(c.ua); got != c.want {
t.Errorf("describeUserAgent(%q) = %q, want %q", c.ua, got, c.want)
}
}
}
// Andenfaktoren kan ikke slås til uden adgangskoden.
//
// Døren åbnede før med et token og lukkede kun med et kodeord: `disable` har
// altid spurgt, `setup` spurgte om ingenting. Et stjålet token kunne derfor hente
// hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den
// eneste kopi af gendannelseskoderne — hvorefter ejerens næste login stoppede ved
// en kode, de ikke havde, og de ikke kunne slå den fra uden at logge ind først.
func TestTOTPCannotBeSwitchedOnWithoutThePassword(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "det forkerte kodeord",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d, ventede at en forkert adgangskode blev afvist", resp.StatusCode)
}
resp, _ = ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{})
if resp.StatusCode == http.StatusOK {
t.Fatal("en tom adgangskode blev accepteret")
}
}
// Ratebegrænseren må ikke nulstilles af adgangskoden alene.
//
// Den blev det, og det gav én, der havde kodeordet, ubegrænsede portioner på ti
// gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på
// koden, log ind igen. Andenfaktoren findes præcis til det tilfælde, hvor
// kodeordet allerede er tabt, så den portion skal betales for hver gang.
func TestAPasswordAloneDoesNotRefillTheCodeAttempts(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
secret := body["secret"].(string)
ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)})
ts.do(t, "POST", "/api/v1/auth/logout", nil)
// Kodeordet er rigtigt hver gang; kun koden er forkert. Uden loftet ville det
// her kunne blive ved i det uendelige.
refused := 0
for i := 0; i < 14; i++ {
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "[email protected]", "password": "et langt kodeord",
})
if resp.StatusCode == http.StatusTooManyRequests {
refused++
continue
}
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
if resp.StatusCode == http.StatusTooManyRequests {
refused++
}
}
if refused == 0 {
t.Fatal("fjorten runder med forkerte koder blev aldrig afvist; spanden fyldes stadig af adgangskoden")
}
}
+158
View File
@@ -0,0 +1,158 @@
package httpapi
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/jobs"
)
// The nightly backup, made visible.
//
// It has run since the beginning and nothing showed it. A backup nobody can see is
// a backup nobody knows has been failing — and the failure mode is quiet: a volume
// that went read-only, a disk that filled, a container that has not been up at
// midnight for a week. The first time anybody finds out is the day they need one.
//
// Administrators only, and behind a session as well as the admin check. A backup
// file is a complete copy of the database, so downloading one is downloading
// everybody's data; a leaked API token must not be able to.
type backupRunJSON struct {
ID string `json:"id"`
Started string `json:"started_at"`
// Finished is absent while a backup is still running, and on one that was
// interrupted — which is the same shape from outside and honestly so: a run
// the process died in the middle of never finished.
Finished string `json:"finished_at,omitempty"`
Size int64 `json:"size_bytes"`
Error string `json:"error,omitempty"`
// Present says the file is still on disk. Rotation keeps the most recent
// fourteen, so an older row is a true record of a backup that was made and a
// file that is no longer there — and a download link for it would 404.
Present bool `json:"present"`
}
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
runs, err := s.db.ListBackupRuns(r.Context(), parseLimit(r.URL.Query().Get("limit"), 30, 100))
if err != nil {
s.internal(w, r, "list backups", err)
return
}
out := make([]backupRunJSON, 0, len(runs))
for _, b := range runs {
j := backupRunJSON{
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
Size: b.SizeBytes, Error: b.Error,
}
if b.FinishedAt != nil {
j.Finished = b.FinishedAt.Format(time.RFC3339)
}
if b.Path != "" {
if _, err := os.Stat(b.Path); err == nil {
j.Present = true
}
}
out = append(out, j)
}
writeJSON(w, http.StatusOK, map[string]any{"backups": out})
}
// handleRunBackup takes one now.
//
// The same function the nightly job runs, without the "has one run today" check.
// Waiting until tonight to learn whether backups work at all is how somebody finds
// out on the day they need one — and it is the only way to get a snapshot from
// *before* something you are about to do.
//
// Synchronous. A backup of a homelab database is a second or two, and a button
// that returns immediately and tells you nothing is a button you press twice.
func (s *Server) handleRunBackup(w http.ResponseWriter, r *http.Request) {
if err := jobs.Backup(r.Context(), s.cfg, s.db, s.log); err != nil {
s.internal(w, r, "run backup", err)
return
}
runs, err := s.db.ListBackupRuns(r.Context(), 1)
if err != nil || len(runs) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
b := runs[0]
j := backupRunJSON{
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
Size: b.SizeBytes, Error: b.Error, Present: true,
}
if b.FinishedAt != nil {
j.Finished = b.FinishedAt.Format(time.RFC3339)
}
writeJSON(w, http.StatusCreated, j)
}
// handleDownloadBackup sends one backup file.
//
// The path comes from the row rather than from the request, and is then checked to
// be inside the backups directory anyway. The row is written by this process and
// should always be safe; the check is there because "should always" is how a path
// traversal gets shipped, and the thing on the other side of it is every account's
// data in one file.
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
runs, err := s.db.ListBackupRuns(r.Context(), 100)
if err != nil {
s.internal(w, r, "list backups", err)
return
}
id := chi.URLParam(r, "backupID")
var path string
for _, b := range runs {
if b.ID == id {
path = b.Path
break
}
}
if path == "" {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
dir, err := filepath.Abs(s.cfg.BackupsDir())
if err != nil {
s.internal(w, r, "backups dir", err)
return
}
full, err := filepath.Abs(path)
if err != nil || !strings.HasPrefix(full, dir+string(os.PathSeparator)) {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
file, err := os.Open(full)
if err != nil {
// Rotation keeps the most recent fourteen, so a row older than that is a
// true record with no file behind it. Not an error — a 404 is the honest
// answer to "send me that one".
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
s.internal(w, r, "stat backup", err)
return
}
// The same treatment attachments get, and for a stronger reason: this is a
// SQLite database, and nothing should ever be tempted to render it inline.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", filepath.Base(full)))
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, "", info.ModTime(), file)
}
+116
View File
@@ -0,0 +1,116 @@
package httpapi
import (
"net/http"
"testing"
)
// The backup panel exists because the job was invisible, so the thing to check is
// that it can be seen and asked for — and that the file it hands out is guarded
// like what it is, which is every account's data in one download.
func TestABackupCanBeTakenListedAndDownloaded(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Nothing yet, and that is a state the list has to survive rather than a case
// nobody hits: it is what a fresh instance looks like.
_, empty := ts.do(t, "GET", "/api/v1/backups", nil)
if got, _ := empty["backups"].([]any); len(got) != 0 {
t.Fatalf("a fresh instance has %d backups", len(got))
}
resp, made := ts.do(t, "POST", "/api/v1/backups", nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("run backup: status %d, body %v", resp.StatusCode, made)
}
id, _ := made["id"].(string)
if id == "" {
t.Fatal("the new run has no id")
}
if size, _ := made["size_bytes"].(float64); size <= 0 {
t.Errorf("size_bytes = %v — a backup of a database with rows in it is not empty", made["size_bytes"])
}
if made["error"] != nil {
t.Errorf("the backup reported an error: %v", made["error"])
}
_, listed := ts.do(t, "GET", "/api/v1/backups", nil)
rows, _ := listed["backups"].([]any)
if len(rows) != 1 {
t.Fatalf("listed %d backups, want 1", len(rows))
}
row := rows[0].(map[string]any)
if row["id"] != id {
t.Errorf("the listed run is not the one that was made")
}
if row["present"] != true {
t.Error("present = false on a backup that was just written")
}
// And the file itself, which must arrive as a download rather than as
// something a browser might try to render.
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/backups/"+id, nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
dl, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer dl.Body.Close()
if dl.StatusCode != http.StatusOK {
t.Fatalf("download: status %d", dl.StatusCode)
}
if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q, want application/octet-stream", ct)
}
if cd := dl.Header.Get("Content-Disposition"); cd == "" || cd[:10] != "attachment" {
t.Errorf("Content-Disposition = %q, want an attachment", cd)
}
// An id that is not a backup is a 404, not a path to try opening.
if resp, _ := ts.do(t, "GET", "/api/v1/backups/../../etc/passwd", nil); resp.StatusCode == http.StatusOK {
t.Error("a traversal in the id was served")
}
if resp, _ := ts.do(t, "GET", "/api/v1/backups/nogetsomhelst", nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("an unknown id: status %d, want 404", resp.StatusCode)
}
}
// Administrators only, and sessions only. A backup is a complete copy of the
// database — a leaked token that could download one turns a stolen credential into
// a copy of everybody's data.
func TestBackupsAreAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/backups"},
{"POST", "/api/v1/backups"},
{"GET", "/api/v1/backups/whichever"},
} {
if resp, _ := other.do(t, c.method, c.path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s as an ordinary user: status %d, want 403", c.method, c.path, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/backups"},
{"POST", "/api/v1/backups"},
} {
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s with an admin's API token: status %d, want 403", c.method, c.path, resp.StatusCode)
}
}
}
+475
View File
@@ -0,0 +1,475 @@
package httpapi
import (
"context"
"net/http"
"strings"
"time"
"github.com/kristianwind/verdande/internal/beacon"
"github.com/kristianwind/verdande/internal/safedial"
"github.com/kristianwind/verdande/internal/store"
)
// The beacon, from the instance's side.
//
// Two halves that happen to live in the same binary: the part that reports in,
// which every installation runs, and the part that receives, which only the one
// acting as collector turns on. See internal/beacon for what is sent and why it
// is on by default rather than compulsory.
const beaconScope = "beacon"
// defaultPublishMin er, hvor mange installationer der skal være, før tallet
// offentliggøres. Selve tallet er et skøn; det, gulvet køber, er det ikke: under
// det fortæller et offentligt tal mest om, hvor lidt der skal til at flytte det.
const defaultPublishMin = 25
// beaconConfig is what this instance has been told to do. The zero value is what
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
// not here — a zero struct must never mean "silently on".
type beaconConfig struct {
Enabled bool `json:"enabled"`
Collector string `json:"collector_url"`
InstanceID string `json:"instance_id"`
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
LastPingAt int64 `json:"last_ping_at,omitempty"`
// LastVersion er den version, der sidst nåede frem. Gemt, fordi en
// installation, der opdaterer sig selv om natten, ellers står som gårsdagens
// version i op til et døgn — og en tælling efter version, der er en dag bagud,
// kan man ikke se en udrulning i.
LastVersion string `json:"last_version,omitempty"`
// LastError er grunden til, at det sidste forsøg ikke nåede frem.
//
// Skrevet ned, fordi alternativet er tavshed: et beacon, der ikke kan nå sin
// collector, prøver igen i morgen og siger ingenting, og det opdages først ved
// at tælle installationer i den anden ende og savne en. Det er ikke en fejl,
// der skal afbryde nogen — det er en linje på en side, der ellers påstår, at
// alt er sendt.
LastError string `json:"last_error,omitempty"`
LastErrorAt int64 `json:"last_error_at,omitempty"`
// NoticeAck er sat, når nogen har set beskeden om, hvad der bliver sendt.
//
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt
// uopfordret — ikke på en indstillingsside, ingen åbner af sig selv. Feltet er
// det, der husker, at det er blevet sagt.
NoticeAck bool `json:"notice_ack"`
// PublishMin er gulvet under det offentliggjorte tal.
//
// Et tal, der *falder*, kan ikke offentliggøres som en kendsgerning: en dårlig
// udrulning, en DNS-ændring, der stille stopper meldingerne, eller rækker, der
// er væk, ser alle sammen ud som "færre bruger det nu". Under gulvet svares
// der ingenting frem for et lille tal, ingen kan tolke.
PublishMin int `json:"publish_min"`
}
// beaconSettings reads the configuration, filling in the defaults and minting the
// anonymous id on first use.
//
// The id is generated here rather than derived from anything about the machine.
// A hash of the hostname or the base URL would be stable and convenient and would
// also be a value the collector could compare against a guess — which is the
// difference between anonymous and pseudonymous, and it is the whole point.
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
cfg := beaconConfig{
Enabled: true, Collector: beacon.DefaultCollector, PublishMin: defaultPublishMin,
}
stored, err := s.db.InstanceSettings(ctx, beaconScope)
if err != nil {
return cfg, err
}
if v, ok := stored["enabled"].(bool); ok {
cfg.Enabled = v
}
if v, ok := stored["collector_url"].(string); ok && strings.TrimSpace(v) != "" {
cfg.Collector = strings.TrimSpace(v)
}
if v, ok := stored["instance_id"].(string); ok && beacon.ValidID(v) {
cfg.InstanceID = v
}
if v, ok := stored["is_collector"].(bool); ok {
cfg.IsCollector = v
}
if v, ok := stored["publish_count"].(bool); ok {
cfg.PublishCount = v
}
if v, ok := stored["last_ping_at"].(float64); ok {
cfg.LastPingAt = int64(v)
}
if v, ok := stored["last_version"].(string); ok {
cfg.LastVersion = v
}
if v, ok := stored["last_error"].(string); ok {
cfg.LastError = v
}
if v, ok := stored["last_error_at"].(float64); ok {
cfg.LastErrorAt = int64(v)
}
if v, ok := stored["notice_ack"].(bool); ok {
cfg.NoticeAck = v
}
if v, ok := stored["publish_min"].(float64); ok && v >= 0 {
cfg.PublishMin = int(v)
}
if cfg.InstanceID == "" {
cfg.InstanceID = store.NewID()
if err := s.saveBeaconSettings(ctx, cfg); err != nil {
return cfg, err
}
}
return cfg, nil
}
func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error {
return s.db.SetInstanceSettings(ctx, beaconScope, map[string]any{
"enabled": cfg.Enabled,
"collector_url": cfg.Collector,
"instance_id": cfg.InstanceID,
"is_collector": cfg.IsCollector,
"publish_count": cfg.PublishCount,
"last_ping_at": cfg.LastPingAt,
"last_version": cfg.LastVersion,
"last_error": cfg.LastError,
"last_error_at": cfg.LastErrorAt,
"notice_ack": cfg.NoticeAck,
"publish_min": cfg.PublishMin,
})
}
// --- what the settings page shows ------------------------------------------------
type beaconStatusJSON struct {
Enabled bool `json:"enabled"`
Collector string `json:"collector_url"`
InstanceID string `json:"instance_id"`
Version string `json:"version"`
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
PublishMin int `json:"publish_min"`
LastPingAt string `json:"last_ping_at,omitempty"`
// Sidste forsøg, der ikke nåede frem — tomt, når det sidste gik godt. Vist
// frem for talt: en dato under "sidst sendt", der bliver ældre og ældre, er
// det eneste spor, et tavst beacon ellers efterlader.
LastError string `json:"last_error,omitempty"`
LastErrorAt string `json:"last_error_at,omitempty"`
Stats *store.BeaconStats `json:"stats,omitempty"`
}
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
out := beaconStatusJSON{
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
PublishMin: cfg.PublishMin,
}
if cfg.LastPingAt > 0 {
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
}
if cfg.LastError != "" {
out.LastError = cfg.LastError
out.LastErrorAt = time.Unix(cfg.LastErrorAt, 0).Format(time.RFC3339)
}
if cfg.IsCollector {
stats, err := s.db.BeaconSummary(r.Context())
if err != nil {
s.internal(w, r, "beacon summary", err)
return
}
out.Stats = &stats
}
writeJSON(w, http.StatusOK, out)
}
type beaconRequest struct {
Enabled *bool `json:"enabled"`
Collector *string `json:"collector_url"`
IsCollector *bool `json:"is_collector"`
PublishCount *bool `json:"publish_count"`
PublishMin *int `json:"publish_min"`
}
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
var req beaconRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
if req.Enabled != nil {
cfg.Enabled = *req.Enabled
}
if req.IsCollector != nil {
cfg.IsCollector = *req.IsCollector
}
if req.PublishCount != nil {
cfg.PublishCount = *req.PublishCount
}
if req.PublishMin != nil {
if *req.PublishMin < 0 {
writeFieldErrors(w, map[string]string{"publish_min": "must not be negative"})
return
}
cfg.PublishMin = *req.PublishMin
}
if req.Collector != nil {
url := strings.TrimSpace(*req.Collector)
if url == "" {
url = beacon.DefaultCollector
}
// http and https only, and said as a field error rather than a 500: an
// operator pointing this at their own collector will mistype it, and the
// page should say which field is wrong.
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
writeFieldErrors(w, map[string]string{"collector_url": "must start with http:// or https://"})
return
}
cfg.Collector = url
}
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
s.internal(w, r, "save beacon settings", err)
return
}
s.handleBeaconStatus(w, r)
}
// --- the collector's side ---------------------------------------------------------
// handleBeaconPing receives one report. Unauthenticated, because the whole point
// is that a stranger's installation can reach it.
//
// Nothing about the request is recorded except the two values in the body. The
// source address is not read here, and there is no code path from this function
// to anything that could write one down — which is the only version of that
// promise worth making.
func (s *Server) handleBeaconPing(w http.ResponseWriter, r *http.Request) {
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
// An instance that is not a collector does not quietly accept and drop pings —
// it says there is nothing here. Otherwise every installation in the world is a
// spam sink that answers 200 to anything.
if !cfg.IsCollector {
writeError(w, http.StatusNotFound, CodeNotFound, "this instance is not a beacon collector")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
var p beacon.Payload
if err := decodeJSON(w, r, &p); err != nil {
return
}
// Checked, not trusted. This is the one endpoint anybody can post to, and the
// two columns behind it should only ever hold the two shapes this program
// writes — an id that is not id-shaped is somebody probing, not an install.
if !beacon.ValidID(p.InstanceID) {
writeFieldErrors(w, map[string]string{"instance_id": "malformed"})
return
}
if p.Version != "" && !beacon.ValidVersion(p.Version) {
writeFieldErrors(w, map[string]string{"version": "malformed"})
return
}
if err := s.db.RecordBeacon(r.Context(), p.InstanceID, p.Version); err != nil {
s.internal(w, r, "record beacon", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleBeaconCount publishes the number, when the collector has been told to.
//
// Thirty days rather than a running total: a total only climbs, so it stops
// meaning "how many people use this" and starts meaning "how many ever tried it".
func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
if !cfg.IsCollector || !cfg.PublishCount {
writeError(w, http.StatusNotFound, CodeNotFound, "not published")
return
}
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
if err == nil && n < cfg.PublishMin {
// Ingenting frem for et lille tal. Et tal under gulvet fortæller mest om,
// hvor lidt der skal til at flytte det — og et, der falder, ville blive
// læst som "færre bruger det nu", også når det i virkeligheden var en
// udrulning, der gik galt, eller en adresse, der stille holdt op med at
// blive ramt.
w.Header().Set("Cache-Control", "public, max-age=60")
writeJSON(w, http.StatusOK, map[string]any{"installs": nil, "window_days": 30})
return
}
if err != nil {
s.internal(w, r, "beacon count", err)
return
}
w.Header().Set("Cache-Control", "public, max-age=60")
writeJSON(w, http.StatusOK, map[string]any{"installs": n, "window_days": 30})
}
// SendBeacon reports this installation, at most once a day.
//
// Exported and handed to the jobs runner the same way the mailbox syncs are: the
// settings, the anonymous id and the version all live on this side, and the
// runner should not have to know about any of them.
//
// The day is counted from the last successful send, not from a wall-clock hour.
// A fleet of installations that all pinged at 03:00 would arrive in one spike;
// counted this way they spread themselves out by when each was started.
func (s *Server) SendBeacon(ctx context.Context) error {
cfg, err := s.beaconSettings(ctx)
if err != nil {
return err
}
if !cfg.Enabled {
return nil
}
// The collector does not report to itself over the network. It would work, and
// it would also mean the one instance whose count matters most depends on its
// own tunnel being up to be counted.
if cfg.IsCollector {
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
return err
}
return s.beaconSent(ctx, cfg)
}
// Undtagelsen fra døgnet er en version, der er skiftet. En installation, der
// opdaterer sig selv om natten og først melder ind igen et døgn senere, står
// som gårsdagens version hele dagen — og så kan man ikke se en udrulning i
// tallene. Målt hos Yggdrasil: to paneler rapporterede den forrige version i
// et døgn efter at være opdateret.
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour && cfg.LastVersion == Version {
return nil
}
// safedial, ikke http.DefaultClient. Adressen kan skrives i fladen, og en
// administrator, der peger den på 127.0.0.1 eller på 169.254.169.254, ville
// ellers have en maskine, der henter indefra på kommando — og svaret på om den
// svarede, er i sig selv det, sådan et forsøg leder efter.
if err := beacon.Send(ctx, safedial.Client(15*time.Second), cfg.Collector, beacon.Payload{
InstanceID: cfg.InstanceID,
Version: Version,
}); err != nil {
// Logged at info, not error. The collector being unreachable is not this
// installation's problem and must never look like one — it is somebody
// else's server, and the only consequence is a number being one lower.
s.log.Info("beacon not delivered", "err", err)
// Men skrevet ned. Uden det her fejler et beacon, der ikke kan nå sin
// collector, i fuldstændig tavshed: det prøver igen i morgen, siden siger
// "sidst sendt" med en dato, der bare bliver ældre, og ingen opdager det.
// Fundet hos Yggdrasil ved at tælle installationer i den anden ende og
// savne en.
cfg.LastError, cfg.LastErrorAt = err.Error(), time.Now().Unix()
return s.saveBeaconSettings(ctx, cfg)
}
return s.beaconSent(ctx, cfg)
}
// beaconSent skriver, at det lykkedes: tidspunktet, versionen der nåede frem, og
// en fejl, der ikke længere gælder. Ét sted, fordi de fire felter skal skrives
// sammen — en gemning, der glemte at rydde fejlen, ville lade en linje stå på
// siden om noget, der virker.
func (s *Server) beaconSent(ctx context.Context, cfg beaconConfig) error {
cfg.LastPingAt = time.Now().Unix()
cfg.LastVersion = Version
cfg.LastError, cfg.LastErrorAt = "", 0
return s.saveBeaconSettings(ctx, cfg)
}
// --- beskeden, der bliver sagt uopfordret ------------------------------------------
// handleBeaconNotice says whether the disclosure is still owed, and what it would
// say.
//
// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt uopfordret.
// En indstillingsside, hvor det står rigtigt og fyldestgørende, er ikke det samme
// som at sige det: den bliver åbnet af dem, der i forvejen har en mistanke om, at
// der er noget at læse. Uden beskeden her er "du kan slå den fra" et tilbud til
// dem, der ved, at der er noget at slå fra.
//
// De tre værdier står i svaret — det rigtige id, den rigtige version, den rigtige
// adresse — så beskeden kan vise, hvad der bliver sendt, frem for at sige
// "anonyme data" og bede om at blive troet på. Det er den eneste version af det
// løfte, der er noget værd: en, læseren selv kan kontrollere.
//
// Panelbredt frem for pr. bruger: det er installationen, der melder ind, ikke
// personen, og en besked pr. konto ville stille det samme spørgsmål til folk, der
// ikke kan svare på det.
func (s *Server) handleBeaconNotice(w http.ResponseWriter, r *http.Request) {
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"pending": !cfg.NoticeAck,
"enabled": cfg.Enabled,
"instance_id": cfg.InstanceID,
"version": Version,
"collector_url": cfg.Collector,
})
}
type beaconNoticeRequest struct {
// Keep er en peger og ikke en bool, og det er hele pointen.
//
// En tom krop ville ellers betyde "slå det fra", og det er den vej, en fejl
// aldrig må falde: et fravalg, ingen har truffet, er lige så meget et brudt
// løfte som et tilvalg, ingen har truffet. Yggdrasil har den fejl i sin
// tilsvarende ende og er blevet bidt af den.
Keep *bool `json:"keep"`
}
// handleAckBeaconNotice records that the notice has been seen, and turns the
// beacon off if that was the answer.
//
// Ét kald til begge svar. "Behold den" og "slå den fra" er den samme handling —
// nogen har taget stilling — og to endpoints ville betyde, at det ene kunne
// glemme at skrive, at der var taget stilling, og beskeden dermed komme igen.
func (s *Server) handleAckBeaconNotice(w http.ResponseWriter, r *http.Request) {
var req beaconNoticeRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if req.Keep == nil {
writeFieldErrors(w, map[string]string{"keep": "required"})
return
}
cfg, err := s.beaconSettings(r.Context())
if err != nil {
s.internal(w, r, "beacon settings", err)
return
}
cfg.NoticeAck = true
cfg.Enabled = *req.Keep
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
s.internal(w, r, "save beacon settings", err)
return
}
s.log.Info("beacon notice answered", "keep", *req.Keep, "user", userFrom(r.Context()).ID)
writeJSON(w, http.StatusOK, map[string]any{"enabled": cfg.Enabled})
}
+132
View File
@@ -0,0 +1,132 @@
package httpapi
import (
"net/http"
"testing"
)
// Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
// bliver sagt uopfordret. Beskeden venter, indtil nogen har taget stilling — og
// den viser de rigtige værdier, så løftet kan efterprøves frem for tros på.
func TestTheBeaconSaysWhatItSendsBeforeAnybodyAsks(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("hent besked: %d", resp.StatusCode)
}
if notice["pending"] != true {
t.Fatalf("beskeden er ikke i vente på en frisk installation: %v", notice)
}
if notice["enabled"] != true {
t.Errorf("beaconet er ikke slået til som udgangspunkt: %v", notice)
}
// De tre værdier, beskeden viser frem, er de rigtige — ikke et eksempel.
settings := beaconSettingsOf(t, ts)
if notice["instance_id"] != settings["instance_id"] {
t.Errorf("id i beskeden = %v, i indstillingerne = %v",
notice["instance_id"], settings["instance_id"])
}
if notice["collector_url"] != settings["collector_url"] {
t.Errorf("adressen i beskeden = %v, i indstillingerne = %v",
notice["collector_url"], settings["collector_url"])
}
if notice["version"] == "" || notice["version"] == nil {
t.Error("beskeden siger ikke, hvilken version der bliver sendt")
}
}
// "Behold den" er et svar, og det svar skal huskes: beskeden må ikke komme igen
// ved næste login.
func TestKeepingTheBeaconAnswersTheNoticeForGood(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": true})
if resp.StatusCode != http.StatusOK || out["enabled"] != true {
t.Fatalf("behold: %d %v", resp.StatusCode, out)
}
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
t.Errorf("beskeden kommer igen efter et svar: %v", notice)
}
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
t.Errorf("beaconet blev slået fra af et ja: %v", s["enabled"])
}
}
// Og "slå den fra" slår den fra — ét kald, både svaret og virkningen.
func TestTurningTheBeaconOffFromTheNoticeStopsIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": false})
if resp.StatusCode != http.StatusOK || out["enabled"] != false {
t.Fatalf("slå fra: %d %v", resp.StatusCode, out)
}
s := beaconSettingsOf(t, ts)
if s["enabled"] != false {
t.Errorf("beaconet kører stadig efter et nej: %v", s["enabled"])
}
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
t.Errorf("beskeden kommer igen efter et nej: %v", notice)
}
}
// En tom krop må ikke kunne betyde "slå det fra". Et fravalg, ingen har truffet,
// er lige så meget et brudt løfte som et tilvalg, ingen har truffet.
func TestAnEmptyAnswerToTheNoticeIsRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("tom krop gav %d, want 422", resp.StatusCode)
}
if s := beaconSettingsOf(t, ts); s["enabled"] != true {
t.Errorf("en tom krop slog beaconet fra: %v", s["enabled"])
}
if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != true {
t.Errorf("en tom krop talte som et svar: %v", notice)
}
}
// Et tal, der falder, kan ikke offentliggøres som en kendsgerning. Under gulvet
// svares der ingenting frem for et lille tal, ingen kan tolke.
func TestThePublishedCountHasAFloorUnderIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings", map[string]any{
"is_collector": true, "publish_count": true,
}); resp.StatusCode != http.StatusOK {
t.Fatal("bliv collector")
}
// Instansen tæller sig selv, så der er én. Gulvet er højere.
resp, out := ts.do(t, "GET", "/api/v1/beacon/count", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("tælling: %d", resp.StatusCode)
}
if out["installs"] != nil {
t.Errorf("et tal under gulvet blev offentliggjort: %v", out["installs"])
}
// Sættes gulvet ned, står tallet der.
if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings",
map[string]any{"publish_min": 0}); resp.StatusCode != http.StatusOK {
t.Fatal("sænk gulvet")
}
if _, out := ts.do(t, "GET", "/api/v1/beacon/count", nil); out["installs"] == nil {
t.Errorf("tallet blev holdt tilbage uden et gulv: %v", out)
}
}
func beaconSettingsOf(t *testing.T, ts *testServer) map[string]any {
t.Helper()
resp, out := ts.do(t, "GET", "/api/v1/beacon/settings", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("hent beacon-indstillinger: %d", resp.StatusCode)
}
return out
}
+33 -6
View File
@@ -85,7 +85,7 @@ func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) {
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil {
s.internal(w, "caldav home", err)
s.internal(w, r, "caldav home", err)
return
}
@@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Depth") != "0" {
tasks, err := s.caldavTasks(r, user, projectID)
if err != nil {
s.internal(w, "caldav tasks", err)
s.internal(w, r, "caldav tasks", err)
return
}
for _, t := range tasks {
@@ -157,7 +157,7 @@ func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) {
tasks, err := s.caldavTasks(r, user, projectID)
if err != nil {
s.internal(w, "caldav report", err)
s.internal(w, r, "caldav report", err)
return
}
byID := map[string]store.Task{}
@@ -231,6 +231,33 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID)
if getErr == nil {
// The task decides, not the URL.
//
// The check above is about the project the *client named*, and a client
// names both halves of this address. Asking the project for permission and
// then taking the task id from the same string ties nothing together: a
// viewer on a shared project could name a project of their own — the Inbox,
// which everybody owns and may edit — and put the shared task's id in the
// filename. The project check passed on the Inbox and the write landed on a
// task they may only read.
//
// GetTask does not close it either: it answers "may this person see it",
// and a viewer may. `CanEdit` is the question, and it is the same one
// handleUpdateTask asks — the API door has always asked it.
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// And it has to live where the address says it does. Without this an editor
// on two projects can reach either through the other's URL — nothing is
// escalated by that on its own, but a URL allowed to lie about where a
// resource lives is how the case above got in.
if existing.ProjectID != projectID {
http.NotFound(w, r)
return
}
// An update. Only the fields a task client can express are touched;
// everything else the task carries stays as it is.
content := parsed.Summary
@@ -240,7 +267,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
update.DueDatetime = parsed.DueDatetime
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
s.internal(w, "caldav update", err)
s.internal(w, r, "caldav update", err)
return
}
if parsed.Completed && !existing.Completed() {
@@ -275,7 +302,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
task.Content = "(uden titel)"
}
if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
s.internal(w, "caldav create", err)
s.internal(w, r, "caldav create", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
@@ -294,7 +321,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
s.internal(w, "caldav delete", err)
s.internal(w, r, "caldav delete", err)
return
}
w.WriteHeader(http.StatusNoContent)
+808
View File
@@ -0,0 +1,808 @@
package httpapi
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/gcal"
"github.com/kristianwind/verdande/internal/ics"
"github.com/kristianwind/verdande/internal/safedial"
"github.com/kristianwind/verdande/internal/store"
)
// defaultCalendarSyncBudget bounds one "fetch now" when nothing else says
// otherwise. Well under the hundred seconds a proxy is willing to wait, so this
// server is the one that answers a slow Google rather than Cloudflare's own HTML
// page — which is how a failure arrives with no code, no message and nothing in
// the error log.
const defaultCalendarSyncBudget = 25 * time.Second
// The window verdande keeps a copy of.
//
// A month grid pages, so asking Google on every arrow press would mean a round trip
// for a question answered ninety seconds ago — a calendar slower than the calendar
// it is showing. A cached window is the alternative, and a window has to end
// somewhere. Ninety days back is roughly "when was that", a year forward is past
// anything anybody has booked; between them they cover every screen the grid can
// reach without a person noticing an edge.
//
// The edge is still real, and the events endpoint says where it is rather than
// answering an empty list for a month outside it. A calendar that quietly shows no
// events is a calendar that lies about a day being clear.
const (
calendarWindowBack = 90
calendarWindowAhead = 365
calendarDateLayout = "2006-01-02"
calendarProviderName = "google"
)
func (s *Server) calendarClient(accessToken string) *gcal.Client {
return gcal.NewClient(accessToken).At(s.cfg.CalendarAPIURL)
}
// calendarConfig is the same registration Gmail signs in through, asking for a
// different scope and coming back to a different URI.
//
// One OAuth client, two features. Google issues a refresh token per authorisation
// and keeps up to a hundred of them live per account, each carrying the scopes it
// was granted with — so connecting the calendar does not disturb a working Gmail,
// and disconnecting one does not take the other with it.
func (s *Server) calendarConfig(ctx context.Context) gcal.Config {
cfg := s.gmailConfig(ctx)
cfg.Scope = gcal.Scope
cfg.RedirectURL = s.cfg.CalendarRedirectURL()
return cfg
}
// --- connecting ------------------------------------------------------------------
// handleCalendarAuthorize starts the flow. Same shape as Gmail's, and the same
// reason for keeping the verifier server-side: the callback arrives as a top-level
// navigation from Google, and a SameSite=Lax cookie is the kind of thing that works
// until a browser tightens its defaults.
func (s *Server) handleCalendarAuthorize(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
cfg := s.calendarConfig(r.Context())
if !cfg.Configured() {
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
"no Google OAuth client is registered on this server")
return
}
pkce, err := gcal.NewPKCE()
if err != nil {
s.internal(w, r, "calendar pkce", err)
return
}
// Its own settings scope, not Gmail's. Two handshakes in one row would mean a
// person who started both flows had one verifier, and whichever came back
// second would be refused for a reason nothing on the screen could explain.
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
if err != nil {
s.internal(w, r, "calendar settings", err)
return
}
settings["pkce_verifier"] = pkce.Verifier
settings["pkce_state"] = pkce.State
// The attempt expires: a stored verifier that lives forever is a replay window
// that lives forever.
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
s.internal(w, r, "calendar settings", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
}
// handleCalendarCallback is where Google sends the browser back.
//
// It answers with a redirect into the app rather than with JSON: the person is
// looking at a browser tab, not at a fetch response.
func (s *Server) handleCalendarCallback(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
settingsURL := s.cfg.BaseURL + "/indstillinger/integrationer"
if errParam := r.URL.Query().Get("error"); errParam != "" {
// Somebody clicked "cancel" on the consent screen — or, on an Internal
// OAuth app, Google refused an account outside the organisation with
// `org_internal`. Both arrive here, and the page names what it was given.
http.Redirect(w, r, settingsURL+"?calendar="+errParam, http.StatusFound)
return
}
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
http.Redirect(w, r, settingsURL+"?calendar=invalid", http.StatusFound)
return
}
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
if err != nil {
s.internal(w, r, "calendar settings", err)
return
}
verifier, _ := settings["pkce_verifier"].(string)
expected, _ := settings["pkce_state"].(string)
expires, _ := settings["pkce_expires"].(float64)
// The state has to match, or this endpoint accepts a code from anywhere.
if verifier == "" || expected == "" || state != expected {
http.Redirect(w, r, settingsURL+"?calendar=state", http.StatusFound)
return
}
if int64(expires) < time.Now().Unix() {
http.Redirect(w, r, settingsURL+"?calendar=expired", http.StatusFound)
return
}
token, err := s.calendarConfig(r.Context()).Exchange(r.Context(), code,
gcal.PKCE{Verifier: verifier})
if err != nil {
s.log.Error("calendar exchange", "err", err, "user", user.ID)
http.Redirect(w, r, settingsURL+"?calendar=failed", http.StatusFound)
return
}
if token.RefreshToken == "" {
// Without one the connection dies in an hour. Google only withholds it when
// the consent prompt was skipped, which access_type and prompt are meant to
// prevent — so this means something is wrong with the client registration
// rather than with this request.
s.log.Error("calendar returned no refresh token", "user", user.ID)
http.Redirect(w, r, settingsURL+"?calendar=norefresh", http.StatusFound)
return
}
// The one-time values are cleared: keeping a spent verifier serves nothing and
// is one more secret at rest.
delete(settings, "pkce_verifier")
delete(settings, "pkce_state")
delete(settings, "pkce_expires")
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
s.internal(w, r, "clear calendar handshake", err)
return
}
account := &store.CalendarAccount{
UserID: user.ID, Provider: calendarProviderName,
RefreshToken: token.RefreshToken, AccessToken: token.AccessToken,
ExpiresAt: token.ExpiresAt,
}
if err := s.db.SaveCalendarAccount(r.Context(), account); err != nil {
s.internal(w, r, "save calendar tokens", err)
return
}
// The list of calendars, fetched now so the page that opens next has something
// to tick rather than an empty panel and a button. Best effort: an account that
// connects but cannot list itself is still connected, and the status endpoint
// asks again when it finds nothing.
if _, err := s.refreshCalendarList(r.Context(), account); err != nil {
s.log.Warn("calendar list", "err", err, "user", user.ID)
}
s.log.Info("calendar connected", "user", user.ID, "account", account.Account)
http.Redirect(w, r, settingsURL+"?calendar=connected", http.StatusFound)
}
// --- what the settings page reads --------------------------------------------------
type calendarStatusJSON struct {
Connected bool `json:"connected"`
Account string `json:"account,omitempty"`
Calendars []store.Calendar `json:"calendars"`
// RedirectURI is what has to be registered with Google, spelled out. Derived
// from VERDANDE_BASE_URL and matched exactly — the single most likely thing to
// be wrong, and the error Google gives for it names neither value.
RedirectURI string `json:"redirect_uri"`
// HasClient says whether there is an OAuth registration to connect through at
// all, so the page can explain rather than offer a button that answers 409.
HasClient bool `json:"has_client"`
LastSyncAt string `json:"last_sync_at,omitempty"`
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
// an interface that quietly looks editable is one somebody will try to edit.
ReadOnly bool `json:"read_only"`
// Abonnementer står ved siden af Google-kontoen og ikke inde i den: man kan
// have det ene uden det andet, og det er hele pointen med dem — en privat
// konto, der ikke kan bruge en Internal OAuth-klient, kan abonnere.
Subscriptions []store.CalendarAccount `json:"subscriptions"`
}
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
// Læst først, så de også kommer med, når der ingen Google-konto er.
subscriptions, err := s.db.Subscriptions(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "subscriptions", err)
return
}
user := userFrom(r.Context())
out := calendarStatusJSON{
Calendars: []store.Calendar{},
RedirectURI: s.cfg.CalendarRedirectURL(),
HasClient: s.calendarConfig(r.Context()).Configured(),
ReadOnly: true,
}
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
if err != nil {
s.internal(w, r, "calendar account", err)
return
}
if account == nil || account.RefreshToken == "" {
out.Subscriptions = subscriptions
writeJSON(w, http.StatusOK, out)
return
}
out.Connected = true
out.Account = account.Account
if !account.LastSyncAt.IsZero() {
out.LastSyncAt = account.LastSyncAt.Format(time.RFC3339)
}
calendars, err := s.db.Calendars(r.Context(), account.ID)
if err != nil {
s.internal(w, r, "calendars", err)
return
}
// An account connected during a Google hiccup has no calendars and no way back:
// the person sees an empty list and nothing to press. Asking again here costs
// one request on a page nobody opens often, and means the wrong answer lasts
// until the next look rather than for ever. Same reasoning as the Gmail address.
if len(calendars) == 0 {
if fetched, err := s.refreshCalendarList(r.Context(), account); err == nil {
calendars = fetched
out.Account = account.Account
} else {
s.log.Warn("calendar list", "err", err, "user", user.ID)
}
}
if calendars != nil {
out.Calendars = calendars
}
out.Subscriptions = subscriptions
writeJSON(w, http.StatusOK, out)
}
type showCalendarsRequest struct {
// Shown is the whole set, not one flip. Same reasoning as the projects' order:
// a person has a handful of calendars, the question is "these ones", and a list
// that can only be built by several requests can land half applied.
Shown []string `json:"shown"`
}
func (s *Server) handleSetCalendars(w http.ResponseWriter, r *http.Request) {
var req showCalendarsRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
if err != nil {
s.internal(w, r, "calendar account", err)
return
}
if account == nil {
// A 404 rather than a 409: everything the caller may not see answers 404,
// and "there is no such connection" is the true answer either way.
writeError(w, http.StatusNotFound, CodeNotFound, "no calendar is connected")
return
}
if err := s.db.ShowCalendars(r.Context(), account.ID, req.Shown); err != nil {
s.internal(w, r, "show calendars", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDisconnectCalendar forgets the connection, and the copy of the calendars
// with it. Unlike the tasks a mailbox made, none of this is the person's own work.
func (s *Server) handleDisconnectCalendar(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
if err != nil {
s.internal(w, r, "calendar account", err)
return
}
if account != nil {
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, account.ID); err != nil {
s.internal(w, r, "disconnect calendar", err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// --- what the grid reads -----------------------------------------------------------
type calendarEventsJSON struct {
Events []store.CalendarEvent `json:"events"`
// From and To are the window verdande holds a copy of, not the window that was
// asked for. A grid that has paged past it gets no events, and an empty answer
// with no explanation is a calendar quietly claiming a day is clear.
From string `json:"from"`
To string `json:"to"`
}
func (s *Server) handleCalendarEvents(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
fields := map[string]string{}
if !validDay(from) {
fields["from"] = "must be a date, as 2026-08-20"
}
if !validDay(to) {
fields["to"] = "must be a date, as 2026-08-20"
}
if len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
events, err := s.db.CalendarEvents(r.Context(), user.ID, from, to)
if err != nil {
s.internal(w, r, "calendar events", err)
return
}
if events == nil {
events = []store.CalendarEvent{}
}
windowFrom, windowTo := calendarWindow(time.Now())
writeJSON(w, http.StatusOK, calendarEventsJSON{
Events: events, From: windowFrom, To: windowTo,
})
}
// handleCalendarSyncNow refreshes immediately, so somebody who has just connected
// can see it work rather than wondering whether it did.
func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
// A budget of its own, well under what sits in front of this server. An unset
// budget is not a budget of nothing: a zero here would expire the context
// before the first call and make every sync a silent no-op.
budget := s.cfg.CalendarSyncBudget
if budget <= 0 {
budget = defaultCalendarSyncBudget
}
ctx, cancel := context.WithTimeout(r.Context(), budget)
defer cancel()
count, err := s.SyncCalendars(ctx, user)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
// Not a failure: Google is slow or busy, some calendars were read, and
// the rest arrive on the next run. Every calendar is committed as it is
// read, so a short budget is a shorter run rather than a lost one.
writeJSON(w, http.StatusOK, map[string]any{"events": count, "partial": true})
return
}
s.upstream(w, r, CodeCalendarFailed, "sync calendar", err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"events": count})
}
// SyncCalendars refreshes every calendar this person has chosen to see.
//
// Returns how many events were written. Also used by the background job, which is
// why it takes a context and a user rather than a request.
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
// Abonnementer først, og deres fejl tæller ikke mod Google.
//
// De to har intet med hinanden at gøre ud over at ende samme sted: et
// abonnement er en adresse, der hentes, hvor Google er et token, der udløber.
// En kalender, hvis vært er nede, må ikke få det til at se ud som om
// Google-forbindelsen er væk — og omvendt.
written, err := s.syncSubscriptions(ctx, user)
if err != nil {
s.log.Warn("calendar subscriptions", "err", err, "user", user.ID)
}
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
if err != nil {
return written, err
}
if account == nil || account.RefreshToken == "" {
return written, nil // not connected; nothing to do and nothing to complain about
}
// The same guard the mailboxes have, for the same reason: a press of "fetch
// now" during the sweep would otherwise have two runs replacing the same window
// at once, and the loser's delete would land after the winner's insert.
unlock := s.lockSync("calendar:" + account.ID)
defer unlock()
if fresh, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName); err == nil && fresh != nil {
account = fresh
}
access, err := s.calendarAccessToken(ctx, account)
if err != nil {
return 0, err
}
client := s.calendarClient(access)
// The list first, so a calendar added or renamed since the last run is drawn
// under its new name and one that has gone stops being drawn at all.
calendars, err := s.calendarList(ctx, client, account)
if err != nil {
return 0, s.forgetIfRevoked(ctx, user, account, err)
}
from, to := calendarWindow(time.Now())
for _, calendar := range calendars {
if !calendar.Shown {
continue
}
// A spent budget ends the run rather than turning into a failed fetch per
// calendar, logged one at a time.
if ctx.Err() != nil {
return written, ctx.Err()
}
events, err := client.Events(ctx, calendar.RemoteID, from, to)
if err != nil {
if errors.Is(err, gcal.ErrUnauthorized) {
return written, s.forgetIfRevoked(ctx, user, account, err)
}
// One calendar somebody was unshared from must not stop the others.
s.log.Warn("calendar events", "err", err, "calendar", calendar.Name)
continue
}
rows := make([]store.CalendarEvent, 0, len(events))
for _, e := range events {
rows = append(rows, store.CalendarEvent{
RemoteID: e.ID, Summary: e.Summary,
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
StartDay: e.StartDay, EndDay: e.EndDay,
AllDay: e.AllDay, Location: e.Location, URL: e.URL,
})
}
if err := s.db.ReplaceCalendarEvents(ctx, calendar.ID, from, to, rows); err != nil {
return written, err
}
written += len(rows)
}
if err := s.db.MarkCalendarSynced(ctx, account.ID, time.Now()); err != nil {
return written, err
}
return written, nil
}
// calendarAccessToken returns a usable access token, refreshing only when the one
// held has actually expired.
func (s *Server) calendarAccessToken(ctx context.Context, account *store.CalendarAccount) (string, error) {
if account.AccessToken != "" && account.ExpiresAt.After(time.Now()) {
return account.AccessToken, nil
}
token, err := s.calendarConfig(ctx).Refresh(ctx, account.RefreshToken)
if err != nil {
return "", err
}
account.AccessToken = token.AccessToken
account.ExpiresAt = token.ExpiresAt
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
return "", err
}
return account.AccessToken, nil
}
// refreshCalendarList fetches the calendar list with a token it obtains itself.
// Used by the paths that have an account but no client yet — connecting, and the
// status endpoint correcting an empty list.
func (s *Server) refreshCalendarList(ctx context.Context, account *store.CalendarAccount) ([]store.Calendar, error) {
access, err := s.calendarAccessToken(ctx, account)
if err != nil {
return nil, err
}
return s.calendarList(ctx, s.calendarClient(access), account)
}
// calendarList writes what the provider says and returns what is stored, which is
// not the same list: the provider says what exists, and only verdande knows which
// of them somebody chose to look at.
func (s *Server) calendarList(ctx context.Context, client *gcal.Client, account *store.CalendarAccount) ([]store.Calendar, error) {
remote, err := client.Calendars(ctx)
if err != nil {
return nil, err
}
list := make([]store.Calendar, 0, len(remote))
for _, c := range remote {
list = append(list, store.Calendar{
RemoteID: c.ID, Name: c.Name, Colour: c.Colour,
TimeZone: c.TimeZone, Primary: c.Primary, Writable: c.Owner,
})
// The primary calendar is the account, and its id is the address. It is the
// only place the connected account names itself without a second scope —
// asking for a profile scope to learn an address already in the answer would
// be one more thing on the consent screen for nothing.
if c.Primary && account.Account == "" {
account.Account = c.ID
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
return nil, err
}
}
}
if err := s.db.ReplaceCalendars(ctx, account.ID, list); err != nil {
return nil, err
}
return s.db.Calendars(ctx, account.ID)
}
// forgetIfRevoked disconnects when Google says the grant is gone, and passes every
// other failure through untouched.
//
// The distinction is the point. A revoked account polled every ten minutes for ever
// is a log line a week long and a connection the person believes they removed; a
// Google that is merely down is one that will answer next time.
func (s *Server) forgetIfRevoked(ctx context.Context, user *store.User, account *store.CalendarAccount, err error) error {
if errors.Is(err, gcal.ErrUnauthorized) {
s.log.Info("calendar access was revoked; disconnecting", "user", user.ID)
_ = s.db.DeleteCalendarAccount(ctx, user.ID, account.ID)
}
return err
}
// calendarWindow is the span verdande keeps a copy of, measured from today.
//
// Built from the calendar date rather than by truncating the instant: Truncate
// rounds against the Unix epoch, which is midnight UTC, and in Copenhagen that is
// two in the morning — so for two hours of every summer day the window would start
// and end a day out.
func calendarWindow(now time.Time) (string, string) {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return day.AddDate(0, 0, -calendarWindowBack).Format(calendarDateLayout),
day.AddDate(0, 0, calendarWindowAhead).Format(calendarDateLayout)
}
func validDay(s string) bool {
_, err := time.Parse(calendarDateLayout, s)
return err == nil
}
// --- abonnementer -----------------------------------------------------------------
// maxICSBytes bounds one fetch. A published calendar of a decade of holidays is a
// few hundred kilobytes; ten megabytes is not a calendar, and reading it into
// memory on a schedule with nobody watching is how a homelab instance falls over
// at three in the morning.
const maxICSBytes = 10 << 20
// normaliseCalendarURL turns what somebody pastes into something that can be
// fetched.
//
// `webcal://` is not a scheme anything speaks. It is a convention — the same URL
// over https, with a scheme that makes a desktop hand it to the calendar app
// instead of the browser — and every publisher writes their links that way. A
// person pasting one has pasted the right address; refusing it would be refusing
// them on a technicality.
func normaliseCalendarURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if after, ok := strings.CutPrefix(raw, "webcal://"); ok {
raw = "https://" + after
}
if !strings.HasPrefix(raw, "https://") && !strings.HasPrefix(raw, "http://") {
return "", errors.New("must be an https:// or webcal:// address")
}
// The same wall the AI provider's base_url goes through, and for the same
// reason: this is an address a user typed, and the server is about to fetch it
// on a schedule of their choosing.
if reason := safedial.CheckURL(raw); reason != "" {
return "", errors.New(reason)
}
return raw, nil
}
// icsClient is the client a subscribed calendar is fetched with.
//
// safedial, checked on the *resolved* address at connect time and on every
// redirect hop — because a name that passed the check when it was pasted can
// answer 127.0.0.1 when the sweep runs at three in the morning.
//
// The field exists so a test can put an ordinary client here: httptest listens on
// loopback, which is exactly what this refuses, so a test of the *format* cannot
// otherwise reach its own server. It is never set outside a test, and the wall it
// bypasses is tested where it belongs — `normaliseCalendarURL` refuses a private
// address where it is written down, and `internal/push` proves the connect-time
// half.
func (s *Server) icsClient() *http.Client {
if s.icsFetch != nil {
return s.icsFetch
}
return safedial.Client(30 * time.Second)
}
// fetchICS reads a subscribed calendar.
//
// safedial, not a plain client — checked on the resolved address at connect time
// and on every redirect hop, because a name that passed the check when it was
// pasted can answer 127.0.0.1 when the sweep runs at three in the morning.
func (s *Server) fetchICS(ctx context.Context, url string) (ics.Subscribed, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ics.Subscribed{}, err
}
req.Header.Set("Accept", "text/calendar, text/plain;q=0.9, */*;q=0.5")
req.Header.Set("User-Agent", "verdande/"+Version)
resp, err := s.icsClient().Do(req)
if err != nil {
return ics.Subscribed{}, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return ics.Subscribed{}, fmt.Errorf("%s said %s", resp.Request.URL.Host, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxICSBytes+1))
if err != nil {
return ics.Subscribed{}, err
}
if len(body) > maxICSBytes {
return ics.Subscribed{}, errors.New("that calendar is too large")
}
return ics.ParseCalendar(string(body)), nil
}
// syncSubscriptions refreshes every calendar this person subscribes to by address.
//
// One failing address does not stop the others. A subscription is somebody else's
// server, and the ordinary state of somebody else's server is that it is fine
// until one morning it is not — a holiday feed that 404s must not take the
// football club's fixtures down with it.
func (s *Server) syncSubscriptions(ctx context.Context, user *store.User) (int, error) {
subs, err := s.db.Subscriptions(ctx, user.ID)
if err != nil || len(subs) == 0 {
return 0, err
}
from, to := calendarWindow(time.Now())
written := 0
var failures []string
for _, sub := range subs {
if ctx.Err() != nil {
return written, ctx.Err()
}
unlock := s.lockSync("calendar:" + sub.ID)
calendarID, err := s.db.SubscribedCalendarID(ctx, sub.ID)
if err != nil {
unlock()
failures = append(failures, sub.URL)
continue
}
cal, err := s.fetchICS(ctx, sub.URL)
if err != nil {
unlock()
s.log.Warn("subscribed calendar", "err", err, "url", sub.URL, "user", user.ID)
failures = append(failures, sub.URL)
continue
}
// The name the file calls itself, once. A subscription is added under its
// address because that is all there is to go on before it has been fetched.
if err := s.db.RenameSubscribedCalendar(ctx, sub.ID, cal.Name); err != nil {
s.log.Warn("name subscribed calendar", "err", err)
}
rows := make([]store.CalendarEvent, 0, len(cal.Events))
for _, e := range cal.Events {
// Outside the window is not stored. The grid asks about a window, the
// sweep replaces a window, and a decade of holidays in one file would
// otherwise be a decade of rows rewritten every fifteen minutes.
if e.EndDay < from || e.StartDay > to {
continue
}
rows = append(rows, store.CalendarEvent{
RemoteID: e.UID, Summary: e.Summary, Location: e.Location, URL: e.URL,
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
StartDay: e.StartDay, EndDay: e.EndDay, AllDay: e.AllDay,
})
}
if err := s.db.ReplaceCalendarEvents(ctx, calendarID, from, to, rows); err != nil {
unlock()
return written, err
}
if err := s.db.MarkCalendarSynced(ctx, sub.ID, time.Now()); err != nil {
s.log.Warn("mark subscription synced", "err", err)
}
written += len(rows)
unlock()
}
if len(failures) == len(subs) {
return written, fmt.Errorf("none of the %d subscribed calendars could be read", len(subs))
}
return written, nil
}
// handleSubscribeCalendar adds a calendar somebody only has an address to.
//
// Fetched once, here, before it is stored. An address that cannot be read is a
// typo far more often than it is a server that happens to be down, and telling
// somebody now — while they are looking at the field they just pasted into — is
// worth the two seconds. A subscription that silently never fills is the same
// silence the notifications had.
func (s *Server) handleSubscribeCalendar(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
Name string `json:"name"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
url, err := normaliseCalendarURL(req.URL)
if err != nil {
writeFieldErrors(w, map[string]string{"url": err.Error()})
return
}
user := userFrom(r.Context())
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
cal, err := s.fetchICS(ctx, url)
if err != nil {
s.log.Warn("subscribe calendar", "err", err, "user", user.ID)
writeFieldErrors(w, map[string]string{"url": "that address could not be read"})
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = strings.TrimSpace(cal.Name)
}
if name == "" {
name = url
}
account, err := s.db.SubscribeCalendar(r.Context(), user.ID, url, name)
if err != nil {
// The unique index is the one refusal worth naming: subscribing twice to
// the same address is the same calendar fetched twice, with no way to say
// which one counts.
if errors.Is(err, store.ErrDuplicate) {
writeFieldErrors(w, map[string]string{"url": "that calendar is already subscribed"})
return
}
s.storeError(w, r, "subscribe calendar", err)
return
}
// The events it already fetched, rather than making the person wait fifteen
// minutes for the sweep to prove it worked.
if _, err := s.syncSubscriptions(r.Context(), user); err != nil {
s.log.Warn("first subscription sync", "err", err)
}
writeJSON(w, http.StatusCreated, account)
}
func (s *Server) handleUnsubscribeCalendar(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, chi.URLParam(r, "id")); err != nil {
s.storeError(w, r, "unsubscribe calendar", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+471
View File
@@ -0,0 +1,471 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// fakeCalendarGoogle stands in for the parts of Google this feature touches: the
// token endpoint, the calendar list and one calendar's events.
//
// There is no other way to exercise any of it. The real thing needs a registered
// OAuth client, an account and somebody's consent, and none of the three can be had
// inside a test — so the shapes Google returns are written down and the behaviour
// is checked against them, the way internal/imap is checked against go-imap's own
// in-process server.
type fakeCalendarGoogle struct {
*httptest.Server
// refreshes counts token refreshes, so a test can prove one is not made for a
// token that has not expired.
refreshes int
// refuse makes every API call answer 401, which is what a revoked grant does.
refuse bool
events map[string][]map[string]any
}
func newFakeCalendarGoogle(t *testing.T) *fakeCalendarGoogle {
t.Helper()
g := &fakeCalendarGoogle{events: map[string][]map[string]any{}}
g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/token":
g.refreshes++
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "ya29.fresh", "refresh_token": "1//0-refresh",
"expires_in": 3600,
})
case g.refuse:
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{
{"id": "[email protected]", "summary": "[email protected]", "primary": true,
"accessRole": "owner", "backgroundColor": "#3f51b5",
"timeZone": "Europe/Copenhagen"},
{"id": "fam", "summary": "Familien", "accessRole": "reader",
"backgroundColor": "#0b8043"},
}})
case strings.HasPrefix(r.URL.Path, "/calendars/"):
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
items := g.events[id]
if items == nil {
items = []map[string]any{}
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": items})
default:
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
}
}))
t.Cleanup(g.Close)
return g
}
// connectedCalendar returns a server with a calendar account already connected —
// the state the OAuth callback leaves behind, without the consent screen nothing
// can automate.
func connectedCalendar(t *testing.T, g *fakeCalendarGoogle, tune func(*config.Config)) (*testServer, *store.User) {
t.Helper()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GoogleTokenURL = g.URL + "/token"
cfg.CalendarAPIURL = g.URL
if tune != nil {
tune(cfg)
}
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
account := &store.CalendarAccount{
UserID: user.ID, Provider: "google", RefreshToken: "1//0-refresh",
}
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
t.Fatal(err)
}
return ts, user
}
// The scope on the consent screen is what somebody agrees to. It has to be the
// read-only one, and the redirect has to be the calendar's own — sharing Gmail's
// would put "which token store do I write" behind a lookup.
func TestAuthorizeAsksForReadOnlyCalendar(t *testing.T) {
g := newFakeCalendarGoogle(t)
ts, _ := connectedCalendar(t, g, nil)
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
url, _ := body["url"].(string)
for _, want := range []string{
"scope=" + "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly",
"%2Foauth%2Fcalendar%2Fcallback",
"access_type=offline",
"prompt=consent",
} {
if !strings.Contains(url, want) {
t.Errorf("the authorisation URL is missing %q:\n%s", want, url)
}
}
if strings.Contains(url, "calendar.events&") || strings.Contains(url, "auth%2Fcalendar&") {
t.Errorf("a writing scope was asked for:\n%s", url)
}
}
// Without a registered client there is nothing to connect through, and the answer
// has to say that rather than "that already exists".
func TestAuthorizeWithoutAClientSaysSo(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status %d, want 409", resp.StatusCode)
}
if body["code"] != CodeGmailNotConfigured {
t.Errorf("code = %v, want %q", body["code"], CodeGmailNotConfigured)
}
}
// The whole path, minus the consent screen: sync fetches the list, refreshes the
// events of what was chosen, and the grid endpoint answers with them.
func TestSyncFetchesTheChosenCalendarsAndTheGridReadsThem(t *testing.T) {
g := newFakeCalendarGoogle(t)
g.events["fam"] = []map[string]any{
{"id": "e1", "summary": "Bestyrelsesmøde", "htmlLink": "https://calendar.google.com/e1",
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"}},
}
g.events["[email protected]"] = []map[string]any{
{"id": "e2", "summary": "Skal ikke med",
"start": map[string]string{"date": "2026-08-21"},
"end": map[string]string{"date": "2026-08-22"}},
}
ts, user := connectedCalendar(t, g, nil)
// The list arrives on the first look at the settings page, which is what
// somebody would be doing at this point.
resp, body := ts.do(t, "GET", "/api/v1/calendar", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
if body["connected"] != true {
t.Fatalf("not reported as connected: %v", body)
}
if body["read_only"] != true {
t.Error("the connection did not say it is read-only; an interface that looks " +
"editable is one somebody will try to edit")
}
// The primary calendar's id is the address, which is how the account names
// itself without asking for a profile scope as well.
if body["account"] != "[email protected]" {
t.Errorf("account = %v", body["account"])
}
list, _ := body["calendars"].([]any)
if len(list) != 2 {
t.Fatalf("got %d calendars, want 2: %v", len(list), body["calendars"])
}
var famID string
for _, raw := range list {
c := raw.(map[string]any)
if c["shown"] == true {
t.Errorf("%v was shown without anybody choosing it", c["name"])
}
if c["remote_id"] == "fam" {
famID = c["id"].(string)
}
}
// Nothing is chosen, so a sync writes nothing: an account with a dozen shared
// calendars must not fill somebody's grid the moment it is connected.
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
if resp.StatusCode != http.StatusOK || body["events"] != float64(0) {
t.Fatalf("an unchosen calendar was fetched: %d %v", resp.StatusCode, body)
}
resp, _ = ts.do(t, "PUT", "/api/v1/calendar/calendars",
map[string]any{"shown": []string{famID}})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("choosing a calendar: status %d", resp.StatusCode)
}
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
}
if body["events"] != float64(1) {
t.Fatalf("events = %v, want 1 — only the chosen calendar", body["events"])
}
resp, body = ts.do(t, "GET",
"/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("events: status %d, body %v", resp.StatusCode, body)
}
events, _ := body["events"].([]any)
if len(events) != 1 {
t.Fatalf("got %d events, want 1: %v", len(events), body["events"])
}
event := events[0].(map[string]any)
if event["summary"] != "Bestyrelsesmøde" || event["start_day"] != "2026-08-20" {
t.Errorf("the event came back as %v", event)
}
// The calendar's colour rides along, or the chip has to ask for it per row.
if event["colour"] != "#0b8043" {
t.Errorf("colour = %v; two calendars in one grid are told apart by it", event["colour"])
}
// The window verdande holds is stated, so a grid paged past it can say so
// rather than showing an empty month it cannot explain.
if body["from"] == "" || body["to"] == "" {
t.Error("the answer does not say which window it covers")
}
// And it is one person's calendar. Somebody else on the same instance sees
// nothing of it.
other := ts.newUser(t, "[email protected]", "Andreas")
_, body = other.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
if events, _ := body["events"].([]any); len(events) != 0 {
t.Errorf("somebody else's calendar was visible: %v", body["events"])
}
_ = user
}
// A token that has not expired must not be traded in for another. Refreshing on
// every run burns the quota and makes the failure mode of a bad refresh token
// arrive on every sweep rather than once an hour.
func TestAFreshTokenIsNotRefreshed(t *testing.T) {
g := newFakeCalendarGoogle(t)
ts, user := connectedCalendar(t, g, nil)
account, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
if err != nil {
t.Fatal(err)
}
account.AccessToken = "ya29.still-good"
account.ExpiresAt = time.Now().Add(time.Hour)
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
t.Fatal(err)
}
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
}
if g.refreshes != 0 {
t.Errorf("the token was refreshed %d times while it was still good", g.refreshes)
}
// And an expired one is.
account.ExpiresAt = time.Now().Add(-time.Minute)
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
t.Fatal(err)
}
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
}
if g.refreshes != 1 {
t.Errorf("an expired token was refreshed %d times, want 1", g.refreshes)
}
}
// Access revoked at Google's end is a connection that has ended, not a failure to
// retry. Polling it every fifteen minutes for ever is a week of log lines and a
// connection the person believes they removed.
func TestRevokedAccessDisconnects(t *testing.T) {
g := newFakeCalendarGoogle(t)
ts, user := connectedCalendar(t, g, nil)
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
account.AccessToken = "ya29.revoked"
account.ExpiresAt = time.Now().Add(time.Hour)
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
t.Fatal(err)
}
g.refuse = true
resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil)
if resp.StatusCode != StatusUpstreamRefused {
t.Fatalf("status %d, want %d — Google saying no is not this server breaking",
resp.StatusCode, StatusUpstreamRefused)
}
if body["code"] != CodeCalendarFailed {
t.Errorf("code = %v, want %q — `internal_error` is shown as a generic sentence, "+
"which throws the diagnosis away", body["code"], CodeCalendarFailed)
}
gone, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
if err != nil {
t.Fatal(err)
}
if gone != nil {
t.Error("the connection is still there after Google refused the token")
}
// And the failure is in the error log, which is the point of having one: a
// sync that has been failing for a week is invisible otherwise.
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
rows, _ := logged["errors"].([]any)
if len(rows) == 0 {
t.Fatal("nothing was recorded")
}
if row := rows[0].(map[string]any); row["what"] != "sync calendar" {
t.Errorf("what = %v, want \"sync calendar\"", row["what"])
}
}
// Disconnecting takes the copy with it. None of it is the person's own work, and
// a calendar left behind after the account is gone is a frozen lie about a day.
func TestDisconnectingRemovesTheEvents(t *testing.T) {
g := newFakeCalendarGoogle(t)
g.events["fam"] = []map[string]any{
{"id": "e1", "summary": "Møde",
"start": map[string]string{"date": "2026-08-20"},
"end": map[string]string{"date": "2026-08-21"}},
}
ts, user := connectedCalendar(t, g, nil)
ts.do(t, "GET", "/api/v1/calendar", nil)
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
calendars, _ := ts.db.Calendars(t.Context(), account.ID)
var famID string
for _, c := range calendars {
if c.RemoteID == "fam" {
famID = c.ID
}
}
ts.do(t, "PUT", "/api/v1/calendar/calendars", map[string]any{"shown": []string{famID}})
ts.do(t, "POST", "/api/v1/calendar/sync", nil)
if resp, _ := ts.do(t, "DELETE", "/api/v1/calendar", nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("disconnect: status %d", resp.StatusCode)
}
_, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
if events, _ := body["events"].([]any); len(events) != 0 {
t.Errorf("events survived the disconnect: %v", body["events"])
}
_, body = ts.do(t, "GET", "/api/v1/calendar", nil)
if body["connected"] != false {
t.Error("still reported as connected")
}
}
// A range that is not a date has to be refused, not guessed at. An empty `from`
// compared as a string matches everything before it, which is a grid quietly
// answering a question nobody asked.
func TestEventsRefuseARangeThatIsNotADate(t *testing.T) {
g := newFakeCalendarGoogle(t)
ts, _ := connectedCalendar(t, g, nil)
for _, query := range []string{
"", "?from=2026-08-01", "?from=&to=2026-08-31",
"?from=i+morgen&to=2026-08-31", "?from=2026-08-01&to=2026-13-40",
} {
resp, body := ts.do(t, "GET", "/api/v1/calendar/events"+query, nil)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("%q: status %d, want 422 (%v)", query, resp.StatusCode, body)
}
}
}
// A person with no calendar connected still opens the view. It has to answer, and
// answer emptily, rather than fail — the grid is also verdande's own tasks, and
// they must not disappear because Google is not set up.
func TestTheGridAnswersWithNoCalendarConnected(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
if events, ok := body["events"].([]any); !ok || len(events) != 0 {
t.Errorf("events = %v, want an empty list rather than null", body["events"])
}
resp, body = ts.do(t, "GET", "/api/v1/calendar", nil)
if resp.StatusCode != http.StatusOK || body["connected"] != false {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
if body["has_client"] != false {
t.Error("an instance with no OAuth registration claimed to have one; the page " +
"would offer a button that answers 409")
}
// Spelled out even when nothing is connected: it is what has to be registered
// with Google, and it is the single most likely thing to be wrong.
if uri, _ := body["redirect_uri"].(string); !strings.HasSuffix(uri, "/oauth/calendar/callback") {
t.Errorf("redirect_uri = %q", uri)
}
}
// A slow Google must not hold the request open until something in front of this
// server gives up on it and answers with its own HTML.
func TestASlowCalendarDoesNotHangTheRequest(t *testing.T) {
blocked := make(chan struct{})
defer close(blocked)
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-blocked:
case <-r.Context().Done():
}
}))
defer slow.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GoogleTokenURL = slow.URL + "/token"
cfg.CalendarAPIURL = slow.URL
// A second rather than the real twenty-five: what is being tested is that a
// budget is applied at all, and spending the production one on every run
// would put half a minute into the suite to learn the same thing.
cfg.CalendarSyncBudget = time.Second
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
if err := ts.db.SaveCalendarAccount(t.Context(), &store.CalendarAccount{
UserID: user.ID, Provider: "google", RefreshToken: "r",
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
// Its own client, because the shared one gives up after ten seconds — which is
// shorter than the budget being tested and would prove nothing either way.
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/calendar/sync", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
started := time.Now()
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
if err != nil {
t.Fatalf("the request never came back (%v after %s); the sync has no budget of its "+
"own, and whatever sits in front of this server answers for it",
err, time.Since(started).Round(time.Second))
}
defer resp.Body.Close()
if took := time.Since(started); took > 10*time.Second {
t.Errorf("the sync took %s; the budget is not being applied", took.Round(time.Second))
}
if resp.StatusCode >= 500 {
t.Errorf("status %d — a slow Google is not this server breaking", resp.StatusCode)
}
}
+133 -32
View File
@@ -68,7 +68,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
comments, err := s.db.ListComments(r.Context(), taskID)
if err != nil {
s.internal(w, "list comments", err)
s.internal(w, r, "list comments", err)
return
}
out := make([]commentJSON, 0, len(comments))
@@ -78,7 +78,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
if err != nil {
s.internal(w, "list attachments", err)
s.internal(w, r, "list attachments", err)
return
}
direct := make([]attachmentJSON, 0, len(files))
@@ -119,7 +119,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
if err != nil {
s.internal(w, "create comment", err)
s.internal(w, r, "create comment", err)
return
}
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
@@ -149,7 +149,7 @@ func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
if err != nil {
s.storeError(w, "update comment", err)
s.storeError(w, r, "update comment", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -161,7 +161,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
taskID, err := s.db.CommentTask(r.Context(), commentID)
if err != nil {
s.storeError(w, "comment task", err)
s.storeError(w, r, "comment task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
@@ -171,7 +171,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
}
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
s.storeError(w, "delete comment", err)
s.storeError(w, r, "delete comment", err)
return
}
w.WriteHeader(http.StatusNoContent)
@@ -219,28 +219,43 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
stored, size, err := s.storeUpload(file)
if err != nil {
s.internal(w, "store upload", err)
s.internal(w, r, "store upload", err)
return
}
a := &store.Attachment{
TaskID: taskID,
CommentID: r.FormValue("comment_id"),
Filename: safeFilename(header.Filename),
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
Size: size,
Path: stored,
UploadedBy: user.ID,
}
// An attachment hangs on a task or on a comment, never both — the schema says
// so, and the form may have supplied a comment id.
if a.CommentID != "" {
a.TaskID = ""
//
// The comment is resolved and checked against the task in the URL. It was
// taken on trust, which meant the permission check above guarded the *task
// somebody named* while the file landed on *whatever comment they asked for*.
// A viewer on a shared project can list its comments, so she could read an id
// there and then post to a task in her own project with that id attached: the
// file appears under somebody else's comment, in a project she may only read,
// and is served to everyone in it from this origin under a name she chose.
// Somebody removed from a project could do the same with ids they kept.
if id := r.FormValue("comment_id"); id != "" {
owner, err := s.db.CommentTask(r.Context(), id)
if err != nil || owner != taskID {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
a.CommentID, a.TaskID = id, ""
}
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, "record attachment", err)
s.internal(w, r, "record attachment", err)
return
}
@@ -300,23 +315,79 @@ func (s *Server) storeUpload(file io.Reader) (string, int64, error) {
return rel, size, nil
}
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
// reachAttachment answers whether this person may see an attachment at all, and
// whether they may change it.
//
// An attachment hangs on exactly one of three things, and each has its own idea of
// who may reach it. A task's is governed by its project's roles, directly or
// through the comment it hangs on. A group's is governed by nothing but ownership:
// a group belongs to one person, is never shared, and a group that is not yours is
// not found — the same rule the rest of the group routes use.
//
// Both callers below used to resolve straight to a task, so a group's attachment
// answered 404 for its own owner.
func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit bool, err error) {
user := userFrom(r.Context())
a, err := s.db.GetAttachment(r.Context(), attachmentID)
if err != nil {
return false, err
}
if a.GroupID != "" {
if _, err := s.db.GetProjectGroup(r.Context(), a.GroupID, user.ID); err != nil {
return false, err
}
// Nobody else can reach it, so whoever can, can change it.
return true, nil
}
// A file inside a note follows the note, which follows its project — the same
// chain everything else here follows. Without this a picture imported with a
// note answered 404 while sitting in the middle of the text that named it.
if a.NoteID != "" {
n, err := s.db.Note(r.Context(), a.NoteID)
if err != nil {
return false, err
}
if n == nil {
return false, store.ErrNotFound
}
if n.ProjectID == "" {
if n.CreatedBy != user.ID {
return false, store.ErrNotFound
}
return true, nil
}
role, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, store.RoleViewer)
if err != nil {
return false, err
}
return role.CanEdit(), nil
}
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "attachment task", err)
return
return false, err
}
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil {
return false, err
}
return role.CanEdit(), nil
}
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
if _, err := s.reachAttachment(r, attachmentID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
a, err := s.db.GetAttachment(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "get attachment", err)
s.storeError(w, r, "get attachment", err)
return
}
@@ -338,12 +409,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
}
defer f.Close()
// Everything is served as a download rather than rendered. An uploaded SVG or
// HTML file displayed inline would run its own script on this origin, with the
// session cookie attached — attachments are the one place a user supplies
// content that another user opens.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
// Almost everything is served as a download rather than rendered. An uploaded
// SVG or HTML file displayed inline would run its own script on this origin,
// with the session cookie attached — attachments are the one place a user
// supplies content that another user opens.
//
// The exception is a short list of raster images, and it is not a softening of
// that rule but what makes a note work: a note carries its pictures in the text
// as `![](…)`, and served as octet-stream every one of them is a broken image.
// The whole Apple Notes import exists to bring those pictures along.
//
// Safe because it is an allowlist of formats that cannot carry script, sent as
// the exact type with `nosniff` — so the browser must treat it as that image or
// not at all, and can never be talked into reading it as HTML. SVG is
// deliberately absent: it is a document, not a picture, and it is the one image
// type that can run script.
disposition := "attachment"
contentType := "application/octet-stream"
if inlineImage[a.MimeType] {
disposition = "inline"
contentType = a.MimeType
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
@@ -352,24 +441,36 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
}
// inlineImage is what may be shown rather than downloaded.
//
// Raster formats only, and named one by one rather than matched on an "image/"
// prefix: the prefix would let image/svg+xml through, which is a document that
// can run script, and a list that grows by accident is how that happens.
var inlineImage = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/gif": true,
"image/webp": true,
"image/avif": true,
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
"image/heic": true,
"image/heif": true,
}
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
user := userFrom(r.Context())
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "attachment task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
canEdit, err := s.reachAttachment(r, attachmentID)
if err != nil || !canEdit {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "delete attachment", err)
s.storeError(w, r, "delete attachment", err)
return
}
+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) {
+101
View File
@@ -0,0 +1,101 @@
package httpapi
import (
"strings"
"testing"
"github.com/kristianwind/verdande/internal/config"
)
// Politikken skal stå i svaret, og den skal stadig være stram.
//
// Den er dét, der gjorde en XSS i noteeditoren til indsat HTML frem for kørt
// script, dengang en `alt` slap uden om sin undslupning — og der var ingen prøve
// på, at headeren overhovedet blev sendt. En sikkerhedsforanstaltning, ingen
// prøve rører, er en, der kan forsvinde i en oprydning uden at nogen opdager det.
func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
policy := resp.Header.Get("Content-Security-Policy")
if policy == "" {
t.Fatal("der er ingen Content-Security-Policy på svaret")
}
// Målt på script-src alene, og ikke på hele politikken.
//
// `style-src 'unsafe-inline'` står der med vilje og er forklaret i csp.go:
// Svelte skriver stilarter ind i dokumentet, og en indsat stil kan gøre en
// side grim, ikke køre kode. Det er script-src, der afgør, om indsat HTML
// bliver til et kørende script, og det er derfor den, prøven ser på.
scriptSrc := ""
for _, part := range strings.Split(policy, ";") {
if strings.HasPrefix(strings.TrimSpace(part), "script-src") {
scriptSrc = part
}
}
if scriptSrc == "" {
t.Fatal("der er ingen script-src i politikken")
}
for _, forbidden := range []string{"'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", "'strict-dynamic'"} {
if strings.Contains(scriptSrc, forbidden) {
t.Errorf("script-src indeholder %s: %q", forbidden, strings.TrimSpace(scriptSrc))
}
}
// Og de fire, der skal være der.
for _, want := range []string{"script-src", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
if !strings.Contains(policy, want) {
t.Errorf("politikken mangler %q", want)
}
}
}
// HSTS følger med, når instansen kører over HTTPS — og kun der.
//
// Sessionen bor i en __Host-cookie, der kun findes over TLS, så hele modellen
// forudsætter allerede HTTPS. HSTS er det, der holder den forudsætning mod en
// netværksangriber; men bedt om over ren HTTP ville den bede en browser om at
// kræve et certifikat, en dev-instans ikke har. Begge halvdele skal stå prøven.
func TestHSTSTracksHTTPS(t *testing.T) {
overHTTP := newTestServer(t) // BaseURL er http://localhost
resp, _ := overHTTP.do(t, "GET", "/api/v1/ping", nil)
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
t.Errorf("HSTS sat over ren HTTP: %q", got)
}
overHTTPS := newTestServerWith(t, func(c *config.Config) {
c.BaseURL = "https://todo.example.dk"
})
resp, _ = overHTTPS.do(t, "GET", "/api/v1/ping", nil)
if got := resp.Header.Get("Strict-Transport-Security"); got == "" {
t.Error("ingen HSTS over HTTPS")
} else if !strings.Contains(got, "includeSubDomains") {
t.Errorf("HSTS uden includeSubDomains: %q", got)
}
// Permissions-Policy står på hvert svar, uanset skema: den lukker for kamera,
// mikrofon og placering, som appen aldrig beder om.
if got := resp.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") {
t.Errorf("Permissions-Policy lukker ikke for kameraet: %q", got)
}
// COOP isolerer browserkonteksten og står på hvert svar.
if got := resp.Header.Get("Cross-Origin-Opener-Policy"); got != "same-origin" {
t.Errorf("Cross-Origin-Opener-Policy er ikke same-origin: %q", got)
}
}
// Et bilag må ikke kunne blive læst som et dokument.
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
if inlineImage[mime] {
t.Errorf("%s står på listen over det, der vises inline", mime)
}
}
// Og listen skal faktisk kunne noget, ellers virker billeder i noter ikke.
for _, mime := range []string{"image/png", "image/jpeg"} {
if !inlineImage[mime] {
t.Errorf("%s mangler; billeder i noter kan ikke vises", mime)
}
}
}
+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
}
+375
View File
@@ -0,0 +1,375 @@
package httpapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// The notes edition is a different program, and this is what makes that true.
//
// A gate in a route tree is the easiest thing in the world to believe and the
// hardest to see: the code reads `if full {`, somebody confirms the mechanism
// exists, and nothing ever asks whether it covers the routes it was written for.
// So this does not check that a gate is *present*. It walks both routers and
// compares the two sets of routes they actually serve.
//
// The difference is written down below. A route added later that differs between
// the editions — in either direction — fails here until somebody says which half
// it belongs to. That is the point: the list is the decision, and the walk is
// what keeps the decision honest.
func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
full := routesOf(t, config.EditionFull)
notes := routesOf(t, config.EditionNotes)
// Every route the full edition has and the notes edition does not. Written as
// the whole list rather than a prefix rule: a prefix is a description of what
// somebody meant, and this has to be a description of what is there.
//
// 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",
"POST /api/v1/tasks/{taskID}/comments",
"POST /api/v1/tasks/{taskID}/complete",
"POST /api/v1/tasks/{taskID}/move",
"POST /api/v1/tasks/{taskID}/reminders",
"POST /api/v1/tasks/{taskID}/reopen",
"POST /api/v1/tasks/{taskID}/snooze",
"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
}
var unexpected, stillThere []string
for r := range full {
if notes[r] {
if withheld[r] {
stillThere = append(stillThere, r)
}
continue
}
if !withheld[r] {
unexpected = append(unexpected, r)
}
}
// And the other direction: the notes edition must not grow a route the full
// one lacks. A gate that adds is not a gate either.
var onlyInNotes []string
for r := range notes {
if !full[r] {
onlyInNotes = append(onlyInNotes, r)
}
}
sort.Strings(unexpected)
sort.Strings(stillThere)
sort.Strings(onlyInNotes)
for _, r := range unexpected {
t.Errorf("withheld from the notes edition but not written down here: %s", r)
}
for _, r := range stillThere {
t.Errorf("listed as withheld and still served by the notes edition: %s", r)
}
for _, r := range onlyInNotes {
t.Errorf("served only by the notes edition: %s", r)
}
// Printed on the green as well as the red. "The editions differ correctly" over
// zero withheld routes and over twenty-nine are the same pass, and only one of
// them means anything — so the number is on screen either way.
t.Logf("full: %d routes · notes: %d · withheld: %d", len(full), len(notes), len(full)-len(notes))
// The half that stops this being a deletion rather than a gate: what the notes
// edition is FOR has to still be there.
for _, must := range []string{
"GET /api/v1/notes",
"POST /api/v1/notes",
"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)
}
}
}
// routesOf builds a server at one edition and returns every route it serves.
func routesOf(t *testing.T, edition string) map[string]bool {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
srv := New(&config.Config{
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(), Edition: edition,
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
out := map[string]bool{}
err = chi.Walk(srv.router,
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
if route != "/" {
route = strings.TrimSuffix(route, "/")
}
out[method+" "+route] = true
return nil
})
if err != nil {
t.Fatalf("walk routes: %v", err)
}
if len(out) < 50 {
// A walk that found almost nothing would make every comparison below pass.
t.Fatalf("only %d routes at edition %q — the walk found nothing to compare", len(out), edition)
}
return out
}
// 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
}
+149
View File
@@ -0,0 +1,149 @@
package httpapi
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
// The comment above the code list in errors.go says they are kept in one place
// "so the frontend's mapping can be checked against it rather than discovered
// from whatever a handler happened to write". Nothing checked it, and the
// frontend was quietly missing entries — which is not a cosmetic gap: a code
// with no message falls through to the server's English log prose, and a code
// mapped to the wrong sentence tells the person something untrue. Connecting
// Gmail without an OAuth client said "Det er der allerede."
var (
codeConst = regexp.MustCompile(`(?m)^\s*Code\w+\s*=\s*"([a-z_]+)"`)
messageKey = regexp.MustCompile(`(?m)^\s*([a-z_]+):\s*$|^\s*([a-z_]+):\s*'`)
messagesTop = "const MESSAGES = {"
)
func TestEveryErrorCodeHasAMessageInTheFrontend(t *testing.T) {
codes := serverErrorCodes(t)
messages := frontendMessageKeys(t)
var missing []string
for _, code := range codes {
if !messages[code] {
missing = append(missing, code)
}
}
sort.Strings(missing)
for _, code := range missing {
t.Errorf("%q is returned by the API and has no message in web/src/lib/api.js — "+
"a person would be shown the server's English log prose", code)
}
}
func serverErrorCodes(t *testing.T) []string {
t.Helper()
source, err := os.ReadFile("errors.go")
if err != nil {
t.Fatalf("read errors.go: %v", err)
}
var codes []string
for _, m := range codeConst.FindAllStringSubmatch(string(source), -1) {
codes = append(codes, m[1])
}
if len(codes) == 0 {
t.Fatal("no codes were read out of errors.go — the scanner and the file disagree")
}
return codes
}
// frontendMessageKeys reads the keys of the MESSAGES object. A small scanner
// rather than a JS parser, for the same reason the OpenAPI check uses one: it
// needs one thing out of a file this test also owns.
func frontendMessageKeys(t *testing.T) map[string]bool {
t.Helper()
source, err := os.ReadFile(filepath.Join("..", "..", "web", "src", "lib", "api.js"))
if err != nil {
t.Fatalf("read api.js: %v", err)
}
body := string(source)
start := strings.Index(body, messagesTop)
if start < 0 {
t.Fatalf("could not find %q in api.js", messagesTop)
}
body = body[start+len(messagesTop):]
end := strings.Index(body, "\n};")
if end < 0 {
t.Fatal("the MESSAGES object does not end where this scanner expects")
}
keys := map[string]bool{}
for _, line := range strings.Split(body[:end], "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
name, _, ok := strings.Cut(line, ":")
if ok {
keys[strings.TrimSpace(name)] = true
}
}
if len(keys) == 0 {
t.Fatal("no message keys were read out of api.js")
}
return keys
}
// The four that used to be indistinguishable from "that already exists".
func TestTheMisleadingConflictsHaveTheirOwnCodes(t *testing.T) {
for _, code := range []string{
CodeGmailNotConfigured,
CodeAINotConfigured,
CodeTOTPNotEnabled,
CodeInboxProtected,
} {
if code == CodeConflict {
t.Errorf("%q is still plain conflict", code)
}
}
messages := frontendMessageKeys(t)
for _, code := range []string{"gmail_not_configured", "ai_not_configured",
"totp_not_enabled", "inbox_protected"} {
if !messages[code] {
t.Errorf("%q has no Danish message", code)
}
}
}
// The Inbox is the one project that cannot be deleted, and saying "that already
// exists" about it helps nobody.
func TestDeletingTheInboxSaysWhy(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
list, _ := projects["projects"].([]any)
var inboxID string
for _, raw := range list {
p := raw.(map[string]any)
if p["is_inbox"] == true {
inboxID = p["id"].(string)
}
}
if inboxID == "" {
t.Fatal("no inbox was found")
}
resp, body := ts.do(t, "DELETE", "/api/v1/projects/"+inboxID, nil)
if resp.StatusCode != 409 {
t.Fatalf("status %d, want 409", resp.StatusCode)
}
if body["code"] != CodeInboxProtected {
t.Errorf("code = %v, want %q", body["code"], CodeInboxProtected)
}
}
+47
View File
@@ -0,0 +1,47 @@
package httpapi
import (
"net/http"
"time"
)
// What has gone wrong lately, kept where a restart cannot take it.
//
// The panel's watcher reports "HTTP 5xx, twice, at 11:49" — which is enough to
// know something broke and nothing at all to act on. The line that explains it is
// in the container's log, and a Rune's container is replaced on every restart, so
// by the time anybody looks the explanation is gone. These rows are the same
// information written somewhere that survives.
type serverErrorJSON struct {
ID string `json:"id"`
At string `json:"at"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
// What the handler was doing, in its own words — "list projects", "move task".
What string `json:"what"`
Message string `json:"message"`
// Who was asking, when there was somebody: a fault only one account hits is a
// different problem from one everybody hits.
UserName string `json:"user_name,omitempty"`
// RequestID ties the row back to the log line, for whoever still has it.
RequestID string `json:"request_id,omitempty"`
}
func (s *Server) handleListErrors(w http.ResponseWriter, r *http.Request) {
errs, err := s.db.ListErrors(r.Context(), parseLimit(r.URL.Query().Get("limit"), 100, 500))
if err != nil {
s.internal(w, r, "list errors", err)
return
}
out := make([]serverErrorJSON, 0, len(errs))
for _, e := range errs {
out = append(out, serverErrorJSON{
ID: e.ID, At: e.At.Format(time.RFC3339), Method: e.Method, Path: e.Path,
Status: e.Status, What: e.What, Message: e.Message,
UserName: e.UserName, RequestID: e.RequestID,
})
}
writeJSON(w, http.StatusOK, map[string]any{"errors": out})
}
+36
View File
@@ -36,8 +36,44 @@ const (
CodeRateLimited = "rate_limited"
CodeInternal = "internal_error"
CodePayloadTooLarge = "payload_too_large"
// Four situations that used to answer plain `conflict`, which the frontend
// renders as "that already exists". For these that is not merely vague, it is
// wrong: an unconfigured Gmail is not a Gmail that is already there, and a
// second factor that is off is not one that is on. A code exists so the
// interface can say the true thing — the server's own prose is English for a
// log, and deciding what a person reads is the frontend's job.
CodeGmailNotConfigured = "gmail_not_configured"
CodeAINotConfigured = "ai_not_configured"
CodeTOTPNotEnabled = "totp_not_enabled"
CodeInboxProtected = "inbox_protected"
// And a fifth, for the same reason: refusing to remove the last administrator
// is not "that already exists" either, and it is the one refusal on the user
// page that a person needs explained rather than merely reported.
CodeLastAdmin = "last_admin"
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
// "insufficient authentication scopes" — and they are useless behind a generic
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
// read "something went wrong", and had nowhere to go. The interface wraps the
// message in one that says where it came from.
CodeGmailFailed = "gmail_failed"
// And the same for a mailbox read over IMAP. A host's refusal is the whole
// diagnosis — a wrong app password, a host that is not listening, a folder that
// is not there — and it says it in its own words.
CodeMailboxFailed = "mailbox_failed"
// And for a calendar read from Google. Its own code rather than the Gmail one,
// because the sentence the interface wraps it in names where it came from — and
// "Gmail said no" about a calendar sends somebody to the wrong panel.
CodeCalendarFailed = "calendar_failed"
)
// errLastAdmin is returned to the caller that already wrote the response, so it
// stops rather than carrying on. It is never rendered anywhere.
var errLastAdmin = errors.New("httpapi: refused to remove the last administrator")
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, APIError{Code: code, Message: message})
}

Some files were not shown because too many files have changed in this diff Show More