97e6be3afad820221f042240b7f7b7ca62bb0bad
63
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>
|
||
|
|
77994eff09 |
feat(security): a TOTP code can be used once
RFC 6238 says the verifier must reject a code it has already accepted: a code stays valid for its whole thirty-second window, so one seen in flight — a phishing page, a proxy, over the shoulder — could be replayed until it expired. Tasks now record the counter of the last accepted step (totp_last_step), and a login accepts a code only when its step is strictly greater. The guard is in the UPDATE, not a read-then-write, so two logins racing with the same stolen code cannot both find the old value and both proceed — exactly one moves the row. auth.TOTPStep is VerifyTOTP that also reports which step matched, walking the same one-step window; the code entered a little early or late spends the step it was issued at, not the step it was checked at, so a late-but-valid code is still single-use. Verified end to end: the same code presented to two logins in one window is accepted once and refused the second time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3e52eea2da |
feat(kvasir): én mail bliver til én opgave, uanset hvem der læser den
Fundamentet under Kvasir, og det er værd at have uden ham. Intet kunne svare på "er denne mail allerede blevet til en opgave?". To læsere af samme postkasse — en anden assistent, en gentaget kørsel, et menneske — laver derfor to opgaver. Det er ikke teoretisk: en briefing lavede "Betal faktura fra Browns" to gange i samme kørsel. Opgaver har nu en `source_key`, navngivet med et rum foran: `gmail:thread:…`, `imap:<vært>:<mappe>:<uid>`. Et Gmail-tråd-id og et IMAP-uid ligner ikke hinanden, lige indtil den dag de kolliderer, og et kolon koster ingenting. Det betyder også, at den næste kilde ikke skal bruge en ny kolonne — pointen med en nøgle frem for en fremmednøgle. Der findes ingen messages-tabel at pege på, og det her skal virke uden. Et delvist unikt indeks er selve låsen, ikke en optimering: to agenter, der læser samme indbakke, SKAL være ude af stand til at lave to opgaver, og "skal være ude af stand til" er en betingelse frem for en aftale, nogen husker. Afgrænset til den, mailen kom til — to personer på samme instans, der begge modtager tråden, får hver sin opgave, og det er rigtigt. CreateTask svarer ErrDuplicate frem for at oprette nummer to, og begge mail-veje springer over i stedet for at fejle. IMAP-vejen tæller den stadig som læst — ellers rykker last_uid sig aldrig forbi den, og hver eneste kørsel fra da af standser samme sted. `TaskIDBySource` er spørgsmålet den anden vej, så en assistent, der ikke ejer triagen, kan se, at mailen er håndteret. Slettede opgaver tæller med: en opgave, der blev lavet af en mail og smidt væk, er også et svar. |
||
|
|
c8d67dd07d |
feat(notes): notekroppe forsegles, og søgningen åbner dem selv
CI / OpenAPI is valid (push) Successful in 38s
CI / Go (push) Failing after 18m36s
CI / End-to-end (push) Failing after 18m54s
CI / Docker image builds (push) Successful in 7m27s
Documentation / build (push) Failing after 7m7s
Documentation / deploy (push) Skipped
Release / Build and publish image (push) Failing after 9m59s
Grunden er den samme som for postkassers kodeord og OAuth-tokens: en sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem hver eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi, fordi der ligger sådan nogle i den installation, det her blev bygget til. **Både titel og krop.** Titlen *er* første linje, så en forseglet krop ved siden af en læselig titel ville efterlade begyndelsen af hver eneste note i klartekst. **Og de to kopier gik med.** Det var derfor det ikke var gjort for længe siden: `notes_fts` indekserede kroppen, og den genererede `fold`-søjle bar en translittereret kopi af den. At forsegle kroppen og lade dem stå ville have været teater — en angriber med filen kunne læse hver note ud af indekset. Begge er væk, og tabellen er bygget om, fordi SQLite ikke kan fjerne en genereret søjle på plads. **Søgningen åbner teksten i Go.** Målt frem for gættet: tolv hundrede noter à 3,3 kB koster 33–43 ms pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den spørger — så det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne; fire megabyte AES-GCM er få millisekunder. Den går med at folde og småskrive hver krop, og det er lineært: regn med omtrent 350 ms ved ti tusind noter. Dér er det den forkerte form, og svaret er da et nøglet indeks — hvis pris er, at præfiks- og delordssøgningen falder bort. bm25 forsvandt med indekset. Det, der er beholdt, er den halvdel, der betød mest: titlen vejer ti gange kroppen. Med et loft pr. ord, så en note, der nævner ordet fyrre gange, ikke slår en, der nævner alle ordene én gang — det er den fejl, en ren optælling laver, og den er let at lave og svær at få øje på. **En bagudfyldning ved opstart, ikke efterhånden.** Instansens indstillinger forsegles, når de skrives, og det er rigtigt for dem. For et arkiv på tolv hundrede importerede noter, som ingen redigerer igen, betyder "når de skrives" aldrig — og det er præcis dét arkiv, der er værd at forsegle. Idempotent, så den koster et gennemløb af id'er hver anden gang, og `updated_at` røres ikke: at lukke en konvolut om en note er ikke at skrive den, og et stempel med i aften ville kaste den rækkefølge væk, importen brugte en dag på at hente hjem. En fejl under bagudfyldningen stopper opstarten. Halvdelen af noterne lukket er ikke i stykker — `v1:`-præfikset gør, at begge slags kan læses — men en instans, der starter og lader som om den beskytter noget, den ikke gør, er værre end en, der siger fra. Hvad der stadig ligger åbent, står i SECURITY.md og på dokumentationssiden: opgavetitler, beskrivelser, kommentarer, og `note_links`, som røber hvilke projekter og opgaver en note peger på, men ikke hvad der står. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
be581eb8ae |
fix(search): et ord inde i et ord kunne ikke findes
Release / Build and publish image (push) Failing after 9m57s
Meldt ind som "søgning har ikke det store held": en note med `gamerule keepInventory true` kunne ikke findes ved at skrive "keep inventory". Et indeks er bygget af tokens, og et token er det, en tokenizer besluttede var et ord. `keepInventory` er ét. Så "keep inventory" spørger efter to ord, der ikke findes nogen steder, og søgningen svarer, at der ikke er noget — mens noten står lige der. Præfikssøgningen tager halvdelen af det. `keep*` rammer faktisk `keepinventory`; `inventory*` kan ikke, for det er ikke et præfiks af noget. Og de to led er bundet sammen med AND, så den halvdel, der virker, bliver kasseret af den, der ikke kan. Det er ikke et sært tilfælde i en notesapp. Ti års noter er fulde af kommandoer, identifikatorer og filnavne, og hver eneste af dem er flere ord, indekset tror er ét: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`. Indekset svarer først. Har det ingenting, læses teksten: hvert ord skal stå et sted i noten, hvor som helst, i hvilket som helst ord. `fold` er `title || ' ' || body` med de danske bogstaver translittereret, så én søjle dækker titlen, brødteksten og "gron" for "grøn" på én gang. Opgavesøgningen har fået den samme arm. At have den i den ene af de to ville være værre end at have den i ingen af dem: "den finder det i mine noter, men ikke i mine opgaver" er den næste melding, og svaret på den ville være denne linje. Hvad det koster, og hvornår formen er forkert, står skrevet ved koden: en LIKE med et indledende % kan ikke bruge et indeks, så den læser hver note. Ved tolv hundrede noter er det fire megabyte og et par millisekunder, og den kører kun, når den hurtige vej allerede kom tomhændet tilbage — altså netop den søgning, der var ved at skuffe nogen. Ved hundrede tusind er svaret et FTS5-`trigram`-indeks, ikke en større scanning. Prøverne er set fejle uden rettelsen, og de dækker begge veje: "inventory" alene, som ingen præfikssøgning kan nå, og "Bedrock", som stadig skal besvares af indekset — ellers er rangeringen, der blev bygget til tolv hundrede noter, stille forsvundet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b87c9ea6e2 |
fix(auth): en afvist passkey sagde "forkert e-mail eller adgangskode"
Release / Build and publish image (push) Failing after 10m0s
Der er hverken en e-mail eller en adgangskode i et passkey-login. Loginet er discoverable — enheden ved selv, hvilken konto nøglen hører til, og der bliver ikke tastet noget. Alligevel fik en afvisning fladens generiske 401-tekst, og den nævner begge dele. Det sendte en person ud at lede efter en tastefejl i sin konto, der aldrig havde været der. Det, der faktisk var sket, var at instansen var flyttet fra http til https: en passkey hører til præcis den adresse, den blev lavet på — `RPOrigins` bygges af `VERDANDE_BASE_URL` med skema og alt — så et login på det ene skema mod en server, der venter det andet, kan ikke verificeres. Beskeden siger nu det, og siger også hvor man skal kigge. Serveren har hele tiden svaret "that key was not accepted"; det var oversættelsen i fladen, der gjorde den til noget andet. Aflysning bliver behandlet som før — den er ikke en fejl, og en fejl for den ville læse som om nøglen blev afvist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b4448b245 |
fix(settings): AI, Gmail og kalenderen delte én række og slettede hinanden
Meldt ind som "AI-informationerne gemmes ikke". De blev gemt. De blev taget bagefter. `user_settings` blev oprettet med `user_id` som hele sin primærnøgle — én række per person, uanset scope — og upserten skrev `scope` om ved konflikt. Der er tre scopes i live: `ai`, `gmail` og `calendar`. De delte den ene række, så hver skrivning slettede de to andre, og hver eneste svarede 204. Det virker begge veje: at forbinde kalenderen slettede Gmail-forbindelsen, og Gmail slettede kalenderen. Fælden var set komme to gange. 0011 gav instansens indstillinger deres egen tabel delvis af den grund og skrev advarslen ned med rene ord; 0014 flyttede postkasserne ud og skrev, at advarslen "nær var trådt i for anden gang". Tredje gang var kalenderen, som lagde et `calendar`-scope ved siden af de to andre. Lektien værd at holde fast i: en advarsel i en migration bliver læst af den, der redigerer *den* migration, og det var ingen. Formen er rettet her, så der ikke er noget tilbage at advare om — `PRIMARY KEY (user_id, scope)`, og upserten rammer den nøgle. Tabellen bygges om, fordi SQLite ikke kan ændre en primærnøgle på plads. De rækker, der findes, kopieres som de er: hver person har højst én, og den holder det scope, der vandt sidst. Det, de tidligere skrivninger tog, er overskrevet og kan ikke hentes tilbage her — efter opdateringen skal AI-udbyderen sættes op igen, og Gmail-forbindelsen tjekkes. To prøver: én mod store-laget, hvor formen var fejlen, og én gennem handlerne, hvor den blev set. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
8aad7c13b0 |
perf(notes): listen sendte hele noter for at vise halvfems tegn af dem
Release / Build and publish image (push) Failing after 9m55s
Chrome frøs på notesiden, og der var to grunde. **Nyttelasten.** Listen viser en titel og omkring halvfems tegn. Den fik hele noter for at gøre det: med tolv hundrede blev svaret 3,9 MB, hvoraf otteoghalvfems procent var brødtekst, ingen ville læse på den skærm — hentet, tolket og holdt i hukommelsen hver gang siden blev åbnet. Nu 763 KB. Kroppen tømmes frem for at blive forkortet, og det er den vigtige halvdel. Et felt, der hedder `body` og indeholder et *stykke* af kroppen, er en fælde: editoren ville åbne det, pausen ville gemme det, og noten ville være skåret ned til sit eget uddrag af ingenting andet end at være blevet kigget på. Tom er en form, klienten kan se efter — og den gør det og henter hele noten, når den åbner en. Det kostede en adskillelse mere: hvilken række der er *valgt*, og hvilken note der er *indlæst*, er nu to ting. Editoren indlæser sin tekst, når notens id skifter, så hvis den fik uddraget først og hele noten bagefter, ville id'et være det samme begge gange og den anden aldrig blive vist. Man ville sidde med en tom note, der lige havde haft en titel. **Tegningen.** Alle tolv hundrede rækker fik layout og maling, også de tusind ingen kan se: 2,1 sekunder til første tegning. `content-visibility: auto` lader browseren springe det over og tage det, når man ruller derhen — 0,95 sekunder, og uden en eneste linje JavaScript, ingen rullematematik og ingen liste, der kan komme ud af trit med sig selv. **Og søgefeltet.** Det var en pille med et forstørrelsesglas tegnet ind som et baggrundsbillede med farven skrevet i hånden — #8b918d, uanset tema — hvilket gjorde det til det ene sted på siden, der ikke fulgte resten. Nu samme form som søgefeltet i toppen, et rigtigt ikon i `currentColor`, en ryd-knap og et antal fundne. Det havde heller ingen pause: hver tast sendte en søgning gennem tolv hundrede noter, så "kaffe" var fem forespørgsler, hvoraf de fire var forældede inden de kom tilbage — og svarene kunne lande i den forkerte rækkefølge. Nu én. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3a02bc8304 |
fix(notes): søgningen svarede med det forkerte først, og et træk gjorde ingenting
Release / Build and publish image (push) Failing after 9m55s
**Søgningen sorterede efter dato.** Usynligt med ti noter og ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så `updated_at` siger ingenting, og loftet skar listen af ved halvtreds vilkårlige rækker. At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det fra brugerens plads er præcis dét. Nu bm25, med titlen vægtet ti gange kroppen: et ord i titlen er, hvad noten *handler om*, mens det samme ord én gang i en lang krop er en henvisning. `fold` vejer som kroppen, ellers ville et dansk match slå et præcist. Sammenføjet frem for `rowid IN (…)`, fordi rangeringen hører til matchet, og en underforespørgsel smider den væk. Fladen sorterer ikke om, når der er søgt — det ville kaste netop det væk, der gør en søgning i tolv hundrede noter brugbar. **Et træk af en note hen på et projekt gjorde ingenting.** `ondragover` tog imod både opgaver og noter, så rækken lyste op — og `ondrop` sendte kun opgaver videre, mens noten faldt ned i grenen, der omarrangerer projekter, hvor den ikke betyder noget. Den værst mulige form for i stykker: den så ud, som om den ville virke, helt indtil man slap. Det står i overleveringen som "virker i koden, men ikke fastholdt i prøver, fordi prøven blev upålidelig". Den er skrevet igen, og den ser på resultatet — ligger noten i projektet bagefter — frem for på om rækken blev fremhævet, hvilket var netop det, den gamle kunne se og som blev ved med at være sandt, mens funktionen var i stykker. **Og datoen i listen var altid `updated_at`.** For tolv hundrede importerede noter er det i aftes, så en note fra 2017 stod som "i går", og den dato, man sorterede efter, var ingen steder at se. Rækken viser nu den dato, listen er sorteret efter, og har begge i sit værktøjstip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4aa8bd6a40 |
fix(sikkerhed): resten af gennemgangen — SSRF, tre ubegrænsede veje, og fire hemmeligheder
Release / Build and publish image (push) Failing after 9m54s
**H2 — SSRF gennem AI-udbyderens adresse.** `base_url` blev gemt uden kontrol og hentet med en almindelig klient, og udbyderens svar blev givet tilbage ordret. Det var en fungerende skanner af det interne net for enhver bruger: peg den på en vært, læs de første tre hundrede bytes af det, der svarede. På en homelab er det det meste af, hvad der er værd at nå. Nu gennem `safedial`, og svarets krop går i loggen frem for i svaret. Operatøren har stadig brug for beskeden — for en lokal model er den som regel selve problemet — så den er der, bare kun for den, der kører instansen. **M2 — Web Push havde tre.** Endepunktet blev gemt ukontrolleret og POSTet til på en tidsplan, den, der ringer, selv vælger: sæt en påmindelse et minut ude, og serveren henter adressen, hvert minut. Upserten flyttede desuden ejerskabet med `user_id = excluded.user_id`, så enhver, der kendte en andens endepunkt, kunne overtage det — offerets telefon ville få angriberens beskeder og ingen af sine egne. Og `DeletePushSubscription` havde ingen ejer-kontrol. **H3 — tre veje uden loft.** FTS-udtrykket byggede omkring halvtreds bytes pr. ord uden grænse; en søgning på en kvart megabyte blev til to megabyte MATCH-udtryk og seksogtyve sekunder inde i SQLite, mens den holdt på én af kun fire til otte forbindelser. Nogle få af dem samtidig, og databasen er utilgængelig for alle på instansen, logins inklusive. `filterquery.Parse` er kvadratisk — halvtreds tusind led er syv sekunders CPU og seksogfyrre gigabyte allokering i én forespørgsel — og havde hverken loft på antal eller på dybde. Og noteimporten læste hver fil i arkivet uden grænse: 64 MB komprimeret kan blive til seksogtres gigabyte skrevet ned på den disk, databasen ligger på. **M1 — `comment_id` blev taget på tro.** Rettighedskontrollen så på opgaven i adressen, mens filen blev hængt på den kommentar, formularen bad om. En viewer på et delt projekt kan liste dets kommentarer, så hun kunne læse et id og lægge en fil under en andens kommentar i et projekt, hun kun må læse. **M4 — fire hemmeligheder lå i klartekst i hver sikkerhedskopi.** TOTP-koden og AI-nøglen forsegles nu; begge konverterer sig selv næste gang de skrives. Kalenderfeedets og mailadressens tokens gør ikke — de slås op på deres præcise værdi og vises i fladen mere end én gang, hvilket en hash ikke kan. Det står nu skrevet i `SECURITY.md`, som påstod det modsatte. At rette det betyder, at adressen kun kan vises én gang, og det er en beslutning om, hvordan funktionen virker. Og en prøve på, at politikken overhovedet bliver sendt. Det var den, der gjorde XSS-en i nat til indsat HTML frem for kørt script, og der var ingen prøve, der rørte den — en foranstaltning, ingen prøve rører, kan forsvinde i en oprydning, uden at nogen opdager det. Målt på script-src alene: `style-src 'unsafe-inline'` står der med vilje, og en indsat stil kan gøre en side grim, ikke køre kode. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
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> |
||
|
|
7828c1e6ed |
feat(gcal): Google Calendar kan læses, og kalenderen får sine egne tabeller
Klienten henter kalenderlisten og begivenhederne i et vindue, og laver Googles to former om til den ene, et gitter kan spørge om: dage i begge ender, begge med. Googles heldags-slutdato er eksklusiv, og et møde, der slutter ved midnat, slutter aftenen før — begge dele rettes ét sted frem for hos hver læser. Egne tabeller frem for `mailboxes`. De to ligner hinanden på afstand og er uenige om hver eneste kolonne bagefter: `trigger_kind`, `seen`, `last_uid` og `project_id` svarer alle på "hvad laver en besked om til en opgave", og intet her bliver til en opgave. Én konto rummer desuden mange kalendere, og en en-til-mange er der ikke plads til. Det afgørende er mindre og mere praktisk: `mailboxes.kind` har en CHECK, og SQLite kan ikke ændre en CHECK — en tredje værdi koster en tabelombygning med fremmednøgler slået fra. Derfor er der ingen CHECK på `provider` her: CalDAV skal koste en række, ikke en ombygning. |
||
|
|
9b4e7686f9 |
feat(notes): datoerne følger med, billederne bliver tegnet, og listen kan sorteres
Release / Build and publish image (push) Failing after 9m54s
Fire ting, som en rigtig import af tolv hundrede noter fandt på en time. **Billederne blev vist som ord.** `markdownToHtml` havde ingen regel for `` overhovedet, så en note fra Apple Noter viste sin egen billedadresse som tekst. Filen var hentet, gemt og hæftet på noten; det eneste, der manglede, var at nogen tegnede den. Kun vores egen adresse bliver til et billede — et `` fra en indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og en note er det sidste sted, man vil have en sporingspixel. Og vejen tilbage skulle med: uden `IMG` i inlineToMarkdown ville den første gemning have taget hvert billede ud af noten igen. **Titlen blev revet i stykker.** Noter deler en linje op i så mange stumper, den har brug for — "Aalborg Kommune" står som `<h1>A</h1><h1>alb</h1><h1>org Kom</h1><h1>mune</h1>`, fordi der er en attributkørsel bag hver stump — og eksporten læste et linjeskift ved hver af dem. Noten kom ud som fire overskrifter oven på hinanden, altså ødelagt på den mest synlige måde der findes: i sin egen titel. Samme rod gav `**M****øde**` for fed og `*M**øde*` for kursiv, hvor de to stjerner i midten læses som fed. Nu brydes der kun, når blokken skifter, og et mærke, der lukkes og straks åbnes igen, er ingenting. **Datoerne fandtes ikke.** Eksporten læste dem aldrig, og Markdown havde ingen plads til dem — så en flytning af tolv hundrede noter blev en bunke, der alle var skrevet samme aften. Rækkefølgen er halvdelen af, hvad et arkiv er. Nu står de i et YAML-hoved, som Obsidian og Bear allerede skriver, og importen læser det. `SetNoteTimes` frem for gennem SaveNote: `updated_at` betyder "hvornår skrev *dette* program noten sidst", og lod man enhver kalder sætte den, ville kolonnen betyde det, den sidste skriver havde lyst til. **Og listen kan sorteres** — senest rørt, oprettet, navn. Favoritter står øverst uanset hvad; det er hele meningen med at gøre en note til favorit. Eksporten henter nu i klumper. Ét kald pr. note tog halvanden time for tolv hundrede, ikke fordi Noter er langsom, men fordi hvert kald er en proces, der skal startes — og det gjorde det umuligt at rette en fejl i oversættelsen og se resultatet, for hver runde var en frokostpause. Antallet af bilag kom med i klumpen af samme grund: ni ud af ti kald var en proces startet for at få svaret nul. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
f6c60bdaf9 |
fix(notes): #garageristeriet og #GarageRisteriet er samme projekt
Release / Build and publish image (push) Failing after 9m58s
De var det ikke. Nøglen blev gemt præcis som skrevet og slået op på samme måde, så en note, der nævnte et projekt med lille begyndelsesbogstav, dukkede simpelthen ikke op på det — stille, og uden noget der antydede hvorfor. Foldet på vej ind og foldet i opslaget, så sammenligningen bliver ved med at være en nøjagtig lighed og bruger sit indeks. Havde man i stedet sænket kolonnen i forespørgslen, skulle hele tabellen læses. Kun projekter. En [[notetitel]] er en titel, ikke en nøgle: to noter, der hedder "Møde" og "møde", er to noter, og at folde dem sammen ville slå noget sammen, nogen havde holdt adskilt. Der er en prøve for begge dele. Migrationen samler de rækker, der allerede er skrevet. Primærnøglen er (note_id, kind, target_id), så en note, der skrev begge stavemåder, ville kollidere med sig selv — derfor bygges sættet forfra med INSERT OR IGNORE frem for en opdatering, der ville fejle på parret. Fladen viser projektets egen stavemåde og ikke den foldede nøgle. En etiket, ingen har skrevet, og som står hverken i noten eller på projektet, ville være værre end ingen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5e670119db |
feat(notes): titlen er første linje, og # foreslår projektet
Release / Build and publish image (push) Failing after 9m59s
Titlen var udledt én gang og derefter ladt i fred, så den gik i stå i det øjeblik nogen skrev begyndelsen om — en note med overskriften "Ny note" stod i listen som "fdgfgfgh", hvilket var, hvad dens første linje havde været nogle rettelser tidligere. Den udledes nu ved hver gemning, og ingen kan sætte den ved siden af teksten: kolonnen er en kopi af kroppen, ikke et felt derudover. Feltet er væk fra både HTTP-fladen og MCP-værktøjerne, for et felt, serveren ignorerer, er et løfte, den ikke kan holde. Skriver man #Kaf, foreslås #Kaffebaren. Ordet læses fra den tekstknude, markøren står i, og ikke fra hele dokumentet — en note kan nævne tyve projekter, og kun det under markøren er ved at blive skrevet. Listen ejer piletasterne og retur, mens den er åben, som enhver anden fuldførelse. Og under feltet står nu, hvad teksten forstår: # projekt, [[en anden note]], ⌘B, ⌘U. Samme idé som linjen under hurtig tilføjelse — en note er det andet sted i programmet, hvor det, man skriver, bliver læst for betydning. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
1bbae698d5 |
feat(notes): en note deles ved at ligge i et projekt
Ikke en adgangsliste af sin egen. Projekter har allerede medlemmer, roller og invitationer — alt sammen afprøvet, og alt sammen noget folk har lært her i forvejen. En anden måde at give adgang på er et andet sted at gøre det forkert, og de to ville være uenige den dag, den ene blev ændret. To ting, prøven fandt, som begge var rigtige fejl. Projektsiden viste kun noter, der NÆVNTE projektet, ikke dem, der lå i det — så en note, man lige havde delt derind, dukkede ikke op. Nu vises begge slags. Og noteoversigten viste kun dem uden projekt, så en note forsvandt fra listen i det øjeblik, den blev delt. Det fik deling til at se ud som sletning. Listen viser nu alt, man kan se: sine egne løse, og dem i de projekter, man er med i. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
98ea08b0bb |
feat(notes): fundamentet — Markdown, søgning og links begge veje
Release / Build and publish image (push) Failing after 10m3s
Første etape af noter. Tabel, CRUD, fuldtekstsøgning og de links, en note bærer. Markdown i en kolonne, ikke rich text og ikke et format af vores eget. Eksport var et krav, før der var skrevet en linje, og er den gemte form den eksporterede form, er der ingen konvertering tilbage at tabe noget i — noten kan læses af enhver anden editor den dag programmet ikke findes. Et #tag i en note er DET SAMME tag som i en opgave. Ikke en anden slags, der ligner. Det er hele svaret på "tag en note og se den i projektet": referencen er den samme, så der ikke opstår to systemer, brugeren selv skal holde i sync. Links gemmes som rækker, udledt af teksten ved hver gemning. Teksten er stadig sandheden; tabellen er et indeks over den. Uden det ville "hvad peger på denne opgave" betyde en gennemsøgning af al prosa, og den rude ville være for langsom til at lade stå åben. De rives ned og bygges op frem for at blive sammenlignet: en sammenligning er en anden beskrivelse af samme kendsgerning, og den kan komme til at være uenig med teksten. Noter ligger under projekter og arver deling, roller, papirkurv og historik. En mappestruktur ved siden af ville være ét hierarki for meget. MatchExpr fik navngivne kolonner. Den var skrevet til opgavernes og gav "no such column" mod noternes — hvilket i det mindste er højlydt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
86ec0ce2d9 |
fix(imap): markøren er vores, ikke serverens
Samme mail blev en opgave hver tiende minut, og markøren stod stille på 1271. Målt i produktion: en manuel synk lavede én opgave og flyttede ikke last_uid en eneste plads. Årsagen er ikke vores regnestykke, men hvad søgningen svarer. Vi beder om 1272:* — og en rigtig server læser det som 0:1272 og sender hele postkassen tilbage. Alt, hvad den finder, ligger så under markøren, og markøren kan derfor aldrig komme videre. Uid'er ved eller under markøren frasorteres nu hos os. Det koster ét gennemløb af nogle hundrede tal og afhænger ikke af nogen andens læsning af specifikationen. De to tidligere forsøg var ikke forkerte, de var bare ikke årsagen: at tage uid'en fra søgningen frem for fra fetch-svaret, og at give hver postkasse sin egen lås, var begge reelle fejl. Denne er den, der fik den til at gentage sig. Prøven læser en postkasse med markøren over alt i den og kræver nul tilbage — og gentager det for hver enkelt besked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
62498c2765 |
feat(sidebar): tallet ved et projekt er opgaver, og folden har en tast
"Hvorfor står der 2 ud for et tomt projekt?" Fordi tallet var member_count — hvor mange mennesker. Den samme grå kolonne betød tre ting: personer ved projekter, projekter ved grupper, opgaver ved etiketter. I en opgaveapp læses et tal ved siden af et projekt som opgaver, og det gjorde manden, der har skrevet appen. Nu er det åbne opgaver, og det vises når der er noget tilbage frem for når projektet er delt — som var den gamle betingelse, og grunden til at netop de tre delte havde et tal. Hvem der ellers er med står på projektets egen side. Tællingen hentes fra serveren igen, når opgaver flytter sig. Det ville være hurtigere at lægge til og trække fra lokalt, og det ville være forkert inden for en dag: en opgave flyttet mellem projekter, en lukket i en anden fane, en gentagende der lukker og åbner sig selv. Og sidebjælken kunne allerede foldes — der var bare kun en knap med et rektangel med en streg i, og det er ikke et ord. Nu gør ⌘B det samme, knappen siger det, og genvejssiden nævner det. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b0f78d1549 |
feat(gmail): Gmail flytter ind i postkasse-tabellen
Ét sted at spørge "hvilke postkasser har den her person". Gmails tokens, udløser, etiket, set-liste og målprojekt ligger nu som en række i mailboxes ved siden af dem, der læses over IMAP, og baggrundsjobbet fejer én gang i stedet for to og skifter vej efter rækkens kind. PKCE-værdierne bliver i user_settings. De hører til ét forsøg på at logge ind, ikke til en postkasse, og de skal væk igen når forsøget er ovre. Målprojektet fik sin egen kolonne, som IMAP-postkasser også får glæde af — en arbejdspostkasse, der filer i arbejdsprojektet, er det åbenlyse at ville have. Ikke en fremmednøgle: sletter man projektet, skal destinationen stille holde op med at gælde, ikke afvise sletningen eller tage postkassen med sig. Flytningen kunne først køre nu. Den tømmer indstillingsrækken, og havde den ligget i v0.14.0, mens handlerne stadig læste den, var en fungerende postkasse blevet koblet fra. Værdierne kopieres som chiffertekst — begge tabeller forsegler med samme nøgle — så migrationen behøver ikke nøglen, hvilket en migration heller ikke har noget at gøre med at holde. Der er en prøve for præcis dét: en Gmail forbundet før flytningen, med et forseglet token skrevet som indstillingstabellen skrev dem, kommer ud på den anden side med token, udløser, adresse, målprojekt og set-liste i behold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
30adc69cb5 |
feat(mailboxes): plads til Gmails udløser og dens set-liste
Kolonnerne, ikke flytningen. Gmail-handlerne læser stadig user_settings, og en migration, der tømte den tabel før de var skrevet om, ville koble en fungerende postkasse fra. Det additive først; flytningen når koden på den anden side er klar til den, og den ligger som PENDING_gmail_move.sql.txt uden for .sql-mønstret, så den ikke kan køre ved et uheld. Udløseren fik sin egen kolonne frem for at blive pakket ind i label med et kolon. En kolonne, der rummer to ting adskilt af et tegn, er en kolonne, nogen læser forkert senere — og en etiket med kolon i navnet ville gøre det på første dag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1e59886d86 |
feat(mailboxes): en tabel, så I kan have hver sin postkasse — og flere hver
Gmail lå i user_settings under en scope. Den tabel har user_id som hele primærnøglen, så den rummer præcis én scope pr. person, og dens upsert skriver scope-navnet om ved konflikt: at lægge IMAP ved siden af ville have slettet Gmail-forbindelsen uden at sige det. Migration 0011 advarer ordret mod netop det, og advarslen var tæt på at blive gået i for anden gang. Formen var forkert alligevel. Man har postkasser, flertal — en arbejds-Gmail og en privat iCloud er det almindelige tilfælde, ikke det sære. En række hver siger det. Og den svarer på spørgsmålet, der satte det i gang: hver postkasse hører til en bruger, så to mennesker på samme instans forbinder hver sin og ser kun sin egen. Det eneste, der bliver fælles, er Googles OAuth-klient, og det er rigtigt: den er appen, ikke kontoen. Kodeord og tokens skrives forseglet. Forseglingen i notifications.go dækker kun indstillingstabellerne, så denne tabels metoder kalder den i hånden — værd at vide, før der kommer en tredje hemmelig kolonne. Gmail bliver hvor den er indtil videre. Den virker, og at lægge en flytning af en kørende forbindelse i samme ombæring som en ny integration gør begge dele sværere at efterprøve. Skemaet har allerede plads til den. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
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> |
||
|
|
a52ad9817f |
feat(tasks): undo a completion, and a place to find what was closed
Reported from use: "I just closed a task by accident, and there is no undo and no list of closed tasks." Both halves were true. Completing is one click on a small circle beside a row you were only reading, and it takes the row off the screen — so the mistake and the evidence of it leave together. There was no name left to search for and nowhere to look: the project page can show its own closed tasks, but only if you already know which project it was in. The toast covers the mistake you notice at once. It names the task, so you can see which one you hit, and stands for twelve seconds rather than five — five is enough to read "could not save" and not enough to notice, decide and reach the mouse. A toast with an action also gets two targets: the message dismisses and the action acts, which were one button before and would otherwise put "yes, put it back" and "go away" in the same place. `/faerdige` covers the one you notice tomorrow. A place rather than a toggle in every view, because somebody hunting for a task they closed does not know which view they closed it in. Newest first — a record of what is finished is read backwards from now, not by a due date the task no longer has — and grouped by the day, because "I dag" and "I går" is how anybody would describe it. A recurring task is offered no undo. Completing it moves it to its next date rather than closing it, so there is nothing to reverse, and an undo that quietly rolled the date back would be a different act from the one it claims to be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0dbf7a40bf |
feat(tasks): write a task straight into a section
The box at the top puts things in the project with no section, which is right for capturing. But a project has sections because the work belongs somewhere, and dragging every new task down into the right one is a second gesture for something you already knew when you typed it. Two ways in, because they answer different moments. A field at the foot of each section and each board column — standing there is enough, no syntax to know. And `/section` in quick add, for the box at the top and for Today and Upcoming, where you are not standing in a section at all. The field goes through quick add rather than createTask, so a line written there still parses its dates, priorities and labels. The sigil needed care: '#' and '@' never turn up in a Danish task line by accident and '/' turns up constantly. It has to follow whitespace and be followed immediately by the name, which "km/t", "3/4" and "20 kr / stk" all fail. The bare form is greedy, because "Kunder/Ordrer" is a real section name and the sigil is inside it. A section id from the client is checked against the project the task actually landed in. A `#project` in the same line can move the task elsewhere, and the id would otherwise follow it into a project that section does not belong to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
726a489a67 |
fix(migrations): say why foreign keys go off, accurately
Release / Build and publish image (push) Failing after 10m0s
The note on 0009 and 0012 said the drop would cascade and take every row with it. It would not: nothing references `activity` or `attachments`, so dropping either takes nothing along. That claim was carried over from 0008, where seven tables do reference `tasks` and it is exactly right. The real reason is the check that comes with the marker. With foreign keys on, the copy is validated a row at a time against a half-rebuilt schema, and the first bad row aborts with no idea how many others are like it. With them off, `PRAGMA foreign_key_check` runs once before the commit and names what is actually wrong — which matters, because these run against a live database on somebody's next restart and a failed migration is a server that does not start. The upgrade test now carries activity rows, an attachment and a group across all five migrations rather than stopping at 0008, and checks that the columns added by ALTER arrived with their defaults rather than as NULL. Co-Authored-By: Claude Opus 5 <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>
|
||
|
|
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> |
||
|
|
04a7ba1024 |
feat(sidebar): headings that fold, a group head that is not indented, and colour where it is edited
Three things reported from use, all in the sidebar. Projekter, Delt med mig, Filtre and Etiketter fold, the way a project group already does. Kept on the account rather than in localStorage, and for the same reason the group's own `collapsed` is: folding a heading says "this is the part of my work I am not in right now", and that is as true on the laptop as on the desktop. The sidebar's *width* is the opposite case and stays per browser. A JSON array of keys rather than a column each, so a renamed heading is not a migration. The group heading is no longer indented — only what is filed under it. The colour dot sat as a sibling before the fold button and pushed the whole row in, and a heading that starts further in than the rows above it says the heading is inside something. And the colour is chosen while renaming. The dot was itself the button, which gave the heading four controls on a line narrow enough to crowd the name; both are edits to the same thing, so they belong in the same moment. The form closes on `focusout` and only when focus has left it entirely, and the swatches refuse focus on mousedown — Safari and Firefox do not focus a button on click, so `relatedTarget` would be null and the form would shut under the pointer. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
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> |
||
|
|
550f420c59 |
feat(sidebar): colours you can choose, and projects indented under their group
`projects.color` has been in the schema since the first migration, has defaulted to 'graphite' on every row ever written, and nothing has ever read it. There was no palette, no name-to-colour mapping, and the dot in the sidebar was hardcoded to a border grey. So this is not "add a picker"; it is implementing colour for the first time. Names, not hex values. A hex in ten thousand rows is a decision nobody can revisit; a name is resolved when the interface paints and can be retuned for a new theme or a new display. It is also the difference between a database that stores a fact and one that stores a rendering. The set is closed and refused at the edge — an unknown name would be stored happily and painted as the default, which looks like a colour that did not save. One palette across all five themes, and this is the one place in app.css that is not redefined per theme. Everything else there is text, a ground or a border and has to be measured against what sits behind it. These are small solid marks beside a label that already carries the meaning: what they owe is to be told apart, and to be visible on both a near-black and a white ground, which is why all ten sit in the mid-tone band. It also keeps them out of the trap the themes are in — a colour added to four blocks and forgotten in the fifth looks finished and falls back silently. There is no fifth block to forget. The list is written down three times: Go for validation, colors.js for the picker, app.css for the tokens that paint. So a test reads the other two — the same small-scanner approach openapi_test.go takes to the spec — and fails if the server accepts a colour nothing can draw, or the picker offers one the server refuses. Groups get a colour of their own (migration 0006) and projects inside a group are indented. The indent is to the group's *name*, not to its dot: the eye follows the text, and lining the projects up under the dot leaves them looking a step short of where the heading starts. The drop line is indented with them, so a drop inside a group reads as landing in the group. The group's dot is the control as well as the preview — you press the thing you are about to change, as on the project page. That was not the first design: a third text action, "Farve", sat beside Omdøb and Slet, and those carry `opacity: 0` rather than `display: none`, so they take their width whether or not you can see them. A group called "Arbejde" rendered as "ARBEJD". Moving the control onto the dot gave the name its room back. The smoke test measures the indent at the content edge rather than the element's, because the indent is padding and the box does not move — which is how the first version of that assertion passed 12 against 12. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
d37d240625 |
feat(search): find a task by its label
Search covered task text only, so "regnskab" found nothing with the task labelled @regnskab sitting in front of you — which reads as the search box having lost it. A label is often the only thing you remember. "That thing about the accounts" is a search for @regnskab, not for any word in the title. The label side is a plain lowercase match rather than a second FTS index. Labels are short, and their names are typed by the same person now searching for them, so there is no spelling to be generous about — the diacritic folding that earns its keep on task text does not earn a second index here. The join sits inside the WHERE the visibility rules already build, so somebody else's label cannot pull their task into your results. There is a test for that, because it is the kind of thing an OR is good at breaking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9056c44c9a |
feat(tasks): move, repeat, delegate and re-read a task from its drawer
Four things you could not do to a task you had open, and one that had never worked at all. **Project, recurrence and assignee** are now fields in the drawer. A task created in the wrong place could only be fixed by dragging it, a repeat could only be set by typing it into the title when the task was created, and delegation had no interface whatsoever despite assignee_id being in the API from the start. The assignee only appears on a project with somebody else in it, because on your own there is nobody to choose. **The title is read the way quick add reads it.** Typing "i morgen kl 14 p1" into a title used to leave it as those words, so where you typed a sentence decided what it meant. The parse runs on the server — the grammar is Danish and English and lives there — and applies only when it consumed something. What it took is said out loud, and the fields on screen are updated to match: the drawer otherwise seeds its inputs once per task so a save cannot overwrite what you are typing, and that rule has to bend exactly here, where the save changed the very fields in front of you. **And the member list has never worked.** The share panel, and now the assignee field, ask for it. Every call errored: the ORDER BY named an expression, and SQLite only accepts result-set columns across a UNION, so it failed at query time with "1st ORDER BY term does not match any column in the result set". Found by the smoke test that opens a task, which is the second real bug that suite has caught by watching for failed requests rather than only for what renders. Co-Authored-By: Claude Opus 5 <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> |