fe7ce5c853c5c5892c88f80c1435edce6d226aaa
18
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c4e480a0c4 |
fix(gmail): stop hiding why it failed, twice over
Release / Build and publish image (push) Failing after 10m6s
Reported from use: "Fetch now" said "something went wrong" and the error log was empty. Both halves were the same bug wearing two hats. `handleGmailSyncNow` answered 502 with the code `internal_error`, which the interface maps to a generic sentence — so Google's own words, which are the whole diagnosis, were replaced by one that says nothing. And `writeError` records nothing; only `s.internal` does. So there was no message to read and nowhere to look it up afterwards. `s.upstream()` is the answer: 502 because this is somebody else's service saying no rather than this server breaking, a code of its own so the interface can quote what was said, and a row in the error log — which is the entire reason that log exists. A Gmail sync failing quietly for a week is otherwise invisible: the background job writes a line the next restart erases. The same connection also left the mailbox unnamed. The address is learned at connect time and that call can fail; a warning went to the log and an empty string to the database, so the page said "an unknown account" and kept saying it. The only way back was to disconnect and reconnect, which nobody would guess. The status endpoint asks again when it finds none, so a wrong answer lasts until the next look rather than until somebody reconnects. Ten Danish strings were hiding in `confirm()` and `app.toast()` calls, including every destructive confirmation in the app — the worst possible set to leave in one language, since it is what somebody reads in the half-second before agreeing to lose something. The i18n guard reads markup and attributes, and a string argument lives in the script block. It reads those now, and found four my own grep missed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2b45aa33e1 |
feat(admin): ask the panel to restart, from verdande's own settings
Release / Build and publish image (push) Failing after 10m2s
A container cannot replace its own image from the inside, so "update" is really "ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on the way, which is what makes a restart an update at all. This removes the second browser tab, not the panel's authority: the panel still does the work and still records who asked. Three settings, needed together, and the page names whichever are missing — an operator reading "not configured" should not have to go and find out which of three. The token is never sent to a browser; the page is told only whether it is set. Answers 202 rather than waiting. The panel stops this container as part of answering, so the reply may never arrive, and a request cut off mid-flight is the successful case wearing a failure's clothes. The interface believes neither answer and polls /healthz until it responds. Administrators only, and sessions only. A leaked API token that could restart the server is a denial of service in one request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
19aa02e80c |
feat(projects): section headings you can count at a glance, and an Inbox that speaks the language
Two from use. Section headings were small uppercase text over a hairline — a heading you have to look for. A project with four sections is four groups, and the eye should be able to count them without reading a word, so the heading gets a ground of its own and the rows sit against the page. `--surface` rather than a tint of the accent: the colour on that page belongs to the project, and a section is not a second thing to colour. A step in lightness separates them in all five themes without anybody measuring anything. And the Inbox. The server names it at account creation, before anybody has seen a settings page, so it is the one project name nobody chose — and an account made in Danish went on saying "Indbakke" inside an English interface, which reads as a string somebody forgot to translate. It was. `projectName()` translates it while it still carries the name the server gave it; rename it and that is your name for it, in every language, like any other project. The rename field keeps the raw name, or editing a translated label would save the translation. The screenshot set gains the list view, which is where sections read as bands rather than as columns — and the shape most people actually work in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fe52818ab6 |
feat(auth): passkeys, as a way in and as a second factor
A password and a TOTP code are both shared secrets: the server keeps something that can be stolen from it, and the person keeps something that can be talked out of them. A passkey is neither. The private half never leaves the device, and the signature is bound to the origin that asked for it — so a convincing copy of the sign-in page at another address gets a signature it cannot use. Logging in asks for no email. The device knows which account its key belongs to, which is not only convenience: it means the sign-in page cannot be used to find out who has an account here. A key that verified its owner is both factors at once and completes the login; one that only proved possession still meets the TOTP step, exactly as a password does. The relying party id is derived from VERDANDE_BASE_URL rather than configured separately — a second setting that has to agree with the first is one that will one day disagree, and the failure is a login nobody can complete. An IP address is refused up front: no browser accepts one, and without the check the server issues a challenge happily and the browser throws SecurityError long afterwards. The list endpoint reports whether this deployment can offer them at all, so the interface never shows a button that cannot work. A challenge is stored server-side, answerable exactly once, and taken rather than read — two requests racing the same one would both succeed, which is the shape of a replay. A sign count that has gone backwards means the credential was cloned and is refused rather than logged. The Go tests cover who may reach these endpoints and that challenges are spent. The ceremony itself needs an authenticator, so the smoke test drives Chromium's virtual one over CDP — the only thing that can tell whether the base64url conversions are right, since one wrong byte produces a signature that will not verify and reads as a broken key rather than a broken encoder. That test runs at localhost in its own context while the suite stays on the loopback address, because WebAuthn refuses an IP and resolving localhost is slow enough here to cost the suite minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ab5e3e35a6 |
fix(i18n): three Danish strings that had no æ, ø or å in them
Spotted on the running instance: the version line read "Running v0.11.0. Der bliver ikke set efter opdateringer." — half translated, on one line. The sweep that was supposed to catch these grepped for Danish letters, and none of the three survivors contain one: "Ingen nyere version fundet.", "Ikke mere i dag." and a token hint split across two source lines so neither half looked like prose. So there is a test for it now. It reads the templates and looks for the words that give Danish away whatever letters they are spelled with — a word list rather than a language detector, because this ships as one static binary and thirty common words catch the sentences a person actually writes. It fails on the exact three that got through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d8b70918b |
feat(i18n): the interface speaks both languages
The account has had a `locale` since the beginning and it only chose which grammar quick add read a line with — so picking English changed nothing anybody could see, and the field said so honestly: "Sprog i hurtig tilføjelse". It says "Sprog" now, because it means it. Every component goes through `t()`. Danish is the source: a key missing from English falls through to Danish rather than to the key, because a Danish sentence in an English screen can be read and `settings.notifications.title` cannot. `tag()` is the single place `Intl` is asked. Before, `'da-DK'` was written into a dozen components, and that is the half of a translation that gets left behind — an English interface still saying "24. aug." is exactly the shape of a job half done. There is a smoke test that switches to English and refuses to see a Danish month name in the calendar's heading. Colour and theme names are keys now. `colors.js` keeps its ids untouched — they are what the database stores, and `internal/store` reads them out of that file — but `name` is `'color.tomato'`, resolved where it is drawn. The Inbox keeps its name. It is a project row the server creates at signup, not a string in the interface, and it can be renamed like any other project. Four copies of "3 hours ago" became one, in `web/src/lib/when.js`. They had already drifted: one said "aldrig logget ind", another "aldrig", and two stopped at days where the others carried on to a date. Routes stay Danish. `/indstillinger`, `/projekt`, `/uddelegeret` are bookmarks and they are in every smoke test; translating them buys nothing and costs both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
31bf0557df |
feat(gmail): the OAuth client can be entered instead of deployed
The one click that opens Google's consent screen already existed. What it needs behind it is a registered client, and no app can conjure one: Google issues no Gmail access to an unregistered client, and `gmail.readonly` is a restricted scope, so an id shipped inside a public image would be both extractable from it and unusable without Google's security review of every deployment using it. The registration is a one-off. Having to edit a Rune's manifest and recreate the container to paste the result was not. `VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` still win when set. A value in the environment is a deliberate deployment decision, applied again on every recreate, and a form that could quietly override it would make the manifest a lie — so the page says where the value came from, and PUT answers 409. The redirect URI is spelled out on the page rather than left to be reconstructed. It is derived from VERDANDE_BASE_URL, it has to match Google's copy exactly, and the error Google returns when it does not names neither value. Its own table rather than `user_settings`: an OAuth client is the instance's, not one person's — and `user_settings` has `user_id` as its whole primary key, so a second scope there would silently overwrite the AI provider's key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1da7ea64bf |
feat(admin): the nightly backup, made visible
It has run since the beginning and nothing showed it. A backup nobody can see is a backup nobody knows has been failing, and the failure is quiet: a volume gone read-only, a disk that filled, a container that has not been up at midnight all week. The first anybody hears of it is the day they need one. `ListBackupRuns` was already in the store and exposed nowhere. Now there is a list, a download, and a button that takes one now — the same work the nightly job does without its "one a day" check, because waiting until tonight to learn whether backups work at all is how somebody finds out on the day they need one, and it is the only way to get a snapshot from *before* something you are about to do. Restoring is deliberately not a button. Swapping a database under a server that is writing to it means stopping every writer, replacing the file and reopening, and a half-done version of that destroys the thing it was restoring. The panel says what to do instead. `RunBackupNow` turns out to have been a second copy of the backup logic, written for a button that did not exist yet, and the two had already drifted: it never created the backups directory and logged nothing. One implementation now. Administrators only, and sessions only. A backup is a complete copy of the database, so this hands out everybody's data in one file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
85f7b2488d |
feat(admin): what has been done on this server, across every project
The other half of the error log. That one is what broke; this is what was done, and nobody could ask it: `ListActivity` takes one project id, and an administrator is not necessarily a member of the project they need to look into. Paged with a keyset cursor rather than an offset. The log is written to constantly and read rarely, so a page fetched a minute after the one before it would be shifted by everything written in between — one row shown twice and the row behind it skipped, which is the single failure an audit log must not have. Both halves of the cursor matter: the log is written in whole seconds, so a timestamp alone cannot separate two rows written in the same one. Migration 0009 makes `activity.user_id` SET NULL for the same reason 0008 did it to `created_by`. A log whose rows vanish with the account they describe answers "what has happened here" with everything except the part somebody had a reason to remove. `project_id` keeps cascading on purpose: the trash is the undo, and past it the deletion was deliberate. The event names moved to `web/src/lib/events.js`, because two surfaces read the same log now and two copies of nineteen strings is two places for them to drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ebdc8eacf2 |
feat(admin): deleting an account stops deleting other people's work
`tasks.created_by` was ON DELETE CASCADE, so retiring a colleague removed every task they had ever written — including in projects somebody else owns. The interface said so in plain words before you pressed the button, which is a warning rather than a fix. It was the last remaining way to lose other people's work by accident. SET NULL instead: the task stays and loses its author, the same shape `assignee_id` and `completed_by` already have. SQLite cannot alter a foreign key action, so the table is rebuilt — and with foreign keys on, the drop in the middle of a rebuild is a cascading delete that takes every row referencing `tasks` with it. `PRAGMA foreign_keys` is a no-op inside a transaction, so migrations now run on a connection of their own, and a migration can opt out of enforcement with `-- verdande:rebuild-tables`. `PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a dangling reference fails the migration rather than writing it. The test fills a database at 0007, upgrades it, and counts. Without the marker it loses a task, a label link and a comment — which is exactly what it is for. The admin list now says two numbers instead of one: what disappears, and what stays behind unattributed. They are different sentences and the second is a reassurance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c2e2d66e76 |
feat(admin): keep server errors where a restart cannot take them
Release / Build and publish image (push) Failing after 12m56s
The panel's watcher reported four 5xx today — twice from one address at 11:49, twice from another at 10:45 — and there is no way to find out what any of them were. verdande logs the real cause of every 500, but on a Rune the log belongs to the container and the container is replaced on every restart. So an operator is told that something broke and given nothing to act on, which is the worst of both: alarming and useless. Every 500 now writes a row: what the handler was attempting in its own words, the error it hit, the method and path, who was asking, and the request id that ties it back to the log line for whoever still has it. The "what" is the part a status code cannot give — "list projects" and "move task" are the difference between a fault you can find and one you can only count. Written best-effort and never checked. This is the path that is already reporting a failure, and a diagnostic that can itself fail the request would turn one broken screen into two. Threading the request through `internal()` and `storeError()` touched 108 call sites across 19 files, which is the price of not having had it there from the start. It is mechanical and the compiler checked all of it. Kept for thirty days by the nightly sweep: long enough to answer "what happened last week", short enough that a fault loop cannot fill the volume the tasks live on. Read at Indstillinger → Fejl, administrators only — it carries paths, account names and the server's own error strings. The empty state says "Ingen fejl registreret. Det er den rigtige tilstand", because a blank panel should not read as a broken one. The test drops a table and asks for the endpoint that reads it: the same code path a disk error takes, and the row that comes back has to name the operation, the account and the request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e8ebd0fa78 |
feat(admin): a page for who is on the instance, and how somebody else gets on it
Release / Build and publish image (push) Failing after 10m0s
The README has promised "internal users are created by an admin" since the beginning and there was no such thing. There are exactly two ways an account has ever come into existence: the first admin at setup, and somebody accepting an invite to a project. So the only way to add a person was to share a project with them first. The store has been ready the whole time. CreateInvite already documents that "projectID may be empty, which invites somebody to the instance rather than to a particular project", and AcceptInvite already has the branch that grants no membership when there is none. requireAdmin has existed in the middleware and was used nowhere. This wires those three together and puts a page on them. An invite rather than an account with a password the administrator picks. A password typed here would be known to two people before it was ever used, and would then have to travel to the second one by some channel nobody has thought about. No name is asked for either — the signup form asks the person, and a name an administrator guesses is a name somebody has to correct. Two refusals keep the instance administrable: the last administrator cannot be demoted or deleted, and nobody can delete the account they are signed in as. Both are enforced on the server; the interface simply does not draw the buttons on your own row, because a button whose only purpose is to be refused wastes a click to teach a rule. The last-admin refusal gets its own error code, because "that already exists" is not what happened and this is the one refusal a person needs explained. Deleting an account is the only hard delete in this database, and building the confirmation is what turned up how wide it goes. `projects.owner_id` cascades, which is expected. But `tasks.created_by` cascades too — so removing a colleague deletes every task they ever wrote, including in projects somebody else owns. The count in the list says so, and the confirmation says it in words. Changing that to ON DELETE SET NULL, so the work stays and loses its author, means rebuilding the tasks table and its FTS triggers: worth doing, not worth doing quietly while adding a screen. It is written down in the store instead. Pending invites are listed and can be withdrawn. Only the token's hash is stored, so a link sent to the wrong address cannot be looked up and resent — withdrawing it is the only way to stop it before it expires. Six API tests for the guards and the counts, and two smoke tests: the whole loop from invite to a second browser creating the account and appearing in the list as somebody who is not an administrator, and the absence of buttons on your own row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4276892590 |
feat(auth): see where you are signed in, and end one of them
`last_seen_at` has been written on every request since sessions existed —
throttled to once a minute, deliberately, so a list could one day say
"denne enhed, lige nu" — and nothing has ever read it. The store even had
ListSessions. What was missing was two routes and a panel.
GET /auth/sessions lists them, most recently used first, with the current
one marked so nobody has to work out which row is the browser they are
reading it in. DELETE /auth/sessions/{id} ends one. Ending the current
session is allowed and is simply logging out: refusing it would be a rule
with no reason behind it, and somebody clearing every device wants this
one gone too.
Both are behind requireSession rather than the ambient auth, the same as
the API tokens. A leaked token that could list its owner's devices and end
their sessions turns a theft into a lockout.
The delete is scoped in the statement rather than checked and then run.
An id read in one step and deleted in the next is a window, and the whole
reason somebody is on this screen is that they think another person is in
their account.
The device names are a handful of substring tests, not a parsing library.
The question this answers is "is one of these me?", and for that a wrong
guess between two Chromium forks costs nothing while a dependency that has
to be kept current costs something every year. Order is the whole
correctness of it: an iPhone's user agent says Mac OS X, Edge says Chrome,
and Chrome says Safari. The raw string is sent alongside and sits in the
row's title, so anything the summary gets wrong is still visible — and
something that is not a browser at all shows its own name rather than
"Ukendt", because a CalDAV client is a real session.
Times are relative. The question is "was that me, just now?", and an
absolute timestamp makes the reader do the subtraction; past a week it
switches to a date, because "for 23 dage siden" is a number nobody can
place.
The smoke test signs in a second time in a second browser context, ends
that session from the first, and checks the other browser is really signed
out — and that this one is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1529c87231 |
feat(theme): five themes, and a picker that shows them
Release / Build and publish image (push) Failing after 9m58s
Two themes and a toggle between them. Now five, chosen from a grid under Settings → Konto: the two that existed, a warm dark (Skumring) with amber where the green was, a warm light (Papir), and a high-contrast one. Each swatch is painted in its own theme rather than described, so the choice is made by looking. data-theme sits on the swatch and not on the card — it redefines --ink too, and a card wearing a dark theme's ink on a light page has a name nobody can read. Every theme states its full palette rather than inheriting and patching. A partial theme looks finished until somebody adds a token, at which point four themes silently fall back to a colour from a fifth. That is also why the dark palette is repeated under [data-theme='dark']: :root carries it as the default and :root only ever matches <html>, so a swatch asking for it by name would otherwise show whatever the page was wearing. The theme-color meta is updated too, so an installed PWA's frame and a phone's address bar follow rather than staying whichever colour shipped in the markup. Contrast measured for all five. The new ones clear 4.5:1 down to the quietest tier — which is where dates and counts live, and the first thing to disappear. Kontrast reaches 8.9:1 there. The two original themes are left as they are: their faint ink sits at 3.1–3.8, which is worth revisiting, but not as a side effect of adding colours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a6e1bc39e |
feat(projects): rename, delete, and a trash you can actually reach
A project could be created and shared and never renamed or deleted. Both have worked through the API since the beginning; there was simply nothing to click. The heading is now the field — a heading that happens to be editable rather than a form that looks like one — and saves on blur, with Escape putting the old name back. Owner only, and the Inbox keeps its plain heading because it cannot be either renamed away or deleted. Deleting needed more than a button. It is soft, with thirty days to change your mind, and restoring has been possible the whole time — from an id, which is exactly what you no longer have once the project has gone from the screen. A recovery window nobody can reach is not one. So GET /trash/projects lists what you have deleted, with how many tasks went with it and how long is left, and Settings → Data has the button. Owned, not shared: a member of a project somebody else deleted has no business bringing it back, and there is a test that says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
78ec9b08a1 |
fix: say the true thing when Gmail, AI or mail is not set up
Two bugs with one shape: the interface confidently told you something that was not so. Connecting Gmail without a registered OAuth client said "Det er der allerede." Thirteen different situations answered a plain `conflict`, and the frontend renders that as "that already exists" — which for four of them is not vague but wrong. An unconfigured Gmail is not a Gmail that is already connected; a second factor that is off is not one that is on; the Inbox is not a project that already exists somewhere else. Those four have their own codes now, and their own sentences. The mail-to-task address came out as todo+…@localhost. VERDANDE_SMTP_FROM defaults to verdande@localhost and the domain was taken from it unconditionally, so an instance with no mail server handed out an address that looks real, can never receive anything, and gives no hint which of the two it is. The default no longer wins over the public hostname, and the response now carries whether a mail server exists at all — because even with the right domain, nothing routes to it until one does. errors.go has always claimed the code list is kept in one place "so the frontend's mapping can be checked against it". Nothing checked it, and the mapping was missing bad_request. Now a test reads both files and fails on a code with no message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e63d852847 |
feat(mcp): let the connector address carry its own key
verdande could not be added to Claude at all. The documented setup — POST /api/v1/mcp with an Authorization header — cannot be configured from Claude's custom-connector dialog, which asks for a name, a URL and OAuth client credentials and has no field for a bearer token. The instructions described something the interface does not offer. Worse, the obvious guess failed silently in the most misleading way available: /mcp was not a route, so the SPA fallback answered 200 with a page of HTML. The connector reported a successful connection and then failed to parse the response, which reads as a broken client rather than a missing endpoint. So /mcp exists now and takes its credential in the query string, exactly as the calendar feed does and for the same reason: a client that cannot be told to send a header means the address is the credential. The request logger records paths without their query strings, so the key does not reach the log. It is deliberately outside /api/v1 and accepts the key and nothing else. Reading a session cookie here would make it a state-changing POST outside the CSRF check — a cross-site request that acts as whoever is signed in — and there is a test that fails if that ever becomes true. The API path is unchanged for anything that can send a header. The tokens page now shows the connector address next to the token it belongs to, since that is the one moment the token exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9d9444d0ac |
feat(web): the settings surface
There was no /indstillinger route at all. The Gmail OAuth callback redirects there and had been answering 404 since it was written. Six pages rather than one long scroll, because the sections have nothing to do with each other and somebody opening API tokens should not have to walk past their own password to get there: the account and its second factor, notifications, integrations, AI, tokens, and data. Everything listed as having no interface in the handoff is reachable from one of them — the calendar feed, the mail-to-task address, Gmail, the AI provider, import, export, templates and the update notice. Two decisions worth naming. The AI key field starts empty and says why: a settings page that repopulates a password field is one that will eventually leak a key into a screenshot, and an empty value means "leave the stored one alone" rather than "clear it". And the form chrome lives in the layout as :global rules, because scoped styles do not reach a child route and six copies of the same twelve selectors is six places for them to drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |