mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(notifications): give guardian and discreet deliveries the app root as tap target
Every visible delivery now carries a url. A managed-record delivery to a guardian and a discreet one open "/", which names no event and no record; only the silent intake sync push stays without one. The tap-target test walks every event type across own, guardian and discreet delivery and the channels that carry the absolute url. Document the forward-auth exemption for the quick-answer route.
This commit is contained in:
1 parent
fd44a8381b
commit
e292db1fbd
6 files changed
+161
-26
No files matched your search
@@ -472,7 +472,7 @@ once per event type, and every channel carries the same target:
|
||||
| Medication reminder, low supply, shared dose | `/medications` (low supply opens the medication) |
|
||||
| Check-up and measurement reminders | `/checkups` |
|
||||
| Proactive Coach nudge | the Coach conversation it started |
|
||||
| Cycle reminders | `/cycle` (nothing with discreet notifications on) |
|
||||
| Cycle reminders | `/cycle` (the dashboard with discreet notifications on) |
|
||||
| New sign-in | Settings, Security |
|
||||
| Integration sync failing | Settings, Integrations (an admin alert about another account opens the admin page) |
|
||||
| Illness red flag | the illness episode |
|
||||
@@ -496,8 +496,11 @@ How each channel carries it:
|
||||
ntfy, Telegram, the webhook and email need an absolute link, built from
|
||||
`APP_URL` (or `NEXT_PUBLIC_APP_URL`). Without one those channels deliver as
|
||||
before, just without a link. A notification about a profile you look after
|
||||
as a Guardian carries no deep link, because your session opens on your own
|
||||
record.
|
||||
as a Guardian opens the dashboard rather than the profile's page, because
|
||||
your session opens on your own record and the record switcher lives there.
|
||||
A discreet notification opens the dashboard as well, so its link names
|
||||
nothing. The silent background sync push for medication intake is the only
|
||||
notification without a link.
|
||||
|
||||
### Health figures in notifications
|
||||
|
||||
@@ -538,6 +541,23 @@ token is the only credential, so it is built to be harmless on its own:
|
||||
The buttons need `APP_URL`, because the ntfy app calls the URL from your
|
||||
phone. Your phone has to reach the instance at that address.
|
||||
|
||||
### Quick answers behind forward auth
|
||||
|
||||
If the instance sits behind a forward-auth gateway (Authelia, Authentik,
|
||||
oauth2-proxy, Cloudflare Access and the like), the gateway answers the
|
||||
button's request with its login page and the answer is never logged: the
|
||||
ntfy app has no session with the gateway. Exempt the one path from the
|
||||
gateway:
|
||||
|
||||
```
|
||||
/api/notifications/action/*
|
||||
```
|
||||
|
||||
The route does not rely on the gateway. The signed token is its only
|
||||
credential, it is rate-limited before the token is read, and it can log one
|
||||
mood answer for one account and nothing else. Every other path stays behind
|
||||
the gateway.
|
||||
|
||||
What the token cannot protect against is the topic itself. The button URLs,
|
||||
token included, travel through your ntfy server inside the message, and the
|
||||
message is delivered to everyone subscribed to the topic. For the six hours
|
||||
|
||||
@@ -85,7 +85,9 @@ describe("withDeliveryLinks", () => {
|
||||
"guardian-1",
|
||||
);
|
||||
expect(out.quickActions).toBeUndefined();
|
||||
expect(out.tap).toBeUndefined();
|
||||
// The app root, never the managed record's own surface.
|
||||
expect(out.tap?.path).toBe("/");
|
||||
expect(out.tap?.url).toBe("https://health.example/");
|
||||
});
|
||||
|
||||
it("keeps the path but drops absolute URLs and answers without a public origin", async () => {
|
||||
|
||||
@@ -6,10 +6,24 @@
|
||||
* `Record<EventType, …>` type already refuses to compile without one; this
|
||||
* pins that every entry is also a usable path).
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { EVENT_TYPES, type EventType } from "@/lib/notifications/types";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn().mockResolvedValue({ locale: "en" }) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { withDeliveryLinks } from "@/lib/notifications/delivery-links";
|
||||
import { buildNtfyMessage } from "@/lib/notifications/senders/ntfy";
|
||||
import { buildWebhookBody } from "@/lib/notifications/senders/webhook";
|
||||
import {
|
||||
EVENT_TYPES,
|
||||
type EventType,
|
||||
type NotificationPayload,
|
||||
} from "@/lib/notifications/types";
|
||||
import {
|
||||
NEUTRAL_TAP_PATH,
|
||||
NOTIFICATION_TAP_PATHS,
|
||||
absoluteAppUrl,
|
||||
configuredAppOrigin,
|
||||
@@ -91,14 +105,15 @@ describe("resolveTapPath", () => {
|
||||
).toBe("/coach");
|
||||
});
|
||||
|
||||
it("gives a managed Guardian delivery no path", () => {
|
||||
it("gives a managed Guardian delivery the app root, not the event's path", () => {
|
||||
expect(
|
||||
resolveTapPath({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
recordUserId: "record-1",
|
||||
recipientUserId: "guardian-1",
|
||||
metadata: { url: "/medications/m1" },
|
||||
}),
|
||||
).toBeNull();
|
||||
).toBe(NEUTRAL_TAP_PATH);
|
||||
});
|
||||
|
||||
it("keeps a self delivery's path when both ids are the same", () => {
|
||||
@@ -111,10 +126,14 @@ describe("resolveTapPath", () => {
|
||||
).toBe("/medications");
|
||||
});
|
||||
|
||||
it("gives a discreet delivery no path, so nothing names the cycle surface", () => {
|
||||
it("gives a discreet delivery the app root, so nothing names the cycle surface", () => {
|
||||
expect(
|
||||
resolveTapPath({ eventType: "CYCLE_PERIOD_SOON", discreet: true }),
|
||||
).toBeNull();
|
||||
resolveTapPath({
|
||||
eventType: "CYCLE_PERIOD_SOON",
|
||||
discreet: true,
|
||||
metadata: { url: "/cycle?day=1" },
|
||||
}),
|
||||
).toBe(NEUTRAL_TAP_PATH);
|
||||
});
|
||||
|
||||
it("gives the silent sync push no path even with an override", () => {
|
||||
@@ -164,3 +183,67 @@ describe("telegramLinkMode", () => {
|
||||
expect(telegramLinkMode(url)).toBe(mode);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every visible delivery carries a target on every channel: each event type,
|
||||
* delivered to its own account, to a Guardian of a managed record, and in
|
||||
* discreet mode. Only the silent sync push carries none. The channels that
|
||||
* take the absolute URL are checked through their pure builders; APNs and Web
|
||||
* Push take the path and are pinned in their own sender tests.
|
||||
*/
|
||||
describe("tap target on every kind and channel", () => {
|
||||
const variants: Array<{
|
||||
name: string;
|
||||
extra: Pick<
|
||||
Partial<NotificationPayload>,
|
||||
"recordUserId" | "recipientUserId" | "discreet"
|
||||
>;
|
||||
neutral: boolean;
|
||||
}> = [
|
||||
{ name: "own account", extra: {}, neutral: false },
|
||||
{
|
||||
name: "managed Guardian",
|
||||
extra: { recordUserId: "record-1", recipientUserId: "guardian-1" },
|
||||
neutral: true,
|
||||
},
|
||||
{ name: "discreet", extra: { discreet: true }, neutral: true },
|
||||
];
|
||||
|
||||
it.each(variants)("$name", async ({ extra, neutral }) => {
|
||||
process.env.APP_URL = "https://health.example";
|
||||
let checked = 0;
|
||||
for (const eventType of EVENT_TYPES) {
|
||||
const payload = await withDeliveryLinks(
|
||||
{ userId: "u1", title: "t", message: "m", eventType, ...extra },
|
||||
"guardian-1",
|
||||
);
|
||||
if (SILENT.has(eventType)) {
|
||||
expect(payload.tap, `${eventType} is silent`).toBeUndefined();
|
||||
continue;
|
||||
}
|
||||
const expectedPath = neutral
|
||||
? NEUTRAL_TAP_PATH
|
||||
: NOTIFICATION_TAP_PATHS[eventType];
|
||||
expect(payload.tap?.path, eventType).toBe(expectedPath);
|
||||
const url = `https://health.example${expectedPath}`;
|
||||
expect(payload.tap?.url, eventType).toBe(url);
|
||||
|
||||
expect(
|
||||
buildNtfyMessage("topic", payload).click,
|
||||
`ntfy ${eventType}`,
|
||||
).toBe(url);
|
||||
expect(
|
||||
JSON.parse(buildWebhookBody({ format: "generic" }, payload)).url,
|
||||
`webhook ${eventType}`,
|
||||
).toBe(url);
|
||||
expect(
|
||||
JSON.parse(buildWebhookBody({ format: "gotify" }, payload)).extras[
|
||||
"client::notification"
|
||||
].click.url,
|
||||
`gotify ${eventType}`,
|
||||
).toBe(url);
|
||||
checked += 1;
|
||||
}
|
||||
expect(checked).toBe(EVENT_TYPES.length - SILENT.size);
|
||||
});
|
||||
});
|
||||
@@ -726,7 +726,7 @@ describe("sendViaApns — dispatcher fan-out", () => {
|
||||
expect(note.category).toBe("MOOD_REMINDER");
|
||||
});
|
||||
|
||||
it("never gives a managed Guardian a tap path, even when one is set", async () => {
|
||||
it("gives a managed Guardian the app root, even when a deeper path is set", async () => {
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
|
||||
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
|
||||
] as never);
|
||||
@@ -741,7 +741,24 @@ describe("sendViaApns — dispatcher fan-out", () => {
|
||||
tap: { path: "/medications", url: null, label: "Open" },
|
||||
});
|
||||
|
||||
expect(sendMock.mock.calls[0][0].payload.url).toBeUndefined();
|
||||
expect(sendMock.mock.calls[0][0].payload.url).toBe("/");
|
||||
});
|
||||
|
||||
it("carries the resolved root on a discreet delivery", async () => {
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
|
||||
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
|
||||
] as never);
|
||||
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
|
||||
|
||||
await sendViaApns("u-1", {
|
||||
title: "t",
|
||||
message: "m",
|
||||
eventType: "CYCLE_PERIOD_SOON",
|
||||
discreet: true,
|
||||
tap: { path: "/", url: null, label: "Open" },
|
||||
});
|
||||
|
||||
expect(sendMock.mock.calls[0][0].payload.url).toBe("/");
|
||||
});
|
||||
|
||||
it("forwards reminderId on MEASUREMENT_REMINDER and strips unlisted keys", async () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-at
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
import { stripHtml } from "@/lib/notifications/strip-html";
|
||||
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
|
||||
import { NEUTRAL_TAP_PATH } from "@/lib/notifications/tap-target";
|
||||
import type { NotificationTap } from "@/lib/notifications/types";
|
||||
|
||||
export interface ApnsPayload {
|
||||
@@ -755,10 +756,13 @@ export async function sendViaApns(
|
||||
),
|
||||
// The resolved tap target (`tap-target.ts`): a same-origin path
|
||||
// the app opens on tap. Overrides a producer's `metadata.url`,
|
||||
// which the resolver already took into account. Never on a
|
||||
// managed delivery or a discreet one (the resolver returns null).
|
||||
...(!managedDelivery && payload.tap?.path
|
||||
? { url: payload.tap.path }
|
||||
// which the resolver already took into account. A managed
|
||||
// delivery and a discreet one carry the app root (the resolver
|
||||
// answers `/`); the managed arm is pinned here as well, so a
|
||||
// payload that bypassed the resolver still cannot deep-link a
|
||||
// Guardian into the record they are only notified about.
|
||||
...(payload.tap?.path
|
||||
? { url: managedDelivery ? NEUTRAL_TAP_PATH : payload.tap.path }
|
||||
: {}),
|
||||
},
|
||||
threadId: routingEvent,
|
||||
|
||||
@@ -70,16 +70,25 @@ export function isSafeAppPath(value: unknown): value is string {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app root: where a delivery that may not name its event leads.
|
||||
*/
|
||||
export const NEUTRAL_TAP_PATH = "/";
|
||||
|
||||
/**
|
||||
* The path a delivery opens, or `null` when it carries none.
|
||||
*
|
||||
* - A Guardian receiving a managed-record notification gets no deep link:
|
||||
* their session opens on their own record, and a path into the managed
|
||||
* record's surfaces would land them on the wrong person. The app root is
|
||||
* where the record switcher lives.
|
||||
* - Discreet mode (cycle privacy) carries no path either, because the path
|
||||
* names the surface (`/cycle`) and discreet mode promises that nothing on
|
||||
* the wire names the event.
|
||||
* Every visible delivery carries one (v1.43); only a silent background push
|
||||
* (`null` in {@link NOTIFICATION_TAP_PATHS}) does not. Two deliveries get the
|
||||
* app root instead of their event's path:
|
||||
*
|
||||
* - A Guardian receiving a managed-record notification: their session opens
|
||||
* on their own record, and a path into the managed record's surfaces would
|
||||
* land them on the wrong person. The app root is where the record switcher
|
||||
* lives.
|
||||
* - Discreet mode (cycle privacy): the event's path names the surface
|
||||
* (`/cycle`), and discreet mode promises that nothing on the wire names the
|
||||
* event. The root names nothing.
|
||||
*/
|
||||
export function resolveTapPath(
|
||||
payload: Pick<
|
||||
@@ -89,8 +98,8 @@ export function resolveTapPath(
|
||||
): string | null {
|
||||
const fallback = NOTIFICATION_TAP_PATHS[payload.eventType];
|
||||
if (fallback === null || fallback === undefined) return null;
|
||||
if (isManagedGuardianDelivery(payload)) return null;
|
||||
if (payload.discreet) return null;
|
||||
if (isManagedGuardianDelivery(payload)) return NEUTRAL_TAP_PATH;
|
||||
if (payload.discreet) return NEUTRAL_TAP_PATH;
|
||||
const override = payload.metadata?.url;
|
||||
return isSafeAppPath(override) ? override : fallback;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user