Commit Graph
6505 Commits
Author SHA1 Message Date
Marc-André Bombeck 3bb08c3c8a fix(settings): hold the channel test until changes are saved
The webhook, ntfy, email and Telegram test routes send through the
saved channel config. After a field changed, the card kept showing the
Saved note from the last save and Test still ran, so it reported on the
old settings: a webhook switched to the Gotify format was tested with
the generic body and Gotify refused it. The note now disappears on a
change, and the test button stays off with a save-first hint while the
form differs from what is saved. Refs #947
2026-09-13 18:28:47 +02:00
Marc-André Bombeck d4766a580b Merge pull request #963 from MBombeck/release/v1.38.21
Release v1.38.21
v1.38.21
2026-09-13 15:38:09 +02:00
Marc-André Bombeck 8c8e03a6eb chore(release): v1.38.21 — channel tests say why they failed, and Gotify gets its format 2026-09-13 15:18:20 +02:00
Marc-André Bombeck 669058d37c Merge the review fixes for channel tests 2026-09-13 15:17:36 +02:00
Marc-André Bombeck 3dece61cc8 Merge the review fix for record settings the grant cannot manage 2026-09-13 15:17:36 +02:00
Marc-André Bombeck d4fa7d5913 Merge the review fixes for the service worker and the Loki note 2026-09-13 15:17:36 +02:00
Marc-André Bombeck 60dfffc7ad docs(api): name each channel's own credentials in test errors
The ntfy and webhook 502 descriptions shared one list of refused credentials that neither channel fully has. Each now names its own, including the userinfo and long path segments of a webhook URL. The email description names the TLS, protocol and envelope cases.

Refs #947
2026-09-13 14:50:33 +02:00
Marc-André Bombeck 32eaf7a29e fix(admin): show each channel's cause in the notification test toast
The admin test response carried a code and status per channel that nothing rendered. The toast now lists each failing channel with the translated cause and the HTTP status or SMTP code. The relay reply is no longer part of that response, and the ntfy error no longer echoes the topic, which the sender treats as a secret.

Refs #947
2026-09-13 14:50:33 +02:00
Marc-André Bombeck 2480c9fa55 fix(notifications): refuse encoded and path-borne secrets in replies
Userinfo and each query value are decoded on their own, so one malformed escape no longer discards every other secret in the URL. A reply is compared with each secret in literal, JSON-escaped and percent-encoded form, and URL path segments of 16 or more characters count as secrets, which covers the token in a Discord or Slack webhook URL and an n8n webhook id.

Refs #947
2026-09-13 14:50:33 +02:00
Marc-André Bombeck f81cdf8768 fix(notifications): name TLS, protocol and envelope faults in email tests
A STARTTLS failure (port 465 set up as 587) and a protocol error now read as connection_failed, and an envelope or message refused without a reply code as upstream_rejected. They used to fall through to the 500 for an unnamed failure.

Refs #947
2026-09-13 14:50:33 +02:00
Marc-André Bombeck a11e5991cf test(settings): read a real failure response through the test button
The fetch and envelope reading move into runConnectionTest, which the button calls. A test stubs the fetch with the 502 envelopes the notification test routes send and asserts the rendered code, HTTP status, SMTP code and relay reply, so dropping the meta extraction fails it.

Refs #947
2026-09-13 14:50:32 +02:00
Marc-André Bombeck 6a663dfc27 fix(notifications): record a relay refusal at warn, not error
A 502 from a channel test reports the user's relay or mail setup, not a fault on this server, yet the status alone raised every press to an error-level event. The event builder gains a ceiling for the status-derived level, and the test routes cap it at warn on the 502 path. The 500 for an unnamed failure stays at error, and explicit elevations are unaffected.

The notifications guide notes that a reverse proxy which replaces 5xx pages hides the detail, so the card falls back to Test failed.

