mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge channel tests that report the other end's answer, and the Gotify format
This commit is contained in:
commit
4306bf1aec
40 files changed
+2059
-119
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user