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

+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(),
});
}