Refs #947
2026-09-13 14:50:32 +02:00
Marc-André Bombeck 45ee70acb7 fix(settings): list record content only where the grant manages it
The anamnesis page writes through requireRecordAuth("manage",
"profile"), which refuses a grant whose sections do not reach profile.
The Settings list and the navigation entry checked only the level and
the record kind, so a MANAGE grant scoped away from profile would be
offered a page whose saves are refused.

Record-content destinations now name the section they write, and are
listed only when the active record's manageable sections include it.
A destination without a named section is never listed. The shell and
the navigation read the same predicate. MANAGE grants are whole-record
today, so managed profiles and adult MANAGE shares list what they did
before.

Refs #939
2026-09-13 14:49:56 +02:00
Marc-André Bombeck 873e9cb4fb fix(pwa): keep a subscribed worker registered behind a gateway
A same-origin auth gateway that answers an expired session with a 200
sign-in page returns HTML for both the navigation and the /api/version
probe, which read as another app. The worker then unregistered and
destroyed the Web Push subscription, which nothing re-creates, so
reminders stopped without notice.

The worker no longer unregisters while the registration holds a push
subscription, or when the lookup is unavailable. It deletes its caches
and passes requests to the network for the rest of its life instead.
The probe now sends same-origin credentials, so a valid gateway cookie
reaches HealthLog.

Refs #847
2026-09-13 14:45:31 +02:00
Marc-André Bombeck b1331de130 docs(env): name the unspecified address among refused Loki hosts
The Loki block listed link-local and metadata addresses as the only
refused destinations; 0.0.0.0 and :: are refused too.

Refs #947
2026-09-13 14:45:30 +02:00
Marc-André Bombeck 6b146c25d9 fix(settings): stop telling people the webhook URL must be public
A notification target on a private network has been reachable since the
operator allowlist for private origins arrived; the hint under the URL
field and the guide still said the address had to be public.
2026-09-13 14:31:24 +02:00
Marc-André Bombeck 4306bf1aec Merge channel tests that report the other end's answer, and the Gotify format 2026-09-13 14:30:14 +02:00
Marc-André Bombeck f89dda81a6 test: point the prefix drift guard at the shared matcher
The guard read src/lib/idempotency.ts for the hle_ prefix, which moved to src/lib/secret-shape.ts with the matcher. It now asserts that the shared matcher refuses an hle_ token and that the idempotency cache imports and calls it, so a private copy of the pattern would fail it too.
2026-09-13 14:24:37 +02:00
Marc-André Bombeck e788b53be0 docs(notifications): describe what the webhook sends and Gotify setup
The outbound payload was documented nowhere. The webhook section now shows the generic body and the Gotify body as the Test button sends them, what each field carries, and how to point the webhook at Gotify: a URL ending in /message, the application token in an X-Gotify-Key header, and the Gotify format.

Refs #947
2026-09-13 14:19:48 +02:00
Marc-André Bombeck fafb5cdc08 feat(notifications): add a Gotify payload format to the webhook
Gotify's POST /message types priority as an integer, so the generic body, which sends the word, was refused with a 400 before the token was ever the question. The webhook card now offers a payload format: generic, which stays byte for byte what it was, and gotify, which sends title, message, an integer priority and extras.

Priorities follow the Gotify Android client's channel thresholds: 5 for routine events (audible), 8 for medication reminders (the lowest heads-up value), 10 for urgent events. The extras declare plain text and carry the event name, masked to reminder in discreet mode as the generic body does.

The choice lives in the encrypted channel config, so no migration: an absent format is generic, only gotify is written, and a save that omits the field keeps the stored choice. GET returns the resolved value. The sender comment no longer suggests an Authorization Bearer header for Gotify, which takes X-Gotify-Key or a token query parameter.

Refs #947
2026-09-13 14:19:48 +02:00
Marc-André Bombeck f1aebcc3bf Merge private Loki shipping and the service worker that leaves a foreign origin 2026-09-13 14:17:25 +02:00
Marc-André Bombeck e34c5db45e fix(pwa): confirm a foreign origin before the worker retires
A reverse proxy with a response-header allowlist strips
X-HealthLog-Page from every real HealthLog page, and the worker then
unregistered on each load and dropped Web Push subscriptions.

A successful page without the marker now only sends requests to the
network while the worker asks /api/version with no-store and a manual
redirect. It deletes its caches and unregisters only when the answer
positively is not HealthLog: a 404, a non-JSON 2xx, or JSON without
the version shape. A failed probe, any other status or the HealthLog
shape resumes normal service. buildSha may be null on a self-built
image, so only its presence is checked.

Refs #847
2026-09-13 14:16:52 +02:00
Marc-André Bombeck 92606f6409 feat(settings): show the status and reply of a failed channel test
The test-connection callout now appends the HTTP status or SMTP reply code the route reports, and shows what the relay answered on a line beneath it. The reply is rendered as a React text child, so markup in it is shown as characters.

Adds the upstream_rejected message in all seven locales.

Refs #947
2026-09-13 14:13:55 +02:00
Marc-André Bombeck dd5dd1e45e docs(nav): correct two comments about utilities and manage invites
The More hub carries feature destinations only; Settings and
Notifications sit in the top-bar menu. The browser-only refusal for a
manage invitation comes from the transport check, which runs before and
independently of the second-factor step-up.
2026-09-13 14:12:54 +02:00
Marc-André Bombeck 7d40103616 Merge Settings inside a profile you look after 2026-09-13 14:12:18 +02:00
Marc-André Bombeck f6a890df8a fix(notifications): answer a failed channel test with its cause
The webhook, ntfy and email test routes turned every delivery failure into a bare 500, so the settings card could only say "Test failed" while the server knew the relay had answered 400. A failure at the other end, or on the way to it, is now a 502 with meta.errorCode, the upstream HTTP status or SMTP reply code, and for webhook and ntfy what the relay answered when that is short and holds no credential. The codes reuse the test card's vocabulary; upstream_rejected is new, for a relay that accepted the connection and refused the payload. The private-origin 422 is unchanged, and only a failure the server cannot name stays a 500.

The guard that pinned the bare 500 now asserts the opposite, with the reason in its name.

The admin fan-out test reports the same code and status per channel, and tests the webhook and email channels, which it used to list as an unknown channel type.

Refs #947
2026-09-13 14:10:35 +02:00
Marc-André Bombeck c93a384db8 Merge pull request #933 from MBombeck/dependabot/npm_and_yarn/tanstack/react-query-5.102.8
chore(deps): bump @tanstack/react-query from 5.101.4 to 5.102.8
2026-09-13 14:09:38 +02:00
Marc-André Bombeck 8c345f600e feat(notifications): keep what the relay answered on a failed send
The webhook and ntfy senders now carry up to 200 characters of a non-2xx response body on the send outcome, with control and formatting characters removed. The body is dropped when it looks like a credential or echoes the header value, auth token, topic or a URL token. Nothing stores it; the ledger row and channel state keep the reason code as before.

Transport faults without a status now say whether they were a timeout or a refused connection, and the email sender maps SMTP failures into the same vocabulary with the reply code. Both exist so the test routes can answer with the cause instead of a bare 500.

Refs #947
2026-09-13 14:04:40 +02:00
Marc-André Bombeck 4a49120fcf fix(i18n): say the manage invite asks a second factor only if set
Offering manage access is refused outside a browser session, and the
second factor is asked only when the account has one. The note under
the manage option and the browser-only error said the factor was
always asked. Reworded in all seven locales.
2026-09-13 14:03:21 +02:00
Marc-André Bombeck 60a5783534 test(e2e): walk to a managed profile's Modules settings
Switch into the managed record, follow the sidebar Settings entry to
the profile card and on to the Modules card, then repeat from the
phone-width user menu. Asserts the entry's target, the absent
Notifications entry and the record id on each card, after the shell
has loaded.

Refs #939
2026-09-13 14:03:17 +02:00
Marc-André Bombeck 9dcef3b0e2 fix(nav): offer Settings inside a record that has Settings pages
A self-hoster looking after a managed profile could not find Settings
after switching into it: the utility entries vanished for every shared
record, while the Settings shell lists the profile's own configuration
and the section gate admits it.

The utility tail now answers for the record on screen. Settings is
offered when the shell lists at least one section for it and links to
the first one: the profile card for a managed profile, the anamnesis
page for an adult share at manage. Read and write shares, and a
refused or pending switch, get no utility. Notifications stays hidden
under every switch. The desktop sidebar footer and the mobile user
menu read the same list; the footer identity block stays a plain
block.

Refs #939
2026-09-13 14:03:16 +02:00
Marc-André Bombeck f5fc713783 refactor(settings): share the record section predicate
Move the rule that decides which Settings sections a shared record
lists out of the settings shell and into the record-settings
classification module, next to the inventory it reads. The shell
filters through it unchanged; the navigation reads the same answer
in the next change, so the two cannot disagree about whether a record
has Settings pages and where they start.

Refs #939
2026-09-13 14:02:56 +02:00
Marc-André Bombeck cd44a85537 fix(pwa): let the service worker leave an origin HealthLog left
The worker is registered with scope / and serves /_next/static/*
cache-first. A self-hoster who stopped HealthLog and started another
app on the same port kept getting HealthLog's cached chunks until they
unregistered the worker by hand.

The proxy now marks every page response with a presence-only
X-HealthLog-Page header. When a successful navigation arrives without
it, the worker stops handling requests for the rest of its life,
deletes its own caches and unregisters, and hands the page through
untouched. A network failure keeps the offline fallback, and a
redirect or error status is never read as another app, so a gateway
in front of a stopped HealthLog does not drop the Web Push
subscription.

Refs #847
2026-09-13 14:00:11 +02:00
Marc-André Bombeck afb26251e5 fix(logging): ship to a private Loki and report failed pushes
LOKI_ENDPOINT is read only from the server environment, so the push now
dials its exact origin through the pinned operator-approved resolver
instead of the public-host pin that refused a Loki on the operator's
own network. Link-local, metadata and unspecified addresses stay
refused.

A thrown push or a non-2xx answer used to vanish in an empty catch. It
now writes one line to stderr per reason at most every five minutes,
naming the Loki origin, the reason and the dropped event count, without
credentials. The endpoint accepts both the base URL and the full push
URL; the second form used to double the path into a silent 404.

Refs #947
2026-09-13 13:57:33 +02:00
Marc-André Bombeck 15f0e499f9 refactor: share the secret-shape matcher from one leaf
The idempotency cache refuses to persist a body carrying a HealthLog token or an AI provider key. The notification test routes are about to refuse echoing such text too, so the pattern moves to src/lib/secret-shape.ts instead of being written a second time. Behaviour of the cache is unchanged.
2026-09-13 13:56:58 +02:00
dependabot[bot] fabdfea6e2 chore(deps): bump @tanstack/react-query from 5.101.4 to 5.102.8
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.101.4 to 5.102.8.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.102.8/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.102.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-13 11:50:05 +00:00
Marc-André Bombeck 75760b606a Merge pull request #962 from MBombeck/docs/compose-run-migrations
Run the manual migration inside the stack
2026-09-13 13:21:49 +02:00
Marc-André Bombeck 6a5ad9d9dc docs(ops): run the manual migration inside the stack
The update runbook ran migrations with a plain docker run and an
.env.production file. No setup guide creates that file, and a plain
docker run starts outside the Compose network, where the database's
service name does not resolve, so the step waited thirty seconds and
gave up with a connection error.

It uses docker compose run now, which starts the one-off container in
the stack with the app's own environment. Refs #961
2026-09-13 13:03:41 +02:00
Marc-André Bombeck d1bab391fc Merge release v1.38.20
Signing in no longer tells anybody which addresses have an account here.
v1.38.20
2026-09-11 16:41:29 +02:00
Marc-André Bombeck 0568c76076 chore(release): v1.38.20 — signing in no longer says who has an account here
Bump the version, the service-worker fallback and the OpenAPI document,
and add the CHANGELOG entry for v1.38.20.
2026-09-11 16:15:58 +02:00
Marc-André Bombeck 9bf78e32d6 Merge remote-tracking branch 'origin/main' into fix/close-account-enumeration 2026-09-11 16:01:25 +02:00
Marc-André Bombeck 660d6b39be Merge release v1.38.19
The first run tells you what is actually true about your account, an invitation link behaves, and the AI you ask a question to has budget left when you ask it.
v1.38.19
2026-09-11 14:56:05 +02:00
Marc-André Bombeck 93874af99b fix(api): record the retirement against the release that carries it
v1.38.19 was cut before this change and still answers on the path, so
naming it as the version that removed the route would be wrong in the
published contract from the day it appears.
2026-09-11 14:39:30 +02:00
Marc-André Bombeck 625763982b Narrow a spent email budget to the field, and record both refusals
A refused address change returned before the update was built, so every
other field in the same request went with it. The settings form posts
the whole profile at once, so somebody who had spent the budget could
not save a timezone or a height for an hour and was told it was about
email addresses. The address is now dropped and the rest of the save
lands, reported through the existing rejectedFields key with the code
`rate_limited`; the 429 is kept for a request that asked for nothing
else, where a partial success would be a lie.

The comment claiming an audit row beside each question was inverted:
both refusals returned before the row is written, so only the answers
that told the caller nothing were being recorded. The two answers worth
detecting write their own rows now.

The stored address is normalised before the "has it changed" comparison.
Registration writes it as typed, so an account created with capitals did
not match its own address and paid for its first ordinary save.
2026-09-11 14:36:58 +02:00
Marc-André Bombeck 3ace3aad35 Answer 410 for the removed account-discovery path
The path stood in the published contract, so a caller outside this
repository can hold a reference to it whether or not we can name one. A
bare 404 reads as a broken deploy or a misrouted proxy, which is exactly
the reading that kept a client retrying a removed path for twelve days
once before. Registering the retirement makes the edge answer 410 with
the removing version and no replacement, and republishes the path as
gone rather than erasing it from the document.
2026-09-11 14:36:47 +02:00
Marc-André Bombeck 6a4e1f61c8 chore(release): v1.38.19 — the setup says what is true, and the AI has budget left when you ask
Bump the version, the service-worker fallback and the OpenAPI document,
and add the CHANGELOG entry for v1.38.19.
2026-09-11 14:36:38 +02:00
Marc-André Bombeck 10d6dc8099 Guard the dummy mint, not only the dummy verification
The dummy hash was awaited as an argument to the verifier, so a rejected
mint threw before the verifier was reached and before the catch on it
could apply. The promise was created once at import, and a rejected
promise stays rejected, so one failed mint under memory pressure would
have made every unknown identifier answer 500 while a real account
answered 401 — a cleaner oracle than the timing gap this closes, and
passkey-only accounts locked out of the password form until restart.

The mint is now awaited inside the guarded region, is lazy rather than
an import-time cost in every process that touches this module, and is
dropped on failure so the next refusal mints again instead of inheriting
a cached rejection. A failure leaves a log line; the answer is false
either way. The tests cover a rejecting mint, not only a rejecting
verify.
2026-09-11 14:36:27 +02:00
Marc-André Bombeck 80426697db Record why registration checks duplicates before hashing
With the discovery endpoint gone, this is the remaining anonymous path
that answers whether an address is registered. The 409 already gives
that away, so the work asymmetry ahead of it costs nothing today. It
would become the whole answer the moment somebody unified the status
code without moving the check after the hash, so the ordering now says
it is load-bearing.
2026-09-11 14:36:19 +02:00
Marc-André Bombeck 4460047910 Merge the coach cap counting only the tokens the operator paid for
Background insights can no longer use up the day before somebody asks a question; a fifth of the daily limit is held back for what a person does by hand.
2026-09-11 14:18:21 +02:00
Marc-André Bombeck a602437649 Merge the ledger wait
The test slept fifty milliseconds and read; it waits for the rows now.
2026-09-11 14:13:06 +02:00