97e6be3afad820221f042240b7f7b7ca62bb0bad
45
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 <noreply@anthropic.com>
|
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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. |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com>
|
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com>
|
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com>
|
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
e63d852847 |
feat(mcp): let the connector address carry its own key
verdande could not be added to Claude at all. The documented setup — POST /api/v1/mcp with an Authorization header — cannot be configured from Claude's custom-connector dialog, which asks for a name, a URL and OAuth client credentials and has no field for a bearer token. The instructions described something the interface does not offer. Worse, the obvious guess failed silently in the most misleading way available: /mcp was not a route, so the SPA fallback answered 200 with a page of HTML. The connector reported a successful connection and then failed to parse the response, which reads as a broken client rather than a missing endpoint. So /mcp exists now and takes its credential in the query string, exactly as the calendar feed does and for the same reason: a client that cannot be told to send a header means the address is the credential. The request logger records paths without their query strings, so the key does not reach the log. It is deliberately outside /api/v1 and accepts the key and nothing else. Reading a session cookie here would make it a state-changing POST outside the CSRF check — a cross-site request that acts as whoever is signed in — and there is a test that fails if that ever becomes true. The API path is unchanged for anything that can send a header. The tokens page now shows the connector address next to the token it belongs to, since that is the one moment the token exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
68d82a1cb6 |
feat(api): personal API tokens, and let people change their own profile
docs/api.md has told people to make a token under Settings since it was written. The store has held CreateAPIToken, ListAPITokens and DeleteAPIToken since the first migration. There were no routes: nothing between the two, and no way to make one. GET, POST and DELETE under /tokens, all behind a new requireSession middleware. A bearer token is refused there with 403 even though it is accepted everywhere else — otherwise a leaked token is permanent, because whoever has it issues a second one and revoking the first changes nothing. The plaintext comes back from POST and never again; only the hash is stored, so the interface has to say so at that moment rather than offering a reveal that cannot work. PATCH /auth/me alongside it. Name, timezone and language could not be changed anywhere — not through the API and not through the interface — and an account created from an invite inherits a default timezone. That is not cosmetic: every date in the app is resolved in it, so somebody in another country had every due date land an hour out with no way to say so. The zone is validated against the running system's database rather than a list kept here, which is the same database the dates are resolved with. The email address stays read-only: it identifies the account and is what invites were sent to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5546e191ac |
feat(gmail,update): finish the OAuth2 flow, and tell admins about new releases
Gmail now connects. Authorisation-code flow with PKCE, the verifier kept server-side rather than in a cookie — the callback is a top-level navigation from Google, and a SameSite=Lax cookie is the kind of thing that works until a browser tightens its defaults. State is checked, the attempt expires after ten minutes, and the spent verifier is deleted rather than left at rest. Scope is gmail.readonly and the sync is one-way on purpose. Unstarring a message does not delete the task; completing the task does not touch the mailbox. Two-way would mean deciding what happens when both sides changed, and the only honest answer is a sync model far larger than the feature. The query carries newer_than:30d so connecting an account with a decade of starred mail does not create a decade of tasks. The update check is off unless the operator turns it on, asks GitHub at most every six hours, and sends nothing about the instance — no identifier, no version, no counts. A development build never reports an update: somebody running their own tree is ahead of every release, and telling them otherwise trains them to ignore the notice. Only administrators see it, because only they can act on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b19ed062d |
feat(phase5): MCP server, CalDAV, mail-to-task, Gmail settings and an AI layer
The MCP server speaks JSON-RPC over Streamable HTTP with the six tools the brief names, authenticated by a personal API token — so a connector reaches exactly what its owner reaches and there is no second identity to reason about. A failed tool call returns a tool error, not a protocol error: a model can recover from the first and can do nothing with the second. CalDAV serves VTODO, two-way, so Apple Reminders and Thunderbird can read, create and complete tasks. Clients authenticate with Basic using an API token as the password, never the account password — a client stores what it is given, and what it is given should not be able to sign in to the app. Completed tasks stay in the collection: a client that stops seeing one treats it as deleted and drops its own record of having done it. The AI layer is one interface with adapters for Anthropic, OpenAI, Google and any OpenAI-compatible endpoint. That last one is why the abstraction exists: an integration that only spoke to a hosted API would be useless to somebody running their own models. Every feature degrades to "off" when nothing is configured, and the stored API key is never returned to the settings page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4058ff0533 |
feat(phase2,phase4): comments, attachments, notifications, Web Push, import/export
Comments and attachments close out phase 2. Files are stored content-addressed by hash, so the path never contains anything the uploader chose — a filename is attacker-controlled text, and building a path from it is how "../" becomes a write outside the data directory. Everything downloads as application/octet-stream: an uploaded SVG served inline would run its own script on this origin with the session cookie attached. Web Push is written directly against RFC 8030/8291/8292 rather than pulled in — it is three specs' worth of well-defined crypto and about two hundred lines, and the alternatives carry a JWT and crypto stack each larger than the file. Phase 4 imports Todoist's CSV and exports it back. Todoist numbers priorities the opposite way to how it displays them — its 4 is P1 — so the two are converted rather than copied; getting it backwards silently inverts the urgency of every imported task. Nesting is positional, so the reader keeps an indent stack and the writer never sorts. The round trip is tested as the brief asks: a CSV in, out, and back produces an identical project, and two exports of one project are byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7e4c730049 |
feat(phase3): reminders, ICS feeds, templates and nightly backups
Reminders are absolute or relative ("10 minutes before"). A relative one
is resolved against the task in SQL, so moving a task moves its reminders
too. They are marked sent before delivery, not after: a reminder that
goes out unrecorded repeats every minute forever, which is worse than
one recorded that fails to send.
The calendar feed is a per-user token in the path, because Apple Calendar
and Google fetch with no credentials and no way to prompt for any. Tasks
go out as VTODO — the same component CalDAV will serve — and timed ones
also as VEVENT, since Google ignores VTODO entirely.
Templates store relative day offsets rather than dates, which is what
makes them reusable: an onboarding checklist is "day 1, day 3, day 14",
not three dates in March. Building a project from one is a single
transaction, so a failure cannot leave half a checklist.
Backups use SQLite's VACUUM INTO, not a file copy — a copy of a live
WAL database can miss recent writes or catch a page mid-update, and the
result looks like a database right up until it is needed. Rotation keeps
fourteen by count, not by age: a container that was off for a month must
not come back and delete every backup it has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4d142b2fa7 |
feat(filters): saved filters with a query language, and label management
"today & p1", "#Firma & @regnskab", "overdue | (today & assigned to: me)". The grammar is Todoist's, so the import path from it does not require relearning anything, and both languages' keywords are accepted. It compiles to parameterised SQL rather than filtering in Go: one indexed query instead of ten thousand rows crossing the boundary. No typed text is ever concatenated into the statement — this is a language people write by hand, which makes it exactly where string-building would become an injection. An expression is compiled before it is saved. A filter that cannot run is worse than none: it is discovered later, as a list that is empty for a reason nobody can see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
12a9597919 |
feat(web): SvelteKit PWA with the Nordic design system
The interface for phase 1: sign-in and first-run setup, Today, Upcoming, project views, sharing, Cmd+K search and the keyboard shortcuts. Quick add highlights what the parser understood underneath the text as you type — a mirrored layer behind a transparent input, sliced by byte offset because the server counts bytes and JavaScript counts UTF-16 units, which disagree on exactly the Danish letters this app is full of. Every local action is optimistic and reconciled afterwards: ticking a task off strikes it through and animates it out before the request leaves, and rolls back with a message if the server disagrees. The Content-Security-Policy is computed at startup by hashing the inline scripts in the built index.html. Both of them change on every frontend build, so a hard-coded hash would go stale silently and show up only as a blank page in production. The alternative, 'unsafe-inline', would drop the one protection that makes an injected script in a task title inert. Today is derived from the store by date rather than from the ids that came back with the first request — filtering against a captured list meant a task added through quick add never appeared until a reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dff86dd23d |
feat(api): projects, sections, tasks, quick add, views and live sync
Completes the MVP surface: project and section CRUD, task CRUD with sub-trees, drag-and-drop ordering, the quick-add endpoint over the natural-language parser, Today and Upcoming, search, sharing with roles, an activity log and a WebSocket for live updates. Permission checks are route middleware rather than a first line in each handler, so an endpoint is guarded by where it sits instead of by the author remembering. Anything the caller may not see answers 404, never 403 — a 403 confirms the thing exists. Visibility is enforced inside the list query, so another user's project cannot be reached by filtering or searching at it. Ordering is a float, so a drop writes one row instead of renumbering the list. When repeated drops into one gap exhaust the precision, the section is spread out again and the move retried — in a separate transaction, since the attempt that discovered the problem rolls back. The connection pool is not 1: a single connection makes the pool a lock, and a query run while an earlier result set is still open then waits for a connection it is itself holding. The store now closes its rows before querying again, and the pool leaves that mistake slow rather than fatal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1bb644f4d1 |
feat(api): authentication — login, two-step TOTP, invites and resets
Sessions are stored as hashes, so a database dump contains nothing that can be pasted into a browser. A login that has passed the password but not the second factor is modelled as a pending session: it expires, is revocable, and reaches exactly one endpoint. Signup is invite-only and takes the email address from the invite rather than the form, so a link cannot be redirected to somebody else. The first admin is created by an endpoint that refuses once any account exists, checked and inserted in one transaction because it is necessarily unauthenticated. Login failures and forgotten-password requests answer identically whether or not the address has an account — otherwise the form is an account-existence oracle. Password rules are a length floor and nothing else: composition rules push people towards worse passwords. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
02c8b1be61 |
feat(server): runnable binary, Rune manifest, Docker image and CI
The HTTP server serves /healthz, the API namespace and the PWA, with the frontend embedded behind a build tag so a clean checkout compiles without a Node toolchain. /healthz reads the database rather than just answering: a process that cannot reach its own data is not healthy, and a 200 there would keep a broken container in rotation. The image is distroless and static — the SQLite driver is pure Go, so CGO stays off. The Rune manifest uses keep_entrypoint because there is no shell in the image to run a startup command with, and its log watchers match the JSON log format the container actually emits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |