b6cbc6cbc2731f32bc67af32b613da4d887feb26
14
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b6cbc6cbc2 |
fix(ai): din egen model kunne ikke sættes op — tre fejl i én oplevelse
Rapporteret som "AI gemmer ikke mine oplysninger". Anden gang det symptom meldes, og en helt anden årsag end sidst — dengang delte scopes i user_settings én række og slettede hinanden. Udbyderen hedder "OpenAI-kompatibel (Ollama, LM Studio, …)" og feltet foreslår selv http://localhost:11434/v1. safedial nægter præcis den slags adresser. Så den ene udbyder, der findes for at køre sin egen model, kunne ikke sættes op, og formularen foreslog en adresse, serveren ville afvise. Serveren svarede 422 med en fejl på base_url. Formularen havde ingen plads til at vise en fejl på det felt, og fordi fejlobjektet ikke var tomt, blev der heller ikke vist en toast. Beskeden forsvandt, og det så ud, som om der ikke skete noget ved at trykke Gem. Tre rettelser: - Private adresser tillades for admins. Muren står, fordi en server, der henter en brugerangivet adresse, lader den, der angiver den, nå ting han ellers ikke kan. En admin på en selvhostet instans ejer allerede værten og dens netværk, så det giver ham intet nyt. For en inviteret bruger er den lukket som før. Tilladelsen regnes ud pr. forespørgsel, ikke gemt ved siden af adressen — den, der ikke længere er admin, skal ikke kunne nå indenfor på et gammelt flag. - Tailnet er ikke "indenfor". Halvdelen virkede allerede ved et tilfælde: Gos IsPrivate dækker RFC1918 og ikke det CGNAT-område, Tailscale låner, så 100.x slap igennem uden at nogen havde besluttet det. IPv6-halvdelen gjorde ikke — fd7a:115c:a1e0::/48 ligger inde i fc00::/7. Samme maskine var altså tilgængelig eller ej alt efter adressefamilie. Begge områder er nu navngivet, hvilket gør IPv4-halvdelen bevidst frem for heldig. - Feltfejl kan ikke længere forsvinde. base_url og model har fået et sted at vise en, og en fejl på et felt, der ikke har et, råbes op som en toast frem for at blive tabt. |
||
|
|
dcb71f4c14 |
feat(calendar): abonnement på flere kalendere, ICS og webcal
Release / Build and publish image (push) Failing after 10m3s
Bedt om som "abonnering af flere kalendere (ics og webcal?)". Det er også den eneste vej for en privat konto: instansens OAuth-klient er Internal, og Google afviser en konto udefra med `org_internal`, uanset hvad der ellers er sat op. **Samme tabeller.** Et abonnement er én kalender med begivenheder i, og det er præcis det, `calendars` og `calendar_events` allerede beskriver — med vinduet, farven, `shown` og hele fejemaskinen omkring sig. En tabel til ville være en anden vej til det samme sted, og to veje til ét sted er to steder at rette den dag noget om begivenheder ændrer sig. Det, der skulle laves om, var identiteten. En OAuth-konto er én pr. person og udbyder — tokenet er identiteten — mens et abonnement er én pr. adresse, og man har flere. `calendar_accounts_once` er derfor delvist nu: det holder for rækkerne uden adresse, og et andet indeks holder adresserne unikke. Delvise indekser er den ene ting, SQLite kan her, som ellers krævede en tabelombygning. **`webcal://` skrives om til `https://`.** Det er ikke en protokol, noget taler — det er en konvention, der får et skrivebord til at give adressen til kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres links sådan. At afvise den ville være at afvise folk på en teknikalitet. **Adressen gennem `safedial`, begge halvdele.** Afvist hvor den skrives ned, så den, der står med feltet, får svaret med det samme — og igen på den *opslåede* adresse hver gang den hentes, fordi et navn, der blev godkendt ved indtastningen, kan svare 127.0.0.1, når fejemaskinen kører klokken tre om natten. Det er den sætning, `internal/safedial` selv står og advarer om, og den blev skrevet efter et SSRF-fund i morges. **Hentet én gang, før den gemmes.** En adresse, der ikke kan læses, er en tastefejl langt oftere end en server, der tilfældigvis er nede, og at sige det, mens nogen står med feltet, er de to sekunder værd. Et abonnement, der lydløst aldrig fyldes, er den samme tavshed, notifikationerne havde. Læseren er ny i `internal/ics`, som hidtil kun kunne skrive. To ting, den med vilje ikke gør, står skrevet ved den: den udfolder ikke gentagelser, og den opløser ikke tidszoner. Begge dele er større end de ser ud, og begge har ét sted at høre til den dag de skal. En ting, formatet lærte: DTEND er eksklusiv for en heldagsbegivenhed. Gemmes den, som filen skriver den, dækker hver eneste helligdag også dagen efter sig. Én adresse, der fejler, stopper ikke de andre. Et abonnement er en anden persons server, og den ordinære tilstand for en anden persons server er, at den er i orden indtil en morgen hvor den ikke er. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fcd6dca852 |
fix(push): "der kommer ikke nogen" kunne ikke besvares
Release / Build and publish image (push) Failing after 10m0s
Alt om det var usynligt. Indstillingssiden sagde "til for denne enhed", så snart der fandtes et abonnement — også når hver eneste push blev afvist. Og en afvist push gik i en logline på **Debug**, som ikke skrives på en instans, der kører på standardniveau. Så en levering, der fejler lydløst, og en levering, der aldrig sker, så ens ud, og forskellen er hele diagnosen. Tre forskellige problemer havde ét udseende: browseren tilmeldte sig aldrig, tjenesten afviste den, eller der var ikke noget forfaldent endnu. **En prøveknap** under Indstillinger → Notifikationer sender én notifikation gennem den rigtige rute — ikke en genvej, så det, den beviser, er det, der faktisk skal virke — og siger hvad hver enhed gjorde ved den. Kom den ikke frem, og afviste ingen den, står der, at det så er browseren eller styresystemet, der holder den tilbage. Nu peger svaret på ét af de tre steder. Loglinjen er hævet fra Debug til Warn. Det var den ene linje, der ville have sagt hvorfor. Teksten kommer fra klienten, fordi ordbøgerne gør. En server, der bærer sin egen kopi af "det her er en prøve", bærer den på ét sprog. **Og en lækage, prøven fandt.** Go pakker en transportfejl som `*url.Error`, og dens tekst er `Post "https://fcm.googleapis.com/fcm/send/dQw4…": dial tcp: …` — hele endepunktet, som er den legitimation, push-tjenesten udstedte til den enhed. Den var på vej ud i svaret, i loglinjen og i ethvert skærmbillede af nogen af dem. Prøven var skrevet for at bevise, at kun værten blev nævnt, og det var sådan den blev fundet. Fejlen pakkes ud, og adressen skrubbes bagefter alligevel: den næste fejltype, der bærer den med, kommer ikke med en advarsel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cab4e703f5 |
fix(sikkerhed): CalDAV lod en viewer skrive, og Web Push kunne ringe indad
Release / Build and publish image (push) Failing after 10m0s
To huller fundet ved en gennemgang efter dagens arbejde. Begge har en prøve, der er set fejle før rettelsen. CalDAV PUT afgjorde adgang på det projekt, klienten *nævnte i adressen*, og tog derefter opgavens id fra den samme adresse. Intet bandt de to sammen. En viewer på et delt projekt kunne pege på et projekt, de selv ejer — indbakken, som alle har og alle må redigere — og lægge den delte opgaves id i filnavnet. Projekt- tjekket gik godt på indbakken, og skrivningen landede på en opgave, de kun må læse. GetTask lukkede det ikke: den svarer "må denne person se det", og det må en viewer. `CanEdit` er spørgsmålet, og det er det samme, handleUpdateTask altid har stillet — API-døren har spurgt hele tiden, CalDAV-døren spurgte ikke. Opgaven skal nu også ligge, hvor adressen siger. Uden det kan en redaktør på to projekter nå det ene gennem det andets adresse; intet eskaleres af det i sig selv, men en adresse, der har lov at lyve om hvor noget bor, er sådan sagen ovenfor kom ind. Web Push blev leveret med en almindelig http.Client. Adressen kommer fra browserens abonnement — altså fra den, der er logget ind — og serveren POSTer til den på et skema, de selv vælger: sæt en påmindelse et minut ude, og jobbet går igen hvert minut. Tilmeldingen afviser en privat adresse, og den kontrol er ikke nok alene: den kører på URL'en som skrevet, og et navn, den godkender, kan svare 127.0.0.1 næste gang det slås op. Det er præcis den sætning, `internal/safedial` selv står og advarer om. Leveringen går gennem safedial nu, som tjekker den opslåede adresse ved forbindelsen og hvert viderestillingshop på samme måde. Kontrollen ved tilmelding bliver, fordi den siger nej, mens nogen kigger på skærmen. Og en hærdning: /inbound/mail har fået et loft. Det er den svagere af de to offentlige skriveveje — beaconet skriver en række for en fremmed, denne skriver ingenting uden en 256-bit adresse, man skal kende i forvejen — men adressen deles med mailservere og står på en indstillingsside, og når den først er kendt, var der intet loft på, hvad der kunne laves med den. Tallet er sat generøst: modtageren er mailserveren, én adresse der leverer det hele. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
95378a1199 |
docs: hand over the day, and what is still open
The list this session started with is empty, and so is the one that grew during it — eleven items reported by somebody using the app while it was being built, taken in the order they arrived. Two traps worth the next session's time, both found the hard way. A Rune's manifest `version` has to be bumped when its variables change: the panel keys its catalogue on that number, so new fields with an unchanged version are invisible, the upload succeeds, and nothing reports it. And the restart button's `confirm()` cannot be driven from automation — a browser modal blocks the renderer — which is the point, but it means an agent testing it has to ask a person to press OK. Three things are reported and not fixed: Gmail showing "an unknown account" because the profile call fails into a log line that a restart erases, an unidentified "something went wrong" on the integrations page, and three Danish strings in a script block that the i18n guard cannot see because it only reads markup. "Det næste, jeg selv ville tage" was three items that are now done. It says what is actually next. Co-Authored-By: Claude Opus 5 <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> |
||
|
|
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> |
||
|
|
afc46a5c20 |
fix(api): do the things the API says yes to
Release / Build and publish image (push) Failing after 9m58s
Three reports from somebody driving the app through the MCP connector, all the same shape: an argument accepted, an OK returned, and nothing done. **project_id on an update never moved the task.** Both the REST handler and the MCP tool read it, both checked the caller's rights on the destination — which reads as "understood" — and then neither passed it on, because store.TaskUpdate has no such field: a project change is a move, since sort_order is per project. So the task stayed where it was and the answer said OK. Moving a task between projects was impossible except by creating it again and closing the old one, which is what the person ended up doing. Both paths now go through one helper, so they cannot drift apart again. **An unknown #project vanished without a word.** The parser reads "#3Dekoration" correctly; the lookup then fails, the task goes to the Inbox, and the name is gone from the title with nothing anywhere saying why. Still not an error — a thought should not be thrown away over a typo — but quick-add now returns unknown_project, and the MCP tool returns a note written for a model to relay. The old behaviour was kind in the web interface, where you see the row appear in the wrong place and fix it. In a chat nobody sees the row. **Sub-tasks were unreachable over MCP.** create_task had no parent_id, though the REST API has had it all along, so importing a parent with seven children produced eight unrelated tasks with the parent's name prefixed onto each. A sub-task joins its parent's project — the two cannot disagree — and a second level is refused rather than silently flattened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3f4af6c0ab |
fix: stop the app shell answering for /.well-known/
Release / Build and publish image (push) Failing after 9m56s
Adding the connector to Claude failed with "Couldn't register with Verdande's sign-in service", on a server that has no sign-in service and needs none. Claude probes /.well-known/oauth-authorization-server before connecting to an MCP server. The SPA fallback answered 200 with a page of HTML — which reads as "yes, there is an authorization server here" — so it went on to dynamic client registration and failed there instead of concluding the obvious thing, which is that this server does not use OAuth and the key in the URL is the whole credential. /.well-known/ is reserved by RFC 8615 and is never a route in this app. A 404 is the true answer, and it is also the useful one: the client stops asking and uses the URL as given. This is the third time the catch-all has caused a confusing failure by saying 200 to something that does not exist — /mcp itself was the second. The fallback should hand back the shell for routes the app might have, not for every path on the server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
5546e191ac |
feat(gmail,update): finish the OAuth2 flow, and tell admins about new releases
Gmail now connects. Authorisation-code flow with PKCE, the verifier kept server-side rather than in a cookie — the callback is a top-level navigation from Google, and a SameSite=Lax cookie is the kind of thing that works until a browser tightens its defaults. State is checked, the attempt expires after ten minutes, and the spent verifier is deleted rather than left at rest. Scope is gmail.readonly and the sync is one-way on purpose. Unstarring a message does not delete the task; completing the task does not touch the mailbox. Two-way would mean deciding what happens when both sides changed, and the only honest answer is a sync model far larger than the feature. The query carries newer_than:30d so connecting an account with a decade of starred mail does not create a decade of tasks. The update check is off unless the operator turns it on, asks GitHub at most every six hours, and sends nothing about the instance — no identifier, no version, no counts. A development build never reports an update: somebody running their own tree is ahead of every release, and telling them otherwise trains them to ignore the notice. Only administrators see it, because only they can act on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b19ed062d |
feat(phase5): MCP server, CalDAV, mail-to-task, Gmail settings and an AI layer
The MCP server speaks JSON-RPC over Streamable HTTP with the six tools the brief names, authenticated by a personal API token — so a connector reaches exactly what its owner reaches and there is no second identity to reason about. A failed tool call returns a tool error, not a protocol error: a model can recover from the first and can do nothing with the second. CalDAV serves VTODO, two-way, so Apple Reminders and Thunderbird can read, create and complete tasks. Clients authenticate with Basic using an API token as the password, never the account password — a client stores what it is given, and what it is given should not be able to sign in to the app. Completed tasks stay in the collection: a client that stops seeing one treats it as deleted and drops its own record of having done it. The AI layer is one interface with adapters for Anthropic, OpenAI, Google and any OpenAI-compatible endpoint. That last one is why the abstraction exists: an integration that only spoke to a hosted API would be useless to somebody running their own models. Every feature degrades to "off" when nothing is configured, and the stored API key is never returned to the settings page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |