diff --git a/.env.production.example b/.env.production.example
index 0d5f7a2c5..c87444ee7 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -521,10 +521,17 @@ API_TOKEN_HMAC_KEY=""
# LOG_SLOW_THRESHOLD_MS="3000"
# LOG_INCLUDE_STACK="false"
-# Optional log shipping to a Loki instance. LOKI_ENDPOINT is the push URL and
-# is what turns shipping on; the username/password pair is only needed when
-# your Loki sits behind basic auth. Leave all three unset and logs stay on
-# stdout for the Docker log driver to rotate.
+# Optional log shipping to a Loki instance. LOKI_ENDPOINT turns shipping on.
+# Either form works: the base URL (http://loki:3100) or the full push URL
+# (http://loki:3100/loki/api/v1/push). A Loki on your own network is fine,
+# private addresses and internal hostnames included; only link-local,
+# cloud-metadata and the unspecified address (0.0.0.0, ::) are refused. The
+# username/password pair is only needed when your Loki sits behind basic
+# auth. When a push fails (Loki down, wrong path, rejected credentials) the
+# app writes one line to stderr naming the Loki origin, the reason and how
+# many events were dropped, at most once every five minutes per reason; the
+# password never appears in it. Leave all three unset and logs stay on stdout
+# for the Docker log driver to rotate.
# LOKI_ENDPOINT=""
# LOKI_USERNAME=""
# LOKI_PASSWORD=""
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 828e226ea..cf318e847 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,95 @@
# Changelog
+## [1.38.21] — 2026-09-13
+
+A channel test says why it failed, a Gotify server gets the message it
+expects, and Settings is back inside a profile you look after.
+
+### Added
+
+- **The webhook can send in Gotify's format.** Gotify expects the priority
+ as a number and refused HealthLog's messages, which carry it as a word.
+ Choose Gotify under Payload format on the webhook card and the message
+ arrives the way Gotify wants it: routine events with sound, medication
+ reminders as a banner on Gotify's Android app, urgent events at the top
+ of the scale. Existing webhooks keep sending exactly what they sent
+ before. The notifications guide now shows both message formats and how
+ to set up Gotify, with the URL ending in `/message` and the token in the
+ `X-Gotify-Key` header. Thanks to @sreeramachandramurthy for #947.
+
+### Fixed
+
+- **Pressing Test on the webhook, ntfy or email card now tells you what
+ went wrong.** When the other end refused the message, the card said
+ "Test failed" and nothing else, although the server knew the answer; one
+ self-hoster had to dig through the container log to learn that their
+ relay had answered 400. The card now names the cause, such as a refused
+ token, a wrong address, a relay that rejected the message or a mail
+ server that did not answer in time, with the status code. For webhook
+ and ntfy it also quotes the first part of the relay's own error message,
+ unless it looks like a token or contains one of your saved secrets. Real
+ deliveries were never affected; only the test button hid the reason.
+ The notification test in the admin area shows the same cause for each
+ channel, and a relay refusing a test no longer counts as a server error
+ in the operator's log. Thanks to @sreeramachandramurthy for #947.
+- **The hint under the webhook address no longer says it must be public.**
+ A target on your own network works when the server operator lists it in
+ `NOTIFICATION_PRIVATE_ORIGINS`, which has been possible since 1.38.17.
+
+- **Settings is back in the menu while you are inside a profile you look
+ after.** Switching into a managed profile removed Settings from the menu
+ entirely, on the desktop and on the phone, even though the profile's own
+ settings page existed and worked: which modules it tracks, its targets and
+ where its notifications go. The only way there was typing the address.
+ The menu now offers Settings inside that profile and opens the profile's
+ own page. If someone gave you manage access to their own account, Settings
+ opens their medical history, the one settings page that access covers.
+ Notifications stays out of the menu while you are inside someone else's
+ record. Thanks to @sreeramachandramurthy for asking in #939.
+- **The note on a manage invitation says when a second factor is needed.**
+ It said offering manage access always asks for your second factor. It
+ asks only if you have one set up; that it works only in a browser is true
+ either way.
+
+- **A different app on the address where HealthLog used to run now loads
+ normally.** If you stopped HealthLog and started something else on the
+ same address and port, HealthLog's offline worker in your browser kept
+ answering: the other app got HealthLog's cached files and your changes to
+ it never showed up, until you removed the worker by hand in the browser's
+ developer tools. The worker now checks whether the page still comes from
+ HealthLog, and when it does not, it clears its cache and removes itself.
+ When HealthLog is only down, or a reverse proxy in front of it answers
+ with an error, nothing changes and the offline page still appears. If
+ you had push notifications switched on in that browser, the worker stays
+ registered and keeps them, and only stops serving its cached files. Thanks
+ to @el-abcd for describing this in #847.
+- **If you ship logs to Loki, a Loki on your own network works now, and a
+ failed push says so.** Only public addresses were allowed, so a Loki on
+ the same Docker network or on your LAN was refused without a word, and
+ events were dropped. `LOKI_ENDPOINT` comes from your server environment,
+ so the address you set there is now trusted as it is; link-local and
+ cloud metadata addresses stay refused. It also takes either the base URL
+ or the full push URL ending in `/loki/api/v1/push`; pasting the full URL
+ used to break shipping just as quietly. When a push fails, the container
+ log gets one line with the Loki address, the reason and how many events
+ were lost, at most every five minutes per reason, and never the password.
+ Thanks to @sreeramachandramurthy for the empty Grafana in #947.
+
+### Changed
+
+- **If you write against the API, a failed channel test answers 502 with
+ the cause.** `POST /api/settings/webhook/test`, `/ntfy/test` and
+ `/email/test` used to answer 500 for every refusal from the other end.
+ They answer 502 now, with `meta.errorCode` (one of
+ `credentials_rejected`, `endpoint_not_found`, `rate_limited`,
+ `upstream_error`, `upstream_rejected`, `redirected`, `timeout`,
+ `connection_failed`), `meta.upstreamStatus`, and for webhook and ntfy
+ `meta.upstreamBody` with at most 200 characters of the relay's reply;
+ email carries `meta.smtpCode` instead. The 422 for a private address
+ that is not allowed is unchanged, and 500 is left for failures the
+ server cannot name. `GET /api/settings/webhook` gains `format`
+ (`generic` or `gotify`); a `PUT` without it keeps the stored value.
+
## [1.38.20] — 2026-09-11
Signing in no longer tells anybody which addresses have an account here.
diff --git a/docker-compose.yml b/docker-compose.yml
index e55a024af..2ad99006b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -345,10 +345,11 @@ x-healthlog-env: &healthlog_env
LOG_SAMPLE_RATE: "${LOG_SAMPLE_RATE:-}"
LOG_SLOW_THRESHOLD_MS: "${LOG_SLOW_THRESHOLD_MS:-}"
LOG_INCLUDE_STACK: "${LOG_INCLUDE_STACK:-}"
- # Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the push
- # URL to turn it on; the username/password pair is only needed when your
- # Loki is behind basic auth. Leave all three unset and logs stay on stdout
- # for the Docker log driver to rotate.
+ # Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the base
+ # URL or the full push URL to turn it on; a Loki on your own network works.
+ # The username/password pair is only needed when your Loki is behind basic
+ # auth. Push failures show up as a rate-limited line on stderr. Leave all
+ # three unset and logs stay on stdout for the Docker log driver to rotate.
LOKI_ENDPOINT: "${LOKI_ENDPOINT:-}"
LOKI_USERNAME: "${LOKI_USERNAME:-}"
LOKI_PASSWORD: "${LOKI_PASSWORD:-}"
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index fb07ebd4e..9ae03569f 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.38.20
+ version: 1.38.21
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -12354,7 +12354,8 @@ paths:
Refuses with 400 when the instance has no SMTP transport, when the account has no email channel, or when the
- channel carries no recipient — three distinguishable prose messages on one status. A transport failure is a 500.
+ channel carries no recipient — three distinguishable prose messages on one status. A failure at the mail server
+ or on the way to it is a 502 that names its cause; only a failure the server cannot name is a 500.
responses:
"200":
description: The provider accepted the message. Not proof it was delivered.
@@ -12365,6 +12366,22 @@ paths:
"401": *a1
"422": *a4
"429": *a5
+ "500":
+ description: The send failed in a way the server could not name. An internal fault, not a statement about the mail server.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "502":
+ description: "The mail server, or the way to it, failed. `meta.errorCode` names the cause: `credentials_rejected` (the
+ server refused the SMTP login), `upstream_rejected` (a permanent 5xx rejection, e.g. an unknown mailbox or
+ relay denied, or an envelope or message refused without a reply code), `upstream_error` (a temporary 4xx
+ rejection), `timeout`, or `connection_failed` (including a TLS handshake or protocol failure).
+ `meta.smtpCode` carries the SMTP reply code when the mail server sent one."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
/api/settings/ntfy/test:
post:
tags:
@@ -12372,9 +12389,10 @@ paths:
summary: Send a test message to the saved ntfy topic
description: Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per
5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server
- URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode`
- = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`;
- the test and the scheduled delivery take the same decision.
+ URL or topic. A failure at the ntfy server or on the way to it is a 502 that carries the upstream status and,
+ when safe, what the server answered; only a failure the server cannot name is a 500. A server on a private
+ network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its
+ exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.
responses:
"200":
description: The provider accepted the message. Not proof it was delivered.
@@ -12392,6 +12410,25 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
+ "500":
+ description: The send failed in a way the server could not name. An internal fault, not a statement about the ntfy server.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "502":
+ description: "The ntfy server, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the
+ other end refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found`
+ (404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or
+ `connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and
+ `meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it
+ is omitted when the body was empty or could carry a credential: a token-shaped string, or the saved auth
+ token, the topic, or a credential in the server URL (userinfo, a query value, a path segment of 16 or more
+ characters), literal, JSON-escaped or percent-encoded."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
/api/settings/telegram/test:
post:
tags:
@@ -12421,11 +12458,12 @@ paths:
- Notifications
summary: Send a test payload to the saved webhook
description: Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per
- user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a
- 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal
- host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless
- the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take
- the same decision.
+ user. Refuses with 400 when no webhook channel is saved or the saved config has no URL. A failure at the relay
+ or on the way to it is a 502 that carries the upstream status and, when safe, what the relay answered; only a
+ failure the server cannot name is a 500. The URL was checked against the SSRF floor when it was saved, so a test
+ cannot be used to reach an internal host. A URL on a private network is refused with 422 and `meta.errorCode` =
+ `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the
+ test and the scheduled delivery take the same decision.
responses:
"200":
description: The provider accepted the message. Not proof it was delivered.
@@ -12443,6 +12481,25 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
+ "500":
+ description: The send failed in a way the server could not name. An internal fault, not a statement about the relay.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "502":
+ description: "The relay, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the other end
+ refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found`
+ (404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or
+ `connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and
+ `meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it
+ is omitted when the body was empty or could carry a credential: a token-shaped string, or the saved header
+ value (and its token part), the URL's userinfo, a query value, or a path segment of 16 or more characters
+ (the token in a Discord or Slack webhook URL), literal, JSON-escaped or percent-encoded."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
/api/settings/data:
delete:
tags:
@@ -25863,6 +25920,14 @@ components:
maxLength: 500
- type: string
const: ""
+ format:
+ description: Body shape. `generic` sends HealthLog's own JSON envelope; `gotify` sends the body Gotify's `POST /message`
+ expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved
+ before the choice existed.
+ type: string
+ enum:
+ - generic
+ - gotify
enabled:
type: boolean
required:
@@ -25872,7 +25937,7 @@ components:
re-checked at dispatch time; a private origin passes both only when the operator listed it in
`NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason
as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this
- endpoint."
+ endpoint. An omitted `format` keeps the stored body shape."
DeleteAccountRequest:
type: object
properties:
@@ -57447,11 +57512,18 @@ components:
hasHeaderValue:
type: boolean
description: Whether a header value (the shared secret) is stored. The value itself is never returned.
+ format:
+ type: string
+ enum:
+ - generic
+ - gotify
+ description: Body shape the webhook sends. `generic` when no channel row exists or the config predates the choice.
required:
- enabled
- url
- headerName
- hasHeaderValue
+ - format
additionalProperties: false
description: Stored generic-webhook channel config, with the secret header value reduced to a presence flag.
PutWebhookSettingsResponse:
diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md
index a3dd06de1..c631f5138 100644
--- a/docs/self-hosting/notifications.md
+++ b/docs/self-hosting/notifications.md
@@ -191,7 +191,7 @@ and surface on the lock screen.
The generic webhook POSTs every enabled event as JSON to a URL you own —
wire it into your own automation (Home Assistant, n8n, a Discord/Slack
-relay, whatever routes the payload). You supply a public URL and,
+relay, whatever routes the payload). You supply the URL and,
optionally, one custom header (e.g. an auth token).
1. In HealthLog open `/settings/notifications`, fill the Webhook card's
@@ -207,6 +207,80 @@ Home Assistant on your own network needs the operator grant described in
the next section. The webhook carries the event's urgency flag so you can
route urgent events differently.
+### What the webhook sends
+
+Every event is one `POST` with `Content-Type: application/json` and the
+one header you configured. It goes to the URL exactly as you saved it:
+nothing is added to the path or the query. The body depends on the
+**Payload format** you choose on the card. The examples below are what the
+Test button sends.
+
+**Generic JSON** is the default, and what every webhook saved before the
+choice existed keeps sending:
+
+```json
+{
+ "title": "HealthLog Test",
+ "message": "HealthLog: Connection successful! Webhook notifications are active.",
+ "eventType": "SYSTEM_ALERT",
+ "priority": "default"
+}
+```
+
+- `title` and `message` are plain text. HTML tags are stripped, and routine
+ reminders lose their decorative emoji.
+- `eventType` is the event name, for routing rules. With discreet cycle
+ notifications on, a cycle event arrives as `reminder`, and its title and
+ message are masked as they are on every other channel.
+- `priority` is a word: `urgent` for an event flagged urgent, `high` for
+ medication reminders, `default` for everything else.
+
+**Gotify** sends the body Gotify's `POST /message` endpoint expects:
+
+```json
+{
+ "title": "HealthLog Test",
+ "message": "HealthLog: Connection successful! Webhook notifications are active.",
+ "priority": 5,
+ "extras": {
+ "client::display": { "contentType": "text/plain" },
+ "healthlog::event": { "type": "SYSTEM_ALERT" }
+ }
+}
+```
+
+- `priority` is an integer: `5` for most events, `8` for medication
+ reminders, `10` for urgent events. The Gotify Android app plays a sound
+ from 4 and shows a heads-up banner from 8, so routine events are audible
+ and reminders pop up on screen.
+- `extras` tells Gotify clients to show the message as plain text and
+ carries the event name, which is `reminder` for a discreet cycle event.
+
+The generic body does not work with Gotify. Gotify reads `priority` as a
+number, so the word `default` makes it answer `400 Bad Request`.
+
+### Sending to Gotify
+
+1. In Gotify, create an application and copy its token.
+2. On the Webhook card, set **URL** to your Gotify address ending in
+ `/message`, for example `https://gotify.example.com/message`.
+3. Set **Header name** to `X-Gotify-Key` and **Header value** to the
+ application token. Gotify also accepts the token as `?token=` on the URL,
+ but in a header it stays out of proxy access logs.
+4. Set **Payload format** to **Gotify**, save, and press Test.
+
+If the test fails, the card shows the status Gotify answered and, when it is
+short, Gotify's own error text. A `401` means the token is wrong. A `400`
+means Gotify could not read the body; check that the format is set to
+Gotify. A Gotify on your own network also needs the operator grant below.
+
+The detail reaches the card inside the server's `502` response. A reverse
+proxy that replaces error pages for 5xx responses, such as Traefik's
+`errors` middleware or nginx with `proxy_intercept_errors on`, swaps that
+response for its own page, and the card can then only say "Test failed".
+Exclude the `/api/` paths from the error-page rule to keep the detail. This
+applies to the ntfy and email tests too.
+
## Notification targets on a private network
Every webhook and ntfy target is resolved at send time and refused when
diff --git a/e2e/v138-managed-record-capabilities.spec.ts b/e2e/v138-managed-record-capabilities.spec.ts
index 529d127c4..adedc711f 100644
--- a/e2e/v138-managed-record-capabilities.spec.ts
+++ b/e2e/v138-managed-record-capabilities.spec.ts
@@ -16,6 +16,12 @@
* control is absent and the page says why, in a sentence, where the
* control would have been.
*
+ * A third journey reaches the profile's Settings from the navigation (#939).
+ * The Settings shell has always listed a managed profile's own configuration,
+ * and the section gate has always admitted it, but no navigation led there:
+ * the utility entries disappeared for every shared record. It walks the
+ * desktop sidebar and the mobile user menu to the Modules card.
+ *
* Modelled on `v137-sharing-managed-profiles.spec.ts`; the switch helpers are
* the same ones, for the same reasons written there.
*/
@@ -137,6 +143,73 @@ test.describe.serial("a guardian's controls inside a managed profile", () => {
await leaveRecord(page);
});
+ test("reaches the profile's Modules settings from the navigation on both widths", async ({
+ page,
+ }) => {
+ await openRecord(page, managed.username);
+ const banner = page.locator('[data-slot="shared-record-banner"]');
+ await expect(banner).toHaveAttribute("data-record-kind", "managed");
+ await expect(banner).toHaveAttribute("data-access-level", "manage");
+ const accountId = await banner.getAttribute("data-account-id");
+ expect(accountId).toBeTruthy();
+
+ // The banner renders from the same `/api/auth/me` answer the navigation
+ // reads, and the shell paints nothing before it resolves. Waiting on it
+ // above is what makes every presence and absence below a statement about
+ // the loaded shell rather than about the hydration gate.
+ const sidebar = page.locator('aside[aria-label="Sidebar"]');
+ const settings = sidebar.locator('[data-slot="nav-settings-link"]');
+ await expect(settings).toHaveCount(1);
+ await expect(settings).toHaveAttribute("href", "/settings/account");
+ await expect(sidebar.locator('a[href="/notifications"]')).toHaveCount(0);
+
+ await settings.click();
+ await expect(page).toHaveURL(/\/settings\/account$/);
+ await expect(
+ page.locator('[data-record-settings-family="profile"]'),
+ ).toHaveAttribute("data-record-id", accountId as string, {
+ timeout: 30_000,
+ });
+
+ await page
+ .locator('a[href="/settings/modules"]')
+ .filter({ visible: true })
+ .first()
+ .click();
+ await expect(page).toHaveURL(/\/settings\/modules$/);
+ const modules = page.locator(
+ '[data-record-settings-family="modules"][aria-busy="false"]',
+ );
+ await expect(modules).toBeVisible({ timeout: 30_000 });
+ await expect(modules).toHaveAttribute(
+ "data-record-id",
+ accountId as string,
+ );
+
+ // The phone layout: the utilities live in the top-bar user menu there.
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/");
+ await expect(banner).toBeVisible({ timeout: 30_000 });
+ const topBar = page.locator('[data-slot="top-bar"]');
+ await expect(topBar).toBeVisible();
+ await topBar.getByRole("button", { name: "User menu" }).click();
+ const menu = page.getByRole("menu");
+ const menuSettings = menu.locator('[data-slot="nav-settings-link"]');
+ await expect(menuSettings).toHaveAttribute("href", "/settings/account");
+ // The menu is open and populated, so this absence is the menu's answer.
+ await expect(menu.locator('a[href="/notifications"]')).toHaveCount(0);
+ await menuSettings.click();
+ await expect(page).toHaveURL(/\/settings\/account$/);
+ await expect(
+ page.locator('[data-record-settings-family="profile"]'),
+ ).toHaveAttribute("data-record-id", accountId as string, {
+ timeout: 30_000,
+ });
+
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await leaveRecord(page);
+ });
+
test("withholds the vault's upload control and says why", async ({
page,
}) => {
diff --git a/messages/de.json b/messages/de.json
index 3a48fb6f1..cbc904085 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5587,9 +5587,13 @@
"webhookDescription": "Benachrichtigungen per POST an eine beliebige URL — Gotify, Discord, Slack, Matrix oder Home Assistant.",
"webhookEnable": "Webhook aktivieren",
"webhookUrl": "Webhook-URL",
- "webhookUrlHint": "Die vollständige URL für den POST. Muss öffentlich erreichbar sein.",
+ "webhookUrlHint": "Die vollständige URL für den POST. Eine Adresse im privaten Netz muss der Betreiber des Servers freigeben.",
"webhookHeaderName": "Header-Name (optional)",
"webhookHeaderValue": "Header-Wert (optional)",
+ "webhookFormat": "Nachrichtenformat",
+ "webhookFormatGeneric": "Generisches JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Nutze die URL, die auf /message endet, und trage das App-Token als Header X-Gotify-Key ein.",
"email": "E-Mail",
"emailDescription": "Warnungen und Erinnerungen per E-Mail empfangen.",
"emailEnable": "E-Mail aktivieren",
@@ -6136,6 +6140,7 @@
"rate_limited": "Vom Upstream rate-limitiert",
"timeout": "Request hat das Zeitlimit überschritten",
"upstream_error": "Upstream hat einen Fehler gemeldet",
+ "upstream_rejected": "Die Gegenstelle hat die Nachricht abgelehnt",
"connection_failed": "Verbindung fehlgeschlagen",
"not_configured": "Nicht konfiguriert",
"url_not_public": "URL ist kein öffentlicher Endpunkt",
@@ -10389,7 +10394,7 @@
"accessManageLabel": "Darf verwalten",
"accessManageCapability": "Sie kann Einträge anlegen, ändern und löschen, auch solche, die du selbst erfasst hast.",
"accessManageDetail": "Sie kann außerdem die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.",
- "manageStepUpNote": "Dafür wird dein zweiter Faktor abgefragt, deshalb geht das nur im Browser.",
+ "manageStepUpNote": "Das geht nur im Browser, und falls du einen zweiten Faktor eingerichtet hast, wird er abgefragt.",
"scopeLegend": "Was die Person öffnen darf",
"scopeAllLabel": "Gesamte Akte",
"scopeAllCapability": "Jeder Teil davon, so wie das Teilen bisher funktioniert hat.",
@@ -10413,7 +10418,7 @@
"errorRateLimited": "Zu viele Einladungen. Bitte später noch einmal versuchen.",
"errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.",
"errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.",
- "errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben, weil dabei dein zweiter Faktor abgefragt wird.",
+ "errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben.",
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
"errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier — prüfe deine Verbindung und versuche es erneut."
},
diff --git a/messages/en.json b/messages/en.json
index b2a37305f..2230b740e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5587,9 +5587,13 @@
"webhookDescription": "POST notifications to any URL — Gotify, Discord, Slack, Matrix, or Home Assistant.",
"webhookEnable": "Enable webhook",
"webhookUrl": "Webhook URL",
- "webhookUrlHint": "The full URL to POST to. Must be publicly reachable.",
+ "webhookUrlHint": "The full URL to POST to. An address on a private network needs approval from the server operator.",
"webhookHeaderName": "Header name (optional)",
"webhookHeaderValue": "Header value (optional)",
+ "webhookFormat": "Payload format",
+ "webhookFormatGeneric": "Generic JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Use the URL ending in /message and put the application token in the header X-Gotify-Key.",
"email": "Email",
"emailDescription": "Receive alerts and reminders by email.",
"emailEnable": "Enable email",
@@ -6136,6 +6140,7 @@
"rate_limited": "Rate-limited by the upstream",
"timeout": "Request timed out",
"upstream_error": "Upstream returned an error",
+ "upstream_rejected": "The other end refused the message",
"connection_failed": "Connection failed",
"not_configured": "Not configured",
"url_not_public": "URL is not a public endpoint",
@@ -10389,7 +10394,7 @@
"accessManageLabel": "Can manage",
"accessManageCapability": "They can add, change and remove entries, including ones you entered yourself.",
"accessManageDetail": "They can also read the insights generated from your record. They cannot change your login, your security settings, your connections, or who else has access. You can end this at any time.",
- "manageStepUpNote": "Offering this asks for your second factor, so it can only be done in a browser.",
+ "manageStepUpNote": "Offering this only works in a browser, and asks for your second factor if you have one.",
"scopeLegend": "What they can open",
"scopeAllLabel": "Entire record",
"scopeAllCapability": "Every part of it, the way sharing has always worked.",
@@ -10413,7 +10418,7 @@
"errorRateLimited": "Too many invitations. Please try again later.",
"errorFailed": "The invitation could not be sent. Please try again.",
"errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.",
- "errorManageBrowserOnly": "Manage access can only be offered in a browser, because it asks for your second factor.",
+ "errorManageBrowserOnly": "Manage access can only be offered in a browser.",
"errorStepUp": "Confirm your second factor, then try again.",
"errorOffline": "That did not reach the server. Your invitation is still here — check your connection and try again."
},
diff --git a/messages/es.json b/messages/es.json
index e3f4ca56c..4d28cc37f 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5587,9 +5587,13 @@
"webhookDescription": "Envía notificaciones por POST a cualquier URL: Gotify, Discord, Slack, Matrix o Home Assistant.",
"webhookEnable": "Activar webhook",
"webhookUrl": "URL del webhook",
- "webhookUrlHint": "La URL completa para el POST. Debe ser accesible públicamente.",
+ "webhookUrlHint": "La URL completa para el POST. Una dirección de una red privada necesita la aprobación del administrador del servidor.",
"webhookHeaderName": "Nombre de cabecera (opcional)",
"webhookHeaderValue": "Valor de cabecera (opcional)",
+ "webhookFormat": "Formato del mensaje",
+ "webhookFormatGeneric": "JSON genérico",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Usa la URL que termina en /message y pon el token de la aplicación en la cabecera X-Gotify-Key.",
"email": "Correo electrónico",
"emailDescription": "Recibe alertas y recordatorios por correo electrónico.",
"emailEnable": "Activar correo electrónico",
@@ -6136,6 +6140,7 @@
"rate_limited": "Limitado por el servidor remoto",
"timeout": "La solicitud agotó el tiempo de espera",
"upstream_error": "El servidor remoto devolvió un error",
+ "upstream_rejected": "El servidor remoto rechazó el mensaje",
"connection_failed": "Falló la conexión",
"not_configured": "No configurado",
"url_not_public": "La URL no es un endpoint público",
@@ -10389,7 +10394,7 @@
"accessManageLabel": "Puede gestionar",
"accessManageCapability": "Puede añadir, cambiar y eliminar entradas, incluidas las que has escrito tú.",
"accessManageDetail": "También puede leer los análisis generados a partir de tu ficha. No puede cambiar tu inicio de sesión, tus ajustes de seguridad, tus servicios conectados ni quién más tiene acceso. Puedes terminarlo cuando quieras.",
- "manageStepUpNote": "Ofrecerlo pide tu segundo factor, así que solo se puede hacer en un navegador.",
+ "manageStepUpNote": "Solo se puede hacer en un navegador, y pide tu segundo factor si tienes uno.",
"scopeLegend": "Qué puede abrir",
"scopeAllLabel": "Todo el historial",
"scopeAllCapability": "Todas sus partes, tal y como ha funcionado siempre el uso compartido.",
@@ -10413,7 +10418,7 @@
"errorRateLimited": "Demasiadas invitaciones. Inténtalo más tarde.",
"errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.",
"errorScope": "Esas secciones no se pueden compartir así. Elige al menos una sección o comparte todo el historial.",
- "errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador, porque pide tu segundo factor.",
+ "errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador.",
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
"errorOffline": "Esto no llegó al servidor. Tu invitación sigue aquí: comprueba tu conexión e inténtalo de nuevo."
},
diff --git a/messages/fr.json b/messages/fr.json
index 48fc47a50..58ecbe43e 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5587,9 +5587,13 @@
"webhookDescription": "Envoyer les notifications par POST vers n’importe quelle URL — Gotify, Discord, Slack, Matrix ou Home Assistant.",
"webhookEnable": "Activer le webhook",
"webhookUrl": "URL du webhook",
- "webhookUrlHint": "L’URL complète pour le POST. Doit être accessible publiquement.",
+ "webhookUrlHint": "L’URL complète pour le POST. Une adresse sur un réseau privé doit être autorisée par l’administrateur du serveur.",
"webhookHeaderName": "Nom d’en-tête (facultatif)",
"webhookHeaderValue": "Valeur d’en-tête (facultatif)",
+ "webhookFormat": "Format du message",
+ "webhookFormatGeneric": "JSON générique",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Utilisez l’URL se terminant par /message et placez le jeton de l’application dans l’en-tête X-Gotify-Key.",
"email": "E-mail",
"emailDescription": "Recevoir les alertes et les rappels par e-mail.",
"emailEnable": "Activer l’e-mail",
@@ -6136,6 +6140,7 @@
"rate_limited": "Limité par le service en amont",
"timeout": "Délai de la requête dépassé",
"upstream_error": "Le service en amont a renvoyé une erreur",
+ "upstream_rejected": "Le service distant a refusé le message",
"connection_failed": "Échec de la connexion",
"not_configured": "Non configuré",
"url_not_public": "L’URL n’est pas un point de terminaison public",
@@ -10389,7 +10394,7 @@
"accessManageLabel": "Peut gérer",
"accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées, y compris celles que vous avez saisies vous-même.",
"accessManageDetail": "Elle peut aussi lire les analyses tirées de votre dossier. Elle ne peut modifier ni votre connexion, ni vos paramètres de sécurité, ni vos services connectés, ni la liste des personnes qui ont accès. Vous pouvez y mettre fin à tout moment.",
- "manageStepUpNote": "Cette offre demande votre deuxième facteur, elle n'est donc possible que dans un navigateur.",
+ "manageStepUpNote": "Cette offre n'est possible que dans un navigateur, et demande votre deuxième facteur si vous en avez un.",
"scopeLegend": "Ce que cette personne peut ouvrir",
"scopeAllLabel": "Dossier complet",
"scopeAllCapability": "Toutes ses parties, comme le partage a toujours fonctionné.",
@@ -10413,7 +10418,7 @@
"errorRateLimited": "Trop d'invitations. Veuillez réessayer plus tard.",
"errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer.",
"errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.",
- "errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur, car il demande votre deuxième facteur.",
+ "errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur.",
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
"errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là — vérifiez votre connexion et réessayez."
},
diff --git a/messages/it.json b/messages/it.json
index 0e97ed7be..9003b0872 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5587,9 +5587,13 @@
"webhookDescription": "Invia le notifiche via POST a qualsiasi URL — Gotify, Discord, Slack, Matrix o Home Assistant.",
"webhookEnable": "Attiva webhook",
"webhookUrl": "URL del webhook",
- "webhookUrlHint": "L’URL completo per il POST. Deve essere raggiungibile pubblicamente.",
+ "webhookUrlHint": "L’URL completo per il POST. Un indirizzo su una rete privata deve essere autorizzato dall’amministratore del server.",
"webhookHeaderName": "Nome intestazione (opzionale)",
"webhookHeaderValue": "Valore intestazione (opzionale)",
+ "webhookFormat": "Formato del messaggio",
+ "webhookFormatGeneric": "JSON generico",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Usa l’URL che termina con /message e inserisci il token dell’applicazione nell’intestazione X-Gotify-Key.",
"email": "Email",
"emailDescription": "Ricevi avvisi e promemoria via email.",
"emailEnable": "Attiva email",
@@ -6136,6 +6140,7 @@
"rate_limited": "Limitato dal servizio upstream",
"timeout": "Richiesta scaduta",
"upstream_error": "Il servizio upstream ha restituito un errore",
+ "upstream_rejected": "Il servizio remoto ha rifiutato il messaggio",
"connection_failed": "Connessione non riuscita",
"not_configured": "Non configurato",
"url_not_public": "L’URL non è un endpoint pubblico",
@@ -10389,7 +10394,7 @@
"accessManageLabel": "Può gestire",
"accessManageCapability": "Può aggiungere, modificare ed eliminare voci, comprese quelle inserite da te.",
"accessManageDetail": "Può anche leggere le analisi generate dalla tua cartella. Non può cambiare il tuo accesso, le tue impostazioni di sicurezza, i tuoi servizi collegati né chi altro ha accesso. Puoi terminare tutto questo in qualsiasi momento.",
- "manageStepUpNote": "Per offrirlo viene richiesto il tuo secondo fattore, quindi si può fare solo da browser.",
+ "manageStepUpNote": "Si può fare solo da browser, e viene richiesto il tuo secondo fattore se ne hai uno.",
"scopeLegend": "Che cosa può aprire",
"scopeAllLabel": "Intera cartella",
"scopeAllCapability": "Ogni sua parte, come la condivisione ha sempre funzionato.",
@@ -10413,7 +10418,7 @@
"errorRateLimited": "Troppi inviti. Riprova più tardi.",
"errorFailed": "Non è stato possibile inviare l'invito. Riprova.",
"errorScope": "Queste sezioni non si possono condividere così. Scegli almeno una sezione oppure condividi l'intera cartella.",
- "errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser, perché richiede il tuo secondo fattore.",
+ "errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser.",
"errorStepUp": "Conferma il tuo secondo fattore e riprova.",
"errorOffline": "La richiesta non ha raggiunto il server. Il tuo invito è ancora qui: controlla la connessione e riprova."
},
diff --git a/messages/ko.json b/messages/ko.json
index d09db31aa..6bd7fcd53 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5587,9 +5587,13 @@
"webhookDescription": "Gotify, Discord, Slack, Matrix, Home Assistant 등 어떤 URL로든 알림을 POST해요.",
"webhookEnable": "웹훅 켜기",
"webhookUrl": "웹훅 URL",
- "webhookUrlHint": "POST를 보낼 전체 URL이에요. 외부에서 접근할 수 있어야 해요.",
+ "webhookUrlHint": "POST를 보낼 전체 URL이에요. 사설 네트워크 주소는 서버 운영자가 허용해야 해요.",
"webhookHeaderName": "헤더 이름 (선택)",
"webhookHeaderValue": "헤더 값 (선택)",
+ "webhookFormat": "메시지 형식",
+ "webhookFormatGeneric": "일반 JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "/message로 끝나는 URL을 쓰고, 앱 토큰은 X-Gotify-Key 헤더에 넣어 주세요.",
"email": "이메일",
"emailDescription": "경고와 알림을 이메일로 받아요.",
"emailEnable": "이메일 켜기",
@@ -6136,6 +6140,7 @@
"rate_limited": "상대 서버에서 요청 수를 제한했어요",
"timeout": "요청 시간이 초과됐어요",
"upstream_error": "상대 서버가 오류를 반환했어요",
+ "upstream_rejected": "상대 서버가 메시지를 거부했어요",
"connection_failed": "연결에 실패했어요",
"not_configured": "설정되지 않았어요",
"url_not_public": "URL이 공개 엔드포인트가 아니에요",
@@ -10389,7 +10394,7 @@
"accessManageLabel": "관리 가능",
"accessManageCapability": "내가 직접 입력한 것까지 포함해 기록을 추가·수정·삭제할 수 있어요.",
"accessManageDetail": "내 기록에서 만들어진 인사이트도 볼 수 있어요. 내 로그인, 보안 설정, 연동, 다른 사람의 권한은 바꿀 수 없어요. 언제든 이 권한을 끝낼 수 있어요.",
- "manageStepUpNote": "이 권한을 주려면 2단계 인증이 필요해서 브라우저에서만 할 수 있어요.",
+ "manageStepUpNote": "브라우저에서만 할 수 있고, 2단계 인증을 설정했다면 인증을 요청해요.",
"scopeLegend": "상대가 열 수 있는 범위",
"scopeAllLabel": "기록 전체",
"scopeAllCapability": "기록의 모든 부분. 지금까지의 공유 방식 그대로예요.",
@@ -10413,7 +10418,7 @@
"errorRateLimited": "초대가 너무 많아요. 잠시 후 다시 시도해 주세요.",
"errorFailed": "초대를 보내지 못했어요. 다시 시도해 주세요.",
"errorScope": "그 섹션들은 그렇게 공유할 수 없어요. 섹션을 하나 이상 고르거나 기록 전체를 공유하세요.",
- "errorManageBrowserOnly": "관리 권한은 2단계 인증이 필요해서 브라우저에서만 줄 수 있어요.",
+ "errorManageBrowserOnly": "관리 권한은 브라우저에서만 줄 수 있어요.",
"errorStepUp": "2단계 인증을 확인한 뒤 다시 시도해 주세요.",
"errorOffline": "서버에 닿지 못했어요. 초대 내용은 그대로 있으니 연결을 확인하고 다시 시도해 주세요."
},
diff --git a/messages/pl.json b/messages/pl.json
index 3c84cbc85..06f0da168 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5587,9 +5587,13 @@
"webhookDescription": "Wysyłaj powiadomienia metodą POST na dowolny URL — Gotify, Discord, Slack, Matrix lub Home Assistant.",
"webhookEnable": "Włącz webhook",
"webhookUrl": "Adres URL webhooka",
- "webhookUrlHint": "Pełny adres URL dla żądania POST. Musi być publicznie dostępny.",
+ "webhookUrlHint": "Pełny adres URL dla żądania POST. Adres w sieci prywatnej musi zatwierdzić administrator serwera.",
"webhookHeaderName": "Nazwa nagłówka (opcjonalnie)",
"webhookHeaderValue": "Wartość nagłówka (opcjonalnie)",
+ "webhookFormat": "Format wiadomości",
+ "webhookFormatGeneric": "Ogólny JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Użyj adresu URL kończącego się na /message, a token aplikacji wpisz w nagłówku X-Gotify-Key.",
"email": "E-mail",
"emailDescription": "Otrzymuj alerty i przypomnienia e-mailem.",
"emailEnable": "Włącz e-mail",
@@ -6136,6 +6140,7 @@
"rate_limited": "Ograniczone przez usługę nadrzędną",
"timeout": "Przekroczono limit czasu żądania",
"upstream_error": "Usługa nadrzędna zwróciła błąd",
+ "upstream_rejected": "Usługa docelowa odrzuciła wiadomość",
"connection_failed": "Połączenie nie powiodło się",
"not_configured": "Nieskonfigurowano",
"url_not_public": "Adres URL nie jest publicznym punktem końcowym",
@@ -10389,7 +10394,7 @@
"accessManageLabel": "Może zarządzać",
"accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy, także te, które wprowadziłeś samodzielnie.",
"accessManageDetail": "Może też czytać analizy wygenerowane na podstawie Twojej kartoteki. Nie może zmienić Twojego logowania, ustawień bezpieczeństwa, połączonych usług ani tego, kto jeszcze ma dostęp. Możesz to zakończyć w dowolnym momencie.",
- "manageStepUpNote": "Przy takim zaproszeniu prosimy o drugi składnik logowania, dlatego można to zrobić tylko w przeglądarce.",
+ "manageStepUpNote": "Można to zrobić tylko w przeglądarce, a jeśli masz drugi składnik logowania, poprosimy o niego.",
"scopeLegend": "Co ta osoba może otworzyć",
"scopeAllLabel": "Cała kartoteka",
"scopeAllCapability": "Każda jej część, tak jak udostępnianie działało do tej pory.",
@@ -10413,7 +10418,7 @@
"errorRateLimited": "Zbyt wiele zaproszeń. Spróbuj później.",
"errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie.",
"errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.",
- "errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce, ponieważ wymaga drugiego składnika logowania.",
+ "errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce.",
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
"errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest — sprawdź połączenie i spróbuj ponownie."
},
diff --git a/package.json b/package.json
index 878b9833d..53628e728 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.38.20",
+ "version": "1.38.21",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/public/sw.js b/public/sw.js
index 8c4284924..f0732ea11 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.38.20";
+ /* @sw-version-fallback */ "v1.38.21";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -61,6 +61,21 @@ const CURRENT_HEALTHLOG_CACHES = new Set([
PAGE_CACHE,
DATA_CACHE,
]);
+// Presence marker `src/proxy.ts` sets on every HealthLog page response. See
+// `isForeignNavigationResponse` for why the worker needs it and the proxy for
+// why it carries no version. Keep the name in lockstep with
+// `HEALTHLOG_PAGE_MARKER_HEADER` there.
+const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page";
+// Set as soon as a navigation looks like another app (see
+// `isForeignNavigationResponse`). While it is set the fetch handler declines
+// every request, so the page and its assets come from the network. It is
+// cleared again when `confirmForeignOrigin` finds HealthLog after all, and
+// stays set for the rest of the worker's life when the origin is confirmed
+// foreign and the worker retires.
+let foreignOrigin = false;
+// One confirmation at a time: a burst of navigations must not fire a burst of
+// probes, or race one probe's "HealthLog after all" against another's retire.
+let foreignOriginCheck = null;
const MAX_STATIC_ENTRIES = 150;
const MAX_PAGE_ENTRIES = 30;
const MAX_DATA_ENTRIES = 60;
@@ -168,6 +183,10 @@ self.addEventListener("activate", (event) => {
// ── Fetch: strategy per resource type ────────────────────────────────────────
self.addEventListener("fetch", (event) => {
+ // The origin serves another app now (see `retireFromForeignOrigin`): no
+ // `respondWith`, no cache, the browser's own fetch for everything.
+ if (foreignOrigin) return;
+
const { request } = event;
const url = new URL(request.url);
@@ -335,6 +354,168 @@ function isCacheableNavigation(request, response) {
return true;
}
+/**
+ * Does this navigation response come from something other than HealthLog?
+ *
+ * The worker is registered with scope `/` because HealthLog serves everything
+ * from `/`. When HealthLog is stopped and a different app starts on the same
+ * origin (the usual case is `localhost:3000` on a developer machine), this
+ * worker kept answering: `/_next/static/*` is cache-first, so the other app
+ * ran on HealthLog's cached chunks, and the only way out was unregistering by
+ * hand in the browser's developer tools.
+ *
+ * Only a response that positively looks like another app counts:
+ *
+ * - a redirect is opaque to the worker (`opaqueredirect`, status 0, no
+ * readable headers), and HealthLog redirects every signed-out page, so it
+ * proves nothing; status 0 fails the `ok` check below;
+ * - a non-2xx answer proves nothing either: a reverse proxy or auth gateway
+ * in front of a stopped HealthLog answers 502 or 401 without the marker,
+ * and unregistering there would also drop the Web Push subscription;
+ * - a network failure never reaches this check (it throws into the offline
+ * fallback in `networkFirst`).
+ *
+ * What is left is a successful page without the marker HealthLog's proxy puts
+ * on every page response. That is a suspicion, not a verdict: a reverse proxy
+ * with a response-header allowlist strips the marker from every genuine page.
+ * `confirmForeignOrigin` settles it before anything is deleted.
+ */
+function isForeignNavigationResponse(response) {
+ if (!response || !response.ok) return false;
+ return !response.headers.has(HEALTHLOG_PAGE_MARKER_HEADER);
+}
+
+/**
+ * Is the origin positively not HealthLog? Asked of `/api/version`, which is
+ * public (on the proxy's `PUBLIC_PATHS` list, no session needed) and answers
+ * `{ data: { version, buildSha, ... } }`. A body check survives any proxy that
+ * rewrites headers.
+ *
+ * Retire only on a positive answer from something else: a 404, a 2xx that is
+ * not JSON (another app's HTML fallback), or JSON without the HealthLog shape.
+ * `buildSha` is `null` on an image not built by the release workflow, so the
+ * shape is "`version` is a string and `buildSha` is present", not "both are
+ * strings". Anything that is not an answer (a thrown fetch, a 5xx, a 401 from
+ * an auth gateway, a redirect) proves nothing, and the worker resumes normal
+ * service.
+ *
+ * One gateway shape still reads as "another app": a same-origin auth gateway
+ * that answers an expired session with its sign-in page and status 200 (nginx
+ * `auth_request` with `error_page 401 = /oauth2/sign_in` does this). Both the
+ * navigation and this probe then come back as HTML. The probe sends the
+ * gateway cookie (`credentials: "same-origin"`), so a valid gateway session
+ * reaches HealthLog; with an expired one the verdict is wrong, which is why
+ * `retireFromForeignOrigin` never unregisters while a push subscription exists.
+ *
+ * The probe is the worker's own `fetch`, which never passes through this
+ * worker's fetch handler; `no-store` keeps the HTTP cache out of it and
+ * `redirect: "manual"` keeps a login redirect from reading as an answer.
+ */
+async function confirmForeignOrigin() {
+ let response;
+ try {
+ response = await fetch("/api/version", {
+ cache: "no-store",
+ redirect: "manual",
+ credentials: "same-origin",
+ });
+ } catch {
+ return false;
+ }
+ if (response.status === 404) return true;
+ if (!response.ok) return false;
+ let body;
+ try {
+ body = JSON.parse(await response.text());
+ } catch {
+ return true;
+ }
+ const data = body && typeof body === "object" ? body.data : null;
+ const isHealthlog =
+ !!data &&
+ typeof data === "object" &&
+ typeof data.version === "string" &&
+ "buildSha" in data;
+ return !isHealthlog;
+}
+
+/**
+ * Confirm, then either retire or resume. Shared by concurrent navigations.
+ */
+function checkForeignOrigin() {
+ if (!foreignOriginCheck) {
+ foreignOriginCheck = (async () => {
+ let foreign = false;
+ try {
+ foreign = await confirmForeignOrigin();
+ } catch {
+ foreign = false;
+ }
+ if (foreign) {
+ await retireFromForeignOrigin();
+ return;
+ }
+ // HealthLog after all, or no answer: serve normally again, and let a
+ // later suspicious navigation ask again.
+ foreignOrigin = false;
+ foreignOriginCheck = null;
+ })();
+ }
+ return foreignOriginCheck;
+}
+
+/**
+ * Whether this registration holds a Web Push subscription. Unregistering
+ * destroys the subscription and nothing in the app re-creates it (the push
+ * card only reads it), so reminders would stop without notice. A missing
+ * `pushManager` or a throwing lookup counts as "has one": keeping a worker
+ * that only passes requests through costs nothing, losing reminders does.
+ */
+async function hasPushSubscription() {
+ try {
+ const pushManager = self.registration.pushManager;
+ if (!pushManager || typeof pushManager.getSubscription !== "function") {
+ return true;
+ }
+ return (await pushManager.getSubscription()) !== null;
+ } catch {
+ return true;
+ }
+}
+
+/**
+ * Leave an origin that no longer serves HealthLog: delete every HealthLog
+ * cache (only ours, matched by name, never another app's CacheStorage) and
+ * unregister, so the next load of that origin runs without this worker. The
+ * `foreignOrigin` flag set by the caller stays set, so the requests the
+ * current page still makes go to the network. Each step is best-effort and
+ * independent: a failed cache delete must not keep the worker registered.
+ *
+ * With a push subscription the worker does not unregister: the verdict may
+ * be a gateway's 200 sign-in page (see `confirmForeignOrigin`), and
+ * unregistering would destroy the subscription. It deletes the caches and
+ * stays in pass-through for the rest of its life instead, so another app on
+ * the origin still gets network content. The next worker start probes again.
+ */
+async function retireFromForeignOrigin() {
+ try {
+ const keys = await caches.keys();
+ await Promise.all(
+ keys
+ .filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k))
+ .map((k) => caches.delete(k)),
+ );
+ } catch {
+ // CacheStorage unavailable; unregistering still matters more.
+ }
+ if (await hasPushSubscription()) return;
+ try {
+ await self.registration.unregister();
+ } catch {
+ // Nothing further to do from inside the worker.
+ }
+}
+
/**
* Network-first: try network (preferring the navigation-preload response
* when the browser already started it), fall back to cache.
@@ -351,6 +532,13 @@ async function networkFirst(event, cacheName) {
const response =
(event.preloadResponse ? await event.preloadResponse : null) ||
(await fetch(request));
+ if (isForeignNavigationResponse(response)) {
+ // Step aside at once (declining a request only sends it to the
+ // network), but delete nothing until the origin is confirmed foreign.
+ foreignOrigin = true;
+ event.waitUntil(checkForeignOrigin());
+ return response;
+ }
if (response.ok && isCacheableNavigation(request, response)) {
event.waitUntil(
bestEffortCacheWrite(
diff --git a/src/__tests__/managed-record-navigation.test.tsx b/src/__tests__/managed-record-navigation.test.tsx
index 5f8fd08d7..8653150cf 100644
--- a/src/__tests__/managed-record-navigation.test.tsx
+++ b/src/__tests__/managed-record-navigation.test.tsx
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { classifySettingsDestination } from "@/lib/record-settings";
+import { delegatedDomains } from "@/lib/sharing/domain-write-support";
const pathnameRef = { value: "/settings/security" };
const activeRecordRef = { value: true };
@@ -31,6 +32,12 @@ vi.mock("@/hooks/use-auth", () => ({
recordKind: recordRef.value.kind,
sections: null,
canWrite: true,
+ // Bound as the server publishes it for a whole-record grant.
+ manageableDomains: delegatedDomains(
+ recordRef.value.level as "read" | "write" | "manage",
+ null,
+ "manage",
+ ),
},
accounts: [],
canSwitch: true,
diff --git a/src/__tests__/proxy-page-marker.test.ts b/src/__tests__/proxy-page-marker.test.ts
new file mode 100644
index 000000000..9c4c897bc
--- /dev/null
+++ b/src/__tests__/proxy-page-marker.test.ts
@@ -0,0 +1,72 @@
+/**
+ * The page marker `public/sw.js` uses to recognise that its origin still
+ * serves HealthLog (#847). A successful navigation without it makes the
+ * worker delete its caches and unregister, so the marker has to be on every
+ * page the proxy lets through, signed in or not, and it must never carry a
+ * version.
+ */
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/process-type", () => ({
+ shouldRunWeb: () => true,
+}));
+
+import { HEALTHLOG_PAGE_MARKER_HEADER, proxy } from "../proxy";
+
+beforeEach(() => {
+ vi.stubEnv("NODE_ENV", "production");
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+function request(pathname: string, signedIn = true): NextRequest {
+ return new NextRequest(`http://localhost${pathname}`, {
+ headers: signedIn
+ ? { cookie: "healthlog_session=sess-1", accept: "text/html" }
+ : { accept: "text/html" },
+ });
+}
+
+describe("proxy.ts page marker for the service worker", () => {
+ it.each(["/", "/measurements", "/settings/profile", "/c/hls_abc123"])(
+ "marks the signed-in page %s",
+ (path) => {
+ const res = proxy(request(path));
+ expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1");
+ },
+ );
+
+ it.each(["/auth/login", "/privacy", "/onboarding"])(
+ "marks the public page %s",
+ (path) => {
+ const res = proxy(request(path, false));
+ expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1");
+ },
+ );
+
+ it.each(["/api/version", "/api/measurements", "/api/auth/me"])(
+ "leaves the API response %s unmarked",
+ (path) => {
+ const res = proxy(request(path));
+ expect(res.headers.has(HEALTHLOG_PAGE_MARKER_HEADER)).toBe(false);
+ },
+ );
+
+ it("carries presence only, never a version or build id", () => {
+ vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "9.9.9");
+ const res = proxy(request("/"));
+ expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1");
+ });
+
+ it("uses the header name public/sw.js checks", () => {
+ const sw = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8");
+ expect(sw).toContain(
+ `const HEALTHLOG_PAGE_MARKER_HEADER = "${HEALTHLOG_PAGE_MARKER_HEADER}";`,
+ );
+ });
+});
diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts
index ec749ab7b..c64e25563 100644
--- a/src/__tests__/service-worker.test.ts
+++ b/src/__tests__/service-worker.test.ts
@@ -14,6 +14,11 @@
* `event.preloadResponse` when the browser supplies it;
* 4. `trimCache` reads the key list once and deletes the excess prefix
* in a single pass (the previous loop re-fetched all keys per delete).
+ *
+ * A HealthLog page response carries the `X-HealthLog-Page` marker the proxy
+ * sets, so the navigation fixtures below build pages with `healthlogPage()`.
+ * A successful page without it is another app on the same origin (#847),
+ * which the "foreign origin" block covers.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
@@ -38,6 +43,15 @@ const CURRENT_PAGE_CACHE = `healthlog-pages-${FALLBACK_VERSION}`;
const CURRENT_STATIC_CACHE = `healthlog-static-${FALLBACK_VERSION}`;
const CURRENT_DATA_CACHE = `healthlog-data-${FALLBACK_VERSION}`;
+const PAGE_MARKER = "X-HealthLog-Page";
+
+/** A page response as HealthLog's proxy delivers it: marker included. */
+function healthlogPage(body: string, init: ResponseInit = {}): Response {
+ const headers = new Headers(init.headers);
+ headers.set(PAGE_MARKER, "1");
+ return new Response(body, { ...init, headers });
+}
+
class FakeCache {
map = new Map();
keysCalls = 0;
@@ -121,6 +135,9 @@ interface SwHarness {
listeners: Map void>;
cacheStorage: FakeCacheStorage;
navPreload: { enabled: boolean; enable: () => Promise };
+ unregisterCalls: { count: number };
+ /** What `pushManager.getSubscription()` does; default: no subscription. */
+ push: { getSubscription: () => Promise };
lifetimePromises: Promise[];
context: Record;
}
@@ -129,6 +146,8 @@ function bootServiceWorker(): SwHarness {
const listeners = new Map void>();
const cacheStorage = new FakeCacheStorage();
const lifetimePromises: Promise[] = [];
+ const unregisterCalls = { count: 0 };
+ const push = { getSubscription: async (): Promise => null };
const navPreload = {
enabled: false,
enable: async () => {
@@ -147,6 +166,13 @@ function bootServiceWorker(): SwHarness {
clients: { claim: async () => {} },
registration: {
navigationPreload: navPreload,
+ unregister: async () => {
+ unregisterCalls.count += 1;
+ return true;
+ },
+ pushManager: {
+ getSubscription: () => push.getSubscription(),
+ },
showNotification: async (title: string, opts: { tag?: string }) => {
shown.push({ title, tag: opts?.tag, closed: false });
},
@@ -194,7 +220,15 @@ function bootServiceWorker(): SwHarness {
};
vm.createContext(context);
vm.runInContext(SW_SOURCE, context);
- return { listeners, cacheStorage, navPreload, lifetimePromises, context };
+ return {
+ listeners,
+ cacheStorage,
+ navPreload,
+ unregisterCalls,
+ push,
+ lifetimePromises,
+ context,
+ };
}
async function dispatchActivate(harness: SwHarness): Promise {
@@ -389,7 +423,7 @@ describe("sw.js — best-effort cache writes", () => {
const harness = bootServiceWorker();
const pageCache = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
pageCache.putError = new Error("CacheStorage quota exceeded");
- const networkResponse = new Response("network shell", {
+ const networkResponse = healthlogPage("network shell", {
headers: { "X-Network-Response": "original" },
});
harness.context.fetch = async () => networkResponse;
@@ -413,7 +447,7 @@ describe("sw.js — best-effort cache writes", () => {
notifyPutStarted = resolve;
});
pageCache.onPut = notifyPutStarted;
- const networkResponse = new Response("network shell");
+ const networkResponse = healthlogPage("network shell");
harness.context.fetch = async () => networkResponse;
const responsePromise = dispatchNavigationFetch(harness, "/measurements");
@@ -572,7 +606,7 @@ describe("sw.js — networkFirst offline fallback", () => {
const res = await dispatchNavigationFetch(
harness,
"/",
- Promise.resolve(new Response("preloaded shell")),
+ Promise.resolve(healthlogPage("preloaded shell")),
);
expect(await res.clone().text()).toBe("preloaded shell");
@@ -587,7 +621,7 @@ describe("sw.js — networkFirst privacy gate", () => {
it("does not cache a navigation response that carries Cache-Control: no-store", async () => {
const harness = bootServiceWorker();
harness.context.fetch = async () =>
- new Response("private shell", {
+ healthlogPage("private shell", {
headers: { "Cache-Control": "no-store" },
});
@@ -600,7 +634,7 @@ describe("sw.js — networkFirst privacy gate", () => {
it("does not cache the /c/ clinician-share view even without no-store", async () => {
const harness = bootServiceWorker();
- harness.context.fetch = async () => new Response("share shell");
+ harness.context.fetch = async () => healthlogPage("share shell");
const res = await dispatchNavigationFetch(harness, "/c/hls_abc123");
expect(await res.clone().text()).toBe("share shell");
@@ -611,7 +645,7 @@ describe("sw.js — networkFirst privacy gate", () => {
it("still caches an ordinary navigation response", async () => {
const harness = bootServiceWorker();
- harness.context.fetch = async () => new Response("app shell");
+ harness.context.fetch = async () => healthlogPage("app shell");
const res = await dispatchNavigationFetch(harness, "/measurements");
expect(await res.clone().text()).toBe("app shell");
@@ -621,6 +655,430 @@ describe("sw.js — networkFirst privacy gate", () => {
});
});
+/**
+ * Dispatch any request and report whether the worker took it. Used where the
+ * assertion IS whether `respondWith` was called: a declined request goes to
+ * the network through the browser, untouched by this worker.
+ */
+function dispatchAnyFetch(
+ harness: SwHarness,
+ path: string,
+ headers: Record = {},
+): { responded: boolean; response: Promise | null } {
+ let captured: Promise | null = null;
+ let responded = false;
+ harness.listeners.get("fetch")!({
+ request: new Request(`${ORIGIN}${path}`, { headers }),
+ respondWith: (p: Promise) => {
+ responded = true;
+ captured = p;
+ },
+ waitUntil: (p: Promise) => {
+ harness.lifetimePromises.push(p);
+ },
+ });
+ return { responded, response: captured };
+}
+
+const HEALTHLOG_VERSION_BODY = JSON.stringify({
+ data: { version: "1.38.21", buildSha: null, builtAt: null },
+ error: null,
+});
+
+/**
+ * Route the harness `fetch`: `/api/version` (the worker's confirmation probe)
+ * gets `version`, everything else gets `page`. Records every requested path.
+ */
+function routeFetch(
+ harness: SwHarness,
+ page: () => Response,
+ version: () => Response | Promise,
+): string[] {
+ const calls: string[] = [];
+ harness.context.fetch = async (input: RequestInfo | URL) => {
+ const url = new URL(
+ typeof input === "string" || input instanceof URL ? input : input.url,
+ ORIGIN,
+ );
+ calls.push(url.pathname);
+ return url.pathname === "/api/version" ? version() : page();
+ };
+ return calls;
+}
+
+const foreignHtml = () =>
+ new Response("another app", {
+ headers: { "Content-Type": "text/html" },
+ });
+
+async function seedHealthlogCaches(harness: SwHarness): Promise {
+ const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
+ await statics.put(
+ `${ORIGIN}/_next/static/chunks/app.js`,
+ new Response("healthlog chunk"),
+ );
+ const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
+ await pages.put(`${ORIGIN}/`, healthlogPage("healthlog shell"));
+ const legacy = await harness.cacheStorage.open("healthlog-data-v0.0.1");
+ await legacy.put(`${ORIGIN}/api/version`, new Response("{}"));
+ // CacheStorage the other app owns on the same origin.
+ const other = await harness.cacheStorage.open("next-app-runtime");
+ await other.put(`${ORIGIN}/x`, new Response("theirs"));
+}
+
+describe("sw.js — foreign origin (#847)", () => {
+ it("keeps serving and caching while the page carries the HealthLog marker", async () => {
+ const harness = bootServiceWorker();
+ await seedHealthlogCaches(harness);
+ harness.context.fetch = async () => healthlogPage("fresh shell");
+
+ const res = await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(await res.text()).toBe("fresh shell");
+ expect(harness.unregisterCalls.count).toBe(0);
+ expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
+ const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js");
+ expect(asset.responded).toBe(true);
+ expect(await (await asset.response!).text()).toBe("healthlog chunk");
+ });
+
+ it("retires when the page lacks the marker and the version probe finds another app's HTML", async () => {
+ const harness = bootServiceWorker();
+ await seedHealthlogCaches(harness);
+ const foreignPage = foreignHtml();
+ const calls = routeFetch(harness, () => foreignPage, foreignHtml);
+
+ const res = await dispatchNavigationFetch(harness, "/");
+
+ // The other app's page is returned untouched, and the worker declines
+ // requests at once, before the probe has answered.
+ expect(res).toBe(foreignPage);
+ expect(
+ dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
+ ).toBe(false);
+
+ await Promise.all(harness.lifetimePromises);
+
+ expect(calls).toEqual(["/", "/api/version"]);
+ expect(harness.unregisterCalls.count).toBe(1);
+ // Every HealthLog cache is gone, current and legacy; the other app's is not.
+ expect([...harness.cacheStorage.stores.keys()]).toEqual([
+ "next-app-runtime",
+ ]);
+
+ // Still declining after the retire: static, HTML and API all go to the
+ // network through the browser.
+ expect(
+ dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
+ ).toBe(false);
+ expect(
+ dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded,
+ ).toBe(false);
+ expect(dispatchAnyFetch(harness, "/api/measurements").responded).toBe(
+ false,
+ );
+ });
+
+ it("retires when the version probe answers 404", async () => {
+ const harness = bootServiceWorker();
+ await seedHealthlogCaches(harness);
+ routeFetch(
+ harness,
+ foreignHtml,
+ () => new Response("Not Found", { status: 404 }),
+ );
+
+ await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(harness.unregisterCalls.count).toBe(1);
+ expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(false);
+ });
+
+ it("retires when the version probe answers JSON without the HealthLog shape", async () => {
+ const harness = bootServiceWorker();
+ routeFetch(
+ harness,
+ foreignHtml,
+ () => new Response(JSON.stringify({ version: "3.0.0" })),
+ );
+
+ await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(harness.unregisterCalls.count).toBe(1);
+ });
+
+ it("probes with no-store, a manual redirect and same-origin credentials", async () => {
+ const harness = bootServiceWorker();
+ let probeInit: RequestInit | undefined;
+ harness.context.fetch = async (
+ input: RequestInfo | URL,
+ init?: RequestInit,
+ ) => {
+ const path = new URL(
+ String(input instanceof Request ? input.url : input),
+ ORIGIN,
+ ).pathname;
+ if (path === "/api/version") {
+ probeInit = init;
+ return new Response(HEALTHLOG_VERSION_BODY);
+ }
+ return foreignHtml();
+ };
+
+ await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(probeInit?.cache).toBe("no-store");
+ expect(probeInit?.redirect).toBe("manual");
+ // A valid same-origin gateway cookie must reach /api/version.
+ expect(probeInit?.credentials).toBe("same-origin");
+ });
+
+ it("does not retire when a header-stripping proxy hides the marker from a real HealthLog", async () => {
+ const harness = bootServiceWorker();
+ await seedHealthlogCaches(harness);
+ // The page is HealthLog's, minus the marker a proxy allowlist removed.
+ routeFetch(
+ harness,
+ () => new Response("healthlog page without marker"),
+ () =>
+ new Response(HEALTHLOG_VERSION_BODY, {
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+
+ const res = await dispatchNavigationFetch(harness, "/");
+ expect(await res.text()).toBe("healthlog page without marker");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(harness.unregisterCalls.count).toBe(0);
+ expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
+ // Normal service resumed: the cached chunk is served cache-first again.
+ const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js");
+ expect(asset.responded).toBe(true);
+ expect(await (await asset.response!).text()).toBe("healthlog chunk");
+ });
+
+ it.each([
+ [
+ "the probe throws (network)",
+ () => Promise.reject(new TypeError("network down")),
+ ],
+ ["the probe answers 500", () => new Response("boom", { status: 500 })],
+ [
+ "the probe answers 401 from an auth gateway",
+ () => new Response("Unauthorized", { status: 401 }),
+ ],
+ ])(
+ "does not retire when %s, and resumes service",
+ async (_label, version) => {
+ const harness = bootServiceWorker();
+ await seedHealthlogCaches(harness);
+ routeFetch(harness, foreignHtml, version as () => Promise);
+
+ await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(harness.unregisterCalls.count).toBe(0);
+ expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
+ expect(
+ dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
+ ).toBe(true);
+ },
+ );
+
+ it("asks again on a later suspicious navigation after an inconclusive probe", async () => {
+ const harness = bootServiceWorker();
+ let probeStatus = 503;
+ const calls = routeFetch(harness, foreignHtml, () =>
+ probeStatus === 503
+ ? new Response("down", { status: 503 })
+ : new Response("Not Found", { status: 404 }),
+ );
+
+ await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+ expect(harness.unregisterCalls.count).toBe(0);
+
+ probeStatus = 404;
+ await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(calls.filter((p) => p === "/api/version")).toHaveLength(2);
+ expect(harness.unregisterCalls.count).toBe(1);
+ });
+
+ it("recognises a foreign page delivered through navigation preload", async () => {
+ const harness = bootServiceWorker();
+ await seedHealthlogCaches(harness);
+ const calls = routeFetch(
+ harness,
+ () => healthlogPage("unexpected"),
+ () => new Response("Not Found", { status: 404 }),
+ );
+
+ const res = await dispatchNavigationFetch(
+ harness,
+ "/",
+ Promise.resolve(new Response("another app")),
+ );
+ await Promise.all(harness.lifetimePromises);
+
+ expect(await res.text()).toBe("another app");
+ // The page came from the preload; the only network call is the probe.
+ expect(calls).toEqual(["/api/version"]);
+ expect(harness.unregisterCalls.count).toBe(1);
+ });
+
+ it("keeps a worker that holds a push subscription: caches deleted, no unregister, pass-through", async () => {
+ const harness = bootServiceWorker();
+ await seedHealthlogCaches(harness);
+ harness.push.getSubscription = async () => ({
+ endpoint: "https://push.example/abc",
+ });
+ // A gateway's 200 sign-in page for both the navigation and the probe.
+ routeFetch(harness, foreignHtml, foreignHtml);
+
+ await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(harness.unregisterCalls.count).toBe(0);
+ expect([...harness.cacheStorage.stores.keys()]).toEqual([
+ "next-app-runtime",
+ ]);
+ expect(
+ dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
+ ).toBe(false);
+ expect(
+ dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded,
+ ).toBe(false);
+ });
+
+ it.each([
+ [
+ "the subscription lookup throws",
+ (h: SwHarness) => {
+ h.push.getSubscription = async () => {
+ throw new Error("push service unavailable");
+ };
+ },
+ ],
+ [
+ "pushManager is missing",
+ (h: SwHarness) => {
+ delete (h.context.self as { registration: { pushManager?: unknown } })
+ .registration.pushManager;
+ },
+ ],
+ ])("does not unregister when %s", async (_label, arrange) => {
+ const harness = bootServiceWorker();
+ await seedHealthlogCaches(harness);
+ arrange(harness);
+ routeFetch(harness, foreignHtml, foreignHtml);
+
+ await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(harness.unregisterCalls.count).toBe(0);
+ expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(false);
+ });
+
+ it("still unregisters when deleting the caches fails", async () => {
+ const harness = bootServiceWorker();
+ harness.cacheStorage.keys = async () => {
+ throw new Error("CacheStorage unavailable");
+ };
+ routeFetch(harness, foreignHtml, foreignHtml);
+
+ await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(harness.unregisterCalls.count).toBe(1);
+ });
+
+ it("keeps the offline fallback when the network fails (HealthLog is down, not replaced)", async () => {
+ const harness = bootServiceWorker();
+ await seedHealthlogCaches(harness);
+ // Default harness fetch throws: network down.
+
+ const res = await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(await res.text()).toBe("healthlog shell");
+ expect(harness.unregisterCalls.count).toBe(0);
+ expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
+ });
+
+ it.each([
+ [
+ "an error page from HealthLog itself",
+ healthlogPage("boom", { status: 500 }),
+ ],
+ [
+ "a gateway error from a proxy in front of a stopped HealthLog",
+ new Response("Bad Gateway", { status: 502 }),
+ ],
+ [
+ "a 401 from an auth gateway",
+ new Response("Unauthorized", { status: 401 }),
+ ],
+ ])("does not treat %s as foreign", async (_label, response) => {
+ const harness = bootServiceWorker();
+ await seedHealthlogCaches(harness);
+ harness.context.fetch = async () => response;
+
+ const res = await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(res).toBe(response);
+ expect(harness.unregisterCalls.count).toBe(0);
+ expect(
+ dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
+ ).toBe(true);
+ });
+
+ it("does not treat an opaque redirect (a signed-out HealthLog page) as foreign", async () => {
+ const harness = bootServiceWorker();
+ const redirect = Response.redirect(`${ORIGIN}/auth/login`, 307);
+ // A navigation fetched with `redirect: "manual"` surfaces as an opaque
+ // redirect in a real worker; model the type the worker sees.
+ Object.defineProperty(redirect, "type", { value: "opaqueredirect" });
+ Object.defineProperty(redirect, "headers", { value: new Headers() });
+ Object.defineProperty(redirect, "ok", { value: false });
+ harness.context.fetch = async () => redirect;
+
+ await dispatchNavigationFetch(harness, "/");
+ await Promise.all(harness.lifetimePromises);
+
+ expect(harness.unregisterCalls.count).toBe(0);
+ });
+
+ it("leaves the one-shot auth navigation path unchanged", async () => {
+ const harness = bootServiceWorker();
+ let fetchCalls = 0;
+ harness.context.fetch = async () => {
+ fetchCalls += 1;
+ return new Response("network callback");
+ };
+
+ const { responded, response } = dispatchAuthNavigation(
+ harness,
+ "/api/auth/oidc/callback?code=abc&state=xyz",
+ Promise.resolve(new Response("preloaded callback")),
+ );
+
+ expect(responded).toBe(true);
+ expect(await (await response!).text()).toBe("preloaded callback");
+ expect(fetchCalls).toBe(0);
+ // An auth response has no page marker and must not retire the worker.
+ await Promise.all(harness.lifetimePromises);
+ expect(harness.unregisterCalls.count).toBe(0);
+ });
+});
+
describe("sw.js — trimCache", () => {
it("reads the key list once and deletes only the oldest excess entries", async () => {
const harness = bootServiceWorker();
diff --git a/src/app/api/admin/notifications/test/__tests__/route.test.ts b/src/app/api/admin/notifications/test/__tests__/route.test.ts
index 295e44f25..796464b39 100644
--- a/src/app/api/admin/notifications/test/__tests__/route.test.ts
+++ b/src/app/api/admin/notifications/test/__tests__/route.test.ts
@@ -65,6 +65,12 @@ vi.mock("@/lib/notifications/senders/web-push", () => ({
vi.mock("@/lib/notifications/senders/apns", () => ({
sendViaApns: vi.fn(),
}));
+vi.mock("@/lib/notifications/senders/webhook", () => ({
+ sendViaWebhook: vi.fn(),
+}));
+vi.mock("@/lib/notifications/senders/email", () => ({
+ sendViaEmail: vi.fn(),
+}));
vi.mock("@/lib/i18n/server-translator", () => ({
getServerTranslator: vi.fn(() => ({
@@ -78,6 +84,8 @@ import { requireAdmin } from "@/lib/api-handler";
import { sendViaApns } from "@/lib/notifications/senders/apns";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
+import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
+import { sendViaEmail } from "@/lib/notifications/senders/email";
const ADMIN_USER_ID = "admin-1";
const ADMIN_CTX = {
@@ -92,7 +100,7 @@ const ADMIN_CTX = {
interface FakeChannelRow {
id: string;
userId: string;
- type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS";
+ type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS" | "WEBHOOK" | "EMAIL";
enabled: boolean;
config: string;
preferences: Array<{ eventType: string; enabled: boolean }>;
@@ -119,6 +127,10 @@ interface ResultEnvelope {
channel: string;
success: boolean;
error?: string;
+ errorCode?: string;
+ upstreamStatus?: number;
+ smtpCode?: number;
+ upstreamBody?: string;
}>;
};
}
@@ -299,3 +311,129 @@ describe("POST /api/admin/notifications/test — preserved branches", () => {
});
});
});
+
+describe("POST /api/admin/notifications/test — a failure keeps its cause (#947)", () => {
+ function channel(
+ type: FakeChannelRow["type"],
+ config: string,
+ ): FakeChannelRow {
+ return {
+ id: `ch-${type}`,
+ userId: ADMIN_USER_ID,
+ type,
+ enabled: true,
+ config,
+ preferences: [],
+ };
+ }
+
+ it("carries the ntfy status and code instead of a bare sentence", async () => {
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
+ channel("NTFY", 'enc({"topic":"hl-test"})'),
+ ] as never);
+ vi.mocked(sendViaNtfy).mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: "ntfy_400",
+ statusCode: 400,
+ });
+
+ const body = (await (await POST()).json()) as ResultEnvelope;
+
+ expect(body.data.results[0]).toMatchObject({
+ channel: "NTFY",
+ success: false,
+ errorCode: "upstream_rejected",
+ upstreamStatus: 400,
+ });
+ expect(body.data.results[0].error).toContain("HTTP 400");
+ expect(body.data.results[0].error).not.toContain("hl-test");
+ });
+
+ it("tests the webhook channel rather than calling it unknown", async () => {
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
+ channel("WEBHOOK", 'enc({"url":"https://relay.example.com/message"})'),
+ ] as never);
+ vi.mocked(sendViaWebhook).mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: "webhook_400",
+ statusCode: 400,
+ upstreamBody: "priority must be an integer",
+ });
+
+ const body = (await (await POST()).json()) as ResultEnvelope;
+
+ expect(sendViaWebhook).toHaveBeenCalledWith(
+ { url: "https://relay.example.com/message" },
+ expect.objectContaining({ eventType: "SYSTEM_ALERT" }),
+ );
+ expect(body.data.results[0]).toMatchObject({
+ channel: "WEBHOOK",
+ success: false,
+ errorCode: "upstream_rejected",
+ upstreamStatus: 400,
+ });
+ // The relay body belongs to the settings card, not the admin toast.
+ expect(body.data.results[0].upstreamBody).toBeUndefined();
+ });
+
+ it("tests the email channel and forwards the SMTP code", async () => {
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
+ channel("EMAIL", 'enc({"recipient":"admin@example.com"})'),
+ ] as never);
+ vi.mocked(sendViaEmail).mockResolvedValue({
+ ok: false,
+ hardReject: true,
+ reason: "email_smtp_5xx",
+ failureCode: "upstream_rejected",
+ smtpCode: 550,
+ });
+
+ const body = (await (await POST()).json()) as ResultEnvelope;
+
+ expect(body.data.results[0]).toMatchObject({
+ channel: "EMAIL",
+ success: false,
+ errorCode: "upstream_rejected",
+ smtpCode: 550,
+ });
+ expect(body.data.results[0].error).toContain("SMTP 550");
+ });
+
+ it("forwards a private-origin refusal with its own code", async () => {
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
+ channel("WEBHOOK", 'enc({"url":"http://10.0.0.5/message"})'),
+ ] as never);
+ vi.mocked(sendViaWebhook).mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: "webhook_private_origin_refused",
+ errorCode: "private_origin_not_approved",
+ });
+
+ const body = (await (await POST()).json()) as ResultEnvelope;
+
+ expect(body.data.results[0]).toMatchObject({
+ channel: "WEBHOOK",
+ success: false,
+ errorCode: "private_origin_not_approved",
+ });
+ });
+
+ it("reports a delivered webhook and email as successes", async () => {
+ vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
+ channel("WEBHOOK", 'enc({"url":"https://relay.example.com"})'),
+ channel("EMAIL", 'enc({"recipient":"admin@example.com"})'),
+ ] as never);
+ vi.mocked(sendViaWebhook).mockResolvedValue({ ok: true, statusCode: 200 });
+ vi.mocked(sendViaEmail).mockResolvedValue({ ok: true });
+
+ const body = (await (await POST()).json()) as ResultEnvelope;
+
+ expect(body.data.results).toEqual([
+ { channel: "WEBHOOK", success: true },
+ { channel: "EMAIL", success: true },
+ ]);
+ });
+});
diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts
index 33492125f..4c71a805e 100644
--- a/src/app/api/admin/notifications/test/route.ts
+++ b/src/app/api/admin/notifications/test/route.ts
@@ -7,9 +7,19 @@ import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import { sendViaApns } from "@/lib/notifications/senders/apns";
+import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
+import { sendViaEmail } from "@/lib/notifications/senders/email";
+import type { SendOutcome } from "@/lib/notifications/retry-policy";
+import {
+ testFailureDetail,
+ testFailureSentence,
+ type TestFailureDetail,
+} from "@/lib/notifications/test-delivery-failure";
import type {
TelegramChannelConfig,
NtfyChannelConfig,
+ WebhookChannelConfig,
+ EmailChannelConfig,
ChannelType,
NotificationPayload,
} from "@/lib/notifications/types";
@@ -62,6 +72,45 @@ async function ensureTelegramChannel(userId: string): Promise {
return true;
}
+/** The admin toast renders `errorCode` and the status; the relay body stays on the settings card. */
+type TestResult = {
+ channel: string;
+ success: boolean;
+ error?: string;
+} & Omit;
+
+/**
+ * A failed HTTP or SMTP send, with the code, status and SMTP reply code the
+ * user-facing test routes answer with, instead of a sentence that drops
+ * them. A private-origin refusal forwards its own code.
+ */
+function failedSend(
+ channel: string,
+ label: string,
+ result: SendOutcome,
+): TestResult {
+ const { errorCode, upstreamStatus, smtpCode } = result.errorCode
+ ? {
+ errorCode: result.errorCode,
+ upstreamStatus: undefined,
+ smtpCode: undefined,
+ }
+ : testFailureDetail(result);
+ const cause = result.errorCode
+ ? `refused by the private-origin policy (${result.errorCode})`
+ : errorCode
+ ? testFailureSentence(label, { errorCode, upstreamStatus, smtpCode })
+ : (result.reason ?? "send failed");
+ return {
+ channel,
+ success: false,
+ error: cause,
+ ...(errorCode ? { errorCode } : {}),
+ ...(upstreamStatus !== undefined ? { upstreamStatus } : {}),
+ ...(smtpCode !== undefined ? { smtpCode } : {}),
+ };
+}
+
function isLocale(value: string | null | undefined): value is Locale {
return (
value === "de" ||
@@ -121,11 +170,7 @@ export const POST = apiHandler(async () => {
message: t("notifications.admin.testNotificationBody"),
};
- const results: Array<{
- channel: string;
- success: boolean;
- error?: string;
- }> = [];
+ const results: TestResult[] = [];
for (const channel of channels) {
const pref = channel.preferences[0];
@@ -166,11 +211,34 @@ export const POST = apiHandler(async () => {
const ntfyResult = await sendViaNtfy(config, payload);
success = ntfyResult.ok;
if (!success) {
- results.push({
- channel: "NTFY",
- success: false,
- error: `Send failed (topic: ${config.topic})`,
- });
+ // The topic is not echoed: on a public server it is the secret.
+ results.push(failedSend("NTFY", "The ntfy server", ntfyResult));
+ continue;
+ }
+ break;
+ }
+ // The generic webhook and email are first-class channels in the
+ // dispatcher; without these arms they reported "Unknown channel type".
+ case "WEBHOOK": {
+ const config = JSON.parse(
+ decrypt(channel.config),
+ ) as WebhookChannelConfig;
+ const webhookResult = await sendViaWebhook(config, payload);
+ success = webhookResult.ok;
+ if (!success) {
+ results.push(failedSend("WEBHOOK", "The webhook", webhookResult));
+ continue;
+ }
+ break;
+ }
+ case "EMAIL": {
+ const config = JSON.parse(
+ decrypt(channel.config),
+ ) as EmailChannelConfig;
+ const emailResult = await sendViaEmail(config, payload);
+ success = emailResult.ok;
+ if (!success) {
+ results.push(failedSend("EMAIL", "The mail server", emailResult));
continue;
}
break;
diff --git a/src/app/api/settings/__tests__/email-test-route.test.ts b/src/app/api/settings/__tests__/email-test-route.test.ts
new file mode 100644
index 000000000..2bf5e0684
--- /dev/null
+++ b/src/app/api/settings/__tests__/email-test-route.test.ts
@@ -0,0 +1,131 @@
+/**
+ * POST /api/settings/email/test — a failed send names its cause.
+ *
+ * Before this the route answered every failure with a bare 500, so the card
+ * read "Test failed" whether the mail server refused the login, timed out,
+ * or rejected the recipient.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: { notificationChannel: { findUnique: vi.fn() } },
+}));
+vi.mock("@/lib/crypto", () => ({
+ decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
+}));
+const sendViaEmailMock = vi.fn();
+vi.mock("@/lib/notifications/senders/email", () => ({
+ sendViaEmail: (...args: unknown[]) => sendViaEmailMock(...args),
+}));
+vi.mock("@/lib/notifications/senders/email-config", () => ({
+ isEmailConfigured: () => true,
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn().mockResolvedValue({
+ allowed: true,
+ remaining: 4,
+ resetAt: Date.now() + 60_000,
+ }),
+ rateLimitHeaders: () => ({}),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { POST } from "../email/test/route";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "testuser", role: "USER" as const },
+};
+
+function post(): Promise {
+ return (POST as (request: Request) => Promise)(
+ new Request("http://localhost/api/settings/email/test", {
+ method: "POST",
+ }),
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
+ config: 'encrypted:{"recipient":"you@example.com"}',
+ } as never);
+});
+
+describe("POST /api/settings/email/test", () => {
+ it.each([
+ ["credentials_rejected", 535, "rejected the credentials (SMTP 535)"],
+ ["upstream_rejected", 550, "refused the message (SMTP 550)"],
+ ])(
+ "answers an SMTP rejection with 502, %s and the reply code",
+ async (failureCode, smtpCode, sentence) => {
+ sendViaEmailMock.mockResolvedValue({
+ ok: false,
+ hardReject: smtpCode >= 500,
+ reason: "email_smtp_5xx",
+ failureCode,
+ smtpCode,
+ });
+
+ const response = await post();
+
+ expect(response.status).toBe(502);
+ const json = await response.json();
+ expect(json.meta).toEqual({ errorCode: failureCode, smtpCode });
+ expect(json.error).toContain(sentence);
+ },
+ );
+
+ it("answers a timeout with 502 and the timeout code", async () => {
+ sendViaEmailMock.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: "email_smtp_error",
+ failureCode: "timeout",
+ });
+
+ const response = await post();
+
+ expect(response.status).toBe(502);
+ await expect(response.json()).resolves.toMatchObject({
+ meta: { errorCode: "timeout" },
+ });
+ });
+
+ it("keeps a 500 for a failure the sender could not name", async () => {
+ sendViaEmailMock.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: "email_smtp_error",
+ });
+
+ const response = await post();
+
+ expect(response.status).toBe(500);
+ const json = await response.json();
+ expect(json.meta).toBeUndefined();
+ });
+
+ it("answers a delivered test with 200", async () => {
+ sendViaEmailMock.mockResolvedValue({ ok: true });
+
+ const response = await post();
+
+ expect(response.status).toBe(200);
+ });
+});
diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts
index 4bc8d5b77..edcc96bd4 100644
--- a/src/app/api/settings/__tests__/private-origin-routes.test.ts
+++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts
@@ -8,7 +8,9 @@
* of a generic "Invalid data", and a listed literal address saves although
* the plain public floor would refuse it. The test routes forward the
* sender's own refusal as a 422 with the same code instead of the bare 500
- * that made the reporter read the wide-event log to learn it was policy.
+ * that made the reporter read the wide-event log to learn it was policy, and
+ * answer a relay's error status with a 502 that carries the status and what
+ * the relay said.
*
* The last block is the structural half: the test button and the dispatcher
* must reach the SAME sender function, so the decision cannot differ between
@@ -50,7 +52,10 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
-vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+const emitIfSampledMock = vi.fn();
+vi.mock("@/lib/logging/transports", () => ({
+ emitIfSampled: (...args: unknown[]) => emitIfSampledMock(...args),
+}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -351,20 +356,106 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
- ])("%s: a plain delivery failure is still a 500", async (path, sender) => {
- sender.mockResolvedValue({
- ok: false,
- hardReject: false,
- reason: `${path}_503`,
- statusCode: 503,
- });
+ ])(
+ "%s: a relay that answered 400 is a 502 naming the status and what it said, not a bare 500 (#947: the card could only say Test failed)",
+ async (path, sender) => {
+ sender.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: `${path}_400`,
+ statusCode: 400,
+ upstreamBody: '{"error":"Bad Request","errorCode":400}',
+ });
- const response = await post(path);
+ const response = await post(path);
- expect(response.status).toBe(500);
- const json = await response.json();
- expect(json.meta).toBeUndefined();
- });
+ expect(response.status).toBe(502);
+ const json = await response.json();
+ expect(json.meta).toEqual({
+ errorCode: "upstream_rejected",
+ upstreamStatus: 400,
+ upstreamBody: '{"error":"Bad Request","errorCode":400}',
+ });
+ expect(json.error).toContain("HTTP 400");
+ // A user's relay refusing is not an operator error: the event is warn.
+ expect(emitIfSampledMock).toHaveBeenCalledWith(
+ expect.objectContaining({ level: "warn" }),
+ );
+ },
+ );
+
+ it.each([
+ [401, "credentials_rejected"],
+ [403, "credentials_rejected"],
+ [404, "endpoint_not_found"],
+ [410, "endpoint_not_found"],
+ [415, "upstream_rejected"],
+ [422, "upstream_rejected"],
+ [429, "rate_limited"],
+ [503, "upstream_error"],
+ [307, "redirected"],
+ ])(
+ "webhook: an upstream %i maps to %s, without a body when the sender dropped it",
+ async (status, code) => {
+ sendViaWebhookMock.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: `webhook_${status}`,
+ statusCode: status,
+ });
+
+ const response = await post("webhook");
+
+ expect(response.status).toBe(502);
+ const json = await response.json();
+ expect(json.meta).toEqual({ errorCode: code, upstreamStatus: status });
+ },
+ );
+
+ it.each([
+ ["webhook", sendViaWebhookMock],
+ ["ntfy", sendViaNtfyMock],
+ ])(
+ "%s: a timeout without a status is a 502 with the timeout code",
+ async (path, sender) => {
+ sender.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: `${path}_network_error`,
+ failureCode: "timeout",
+ });
+
+ const response = await post(path);
+
+ expect(response.status).toBe(502);
+ await expect(response.json()).resolves.toMatchObject({
+ meta: { errorCode: "timeout" },
+ });
+ },
+ );
+
+ it.each([
+ ["webhook", sendViaWebhookMock],
+ ["ntfy", sendViaNtfyMock],
+ ])(
+ "%s: only a failure the sender could not name stays a 500",
+ async (path, sender) => {
+ sender.mockResolvedValue({
+ ok: false,
+ hardReject: false,
+ reason: `${path}_network_error`,
+ });
+
+ const response = await post(path);
+
+ expect(response.status).toBe(500);
+ const json = await response.json();
+ expect(json.meta).toBeUndefined();
+ expect(emitIfSampledMock).toHaveBeenCalledWith(
+ expect.objectContaining({ level: "error" }),
+ );
+ },
+ );
it.each([
["webhook", sendViaWebhookMock],
diff --git a/src/app/api/settings/__tests__/webhook-format-route.test.ts b/src/app/api/settings/__tests__/webhook-format-route.test.ts
new file mode 100644
index 000000000..d20ba38b8
--- /dev/null
+++ b/src/app/api/settings/__tests__/webhook-format-route.test.ts
@@ -0,0 +1,194 @@
+/**
+ * The webhook payload format at the settings route (#947).
+ *
+ * Absent means generic, so a config saved before the choice existed keeps
+ * sending the body it always sent. Only the Gotify choice is written, an
+ * omitted format keeps the stored one, and the GET reports the resolved
+ * value rather than leaving the client to guess the default.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ notificationChannel: {
+ findUnique: vi.fn(),
+ updateMany: vi.fn(),
+ upsert: vi.fn(),
+ },
+ },
+}));
+vi.mock("@/lib/crypto", () => ({
+ encrypt: vi.fn((value: string) => `encrypted:${value}`),
+ decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn().mockResolvedValue({
+ allowed: true,
+ remaining: 4,
+ resetAt: Date.now() + 60_000,
+ }),
+ rateLimitHeaders: () => ({}),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { GET, PUT } from "../webhook/route";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "testuser", role: "USER" as const },
+};
+
+type Route = (request: Request) => Promise;
+
+function put(body: unknown): Promise {
+ return (PUT as Route)(
+ new Request("http://localhost/api/settings/webhook", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ );
+}
+
+function get(): Promise {
+ return (GET as Route)(new Request("http://localhost/api/settings/webhook"));
+}
+
+function stored(): string {
+ const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0];
+ return call.create.config as string;
+}
+
+function existing(config: Record) {
+ vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
+ enabled: true,
+ config: `encrypted:${JSON.stringify(config)}`,
+ } as never);
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(
+ null as never,
+ );
+ vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
+});
+
+describe("PUT /api/settings/webhook — payload format", () => {
+ it("stores the Gotify choice", async () => {
+ const response = await put({
+ url: "https://gotify.example.com/message",
+ headerName: "X-Gotify-Key",
+ headerValue: "AbCdEfGh123",
+ format: "gotify",
+ enabled: true,
+ });
+
+ expect(response.status).toBe(200);
+ expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toEqual({
+ url: "https://gotify.example.com/message",
+ headerName: "X-Gotify-Key",
+ headerValue: "AbCdEfGh123",
+ format: "gotify",
+ });
+ });
+
+ it("writes nothing for generic, so the stored config keeps its old shape", async () => {
+ await put({
+ url: "https://relay.example.com/hook",
+ format: "generic",
+ enabled: true,
+ });
+
+ expect(stored()).toBe('encrypted:{"url":"https://relay.example.com/hook"}');
+ });
+
+ it("keeps a stored Gotify choice when the body omits the field", async () => {
+ existing({
+ url: "https://gotify.example.com/message",
+ headerValue: "AbCdEfGh123",
+ format: "gotify",
+ });
+
+ await put({
+ url: "https://gotify.example.com/message",
+ headerValue: "NewToken456",
+ enabled: true,
+ });
+
+ expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toMatchObject({
+ headerValue: "NewToken456",
+ format: "gotify",
+ });
+ });
+
+ it("switches a Gotify channel back to generic when asked", async () => {
+ existing({ url: "https://gotify.example.com/message", format: "gotify" });
+
+ await put({
+ url: "https://gotify.example.com/message",
+ format: "generic",
+ enabled: true,
+ });
+
+ expect(stored()).toBe(
+ 'encrypted:{"url":"https://gotify.example.com/message"}',
+ );
+ });
+
+ it("refuses a format it does not know", async () => {
+ const response = await put({
+ url: "https://relay.example.com/hook",
+ format: "discord",
+ enabled: true,
+ });
+
+ expect(response.status).toBe(422);
+ expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
+ });
+});
+
+describe("GET /api/settings/webhook — payload format", () => {
+ it("reports generic for a config saved before the choice existed", async () => {
+ existing({ url: "https://relay.example.com/hook" });
+
+ const json = await (await get()).json();
+
+ expect(json.data.format).toBe("generic");
+ });
+
+ it("reports the stored Gotify choice", async () => {
+ existing({ url: "https://gotify.example.com/message", format: "gotify" });
+
+ const json = await (await get()).json();
+
+ expect(json.data.format).toBe("gotify");
+ });
+
+ it("reports generic when no channel exists", async () => {
+ const json = await (await get()).json();
+
+ expect(json.data).toEqual({
+ enabled: false,
+ url: "",
+ headerName: "",
+ hasHeaderValue: false,
+ format: "generic",
+ });
+ });
+});
diff --git a/src/app/api/settings/email/test/route.ts b/src/app/api/settings/email/test/route.ts
index 4e40c8b9a..e6c1ecaa0 100644
--- a/src/app/api/settings/email/test/route.ts
+++ b/src/app/api/settings/email/test/route.ts
@@ -7,6 +7,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isEmailConfigured } from "@/lib/notifications/senders/email-config";
+import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
/**
* POST: send a test email to the configured recipient.
@@ -46,7 +47,13 @@ export const POST = apiHandler(async () => {
});
if (!result.ok) {
- return apiError("Failed to send test email", 500);
+ // The mail server, or the way to it, failed: 502 with the code and the
+ // SMTP reply code. Only an unnamed internal fault is a 500.
+ return answerTestDeliveryFailure(result, {
+ channel: "email",
+ label: "The mail server",
+ fallbackMessage: "Failed to send test email",
+ });
}
annotate({
diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts
index b260f1643..feeaff9ea 100644
--- a/src/app/api/settings/ntfy/test/route.ts
+++ b/src/app/api/settings/ntfy/test/route.ts
@@ -6,6 +6,7 @@ import type { NtfyChannelConfig } from "@/lib/notifications/types";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
+import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
/**
* POST: Send a test notification via ntfy.
@@ -58,7 +59,13 @@ export const POST = apiHandler(async () => {
{ errorCode: result.errorCode },
);
}
- return apiError("Failed to send test message", 500);
+ // The server, or the way to it, failed: 502 with the upstream status
+ // and what it said (#947). Only an unnamed internal fault is a 500.
+ return answerTestDeliveryFailure(result, {
+ channel: "ntfy",
+ label: "The ntfy server",
+ fallbackMessage: "Failed to send test message",
+ });
}
annotate({ action: { name: "settings.ntfy.test" }, meta: { success: true } });
diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts
index 1f1b072b3..49cccdec3 100644
--- a/src/app/api/settings/webhook/route.ts
+++ b/src/app/api/settings/webhook/route.ts
@@ -20,6 +20,7 @@ import { encrypt, decrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
+import type { WebhookChannelConfig } from "@/lib/notifications/types";
/**
* The save-time schema evaluates the same policy the sender does: the public
@@ -61,22 +62,23 @@ export const GET = apiHandler(async () => {
url: "",
headerName: "",
hasHeaderValue: false,
+ format: "generic",
});
}
- const config = JSON.parse(decrypt(channel.config)) as {
- url: string;
- headerName?: string;
- headerValue?: string;
- };
+ const config = JSON.parse(
+ decrypt(channel.config),
+ ) as Partial;
annotate({ action: { name: "settings.webhook.get" } });
return apiSuccess({
enabled: channel.enabled,
- url: config.url,
+ url: config.url ?? "",
headerName: config.headerName ?? "",
hasHeaderValue: !!config.headerValue,
+ // A config saved before the choice existed carries no format: generic.
+ format: config.format === "gotify" ? "gotify" : "generic",
});
});
@@ -166,7 +168,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return apiValidationError("Invalid data", issues, 422);
}
- const { url, headerName, headerValue, enabled } = parsed.data;
+ const { url, headerName, headerValue, format, enabled } = parsed.data;
if (enabled && !url) {
return apiError("Webhook URL is required when the webhook is enabled", 422);
@@ -174,24 +176,30 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// Preserve an existing header value when the client sends an empty one (the
// GET path never returns the secret, so a save round-trip would otherwise
- // wipe it). A non-empty value replaces it.
+ // wipe it). A non-empty value replaces it. An omitted format keeps the
+ // stored one too, so a client that does not know the field cannot flip a
+ // Gotify channel back to the generic body by saving.
let nextHeaderValue = headerValue || undefined;
- if (!nextHeaderValue) {
+ let nextFormat = format;
+ if (!nextHeaderValue || nextFormat === undefined) {
const existing = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
});
if (existing) {
- const prev = JSON.parse(decrypt(existing.config)) as {
- headerValue?: string;
- };
- nextHeaderValue = prev.headerValue || undefined;
+ const prev = JSON.parse(
+ decrypt(existing.config),
+ ) as Partial;
+ if (!nextHeaderValue) nextHeaderValue = prev.headerValue || undefined;
+ if (nextFormat === undefined) nextFormat = prev.format;
}
}
+ // Absent means generic, so only the Gotify choice is written.
const config = JSON.stringify({
url: url || "",
...(headerName ? { headerName } : {}),
...(nextHeaderValue ? { headerValue: nextHeaderValue } : {}),
+ ...(nextFormat === "gotify" ? { format: "gotify" } : {}),
});
const encryptedConfig = encrypt(config);
@@ -207,7 +215,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
update: { enabled, config: encryptedConfig },
});
- annotate({ action: { name: "settings.webhook.update" }, meta: { enabled } });
+ annotate({
+ action: { name: "settings.webhook.update" },
+ meta: { enabled, format: nextFormat === "gotify" ? "gotify" : "generic" },
+ });
return apiSuccess({ saved: true });
});
diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts
index cc6bfa6dc..7021e2bfe 100644
--- a/src/app/api/settings/webhook/test/route.ts
+++ b/src/app/api/settings/webhook/test/route.ts
@@ -6,6 +6,7 @@ import type { WebhookChannelConfig } from "@/lib/notifications/types";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
+import { answerTestDeliveryFailure } from "@/lib/notifications/test-delivery-failure";
/**
* POST: send a test notification via the configured generic webhook.
@@ -57,7 +58,14 @@ export const POST = apiHandler(async () => {
{ errorCode: result.errorCode },
);
}
- return apiError("Failed to send test message", 500);
+ // The relay, or the way to it, failed: 502 with the upstream status and
+ // what it said, so the card can show "the relay answered 400" instead
+ // of "Test failed" (#947). Only an unnamed internal fault is a 500.
+ return answerTestDeliveryFailure(result, {
+ channel: "webhook",
+ label: "The webhook",
+ fallbackMessage: "Failed to send test message",
+ });
}
annotate({
diff --git a/src/components/admin/__tests__/notification-test-failures.test.ts b/src/components/admin/__tests__/notification-test-failures.test.ts
new file mode 100644
index 000000000..b2fd9f403
--- /dev/null
+++ b/src/components/admin/__tests__/notification-test-failures.test.ts
@@ -0,0 +1,76 @@
+/**
+ * The admin notification test toast names each failing channel's cause
+ * (review L5): the code the route sends is translated and the status is
+ * shown, so the per-channel fields in the response have a reader.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+import { describeChannelTestFailures } from "../notification-test-failures";
+
+const MESSAGES: Record = {
+ "settings.testConnection.errors.upstream_rejected":
+ "The other end refused the message",
+ "settings.testConnection.errors.credentials_rejected": "Credentials rejected",
+ "admin.notificationTestFailed": "Failed to send test notification",
+};
+const t = (key: string) => MESSAGES[key] ?? key;
+
+describe("describeChannelTestFailures", () => {
+ it("lists only failing channels, with the translated cause and the status", () => {
+ expect(
+ describeChannelTestFailures(
+ [
+ { channel: "APNS", success: true },
+ {
+ channel: "WEBHOOK",
+ success: false,
+ error: "The webhook answered HTTP 400.",
+ errorCode: "upstream_rejected",
+ upstreamStatus: 400,
+ },
+ {
+ channel: "EMAIL",
+ success: false,
+ error: "The mail server rejected the credentials (SMTP 535).",
+ errorCode: "credentials_rejected",
+ smtpCode: 535,
+ },
+ ],
+ t,
+ ),
+ ).toEqual([
+ "Webhook: The other end refused the message (HTTP 400)",
+ "Email: Credentials rejected (SMTP 535)",
+ ]);
+ });
+
+ it("falls back to the route's sentence when there is no known code", () => {
+ expect(
+ describeChannelTestFailures(
+ [
+ {
+ channel: "NTFY",
+ success: false,
+ error: "SYSTEM_ALERT disabled in settings",
+ },
+ { channel: "MYSTERY", success: false, errorCode: "unlisted" },
+ ],
+ t,
+ ),
+ ).toEqual([
+ "ntfy: SYSTEM_ALERT disabled in settings",
+ "MYSTERY: Failed to send test notification",
+ ]);
+ });
+
+ it("is what the admin toast renders", () => {
+ const src = readFileSync(
+ join(process.cwd(), "src/components/admin/reminders-section.tsx"),
+ "utf8",
+ );
+ expect(src).toMatch(/describeChannelTestFailures\(data\?\.results/);
+ expect(src).toMatch(/description:/);
+ });
+});
diff --git a/src/components/admin/notification-test-failures.ts b/src/components/admin/notification-test-failures.ts
new file mode 100644
index 000000000..caf578b50
--- /dev/null
+++ b/src/components/admin/notification-test-failures.ts
@@ -0,0 +1,46 @@
+import {
+ CHANNEL_TYPE_LABELS,
+ type ChannelType,
+} from "@/lib/notifications/types";
+
+/** One channel's row in the admin notification test response. */
+export interface ChannelTestResult {
+ channel: string;
+ success: boolean;
+ error?: string;
+ errorCode?: string;
+ upstreamStatus?: number;
+ smtpCode?: number;
+}
+
+/**
+ * One line per failing channel for the admin test toast: the channel name,
+ * the cause in the admin's language (the same `settings.testConnection.errors`
+ * vocabulary the settings cards use), and the HTTP status or SMTP reply code.
+ * A row without a known code falls back to the route's own sentence.
+ */
+export function describeChannelTestFailures(
+ results: ReadonlyArray,
+ t: (key: string) => string,
+): string[] {
+ return results
+ .filter((r) => !r.success)
+ .map((r) => {
+ const label = CHANNEL_TYPE_LABELS[r.channel as ChannelType] ?? r.channel;
+ const key = r.errorCode
+ ? `settings.testConnection.errors.${r.errorCode}`
+ : null;
+ const translated = key ? t(key) : null;
+ const cause =
+ translated && translated !== key
+ ? translated
+ : (r.error ?? t("admin.notificationTestFailed"));
+ const code =
+ typeof r.upstreamStatus === "number"
+ ? ` (HTTP ${r.upstreamStatus})`
+ : typeof r.smtpCode === "number"
+ ? ` (SMTP ${r.smtpCode})`
+ : "";
+ return `${label}: ${cause}${code}`;
+ });
+}
diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx
index ff4f28818..2a6d892d3 100644
--- a/src/components/admin/reminders-section.tsx
+++ b/src/components/admin/reminders-section.tsx
@@ -22,6 +22,10 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAdminSettings, useUpdateSettings } from "./_shared";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
+import {
+ describeChannelTestFailures,
+ type ChannelTestResult,
+} from "./notification-test-failures";
interface NotificationHealth {
windowHours: number;
@@ -132,19 +136,23 @@ export function RemindersSection() {
return apiPost<
| {
message?: string;
- results?: Array<{
- channel: string;
- success: boolean;
- error?: string;
- }>;
+ results?: ChannelTestResult[];
}
| undefined
>("/api/admin/notifications/test");
},
onSuccess: (data) => {
- const hasFailures = data?.results?.some((r) => !r.success);
- if (hasFailures) {
- toast.error(data?.message ?? t("admin.notificationTestFailed"));
+ const failures = describeChannelTestFailures(data?.results ?? [], t);
+ if (failures.length > 0) {
+ toast.error(data?.message ?? t("admin.notificationTestFailed"), {
+ description: (
+