c8d67dd07d91d3fa2c94125bff17bf373acb6e5c
35
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <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> |
||
|
|
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 <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> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <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> |
||
|
|
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 <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> |
||
|
|
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>
|
||
|
|
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 <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> |
||
|
|
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 <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> |
||
|
|
9d9444d0ac |
feat(web): the settings surface
There was no /indstillinger route at all. The Gmail OAuth callback redirects there and had been answering 404 since it was written. Six pages rather than one long scroll, because the sections have nothing to do with each other and somebody opening API tokens should not have to walk past their own password to get there: the account and its second factor, notifications, integrations, AI, tokens, and data. Everything listed as having no interface in the handoff is reachable from one of them — the calendar feed, the mail-to-task address, Gmail, the AI provider, import, export, templates and the update notice. Two decisions worth naming. The AI key field starts empty and says why: a settings page that repopulates a password field is one that will eventually leak a key into a screenshot, and an empty value means "leave the stored one alone" rather than "clear it". And the form chrome lives in the layout as :global rules, because scoped styles do not reach a child route and six copies of the same twelve selectors is six places for them to drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |