Compare commits

..
7 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 a00cf39558 fix(gmail): "hent nu" svarer selv, i stedet for at lade Cloudflare gøre det
Release / Build and publish image (push) Failing after 9m59s
En postkasse, der ikke svarer, holdt hele forespørgslen åben. Efter hundrede
sekunder gav Cloudflare op og sendte sin egen 502-side tilbage — HTML uden
fejlkode, så fladen kunne kun sige "Noget gik galt", og fejlloggen var tom,
fordi handleren aldrig nåede at vende tilbage.

Synk får nu sit eget budget på femogtyve sekunder. Løkken tjekker fristen mellem
hver besked, og hver opgave gemmes efterhånden som den laves, så en overskredet
frist er en kortere kørsel og ikke en tabt. Baggrundsjobbet beholder den fulde
tid; det har ingen, der venter.

Endepunkterne kan nu pege et andet sted hen, så testen kan stille en postkasse
op, der aldrig svarer, og se hvor lang tid der går. Budgettet kan sættes, så
den prøve koster et sekund frem for femogtyve.

Et budget på nul betyder standarden — ikke ingen tid. Uden det ville en
opsætning, der ikke satte feltet, lade fristen udløbe før første kald og gøre
hver eneste synk til et stilfærdigt nul.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 13:43:48 +02:00
Kristian WindandClaude Opus 5 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 <[email protected]>
2026-08-18 13:28:44 +02:00
Kristian WindandClaude Opus 5 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 <[email protected]>
2026-08-18 13:21:56 +02:00
Kristian WindandClaude Opus 5 e004696362 fix(rune): bump the manifest version, or the new variables never appear
The three panel settings were added to the manifest in v0.12.0 and `version` was
left at 2. The panel keys its catalogue on that number, so a manifest with new
fields and an unchanged version is one it has no reason to re-read — the server's
settings page simply does not offer them, and nothing reports it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 12:30:43 +02:00
Kristian WindandClaude Opus 5 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 <[email protected]>
2026-08-18 12:18:35 +02:00
Kristian WindandClaude Opus 5 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 <[email protected]>
2026-08-18 12:02:56 +02:00
Kristian WindandClaude Opus 5 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 <[email protected]>
2026-08-18 11:56:54 +02:00
69 changed files with 2794 additions and 72 deletions
+3
View File
@@ -197,6 +197,9 @@ to put one that the operator would ever see.
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
## What lives in `/data`
+12
View File
@@ -60,6 +60,18 @@ own mailbox from **Settings → Gmail**. The redirect URI is derived from
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
All three together let **Indstillinger → Notifikationer** restart this instance,
which is also how it picks up a new version: a container cannot replace its own
image, so the panel recreates it and pulls `:latest` on the way. Any one of the
three alone does nothing, and the page names the ones that are missing.
The token is a panel credential with control over that server. Anybody who can
read this environment can restart it — which is true of anybody with the host
anyway — and it is never sent to a browser.
Off unless you turn it on. When on, verdande asks the public GitHub releases API
at most every six hours and sends nothing about your instance — no identifier, no
+276
View File
@@ -388,6 +388,195 @@ paths:
"204": { description: Off. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/passkeys:
get:
tags: [auth]
summary: The keys registered on this account.
description: |
A passkey is a key on a device rather than a secret in a head. 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.
One row per credential, because a person has a laptop and a phone and losing
one must not mean losing the way in. The name is theirs to write: a list
nobody can read is a list nobody revokes from.
Sessions only, like `/tokens` — a leaked API token must not be able to add
its own way in, which would turn a theft into a tenancy.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
passkeys:
type: array
items: { $ref: "#/components/schemas/Passkey" }
available:
type: boolean
description: |
Whether this deployment can offer passkeys at all. False when
`VERDANDE_BASE_URL` is an IP address or otherwise not a
domain an authenticator will accept — the interface asks once
rather than discovering it from a 503 it caused by offering a
button.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/auth/passkeys/register/begin:
post:
tags: [auth]
summary: Start registering a key. Returns the options and a challenge id.
description: |
Registration is done by somebody already signed in: you cannot be handed a
way into an account you have not proved you own.
The challenge is kept server-side rather than in a cookie, because the whole
point of it is that the server chose it and remembers choosing it. It is
answerable once and expires after five minutes.
responses:
"200":
description: Pass `options` to `navigator.credentials.create()`.
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
options: { type: object, additionalProperties: true }
"503":
description: |
`VERDANDE_BASE_URL` is not an address an authenticator will accept. 503
rather than 404: this version has passkeys, this deployment cannot offer
them, and those are different things to fix.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/api/v1/auth/passkeys/register/finish:
post:
tags: [auth]
summary: Finish registering a key.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
name:
type: string
description: What to call it in the list — "min bærbare".
credential:
type: object
additionalProperties: true
description: The response from `navigator.credentials.create()`.
responses:
"201":
description: Registered.
content:
application/json:
schema: { $ref: "#/components/schemas/Passkey" }
"400":
description: The attempt expired, was already used, or did not verify.
"503":
description: Passkeys are unavailable on this instance.
/api/v1/auth/passkeys/{passkeyID}:
parameters:
- { name: passkeyID, in: path, required: true, schema: { type: string } }
patch:
tags: [auth]
summary: Rename a key.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
responses:
"204": { description: Renamed. }
"404": { $ref: "#/components/responses/NotFound" }
delete:
tags: [auth]
summary: Remove a key.
description: |
Scoped in the SQL rather than checked first: an id read in one step and
deleted in the next is a window, and the reason somebody is on this screen
is usually that they think a device is gone.
responses:
"204": { description: Removed. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/auth/passkey/login/begin:
post:
tags: [auth]
summary: Start signing in with a key. No session, and no email.
description: |
A discoverable login: nobody has said who they are yet, and that is the
point. The device knows which account its key belongs to, so there is no
address to type and no list of accounts for anybody to probe.
responses:
"200":
description: Pass `options` to `navigator.credentials.get()`.
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
options: { type: object, additionalProperties: true }
"429": { $ref: "#/components/responses/RateLimited" }
"503":
description: Passkeys are unavailable on this instance.
/api/v1/auth/passkey/login/finish:
post:
tags: [auth]
summary: Finish signing in with a key.
description: |
A key that verified its owner — a PIN, a fingerprint, a face — is both
factors at once, and completes the login outright. One that only proved
possession still stops at the TOTP step, exactly as a password does.
A counter that has gone backwards means the credential has been cloned. It
is refused rather than logged: that counter is the only signal this design
gives.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
challenge_id: { type: string }
credential:
type: object
additionalProperties: true
description: The response from `navigator.credentials.get()`.
responses:
"200":
description: Signed in, or `totp_required` when the key was possession only.
content:
application/json:
schema:
type: object
properties:
user: { $ref: "#/components/schemas/User" }
totp_required: { type: boolean }
"401":
description: |
One message for an unknown key and for a bad signature. Telling them
apart is a way to learn which credentials exist here.
"429": { $ref: "#/components/responses/RateLimited" }
"503":
description: Passkeys are unavailable on this instance.
/api/v1/auth/sessions:
get:
tags: [auth]
@@ -1148,6 +1337,67 @@ paths:
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/panel:
get:
tags: [auth]
summary: Whether this instance can restart itself. Administrators only.
description: |
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 says whether the three settings are present. The token is never sent:
the page needs to know whether the button can work, not what the credential
is. When it cannot, the missing variables are named — an operator reading
"not configured" should not have to go and find out which of three.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
configured: { type: boolean }
panel_url: { type: string }
missing:
type: array
items: { type: string }
description: The environment variables that are not set.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/panel/restart:
post:
tags: [auth]
summary: Ask the panel to recreate this container. Administrators only.
description: |
The panel does the work and records who asked; this removes the second
browser tab, not the panel's authority.
Answers 202 rather than waiting: the panel stops this container as part of
answering, so the reply may never arrive. A request cut off mid-flight is
the successful case wearing a failure's clothes — the interface polls
`/healthz` rather than believing either answer.
Sessions only, like the rest of this group. A leaked API token that could
restart the server is a denial of service in one request.
responses:
"202":
description: The panel accepted, or was already restarting this container.
content:
application/json:
schema:
type: object
properties:
restarting: { type: boolean }
note: { type: string }
"403": { $ref: "#/components/responses/Forbidden" }
"502":
description: |
The panel refused. Usually a token without control of this server.
"503":
description: The three settings are not all present.
/api/v1/errors:
get:
tags: [auth]
@@ -3127,6 +3377,32 @@ components:
same thing once decoded, and telling "put it nowhere" apart from "I
did not mention it" is what makes this a PATCH.
Passkey:
type: object
properties:
id: { type: string }
name:
type: string
description: What the owner called it. "min bærbare" is what makes a list reviewable.
aaguid:
type: string
description: |
Identifies the authenticator model, which is what lets a list say
something recognisable beside a key somebody named months ago.
discoverable:
type: boolean
description: |
Whether the credential lives on the device. Only these can start a login
with no email typed first.
user_verified:
type: boolean
description: |
Whether the authenticator verified the person — a PIN, a fingerprint, a
face. One that did is both factors at once; one that only proved
possession is a first factor and still meets the TOTP step.
created_at: { type: string, format: date-time }
last_used_at: { type: string, format: date-time }
ProjectGroup:
type: object
properties:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 167 KiB

+11 -2
View File
@@ -3,21 +3,30 @@ module github.com/kristianwind/verdande
go 1.26.3
require (
github.com/coder/websocket v1.8.15
github.com/go-chi/chi/v5 v5.3.1
github.com/go-webauthn/webauthn v0.17.4
github.com/pquerna/otp v1.5.0
github.com/teambition/rrule-go v1.8.2
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.56.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/teambition/rrule-go v1.8.2 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
+28 -2
View File
@@ -2,12 +2,27 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -18,6 +33,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -25,10 +42,17 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
@@ -39,6 +63,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+30
View File
@@ -280,3 +280,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string {
}
return code
}
// The relying party is derived from the instance's own base URL, and the two ways
// that goes wrong are both silent until a browser refuses.
func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
for _, c := range []struct {
base string
ok bool
why string
}{
{"https://verdande.example.dk", true, "a real domain"},
{"http://localhost:5173", true, "localhost, which every browser excepts"},
{"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
// An IP address is not a domain. The browser throws SecurityError long
// after the server has happily issued a challenge, so it is refused here
// and the endpoints answer 503 instead.
{"http://127.0.0.1:8080", false, "an IPv4 literal"},
{"http://[::1]:8080", false, "an IPv6 literal"},
{"", false, "no address at all"},
{"not a url", false, "not an address"},
} {
_, err := NewWebAuthn(c.base, "verdande")
if c.ok && err != nil {
t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
}
if !c.ok && err == nil {
t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package auth
import (
"encoding/base64"
"fmt"
"net"
"net/url"
"strings"
"github.com/go-webauthn/webauthn/webauthn"
)
// Passkeys, wrapped so the rest of the app never touches the protocol types.
//
// 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.
//
// That binding is also the one thing that can be got wrong here, and it is
// invisible when it is: the RP ID has to be the registrable domain of the address
// people actually visit. Get it wrong and registration works, login works on your
// machine, and everybody behind a different hostname is quietly locked out.
// WebAuthn is the relying party — this instance, as an authenticator sees it.
type WebAuthn struct{ *webauthn.WebAuthn }
// NewWebAuthn derives the relying party from the instance's own base URL.
//
// Derived rather than configured, because a second setting that has to agree with
// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and
// the failure is a login that cannot be completed, reported as "the button does
// nothing".
func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL)
}
// The RP ID is the host without the port. A port in it is rejected by every
// browser, and localhost:5173 during development is exactly where somebody
// would first put one.
rpID := parsed.Hostname()
// An IP address is not a domain, and no browser will accept one as an RP ID —
// `SecurityError: This is an invalid domain`, thrown in the browser, long after
// the server has happily issued a challenge. Refused here instead, so the
// endpoints answer 503 and the interface never offers a button that cannot
// work. `localhost` is the exception every browser makes, and it is what
// development runs on.
if ip := net.ParseIP(rpID); ip != nil {
return nil, fmt.Errorf(
"auth: passkeys need a domain name, and %q is an IP address — "+
"set VERDANDE_BASE_URL to the address people actually visit", rpID)
}
w, err := webauthn.New(&webauthn.Config{
RPID: rpID,
RPDisplayName: displayName,
// The origin is the full scheme://host:port, which is what the browser
// sends and what the signature is checked against.
RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")},
})
if err != nil {
return nil, fmt.Errorf("auth: passkeys: %w", err)
}
return &WebAuthn{w}, nil
}
// A PasskeyUser is what the library needs to know about an account.
//
// The handle is the account id rather than anything a person chose. It is written
// into the credential on the device and cannot be changed afterwards, so it must
// not be an email address: changing your email would orphan every key you own.
type PasskeyUser struct {
ID string
Email string
Name string
Credentials []webauthn.Credential
}
func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) }
func (u *PasskeyUser) WebAuthnName() string { return u.Email }
func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name }
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes
// go in a BLOB well enough, but the id is also compared against what the browser
// sends back as base64url, and one encoding on both sides is one thing to be
// wrong about.
func EncodeCredentialID(raw []byte) string {
return base64.RawURLEncoding.EncodeToString(raw)
}
func DecodeCredentialID(encoded string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(encoded)
}
+30
View File
@@ -38,6 +38,32 @@ type Config struct {
// the deal its operator made by self-hosting.
UpdateCheck bool
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
// from its own interface rather than from another browser tab.
//
// A container cannot replace its own image from the inside. Restarting is the
// panel's job — it recreates the container and pulls `:latest` on the way — so
// what this needs is a way to ask. All three are required together; any one
// alone cannot do anything.
//
// The token is a panel credential with control over that server. Anybody who
// can read this environment can restart it, which is true of anybody with the
// host anyway. It is never sent to a browser: the settings page is told only
// whether it is set.
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
// front of this server. Configurable so a test can prove the budget is applied
// without spending the real one on every run.
GmailSyncBudget time.Duration
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
// in production, where it means Google.
GmailTokenURL string
GmailAPIURL string
PanelURL string
PanelToken string
PanelServerID string
// TrashRetention is how long a soft-deleted task or project stays recoverable.
TrashRetention time.Duration
@@ -69,6 +95,10 @@ func Load() (*Config, error) {
Dev: envBool("VERDANDE_DEV", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
GmailSyncBudget: 25 * time.Second,
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
+44 -7
View File
@@ -25,20 +25,50 @@ import (
)
const (
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
tokenEndpoint = "https://oauth2.googleapis.com/token"
apiBase = "https://gmail.googleapis.com/gmail/v1"
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
// Read-only, and only mail. Not profile, not contacts, not send.
Scope = "https://www.googleapis.com/auth/gmail.readonly"
)
// Endpoints is where Google is.
//
// A field rather than a constant so a test can point it at a server it controls.
// There is no other way to exercise what happens when Gmail is slow or refuses —
// and "the sync comes back before whatever sits in front of this server gives up
// on it" is exactly the kind of promise worth a test and impossible to make
// against the real thing.
//
// The zero value is Google, so nothing outside a test has to know it exists.
type Endpoints struct {
Token string
API string
}
func (e Endpoints) token() string {
if e.Token != "" {
return e.Token
}
return defaultTokenEndpoint
}
func (e Endpoints) api() string {
if e.API != "" {
return e.API
}
return defaultAPIBase
}
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
// the instance rather than to a person: one registration, any number of users.
type Config struct {
ClientID string
ClientSecret string
RedirectURL string
// Endpoints is Google unless a test says otherwise.
Endpoints Endpoints
}
func (c Config) Configured() bool {
@@ -128,7 +158,7 @@ func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error)
}
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
strings.NewReader(form.Encode()))
if err != nil {
return Token{}, err
@@ -171,6 +201,7 @@ func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
type Client struct {
accessToken string
endpoints Endpoints
http *http.Client
}
@@ -178,6 +209,12 @@ func NewClient(accessToken string) *Client {
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
}
// At points the client at another server. Tests only; the zero value is Google.
func (c *Client) At(e Endpoints) *Client {
c.endpoints = e
return c
}
// Message is the part of a Gmail message a task is made from.
type Message struct {
ID string
@@ -196,7 +233,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
var parsed struct {
EmailAddress string `json:"emailAddress"`
}
if err := c.get(ctx, apiBase+"/users/me/profile", &parsed); err != nil {
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
return "", err
}
return parsed.EmailAddress, nil
@@ -220,7 +257,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
ID string `json:"id"`
} `json:"messages"`
}
if err := c.get(ctx, apiBase+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
return nil, err
}
@@ -251,7 +288,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
} `json:"headers"`
} `json:"payload"`
}
if err := c.get(ctx, apiBase+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
return Message{}, err
}
+28
View File
@@ -573,6 +573,34 @@ func inboxName(locale string) string {
// 11:49" long after the line explaining it has gone, which tells an operator that
// something broke and gives them no way to find out what. The row outlives the
// container; Indstillinger → Fejl is where it is read.
// upstream reports a failure that came from somebody else's service.
//
// Separate from `internal` because the cause is different and so is the fix: a 500
// is this server broken, and this is Google — or whoever — saying no. The status
// says so (502), and the message is passed through rather than replaced, because
// "invalid_grant" or "insufficient authentication scopes" is the whole diagnosis
// and a generic sentence in its place throws it away.
//
// It is recorded like a 500 for exactly the reason the error log exists: a Gmail
// sync that has been failing for a week is invisible otherwise. The background job
// writes a log line the next restart erases, and nobody is watching the one screen
// that would have said so.
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
s.log.Warn(what, "err", err)
userID := ""
if u := userFrom(r.Context()); u != nil {
userID = u.ID
}
s.db.RecordError(r.Context(), store.ServerError{
Method: r.Method, Path: r.URL.Path, Status: http.StatusBadGateway,
What: what, Message: err.Error(), UserID: userID,
RequestID: middleware.GetReqID(r.Context()),
})
writeError(w, http.StatusBadGateway, code, err.Error())
}
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
s.log.Error(what, "err", err)
+3 -1
View File
@@ -46,7 +46,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
BaseURL: "http://127.0.0.1",
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
// party id, and every browser makes the same exception for localhost.
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
+7
View File
@@ -51,6 +51,13 @@ const (
// is not "that already exists" either, and it is the one refusal on the user
// page that a person needs explained rather than merely reported.
CodeLastAdmin = "last_admin"
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
// "insufficient authentication scopes" — and they are useless behind a generic
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
// read "something went wrong", and had nowhere to go. The interface wraps the
// message in one that says where it came from.
CodeGmailFailed = "gmail_failed"
)
// errLastAdmin is returned to the caller that already wrote the response, so it
+90 -4
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"time"
@@ -10,6 +11,11 @@ import (
"github.com/kristianwind/verdande/internal/store"
)
// defaultGmailSyncBudget bounds one "fetch now" when nothing else says otherwise.
// Well under the hundred seconds a proxy is willing to wait, so this server is the
// one that answers a slow mailbox.
const defaultGmailSyncBudget = 25 * time.Second
// gmailConfig is the OAuth client this instance authorises with.
//
// The environment wins when it is set. A value in the environment is a deliberate
@@ -17,11 +23,20 @@ import (
// applied on every recreate — and a form that could quietly override it would make
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
// to redeploy a container to paste a client id.
// gmailClient is the API client, pointed wherever the config says Google is.
func (s *Server) gmailClient(accessToken string) *gmail.Client {
return gmail.NewClient(accessToken).At(gmail.Endpoints{
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
})
}
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
cfg := gmail.Config{
ClientID: s.cfg.GmailClientID,
ClientSecret: s.cfg.GmailClientSecret,
RedirectURL: s.cfg.GmailRedirectURL(),
Endpoints: endpoints,
}
if cfg.ClientID != "" && cfg.ClientSecret != "" {
return cfg
@@ -150,7 +165,10 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
return
}
email, err := gmail.NewClient(token.AccessToken).Profile(r.Context())
// Best effort: a mailbox that connects but cannot name itself is still
// connected, and the status endpoint asks again when it finds no address —
// so this failing is a delay rather than a dead end.
email, err := s.gmailClient(token.AccessToken).Profile(r.Context())
if err != nil {
s.log.Warn("gmail profile", "err", err)
}
@@ -182,9 +200,36 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
created, err := s.SyncGmail(r.Context(), user)
// A budget of its own, well under what sits in front of this server.
//
// The sync fetches up to twenty-five messages one at a time, each with its own
// thirty-second timeout — so a slow mailbox can hold the request open for
// minutes. Cloudflare gives up at a hundred seconds and answers with its own
// 502 page, which is HTML: the browser gets no JSON, no error code and no
// message, and shows "something went wrong". Nothing reaches the error log
// either, because this handler never returned.
//
// Whatever was created by the deadline is kept: every task is committed as it
// is made, so a short budget is a shorter run rather than a lost one. The
// background job keeps the full one; it has nobody waiting.
// An unset budget is not a budget of nothing: a zero here would expire the
// context before the first call and make every sync a silent no-op.
budget := s.cfg.GmailSyncBudget
if budget <= 0 {
budget = defaultGmailSyncBudget
}
ctx, cancel := context.WithTimeout(r.Context(), budget)
defer cancel()
created, err := s.SyncGmail(ctx, user)
if err != nil {
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
if errors.Is(err, context.DeadlineExceeded) {
// Not a failure: the mailbox is slow or busy, some tasks were made, and
// the rest arrive on the next run ten minutes from now.
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
return
}
s.upstream(w, r, CodeGmailFailed, "sync gmail", err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"created": created})
@@ -229,7 +274,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
}
}
client := gmail.NewClient(access)
client := s.gmailClient(access)
ids, err := client.List(ctx, query, 25)
if err != nil {
if err == gmail.ErrUnauthorized {
@@ -269,6 +314,11 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
if seen[id] {
continue
}
// A spent budget ends the run rather than turning into twenty-five failed
// fetches logged one at a time.
if ctx.Err() != nil {
return created, ctx.Err()
}
msg, err := client.Get(ctx, id)
if err != nil {
s.log.Warn("gmail get message", "err", err, "id", id)
@@ -425,3 +475,39 @@ func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
// gmailProfile fetches the mailbox address and stores it, refreshing the access
// token first if it has expired.
//
// Shared by the connect callback and the status endpoint. The callback is the
// natural place to learn the address, and it is also the one moment that cannot be
// retried — so the status endpoint asks again when it finds nothing, and the answer
// corrects itself on the next look.
func (s *Server) gmailProfile(ctx context.Context, user *store.User, settings map[string]any) (string, error) {
str := func(key string) string {
v, _ := settings[key].(string)
return v
}
access := str("access_token")
expires, _ := settings["expires_at"].(float64)
if access == "" || int64(expires) <= time.Now().Unix() {
token, err := s.gmailConfig(ctx).Refresh(ctx, str("refresh_token"))
if err != nil {
return "", err
}
access = token.AccessToken
settings["access_token"] = access
settings["expires_at"] = token.ExpiresAt.Unix()
}
email, err := s.gmailClient(access).Profile(ctx)
if err != nil {
return "", err
}
settings["email"] = email
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
return "", err
}
return email, nil
}
+159
View File
@@ -0,0 +1,159 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
)
// A Gmail failure has to survive being reported.
//
// It used to be hidden twice over. The handler answered 502 with the code
// `internal_error`, which the interface maps to "something went wrong" — so
// Google's own words, the only thing that says what to do, were replaced by a
// sentence that says nothing. And `writeError` records nothing, so the error log
// was empty: a person clicked Fetch now, read a generic failure, and there was
// nowhere to look it up afterwards.
func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
// A Gmail that refuses, the way an expired grant does.
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
}))
defer google.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GmailTokenURL = google.URL + "/token"
cfg.GmailAPIURL = google.URL
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// Connected, with a refresh token that will be refused.
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
"refresh_token": "spent", "trigger": "starred",
}); err != nil {
t.Fatal(err)
}
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
resp.StatusCode)
}
if body["code"] != "gmail_failed" {
t.Errorf("code = %v, want gmail_failed — `internal_error` is mapped to a generic "+
"sentence, which throws the diagnosis away", body["code"])
}
if msg, _ := body["error"].(string); msg == "" {
t.Error("the message is empty; Gmail's own words are the whole diagnosis")
}
// And it is in the error log, which is the point of having one: a sync that has
// been failing for a week is invisible otherwise.
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
rows, _ := logged["errors"].([]any)
if len(rows) == 0 {
t.Fatal("nothing was recorded; the failure is as invisible as it was before")
}
row := rows[0].(map[string]any)
if row["what"] != "sync gmail" {
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
}
if row["status"] != float64(http.StatusBadGateway) {
t.Errorf("status = %v, want 502", row["status"])
}
}
// A slow mailbox must not hold the request open until something in front of this
// server gives up on it.
//
// This is what was actually happening in production: each Gmail call has its own
// thirty-second timeout, and the sync makes up to twenty-five of them one after the
// other. Cloudflare stopped waiting at a hundred seconds and answered with its own
// 502 page — HTML, so the browser got no code, no message and no error-log row, and
// showed "something went wrong". The handler had never returned.
func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
// A Gmail that never answers.
blocked := make(chan struct{})
defer close(blocked)
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-blocked:
case <-r.Context().Done():
}
}))
defer google.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GmailTokenURL = google.URL + "/token"
cfg.GmailAPIURL = google.URL
// A second rather than the real twenty-five: what is being tested is that a
// budget is applied at all, and spending the production one on every run
// would put half a minute into the suite to learn the same thing.
cfg.GmailSyncBudget = time.Second
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// A token that has not expired, so the run goes straight to the message list
// rather than stopping at a refresh.
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
"refresh_token": "r", "access_token": "a", "trigger": "starred",
"expires_at": time.Now().Add(time.Hour).Unix(),
}); err != nil {
t.Fatal(err)
}
// Its own client, because the shared one gives up after ten seconds — which is
// shorter than the budget being tested and would prove nothing either way.
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/gmail/sync", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
started := time.Now()
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
if err != nil {
t.Fatalf("the request never came back (%v after %s); the sync has no budget of "+
"its own, and whatever sits in front of this server answers for it",
err, time.Since(started).Round(time.Second))
}
defer resp.Body.Close()
took := time.Since(started)
// Cloudflare stops waiting at a hundred seconds. Anything close to that is the
// bug, whatever status eventually arrives.
// Comfortably over the one-second budget and far under any proxy's patience.
if took > 10*time.Second {
t.Errorf("the sync took %s; the budget is not being applied",
took.Round(time.Second))
}
if resp.StatusCode >= 500 && resp.StatusCode != http.StatusBadGateway {
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
}
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
}
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u
}
+19 -1
View File
@@ -379,9 +379,27 @@ func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
v, _ := values[key].(string)
return v
}
// The mailbox address, fetched again if it is missing.
//
// It is stored at connect time, and the call that fetches it can fail — a
// slow token, a scope Google had not applied yet. That failure used to be a
// log line and an empty string, so the page said "an unknown account" and went
// on saying it forever: the only way back was to disconnect and reconnect,
// which nobody would guess. Asking again here costs one request on a page
// nobody opens often, and it means the wrong answer lasts until the next look
// rather than until somebody reconnects.
email := str("email")
if email == "" && str("refresh_token") != "" {
if fetched, err := s.gmailProfile(r.Context(), userFrom(r.Context()), values); err == nil {
email = fetched
} else {
s.log.Warn("gmail profile", "err", err)
}
}
writeJSON(w, http.StatusOK, gmailSettings{
Connected: str("refresh_token") != "",
Email: str("email"),
Email: email,
Trigger: str("trigger"),
Label: str("label"),
})
+37 -1
View File
@@ -1172,7 +1172,7 @@ func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
}
// The single most likely thing to be wrong, so it is spelled out rather than
// left for somebody to reconstruct.
if empty["redirect_uri"] != "http://127.0.0.1/oauth/gmail/callback" {
if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
}
@@ -1280,3 +1280,39 @@ func TestTheGmailClientIsAdminsOnlyAndSessionsOnly(t *testing.T) {
t.Errorf("with an admin's API token: status %d, want 403", resp.StatusCode)
}
}
// Rotating the mail-to-task address, which reported "something went wrong" in
// production with nothing in the error log — the combination that means the 500 did
// not come through `s.internal()`.
func TestTheMailAddressCanBeRotated(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, before := ts.do(t, "GET", "/api/v1/mail-address", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get: status %d, body %v", resp.StatusCode, before)
}
first, _ := before["address"].(string)
if first == "" {
t.Fatal("no address to begin with")
}
resp, after := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rotate: status %d, body %v", resp.StatusCode, after)
}
second, _ := after["address"].(string)
if second == "" || second == first {
t.Errorf("the address did not change: %q → %q", first, second)
}
// And twice in a row, which is what somebody does when the first one appeared
// to fail.
resp, again := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("second rotate: status %d, body %v", resp.StatusCode, again)
}
if third, _ := again["address"].(string); third == second {
t.Error("the second rotation returned the same address")
}
}
+105
View File
@@ -0,0 +1,105 @@
package httpapi
import (
"context"
"fmt"
"net/http"
"time"
)
// Asking the panel to restart this instance.
//
// 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.
//
// The alternative was a second browser tab: read the version here, go to the
// panel, find the server, press Restart. This removes the tab, not the panel's
// authority — the panel still does the work and still records who asked.
//
// Administrators only, and sessions only. A leaked API token that could restart
// the server is a denial of service in one request, and the whole point of the
// session rule elsewhere in this file is that a stolen token must not be able to
// do things only a person at a keyboard should.
type panelStatusJSON struct {
// Configured is all three settings being present. The token itself is never
// sent: the page needs to know whether the button can work, not what the
// credential is.
Configured bool `json:"configured"`
PanelURL string `json:"panel_url,omitempty"`
// Why it cannot work, when it cannot. An operator reading "not configured"
// should not have to go and find out which of three things is missing.
Missing []string `json:"missing,omitempty"`
}
func (s *Server) panelStatus() panelStatusJSON {
var missing []string
if s.cfg.PanelURL == "" {
missing = append(missing, "VERDANDE_PANEL_URL")
}
if s.cfg.PanelToken == "" {
missing = append(missing, "VERDANDE_PANEL_TOKEN")
}
if s.cfg.PanelServerID == "" {
missing = append(missing, "VERDANDE_PANEL_SERVER_ID")
}
return panelStatusJSON{
Configured: len(missing) == 0,
PanelURL: s.cfg.PanelURL,
Missing: missing,
}
}
func (s *Server) handlePanelStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.panelStatus())
}
func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) {
status := s.panelStatus()
if !status.Configured {
writeError(w, http.StatusServiceUnavailable, CodeInternal,
fmt.Sprintf("this instance cannot restart itself: %v are not set", status.Missing))
return
}
user := userFrom(r.Context())
s.log.Info("restart requested from the interface", "user", user.ID, "server", s.cfg.PanelServerID)
// A short timeout of its own. The panel stops this container as part of
// answering, so the reply may never arrive — that is not a failure, and a
// request that hung waiting for it would look like one.
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
defer cancel()
url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
s.internal(w, r, "build panel request", err)
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
// Including "the panel killed us mid-reply", which is the successful case
// wearing a failure's clothes. The interface polls /healthz rather than
// believing either answer, so this only has to be honest about what it saw.
s.log.Warn("panel restart call did not complete", "err", err)
writeJSON(w, http.StatusAccepted, map[string]any{
"restarting": true,
"note": "the panel did not answer; it may already be restarting this container",
})
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
s.log.Error("panel refused the restart", "status", resp.StatusCode)
writeError(w, http.StatusBadGateway, CodeInternal,
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"restarting": true})
}
+131
View File
@@ -0,0 +1,131 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/kristianwind/verdande/internal/config"
)
// Restarting is administrators only, and sessions only. A leaked API token that
// could restart the server is a denial of service in one request.
func TestRestartingIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
for _, path := range []string{"/api/v1/panel", "/api/v1/panel/restart"} {
method := "GET"
if path != "/api/v1/panel" {
method = "POST"
}
if resp, _ := other.do(t, method, path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("restart with an admin's API token: status %d, want 403", resp.StatusCode)
}
}
// Unconfigured, it says which of the three settings are missing rather than
// "not configured" — an operator should not have to go and find out which.
func TestAnUnconfiguredPanelSaysWhatIsMissing(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
if status["configured"] != false {
t.Fatalf("configured = %v on an instance with no panel settings", status["configured"])
}
missing, _ := status["missing"].([]any)
if len(missing) != 3 {
t.Errorf("missing = %v, want all three settings named", missing)
}
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("restart with nothing configured: status %d, want 503", resp.StatusCode)
}
if body["error"] == nil {
t.Error("the refusal says nothing about why")
}
}
// Configured, it calls the panel with a bearer token and reports what came back.
func TestARestartCallsThePanelWithItsToken(t *testing.T) {
var gotPath, gotAuth string
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer panel.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.PanelURL = panel.URL
cfg.PanelToken = "hemmelig"
cfg.PanelServerID = "abc-123"
})
ts.bootstrap(t)
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
if status["configured"] != true {
t.Fatalf("configured = %v with all three set", status["configured"])
}
// The token is a credential and must never reach a browser.
for key, value := range status {
if s, ok := value.(string); ok && s == "hemmelig" {
t.Errorf("the panel token was sent to the client in %q", key)
}
}
resp, _ := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("restart: status %d, want 202", resp.StatusCode)
}
if gotPath != "/api/servers/abc-123/restart" {
t.Errorf("called %q", gotPath)
}
if gotAuth != "Bearer hemmelig" {
t.Errorf("Authorization = %q", gotAuth)
}
}
// A panel that refuses is reported as the panel refusing, not as this server
// breaking: the fix is a token with control of that server, and saying "internal
// error" would send somebody looking in the wrong place.
func TestAPanelThatRefusesIsReportedAsSuch(t *testing.T) {
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer panel.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.PanelURL = panel.URL
cfg.PanelToken = "forkert"
cfg.PanelServerID = "abc-123"
})
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != http.StatusBadGateway {
t.Errorf("a refusing panel: status %d, want 502", resp.StatusCode)
}
if msg, _ := body["error"].(string); msg == "" {
t.Error("the refusal does not say what to check")
}
}
+404
View File
@@ -0,0 +1,404 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/store"
)
// Passkeys: a key on a device instead of a secret in a head.
//
// Four endpoints, in two pairs. Registration is done by somebody already signed in
// — you cannot be handed a way into an account you have not proved you own.
// Logging in is done by somebody who is not, and is the one place here that is
// reachable without a session.
//
// Each ceremony is two requests because the browser has to talk to the
// authenticator in between: the server issues a challenge, the device signs it,
// the server checks the signature against what it remembers issuing. The challenge
// lives in the database rather than in a cookie, because the whole point of it is
// that the server chose it and remembers choosing it.
const challengeTTL = 5 * time.Minute
// passkeyAvailable answers 503 rather than 404 when the relying party could not be
// built. 404 would read as "this version does not have passkeys"; the truth is
// that this deployment cannot offer them, which is a different thing to fix.
func (s *Server) passkeyAvailable(w http.ResponseWriter, r *http.Request) bool {
if s.passkeys != nil {
return true
}
writeError(w, http.StatusServiceUnavailable, CodeInternal,
"passkeys are unavailable on this instance: VERDANDE_BASE_URL is not an address an authenticator will accept")
return false
}
type passkeyJSON struct {
ID string `json:"id"`
Name string `json:"name"`
// AAGUID identifies the authenticator model, which is what lets the list say
// something recognisable beside a key somebody named three months ago.
AAGUID string `json:"aaguid,omitempty"`
Discoverable bool `json:"discoverable"`
UserVerified bool `json:"user_verified"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
}
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
keys, err := s.db.ListPasskeys(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "list passkeys", err)
return
}
out := make([]passkeyJSON, 0, len(keys))
for _, k := range keys {
j := passkeyJSON{
ID: k.ID, Name: k.Name, AAGUID: k.AAGUID,
Discoverable: k.Discoverable, UserVerified: k.UserVerified,
CreatedAt: k.CreatedAt.Format(time.RFC3339),
}
if !k.LastUsedAt.IsZero() {
j.LastUsedAt = k.LastUsedAt.Format(time.RFC3339)
}
out = append(out, j)
}
// Whether this deployment can offer them at all, alongside the ones that exist.
// The interface asks one question rather than discovering the answer from a 503
// it triggered by offering a button — an address an authenticator refuses is a
// deployment fact, not an error somebody caused.
writeJSON(w, http.StatusOK, map[string]any{
"passkeys": out, "available": s.passkeys != nil,
})
}
// --- registering a key ------------------------------------------------------------
func (s *Server) handleBeginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
user := userFrom(r.Context())
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
if err != nil {
s.internal(w, r, "load passkeys", err)
return
}
// Resident keys, and the authenticator asked to verify its owner. Both are
// what make this a *passkey* rather than a second factor: only a discoverable
// credential can start a login with no email typed, and only a verified one
// stands in for the password rather than beside it.
creation, session, err := s.passkeys.BeginRegistration(account,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementPreferred,
UserVerification: protocol.VerificationPreferred,
}),
// Excluding what they already have, so a second registration on the same
// device replaces nothing and reports itself instead of silently making a
// duplicate.
webauthn.WithExclusions(account.excludeList()),
)
if err != nil {
s.internal(w, r, "begin passkey registration", err)
return
}
s.issueChallenge(w, r, user.ID, "register", session, creation)
}
type finishRegistrationRequest struct {
ChallengeID string `json:"challenge_id"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
func (s *Server) handleFinishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
var req finishRegistrationRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
session, ok := s.takeChallenge(w, r, req.ChallengeID, "register", user.ID)
if !ok {
return
}
parsed, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(string(req.Credential)))
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
return
}
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
if err != nil {
s.internal(w, r, "load passkeys", err)
return
}
credential, err := s.passkeys.CreateCredential(account, *session, parsed)
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that key could not be verified")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = "Passkey"
}
key := &store.Passkey{
UserID: user.ID,
CredentialID: auth.EncodeCredentialID(credential.ID),
PublicKey: credential.PublicKey,
AAGUID: auth.EncodeCredentialID(credential.Authenticator.AAGUID),
SignCount: credential.Authenticator.SignCount,
Discoverable: credential.Flags.BackupEligible || parsed.Response.AttestationObject.AuthData.Flags.HasBackupState(),
UserVerified: credential.Flags.UserVerified,
Name: name,
}
if err := s.db.CreatePasskey(r.Context(), key); err != nil {
s.internal(w, r, "store passkey", err)
return
}
writeJSON(w, http.StatusCreated, passkeyJSON{
ID: key.ID, Name: key.Name, AAGUID: key.AAGUID,
Discoverable: key.Discoverable, UserVerified: key.UserVerified,
CreatedAt: key.CreatedAt.Format(time.RFC3339),
})
}
// --- signing in with a key ---------------------------------------------------------
func (s *Server) handleBeginPasskeyLogin(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
// Discoverable: nobody has said who they are yet, and that is the point. The
// device knows which account its key belongs to, so there is no email to type
// and no list of accounts for anybody to probe.
assertion, session, err := s.passkeys.BeginDiscoverableLogin()
if err != nil {
s.internal(w, r, "begin passkey login", err)
return
}
s.issueChallenge(w, r, "", "login", session, assertion)
}
type finishLoginRequest struct {
ChallengeID string `json:"challenge_id"`
Credential json.RawMessage `json:"credential"`
}
func (s *Server) handleFinishPasskeyLogin(w http.ResponseWriter, r *http.Request) {
if !s.passkeyAvailable(w, r) {
return
}
var req finishLoginRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
session, ok := s.takeChallenge(w, r, req.ChallengeID, "login", "")
if !ok {
return
}
parsed, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(string(req.Credential)))
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
return
}
// Which account, resolved from the credential the device signed with. The
// handler is given the raw id and returns the owner; the library checks the
// signature against that owner's stored public key.
var matched *store.Passkey
credential, err := s.passkeys.ValidateDiscoverableLogin(
func(rawID, userHandle []byte) (webauthn.User, error) {
key, err := s.db.PasskeyByCredentialID(r.Context(), auth.EncodeCredentialID(rawID))
if err != nil {
return nil, err
}
matched = key
owner, err := s.db.UserByID(r.Context(), key.UserID)
if err != nil {
return nil, err
}
return s.passkeyUser(r, owner.ID, owner.Email, owner.Name)
}, *session, parsed)
if err != nil || matched == nil {
// One message, as with a wrong password: telling "no such key" apart from
// "bad signature" is a way to learn which credentials exist here.
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
return
}
// A counter that goes backwards means the credential has been cloned. Not
// every authenticator keeps one, so zero on both sides is silence rather than
// suspicion — but a real counter that has gone down is the one signal this
// design gives, and it is refused rather than logged.
if credential.Authenticator.CloneWarning {
s.log.Warn("passkey clone warning", "passkey", matched.ID, "user", matched.UserID)
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
return
}
owner, err := s.db.UserByID(r.Context(), matched.UserID)
if err != nil {
s.internal(w, r, "load account", err)
return
}
if err := s.db.TouchPasskey(r.Context(), matched.ID, credential.Authenticator.SignCount); err != nil {
s.log.Warn("record passkey use", "err", err)
}
s.loginLimiter.reset(clientIP(r))
// A verified key is both factors at once: the device proved possession and the
// person proved they were there, which is exactly what a password plus a code
// is for. One that only proved possession still stops at the code.
pending := owner.TOTPEnabled && !credential.Flags.UserVerified
ttl := s.cfg.SessionTTL
if pending {
ttl = 10 * time.Minute
}
token, _, err := s.db.CreateSession(r.Context(), owner.ID, r.UserAgent(), clientIP(r), ttl, pending)
if err != nil {
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, ttl)
if pending {
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
return
}
uj := toUserJSON(owner)
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
}
// --- managing them ------------------------------------------------------------------
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeFieldErrors(w, map[string]string{"name": "required"})
return
}
if err := s.db.RenamePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
userFrom(r.Context()).ID, name); err != nil {
s.storeError(w, r, "rename passkey", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
if err := s.db.DeletePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
userFrom(r.Context()).ID); err != nil {
s.storeError(w, r, "delete passkey", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- shared plumbing -----------------------------------------------------------------
// passkeyAccount is the library's view of an account, plus the rows behind it.
type passkeyAccount struct {
*auth.PasskeyUser
stored []store.Passkey
}
func (a *passkeyAccount) excludeList() []protocol.CredentialDescriptor {
out := make([]protocol.CredentialDescriptor, 0, len(a.stored))
for _, c := range a.PasskeyUser.Credentials {
out = append(out, c.Descriptor())
}
return out
}
func (s *Server) passkeyUser(r *http.Request, userID, email, name string) (*passkeyAccount, error) {
keys, err := s.db.ListPasskeys(r.Context(), userID)
if err != nil {
return nil, err
}
credentials := make([]webauthn.Credential, 0, len(keys))
for _, k := range keys {
raw, err := auth.DecodeCredentialID(k.CredentialID)
if err != nil {
continue
}
credentials = append(credentials, webauthn.Credential{
ID: raw,
PublicKey: k.PublicKey,
Authenticator: webauthn.Authenticator{
SignCount: k.SignCount,
},
})
}
return &passkeyAccount{
PasskeyUser: &auth.PasskeyUser{
ID: userID, Email: email, Name: name, Credentials: credentials,
},
stored: keys,
}, nil
}
// issueChallenge stores the server's side of a ceremony and hands the browser the
// options plus the id it will answer with.
func (s *Server) issueChallenge(w http.ResponseWriter, r *http.Request, userID, purpose string, session *webauthn.SessionData, options any) {
encoded, err := json.Marshal(session)
if err != nil {
s.internal(w, r, "encode webauthn session", err)
return
}
id, err := s.db.StoreChallenge(r.Context(), userID, string(session.Challenge), purpose, encoded, challengeTTL)
if err != nil {
s.internal(w, r, "store challenge", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"challenge_id": id, "options": options})
}
// takeChallenge reads a challenge once and checks it belongs where it is being
// used. A registration challenge answered by a login — or one account's answered
// by another — is refused rather than merely failing later.
func (s *Server) takeChallenge(w http.ResponseWriter, r *http.Request, id, purpose, userID string) (*webauthn.SessionData, bool) {
owner, raw, err := s.db.TakeChallenge(r.Context(), id, purpose)
if err != nil {
writeError(w, http.StatusBadRequest, CodeBadRequest,
"that attempt has expired or was already used; start again")
return nil, false
}
if userID != "" && owner != userID {
writeError(w, http.StatusBadRequest, CodeBadRequest, "that attempt belongs to another account")
return nil, false
}
var session webauthn.SessionData
if err := json.Unmarshal(raw, &session); err != nil {
s.internal(w, r, "decode webauthn session", err)
return nil, false
}
return &session, true
}
+201
View File
@@ -0,0 +1,201 @@
package httpapi
import (
"encoding/json"
"net/http"
"testing"
"github.com/kristianwind/verdande/internal/store"
)
// What can be tested without an authenticator, which is most of the surface that
// actually goes wrong.
//
// The signature itself cannot be produced here — that needs a real device or a
// virtual one, and Playwright's CDP virtual authenticator is where that belongs.
// What is testable is everything around it: who may reach these endpoints, that a
// challenge is answerable exactly once, that one account's challenge cannot be
// answered by another, and that an expired one is refused. Those are the parts a
// mistake would make silently permissive.
func TestPasskeyRegistrationIsSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
// A leaked API token must not be able to add its own way in: that would turn a
// theft into a tenancy.
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/auth/passkeys"},
{"POST", "/api/v1/auth/passkeys/register/begin"},
{"POST", "/api/v1/auth/passkeys/register/finish"},
} {
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s with an API token: status %d, want 403", c.method, c.path, resp.StatusCode)
}
}
}
// Beginning a registration hands back options and a challenge id, and the options
// carry the things that make this a passkey rather than a second factor.
func TestBeginningARegistrationAsksForADiscoverableVerifiedKey(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("begin: status %d, body %v", resp.StatusCode, body)
}
if body["challenge_id"] == "" || body["challenge_id"] == nil {
t.Fatal("no challenge id came back")
}
options, _ := body["options"].(map[string]any)
publicKey, _ := options["publicKey"].(map[string]any)
if publicKey == nil {
t.Fatalf("no publicKey in the options: %v", options)
}
selection, _ := publicKey["authenticatorSelection"].(map[string]any)
if selection["residentKey"] != "preferred" {
t.Errorf("residentKey = %v — only a discoverable credential can start a login "+
"with no email typed", selection["residentKey"])
}
if selection["userVerification"] != "preferred" {
t.Errorf("userVerification = %v — a key that only proves possession cannot "+
"stand in for the password", selection["userVerification"])
}
// The user handle must be the account id rather than the email: it is written
// into the credential and cannot be changed, so an email there would orphan
// every key the moment somebody changed their address.
user, _ := publicKey["user"].(map[string]any)
if user["name"] != "[email protected]" {
t.Errorf("user.name = %v", user["name"])
}
if id, _ := user["id"].(string); id == "" {
t.Error("no user handle in the options")
}
}
// A challenge is answerable exactly once, and only by whom it was issued to.
func TestAChallengeIsSpentWhenItIsUsed(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
_, begun := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
challenge := begun["challenge_id"].(string)
// Somebody else's challenge, answered by this account. Refused before the
// signature is even looked at.
resp, _ := other.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("another account's challenge: status %d, want 400", resp.StatusCode)
}
// And it is spent: the rightful owner cannot use it either now, because taking
// it is what deleted it. That is deliberate — a challenge that survives a
// failed attempt is a challenge somebody can keep trying.
resp, _ = ts.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a spent challenge: status %d, want 400", resp.StatusCode)
}
}
// Logging in with a key needs no session and says nothing about who exists here.
func TestPasskeyLoginRevealsNothingAboutWhoHasAKey(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// No session at all — a fresh client, which is the whole point.
fresh := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, body := fresh.do(t, "POST", "/api/v1/auth/passkey/login/begin", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("begin login: status %d, body %v", resp.StatusCode, body)
}
challenge := body["challenge_id"].(string)
options, _ := body["options"].(map[string]any)
publicKey, _ := options["publicKey"].(map[string]any)
// A discoverable login must not list credentials: that list would say which
// keys exist on this instance to anybody who asked.
if allowed, present := publicKey["allowCredentials"]; present && allowed != nil {
if list, _ := allowed.([]any); len(list) > 0 {
t.Errorf("the login options list %d credentials; a discoverable login must not",
len(list))
}
}
// A garbage answer is refused with the same message a wrong key would get.
resp, _ = fresh.do(t, "POST", "/api/v1/auth/passkey/login/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a junk assertion: status %d, want 400 or 401", resp.StatusCode)
}
}
// A key belongs to its owner, and nobody else can rename or remove it.
func TestAPasskeyCanOnlyBeManagedByItsOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// Written straight to the store: registering one properly needs a device.
key := &store.Passkey{
UserID: admin.ID, CredentialID: "abc", PublicKey: []byte{1, 2, 3}, Name: "Min bærbare",
}
if err := ts.db.CreatePasskey(t.Context(), key); err != nil {
t.Fatal(err)
}
_, listed := ts.do(t, "GET", "/api/v1/auth/passkeys", nil)
if keys, _ := listed["passkeys"].([]any); len(keys) != 1 {
t.Fatalf("the owner sees %d keys, want 1", len(keys))
}
_, theirs := other.do(t, "GET", "/api/v1/auth/passkeys", nil)
if keys, _ := theirs["passkeys"].([]any); len(keys) != 0 {
t.Errorf("somebody else sees %d of this account's keys", len(keys))
}
for _, c := range []struct {
what, method string
body any
}{
{"rename", "PATCH", map[string]any{"name": "min nu"}},
{"delete", "DELETE", nil},
} {
resp, _ := other.do(t, c.method, "/api/v1/auth/passkeys/"+key.ID, c.body)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s somebody else's key: status %d, want 404", c.what, resp.StatusCode)
}
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/auth/passkeys/"+key.ID,
map[string]any{"name": "Telefonen"}); resp.StatusCode != http.StatusNoContent {
t.Errorf("the owner renaming: status %d", resp.StatusCode)
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/passkeys/"+key.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Errorf("the owner deleting: status %d", resp.StatusCode)
}
}
+34
View File
@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/mail"
"github.com/kristianwind/verdande/internal/mcp"
@@ -39,6 +40,12 @@ type Server struct {
mcp *mcp.Server
updates *update.Checker
// Nil when the base URL is not something an authenticator will accept — a bare
// hostname with no scheme, say. The endpoints answer 503 rather than the
// process refusing to start: passkeys are one way in among three, and an
// instance should not be unreachable because one of them cannot be offered.
passkeys *auth.WebAuthn
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
@@ -63,6 +70,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
resetLimiter: newLimiter(5, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
s.passkeys = w
} else {
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
}
s.mcp = s.buildMCP()
s.updates = update.New(Version, cfg.UpdateCheck)
@@ -132,6 +144,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Method(http.MethodPost, "/password/reset",
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
// Signing in with a passkey. Rate-limited like the password path: a
// signature cannot be guessed, but the endpoint can still be used to
// hammer the database.
r.Method(http.MethodPost, "/passkey/login/begin",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
r.Method(http.MethodPost, "/passkey/login/finish",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
// The second step of a login: a session that has passed the password
// but not yet the code, and can reach nothing else.
r.Group(func(r chi.Router) {
@@ -160,6 +180,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(s.requireSession)
r.Get("/sessions", s.handleListSessions)
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
// Registering a key, and the list of them. Sessions only, for
// the same reason: a leaked token must not be able to add its
// own way in — that would turn a theft into a tenancy.
r.Get("/passkeys", s.handleListPasskeys)
r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
})
})
})
@@ -318,6 +347,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireSession, s.requireAdmin)
r.Get("/errors", s.handleListErrors)
// Restarting this instance from its own interface. The panel does
// the work — a container cannot replace its own image — and pulls
// `:latest` on the way, which is what makes a restart an update.
r.Get("/panel", s.handlePanelStatus)
r.Post("/panel/restart", s.handleRestartFromPanel)
// A backup file is a complete copy of the database, so this whole
// group is sessions-only as well as administrators-only: a leaked
// token must not be able to download everybody's data.
+50
View File
@@ -20,6 +20,56 @@ import (
// away whatever letters it happens to use. A word list rather than a language
// detector: this repository ships one static binary, and a list of thirty common
// words catches the sentences a person would actually write.
// The same words, inside the code rather than the markup.
//
// `confirm()`, `alert()` and `app.toast()` take a string argument, and a string
// argument lives in the script block where the scanner above never looks. Six of
// them survived the whole i18n sweep that way — every destructive confirmation in
// the app, which is the worst possible set to leave in one language, because it is
// the text somebody reads in the half-second before they agree to lose something.
func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
// The argument of one of the three, quoted or backticked. Deliberately narrow:
// a general scan of every string literal in a Svelte script block would drown
// in class names, keys and query fragments.
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(\s*['` + "`" + `]([^'` + "`" + `]{4,})['` + "`" + `]`)
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
var found []string
root := filepath.Join("..", "..", "web", "src")
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
if !strings.HasSuffix(path, ".svelte") && !strings.HasSuffix(path, ".js") {
return nil
}
if strings.Contains(path, "locales") {
return nil
}
body, err := os.ReadFile(path)
if err != nil {
return err
}
rel, _ := filepath.Rel(root, path)
for _, m := range call.FindAllStringSubmatch(string(body), -1) {
if danish.MatchString(m[1]) {
found = append(found, rel+": "+m[1])
}
}
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
if len(found) > 0 {
t.Errorf("%d dialog or toast string(s) are written in Danish inside the code. "+
"They are what somebody reads before agreeing to lose something, and an "+
"English interface shows them in Danish:\n %s",
len(found), strings.Join(found, "\n "))
}
}
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
// Text nodes of three characters or more, and the three attributes a person
// reads. Anything inside {…} is already an expression — usually a t() call —
@@ -0,0 +1,73 @@
-- Passkeys: a key on a device instead of a secret in a head.
--
-- The account already has two ways in — a password, and a TOTP code on top of it.
-- Both are shared secrets: the server keeps something that can be stolen from it,
-- and the person keeps something that can be phished out of them. A passkey is
-- neither. The private half never leaves the device, the public half is all this
-- table holds, and the signature is bound to the origin — so a convincing copy of
-- the sign-in page at a different address gets nothing.
--
-- One row per credential rather than per account, because a person has more than
-- one device and losing a laptop should not mean losing the way in. The name is
-- theirs to write: "min bærbare" is what makes a list of keys reviewable, and a
-- list nobody can read is a list nobody revokes from.
--
-- `sign_count` is the authenticator's own counter. It is stored to be compared:
-- a counter that goes backwards means the credential has been cloned, which is
-- the one thing this design cannot otherwise detect. Not every authenticator keeps
-- one — the spec allows zero — so zero means "not offered" rather than "suspicious".
CREATE TABLE passkeys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
-- The credential id the authenticator generated, raw bytes, base64url as
-- stored. Unique across the instance: it is what a login is looked up by,
-- before anybody has said who they are.
credential_id TEXT NOT NULL,
public_key BLOB NOT NULL,
-- The AAGUID identifies the authenticator model, which is what lets a list say
-- "iCloud Keychain" beside a key somebody named three months ago and forgot.
aaguid TEXT NOT NULL DEFAULT '',
sign_count INTEGER NOT NULL DEFAULT 0,
-- Whether the credential lives on the device itself (a resident key). Only
-- those can start a login with no email typed first, so the interface has to
-- know which it has before it offers that.
discoverable INTEGER NOT NULL DEFAULT 0,
-- Whether the authenticator verified the person — a PIN, a fingerprint, a
-- face. A key that only proves possession is a first factor; one that verified
-- its owner is both, and that is what decides whether a password is still
-- asked for.
user_verified INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE UNIQUE INDEX idx_passkeys_credential ON passkeys (credential_id);
CREATE INDEX idx_passkeys_user ON passkeys (user_id);
-- The challenge a registration or a login is answered against.
--
-- Kept server-side rather than in a cookie: the whole point of the challenge is
-- that the server chose it and remembers choosing it. One row per attempt, deleted
-- when it is answered, and swept when it expires — a challenge that outlives its
-- ceremony is a replay waiting to be tried.
--
-- user_id is null for a login that has not said who it is yet, which is the case
-- a discoverable credential exists for.
CREATE TABLE webauthn_challenges (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users (id) ON DELETE CASCADE,
challenge TEXT NOT NULL,
-- 'register' or 'login'. A challenge issued for one must not be answerable by
-- the other: registering is done by somebody already signed in, and logging in
-- is not.
purpose TEXT NOT NULL,
-- The library's own session state, which carries the allowed credentials and
-- the verification requirement it will check the answer against.
session BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
CHECK (purpose IN ('register', 'login'))
);
CREATE INDEX idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
+206
View File
@@ -0,0 +1,206 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
// A Passkey is one credential on one device.
//
// One row per credential rather than per account: a person has a laptop and a
// phone, and losing one must not mean losing the way in. The name is theirs to
// write — "min bærbare" is what makes the list reviewable, and a list nobody can
// read is a list nobody revokes from.
type Passkey struct {
ID string
UserID string
CredentialID string
PublicKey []byte
AAGUID string
SignCount uint32
Discoverable bool
UserVerified bool
Name string
CreatedAt time.Time
LastUsedAt time.Time
}
func (db *DB) CreatePasskey(ctx context.Context, p *Passkey) error {
if p.ID == "" {
p.ID = NewID()
}
p.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx, `
INSERT INTO passkeys (id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.UserID, p.CredentialID, p.PublicKey, p.AAGUID, p.SignCount,
boolToInt(p.Discoverable), boolToInt(p.UserVerified), p.Name, p.CreatedAt.Unix())
return err
}
func (db *DB) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
FROM passkeys WHERE user_id = ? ORDER BY created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPasskeys(rows)
}
// PasskeyByCredentialID is how a login finds the account, before anybody has said
// who they are. The credential id is unique across the instance, which is what
// makes that possible.
func (db *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
FROM passkeys WHERE credential_id = ?`, credentialID)
if err != nil {
return nil, err
}
defer rows.Close()
list, err := scanPasskeys(rows)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, ErrNotFound
}
return &list[0], nil
}
// TouchPasskey records a use and the authenticator's counter.
//
// The counter is stored to be compared. One that goes backwards means the
// credential has been cloned, which is the single thing this design cannot
// otherwise detect — and not every authenticator keeps one, so zero means "not
// offered" rather than "suspicious".
func (db *DB) TouchPasskey(ctx context.Context, id string, signCount uint32) error {
_, err := db.ExecContext(ctx,
`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`,
signCount, time.Now().Unix(), id)
return err
}
func (db *DB) RenamePasskey(ctx context.Context, id, userID, name string) error {
res, err := db.ExecContext(ctx,
`UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?`, name, id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeletePasskey removes one key. Scoped in the SQL rather than checked first: an
// id read in one step and deleted in the next is a window, and the reason somebody
// is on this screen at all is usually that they think a device is gone.
func (db *DB) DeletePasskey(ctx context.Context, id, userID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM passkeys WHERE id = ? AND user_id = ?`, id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// CountPasskeys is what stops somebody removing their last way in when it is the
// only one they have.
func (db *DB) CountPasskeys(ctx context.Context, userID string) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM passkeys WHERE user_id = ?`, userID).Scan(&n)
return n, err
}
func scanPasskeys(rows *sql.Rows) ([]Passkey, error) {
out := []Passkey{}
for rows.Next() {
var p Passkey
var discoverable, verified int
var created, used int64
if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.AAGUID,
&p.SignCount, &discoverable, &verified, &p.Name, &created, &used); err != nil {
return nil, err
}
p.Discoverable = discoverable == 1
p.UserVerified = verified == 1
p.CreatedAt = time.Unix(created, 0).UTC()
if used > 0 {
p.LastUsedAt = time.Unix(used, 0).UTC()
}
out = append(out, p)
}
return out, rows.Err()
}
// --- challenges -------------------------------------------------------------------
// StoreChallenge keeps the server's side of one ceremony.
//
// Server-side rather than in a cookie: the whole point of a challenge is that the
// server chose it and remembers choosing it. It is deleted when answered and swept
// when it expires — a challenge that outlives its ceremony is a replay waiting to
// be tried.
func (db *DB) StoreChallenge(ctx context.Context, userID, challenge, purpose string, session []byte, ttl time.Duration) (string, error) {
id := NewID()
now := time.Now()
_, err := db.ExecContext(ctx, `
INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, session, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, nullString(userID), challenge, purpose, session, now.Unix(), now.Add(ttl).Unix())
return id, err
}
// TakeChallenge reads a challenge and removes it in the same transaction.
//
// Taken rather than read, because a challenge is answerable exactly once. Two
// requests racing the same one would otherwise both succeed, which is the whole
// shape of a replay.
func (db *DB) TakeChallenge(ctx context.Context, id, purpose string) (userID string, session []byte, err error) {
err = db.Tx(ctx, func(tx *sql.Tx) error {
var owner sql.NullString
var expires int64
row := tx.QueryRowContext(ctx,
`SELECT user_id, session, expires_at FROM webauthn_challenges WHERE id = ? AND purpose = ?`,
id, purpose)
if err := row.Scan(&owner, &session, &expires); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM webauthn_challenges WHERE id = ?`, id); err != nil {
return err
}
if time.Now().Unix() > expires {
return ErrNotFound
}
userID = owner.String
return nil
})
return userID, session, err
}
// PurgeChallenges is run by the nightly sweep. An answered challenge deletes
// itself; an abandoned one is what this is for.
func (db *DB) PurgeChallenges(ctx context.Context) (int64, error) {
res, err := db.ExecContext(ctx,
`DELETE FROM webauthn_challenges WHERE expires_at < ?`, time.Now().Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
+23 -1
View File
@@ -11,7 +11,11 @@ gameskill:
invites and reminders, then start it and open the URL to create the first
admin account.
author: "kristianwind"
version: 2
# Bumped whenever the variables below change. The panel keys its catalogue on
# this: a manifest with new fields and an unchanged version is a manifest the
# panel has no reason to re-read, so the fields never appear and the failure is
# silent — the server's settings page simply does not offer them.
version: 3
icon: "app"
# The image is distroless — no shell — so keep_entrypoint runs the binary the
@@ -78,6 +82,24 @@ gameskill:
type: bool
default: false
# Restarting from verdande's own settings, which is also how it picks up a new
# version: a container cannot replace its own image, so the panel recreates it
# and pulls :latest on the way. All three are needed together; the page names
# whichever are missing.
- key: VERDANDE_PANEL_URL
name: "Panel address, so verdande can ask to be restarted from its own settings"
type: string
default: ""
- key: VERDANDE_PANEL_TOKEN
name: "Panel API token — from an account with control of this server"
type: string
secret: true
default: ""
- key: VERDANDE_PANEL_SERVER_ID
name: "This server's id in the panel (the one in its URL)"
type: string
default: ""
install:
# The image has no shell, so the install step borrows one. It only has to make
# the directory layout exist; verdande creates and migrates its own database on
Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 167 KiB

+126 -11
View File
@@ -50,9 +50,16 @@ function watchForTrouble(page) {
page.on('pageerror', (error) => trouble.push(String(error)));
page.on('response', (response) => {
const url = response.url();
if (url.includes('/api/') && !response.ok()) {
trouble.push(`${response.request().method()} ${new URL(url).pathname} → ${response.status()}`);
}
if (!url.includes('/api/') || response.ok()) return;
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
// which is the correct answer on the sign-in page and after signing out.
// Every other non-ok response is worth failing over — that is what this
// watcher is for.
const path = new URL(url).pathname;
if (path.endsWith('/auth/me') && response.status() === 401) return;
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
});
return trouble;
}
@@ -577,7 +584,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await invitee.goto(link);
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
await invitee.getByLabel('Navn').fill('Nabo');
await invitee.getByLabel('Navn', { exact: true }).fill('Nabo');
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await invitee.getByRole('button', { name: 'Opret konto' }).click();
@@ -734,7 +741,7 @@ test('en administrator kan invitere en bruger til instansen', async ({ browser,
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const invitee = await context.newPage();
await invitee.goto(link);
await invitee.getByLabel('Navn').fill('Anders');
await invitee.getByLabel('Navn', { exact: true }).fill('Anders');
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await invitee.getByRole('button', { name: 'Opret konto' }).click();
@@ -807,7 +814,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const andreas = await context.newPage();
await andreas.goto(link);
await andreas.getByLabel('Navn').fill('andreas');
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await andreas.getByRole('button', { name: 'Opret konto' }).click();
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
@@ -964,7 +971,7 @@ test('historik-siden viser hændelser fra et projekt, administratoren ikke er me
expect(link).toContain('/invite?token=');
await theirs.goto(link);
await theirs.getByLabel('Navn').fill('Sigrid');
await theirs.getByLabel('Navn', { exact: true }).fill('Sigrid');
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await theirs.getByRole('button', { name: 'Opret konto' }).click();
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
@@ -1150,8 +1157,13 @@ test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) =
/januar|februar|marts|maj|juni|juli|oktober|december/i
);
// The Inbox keeps its name: it is a project row created at signup, not a
// string in the interface, and the person can rename it like any other.
// The Inbox is named by the server at account creation, before anybody has seen
// a settings page — so it is the one project name nobody chose, and it follows
// the language like a label. Rename it and that becomes your name for it in
// every language, the same rule as any other project.
await expect(
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Inbox' })
).toBeVisible();
// Danish again, for everything that runs after this.
await page.goto('/indstillinger');
@@ -1447,6 +1459,97 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* A passkey, registered and then signed in with, end to end.
*
* The Go tests cover who may reach these endpoints and that a challenge is spent
* when it is used. What they cannot do is produce a signature — that needs an
* authenticator. Chrome has a virtual one behind CDP, and it is the only way to
* find out whether the encoding is right: every boundary here is a base64url
* conversion, and one wrong byte produces a signature that will not verify,
* reported as "that key was not accepted" — which reads as a broken key rather
* than a broken encoder.
*
* Chromium only, and deliberately so: this is testing our ceremony, not WebKit's
* authenticator.
*/
test('en passkey kan registreres og logges ind med', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'den virtuelle authenticator findes kun i Chromium');
// Its own context, at `localhost` rather than the loopback address the rest of
// the suite uses. WebAuthn refuses an IP as a relying party id — the browser
// throws `SecurityError: This is an invalid domain` — and localhost is the
// exception every browser makes. It is not the shared baseURL because
// resolving `localhost` is slow enough here to cost the whole suite minutes.
//
// Cookies are bound to an origin, so the signed-in state from the setup project
// does not carry across: this signs in with a password first, which is also the
// honest order — you register a key on an account you have already proved you
// own.
const origin = 'http://localhost:8097';
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await context.newPage();
const trouble = watchForTrouble(page);
await page.goto(`${origin}/`);
await page.getByLabel(/E-mail/).fill(USER.email);
await page.getByLabel(/Adgangskode/).fill(USER.password);
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
// A virtual authenticator that verifies its owner, so the key counts as both
// factors — which is what lets the login complete without a password.
const client = await page.context().newCDPSession(page);
await client.send('WebAuthn.enable');
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true
}
});
await page.goto(`${origin}/indstillinger`);
await page.getByLabel('Navn på nøglen').fill('Testnøglen');
await page.getByRole('button', { name: 'Tilføj en nøgle' }).click();
// It is in the list, and it counts as both factors — the virtual authenticator
// verifies, so the login below needs no password.
const key = page.locator('li').filter({ hasText: 'Testnøglen' });
await expect(key).toBeVisible();
await expect(key.getByText('begge faktorer')).toBeVisible();
// It survives a reload, so this is the server's row and not the page's state.
await page.reload();
await expect(page.locator('li').filter({ hasText: 'Testnøglen' })).toBeVisible();
// Now sign out and back in with it. No email typed: the device knows which
// account its key belongs to, which is also why this page cannot be used to
// find out who has an account here.
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
// Playwright matches an accessible name loosely unless told otherwise.
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Log ind med en passkey' }).click();
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
// And the key records that it was used, which is what makes the list worth
// reading when somebody wonders whether a device is still in play.
await page.goto(`${origin}/indstillinger`);
await expect(
page.locator('li').filter({ hasText: 'Testnøglen' }).getByText(/sidst brugt/)
).toBeVisible();
await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
await context.close();
expect(trouble).toEqual([]);
});
/**
* The sidebar never scrolls sideways.
*
@@ -1465,7 +1568,9 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
await page.goto('/indstillinger');
// A name long enough to overflow the column at its default width.
await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard');
// `exact`, or this also matches "Navn på nøglen" in the passkey panel — and an
// ambiguous locator waits for one of them to go away rather than failing.
await page.getByLabel('Navn', { exact: true }).fill('Kristian Vinterberg-Skovgaard');
await page.getByRole('button', { name: 'Gem' }).first().click();
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
@@ -1480,7 +1585,7 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
// Put the name back, so the tests after this one see the account they expect.
await page.getByLabel('Navn').fill(USER.name);
await page.getByLabel('Navn', { exact: true }).fill(USER.name);
await page.getByRole('button', { name: 'Gem' }).first().click();
expect(trouble).toEqual([]);
@@ -1535,6 +1640,16 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
// inside a board was exactly true.
await page.getByRole('button', { name: 'Board', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
// A board gets the width a reading column withholds. This was `.view:has(.board)`
// for as long as the board has existed and never once matched: `.board` is inside
// a child component, and Svelte scopes a selector to the component that wrote it.
// The rule read correctly, compiled, and could not fire.
const wide = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
await page.getByRole('button', { name: 'Liste', exact: true }).click();
const narrow = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
expect(wide, 'board-visningen er ikke bredere end listen').toBeGreaterThan(narrow);
await page.getByRole('button', { name: 'Board', exact: true }).click();
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
const field = page.getByLabel('Ny sektion');
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
+12 -1
View File
@@ -34,6 +34,9 @@ if (process.env.TEST_WORKER_INDEX === undefined) {
}
const PORT = 8097;
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
// every browser makes. The passkey test cannot run against a loopback address.
const baseURL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
@@ -118,7 +121,15 @@ export default defineConfig({
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
VERDANDE_BASE_URL: baseURL,
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
// different things: the suite's address is where the browser goes, and
// this one is what the server calls itself — which is now also the
// WebAuthn relying party id, and WebAuthn refuses an IP address.
//
// The passkey test navigates to localhost explicitly so its origin
// matches. Everything else stays on the loopback address, where it has
// always been.
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
+8
View File
@@ -51,6 +51,9 @@ const SHOTS = [
{ name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
{ name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
{ name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
// The same project as a list, which is where sections read as bands rather than
// as columns — and the shape most people actually work in.
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
@@ -59,6 +62,11 @@ const SHOTS = [
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
];
async function showList(page) {
await page.getByRole('button', { name: 'Liste', exact: true }).click();
await page.waitForTimeout(600);
}
async function showMonth(page) {
await page.getByRole('button', { name: 'Måned', exact: true }).click();
await page.waitForTimeout(800);
+23
View File
@@ -55,6 +55,9 @@ const MESSAGES = {
ai_not_configured: 'error.aiNotConfigured',
totp_not_enabled: 'error.totpNotEnabled',
inbox_protected: 'error.inboxProtected',
// The fallback for when Gmail refused and said nothing usable. When it did say
// something, `humanMessage` quotes it instead — see below.
gmail_failed: 'error.gmailRefused',
last_admin: 'error.lastAdmin'
};
@@ -67,6 +70,15 @@ const MESSAGES = {
*/
export function humanMessage(err) {
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
// Gmail's own words are the diagnosis — "invalid_grant", "insufficient
// authentication scopes" — and a generic sentence in their place throws away
// the only thing that says what to do. Wrapped so it reads as a quotation
// rather than as a stray English string in a Danish interface.
if (err.code === 'gmail_failed' && err.message) {
return t('error.gmailSaid', { what: err.message });
}
const key = MESSAGES[err.code];
if (key) return t(key);
return err.message ?? t(MESSAGES.internal_error);
@@ -181,6 +193,15 @@ export const api = {
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
totpDisable: (password) => post('/auth/totp/disable', { password }),
listSessions: () => get('/auth/sessions'),
// --- passkeys
listPasskeys: () => get('/auth/passkeys'),
beginPasskeyRegistration: () => post('/auth/passkeys/register/begin'),
finishPasskeyRegistration: (data) => post('/auth/passkeys/register/finish', data),
renamePasskey: (id, name) => patch(`/auth/passkeys/${id}`, { name }),
deletePasskey: (id) => del(`/auth/passkeys/${id}`),
beginPasskeyLogin: () => post('/auth/passkey/login/begin'),
finishPasskeyLogin: (data) => post('/auth/passkey/login/finish', data),
endSession: (id) => del(`/auth/sessions/${id}`),
// --- users, administrators only
@@ -190,6 +211,8 @@ export const api = {
deleteUser: (id) => del(`/users/${id}`),
revokeInvite: (id) => del(`/invites/${id}`),
listErrors: () => get('/errors'),
panelStatus: () => get('/panel'),
restartFromPanel: () => post('/panel/restart'),
// The nightly backup, which ran since the beginning with nothing to show it.
// The OAuth client itself, which is the instance's registration with Google
+1 -1
View File
@@ -125,7 +125,7 @@
);
} catch {
app.replace(id, previous);
app.toast('Kunne ikke flytte opgaven.');
app.toast(t('task.moveFailed'));
}
}
</script>
+2 -2
View File
@@ -1,5 +1,5 @@
<script>
import { app, sidebar } from '$lib/stores.svelte.js';
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { COLORS, colorVar } from '$lib/colors.js';
@@ -410,7 +410,7 @@
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
>
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{project.name}
{projectName(project)}
{#if project.shared}
<span class="count">{project.member_count}</span>
{/if}
+61
View File
@@ -20,6 +20,7 @@
import { app } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { available, signIn } from '$lib/passkey.js';
// loading | login | setup | totp | forgot | sent | invite | reset | done
let mode = $state('loading');
@@ -31,6 +32,41 @@
let fields = $state({});
let busy = $state(false);
// --- passkey -----------------------------------------------------------------
let passkeyReady = $state(false);
$effect(() => {
available().then((ok) => (passkeyReady = ok));
});
/**
* No email is asked for and none is sent: the device knows which account its
* key belongs to. That is not only convenience — it means this page cannot be
* used to find out who has an account here.
*/
async function signInWithPasskey() {
error = '';
busy = true;
try {
const result = await signIn();
if (result.totp_required) {
mode = 'totp';
} else {
app.user = result.user;
await app.load();
}
} catch (e) {
// A cancelled prompt is not a failure. Somebody changed their mind, and
// an error for that reads as though the key was rejected.
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
error = humanMessage(e);
}
} finally {
busy = false;
}
}
/**
* The token from an emailed link, if this is one.
*
@@ -267,6 +303,15 @@
</label>
<button type="submit" disabled={busy}>{t('auth.signIn')}</button>
<!-- Only when the browser has somewhere to keep a key. Offering it where
it cannot work is a button that fails for reasons nobody can act on. -->
{#if passkeyReady}
<button type="button" class="passkey" disabled={busy} onclick={signInWithPasskey}>
{busy ? t('passkey.signingIn') : t('passkey.signIn')}
</button>
{/if}
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
{t('auth.forgot')}
</button>
@@ -381,6 +426,22 @@
cursor: default;
}
/* Set apart from the primary button but not made secondary: it is an equal way
in, not a fallback. */
.passkey {
width: 100%;
padding: var(--s2) var(--s4);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
font-size: var(--text-sm);
color: var(--ink);
transition: border-color var(--fast) var(--ease);
}
.passkey:hover {
border-color: var(--accent);
}
.link {
color: var(--ink-faint);
font-size: var(--text-sm);
+3 -3
View File
@@ -11,7 +11,7 @@
* necessarily the last one sent.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { app, projectName } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
@@ -276,7 +276,7 @@
}
async function removeComment(comment) {
if (!confirm('Slet kommentaren?')) return;
if (!confirm(t('detail.deleteComment'))) return;
const previous = comments;
comments = comments.filter((c) => c.id !== comment.id);
try {
@@ -436,7 +436,7 @@
<label for="project">{t('detail.project')}</label>
<select id="project" bind:value={projectId} onchange={saveProject}>
{#each app.projects as project (project.id)}
<option value={project.id}>{project.name}</option>
<option value={project.id}>{projectName(project)}</option>
{/each}
</select>
</div>
+53 -1
View File
@@ -613,5 +613,57 @@ export const da = {
'done.title': 'Færdige',
'done.hint': 'Alt du har lukket, nyeste først. Klik cirklen for at åbne en igen.',
'done.empty': 'Du har ikke lukket noget endnu.',
'done.unknownDay': 'Uden dato'
'done.unknownDay': 'Uden dato',
// --- passkeys ----------------------------------------------------------------------
'passkey.title': 'Passkeys',
'passkey.hint':
'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.',
'passkey.unsupported': 'Denne browser understøtter ikke passkeys.',
'passkey.none': 'Ingen nøgler endnu.',
'passkey.name': 'Navn på nøglen',
'passkey.namePlaceholder': 'fx min bærbare',
'passkey.add': 'Tilføj en nøgle',
'passkey.registering': 'Venter på enheden…',
'passkey.remove': 'Fjern',
'passkey.removeQuestion': 'Fjern "{name}"? Enheden kan ikke logge ind med den bagefter.',
'passkey.bothFactors': 'begge faktorer',
'passkey.possessionOnly': 'kun besiddelse — koden bliver stadig krævet',
'passkey.lastUsed': 'sidst brugt {when}',
'passkey.neverUsed': 'aldrig brugt',
'passkey.signIn': 'Log ind med en passkey',
'passkey.signingIn': 'Venter på enheden…',
'passkey.unavailable':
'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.',
// --- restarting from the interface ---------------------------------------------
'update.restart': 'Genstart og hent nyeste version',
'update.restarting': 'Genstarter…',
'update.restartQuestion':
'Genstart nu? Serveren er nede i omkring et halvt minut, og henter den nyeste version på vejen op.',
'update.asking': 'Beder panelet om at genstarte…',
'update.waiting': 'Venter på at serveren kommer op igen…',
'update.stillDown': 'Serveren er ikke kommet op endnu. Kig i panelet.',
'update.notConfigured':
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.',
'error.gmailSaid': 'Gmail svarede: {what}',
'error.gmailRefused': 'Gmail afviste kaldet. Prøv at forbinde postkassen igen.',
'int.newAddressQuestion': 'Ny adresse? Mail til den gamle bliver afvist.',
'int.copied': 'Kopieret.',
'int.copyFailed': 'Kunne ikke kopiere — markér teksten i stedet.',
'detail.deleteComment': 'Slet kommentaren?',
'int.disconnectQuestion': 'Afbryd forbindelsen til Gmail? Tokens bliver glemt.',
'int.newLinkQuestion': 'Nyt feed-link? Alle eksisterende abonnementer holder op med at virke.',
'data.deleteTemplate': 'Slet skabelonen "{name}"?',
'tokens.revokeQuestion': 'Tilbagekald "{name}"? Alt, der bruger den, holder op med at virke.',
'account.signOutThisDevice': 'Det er denne enhed. Du bliver logget ud.',
'project.deleteQuestion': 'Slet "{name}" med alle dets opgaver?',
'project.deleteRecoverable':
'Det havner i papirkurven og kan hentes tilbage under Indstillinger → Data og skabeloner i {days} dage.'
};
+53 -1
View File
@@ -608,5 +608,57 @@ export const en = {
'done.title': 'Done',
'done.hint': 'Everything you have closed, newest first. Click the circle to reopen one.',
'done.empty': 'You have not closed anything yet.',
'done.unknownDay': 'No date'
'done.unknownDay': 'No date',
// --- passkeys ----------------------------------------------------------------------
'passkey.title': 'Passkeys',
'passkey.hint':
'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.',
'passkey.unsupported': 'This browser does not support passkeys.',
'passkey.none': 'No keys yet.',
'passkey.name': 'Name for the key',
'passkey.namePlaceholder': 'e.g. my laptop',
'passkey.add': 'Add a key',
'passkey.registering': 'Waiting for the device…',
'passkey.remove': 'Remove',
'passkey.removeQuestion': 'Remove "{name}"? That device cannot sign in with it afterwards.',
'passkey.bothFactors': 'both factors',
'passkey.possessionOnly': 'possession only — the code is still required',
'passkey.lastUsed': 'last used {when}',
'passkey.neverUsed': 'never used',
'passkey.signIn': 'Sign in with a passkey',
'passkey.signingIn': 'Waiting for the device…',
'passkey.unavailable':
'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.',
// --- restarting from the interface ---------------------------------------------
'update.restart': 'Restart and pull the latest version',
'update.restarting': 'Restarting…',
'update.restartQuestion':
'Restart now? The server is down for about half a minute, and pulls the newest version on the way back up.',
'update.asking': 'Asking the panel to restart…',
'update.waiting': 'Waiting for the server to come back…',
'update.stillDown': 'The server has not come back yet. Have a look in the panel.',
'update.notConfigured':
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.',
'error.gmailSaid': 'Gmail said: {what}',
'error.gmailRefused': 'Gmail refused the call. Try connecting the mailbox again.',
'int.newAddressQuestion': 'A new address? Mail to the old one is refused.',
'int.copied': 'Copied.',
'int.copyFailed': 'Could not copy — select the text instead.',
'detail.deleteComment': 'Delete the comment?',
'int.disconnectQuestion': 'Disconnect Gmail? The tokens are forgotten.',
'int.newLinkQuestion': 'A new feed link? Every existing subscription stops working.',
'data.deleteTemplate': 'Delete the template "{name}"?',
'tokens.revokeQuestion': 'Revoke "{name}"? Everything using it stops working.',
'account.signOutThisDevice': 'That is this device. You will be signed out.',
'project.deleteQuestion': 'Delete "{name}" with all its tasks?',
'project.deleteRecoverable':
'It goes to the trash and can be restored under Settings → Data and templates for {days} days.'
};
+115
View File
@@ -0,0 +1,115 @@
/**
* The browser half of a passkey ceremony.
*
* WebAuthn passes binary as ArrayBuffers and JSON cannot carry those, so every
* boundary between this and the server is a base64url conversion. Getting one of
* them wrong produces a signature that will not verify, reported as "that key was
* not accepted" — which reads as a broken key rather than a broken encoder. That
* is the whole reason this is one file and not three call sites.
*/
import { api } from './api.js';
/** Whether this browser can do it at all. */
export function supported() {
return typeof window !== 'undefined' && Boolean(window.PublicKeyCredential);
}
/**
* Whether a key can be offered without the person typing anything first.
*
* Not the same question as `supported()`: a browser can do WebAuthn and still have
* nowhere to put a discoverable credential. Asking first is what stops the sign-in
* page offering a button that cannot work.
*/
export async function available() {
if (!supported()) return false;
try {
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch {
return false;
}
}
const toBuffer = (value) => {
const normalised = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalised.padEnd(normalised.length + ((4 - (normalised.length % 4)) % 4), '=');
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
};
const toBase64URL = (buffer) =>
btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
/** Registers a key on the signed-in account. Returns the row the server stored. */
export async function register(name) {
const { challenge_id, options } = await api.beginPasskeyRegistration();
const publicKey = {
...options.publicKey,
challenge: toBuffer(options.publicKey.challenge),
user: { ...options.publicKey.user, id: toBuffer(options.publicKey.user.id) },
excludeCredentials: (options.publicKey.excludeCredentials ?? []).map((c) => ({
...c,
id: toBuffer(c.id)
}))
};
const credential = await navigator.credentials.create({ publicKey });
if (!credential) throw new Error('cancelled');
return api.finishPasskeyRegistration({
challenge_id,
name,
credential: {
id: credential.id,
rawId: toBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
attestationObject: toBase64URL(credential.response.attestationObject)
},
clientExtensionResults: credential.getClientExtensionResults()
}
});
}
/**
* Signs in with a key.
*
* No email is asked for and none is sent: the device knows which account its key
* belongs to. That is not only convenience — it means the sign-in page cannot be
* used to find out who has an account here.
*/
export async function signIn() {
const { challenge_id, options } = await api.beginPasskeyLogin();
const publicKey = {
...options.publicKey,
challenge: toBuffer(options.publicKey.challenge),
allowCredentials: (options.publicKey.allowCredentials ?? []).map((c) => ({
...c,
id: toBuffer(c.id)
}))
};
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('cancelled');
return api.finishPasskeyLogin({
challenge_id,
credential: {
id: credential.id,
rawId: toBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
authenticatorData: toBase64URL(credential.response.authenticatorData),
signature: toBase64URL(credential.response.signature),
userHandle: credential.response.userHandle
? toBase64URL(credential.response.userHandle)
: null
},
clientExtensionResults: credential.getClientExtensionResults()
}
});
}
+22
View File
@@ -620,6 +620,28 @@ class AppState {
}
}
/**
* What to call a project on screen.
*
* Almost always its own name — somebody wrote it and it is theirs. The Inbox is the
* exception: the server names it at account creation, before anybody has seen a
* settings page, so it is the one project name nobody chose. Leaving it as stored
* meant an account created in Danish said "Indbakke" in an English interface, which
* reads as a string somebody forgot to translate. It was.
*
* Only while it still carries a name the server gave it. Rename it and that is your
* name for it, in every language — the same rule as any other project.
*/
const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']);
export function projectName(project) {
if (!project) return '';
if (project.is_inbox && SYSTEM_INBOX_NAMES.has(project.name.trim().toLowerCase())) {
return t('nav.inbox');
}
return project.name;
}
/** Enough of a task's title to recognise it, without a toast the width of the page. */
function truncate(text, max = 40) {
const trimmed = text.trim();
+2 -2
View File
@@ -13,7 +13,7 @@
*/
import { page } from '$app/stores';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { app, projectName } from '$lib/stores.svelte.js';
import { colorVar } from '$lib/colors.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
@@ -165,7 +165,7 @@
<a href="/projekt/{project.id}">
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
></span>
{project.name}
{projectName(project)}
<!-- Counted by the server in the same request. Open tasks, not all
of them: "12" beside a project finished last year is a number
that means nothing. -->
+97 -11
View File
@@ -3,12 +3,67 @@
import { api, humanMessage } from '$lib/api.js';
import { app, theme, THEMES } from '$lib/stores.svelte.js';
import { t, plural, i18n } from '$lib/i18n.svelte.js';
import { supported, register } from '$lib/passkey.js';
import { ago } from '$lib/when.js';
// --- profile ---------------------------------------------------------------
let name = $state('');
let timezone = $state('');
let locale = $state('da');
// --- passkeys -------------------------------------------------------------------
let passkeys = $state([]);
let passkeyName = $state('');
let passkeyError = $state('');
let registering = $state(false);
let passkeySupported = $state(true);
$effect(() => {
passkeySupported = supported();
if (!passkeySupported) return;
api
.listPasskeys()
.then((r) => {
passkeys = r.passkeys;
// The server has the other half of the answer: this browser can do
// passkeys, and this deployment may still be on an address no
// authenticator will accept.
passkeySupported = r.available;
})
.catch(() => {});
});
async function addPasskey(event) {
event.preventDefault();
passkeyError = '';
registering = true;
try {
passkeys = [...passkeys, await register(passkeyName.trim())];
passkeyName = '';
} catch (e) {
// A cancelled prompt is not a failure — the person changed their mind,
// and an error message for that reads as though something broke.
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
passkeyError = humanMessage(e);
}
} finally {
registering = false;
}
}
async function removePasskey(key) {
if (!confirm(t('passkey.removeQuestion', { name: key.name }))) return;
const previous = passkeys;
passkeys = passkeys.filter((k) => k.id !== key.id);
try {
await api.deletePasskey(key.id);
} catch (e) {
passkeys = previous;
app.toast(humanMessage(e));
}
}
let profileErrors = $state({});
let profileSaved = $state(false);
let savingProfile = $state(false);
@@ -160,7 +215,7 @@
async function endSession(session) {
if (session.current) {
if (!confirm('Det er denne enhed. Du bliver logget ud.')) return;
if (!confirm(t('account.signOutThisDevice'))) return;
await api.endSession(session.id);
// A full reload rather than clearing the store by hand: the session is
// gone, so every open socket and every cached answer in this tab is now
@@ -187,16 +242,6 @@
* older than a week gets its date instead: "for 23 dage siden" is a number
* nobody can place.
*/
function ago(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
}
</script>
<section class="panel">
@@ -413,6 +458,47 @@
{/if}
</section>
<!-- Between two-factor and the device list, because it belongs to both: a passkey
is a way in, and it is a thing on a device you might later want to revoke. -->
<section class="panel">
<header>
<h2>{t('passkey.title')}</h2>
<p class="hint">{t('passkey.hint')}</p>
</header>
{#if !passkeySupported}
<p class="hint">{t('passkey.unavailable')}</p>
{:else}
{#if passkeys.length}
<ul class="list">
{#each passkeys as key (key.id)}
<li>
<div class="what">
<span class="primary-line">{key.name}</span>
<span class="secondary">
{key.user_verified ? t('passkey.bothFactors') : t('passkey.possessionOnly')}
·
{key.last_used_at ? t('passkey.lastUsed', { when: ago(key.last_used_at) }) : t('passkey.neverUsed')}
</span>
</div>
<button class="secondary" onclick={() => removePasskey(key)}>{t('passkey.remove')}</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">{t('passkey.none')}</p>
{/if}
<form class="row" onsubmit={addPasskey}>
<input bind:value={passkeyName} placeholder={t('passkey.namePlaceholder')} aria-label={t('passkey.name')} />
<button class="primary" type="submit" disabled={registering}>
{registering ? t('passkey.registering') : t('passkey.add')}
</button>
</form>
{#if passkeyError}<p class="error">{passkeyError}</p>{/if}
{/if}
</section>
<section class="panel">
<header>
<h2>{t('account.devices')}</h2>
@@ -138,7 +138,7 @@
}
async function deleteTemplate(template) {
if (!confirm(`Slet skabelonen "${template.name}"?`)) return;
if (!confirm(t('data.deleteTemplate', { name: template.name }))) return;
const previous = templates;
templates = templates.filter((t) => t.id !== template.id);
@@ -14,7 +14,7 @@
* audit log must not have.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { app, projectName } from '$lib/stores.svelte.js';
import { eventName, eventDetail } from '$lib/events.js';
import { t } from '$lib/i18n.svelte.js';
import { ago } from '$lib/when.js';
@@ -130,7 +130,7 @@
<select bind:value={filterProject}>
<option value="">{t('history.all')}</option>
{#each projects as project (project.id)}
<option value={project.id}>{project.name}</option>
<option value={project.id}>{projectName(project)}</option>
{/each}
</select>
</label>
@@ -125,7 +125,7 @@
}
async function disconnectGmail() {
if (!confirm('Afbryd forbindelsen til Gmail? Tokens bliver glemt.')) return;
if (!confirm(t('int.disconnectQuestion'))) return;
try {
await api.disconnectGmail();
gmail = await api.getGmail();
@@ -153,7 +153,7 @@
});
async function rotateFeed() {
if (!confirm('Nyt feed-link? Alle eksisterende abonnementer holder op med at virke.')) return;
if (!confirm(t('int.newLinkQuestion'))) return;
try {
feedURL = (await api.rotateFeed()).url;
} catch (e) {
@@ -162,7 +162,7 @@
}
async function rotateMail() {
if (!confirm('Ny adresse? Mail til den gamle bliver afvist.')) return;
if (!confirm(t('int.newAddressQuestion'))) return;
try {
const r = await api.rotateMailAddress();
mailAddress = r.address;
@@ -175,9 +175,9 @@
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
app.toast('Kopieret.');
app.toast(t('int.copied'));
} catch {
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
app.toast(t('int.copyFailed'));
}
}
</script>
@@ -89,6 +89,55 @@
let version = $state(null);
// --- restarting from here ---------------------------------------------------
let panel = $state(null);
let restarting = $state(false);
let restartNote = $state('');
$effect(() => {
api
.panelStatus()
.then((p) => (panel = p))
.catch(() => {});
});
/**
* Asks the panel to recreate this container, then waits for it to come back.
*
* The reply may never arrive — the panel stops this container as part of
* answering — so the answer is not believed either way. `/healthz` is polled
* until it responds, which is the only thing that actually says "it is up".
*/
async function restart() {
if (!confirm(t('update.restartQuestion'))) return;
restarting = true;
restartNote = t('update.asking');
try {
await api.restartFromPanel();
} catch (e) {
// A request cut off mid-flight is the successful case wearing a
// failure's clothes. The poll below decides.
restartNote = humanMessage(e);
}
restartNote = t('update.waiting');
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 2000));
try {
const response = await fetch('/healthz', { cache: 'no-store' });
if (response.ok) {
location.reload();
return;
}
} catch {
// Still down, which is expected for the first few seconds.
}
}
restarting = false;
restartNote = t('update.stillDown');
}
$effect(() => {
api.version().then((v) => (version = v)).catch(() => {});
});
@@ -182,6 +231,20 @@
{/if}
</p>
<!-- Restarting is what makes a new version arrive: a container cannot replace
its own image, and the panel pulls `:latest` when it recreates one. The
button is here rather than in another browser tab. -->
{#if panel?.configured}
<div class="row">
<button class="secondary" onclick={restart} disabled={restarting}>
{restarting ? t('update.restarting') : t('update.restart')}
</button>
{#if restartNote}<span class="saved">{restartNote}</span>{/if}
</div>
{:else if panel}
<p class="hint">{t('update.notConfigured', { missing: panel.missing.join(', ') })}</p>
{/if}
{#if version.update_available}
{#if version.notes}
<p class="notes">{version.notes}</p>
@@ -60,7 +60,7 @@
} catch {
// Insecure context, or permission refused. The token is on screen and
// selectable, so this is a convenience that failed, not the feature.
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
app.toast(t('int.copyFailed'));
}
}
@@ -69,12 +69,12 @@
await navigator.clipboard.writeText(connectorURL);
connectorCopied = true;
} catch {
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
app.toast(t('int.copyFailed'));
}
}
async function revoke(token) {
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
if (!confirm(t('tokens.revokeQuestion', { name: token.name })))
return;
const previous = tokens;
+40 -9
View File
@@ -3,7 +3,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app, completedView } from '$lib/stores.svelte.js';
import { app, completedView, projectName } from '$lib/stores.svelte.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { eventName, eventDetail } from '$lib/events.js';
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
@@ -188,9 +188,9 @@
async function remove() {
const days = 30;
const confirmed = confirm(
`Slet "${project.name}" med alle dets opgaver?\n\n` +
`Det havner i papirkurven og kan hentes tilbage under ` +
`Indstillinger → Data og skabeloner i ${days} dage.`
t('project.deleteQuestion', { name: projectName(project) }) +
'\n\n' +
t('project.deleteRecoverable', { days })
);
if (!confirmed) return;
@@ -402,7 +402,12 @@
}
</script>
<div class="view">
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
in CSS: this was `.view:has(.board)` for as long as the board has existed and
never once matched, because `.board` is inside a child component and Svelte
scopes a selector to the component that wrote it. The rule read correctly,
compiled, and could not fire. Kommende has done it this way all along. -->
<div class="view" class:wide={mode !== 'list'}>
{#if project}
<header>
<!-- A heading first, a field second. Swapping the h1 out for an input
@@ -429,10 +434,10 @@
<h1 class:renameable={isOwner}>
{#if isOwner}
<button onclick={() => (editing = true)} title={t('project.clickToRename')}>
{project.name}
{projectName(project)}
</button>
{:else}
{project.name}
{projectName(project)}
{/if}
</h1>
{/if}
@@ -816,6 +821,12 @@
padding: var(--s6) var(--s4) var(--s8);
}
/* A board and a month grid need the room a reading column deliberately
withholds. Same allowance Kommende makes. */
.view.wide {
max-width: 1400px;
}
/* A board and a month grid need the room; a list is easier to read narrow. */
/* Wraps, and the title keeps a floor.
@@ -1262,19 +1273,39 @@
margin-bottom: var(--s2);
}
/* A band rather than a rule.
*
* Small uppercase text over a hairline is 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 below sit against the page.
*
* `--surface`, not a tint of the accent: the colour on this page belongs to the
* project, and a section is not a second thing to colour. What is wanted is
* separation, and a step in lightness gives that in all five themes without
* anybody measuring anything.
*/
.section-head {
display: flex;
align-items: baseline;
gap: var(--s3);
border-bottom: 1px solid var(--line);
margin-bottom: var(--s2);
background: var(--surface);
border-radius: var(--radius);
padding: var(--s2) var(--s3);
margin-bottom: var(--s1);
/* Pulled out to the padding of the rows below, so the band lines up with the
text it heads rather than with the rows' own inset. */
margin-left: calc(var(--s2) * -1);
margin-right: calc(var(--s2) * -1);
}
.section-head h2 {
border-bottom: 0;
margin-bottom: 0;
padding: 0;
flex: 1;
min-width: 0;
color: var(--ink-muted);
}
/* Hidden until the section is hovered or something in it has focus: a heading