feat(notifications): resolve a tap target per event and offer mood quick answers

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 12:21:51 +02:00
1 parent 9aadb29876
commit f19dfc4572
21 files changed
+859 -21

No files matched your search

@@ -271,6 +271,13 @@ describe("S2 — the URL-token authentication set is frozen", () => {
// renders nothing about the profile before the preview answers.
"app/auth/claim/page.tsx":
"carries the handover token from the query string into the claim requests' body",
// v1.42.2 — notification quick answers. A notification app (ntfy's
// `http` action) calls it with no session, so the signed, user- and
// action-bound, short-lived token in the path is the credential. Rate
// limited before verification, single-use through the mood entry's unique
// key, and scrubbed from logs by `PATH_SECRET_PATHS`.
"app/api/notifications/action/[token]/route.ts":
"signed single-use quick-answer token from a notification action",
};
/**
@@ -304,6 +311,7 @@ describe("S2 — the URL-token authentication set is frozen", () => {
expect(bySegment.length).toBeGreaterThan(0);
expect(bySegment).toEqual([
"app/api/c/[token]/unlock/route.ts",
"app/api/notifications/action/[token]/route.ts",
"app/api/whoop/webhook/[token]/route.ts",
"app/api/withings/webhook/[token]/route.ts",
"app/c/[token]/d/[id]/route.ts",
@@ -0,0 +1,152 @@
/**
* `POST /api/notifications/action/<token>` — a quick answer tapped inside a
* notification.
*
* The caller is a notification app (ntfy's `http` action), not a browser
* with a session, so the signed path segment is the whole credential. What
* the token proves and how it is kept single-use is in
* `src/lib/notifications/action-token.ts`; this route only applies it:
*
* 1. Rate-limit per client address BEFORE looking at the token, so the
* route is no oracle for guessing one (the anonymous-surface limiter
* collapses a broken proxy trust chain into one tight bucket).
* 2. Verify the signature (constant-time) and the expiry.
* 3. Re-check what may have changed since the reminder went out: the
* account still exists and its mood module is still on.
* 4. Write the mood entry under the reminder's nonce. The unique
* `(userId, source, externalId)` key makes the first answer the only
* one: a repeat tap, or a different answer on the same reminder, is
* refused with 409 and writes nothing.
*
* The token travels in the path, so it is scrubbed from every log line by
* the `PATH_SECRET_PATHS` entry for this prefix, and the route sits on the
* URL-credential inventory in `session-surface-guard.test.ts`.
*/
import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { logTelegramMood } from "@/lib/mood/create-from-telegram";
import {
MOOD_QUICK_ACTIONS,
quickActionExternalId,
verifyActionToken,
} from "@/lib/notifications/action-token";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
export const dynamic = "force-dynamic";
/** Generous for one person tapping, tight for anyone enumerating. */
const RATE_LIMIT = 20;
const RATE_WINDOW_MS = 10 * 60 * 1000;
interface RouteContext {
params: Promise<{ token: string }>;
}
function isUniqueViolation(err: unknown): boolean {
return (
err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002"
);
}
export const POST = apiHandler(
async (request: NextRequest, context: RouteContext) => {
annotate({ action: { name: "notification.action.invoke" } });
const rl = await checkAuthSurfaceRateLimit(
request,
"notification-action",
RATE_LIMIT,
RATE_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { token } = await context.params;
const verdict = verifyActionToken(token);
if (!verdict.ok) {
annotate({
action: { name: "notification.action.refused" },
meta: { reason: verdict.reason },
});
return verdict.reason === "expired"
? apiError("This answer has expired", 410, {
errorCode: "notification_action.expired",
})
: apiError("Invalid action", 401, {
errorCode: "notification_action.invalid",
});
}
const user = await prisma.user.findUnique({
where: { id: verdict.userId },
select: { id: true, timezone: true },
});
if (!user) {
annotate({
action: { name: "notification.action.refused" },
meta: { reason: "unknown_user" },
});
return apiError("Invalid action", 401, {
errorCode: "notification_action.invalid",
});
}
getEvent()?.setAuth({
user_id: user.id,
auth_method: "notification_action_token",
});
if (!(await isModuleEnabled(user.id, "mood"))) {
annotate({
action: { name: "notification.action.refused" },
meta: { reason: "module_disabled" },
});
return apiError("Mood tracking is turned off", 403, {
errorCode: "notification_action.module_disabled",
});
}
const score = MOOD_QUICK_ACTIONS[verdict.action];
let created: boolean;
try {
const result = await logTelegramMood({
userId: user.id,
score,
tz: user.timezone,
externalId: quickActionExternalId(verdict.nonce),
source: "NOTIFICATION",
});
created = result.created;
} catch (err) {
// Two taps raced past the existence check; the unique key let one in.
if (!isUniqueViolation(err)) throw err;
created = false;
}
if (!created) {
annotate({
action: { name: "notification.action.refused" },
meta: { reason: "already_used" },
});
return apiError("This reminder was already answered", 409, {
errorCode: "notification_action.already_used",
});
}
annotate({
action: { name: "notification.action.mood_logged" },
meta: { action: verdict.action },
});
return apiSuccess({ logged: true, action: verdict.action });
},
);
+2
View File
@@ -1395,6 +1395,8 @@ function formatMoodSource(
return t("mood.sourceTelegram");
case "DAYLIO":
return t("mood.sourceDaylio");
case "NOTIFICATION":
return t("mood.sourceNotification");
default:
return source;
}
+4
View File
@@ -68,6 +68,10 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{
// and its last segment is a route name. A full URL carrying the token is
// still scrubbed, by the `hlp_` rule in `redactSecrets`.
{ prefix: "/claim/", pathStart: true },
// v1.42.2 — notification quick answers (ntfy `http` actions). The signed
// single-use action token is the request's only credential and rides the
// trailing path segment of `/api/notifications/action/<token>`.
{ prefix: "/api/notifications/action/" },
];
function redactPathSegments(input: string): string {
+2 -1
View File
@@ -59,7 +59,8 @@ export interface WideEvent {
| "api_key"
| "cron_secret"
| "telegram_webhook"
| "webhook_secret";
| "webhook_secret"
| "notification_action_token";
/**
* v1.36.0 — the account whose record this request read or wrote, when it
* is not the caller's own. `user_id` above always stays the ACTOR: the
+11 -4
View File
@@ -7,8 +7,9 @@
* write so the two entry points agree on the canonical shape: the same
* `getScoreForMood` / `moodDateKey` derivation, the same per-user `tz`
* anchoring, the same cache invalidation + DAY-rollup recompute the route
* fires. `source` is pinned to "TELEGRAM"
* (already an accepted free-text source value, no enum migration needed).
* fires. `source` is "TELEGRAM" by default; a notification quick answer
* (`/api/notifications/action/<token>`) writes the same shape under
* "NOTIFICATION". Both are free-text source values, no enum migration.
*
* The `userId` is NEVER taken from the Telegram payload — the caller
* passes the id it resolved from the stored `telegramChatId` binding.
@@ -25,6 +26,9 @@ import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { getEvent } from "@/lib/logging/context";
/** The quick-write sources that share this helper. */
export type QuickMoodSource = "TELEGRAM" | "NOTIFICATION";
export interface TelegramMoodResult {
/** True when a new row was written; false when the idempotency key already had one. */
created: boolean;
@@ -47,8 +51,11 @@ export async function logTelegramMood(input: {
/** Stable per-tap id for Telegram redelivery dedup. */
externalId: string;
note?: string | null;
/** Provenance of the quick write. Default "TELEGRAM". */
source?: QuickMoodSource;
client?: PrismaClient | Prisma.TransactionClient;
}): Promise<TelegramMoodResult> {
const source = input.source ?? "TELEGRAM";
const prisma = (input.client ?? defaultPrisma) as PrismaClient;
const mood = MOOD_ENUM_BY_SCORE[input.score];
if (!mood) {
@@ -63,7 +70,7 @@ export async function logTelegramMood(input: {
where: {
userId_source_externalId: {
userId: input.userId,
source: "TELEGRAM",
source,
externalId: input.externalId,
},
},
@@ -88,7 +95,7 @@ export async function logTelegramMood(input: {
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
note: null,
noteEncrypted: encryptNote(input.note ?? null),
source: "TELEGRAM",
source,
externalId: input.externalId,
moodLoggedAt,
},
+197
View File
@@ -0,0 +1,197 @@
/**
* Signed, short-lived, single-use tokens behind notification quick actions.
*
* A quick action is a button inside a notification ("Good", "Okay", "Bad"
* on the mood reminder) that the notification app calls directly, with no
* HealthLog session: ntfy's `http` action POSTs to a URL and nothing else.
* The URL therefore carries the whole credential, and this module is what
* makes that safe:
*
* - Bound. The token names the user and the one action it performs. A
* token minted for one user cannot act for another (the user id is inside
* the signed payload), and a token for "okay" cannot log "bad".
* - Signed. HMAC-SHA256 over the payload, under a subkey derived from
* `API_TOKEN_HMAC_KEY` with a fixed label, so the tag can never collide
* with an OAuth state or a Bearer-token hash made from the same root key.
* Compared with `timingSafeEqual`.
* - Short-lived. The expiry is inside the signed payload; a token is good
* for `ACTION_TOKEN_TTL_MS` after the reminder was sent.
* - Single-use. The three answers of one reminder share one nonce, and the
* write it authorises is keyed on that nonce (`MoodEntry`'s unique
* `(userId, source, externalId)`), so the first answer wins and every
* later tap on the same reminder, whichever button, is refused. The
* database constraint is the arbiter, so two concurrent taps cannot both
* write.
*
* Layout: `na1.<userId>.<action>.<expiryMs>.<nonce>.<tag>`. Every field is
* free of `.` by construction (a cuid, a fixed action id, digits, base64url),
* so the split is unambiguous, and every field is URL-path safe.
*/
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* The mood quick answers and the five-point mood scale value each one logs
* (`MOOD_ENUM_BY_SCORE`: 4 = GUT, 3 = OKAY, 2 = SCHLECHT). Three answers,
* because ntfy shows at most three actions; the scale's outer points
* (1 and 5) stay in the app and in the Telegram keyboard, which shows all
* five.
*/
export const MOOD_QUICK_ACTIONS = {
"mood-good": 4,
"mood-okay": 3,
"mood-bad": 2,
} as const;
export type QuickActionId = keyof typeof MOOD_QUICK_ACTIONS;
export const QUICK_ACTION_IDS = Object.keys(
MOOD_QUICK_ACTIONS,
) as QuickActionId[];
/**
* Six hours: long enough to answer a reminder sent at the chosen evening
* hour before going to bed, short enough that a notification left on a
* phone overnight no longer writes anything the next day.
*/
export const ACTION_TOKEN_TTL_MS = 6 * 60 * 60 * 1000;
const VERSION = "na1";
const SEP = ".";
const MAX_TOKEN_LENGTH = 300;
const KEY_LABEL = "healthlog:notification-action:v1";
function signingKey(): Buffer {
const root = process.env.API_TOKEN_HMAC_KEY;
if (!root || root.length < 32) {
throw new Error(
"API_TOKEN_HMAC_KEY must be set (>=32 chars) to sign notification actions",
);
}
return createHmac("sha256", root).update(KEY_LABEL).digest();
}
function sign(payload: string): string {
return createHmac("sha256", signingKey()).update(payload).digest("base64url");
}
function isQuickActionId(value: string): value is QuickActionId {
return Object.prototype.hasOwnProperty.call(MOOD_QUICK_ACTIONS, value);
}
/** A fresh nonce shared by every action offered on one notification. */
export function newActionNonce(): string {
return randomBytes(16).toString("base64url");
}
export function mintActionToken(input: {
userId: string;
action: QuickActionId;
nonce: string;
now?: number;
}): string {
const expiry = (input.now ?? Date.now()) + ACTION_TOKEN_TTL_MS;
const payload = [
VERSION,
input.userId,
input.action,
String(expiry),
input.nonce,
].join(SEP);
return `${payload}${SEP}${sign(payload)}`;
}
export type ActionTokenVerdict =
| {
ok: true;
userId: string;
action: QuickActionId;
nonce: string;
expiry: number;
}
| { ok: false; reason: "malformed" | "bad_signature" | "expired" };
/**
* Verify a token. The signature is checked before the expiry, so an expired
* verdict is only ever given for a token this server actually minted; a
* forged or altered token reads as `bad_signature` whatever its expiry says.
* Never throws on bad input (only on a missing server key, which is an
* operator misconfiguration).
*/
export function verifyActionToken(
token: string | null | undefined,
now: number = Date.now(),
): ActionTokenVerdict {
if (!token || token.length > MAX_TOKEN_LENGTH) {
return { ok: false, reason: "malformed" };
}
const parts = token.split(SEP);
if (parts.length !== 6) return { ok: false, reason: "malformed" };
const [version, userId, action, expiryStr, nonce, tag] = parts as [
string,
string,
string,
string,
string,
string,
];
if (version !== VERSION || !userId || !nonce || !tag) {
return { ok: false, reason: "malformed" };
}
if (!isQuickActionId(action)) return { ok: false, reason: "malformed" };
const expiry = Number.parseInt(expiryStr, 10);
if (!Number.isFinite(expiry) || String(expiry) !== expiryStr) {
return { ok: false, reason: "malformed" };
}
const expected = Buffer.from(
sign([version, userId, action, expiryStr, nonce].join(SEP)),
);
const received = Buffer.from(tag);
if (
received.length !== expected.length ||
!timingSafeEqual(received, expected)
) {
return { ok: false, reason: "bad_signature" };
}
if (now >= expiry) return { ok: false, reason: "expired" };
return { ok: true, userId, action, nonce, expiry };
}
/** The `MoodEntry.externalId` that makes one reminder's answers single-use. */
export function quickActionExternalId(nonce: string): string {
return `notification-action:${nonce}`;
}
/** The public route a quick action calls. */
export const QUICK_ACTION_ROUTE_PREFIX = "/api/notifications/action/";
export interface NotificationQuickAction {
id: QuickActionId;
label: string;
/** Absolute URL the notification app POSTs to. */
url: string;
}
/**
* The three mood answers for one reminder, sharing one nonce. Needs the
* public origin: an app on a phone can only call an absolute URL.
*/
export function buildMoodQuickActions(input: {
userId: string;
origin: string;
labels: Record<QuickActionId, string>;
now?: number;
}): NotificationQuickAction[] {
const nonce = newActionNonce();
return QUICK_ACTION_IDS.map((id) => ({
id,
label: input.labels[id],
url: `${input.origin}${QUICK_ACTION_ROUTE_PREFIX}${mintActionToken({
userId: input.userId,
action: id,
nonce,
now: input.now,
})}`,
}));
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Attach the tap target and the quick actions to a delivery.
*
* Called by the dispatcher once per recipient, after the delivery identity
* is known and before the channel cascade, so every channel of one dispatch
* carries the same target and the same (single-use) action tokens. The
* senders only translate these fields into their own wire format; none of
* them decides where a notification leads.
*/
import { prisma } from "@/lib/db";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { getEvent } from "@/lib/logging/context";
import {
buildMoodQuickActions,
type QuickActionId,
} from "@/lib/notifications/action-token";
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
import {
absoluteAppUrl,
configuredAppOrigin,
resolveTapPath,
} from "@/lib/notifications/tap-target";
import type { NotificationPayload } from "@/lib/notifications/types";
const QUICK_ACTION_LABEL_KEYS: Record<QuickActionId, string> = {
"mood-good": "notifications.quickActions.moodGood",
"mood-okay": "notifications.quickActions.moodOkay",
"mood-bad": "notifications.quickActions.moodBad",
};
async function recipientLocale(userId: string): Promise<Locale> {
try {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
const value = row?.locale;
if (value && (locales as readonly string[]).includes(value)) {
return value as Locale;
}
} catch {
// A failed read only costs the label its language.
}
return defaultLocale;
}
/**
* Whether this delivery may offer the mood quick answers: a mood reminder
* delivered to the account it is about. A Guardian never gets a button that
* writes into a record they are only notified about.
*/
function offersMoodActions(payload: NotificationPayload): boolean {
return (
payload.eventType === "MOOD_REMINDER" &&
!payload.suppressActions &&
!payload.discreet &&
!isManagedGuardianDelivery(payload)
);
}
export async function withDeliveryLinks(
payload: NotificationPayload,
recipientUserId: string,
now: number = Date.now(),
): Promise<NotificationPayload> {
const path = resolveTapPath(payload);
const moodActions = offersMoodActions(payload);
if (!path && !moodActions) return payload;
const origin = configuredAppOrigin();
const { t } = getServerTranslator(await recipientLocale(recipientUserId));
const next: NotificationPayload = { ...payload };
if (path) {
next.tap = {
path,
url: absoluteAppUrl(path, origin),
label: t("notifications.links.open"),
};
}
if (moodActions && origin) {
try {
next.quickActions = buildMoodQuickActions({
userId: recipientUserId,
origin,
labels: {
"mood-good": t(QUICK_ACTION_LABEL_KEYS["mood-good"]),
"mood-okay": t(QUICK_ACTION_LABEL_KEYS["mood-okay"]),
"mood-bad": t(QUICK_ACTION_LABEL_KEYS["mood-bad"]),
},
now,
});
} catch (err) {
// No signing key: the reminder still goes out, only without answers.
getEvent()?.addWarning(
`notification quick actions unavailable: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
return next;
}
+5 -2
View File
@@ -36,6 +36,7 @@ import {
hasClientManagedApnsGate,
} from "@/lib/notifications/client-managed-apns";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { withDeliveryLinks } from "@/lib/notifications/delivery-links";
import {
claimManagedGuardianEgressAuthorization,
completeManagedGuardianEgressAuthorization,
@@ -131,8 +132,10 @@ export async function dispatchNotification(
recordUserId: delivery.recordUserId,
recipientUserId: delivery.recipientUserId,
};
const recipientPayload = await renderForRecipient(
deliveryPayload,
// Tap target + quick actions, resolved once so every channel of this
// delivery carries the same target and the same single-use tokens.
const recipientPayload = await withDeliveryLinks(
await renderForRecipient(deliveryPayload, delivery.recipientUserId),
delivery.recipientUserId,
);
const channels = await prisma.notificationChannel.findMany({
+9
View File
@@ -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 type { NotificationTap } from "@/lib/notifications/types";
export interface ApnsPayload {
/** APNs `aps.alert.title` + `aps.alert.body`. */
@@ -597,6 +598,7 @@ export async function sendViaApns(
suppressActions?: boolean;
recordUserId?: string;
recipientUserId?: string;
tap?: NotificationTap;
},
): Promise<SendOutcome> {
const config = loadApnsConfig();
@@ -751,6 +753,13 @@ export async function sendViaApns(
? stripCycleMetadata(payload.metadata)
: payload.metadata,
),
// 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 }
: {}),
},
threadId: routingEvent,
// The iOS app registers a `UNNotificationCategory` per event-type
+5 -1
View File
@@ -163,7 +163,11 @@ export async function sendViaEmail(
// Plain text only — strip HTML + decorative emoji on routine reminders.
const subject = plainPushText(payload.title, payload.eventType);
const text = plainPushText(stripHtml(payload.message), payload.eventType);
const body = plainPushText(stripHtml(payload.message), payload.eventType);
// The tap target as a plain link line (plain text body, no markup).
const text = payload.tap?.url
? `${body}\n\n${payload.tap.label}: ${payload.tap.url}`
: body;
try {
await transport.transporter.sendMail({
+59 -1
View File
@@ -58,7 +58,7 @@ export async function sendViaNtfy(
? "reminder"
: payload.eventType.toLowerCase().replace(/_/g, "-");
const headers: Record<string, string> = {
Title: plainPushText(payload.title, payload.eventType),
Title: ntfyHeaderValue(plainPushText(payload.title, payload.eventType)),
Priority: priority,
// Discreet mode (cycle privacy): the X-Tags header is visible on the
// lock screen, so collapse it to a generic tag instead of leaking the
@@ -71,6 +71,16 @@ export async function sendViaNtfy(
headers["Authorization"] = `Bearer ${config.authToken}`;
}
// Tapping the notification opens this URL (ntfy `Click`). Absent when
// the operator has not configured a public origin.
if (payload.tap?.url) {
headers["Click"] = payload.tap.url;
}
const actions = buildNtfyActionsHeader(payload);
if (actions) {
headers["Actions"] = ntfyHeaderValue(actions);
}
// Strip HTML tags for ntfy (plain text only) + emoji on routine reminders
const body = plainPushText(stripHtml(payload.message), payload.eventType);
@@ -187,3 +197,51 @@ export async function sendViaNtfy(
};
}
}
/** ntfy shows at most three actions per notification. */
const NTFY_MAX_ACTIONS = 3;
/**
* A label safe inside the `Actions` header's short format: `,` and `;`
* separate fields and actions there, and a quote would open a quoted value.
*/
function ntfyActionLabel(label: string): string {
return label.replace(/[,;"'\r\n]/g, " ").trim() || "?";
}
/**
* The `Actions` header for the payload's quick answers, in ntfy's short
* format (https://docs.ntfy.sh/publish/#action-buttons):
*
* http, <label>, <url>, method=POST, clear=true; …
*
* `clear=true` dismisses the notification once the request succeeded, so a
* reminder answered from the shade does not linger. The token in the URL is
* the request's only credential (see `action-token.ts`).
*/
export function buildNtfyActionsHeader(
payload: Pick<NotificationPayload, "quickActions">,
): string | null {
const actions = (payload.quickActions ?? []).slice(0, NTFY_MAX_ACTIONS);
if (actions.length === 0) return null;
return actions
.map(
(action) =>
`http, ${ntfyActionLabel(action.label)}, ${action.url}, method=POST, clear=true`,
)
.join("; ");
}
/**
* A header value fetch can send and ntfy reads as UTF-8.
*
* `fetch` refuses any header character above U+00FF outright (a Korean
* title or action label would fail the whole send), and a Latin-1 byte such
* as `ü` reaches ntfy as invalid UTF-8. ntfy decodes RFC 2047 encoded words
* in every header, so a value with anything beyond printable ASCII travels as
* one `=?UTF-8?B?…?=` word; plain ASCII stays readable on the wire.
*/
export function ntfyHeaderValue(value: string): string {
if (/^[\x20-\x7e]*$/.test(value)) return value;
return `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`;
}
+32 -2
View File
@@ -15,6 +15,18 @@ import {
buildMeasurementKeyboard,
} from "@/lib/notifications/senders/telegram-keyboards";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { telegramLinkMode } from "@/lib/notifications/tap-target";
function escapeHtmlText(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeHtmlAttribute(value: string): string {
return escapeHtmlText(value).replace(/"/g, "&quot;");
}
/**
* v1.19.0 — resolve the bot locale for an interactive reminder. The
@@ -197,13 +209,31 @@ export async function sendViaTelegram(
}
: undefined);
// Tap target. Telegram has no "tap opens a URL" for a message, so the
// target rides an inline URL button under it (below any action row), or a
// text link where Telegram would refuse the button (see `telegramLinkMode`).
const linkMode = payload.tap?.url ? telegramLinkMode(payload.tap.url) : "none";
const linkedKeyboard =
linkMode === "button" && payload.tap?.url
? {
inline_keyboard: [
...(keyboard?.inline_keyboard ?? []),
[{ text: payload.tap.label, url: payload.tap.url }],
],
}
: keyboard;
const text =
linkMode === "text" && payload.tap?.url
? `${payload.message}\n\n<a href="${escapeHtmlAttribute(payload.tap.url)}">${escapeHtmlText(payload.tap.label)}</a>`
: payload.message;
const result = await sendTelegramMessage(
config.botToken,
config.chatId,
payload.message,
text,
{
parseMode: "HTML",
replyMarkup: keyboard,
replyMarkup: linkedKeyboard,
},
);
+7 -6
View File
@@ -11,6 +11,7 @@ import { safeFetch } from "@/lib/safe-fetch";
import { plainPushText } from "@/lib/notifications/strip-emoji";
import { stripHtml } from "@/lib/notifications/strip-html";
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
import { isSafeAppPath } from "@/lib/notifications/tap-target";
/**
* Send Web Push notification to all subscribed devices of a user.
@@ -115,12 +116,12 @@ export async function sendViaWebPush(
// `/coach`); everything else keeps the legacy "/" click
// target. Only relative paths are honoured so a poisoned metadata
// value can never point the SW at a foreign origin.
url:
!managedDelivery &&
typeof payload.metadata?.url === "string" &&
payload.metadata.url.startsWith("/")
? payload.metadata.url
: "/",
// The dispatcher's resolved tap target (`tap-target.ts`) wins; a payload
// that did not pass through the dispatcher keeps the metadata path.
url: managedDelivery
? "/"
: (payload.tap?.path ??
(isSafeAppPath(payload.metadata?.url) ? payload.metadata.url : "/")),
// v1.18.4 — urgent events ask the service worker to keep the
// notification on screen until the user acts on it (where the
// browser honours `requireInteraction`).
+9
View File
@@ -69,6 +69,9 @@ export function buildWebhookBody(
const message = plainPushText(stripHtml(payload.message), payload.eventType);
const eventType = payload.discreet ? "reminder" : payload.eventType;
const band = priorityBand(payload);
// The tap target as an absolute URL, when the operator configured a public
// origin (`tap-target.ts`).
const url = payload.tap?.url ?? null;
if (config.format === "gotify") {
return JSON.stringify({
@@ -80,6 +83,9 @@ export function buildWebhookBody(
// body is never markdown (hard rule).
"client::display": { contentType: "text/plain" },
"healthlog::event": { type: eventType },
// Gotify's Android client opens this URL when the notification is
// tapped.
...(url ? { "client::notification": { click: { url } } } : {}),
},
});
}
@@ -102,6 +108,9 @@ export function buildWebhookBody(
content: truncateCodePoints(text, DISCORD_CONTENT_LIMIT),
text,
allowed_mentions: { parse: [] },
// Additive: where the notification leads in the app, for a relay rule
// that wants to attach a link. Absent without a public origin.
...(url ? { url } : {}),
});
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Where a notification takes the user when they tap it.
*
* One map, one resolver. Every event type names the in-app path it opens;
* the dispatcher resolves the path once per delivery and every channel
* carries the same target in its own vocabulary:
*
* - ntfy → `Click` header (absolute URL)
* - Telegram → an inline URL button (absolute `https` URL only, see
* `telegramLinkMode`)
* - Web Push → `url` in the service-worker payload (same-origin path)
* - APNs → `url` in the custom payload (same-origin path)
* - Webhook → `url` in the body (absolute URL)
* - Email → a link line under the body (absolute URL)
*
* The map is typed `Record<EventType, …>`, so a new event type without a
* target does not compile, and `tap-target.test.ts` walks `EVENT_TYPES` so a
* target that stops resolving fails there too.
*
* A producer may name a more precise path in `metadata.url` (the low-stock
* alert opens the medication's stock tab, the Coach nudge opens its own
* conversation). That override is accepted only as a same-origin path, so a
* value that names another host can never become a click target.
*/
import type { EventType, NotificationPayload } from "@/lib/notifications/types";
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
/**
* The default in-app path per event type. `null` means the event is never
* shown to the user (a silent background push), so it carries no target.
*/
export const NOTIFICATION_TAP_PATHS: Record<EventType, string | null> = {
MEDICATION_REMINDER: "/medications",
MEASUREMENT_ANOMALY: "/measurements",
COMPLIANCE_LOW: "/insights/medications",
WITHINGS_SYNC_FAILED: "/settings/integrations",
SYSTEM_ALERT: "/",
PERSONAL_RECORD: "/achievements",
// `?add=1` opens the mood entry sheet on the first paint.
MOOD_REMINDER: "/mood?add=1",
CYCLE_PERIOD_SOON: "/cycle",
CYCLE_PERIOD_CONFIRM: "/cycle",
CYCLE_FERTILE_SOON: "/cycle",
COACH_NUDGE: "/coach",
MEDICATION_LOW_STOCK: "/medications",
MEASUREMENT_REMINDER: "/checkups",
MEDICATION_INTAKE_SYNC: null,
SECURITY_ALERT: "/settings/security",
DAILY_BRIEFING: "/",
SHARED_RECORD_INTAKE: "/medications",
};
const MAX_PATH_LENGTH = 512;
/**
* A same-origin, absolute-path reference: one leading slash, no scheme, no
* protocol-relative `//host`, no backslash (browsers normalise `/\host` to
* `//host`), no whitespace or control characters.
*/
export function isSafeAppPath(value: unknown): value is string {
if (typeof value !== "string") return false;
if (value.length === 0 || value.length > MAX_PATH_LENGTH) return false;
if (!value.startsWith("/")) return false;
if (value.startsWith("//")) return false;
if (value.includes("\\")) return false;
// eslint-disable-next-line no-control-regex
if (/[\s\u0000-\u001f\u007f]/.test(value)) return false;
return true;
}
/**
* 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.
*/
export function resolveTapPath(
payload: Pick<
NotificationPayload,
"eventType" | "metadata" | "discreet" | "recordUserId" | "recipientUserId"
>,
): 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;
const override = payload.metadata?.url;
return isSafeAppPath(override) ? override : fallback;
}
/**
* The operator-configured public origin (`APP_URL`, then
* `NEXT_PUBLIC_APP_URL`), the same precedence the invite and handover links
* use. Env only: a notification is sent from a worker with no request, and
* the `Host` header is never trusted for a link that leaves the server.
*/
export function configuredAppOrigin(): string | null {
for (const value of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) {
const trimmed = value?.trim();
if (!trimmed) continue;
try {
const url = new URL(trimmed);
if (url.protocol !== "https:" && url.protocol !== "http:") continue;
return url.origin;
} catch {
// try the next candidate
}
}
return null;
}
/** Absolute URL for a path, or `null` when no public origin is configured. */
export function absoluteAppUrl(
path: string | null,
origin: string | null = configuredAppOrigin(),
): string | null {
if (!path || !origin) return null;
return `${origin}${path}`;
}
/**
* How Telegram may carry a link.
*
* Telegram refuses an inline URL button whose URL is not `https` or whose
* host is `localhost` (the whole `sendMessage` fails with "BUTTON_URL_INVALID"),
* so the button is offered only for a public `https` origin. A plain-HTTP
* self-host still gets a text link under the message; a loopback origin gets
* nothing, because no phone can open it.
*/
export function telegramLinkMode(url: string): "button" | "text" | "none" {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return "none";
}
const host = parsed.hostname.toLowerCase();
const loopback =
host === "localhost" ||
host.endsWith(".localhost") ||
host === "[::1]" ||
host === "::1" ||
/^127\./.test(host) ||
host === "0.0.0.0";
if (loopback) return "none";
if (parsed.protocol === "https:") return "button";
if (parsed.protocol === "http:") return "text";
return "none";
}
+24
View File
@@ -1,4 +1,5 @@
import type { Locale } from "@/lib/i18n/config";
import type { NotificationQuickAction } from "@/lib/notifications/action-token";
/**
* Notification system type definitions.
@@ -355,6 +356,29 @@ export interface NotificationPayload {
* removes is the OFFER to act on it.
*/
suppressActions?: boolean;
/**
* Where tapping the notification leads, resolved once per delivery by the
* dispatcher from `NOTIFICATION_TAP_PATHS` (`tap-target.ts`). Producers
* never set it; a producer that knows a more precise path says so in
* `metadata.url`. `path` is a same-origin path (Web Push, APNs), `url` the
* same target as an absolute URL on the operator's public origin (ntfy,
* Telegram, webhook, email), `null` when no public origin is configured.
* `label` is the localised text of a link button.
*/
tap?: NotificationTap;
/**
* One-tap answers the channel can offer inside the notification (ntfy
* `http` actions). Minted by the dispatcher for a self-delivered mood
* reminder; each URL carries a signed single-use token
* (`action-token.ts`).
*/
quickActions?: NotificationQuickAction[];
}
export interface NotificationTap {
path: string | null;
url: string | null;
label: string;
}
/**
+63
View File
@@ -16,6 +16,10 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { CHANNEL_TYPES, EVENT_TYPES } from "@/lib/notifications/types";
import {
QUICK_ACTION_IDS,
type QuickActionId,
} from "@/lib/notifications/action-token";
import {
notificationPreferenceSchema,
reEnableChannelSchema,
@@ -148,8 +152,67 @@ const reEnableChannelRequest = reEnableChannelSchema.meta({
"Clear the auto-disable on one of the caller's own channels. A channel id the caller does not own answers 404.",
});
const notificationActionResult = z
.object({
logged: z.literal(true),
action: z
.enum(QUICK_ACTION_IDS as [QuickActionId, ...QuickActionId[]])
.describe(
"The answer that was logged: mood-good = 4 (GUT), mood-okay = 3 (OKAY), mood-bad = 2 (SCHLECHT) on the five-point mood scale.",
),
})
.meta({
id: "NotificationActionResult",
description: "A quick answer from a notification, logged as a mood entry.",
});
export const notificationChannelPaths: NonNullable<ZodOpenApiObject["paths"]> =
{
"/api/notifications/action/{token}": {
post: {
tags: ["Notifications"],
summary: "Log a quick answer tapped inside a notification",
description:
"Called by a notification app (ntfy `http` action), never by a signed-in client: the signed path token is the only credential. The token is bound to one user and one answer, expires six hours after the reminder was sent, and is single-use across all answers offered on that reminder. Rate-limited per client address before the token is read. Writes a mood entry with source `NOTIFICATION`; refused with 403 when the mood module is off.",
requestParams: {
path: z.object({
token: z
.string()
.describe("Signed action token from the notification's URL."),
}),
},
responses: {
"200": {
description: "The answer was logged.",
content: {
"application/json": {
schema: dataEnvelope(
notificationActionResult,
"NotificationActionResult",
),
},
},
},
...stdResponses,
"401": {
description: "Invalid token (forged, altered, or unknown user).",
content: { "application/json": { schema: errorEnvelope } },
},
"403": {
description: "The mood module is turned off for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "This reminder was already answered.",
content: { "application/json": { schema: errorEnvelope } },
},
"410": {
description: "The answer expired.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/notifications/preferences": {
get: {
tags: ["Notifications"],
+4 -4
View File
@@ -11,10 +11,10 @@ interface TelegramResponse {
description?: string;
}
interface TelegramInlineKeyboardButton {
text: string;
callback_data: string;
}
/** A callback button, or a button that opens a URL (`https` only). */
type TelegramInlineKeyboardButton =
| { text: string; callback_data: string }
| { text: string; url: string };
interface TelegramReplyMarkup {
inline_keyboard: TelegramInlineKeyboardButton[][];
+2
View File
@@ -46,6 +46,8 @@ export const moodSourceEnum = z.enum([
"WEB",
"TELEGRAM",
"DAYLIO",
// A quick answer tapped inside a mood-reminder notification (ntfy action).
"NOTIFICATION",
]);
const MOOD_SCORE_MAP: Record<string, number> = {
+5
View File
@@ -41,6 +41,11 @@ export const PUBLIC_PATHS = [
"/api/health",
"/api/version",
"/api/notifications/vapid",
// v1.42.2 — notification quick answers. A notification app (ntfy's `http`
// action) calls this with no session; the signed, single-use, short-lived
// token in the path is the whole credential, verified by the route after
// its rate limit. Trailing slash: admits only `/action/<token>`.
"/api/notifications/action/",
"/api/monitoring/",
"/api/withings/webhook",
// v1.11.0 — WHOOP webhook (`recovery.updated` / `sleep.updated` /