Merge channel tests that report the other end's answer, and the Gotify format

This commit is contained in:
Marc-André Bombeck committed 2026-09-13 14:30:14 +02:00
commit 4306bf1aec
40 files changed
+2059 -119

No files matched your search

+79 -10
View File
@@ -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,21 @@ 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), `upstream_error` (a temporary 4xx rejection), `timeout`, or `connection_failed`.
`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 +12388,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 +12409,24 @@ 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 header
value, auth token, topic or URL token)."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/settings/telegram/test:
post:
tags:
@@ -12421,11 +12456,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 +12479,24 @@ 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, auth token, topic or URL token)."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/settings/data:
delete:
tags:
@@ -25863,6 +25917,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 +25934,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 +57509,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:
+67
View File
@@ -207,6 +207,73 @@ 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.
## Notification targets on a private network
Every webhook and ntfy target is resolved at send time and refused when
+5
View File
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "Die vollständige URL für den POST. Muss öffentlich erreichbar sein.",
"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",
+5
View File
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "The full URL to POST to. Must be publicly reachable.",
"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",
+5
View File
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "La URL completa para el POST. Debe ser accesible públicamente.",
"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",
+5
View File
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "L’URL complète pour le POST. Doit être accessible publiquement.",
"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",
+5
View File
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "L’URL completo per il POST. Deve essere raggiungibile pubblicamente.",
"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",
+5
View File
@@ -5590,6 +5590,10 @@
"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이 공개 엔드포인트가 아니에요",
+5
View File
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "Pełny adres URL dla żądania POST. Musi być publicznie dostępny.",
"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",
@@ -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,127 @@ 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");
});
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,
upstreamBody: "priority must be an integer",
});
});
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 },
]);
});
});
+78 -10
View File
@@ -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,39 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
return true;
}
type TestResult = {
channel: string;
success: boolean;
error?: string;
} & TestFailureDetail;
/**
* 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,
context?: string,
): TestResult {
const detail: TestFailureDetail = result.errorCode
? { errorCode: result.errorCode }
: testFailureDetail(result);
const cause = result.errorCode
? `refused by the private-origin policy (${result.errorCode})`
: detail.errorCode
? testFailureSentence(label, detail)
: (result.reason ?? "send failed");
return {
channel,
success: false,
error: context ? `${cause} (${context})` : cause,
...detail,
};
}
function isLocale(value: string | null | undefined): value is Locale {
return (
value === "de" ||
@@ -121,11 +164,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 +205,40 @@ 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})`,
});
results.push(
failedSend(
"NTFY",
"The ntfy server",
ntfyResult,
`topic: ${config.topic}`,
),
);
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;
@@ -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<Response> {
return (POST as (request: Request) => Promise<Response>)(
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);
});
});
@@ -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
@@ -351,20 +353,99 @@ 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");
},
);
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();
},
);
it.each([
["webhook", sendViaWebhookMock],
@@ -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<Response>;
function put(body: unknown): Promise<Response> {
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<Response> {
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<string, unknown>) {
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",
});
});
});
+8 -1
View File
@@ -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({
+8 -1
View File
@@ -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 } });
+25 -14
View File
@@ -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<WebhookChannelConfig>;
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<WebhookChannelConfig>;
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 });
});
+9 -1
View File
@@ -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({
@@ -154,4 +154,27 @@ describe("notification channel cards", () => {
);
},
);
it("webhook card offers the payload format and sends it with the full save (#947)", async () => {
const html = render(<WebhookCard isAuthenticated />);
expect(html).toContain('id="webhook-format"');
expect(html).toMatch(/<option value="generic" selected="">/);
expect(html).toContain('<option value="gotify">');
// The full save is the first mutation of the last render, whose closure
// holds the values seeded from the server.
const save = mocks.mutationOptions[mocks.mutationOptions.length - 2]!;
await save.mutationFn(false);
const [, init] = mocks.apiFetchRaw.mock.calls[0] as [
string,
{ body: string },
];
expect(JSON.parse(init.body)).toMatchObject({
url: "https://saved.example/hook",
format: "generic",
enabled: false,
});
});
});
@@ -0,0 +1,61 @@
/**
* The test-connection failure callout (#947).
*
* SSR-only, like the rest of the component suite: the callout is hook-free so
* it renders here without a provider. What is pinned is what a person reads:
* the translated sentence, the status the other end answered, and the relay's
* own words as text, never as markup.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { TestConnectionFailure } from "../test-connection-button";
describe("TestConnectionFailure", () => {
it("shows the sentence with the HTTP status and what the relay said", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure
message="The other end refused the message"
upstreamStatus={400}
upstreamBody='{"error":"Bad Request","errorCode":400}'
/>,
);
expect(html).toContain('role="alert"');
expect(html).toContain("The other end refused the message (HTTP 400)");
expect(html).toContain(
"{&quot;error&quot;:&quot;Bad Request&quot;,&quot;errorCode&quot;:400}",
);
});
it("renders a markup-shaped body as characters", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure
message="Upstream returned an error"
upstreamStatus={502}
upstreamBody='<img src=x onerror="alert(1)">'
/>,
);
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
});
it("shows the SMTP code when there is no HTTP status", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure message="Credentials rejected" smtpCode={535} />,
);
expect(html).toContain("Credentials rejected (SMTP 535)");
});
it("shows the sentence alone when the server sent no detail", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure message="Test failed" />,
);
expect(html).toContain("Test failed");
expect(html).not.toContain("HTTP");
expect(html).not.toContain("test-connection-upstream-body");
});
});
@@ -11,7 +11,10 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
* Shared "Test connection" UX for the settings integrations + notifications
* sections (A8-UI). POSTs to the given endpoint, expects a `{ data, error,
* meta }` envelope, and surfaces success (latency) or a translated
* `meta.errorCode` callout.
* `meta.errorCode` callout. When the route reports what the other end
* answered (`meta.upstreamStatus` / `meta.smtpCode` / `meta.upstreamBody`,
* the notification test routes since #947), the callout shows the status and
* the relay's own words beneath it, as plain text.
*
* Endpoints are user-scoped, so the button intentionally does NOT send an
* Idempotency-Key — each click probes the upstream live.
@@ -27,7 +30,56 @@ export interface TestConnectionButtonProps {
interface TestResponse {
data?: { latencyMs?: number; ok?: boolean; sent?: number };
error?: string;
meta?: { errorCode?: string };
meta?: {
errorCode?: string;
upstreamStatus?: number;
smtpCode?: number;
upstreamBody?: string;
};
}
export interface TestConnectionFailureProps {
/** The translated error sentence. */
message: string;
upstreamStatus?: number;
smtpCode?: number;
/** What the other end answered, already bounded and cleaned by the server. */
upstreamBody?: string;
}
/**
* The failure callout. Hook-free so it renders without a provider. The
* relay's body is a React text child, never markup: whatever it contains is
* shown as characters.
*/
export function TestConnectionFailure({
message,
upstreamStatus,
smtpCode,
upstreamBody,
}: TestConnectionFailureProps) {
const code =
typeof upstreamStatus === "number"
? ` (HTTP ${upstreamStatus})`
: typeof smtpCode === "number"
? ` (SMTP ${smtpCode})`
: "";
return (
<div role="alert" className="space-y-1">
<p className="text-destructive flex items-center gap-1.5 text-sm">
<XCircle className="size-3.5 shrink-0" />
{`${message}${code}`}
</p>
{upstreamBody ? (
<p
data-testid="test-connection-upstream-body"
className="text-foreground font-mono text-xs break-all"
>
{upstreamBody}
</p>
) : null}
</div>
);
}
export function TestConnectionButton({
@@ -39,7 +91,13 @@ export function TestConnectionButton({
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<
| { kind: "ok"; latency: number }
| { kind: "error"; errorCode: string }
| {
kind: "error";
errorCode: string;
upstreamStatus?: number;
smtpCode?: number;
upstreamBody?: string;
}
| null
>(null);
@@ -56,9 +114,20 @@ export function TestConnectionButton({
latency: json.data?.latencyMs ?? 0,
});
} else {
const meta = json.meta;
setResult({
kind: "error",
errorCode: json.meta?.errorCode ?? "generic",
errorCode: meta?.errorCode ?? "generic",
upstreamStatus:
typeof meta?.upstreamStatus === "number"
? meta.upstreamStatus
: undefined,
smtpCode:
typeof meta?.smtpCode === "number" ? meta.smtpCode : undefined,
upstreamBody:
typeof meta?.upstreamBody === "string"
? meta.upstreamBody
: undefined,
});
}
} catch {
@@ -109,13 +178,12 @@ export function TestConnectionButton({
)}
{result?.kind === "error" && (
<p
role="alert"
className="text-destructive flex items-center gap-1.5 text-sm"
>
<XCircle className="h-3.5 w-3.5" />
{describeError(result.errorCode)}
</p>
<TestConnectionFailure
message={describeError(result.errorCode)}
upstreamStatus={result.upstreamStatus}
smtpCode={result.smtpCode}
upstreamBody={result.upstreamBody}
/>
)}
</div>
);
+31 -1
View File
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
@@ -21,12 +22,14 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type { WebhookPayloadFormat } from "@/lib/notifications/types";
interface WebhookSettings {
enabled: boolean;
url: string;
headerName: string;
hasHeaderValue: boolean;
format?: WebhookPayloadFormat;
}
export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
@@ -35,6 +38,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const [url, setUrl] = useState("");
const [headerName, setHeaderName] = useState("");
const [headerValue, setHeaderValue] = useState("");
const [format, setFormat] = useState<WebhookPayloadFormat>("generic");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
@@ -50,13 +54,14 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings
? `${settings.url}|${settings.headerName}`
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setUrl(settings!.url);
setHeaderName(settings!.headerName);
setFormat(settings!.format === "gotify" ? "gotify" : "generic");
}
const save = useMutation({
@@ -68,6 +73,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
url,
headerName: headerName || undefined,
headerValue: headerValue || undefined,
format,
enabled,
}),
});
@@ -180,6 +186,30 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
{t("settings.webhookUrlHint")}
</p>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="webhook-format">
{t("settings.webhookFormat")}
</Label>
<NativeSelect
id="webhook-format"
value={format}
onChange={(e) =>
setFormat(e.target.value === "gotify" ? "gotify" : "generic")
}
>
<option value="generic">
{t("settings.webhookFormatGeneric")}
</option>
<option value="gotify">
{t("settings.webhookFormatGotify")}
</option>
</NativeSelect>
{format === "gotify" && (
<p className="text-muted-foreground text-xs">
{t("settings.webhookFormatGotifyHint")}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-name">
{t("settings.webhookHeaderName")}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { looksSecretShaped } from "@/lib/secret-shape";
describe("looksSecretShaped", () => {
it.each([
["an access token", "token hlk_abc123"],
["a refresh token", "hlr_xyz789"],
["a share-link token", "see hls_def456"],
["an invite token", "hlv_0a1b2c3d"],
["an elevation token", "hle_0a1b2c3d"],
["an OpenAI key", "echoed sk-1234567890"],
["an Anthropic key", "sk-ant-api03-xyz12345"],
])("matches %s", (_label, text) => {
expect(looksSecretShaped(text)).toBe(true);
});
it.each([
["a word containing sk-", "task-id must not contain spaces"],
["another one", "risk-management"],
["a JSON error body", '{"error":"Bad Request","errorCode":400}'],
])("does not match %s", (_label, text) => {
expect(looksSecretShaped(text)).toBe(false);
});
});
+9 -1
View File
@@ -172,8 +172,16 @@ describe("the credential prefix lists do not drift apart", () => {
// Two parallel denylists guard the same class of value. They were already
// one prefix out of step (`hls_` was in one and not the other), which is
// how the next one gets missed.
// The cache refuses through the shared matcher, so the matcher is what
// has to know the prefix, and the cache has to be using it rather than a
// private copy of the pattern.
const { looksSecretShaped } = await import("@/lib/secret-shape");
expect(looksSecretShaped(`elevation=hle_${"a".repeat(64)}`)).toBe(true);
const idempotency = readFileSync("src/lib/idempotency.ts", "utf8");
expect(idempotency).toContain("hle_");
expect(idempotency).toMatch(
/import \{ looksSecretShaped \} from "@\/lib\/secret-shape"/,
);
expect(idempotency).toMatch(/looksSecretShaped\(text\)/);
const { redactSecrets } = await import("@/lib/logging/redact");
const secret = `hle_${"a".repeat(64)}`;
+4 -13
View File
@@ -28,6 +28,7 @@ import { isP2002 } from "@/lib/prisma-errors";
import { findActiveGrant } from "@/lib/sharing/grants";
import { decrypt, encrypt } from "@/lib/crypto";
import { createHash } from "node:crypto";
import { looksSecretShaped } from "@/lib/secret-shape";
const TTL_MS = 24 * 60 * 60 * 1000;
/**
@@ -641,21 +642,11 @@ export function withIdempotency<
// Defence-in-depth: never persist a body that carries a freshly-issued
// bearer / refresh token or a third-party AI provider key. Auth and
// settings routes shouldn't be wrapped in withIdempotency to begin
// with, but if a future caller forgets we refuse to leak.
// `hlk_` = our access tokens
// `hlr_` = our refresh tokens
// `hls_` = clinician share-link tokens (v1.11)
// `hlv_` = registration invite tokens (v1.16 — the admin mint
// response is the one place the raw token appears)
// `sk-…` = OpenAI / Anthropic keys (full token form, not the
// raw substring — a 422 body explaining "task-id…"
// or any other word containing `sk-` would otherwise
// silently break idempotency for benign retries).
const SECRET_PATTERN =
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
// with, but if a future caller forgets we refuse to leak. The prefixes
// are listed where the matcher lives (`src/lib/secret-shape.ts`).
const cloned = response.clone();
const text = await cloned.text();
if (!SECRET_PATTERN.test(text)) {
if (!looksSecretShaped(text)) {
await persistCached(ctx, response, text);
} else {
// Secret-shaped body — drop the claim so the key isn't left
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
codeForUpstreamStatus,
testFailureDetail,
testFailureSentence,
} from "@/lib/notifications/test-delivery-failure";
describe("codeForUpstreamStatus", () => {
it.each([
[400, "upstream_rejected"],
[401, "credentials_rejected"],
[403, "credentials_rejected"],
[404, "endpoint_not_found"],
[405, "upstream_rejected"],
[409, "upstream_rejected"],
[410, "endpoint_not_found"],
[413, "upstream_rejected"],
[415, "upstream_rejected"],
[422, "upstream_rejected"],
[429, "rate_limited"],
[500, "upstream_error"],
[502, "upstream_error"],
[301, "redirected"],
[308, "redirected"],
])("%i → %s", (status, code) => {
expect(codeForUpstreamStatus(status)).toBe(code);
});
});
describe("testFailureDetail", () => {
it("prefers the HTTP status over any transport code", () => {
expect(
testFailureDetail({
ok: false,
statusCode: 400,
failureCode: "timeout",
upstreamBody: "bad",
}),
).toEqual({
errorCode: "upstream_rejected",
upstreamStatus: 400,
upstreamBody: "bad",
});
});
it("uses the sender's code when there is no status", () => {
expect(
testFailureDetail({
ok: false,
failureCode: "upstream_rejected",
smtpCode: 550,
}),
).toEqual({ errorCode: "upstream_rejected", smtpCode: 550 });
});
it("names nothing for an unnamed failure", () => {
expect(testFailureDetail({ ok: false, reason: "x" })).toEqual({});
});
});
describe("testFailureSentence", () => {
it("states the status, and falls back to a plain sentence for an unlisted code", () => {
expect(
testFailureSentence("The webhook", {
errorCode: "upstream_rejected",
upstreamStatus: 400,
}),
).toBe("The webhook answered HTTP 400.");
expect(
testFailureSentence("The webhook", { errorCode: "something_new" }),
).toBe("The webhook test failed.");
});
});
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import {
readUpstreamBody,
secretsInHeaderValue,
secretsInUrl,
UPSTREAM_BODY_MAX_CHARS,
} from "@/lib/notifications/upstream-body";
function res(body: string, status = 400): Response {
return new Response(body, { status });
}
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
const BELL = String.fromCodePoint(0x07);
describe("readUpstreamBody", () => {
it("returns a short JSON error body as it arrived", async () => {
const body =
'{"error":"Bad Request","errorCode":400,"errorDescription":"priority must be an integer"}';
await expect(readUpstreamBody(res(body), [])).resolves.toBe(body);
});
it("removes control and bidirectional characters and collapses whitespace", async () => {
const body = `line one\r\n\tline two ${RIGHT_TO_LEFT_OVERRIDE}evil${ZERO_WIDTH_SPACE}${BELL}`;
await expect(readUpstreamBody(res(body), [])).resolves.toBe(
"line one line two evil",
);
});
it("cuts a long body to the cap, marked with an ellipsis", async () => {
const result = await readUpstreamBody(res("x".repeat(5000)), []);
expect(Array.from(result ?? "")).toHaveLength(UPSTREAM_BODY_MAX_CHARS);
expect(result?.endsWith("…")).toBe(true);
});
it("reads at most a bounded prefix of an endless body", async () => {
let pulled = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pulled += 1;
controller.enqueue(new TextEncoder().encode("y".repeat(1024)));
},
});
const result = await readUpstreamBody(
new Response(stream, { status: 502 }),
[],
);
expect(result?.startsWith("yyyy")).toBe(true);
// 4 KB in 1 KB chunks: the body is abandoned after a few pulls.
expect(pulled).toBeLessThan(10);
});
it("refuses a secret-shaped body", async () => {
await expect(
readUpstreamBody(res('{"error":"bad key sk-ant-api03-abcdefgh"}'), []),
).resolves.toBeUndefined();
});
it("refuses a body that echoes a configured secret", async () => {
await expect(
readUpstreamBody(res("token AbCdEf123 is not valid"), [
undefined,
"AbCdEf123",
]),
).resolves.toBeUndefined();
});
it("ignores a configured value too short to be a secret", async () => {
await expect(
readUpstreamBody(res("priority 5 rejected"), ["5"]),
).resolves.toBe("priority 5 rejected");
});
it("returns nothing for an empty or unreadable body", async () => {
await expect(readUpstreamBody(res(" \n"), [])).resolves.toBeUndefined();
await expect(
readUpstreamBody({ ok: false, status: 400 } as Response, []),
).resolves.toBeUndefined();
await expect(
readUpstreamBody(
{
text: () => Promise.reject(new Error("aborted")),
} as unknown as Response,
[],
),
).resolves.toBeUndefined();
});
});
describe("secret extraction", () => {
it("finds query values and userinfo in a URL", () => {
expect(
secretsInUrl(
"https://user:pa%20ss@gotify.example.com/message?token=Abc123",
),
).toEqual(["Abc123", "user", "pa ss"]);
expect(secretsInUrl("not a url")).toEqual([]);
});
it("keeps the token of a scheme-prefixed header value", () => {
expect(secretsInHeaderValue("Bearer tok123")).toEqual([
"Bearer tok123",
"tok123",
]);
expect(secretsInHeaderValue("AppToken")).toEqual(["AppToken"]);
expect(secretsInHeaderValue(undefined)).toEqual([]);
});
});
+16
View File
@@ -42,6 +42,22 @@ export interface SendOutcome {
* instead of a bare 500.
*/
errorCode?: string;
/**
* Why a delivery failed when there is no HTTP status to say it: a timeout,
* a refused connection, an SMTP reply. The value is a code from the test
* card's vocabulary (`settings.testConnection.errors`), so a test route can
* forward it as `meta.errorCode` without translating. Absent when the
* sender cannot tell, which the test routes treat as an internal failure.
*/
failureCode?: string;
/** SMTP reply code of a rejected send (email only). */
smtpCode?: number;
/**
* What the other end answered on a non-2xx, bounded and cleaned by
* `readUpstreamBody`, and absent when it might carry a secret. Only the
* test routes read it; neither the ledger nor the channel state stores it.
*/
upstreamBody?: string;
}
/**
@@ -170,3 +170,70 @@ describe("sendViaEmail", () => {
);
});
});
describe("sendViaEmail — the failure names itself for the test button", () => {
it.each([
[
"an authentication refusal",
Object.assign(new Error("Invalid login"), {
code: "EAUTH",
responseCode: 535,
}),
"credentials_rejected",
535,
],
[
"a timeout",
Object.assign(new Error("Connection timeout"), { code: "ETIMEDOUT" }),
"timeout",
undefined,
],
[
"a refused connection",
Object.assign(new Error("connect ECONNREFUSED"), {
code: "ECONNECTION",
}),
"connection_failed",
undefined,
],
[
"a permanent SMTP rejection",
Object.assign(new Error("550 mailbox unavailable"), {
responseCode: 550,
}),
"upstream_rejected",
550,
],
[
"a transient SMTP rejection",
Object.assign(new Error("451 try later"), { responseCode: 451 }),
"upstream_error",
451,
],
])("%s", async (_label, err, failureCode, smtpCode) => {
loadEmailConfigMock.mockReturnValue(transport);
sendMailMock.mockRejectedValue(err);
const result = await sendViaEmail(
{ recipient: "you@example.com" },
payload(),
);
expect(result.ok).toBe(false);
expect(result.failureCode).toBe(failureCode);
expect(result.smtpCode).toBe(smtpCode);
});
it("leaves an unrecognised fault without a code", async () => {
loadEmailConfigMock.mockReturnValue(transport);
sendMailMock.mockRejectedValue(new Error("something else"));
const result = await sendViaEmail(
{ recipient: "you@example.com" },
payload(),
);
expect(result.failureCode).toBeUndefined();
expect(result.smtpCode).toBeUndefined();
});
});
@@ -233,3 +233,38 @@ describe("sendViaNtfy", () => {
});
});
});
describe("sendViaNtfy — what the server said (#947)", () => {
it("carries a short error body on a non-2xx", async () => {
safeFetchMock.mockResolvedValue(
new Response('{"code":40009,"http":400,"error":"invalid request"}', {
status: 400,
}),
);
const result = await sendViaNtfy(config, payload());
expect(result).toMatchObject({
ok: false,
statusCode: 400,
upstreamBody: '{"code":40009,"http":400,"error":"invalid request"}',
});
});
it("drops a body that echoes the auth token or the topic", async () => {
safeFetchMock.mockResolvedValueOnce(
new Response("token tk_secret rejected", { status: 403 }),
);
expect((await sendViaNtfy(config, payload())).upstreamBody).toBeUndefined();
safeFetchMock.mockResolvedValueOnce(
new Response("topic health is reserved", { status: 403 }),
);
expect((await sendViaNtfy(config, payload())).upstreamBody).toBeUndefined();
});
it("names a timeout", async () => {
safeFetchMock.mockRejectedValue(new SafeFetchError("timed out", "timeout"));
expect((await sendViaNtfy(config, payload())).failureCode).toBe("timeout");
});
});
@@ -281,3 +281,178 @@ describe("sendViaWebhook", () => {
});
});
});
describe("sendViaWebhook — what the relay said (#947)", () => {
it("carries a short error body on a non-2xx", async () => {
safeFetchMock.mockResolvedValue(
new Response('{"error":"Bad Request","errorCode":400}', { status: 400 }),
);
const result = await sendViaWebhook(config, payload());
expect(result).toMatchObject({
ok: false,
statusCode: 400,
reason: "webhook_400",
upstreamBody: '{"error":"Bad Request","errorCode":400}',
});
// The ledger row keeps the reason only, never the body.
expect(recordPushAttemptMock).toHaveBeenCalledWith(
expect.not.objectContaining({ upstreamBody: expect.anything() }),
);
});
it("drops a body that echoes the header token or a query token", async () => {
safeFetchMock.mockResolvedValueOnce(
new Response("invalid token secret", { status: 401 }),
);
const viaHeader = await sendViaWebhook(config, payload());
expect(viaHeader.upstreamBody).toBeUndefined();
safeFetchMock.mockResolvedValueOnce(
new Response("unknown token Q1w2E3r4", { status: 401 }),
);
const viaQuery = await sendViaWebhook(
{ url: "https://relay.example.com/message?token=Q1w2E3r4" },
payload(),
);
expect(viaQuery.upstreamBody).toBeUndefined();
});
it("names a timeout and a refused connection, and leaves an unknown fault uncoded", async () => {
safeFetchMock.mockRejectedValueOnce(
new SafeFetchError("timed out", "timeout"),
);
expect((await sendViaWebhook(config, payload())).failureCode).toBe(
"timeout",
);
safeFetchMock.mockRejectedValueOnce(
new SafeFetchError("ECONNREFUSED", "network"),
);
expect((await sendViaWebhook(config, payload())).failureCode).toBe(
"connection_failed",
);
safeFetchMock.mockRejectedValueOnce(new TypeError("boom"));
expect(
(await sendViaWebhook(config, payload())).failureCode,
).toBeUndefined();
});
});
function sentBody(): string {
return (safeFetchMock.mock.calls[0][1] as { body: string }).body;
}
describe("sendViaWebhook — generic body stays byte for byte", () => {
// Literal strings, not re-parsed objects: a Home Assistant or n8n rule
// that matches on the raw body must keep matching after the format choice
// was added.
it.each([
[
"a routine event",
{},
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"default"}',
],
[
"a medication reminder",
{ eventType: "MEDICATION_REMINDER" },
'{"title":"Title","message":"Body","eventType":"MEDICATION_REMINDER","priority":"high"}',
],
[
"an urgent event",
{ urgent: true },
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"urgent"}',
],
[
"a discreet cycle event",
{ eventType: "CYCLE_PERIOD_SOON", discreet: true },
'{"title":"Title","message":"Body","eventType":"reminder","priority":"default"}',
],
])("%s", async (_label, over, expected) => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaWebhook(
{ url: "https://relay.example.com/hook" },
payload(over),
);
expect(sentBody()).toBe(expected);
});
});
describe("sendViaWebhook — Gotify format (#947)", () => {
const gotify = {
url: "https://gotify.example.com/message",
headerName: "X-Gotify-Key",
headerValue: "AbCdEfGh123",
format: "gotify" as const,
};
it("sends the body Gotify binds, with an integer priority and no eventType field", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaWebhook(gotify, payload({ message: "<b>Body</b>" }));
expect(JSON.parse(sentBody())).toEqual({
title: "Title",
message: "Body",
priority: 5,
extras: {
"client::display": { contentType: "text/plain" },
"healthlog::event": { type: "SYSTEM_ALERT" },
},
});
const [url, init] = safeFetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
expect(url).toBe("https://gotify.example.com/message");
expect(init.headers["X-Gotify-Key"]).toBe("AbCdEfGh123");
});
it.each([
["a routine event", 5, {}],
["a medication reminder", 8, { eventType: "MEDICATION_REMINDER" }],
["an urgent event", 10, { urgent: true }],
[
"an urgent medication reminder",
10,
{ eventType: "MEDICATION_REMINDER", urgent: true },
],
])("maps %s to priority %i", async (_label, priority, over) => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaWebhook(gotify, payload(over));
const body = JSON.parse(sentBody());
expect(body.priority).toBe(priority);
expect(Number.isInteger(body.priority)).toBe(true);
});
it("keeps the cycle event name out of a discreet message", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaWebhook(
gotify,
payload({ eventType: "CYCLE_PERIOD_SOON", discreet: true }),
);
expect(sentBody()).not.toContain("CYCLE");
expect(JSON.parse(sentBody()).extras["healthlog::event"]).toEqual({
type: "reminder",
});
});
it("treats an unknown stored format as generic", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaWebhook(
{ url: "https://relay.example.com/hook", format: "bogus" as never },
payload(),
);
expect(JSON.parse(sentBody()).priority).toBe("default");
});
});
+40 -5
View File
@@ -79,19 +79,48 @@ function classifySmtpError(err: unknown): {
hardReject: boolean;
reason: string;
message: string;
failureCode?: string;
smtpCode?: number;
} {
const message = err instanceof Error ? err.message : "smtp_send_failed";
// nodemailer attaches `responseCode` (the SMTP reply code) on protocol-level
// rejections. 5xx = permanent (bad mailbox, relay denied) → hard reject so
// the channel auto-disables. 4xx / connection errors = transient → retry.
// rejections and a `code` string on transport faults. 5xx = permanent (bad
// mailbox, relay denied) → hard reject so the channel auto-disables.
// 4xx / connection errors = transient → retry.
const responseCode =
typeof err === "object" && err !== null && "responseCode" in err
? (err as { responseCode?: number }).responseCode
: undefined;
if (typeof responseCode === "number" && responseCode >= 500) {
return { hardReject: true, reason: "email_smtp_5xx", message };
const code =
typeof err === "object" && err !== null && "code" in err
? (err as { code?: unknown }).code
: undefined;
const smtpCode = typeof responseCode === "number" ? responseCode : undefined;
// The test card's vocabulary, so the test route can say which it was.
// Anything unrecognised stays without a code.
const failureCode =
code === "EAUTH"
? "credentials_rejected"
: code === "ETIMEDOUT"
? "timeout"
: code === "ECONNECTION" ||
code === "ESOCKET" ||
code === "EDNS" ||
code === "ECONNREFUSED"
? "connection_failed"
: smtpCode !== undefined && smtpCode >= 500
? "upstream_rejected"
: smtpCode !== undefined && smtpCode >= 400
? "upstream_error"
: undefined;
const extras = {
...(failureCode ? { failureCode } : {}),
...(smtpCode !== undefined ? { smtpCode } : {}),
};
if (smtpCode !== undefined && smtpCode >= 500) {
return { hardReject: true, reason: "email_smtp_5xx", message, ...extras };
}
return { hardReject: false, reason: "email_smtp_error", message };
return { hardReject: false, reason: "email_smtp_error", message, ...extras };
}
export async function sendViaEmail(
@@ -169,6 +198,12 @@ export async function sendViaEmail(
hardReject: classified.hardReject,
reason: classified.reason,
message: classified.message,
...(classified.failureCode
? { failureCode: classified.failureCode }
: {}),
...(classified.smtpCode !== undefined
? { smtpCode: classified.smtpCode }
: {}),
};
}
}
+19
View File
@@ -16,6 +16,10 @@ import {
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
} from "@/lib/notifications/egress-policy";
import type { OriginReason } from "@/lib/private-origin-policy";
import {
readUpstreamBody,
secretsInUrl,
} from "@/lib/notifications/upstream-body";
/**
* Send notification via ntfy (simple HTTP POST).
@@ -129,11 +133,19 @@ export async function sendViaNtfy(
result: "error",
reason: classified.reason,
});
// What the server said, for the test button. The topic is treated as a
// secret too: on a public ntfy server it is the only access control.
const upstreamBody = await readUpstreamBody(res, [
config.authToken,
config.topic,
...secretsInUrl(config.serverUrl),
]);
return {
ok: false,
statusCode: res.status,
hardReject: classified.hardReject,
reason: classified.reason,
...(upstreamBody ? { upstreamBody } : {}),
};
} catch (err) {
const message = err instanceof Error ? err.message : "request_failed";
@@ -144,6 +156,12 @@ export async function sendViaNtfy(
const reason = policyRefused
? "ntfy_private_origin_refused"
: "ntfy_network_error";
const failureCode =
err instanceof SafeFetchError && !policyRefused
? err.kind === "timeout"
? "timeout"
: "connection_failed"
: undefined;
getEvent()?.addExternalCall({
service: "ntfy",
method: "sendNotification",
@@ -165,6 +183,7 @@ export async function sendViaNtfy(
...(policyRefused
? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE }
: {}),
...(failureCode ? { failureCode } : {}),
};
}
}
+99 -25
View File
@@ -15,16 +15,94 @@ import {
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
} from "@/lib/notifications/egress-policy";
import type { OriginReason } from "@/lib/private-origin-policy";
import {
readUpstreamBody,
secretsInHeaderValue,
secretsInUrl,
} from "@/lib/notifications/upstream-body";
type PriorityBand = "urgent" | "high" | "default";
/**
* v1.18.4: an explicitly urgent event is `urgent` so a relay rule can
* escalate; MEDICATION_REMINDER keeps `high`; the rest `default`.
*/
function priorityBand(payload: NotificationPayload): PriorityBand {
if (payload.urgent === true) return "urgent";
if (payload.eventType === "MEDICATION_REMINDER") return "high";
return "default";
}
/**
* Gotify's priority is an integer, and its Android client files a message
* into a notification channel by it (`convertPriorityToChannel` in
* gotify/android): below 1 minimum importance, 1-3 low (no sound), 4-7
* default (sound), 8 and above high (sound, vibration, and a heads-up banner).
*
* The bands mirror what the ntfy sender asks for:
* - `default` → 5: audible, like ntfy's default. Not 1-3, which would
* make every routine reminder silent.
* - `high` (medication reminders) → 8: the lowest value that reaches the
* heads-up channel, as ntfy's `high` pops over.
* - `urgent` → 10: the same Android channel as 8, and the top of the scale
* for any client or rule that orders or filters by priority, as ntfy's 5.
*/
const GOTIFY_PRIORITY: Record<PriorityBand, number> = {
default: 5,
high: 8,
urgent: 10,
};
/**
* The request body for the configured format.
*
* Discreet mode (cycle privacy) is honoured the same way in both: the title
* and message arrive already masked upstream, and the event name a relay
* could route on is replaced by the generic `reminder`.
*/
export function buildWebhookBody(
config: Pick<WebhookChannelConfig, "format">,
payload: NotificationPayload,
): string {
const title = plainPushText(payload.title, payload.eventType);
const message = plainPushText(stripHtml(payload.message), payload.eventType);
const eventType = payload.discreet ? "reminder" : payload.eventType;
const band = priorityBand(payload);
if (config.format === "gotify") {
return JSON.stringify({
title,
message,
priority: GOTIFY_PRIORITY[band],
extras: {
// Plain text, stated rather than left to the client default: the
// body is never markdown (hard rule).
"client::display": { contentType: "text/plain" },
"healthlog::event": { type: eventType },
},
});
}
// Generic envelope. Discord and Slack ignore unknown keys, so one shape
// covers the common JSON relays. Unchanged since v1.18.4: an existing
// Home Assistant or n8n rule parses these exact fields.
return JSON.stringify({
title,
message,
eventType,
priority: band,
});
}
/**
* Send a notification via a generic outbound webhook (v1.17.1).
*
* The user supplies a public URL (and optionally one custom header — e.g.
* `Authorization: Bearer <token>` for Gotify). We POST a small JSON envelope
* that Gotify / Discord / Slack / Matrix-bridge / Home Assistant / any homelab
* relay can consume. The body is plain text (no markdown — hard rule); the
* `title`/`message` fields are stripped of HTML + decorative emoji on routine
* reminders exactly like the ntfy sender.
* The user supplies a URL and optionally one custom header, and chooses the
* body shape: HealthLog's generic JSON envelope, or the body Gotify's
* `POST /message` binds (see `buildWebhookBody`). Gotify takes its app token
* as an `X-Gotify-Key` header or a `token` query parameter. The body is plain
* text (no markdown — hard rule); `title`/`message` are stripped of HTML and
* decorative emoji on routine reminders exactly like the ntfy sender.
*
* Outbound goes through `safeFetch` with the connect-time DNS-rebinding pin
* because the host is user-supplied; the optional shared-secret header would
@@ -58,25 +136,7 @@ export async function sendViaWebhook(
headers[config.headerName] = config.headerValue;
}
// Plain-text envelope. The `title` field doubles as Gotify's `title` and
// Discord/Slack ignore unknown keys, so a single shape covers the common
// relays. Discreet mode (cycle privacy) is honoured by the title/body
// already being masked upstream; we also send a generic `eventType` tag so
// a relay rule can route without leaking the cycle event name.
const body = JSON.stringify({
title: plainPushText(payload.title, payload.eventType),
message: plainPushText(stripHtml(payload.message), payload.eventType),
eventType: payload.discreet ? "reminder" : payload.eventType,
// v1.18.4 — an explicitly urgent event maps to `urgent` so a relay
// rule (Gotify priority, Discord mention, Home Assistant automation)
// can escalate; MEDICATION_REMINDER keeps `high`; the rest `default`.
priority:
payload.urgent === true
? "urgent"
: payload.eventType === "MEDICATION_REMINDER"
? "high"
: "default",
});
const body = buildWebhookBody(config, payload);
const policy = evaluateNotificationTarget(config.url);
if (!policy.allowed || !policy.canonicalOrigin) {
@@ -130,11 +190,18 @@ export async function sendViaWebhook(
result: "error",
reason: classified.reason,
});
// What the relay said, for the test button (Gotify names the field it
// could not bind). Refused when it echoes the header or a URL token.
const upstreamBody = await readUpstreamBody(res, [
...secretsInHeaderValue(config.headerValue),
...secretsInUrl(config.url),
]);
return {
ok: false,
statusCode: res.status,
hardReject: classified.hardReject,
reason: classified.reason,
...(upstreamBody ? { upstreamBody } : {}),
};
} catch (err) {
const message = err instanceof Error ? err.message : "request_failed";
@@ -147,6 +214,12 @@ export async function sendViaWebhook(
const reason = policyRefused
? "webhook_private_origin_refused"
: "webhook_network_error";
const failureCode =
err instanceof SafeFetchError && !policyRefused
? err.kind === "timeout"
? "timeout"
: "connection_failed"
: undefined;
getEvent()?.addExternalCall({
service: "webhook",
method: "sendNotification",
@@ -168,6 +241,7 @@ export async function sendViaWebhook(
...(policyRefused
? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE }
: {}),
...(failureCode ? { failureCode } : {}),
};
}
}
@@ -0,0 +1,109 @@
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import type { SendOutcome } from "@/lib/notifications/retry-policy";
/**
* The code for an HTTP status a relay answered, in the test card's
* vocabulary (`settings.testConnection.errors`). The same split the
* Nightscout and wearable test routes use, plus two the relays need: a 3xx
* (safeFetch never follows a redirect) and a 4xx that is not about the
* credentials or the path, where the relay accepted the connection and
* refused the payload.
*/
export function codeForUpstreamStatus(status: number): string {
if (status === 401 || status === 403) return "credentials_rejected";
if (status === 404 || status === 410) return "endpoint_not_found";
if (status === 429) return "rate_limited";
if (status >= 500) return "upstream_error";
if (status >= 300 && status < 400) return "redirected";
return "upstream_rejected";
}
export interface TestFailureDetail {
/** Absent only for a failure the sender could not name: an internal fault. */
errorCode?: string;
upstreamStatus?: number;
smtpCode?: number;
upstreamBody?: string;
}
/** What a failed send outcome says, without the private-origin refusal. */
export function testFailureDetail(result: SendOutcome): TestFailureDetail {
if (result.statusCode !== undefined) {
return {
errorCode: codeForUpstreamStatus(result.statusCode),
upstreamStatus: result.statusCode,
...(result.upstreamBody ? { upstreamBody: result.upstreamBody } : {}),
};
}
if (result.failureCode) {
return {
errorCode: result.failureCode,
...(result.smtpCode !== undefined ? { smtpCode: result.smtpCode } : {}),
};
}
return {};
}
/** One English sentence for the envelope's `error`, the card translates the code. */
export function testFailureSentence(
label: string,
detail: TestFailureDetail,
): string {
if (detail.upstreamStatus !== undefined) {
return `${label} answered HTTP ${detail.upstreamStatus}.`;
}
const smtp =
detail.smtpCode !== undefined ? ` (SMTP ${detail.smtpCode})` : "";
switch (detail.errorCode) {
case "timeout":
return `${label} did not answer in time.`;
case "connection_failed":
return `Could not connect to ${label}.`;
case "credentials_rejected":
return `${label} rejected the credentials${smtp}.`;
case "upstream_rejected":
return `${label} refused the message${smtp}.`;
case "upstream_error":
return `${label} reported a temporary error${smtp}.`;
default:
return `${label} test failed.`;
}
}
/**
* The test routes' answer to a failed send that was not a private-origin
* refusal (those keep their own 422 arm).
*
* The other end, or the way to it, failed: 502 with `meta.errorCode`, the
* upstream status or SMTP code, and what the relay said when it is short
* and holds no secret. A failure the sender could not name is an internal
* fault and stays a 500 with `fallbackMessage`.
*/
export function answerTestDeliveryFailure(
result: SendOutcome,
opts: {
channel: "webhook" | "ntfy" | "email";
label: string;
fallbackMessage: string;
},
): Response {
const detail = testFailureDetail(result);
annotate({
action: { name: `settings.${opts.channel}.test` },
meta: {
success: false,
[`${opts.channel}_test_code`]: detail.errorCode ?? "internal",
...(detail.upstreamStatus !== undefined
? { [`${opts.channel}_test_status`]: detail.upstreamStatus }
: {}),
...(detail.smtpCode !== undefined
? { [`${opts.channel}_test_smtp_code`]: detail.smtpCode }
: {}),
},
});
if (!detail.errorCode) {
return apiError(opts.fallbackMessage, 500);
}
return apiError(testFailureSentence(opts.label, detail), 502, { ...detail });
}
+26 -7
View File
@@ -238,12 +238,26 @@ export interface NtfyChannelConfig {
}
/**
* Generic-webhook channel config (v1.17.1). The user supplies a public URL and
* an OPTIONAL custom header (e.g. `Authorization: Bearer <token>` for Gotify,
* or a Discord/Slack incoming-webhook URL needs no header). The dispatcher
* POSTs a JSON body — see `sendViaWebhook` — through `safeFetch` with
* `requirePublicHost: true` so the SSRF floor + DNS-rebinding pin apply to the
* user-supplied host exactly as they do for ntfy.
* The body shapes the generic webhook can send.
*
* - `generic`: HealthLog's own JSON envelope (`title`, `message`,
* `eventType`, `priority` as a word), for Home Assistant, n8n, a Discord
* or Slack relay, or anything that reads arbitrary JSON.
* - `gotify`: the body Gotify's `POST /message` binds, with `priority` as
* the integer Gotify types it as. The generic body is refused there with
* a 400.
*/
export const WEBHOOK_PAYLOAD_FORMATS = ["generic", "gotify"] as const;
export type WebhookPayloadFormat = (typeof WEBHOOK_PAYLOAD_FORMATS)[number];
/**
* Generic-webhook channel config (v1.17.1). The user supplies a URL and an
* OPTIONAL custom header (Gotify accepts its app token as `X-Gotify-Key`, or
* as a `token` query parameter on the URL; a Discord or Slack incoming-webhook
* URL needs no header). The dispatcher POSTs a JSON body — see
* `sendViaWebhook` — through `safeFetch` with the connect-time pin, so the
* SSRF floor and the DNS-rebinding defence apply to the user-supplied host
* exactly as they do for ntfy.
*/
export interface WebhookChannelConfig {
url: string;
@@ -251,6 +265,11 @@ export interface WebhookChannelConfig {
headerName?: string;
/** Optional header value (e.g. "Bearer <token>"). Encrypted at rest. */
headerValue?: string;
/**
* Body shape. Absent means `generic`, which is every config saved before
* the choice existed; only `gotify` is written to the stored config.
*/
format?: WebhookPayloadFormat;
}
/**
@@ -314,7 +333,7 @@ export interface NotificationPayload {
* does not).
* - ntfy → `Priority: 5` (max) + an `urgent` tag.
* - WebPush → `Urgency: high` header + `requireInteraction`.
* - Webhook → `priority: "urgent"` in the JSON body.
* - Webhook → `priority: "urgent"` in the generic body, `10` in Gotify's.
* - Telegram → normal delivery (no urgency tier exists; never silent).
* Normal events leave `urgent` unset and behave exactly as before.
*/
+121
View File
@@ -0,0 +1,121 @@
import { looksSecretShaped } from "@/lib/secret-shape";
/** What the test card shows at most. */
export const UPSTREAM_BODY_MAX_CHARS = 200;
/** What is read off the wire at most, before anything is cleaned or cut. */
const UPSTREAM_BODY_MAX_READ_BYTES = 4096;
/** A configured value shorter than this is not treated as a secret to hide. */
const MIN_KNOWN_SECRET_LENGTH = 4;
/**
* C0 and C1 control characters, plus the zero-width and bidirectional
* formatting marks that could make the rendered text read differently from
* what the relay sent.
*/
function isControlOrFormatting(codePoint: number): boolean {
return (
codePoint <= 0x1f ||
(codePoint >= 0x7f && codePoint <= 0x9f) ||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2066 && codePoint <= 0x2069)
);
}
async function readBounded(res: Response): Promise<string | undefined> {
const reader = res.body?.getReader?.();
if (reader) {
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (total < UPSTREAM_BODY_MAX_READ_BYTES) {
const { done, value } = await reader.read();
if (done || !value) break;
chunks.push(value);
total += value.byteLength;
}
} finally {
// An oversized error page is abandoned, not drained.
await reader.cancel().catch(() => {});
}
const bytes = new Uint8Array(Math.min(total, UPSTREAM_BODY_MAX_READ_BYTES));
let offset = 0;
for (const chunk of chunks) {
const room = bytes.length - offset;
if (room <= 0) break;
bytes.set(chunk.subarray(0, room), offset);
offset += Math.min(chunk.byteLength, room);
}
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
if (typeof res.text === "function") return res.text();
return undefined;
}
/**
* Read what a relay answered on a non-2xx, in a form safe to hand back to
* the person who pressed the test button.
*
* Bounded twice: at most 4 KB is read off the wire, and at most 200
* characters survive. Control and formatting characters are removed and
* whitespace collapsed. The text is refused outright (undefined) when it is
* empty, unreadable, secret-shaped, or contains one of `knownSecrets` (the
* header value, the auth token, a query-string token): a relay that echoes
* the credential it rejected must not have that credential shown on screen.
*
* The result is for the test routes. Nothing stores it: the ledger and the
* channel state keep only the status and the reason code.
*/
export async function readUpstreamBody(
res: Response,
knownSecrets: ReadonlyArray<string | undefined>,
): Promise<string | undefined> {
let raw: string | undefined;
try {
raw = await readBounded(res);
} catch {
return undefined;
}
if (!raw) return undefined;
const cleaned = Array.from(raw, (ch) =>
isControlOrFormatting(ch.codePointAt(0) ?? 0) ? " " : ch,
)
.join("")
.replace(/\s+/g, " ")
.trim();
if (!cleaned) return undefined;
if (looksSecretShaped(raw) || looksSecretShaped(cleaned)) return undefined;
for (const secret of knownSecrets) {
if (!secret || secret.length < MIN_KNOWN_SECRET_LENGTH) continue;
if (raw.includes(secret) || cleaned.includes(secret)) return undefined;
}
const characters = Array.from(cleaned);
if (characters.length <= UPSTREAM_BODY_MAX_CHARS) return cleaned;
return `${characters.slice(0, UPSTREAM_BODY_MAX_CHARS - 1).join("")}…`;
}
/**
* The secrets a URL can carry on its own: userinfo and every query value
* (Gotify's `?token=` form).
*/
export function secretsInUrl(url: string): string[] {
try {
const parsed = new URL(url);
const found = [...parsed.searchParams.values()];
if (parsed.username) found.push(decodeURIComponent(parsed.username));
if (parsed.password) found.push(decodeURIComponent(parsed.password));
return found;
} catch {
return [];
}
}
/** A header value and, for the `<scheme> <token>` form, the token alone. */
export function secretsInHeaderValue(value: string | undefined): string[] {
if (!value) return [];
const parts = value.trim().split(/\s+/);
return parts.length > 1 ? [value, parts[parts.length - 1]] : [value];
}
+40 -4
View File
@@ -22,6 +22,7 @@ import {
SHARING_NOT_PERMITTED_DESCRIPTION,
stdResponses,
} from "./shared";
import { WEBHOOK_PAYLOAD_FORMATS } from "@/lib/notifications/types";
// v1.16.11 — the one threshold read every dose-status consumer makes
// (cards, table, take-all-due derivation). `lateMinutes` /
@@ -255,7 +256,7 @@ const telegramSettingsRequest = telegramSettingsSchema.meta({
const webhookSettingsRequest = webhookSettingsSchema.meta({
id: "WebhookSettingsRequest",
description:
"Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is 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.",
"Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is 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. An omitted `format` keeps the stored body shape.",
});
/**
@@ -343,6 +344,11 @@ const webhookSettingsResponse = z
.describe(
"Whether a header value (the shared secret) is stored. The value itself is never returned.",
),
format: z
.enum(WEBHOOK_PAYLOAD_FORMATS)
.describe(
"Body shape the webhook sends. `generic` when no channel row exists or the config predates the choice.",
),
})
.meta({
id: "WebhookSettingsResponse",
@@ -685,7 +691,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Send a test email to the saved recipient",
description:
"Fires one message through the operator's SMTP transport to the address saved on this account. Takes no body. Rate-limited 5 per 5 minutes per user.\n\n" +
"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.",
"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 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:
@@ -697,6 +703,16 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
"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), `upstream_error` (a temporary 4xx rejection), `timeout`, or `connection_failed`. `meta.smtpCode` carries the SMTP reply code when the mail server sent one.",
content: { "application/json": { schema: errorEnvelope } },
},
"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: errorEnvelope } },
},
},
},
},
@@ -705,7 +721,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Notifications"],
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.",
"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 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:
@@ -717,6 +733,16 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
"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 header value, auth token, topic or URL token).",
content: { "application/json": { schema: errorEnvelope } },
},
"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: errorEnvelope } },
},
"422": {
description:
"The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
@@ -751,7 +777,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["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.",
"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 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:
@@ -763,6 +789,16 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
"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, auth token, topic or URL token).",
content: { "application/json": { schema: errorEnvelope } },
},
"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: errorEnvelope } },
},
"422": {
description:
"The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
+21
View File
@@ -0,0 +1,21 @@
/**
* One definition of "this text carries a credential HealthLog or a common
* AI provider mints", shared by every surface that refuses to store or echo
* such text.
*
* `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link
* tokens, `hlv_` registration invite tokens, `hle_` elevation tokens;
* `sk-…` / `sk-ant-…` OpenAI and Anthropic keys, in the full token form
* only: a bare `sk-` substring ("task-id", "risk-management") is not a key,
* and matching it would refuse benign text.
*
* It is a shape test, not a secret detector. A credential without one of
* these prefixes (a relay's own app token, a password) passes, so a caller
* that knows the secrets in play must also refuse text containing them.
*/
const SECRET_SHAPED =
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
export function looksSecretShaped(text: string): boolean {
return SECRET_SHAPED.test(text);
}
+10 -1
View File
@@ -1,6 +1,9 @@
import { z } from "zod/v4";
import { isIP } from "node:net";
import { EVENT_TYPES } from "@/lib/notifications/types";
import {
EVENT_TYPES,
WEBHOOK_PAYLOAD_FORMATS,
} from "@/lib/notifications/types";
export const notificationPreferenceSchema = z.object({
channelId: z.string().min(1),
@@ -391,6 +394,12 @@ export function webhookSettingsSchemaWith(
.or(z.literal("")),
headerName: z.string().max(100).optional().or(z.literal("")),
headerValue: z.string().max(500).optional().or(z.literal("")),
format: z
.enum(WEBHOOK_PAYLOAD_FORMATS)
.optional()
.describe(
"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.",
),
enabled: z.boolean(),
});
}