From 1c0a5d77776b5f1e14ac5ca7d22cd19ffaaef595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 05:17:56 +0200 Subject: [PATCH 01/13] test: cover guardian notification eligibility --- .../guardian-notification-policy.test.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/__tests__/guardian-notification-policy.test.ts diff --git a/src/__tests__/guardian-notification-policy.test.ts b/src/__tests__/guardian-notification-policy.test.ts new file mode 100644 index 000000000..23fae3050 --- /dev/null +++ b/src/__tests__/guardian-notification-policy.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { findUnique, findMany } = vi.hoisted(() => ({ + findUnique: vi.fn(), + findMany: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique }, + accountGrant: { findMany }, + }, +})); + +import { + isManagedGuardianFanoutAllowed, + resolveManagedGuardianRecipientIds, +} from "@/lib/notifications/delivery-identity"; + +const recordUserId = "managed-record"; + +function payload(over: Record = {}) { + return { + eventType: "MEDICATION_REMINDER" as const, + userId: recordUserId, + title: "Record content", + message: "Record schedule", + ...over, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + findUnique.mockResolvedValue({ managedProfileAt: new Date() }); + findMany.mockResolvedValue([{ granteeId: "guardian-one" }]); +}); + +describe("guardian notification policy — allowlist and eligibility", () => { + it("allows exactly medication, measurement, low-stock, and explicitly tagged safety-floor alerts", () => { + expect(isManagedGuardianFanoutAllowed(payload())).toBe(true); + expect( + isManagedGuardianFanoutAllowed( + payload({ eventType: "MEASUREMENT_REMINDER" }), + ), + ).toBe(true); + expect( + isManagedGuardianFanoutAllowed( + payload({ eventType: "MEDICATION_LOW_STOCK" }), + ), + ).toBe(true); + expect( + isManagedGuardianFanoutAllowed( + payload({ + eventType: "SYSTEM_ALERT", + managedFanoutEvent: "SAFETY_FLOOR_ALERT", + }), + ), + ).toBe(true); + + expect( + isManagedGuardianFanoutAllowed(payload({ eventType: "SYSTEM_ALERT" })), + ).toBe(false); + expect( + isManagedGuardianFanoutAllowed( + payload({ eventType: "MEASUREMENT_ANOMALY" }), + ), + ).toBe(false); + expect( + isManagedGuardianFanoutAllowed( + payload({ + eventType: "SYSTEM_ALERT", + managedFanoutEvent: "ILLNESS_RED_FLAG", + }), + ), + ).toBe(false); + }); + + it("enumerates only active Guardians of the marked record", async () => { + await expect(resolveManagedGuardianRecipientIds(payload())).resolves.toEqual( + ["guardian-one"], + ); + + expect(findMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ grantorId: recordUserId }), + select: { granteeId: true }, + }); + }); + + it("does not fan out unmarked records, disallowed events, self delivery, or invalid Guardians", async () => { + findUnique.mockResolvedValueOnce({ managedProfileAt: null }); + await expect(resolveManagedGuardianRecipientIds(payload())).resolves.toBeNull(); + + await expect( + resolveManagedGuardianRecipientIds( + payload({ eventType: "MEASUREMENT_ANOMALY" }), + ), + ).resolves.toBeNull(); + + findMany.mockResolvedValueOnce([ + { granteeId: recordUserId }, + { granteeId: "managed-guardian", managedProfileAt: new Date() }, + { granteeId: "guardian-two" }, + ]); + await expect(resolveManagedGuardianRecipientIds(payload())).resolves.toEqual([ + "guardian-two", + ]); + }); +}); From a78189b7f6edad4418371d98cf9fdaf003b23f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 05:23:38 +0200 Subject: [PATCH 02/13] feat: add guardian notification fanout --- .../guardian-notification-policy.test.ts | 34 +++++---- .../__tests__/safety-floor-notify.test.ts | 15 +++- src/lib/illness/red-flag-notify.ts | 5 +- src/lib/illness/safety-floor-notify.ts | 9 +-- .../__tests__/notification-retention.test.ts | 55 +++++++++++++++ src/lib/jobs/reminder/cleanup-handlers.ts | 22 ++++-- src/lib/notifications/delivery-identity.ts | 70 ++++++++++++++++++- src/lib/notifications/dispatch-localised.ts | 5 +- src/lib/notifications/dispatcher.ts | 29 +++++++- src/lib/notifications/types.ts | 20 ++++++ 10 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 src/lib/jobs/reminder/__tests__/notification-retention.test.ts diff --git a/src/__tests__/guardian-notification-policy.test.ts b/src/__tests__/guardian-notification-policy.test.ts index 23fae3050..086ad5de0 100644 --- a/src/__tests__/guardian-notification-policy.test.ts +++ b/src/__tests__/guardian-notification-policy.test.ts @@ -32,7 +32,9 @@ function payload(over: Record = {}) { beforeEach(() => { vi.resetAllMocks(); findUnique.mockResolvedValue({ managedProfileAt: new Date() }); - findMany.mockResolvedValue([{ granteeId: "guardian-one" }]); + findMany.mockResolvedValue([ + { granteeId: "guardian-one", grantee: { managedProfileAt: null } }, + ]); }); describe("guardian notification policy — allowlist and eligibility", () => { @@ -76,19 +78,24 @@ describe("guardian notification policy — allowlist and eligibility", () => { }); it("enumerates only active Guardians of the marked record", async () => { - await expect(resolveManagedGuardianRecipientIds(payload())).resolves.toEqual( - ["guardian-one"], - ); + await expect( + resolveManagedGuardianRecipientIds(payload()), + ).resolves.toEqual(["guardian-one"]); expect(findMany).toHaveBeenCalledWith({ where: expect.objectContaining({ grantorId: recordUserId }), - select: { granteeId: true }, + select: { + granteeId: true, + grantee: { select: { managedProfileAt: true } }, + }, }); }); it("does not fan out unmarked records, disallowed events, self delivery, or invalid Guardians", async () => { findUnique.mockResolvedValueOnce({ managedProfileAt: null }); - await expect(resolveManagedGuardianRecipientIds(payload())).resolves.toBeNull(); + await expect( + resolveManagedGuardianRecipientIds(payload()), + ).resolves.toBeNull(); await expect( resolveManagedGuardianRecipientIds( @@ -97,12 +104,15 @@ describe("guardian notification policy — allowlist and eligibility", () => { ).resolves.toBeNull(); findMany.mockResolvedValueOnce([ - { granteeId: recordUserId }, - { granteeId: "managed-guardian", managedProfileAt: new Date() }, - { granteeId: "guardian-two" }, - ]); - await expect(resolveManagedGuardianRecipientIds(payload())).resolves.toEqual([ - "guardian-two", + { granteeId: recordUserId, grantee: { managedProfileAt: null } }, + { + granteeId: "managed-guardian", + grantee: { managedProfileAt: new Date() }, + }, + { granteeId: "guardian-two", grantee: { managedProfileAt: null } }, ]); + await expect( + resolveManagedGuardianRecipientIds(payload()), + ).resolves.toEqual(["guardian-two"]); }); }); diff --git a/src/lib/illness/__tests__/safety-floor-notify.test.ts b/src/lib/illness/__tests__/safety-floor-notify.test.ts index 0eef5e6a3..a126a6c3c 100644 --- a/src/lib/illness/__tests__/safety-floor-notify.test.ts +++ b/src/lib/illness/__tests__/safety-floor-notify.test.ts @@ -124,15 +124,24 @@ describe("notifySafetyFloor", () => { ); }); - it("does not dispatch or write an anchor for a managed profile", async () => { + it("claims then admits an anchored Guardian fan-out for a managed profile", async () => { userFindUniqueMock.mockResolvedValueOnce({ managedProfileAt: new Date("2026-08-06T00:00:00.000Z"), }); await notifySafetyFloor({ userId: "managed-record", decision: bpHigh }); - expect(claimMock).not.toHaveBeenCalled(); - expect(dispatchMock).not.toHaveBeenCalled(); + expect(claimMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ recordUserId: "managed-record" }), + ); + expect(dispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "managed-record", + eventType: "SYSTEM_ALERT", + managedFanoutEvent: "SAFETY_FLOOR_ALERT", + }), + ); }); it("never throws when dispatch fails", async () => { diff --git a/src/lib/illness/red-flag-notify.ts b/src/lib/illness/red-flag-notify.ts index 2ac3c23a6..19ff68069 100644 --- a/src/lib/illness/red-flag-notify.ts +++ b/src/lib/illness/red-flag-notify.ts @@ -45,8 +45,9 @@ export async function notifyIllnessRedFlag(input: { if (redFlags.length === 0) return; try { - // Plan 11 adds approved guardian fanout for this safety signal. Until - // then, managed profiles must not produce a direct delivery or anchor. + // Red-flag alerts are deliberately outside the closed Guardian fan-out + // allowlist. Managed records therefore retain the existing no-delivery, + // no-anchor behaviour for this distinct SYSTEM_ALERT producer. const user = await prisma.user.findUnique({ where: { id: userId }, select: { managedProfileAt: true }, diff --git a/src/lib/illness/safety-floor-notify.ts b/src/lib/illness/safety-floor-notify.ts index 9065b8fa9..532d1bafd 100644 --- a/src/lib/illness/safety-floor-notify.ts +++ b/src/lib/illness/safety-floor-notify.ts @@ -114,14 +114,6 @@ export async function notifySafetyFloor(input: { if (!decision) return; try { - // Plan 11 adds approved guardian fanout for this safety signal. Until - // then, managed profiles must not produce a direct delivery or anchor. - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { managedProfileAt: true }, - }); - if (user?.managedProfileAt) return; - const reason = `${LEDGER_REASON_PREFIX}${decision.reason}`; const since = new Date(Date.now() - DEDUPE_WINDOW_MS); const claimed = await claimNotificationEvent(prisma, { @@ -139,6 +131,7 @@ export async function notifySafetyFloor(input: { messageKey, params: paramsFor(decision, input.glucoseUnit ?? "mg/dL"), eventType: "SYSTEM_ALERT", + managedFanoutEvent: "SAFETY_FLOOR_ALERT", urgent: true, }); diff --git a/src/lib/jobs/reminder/__tests__/notification-retention.test.ts b/src/lib/jobs/reminder/__tests__/notification-retention.test.ts new file mode 100644 index 000000000..db4ba1443 --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/notification-retention.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + pushDeleteMany: vi.fn(), + eventDeleteMany: vi.fn(), + meta: {} as Record, +})); + +vi.mock("../shared", async (importOriginal) => ({ + ...(await importOriginal>()), + getWorkerPrisma: () => ({ + pushAttempt: { deleteMany: h.pushDeleteMany }, + notificationEvent: { deleteMany: h.eventDeleteMany }, + }), +})); + +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: async ( + _name: string, + fn: (evt: { + addMeta: (key: string, value: unknown) => void; + addWarning: (warning: string) => void; + }) => Promise, + ) => + fn({ + addMeta: (key, value) => { + h.meta[key] = value; + }, + addWarning: vi.fn(), + }), +})); + +import { handlePushAttemptCleanup } from "../cleanup-handlers"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(h.meta)) delete h.meta[key]; + h.pushDeleteMany.mockResolvedValue({ count: 3 }); + h.eventDeleteMany.mockResolvedValue({ count: 5 }); +}); + +describe("notification retention", () => { + it("prunes record event anchors with the canonical bounded attempt retention", async () => { + await handlePushAttemptCleanup([]); + + expect(h.pushDeleteMany).toHaveBeenCalledWith({ + where: { createdAt: { lt: expect.any(Date) } }, + }); + expect(h.eventDeleteMany).toHaveBeenCalledWith({ + where: { createdAt: { lt: expect.any(Date) } }, + }); + expect(h.meta.push_attempt_cleanup_deleted).toBe(3); + expect(h.meta.notification_event_cleanup_deleted).toBe(5); + }); +}); diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 145ac3e30..5216507e2 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -41,8 +41,9 @@ import { import { getWorkerPrisma } from "./shared"; const MOOD_REMINDER_RETENTION_DAYS = 90; -// v1.4.49 — daily prune for the push-attempt ledger. Same 90-day -// retention as the mood-reminder dispatch ledger; both surfaces are +// v1.4.49 — daily prune for the notification delivery ledger. The attempt +// diagnostics and record-only event anchors share the same 90-day horizon; +// both surfaces are // behavioural footprints we keep long enough to debug a duplicate-push // report (~one billing cycle) but no longer. Slots at 03:35 between // mood-reminder cleanup (03:25) and drain-cumulative (03:45) so the @@ -116,13 +117,15 @@ export async function handleMoodReminderCleanup( } /** - * v1.4.49 — daily prune for the per-attempt push-delivery ledger. + * Daily prune for the bounded notification-delivery ledger. * * Every sender (APNS, WEB_PUSH, TELEGRAM, NTFY) writes one * fire-and-forget row to `push_attempts` per dispatch. The admin * diagnostic endpoint only ever reads the trailing 20 rows per user, * so anything older than the 90-day retention window is dead weight - * inflating the table and the `(user_id, created_at DESC)` index. + * inflating the table and the `(user_id, created_at DESC)` index. Record-only + * notification events are claims rather than delivery diagnostics, but after + * the same horizon their dedup windows have elapsed and they have no reader. * * The DELETE is unbounded by user — the index covers `created_at` * directly, so a `WHERE created_at < cutoff` scan is bounded by the @@ -144,11 +147,16 @@ export async function handlePushAttemptCleanup( try { const cutoff = new Date(); cutoff.setUTCDate(cutoff.getUTCDate() - PUSH_ATTEMPT_RETENTION_DAYS); - const deleted = await p.pushAttempt.deleteMany({ + const deletedAttempts = await p.pushAttempt.deleteMany({ where: { createdAt: { lt: cutoff } }, }); - evt.addMeta("push_attempt_cleanup_deleted", deleted.count); - return jobDone({ deleted: deleted.count }); + const deletedEvents = await p.notificationEvent.deleteMany({ + where: { createdAt: { lt: cutoff } }, + }); + const deleted = deletedAttempts.count + deletedEvents.count; + evt.addMeta("push_attempt_cleanup_deleted", deletedAttempts.count); + evt.addMeta("notification_event_cleanup_deleted", deletedEvents.count); + return jobDone({ deleted }); } catch (err) { evt.addWarning(`push-attempt-cleanup failed: ${err}`); return jobFailed("push-attempt cleanup failed", err); diff --git a/src/lib/notifications/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts index 3283dddb1..cd82a8aac 100644 --- a/src/lib/notifications/delivery-identity.ts +++ b/src/lib/notifications/delivery-identity.ts @@ -3,7 +3,10 @@ import { activeGuardianWhere, withManagedProfileLock, } from "@/lib/managed-profiles/lifecycle"; -import type { NotificationPayload } from "@/lib/notifications/types"; +import type { + ManagedGuardianFanoutEvent, + NotificationPayload, +} from "@/lib/notifications/types"; export interface NotificationDeliveryIdentity { recordUserId: string; @@ -11,6 +14,69 @@ export interface NotificationDeliveryIdentity { managed: boolean; } +/** + * The policy is deliberately closed. `SYSTEM_ALERT` by itself is never a + * Guardian event: the safety-floor producer must name its narrow admission + * marker, while illness red flags continue to remain record-local. + */ +export function isManagedGuardianFanoutAllowed( + payload: Pick, +): boolean { + if (payload.managedFanoutEvent === "SAFETY_FLOOR_ALERT") { + return payload.eventType === "SYSTEM_ALERT"; + } + + return ( + payload.managedFanoutEvent === undefined && + (payload.eventType === "MEDICATION_REMINDER" || + payload.eventType === "MEASUREMENT_REMINDER" || + payload.eventType === "MEDICATION_LOW_STOCK") + ); +} + +/** + * Enumerate candidate Guardians only for a legacy record-addressed delivery. + * Each candidate is still re-authorized under the managed-profile advisory + * lock immediately before its own channel selection and provider egress. + */ +export async function resolveManagedGuardianRecipientIds( + payload: NotificationPayload, +): Promise { + if ( + payload.recordUserId !== undefined || + payload.recipientUserId !== undefined || + !isManagedGuardianFanoutAllowed(payload) + ) { + return null; + } + + const record = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { managedProfileAt: true }, + }); + if (record?.managedProfileAt == null) return null; + + const grants = await prisma.accountGrant.findMany({ + where: { + grantorId: payload.userId, + ...activeGuardianWhere(new Date()), + grantee: { managedProfileAt: null }, + }, + select: { + granteeId: true, + grantee: { select: { managedProfileAt: true } }, + }, + }); + + return grants + .filter( + (grant) => + grant.granteeId !== payload.userId && + grant.grantee.managedProfileAt == null, + ) + .map((grant) => grant.granteeId); +} + /** * Resolve notification identities without treating a managed record as its * own destination. Legacy payloads remain valid only for ordinary self @@ -50,6 +116,8 @@ export async function resolveNotificationDeliveryIdentity( if (recipientUserId === recordUserId) return null; + if (!isManagedGuardianFanoutAllowed(payload)) return null; + return prisma.$transaction(async (tx) => withManagedProfileLock(tx, recordUserId, async (lockedRecord) => { if (lockedRecord?.managedProfileAt == null) return null; diff --git a/src/lib/notifications/dispatch-localised.ts b/src/lib/notifications/dispatch-localised.ts index 2311286b3..d24b9828b 100644 --- a/src/lib/notifications/dispatch-localised.ts +++ b/src/lib/notifications/dispatch-localised.ts @@ -41,7 +41,7 @@ import { getEvent } from "@/lib/logging/context"; import { defaultLocale, type Locale } from "@/lib/i18n/config"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; -import type { EventType } from "@/lib/notifications/types"; +import type { EventType, NotificationPayload } from "@/lib/notifications/types"; interface DispatchLocalisedOptions { userId: string; @@ -61,6 +61,8 @@ interface DispatchLocalisedOptions { eventType?: EventType; /** Channel-specific extras forwarded to the dispatcher metadata. */ metadata?: Record; + /** Closed internal marker for an admitted managed-record event. */ + managedFanoutEvent?: NotificationPayload["managedFanoutEvent"]; /** * Escalate to every channel's highest urgency (v1.18.4). Forwarded to * the dispatcher so APNs goes time-sensitive, ntfy max, Web Push @@ -182,6 +184,7 @@ export async function dispatchLocalisedNotification( title, message, metadata, + managedFanoutEvent: opts.managedFanoutEvent, urgent: opts.urgent, }); } diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 77fed2b7d..a718acb4c 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -29,7 +29,10 @@ import { hasClientManagedApnsGate, } from "@/lib/notifications/client-managed-apns"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; -import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity"; +import { + resolveManagedGuardianRecipientIds, + resolveNotificationDeliveryIdentity, +} from "@/lib/notifications/delivery-identity"; /** * Dispatch a notification to all enabled channels for a user. @@ -64,6 +67,30 @@ export async function dispatchNotification( let channelsSucceeded = 0; try { + const guardianRecipientIds = + await resolveManagedGuardianRecipientIds(payload); + if (guardianRecipientIds !== null) { + const outcomes = await Promise.all( + guardianRecipientIds.map((recipientUserId) => + dispatchNotification({ + ...payload, + recordUserId: payload.userId, + recipientUserId, + }), + ), + ); + return outcomes.reduce( + (aggregate, outcome) => ({ + dispatched: aggregate.dispatched || outcome.dispatched, + channelsAttempted: + aggregate.channelsAttempted + outcome.channelsAttempted, + channelsSucceeded: + aggregate.channelsSucceeded + outcome.channelsSucceeded, + }), + { dispatched: false, channelsAttempted: 0, channelsSucceeded: 0 }, + ); + } + const delivery = await resolveNotificationDeliveryIdentity(payload); if (!delivery) { getEvent()?.addWarning("Notification delivery identity was invalid"); diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 7b7d7bbd8..419400ebf 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -125,6 +125,20 @@ export const EVENT_TYPES = [ ] as const; export type EventType = (typeof EVENT_TYPES)[number]; +/** + * The closed set of managed-record events that may reach Guardians. A + * `SYSTEM_ALERT` needs the explicit safety-floor discriminator because + * illness red flags use the same broad delivery event but do not fan out. + */ +export const MANAGED_GUARDIAN_FANOUT_EVENTS = [ + "MEDICATION_REMINDER", + "MEASUREMENT_REMINDER", + "MEDICATION_LOW_STOCK", + "SAFETY_FLOOR_ALERT", +] as const; +export type ManagedGuardianFanoutEvent = + (typeof MANAGED_GUARDIAN_FANOUT_EVENTS)[number]; + /** * Per-event default policy when no `NotificationPreference` row * exists. The dispatcher reads this to decide whether to fire on a @@ -265,6 +279,12 @@ export interface NotificationPayload { message: string; /** Channel-specific extras (e.g. medicationId for Telegram inline buttons) */ metadata?: Record; + /** + * Internal admission marker for managed-record delivery. It is deliberately + * absent from public request contracts: only the four allowlisted producer + * paths may set it, and safety-floor is the only SYSTEM_ALERT admission. + */ + managedFanoutEvent?: ManagedGuardianFanoutEvent; /** * Discreet mode (cycle privacy). When true, the lock-screen-visible * routing metadata the senders would otherwise derive from `eventType` From be4ee9ac6dd275aa5cbe00d3b13c846d7ae0bc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 05:30:49 +0200 Subject: [PATCH 03/13] test: cover guardian notification preferences --- .../notification-delivery-identity.test.ts | 160 +++++++++++++- .../guardian-notification-fanout.test.ts | 202 ++++++++++++++++++ 2 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 tests/integration/guardian-notification-fanout.test.ts diff --git a/src/__tests__/notification-delivery-identity.test.ts b/src/__tests__/notification-delivery-identity.test.ts index 7cd591d7d..f29c3f9b2 100644 --- a/src/__tests__/notification-delivery-identity.test.ts +++ b/src/__tests__/notification-delivery-identity.test.ts @@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({ upsert: vi.fn(), }, user: { findUnique: vi.fn() }, + accountGrant: { findMany: vi.fn() }, auditLog: { create: vi.fn() }, $transaction: (...args: unknown[]) => transactionMock(...args), }, @@ -32,15 +33,17 @@ vi.mock("@/lib/logging/context", () => ({ })); const sendViaWebPushMock = vi.fn(); +const sendViaNtfyMock = vi.fn(); +const sendViaApnsMock = vi.fn(); vi.mock("@/lib/notifications/senders/apns", () => ({ - sendViaApns: vi.fn(), + sendViaApns: (...args: unknown[]) => sendViaApnsMock(...args), })); vi.mock("@/lib/notifications/senders/email", () => ({ sendViaEmail: vi.fn(), })); vi.mock("@/lib/notifications/senders/ntfy", () => ({ - sendViaNtfy: vi.fn(), + sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args), })); vi.mock("@/lib/notifications/senders/telegram", () => ({ sendViaTelegram: vi.fn(), @@ -94,6 +97,8 @@ beforeEach(() => { }), ); sendViaWebPushMock.mockResolvedValue({ ok: true }); + sendViaNtfyMock.mockResolvedValue({ ok: true }); + sendViaApnsMock.mockResolvedValue({ ok: true }); }); describe("notification delivery identity", () => { @@ -180,4 +185,155 @@ describe("notification delivery identity", () => { ); expect(sendViaWebPushMock).not.toHaveBeenCalled(); }); + + it("renders for each Guardian while isolating channel preferences", async () => { + const firstGuardian = "guardian-en"; + const secondGuardian = "guardian-de"; + + ( + prisma.user.findUnique as unknown as ReturnType + ).mockImplementation(async ({ where }: { where: { id: string } }) => { + if (where.id === recordUserId) { + return { managedProfileAt: new Date() } as never; + } + if (where.id === firstGuardian) { + return { + managedProfileAt: null, + locale: "en", + notificationPrefs: { medication: { clientManaged: true } }, + } as never; + } + return { + managedProfileAt: null, + locale: "de", + notificationPrefs: { medication: { clientManaged: true } }, + } as never; + }); + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + { granteeId: firstGuardian, grantee: { managedProfileAt: null } }, + { granteeId: secondGuardian, grantee: { managedProfileAt: null } }, + ] as never); + ( + prisma.notificationChannel.findMany as unknown as ReturnType + ).mockImplementation(async ({ where }: { where: { userId: string } }) => { + if (where.userId === firstGuardian) { + return [ + { + id: "first-web-disabled", + userId: firstGuardian, + type: "WEB_PUSH", + config: "{}", + nextRetryAt: null, + preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }], + }, + { + id: "first-ntfy-enabled", + userId: firstGuardian, + type: "NTFY", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + ] as never; + } + return [ + { + id: "second-web-enabled", + userId: secondGuardian, + type: "WEB_PUSH", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + { + id: "second-ntfy-disabled", + userId: secondGuardian, + type: "NTFY", + config: "{}", + nextRetryAt: null, + preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }], + }, + ] as never; + }); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + title: "record-language-title", + message: "record-language-message", + renderForRecipient: (locale) => ({ + title: `title-${locale}`, + message: `message-${locale}`, + }), + }); + + expect(outcome).toEqual({ + dispatched: true, + channelsAttempted: 2, + channelsSucceeded: 2, + }); + expect(sendViaNtfyMock).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + recipientUserId: firstGuardian, + title: "title-en", + message: "message-en", + }), + ); + expect(sendViaWebPushMock).toHaveBeenCalledWith( + secondGuardian, + expect.objectContaining({ + recipientUserId: secondGuardian, + title: "title-de", + message: "message-de", + }), + ); + expect(sendViaWebPushMock).toHaveBeenCalledTimes(1); + expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); + }); + + it("bypasses client-managed APNs suppression only for managed Guardian delivery", async () => { + const guardian = "guardian-apns"; + ( + prisma.user.findUnique as unknown as ReturnType + ).mockImplementation( + async ({ where }: { where: { id: string } }) => + (where.id === recordUserId + ? { managedProfileAt: new Date() } + : { + managedProfileAt: null, + notificationPrefs: { medication: { clientManaged: true } }, + }) as never, + ); + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + { granteeId: guardian, grantee: { managedProfileAt: null } }, + ] as never); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + id: "guardian-apns", + userId: guardian, + type: "APNS", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + ] as never); + + await expect( + dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + title: "Record content", + message: "Record schedule", + }), + ).resolves.toMatchObject({ dispatched: true, channelsSucceeded: 1 }); + + expect(sendViaApnsMock).toHaveBeenCalledWith( + guardian, + expect.objectContaining({ + recordUserId, + recipientUserId: guardian, + }), + ); + }); }); diff --git a/tests/integration/guardian-notification-fanout.test.ts b/tests/integration/guardian-notification-fanout.test.ts new file mode 100644 index 000000000..469b1449f --- /dev/null +++ b/tests/integration/guardian-notification-fanout.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sent = vi.hoisted(() => ({ + ntfy: [] as Array<{ + recipientUserId: string; + title: string; + message: string; + }>, + apns: [] as Array<{ recipientUserId: string; recordUserId?: string }>, +})); + +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: async ( + _config: unknown, + payload: { recipientUserId?: string; title: string; message: string }, + ) => { + sent.ntfy.push({ + recipientUserId: payload.recipientUserId ?? "missing", + title: payload.title, + message: payload.message, + }); + return { ok: true }; + }, +})); + +vi.mock("@/lib/notifications/senders/apns", () => ({ + sendViaApns: async ( + recipientUserId: string, + payload: { recordUserId?: string }, + ) => { + sent.apns.push({ recipientUserId, recordUserId: payload.recordUserId }); + return { ok: true }; + }, +})); + +import { encrypt } from "@/lib/crypto"; +import type { Locale } from "@/lib/i18n/config"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +let sequence = 0; + +async function createUser(input: { + label: string; + locale: "en" | "de"; + managed?: boolean; + clientManaged?: boolean; +}) { + const suffix = sequence++; + return getPrismaClient().user.create({ + data: { + username: `${input.label}-${suffix}`, + email: `${input.label}-${suffix}@example.test`, + locale: input.locale, + managedProfileAt: input.managed ? new Date() : null, + notificationPrefs: input.clientManaged + ? { medication: { clientManaged: true } } + : undefined, + }, + }); +} + +async function addGuardian(recordUserId: string, recipientUserId: string) { + await getPrismaClient().accountGrant.create({ + data: { + grantorId: recordUserId, + granteeId: recipientUserId, + access: "MANAGE", + acceptedAt: new Date(), + }, + }); +} + +async function addChannel( + userId: string, + type: "NTFY" | "APNS", + enabled = true, +) { + await getPrismaClient().notificationChannel.create({ + data: { + userId, + type, + enabled, + config: encrypt("{}"), + }, + }); +} + +beforeEach(async () => { + sent.ntfy.length = 0; + sent.apns.length = 0; + await truncateAllTables(getPrismaClient()); +}); + +describe("Guardian fan-out (real Postgres)", () => { + it("isolates recipient locale and global preference across two Guardians and two records", async () => { + const [recordA, recordB, guardianEn, guardianDe] = await Promise.all([ + createUser({ label: "record-a", locale: "en", managed: true }), + createUser({ label: "record-b", locale: "de", managed: true }), + createUser({ label: "guardian-en", locale: "en" }), + createUser({ label: "guardian-de", locale: "de" }), + ]); + await Promise.all([ + addGuardian(recordA.id, guardianEn.id), + addGuardian(recordA.id, guardianDe.id), + addGuardian(recordB.id, guardianEn.id), + addGuardian(recordB.id, guardianDe.id), + addChannel(guardianEn.id, "NTFY"), + addChannel(guardianDe.id, "NTFY"), + ]); + + const renderForRecipient = (locale: Locale) => ({ + title: `title-${locale}`, + message: `message-${locale}`, + }); + await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordA.id, + title: "record-a-title", + message: "record-a-message", + renderForRecipient, + }); + + expect(sent.ntfy).toEqual( + expect.arrayContaining([ + { + recipientUserId: guardianEn.id, + title: "title-en", + message: "message-en", + }, + { + recipientUserId: guardianDe.id, + title: "title-de", + message: "message-de", + }, + ]), + ); + + await getPrismaClient().notificationPreference.create({ + data: { + channel: { + connect: { userId_type: { userId: guardianEn.id, type: "NTFY" } }, + }, + eventType: "MEDICATION_REMINDER", + enabled: false, + }, + }); + sent.ntfy.length = 0; + + await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordB.id, + title: "record-b-title", + message: "record-b-message", + renderForRecipient, + }); + + expect(sent.ntfy).toEqual([ + { + recipientUserId: guardianDe.id, + title: "title-de", + message: "message-de", + }, + ]); + }); + + it("bypasses client-managed suppression for each Guardian but not self delivery", async () => { + const [record, guardianEn, guardianDe] = await Promise.all([ + createUser({ label: "record", locale: "en", managed: true }), + createUser({ + label: "guardian-en", + locale: "en", + clientManaged: true, + }), + createUser({ + label: "guardian-de", + locale: "de", + clientManaged: true, + }), + ]); + await Promise.all([ + addGuardian(record.id, guardianEn.id), + addGuardian(record.id, guardianDe.id), + addChannel(guardianEn.id, "APNS"), + addChannel(guardianDe.id, "APNS"), + ]); + + await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: record.id, + title: "record-title", + message: "record-message", + }); + + expect(sent.apns).toEqual( + expect.arrayContaining([ + { recipientUserId: guardianEn.id, recordUserId: record.id }, + { recipientUserId: guardianDe.id, recordUserId: record.id }, + ]), + ); + }); +}); From 59de92b62e54006c6e21870ad8c682245a221022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 05:33:02 +0200 Subject: [PATCH 04/13] fix: isolate guardian notification preferences --- src/lib/jobs/measurement-reminder.ts | 9 ++++ src/lib/jobs/medication-low-stock.ts | 15 ++++++ .../reminder/medication-reminder-check.ts | 11 +++++ src/lib/notifications/dispatch-localised.ts | 13 +++-- src/lib/notifications/dispatcher.ts | 47 ++++++++++++++----- src/lib/notifications/types.ts | 11 +++++ 6 files changed, 91 insertions(+), 15 deletions(-) diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 0e797711c..880f0b6cf 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -329,12 +329,21 @@ export async function runMeasurementReminderTick( reminder.label, reminder.location, ); + const renderForRecipient = (locale: Locale) => { + const rendered = buildMeasurementReminderPayload( + locale, + reminder.label, + reminder.location, + ); + return { title: rendered.title, message: rendered.body }; + }; const outcome = await dispatchImpl({ eventType: "MEASUREMENT_REMINDER", userId: reminder.user.id, title, message: body, + renderForRecipient, metadata: { scheduledAt: now.toISOString(), reminderId: reminder.id, diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 94d9458df..627ffdb99 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -497,11 +497,26 @@ export async function runMedicationLowStockTick( schedules: med.schedules, today: now, }); + const renderForRecipient = (locale: Locale) => { + const rendered = buildLowStockPayload({ + locale, + medName: med.name, + runwayDays: evaluation.runwayDays ?? 0, + unitsRemaining: evaluation.unitsRemaining, + expiredUnits: evaluation.expiredUnits, + leadDays, + triggerDays, + schedules: med.schedules, + today: now, + }); + return { title: rendered.title, message: rendered.body }; + }; const outcome = await dispatchImpl({ eventType: "MEDICATION_LOW_STOCK", userId: user.id, title, message: body, + renderForRecipient, metadata: { scheduledAt: now.toISOString(), medicationId: med.id, diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 1643f69ce..2decef35b 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -5,6 +5,7 @@ * schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import type { Locale } from "@/lib/i18n/config"; import { recordError, recordReminderCheck } from "@/lib/jobs/worker-status"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { decrypt } from "@/lib/crypto"; @@ -630,6 +631,15 @@ export async function handleReminderCheck( Math.ceil(minutesToEnd), med.user.locale, ); + const renderForRecipient = (locale: Locale) => + getPhaseMessage( + currentPhase, + med.name, + doseInfo, + timeWindow, + Math.ceil(minutesToEnd), + locale, + ); const keyboard = getPhaseKeyboard( currentPhase, @@ -652,6 +662,7 @@ export async function handleReminderCheck( userId: med.user.id, title, message, + renderForRecipient, metadata: { medicationId: med.id, scheduleId: schedule.id, diff --git a/src/lib/notifications/dispatch-localised.ts b/src/lib/notifications/dispatch-localised.ts index d24b9828b..4c01f0d11 100644 --- a/src/lib/notifications/dispatch-localised.ts +++ b/src/lib/notifications/dispatch-localised.ts @@ -154,10 +154,14 @@ export async function dispatchLocalisedNotification( const locale = await resolveRecipientLocale( opts.recipientUserId ?? opts.userId, ); - const t = getServerTranslator(locale).t; - - const title = t(opts.titleKey, opts.params); - const message = t(opts.messageKey, opts.params); + const renderForRecipient = (recipientLocale: Locale) => { + const t = getServerTranslator(recipientLocale).t; + return { + title: t(opts.titleKey, opts.params), + message: t(opts.messageKey, opts.params), + }; + }; + const { title, message } = renderForRecipient(locale); // Warn when a translation key falls back to its own raw string — // that signals a missing entry in `messages/{locale}.json` (or a @@ -183,6 +187,7 @@ export async function dispatchLocalisedNotification( recipientUserId: opts.recipientUserId, title, message, + renderForRecipient, metadata, managedFanoutEvent: opts.managedFanoutEvent, urgent: opts.urgent, diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index a718acb4c..751d56af5 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { decrypt, encrypt } from "@/lib/crypto"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import type { NotificationPayload, TelegramChannelConfig, @@ -60,6 +61,26 @@ export interface DispatchOutcome { channelsSucceeded: number; } +function isLocale(value: string | null | undefined): value is Locale { + return (locales as readonly string[]).includes(value ?? ""); +} + +/** Render record-owned content only after the delivery recipient is known. */ +async function renderForRecipient( + payload: NotificationPayload, + recipientUserId: string, +): Promise { + const { renderForRecipient: render, ...deliveryPayload } = payload; + if (!render) return deliveryPayload; + + const recipient = await prisma.user.findUnique({ + where: { id: recipientUserId }, + select: { locale: true }, + }); + const locale = isLocale(recipient?.locale) ? recipient.locale : defaultLocale; + return { ...deliveryPayload, ...render(locale) }; +} + export async function dispatchNotification( payload: NotificationPayload, ): Promise { @@ -101,11 +122,15 @@ export async function dispatchNotification( recordUserId: delivery.recordUserId, recipientUserId: delivery.recipientUserId, }; + const recipientPayload = await renderForRecipient( + deliveryPayload, + delivery.recipientUserId, + ); const channels = await prisma.notificationChannel.findMany({ where: { userId: delivery.recipientUserId, enabled: true }, include: { preferences: { - where: { eventType: deliveryPayload.eventType }, + where: { eventType: recipientPayload.eventType }, }, }, }); @@ -155,7 +180,7 @@ export async function dispatchNotification( }, include: { preferences: { - where: { eventType: deliveryPayload.eventType }, + where: { eventType: recipientPayload.eventType }, }, }, }); @@ -186,7 +211,7 @@ export async function dispatchNotification( // from /settings/notifications once they've seen the badge a few // times and decided they want push. let defaultEnabled = - EVENT_DEFAULT_ENABLED[deliveryPayload.eventType as EventType] ?? true; + EVENT_DEFAULT_ENABLED[recipientPayload.eventType as EventType] ?? true; // v1.7.0 — MOOD_REMINDER single source of truth = the visible card. // The event used to default OFF, layered on top of the per-user @@ -197,7 +222,7 @@ export async function dispatchNotification( // opt-out (a `NotificationPreference` row with `enabled = false`) is // still honoured below via `pref.enabled` — only the no-row default // is derived from the card. - if (deliveryPayload.eventType === "MOOD_REMINDER") { + if (recipientPayload.eventType === "MOOD_REMINDER") { const user = await prisma.user.findUnique({ where: { id: delivery.recipientUserId }, select: { moodReminderEnabled: true }, @@ -211,7 +236,7 @@ export async function dispatchNotification( let clientManagedApns: boolean | null = null; const resolveClientManagedApns = async (): Promise => { if (clientManagedApns !== null) return clientManagedApns; - const gate = CLIENT_MANAGED_APNS_EVENTS[deliveryPayload.eventType]; + const gate = CLIENT_MANAGED_APNS_EVENTS[recipientPayload.eventType]; if (!gate) { clientManagedApns = false; return false; @@ -251,22 +276,22 @@ export async function dispatchNotification( if ( channel.type === "APNS" && !delivery.managed && - hasClientManagedApnsGate(deliveryPayload.eventType) && + hasClientManagedApnsGate(recipientPayload.eventType) && (await resolveClientManagedApns()) ) { - const gate = CLIENT_MANAGED_APNS_EVENTS[deliveryPayload.eventType]; - getEvent()?.addMeta(gate.metaKey, gate.tag(deliveryPayload.metadata)); + const gate = CLIENT_MANAGED_APNS_EVENTS[recipientPayload.eventType]; + getEvent()?.addMeta(gate.metaKey, gate.tag(recipientPayload.metadata)); if (gate.detail && gate.detailKey) { getEvent()?.addMeta( gate.detailKey, - gate.detail(deliveryPayload.userId, deliveryPayload.metadata), + gate.detail(recipientPayload.userId, recipientPayload.metadata), ); } recordPushAttempt({ recordUserId: delivery.recordUserId, recipientUserId: delivery.recipientUserId, channel: "APNS", - eventType: deliveryPayload.eventType, + eventType: recipientPayload.eventType, result: "skipped", reason: "client_managed", }); @@ -280,7 +305,7 @@ export async function dispatchNotification( channel.type as ChannelType, channel.config, delivery.recipientUserId, - deliveryPayload, + recipientPayload, ); if (outcome.ok) { diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 419400ebf..a8db92882 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -1,3 +1,5 @@ +import type { Locale } from "@/lib/i18n/config"; + /** * Notification system type definitions. * Channels: Telegram, ntfy, Web Push @@ -285,6 +287,15 @@ export interface NotificationPayload { * paths may set it, and safety-floor is the only SYSTEM_ALERT admission. */ managedFanoutEvent?: ManagedGuardianFanoutEvent; + /** + * Internal rendering hook for record-owned notification content. The + * dispatcher invokes it only after choosing a recipient, then removes it + * before handing the payload to a channel adapter. + */ + renderForRecipient?: (locale: Locale) => { + title: string; + message: string; + }; /** * Discreet mode (cycle privacy). When true, the lock-screen-visible * routing metadata the senders would otherwise derive from `eventType` From accb98e22311b5a82f44cd1b60ce18ec8cc43fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 05:37:05 +0200 Subject: [PATCH 05/13] test(02-11): cover guardian notification channel safety - Cover non-interactive Guardian payloads across delivery channels\n- Reject Telegram callbacks tied to managed records --- .../guardian-notification-payloads.test.ts | 29 +++++++++++++++++++ .../telegram/webhook/__tests__/route.test.ts | 19 ++++++++++++ .../__tests__/web-push-dose-clear.test.ts | 24 +++++++++++++++ .../senders/__tests__/apns.test.ts | 26 +++++++++++++++++ .../senders/__tests__/telegram.test.ts | 20 +++++++++++++ .../senders/__tests__/urgency.test.ts | 18 ++++++++++++ 6 files changed, 136 insertions(+) create mode 100644 src/__tests__/guardian-notification-payloads.test.ts diff --git a/src/__tests__/guardian-notification-payloads.test.ts b/src/__tests__/guardian-notification-payloads.test.ts new file mode 100644 index 000000000..03ad2f5b0 --- /dev/null +++ b/src/__tests__/guardian-notification-payloads.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; + +describe("managed Guardian notification payload safety", () => { + it("recognises only cross-account record delivery as managed Guardian delivery", () => { + expect( + isManagedGuardianDelivery({ + eventType: "MEDICATION_REMINDER", + userId: "record-1", + recordUserId: "record-1", + recipientUserId: "guardian-1", + title: "t", + message: "m", + }), + ).toBe(true); + + expect( + isManagedGuardianDelivery({ + eventType: "MEDICATION_REMINDER", + userId: "user-1", + recordUserId: "user-1", + recipientUserId: "user-1", + title: "t", + message: "m", + }), + ).toBe(false); + }); +}); diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 05431bd59..ac4e3b012 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -287,6 +287,25 @@ describe("Telegram webhook — rate limit", () => { }); describe("Telegram webhook — callback dispatch", () => { + it("rejects a callback tied to a managed Guardian reminder without mutating the record", async () => { + vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValueOnce({ + medication: { userId: "managed-record" }, + } as never); + + const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); + + expect(res.status).toBe(200); + expect(prisma.medication.findFirst).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medication.update).not.toHaveBeenCalled(); + expect(deleteMessage).not.toHaveBeenCalled(); + expect(answerTelegramCallbackQuery).toHaveBeenCalledWith( + "decrypted:ENC(token-blob)", + "cb-1", + "Invalid action.", + ); + }); + it("'taken:' creates a MedicationIntakeEvent + clears snoozedUntil + acks the callback", async () => { const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res.status).toBe(200); diff --git a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts index 684a85cea..c2b3d76ad 100644 --- a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts +++ b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts @@ -164,6 +164,30 @@ describe("web-push reminder push — stable tag + badge", () => { ); expect(body.tag).toBe("REMINDER"); }); + + it("uses an ordinary landing route without a persistent prompt for a managed Guardian", async () => { + findManyMock.mockResolvedValue([SUB]); + vi.spyOn(global, "fetch").mockResolvedValue( + new Response(null, { status: 201 }), + ); + + await sendViaWebPush("guardian-1", { + eventType: "MEDICATION_REMINDER", + userId: "record-1", + recordUserId: "record-1", + recipientUserId: "guardian-1", + title: "Time for your dose", + message: "Ramipril 5mg", + urgent: true, + metadata: { url: "/medications/med-1" }, + }); + + const body = JSON.parse( + generateRequestDetailsMock.mock.calls[0][1] as string, + ); + expect(body.url).toBe("/"); + expect(body.requireInteraction).toBe(false); + }); }); describe("clear-on-taken push", () => { diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index 1084f838d..85d1a8676 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -610,6 +610,32 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.payload.eventType).toBe("MEDICATION_REMINDER"); }); + it("omits action metadata and categories for a managed Guardian", 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("guardian-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + userId: "record-1", + recordUserId: "record-1", + recipientUserId: "guardian-1", + metadata: { + medicationId: "med-1", + scheduleId: "sched-1", + reminderId: "rem-1", + url: "/medications/med-1", + }, + }); + + const note = sendMock.mock.calls[0][0]; + expect(note.category).toBeUndefined(); + expect(note.payload).toEqual({ eventType: "MEDICATION_REMINDER" }); + }); + it("forwards reminderId on MEASUREMENT_REMINDER and strips unlisted keys", async () => { // The iOS "Erledigt" action POSTs the reminder completion directly // from a foreground / handled notification, so the reminder's id must diff --git a/src/lib/notifications/senders/__tests__/telegram.test.ts b/src/lib/notifications/senders/__tests__/telegram.test.ts index 292900143..fdbc30d10 100644 --- a/src/lib/notifications/senders/__tests__/telegram.test.ts +++ b/src/lib/notifications/senders/__tests__/telegram.test.ts @@ -200,6 +200,26 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => { ]); }); + it("omits a record's inline actions for a managed Guardian", async () => { + await sendViaTelegram( + { botToken: "bot-token", chatId: "guardian-chat" }, + { + ...reminderPayload({ + replyMarkup: { + inline_keyboard: [[{ text: "Take", callback_data: "taken:med-1" }]], + }, + }), + userId: "managed-record", + recordUserId: "managed-record", + recipientUserId: "guardian-1", + }, + ); + + expect( + sendTelegramMessageMock.mock.calls[0][3].replyMarkup, + ).toBeUndefined(); + }); + it("updates one recipient slot when its Telegram chat changes", async () => { findUniqueMock.mockResolvedValue({ chatId: "old-chat", messageId: 123 }); diff --git a/src/lib/notifications/senders/__tests__/urgency.test.ts b/src/lib/notifications/senders/__tests__/urgency.test.ts index bf94783ac..a66b01831 100644 --- a/src/lib/notifications/senders/__tests__/urgency.test.ts +++ b/src/lib/notifications/senders/__tests__/urgency.test.ts @@ -146,6 +146,24 @@ describe("ntfy urgent mapping", () => { expect(headers.Priority).toBe("5"); expect(headers.Tags).toBe("reminder"); }); + + it("does not send ntfy action headers to a managed Guardian", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await sendViaNtfy( + { serverUrl: "https://ntfy.example.com", topic: "t" }, + payload({ + userId: "record-1", + recordUserId: "record-1", + recipientUserId: "guardian-1", + }), + ); + const headers = ( + safeFetchMock.mock.calls[0][1] as { + headers: Record; + } + ).headers; + expect(headers.Actions).toBeUndefined(); + }); }); describe("webhook urgent mapping", () => { From 60b4845fea8acaa89fddc0a69b660ef838c9feb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 05:44:41 +0200 Subject: [PATCH 06/13] feat(02-11): secure guardian notification delivery - Remove record-mutating affordances from Guardian channel payloads\n- Reject managed Telegram callbacks and verify channel attribution --- .../guardian-notification-payloads.test.ts | 8 - .../__tests__/web-push-dose-clear.test.ts | 4 +- src/lib/notifications/managed-delivery.ts | 16 ++ .../senders/__tests__/apns.test.ts | 1 - src/lib/notifications/senders/apns.ts | 18 +- src/lib/notifications/senders/web-push.ts | 3 + src/lib/telegram-webhook-handlers.ts | 41 ++++ .../guardian-notification-fanout.test.ts | 226 ++++++++++++++++-- 8 files changed, 276 insertions(+), 41 deletions(-) create mode 100644 src/lib/notifications/managed-delivery.ts diff --git a/src/__tests__/guardian-notification-payloads.test.ts b/src/__tests__/guardian-notification-payloads.test.ts index 03ad2f5b0..183daeb95 100644 --- a/src/__tests__/guardian-notification-payloads.test.ts +++ b/src/__tests__/guardian-notification-payloads.test.ts @@ -6,23 +6,15 @@ describe("managed Guardian notification payload safety", () => { it("recognises only cross-account record delivery as managed Guardian delivery", () => { expect( isManagedGuardianDelivery({ - eventType: "MEDICATION_REMINDER", - userId: "record-1", recordUserId: "record-1", recipientUserId: "guardian-1", - title: "t", - message: "m", }), ).toBe(true); expect( isManagedGuardianDelivery({ - eventType: "MEDICATION_REMINDER", - userId: "user-1", recordUserId: "user-1", recipientUserId: "user-1", - title: "t", - message: "m", }), ).toBe(false); }); diff --git a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts index c2b3d76ad..6b9eb9d66 100644 --- a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts +++ b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts @@ -165,7 +165,7 @@ describe("web-push reminder push — stable tag + badge", () => { expect(body.tag).toBe("REMINDER"); }); - it("uses an ordinary landing route without a persistent prompt for a managed Guardian", async () => { + it("uses an ordinary landing route without actions for a managed Guardian", async () => { findManyMock.mockResolvedValue([SUB]); vi.spyOn(global, "fetch").mockResolvedValue( new Response(null, { status: 201 }), @@ -186,7 +186,7 @@ describe("web-push reminder push — stable tag + badge", () => { generateRequestDetailsMock.mock.calls[0][1] as string, ); expect(body.url).toBe("/"); - expect(body.requireInteraction).toBe(false); + expect(body.actions).toBeUndefined(); }); }); diff --git a/src/lib/notifications/managed-delivery.ts b/src/lib/notifications/managed-delivery.ts new file mode 100644 index 000000000..ec2ed30af --- /dev/null +++ b/src/lib/notifications/managed-delivery.ts @@ -0,0 +1,16 @@ +import type { NotificationPayload } from "@/lib/notifications/types"; + +/** + * A cross-account notification can only reach a Guardian after the + * dispatcher has revalidated the marked record and its active MANAGE grant. + * Senders use this narrow invariant to remove record-mutating affordances. + */ +export function isManagedGuardianDelivery( + payload: Pick, +): boolean { + return ( + payload.recordUserId !== undefined && + payload.recipientUserId !== undefined && + payload.recordUserId !== payload.recipientUserId + ); +} diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index 85d1a8676..c0cdd32f2 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -620,7 +620,6 @@ describe("sendViaApns — dispatcher fan-out", () => { title: "t", message: "m", eventType: "MEDICATION_REMINDER", - userId: "record-1", recordUserId: "record-1", recipientUserId: "guardian-1", metadata: { diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index a614004bd..cfc6cccfb 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -31,6 +31,7 @@ import { getEvent } from "@/lib/logging/context"; import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; import { plainPushText } from "@/lib/notifications/strip-emoji"; +import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; export interface ApnsPayload { /** APNs `aps.alert.title` + `aps.alert.body`. */ @@ -628,10 +629,7 @@ export async function sendViaApns( process.env.APNS_CRITICAL_ENTITLEMENT === "true"; let result: Awaited> | null = null; - const managedDelivery = - payload.recordUserId !== undefined && - payload.recipientUserId !== undefined && - payload.recordUserId !== payload.recipientUserId; + const managedDelivery = isManagedGuardianDelivery(payload); // Discreet mode (cycle privacy): the lock-screen-visible routing // metadata must not carry the event name. Collapse the category / // threadId / collapseId and the data eventType to a generic value so @@ -671,10 +669,16 @@ export async function sendViaApns( // In discreet mode also drop cycle-semantic metadata (e.g. `phase`) // so a future cycle push can't name a cycle concept on the wire even // though the allowlist permits `phase` (QA LOW). + // A Guardian may read a managed record but must never receive + // an iOS action identifier or deep link that can mutate it. + // Keep the ordinary event envelope so opening the notification + // follows the normal authorised record-selection flow. ...pickIosMetadata( - payload.discreet - ? stripCycleMetadata(payload.metadata) - : payload.metadata, + managedDelivery + ? undefined + : payload.discreet + ? stripCycleMetadata(payload.metadata) + : payload.metadata, ), }, threadId: routingEvent, diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index f0e080dac..753f793a4 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -9,6 +9,7 @@ import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-at import { isPublicUrl } from "@/lib/validations/notifications"; import { safeFetch } from "@/lib/safe-fetch"; import { plainPushText } from "@/lib/notifications/strip-emoji"; +import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; /** * Send Web Push notification to all subscribed devices of a user. @@ -30,6 +31,7 @@ export async function sendViaWebPush( payload: NotificationPayload, ): Promise { try { + const managedDelivery = isManagedGuardianDelivery(payload); // Lazy import to avoid issues when web-push is not installed const webpush = await import("web-push"); @@ -116,6 +118,7 @@ export async function sendViaWebPush( // 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 diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 7bd4568e2..245c002e6 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -107,6 +107,30 @@ async function cleanupReminderTracking( } } +/** + * A managed-record reminder is tracked under the Guardian recipient so a + * replacement can delete the prior message. Its callback must never mutate + * the managed record, even if a crafted Telegram update supplies action data. + */ +async function isManagedReminderCallback(input: { + recipientUserId: string; + chatId: string; + messageId: number; +}): Promise { + const reminder = await prisma.telegramReminderMessage.findFirst({ + where: { + recipientUserId: input.recipientUserId, + chatId: input.chatId, + messageId: input.messageId, + }, + select: { medication: { select: { userId: true } } }, + }); + return ( + reminder?.medication?.userId !== undefined && + reminder.medication.userId !== input.recipientUserId + ); +} + // v1.19.0 — the auto-delete window dropped from 1 h to ~30 min and moved to // the shared `scheduleTelegramAutoDelete` helper so the inbound (free-text) // and outbound (unanswered prompt) paths agree on the window. @@ -379,6 +403,23 @@ export async function handleCallback(update: TelegramUpdate) { const data = callback.data ?? ""; const messageId = callback.message?.message_id; + if ( + messageId !== undefined && + /^(taken|snooze|skip|ack):/.test(data) && + (await isManagedReminderCallback({ + recipientUserId: user.id, + chatId, + messageId, + })) + ) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorInvalidAction"), + ); + return; + } + if (data.startsWith("taken:")) { const medicationId = data.slice("taken:".length).trim(); if (!medicationId) { diff --git a/tests/integration/guardian-notification-fanout.test.ts b/tests/integration/guardian-notification-fanout.test.ts index 469b1449f..9dff5de63 100644 --- a/tests/integration/guardian-notification-fanout.test.ts +++ b/tests/integration/guardian-notification-fanout.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const sent = vi.hoisted(() => ({ + deliveries: [] as Array<{ + channel: "NTFY" | "APNS" | "WEB_PUSH" | "TELEGRAM"; + recordUserId: string; + recipientUserId: string; + }>, ntfy: [] as Array<{ recipientUserId: string; title: string; @@ -9,29 +14,125 @@ const sent = vi.hoisted(() => ({ apns: [] as Array<{ recipientUserId: string; recordUserId?: string }>, })); -vi.mock("@/lib/notifications/senders/ntfy", () => ({ - sendViaNtfy: async ( - _config: unknown, - payload: { recipientUserId?: string; title: string; message: string }, - ) => { - sent.ntfy.push({ - recipientUserId: payload.recipientUserId ?? "missing", - title: payload.title, - message: payload.message, - }); - return { ok: true }; - }, -})); +vi.mock("@/lib/notifications/senders/ntfy", async () => { + const { recordPushAttemptForPayload } = await vi.importActual< + typeof import("@/lib/notifications/senders/push-attempt-record") + >("@/lib/notifications/senders/push-attempt-record"); + return { + sendViaNtfy: async ( + _config: unknown, + payload: { + userId: string; + recordUserId?: string; + recipientUserId?: string; + eventType: string; + title: string; + message: string; + }, + ) => { + const recipientUserId = payload.recipientUserId ?? payload.userId; + sent.ntfy.push({ + recipientUserId, + title: payload.title, + message: payload.message, + }); + sent.deliveries.push({ + channel: "NTFY", + recordUserId: payload.recordUserId ?? payload.userId, + recipientUserId, + }); + recordPushAttemptForPayload(payload, recipientUserId, { + userId: payload.userId, + channel: "NTFY", + eventType: payload.eventType, + result: "ok", + }); + return { ok: true }; + }, + }; +}); -vi.mock("@/lib/notifications/senders/apns", () => ({ - sendViaApns: async ( - recipientUserId: string, - payload: { recordUserId?: string }, - ) => { - sent.apns.push({ recipientUserId, recordUserId: payload.recordUserId }); - return { ok: true }; - }, -})); +vi.mock("@/lib/notifications/senders/apns", async () => { + const { recordPushAttemptForPayload } = await vi.importActual< + typeof import("@/lib/notifications/senders/push-attempt-record") + >("@/lib/notifications/senders/push-attempt-record"); + return { + sendViaApns: async ( + recipientUserId: string, + payload: { userId: string; recordUserId?: string; eventType: string }, + ) => { + sent.apns.push({ recipientUserId, recordUserId: payload.recordUserId }); + sent.deliveries.push({ + channel: "APNS", + recordUserId: payload.recordUserId ?? payload.userId, + recipientUserId, + }); + recordPushAttemptForPayload(payload, recipientUserId, { + userId: payload.userId, + channel: "APNS", + eventType: payload.eventType, + result: "ok", + }); + return { ok: true }; + }, + }; +}); + +vi.mock("@/lib/notifications/senders/web-push", async () => { + const { recordPushAttemptForPayload } = await vi.importActual< + typeof import("@/lib/notifications/senders/push-attempt-record") + >("@/lib/notifications/senders/push-attempt-record"); + return { + sendViaWebPush: async ( + recipientUserId: string, + payload: { userId: string; recordUserId?: string; eventType: string }, + ) => { + sent.deliveries.push({ + channel: "WEB_PUSH", + recordUserId: payload.recordUserId ?? payload.userId, + recipientUserId, + }); + recordPushAttemptForPayload(payload, recipientUserId, { + userId: payload.userId, + channel: "WEB_PUSH", + eventType: payload.eventType, + result: "ok", + }); + return { ok: true }; + }, + }; +}); + +vi.mock("@/lib/notifications/senders/telegram", async () => { + const { recordPushAttemptForPayload } = await vi.importActual< + typeof import("@/lib/notifications/senders/push-attempt-record") + >("@/lib/notifications/senders/push-attempt-record"); + return { + sendViaTelegram: async ( + _config: unknown, + payload: { + userId: string; + recordUserId?: string; + recipientUserId?: string; + eventType: string; + }, + ) => { + const recipientUserId = payload.recipientUserId ?? payload.userId; + sent.deliveries.push({ + channel: "TELEGRAM", + recordUserId: payload.recordUserId ?? payload.userId, + recipientUserId, + }); + recordPushAttemptForPayload(payload, recipientUserId, { + userId: payload.userId, + channel: "TELEGRAM", + eventType: payload.eventType, + result: "ok", + }); + return { ok: true }; + }, + }; +}); import { encrypt } from "@/lib/crypto"; import type { Locale } from "@/lib/i18n/config"; @@ -73,7 +174,7 @@ async function addGuardian(recordUserId: string, recipientUserId: string) { async function addChannel( userId: string, - type: "NTFY" | "APNS", + type: "NTFY" | "APNS" | "WEB_PUSH" | "TELEGRAM", enabled = true, ) { await getPrismaClient().notificationChannel.create({ @@ -87,6 +188,7 @@ async function addChannel( } beforeEach(async () => { + sent.deliveries.length = 0; sent.ntfy.length = 0; sent.apns.length = 0; await truncateAllTables(getPrismaClient()); @@ -199,4 +301,82 @@ describe("Guardian fan-out (real Postgres)", () => { ]), ); }); + + it("writes the complete two-record, two-Guardian, four-channel attribution matrix", async () => { + const [recordA, recordB, guardianOne, guardianTwo, ordinaryRecord] = + await Promise.all([ + createUser({ label: "record-a", locale: "en", managed: true }), + createUser({ label: "record-b", locale: "de", managed: true }), + createUser({ label: "guardian-one", locale: "en" }), + createUser({ label: "guardian-two", locale: "de" }), + createUser({ label: "ordinary-record", locale: "en" }), + ]); + const guardianIds = [guardianOne.id, guardianTwo.id]; + const recordIds = [recordA.id, recordB.id]; + const channelTypes = ["NTFY", "APNS", "WEB_PUSH", "TELEGRAM"] as const; + + await Promise.all([ + ...recordIds.flatMap((recordUserId) => + guardianIds.map((recipientUserId) => + addGuardian(recordUserId, recipientUserId), + ), + ), + // An ordinary adult's MANAGE grant must never cause fan-out. + addGuardian(ordinaryRecord.id, guardianOne.id), + ...guardianIds.flatMap((userId) => + channelTypes.map((type) => addChannel(userId, type)), + ), + ]); + + await Promise.all( + recordIds.map((userId) => + dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId, + title: "record reminder", + message: "record message", + }), + ), + ); + + const expected = recordIds.flatMap((recordUserId) => + guardianIds.flatMap((recipientUserId) => + channelTypes.map((channel) => ({ + channel, + recordUserId, + recipientUserId, + })), + ), + ); + expect(sent.deliveries).toHaveLength(expected.length); + expect(sent.deliveries).toEqual(expect.arrayContaining(expected)); + expect( + sent.deliveries.some((delivery) => + recordIds.includes(delivery.recipientUserId), + ), + ).toBe(false); + + await vi.waitFor(async () => { + const attempts = await getPrismaClient().pushAttempt.findMany({ + where: { eventType: "MEDICATION_REMINDER" }, + select: { channel: true, recordUserId: true, recipientUserId: true }, + }); + expect(attempts).toHaveLength(expected.length); + expect(attempts).toEqual(expect.arrayContaining(expected)); + }); + + await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: ordinaryRecord.id, + title: "ordinary reminder", + message: "ordinary message", + }); + + expect(sent.deliveries).toHaveLength(expected.length); + await expect( + getPrismaClient().pushAttempt.count({ + where: { eventType: "MEDICATION_REMINDER" }, + }), + ).resolves.toBe(expected.length); + }); }); From f5a02c2d291373c05bdcf614f2589ea60ba43f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 09:35:43 +0200 Subject: [PATCH 07/13] test(02-11): cover guardian egress races - Exercise revocation and expiry against blocked channel egress\n- Require a direct retention index for notification events --- .../guardian-notification-fanout.test.ts | 120 +++++++++++++++++- .../notification-attribution.test.ts | 15 +++ 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/tests/integration/guardian-notification-fanout.test.ts b/tests/integration/guardian-notification-fanout.test.ts index 9dff5de63..22eb3f266 100644 --- a/tests/integration/guardian-notification-fanout.test.ts +++ b/tests/integration/guardian-notification-fanout.test.ts @@ -14,6 +14,11 @@ const sent = vi.hoisted(() => ({ apns: [] as Array<{ recipientUserId: string; recordUserId?: string }>, })); +const ntfyEgress = vi.hoisted(() => ({ + block: null as Promise | null, + onStart: null as (() => void) | null, +})); + vi.mock("@/lib/notifications/senders/ntfy", async () => { const { recordPushAttemptForPayload } = await vi.importActual< typeof import("@/lib/notifications/senders/push-attempt-record") @@ -28,9 +33,11 @@ vi.mock("@/lib/notifications/senders/ntfy", async () => { eventType: string; title: string; message: string; - }, - ) => { - const recipientUserId = payload.recipientUserId ?? payload.userId; + }, + ) => { + ntfyEgress.onStart?.(); + if (ntfyEgress.block) await ntfyEgress.block; + const recipientUserId = payload.recipientUserId ?? payload.userId; sent.ntfy.push({ recipientUserId, title: payload.title, @@ -136,7 +143,9 @@ vi.mock("@/lib/notifications/senders/telegram", async () => { import { encrypt } from "@/lib/crypto"; import type { Locale } from "@/lib/i18n/config"; +import { withManagedProfileLock } from "@/lib/managed-profiles/lifecycle"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { revokeGrant } from "@/lib/sharing/grants"; import { getPrismaClient, truncateAllTables } from "./setup"; let sequence = 0; @@ -188,6 +197,8 @@ async function addChannel( } beforeEach(async () => { + ntfyEgress.block = null; + ntfyEgress.onStart = null; sent.deliveries.length = 0; sent.ntfy.length = 0; sent.apns.length = 0; @@ -379,4 +390,107 @@ describe("Guardian fan-out (real Postgres)", () => { }), ).resolves.toBe(expected.length); }); + + it("keeps a Guardian revocation from committing while its channel egress is in flight", async () => { + const [record, recipient, reserveGuardian] = await Promise.all([ + createUser({ label: "record", locale: "en", managed: true }), + createUser({ label: "recipient", locale: "en" }), + createUser({ label: "reserve-guardian", locale: "de" }), + ]); + await Promise.all([ + addGuardian(record.id, recipient.id), + addGuardian(record.id, reserveGuardian.id), + addChannel(recipient.id, "NTFY"), + ]); + const recipientGrant = await getPrismaClient().accountGrant.findFirstOrThrow({ + where: { grantorId: record.id, granteeId: recipient.id }, + select: { id: true }, + }); + let releaseEgress!: () => void; + let markEgressStarted!: () => void; + ntfyEgress.block = new Promise((resolve) => { + releaseEgress = resolve; + }); + const egressStarted = new Promise((resolve) => { + markEgressStarted = resolve; + }); + ntfyEgress.onStart = markEgressStarted; + + const delivery = dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: record.id, + title: "record reminder", + message: "record message", + }); + await egressStarted; + + const revocation = revokeGrant({ + grantId: recipientGrant.id, + grantorId: record.id, + }); + await expectPromiseToRemainPending(revocation); + + releaseEgress(); + await delivery; + await revocation; + }); + + it("keeps a Guardian expiry transition from committing while its channel egress is in flight", async () => { + const [record, recipient, reserveGuardian] = await Promise.all([ + createUser({ label: "record", locale: "en", managed: true }), + createUser({ label: "recipient", locale: "en" }), + createUser({ label: "reserve-guardian", locale: "de" }), + ]); + await Promise.all([ + addGuardian(record.id, recipient.id), + addGuardian(record.id, reserveGuardian.id), + addChannel(recipient.id, "NTFY"), + ]); + const recipientGrant = await getPrismaClient().accountGrant.findFirstOrThrow({ + where: { grantorId: record.id, granteeId: recipient.id }, + select: { id: true }, + }); + let releaseEgress!: () => void; + let markEgressStarted!: () => void; + ntfyEgress.block = new Promise((resolve) => { + releaseEgress = resolve; + }); + const egressStarted = new Promise((resolve) => { + markEgressStarted = resolve; + }); + ntfyEgress.onStart = markEgressStarted; + + const delivery = dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: record.id, + title: "record reminder", + message: "record message", + }); + await egressStarted; + + const expiry = getPrismaClient().$transaction((tx) => + withManagedProfileLock(tx, record.id, () => + tx.accountGrant.update({ + where: { id: recipientGrant.id }, + data: { expiresAt: new Date() }, + }), + ), + ); + await expectPromiseToRemainPending(expiry); + + releaseEgress(); + await delivery; + await expiry; + }); }); + +async function expectPromiseToRemainPending(promise: Promise) { + const settled = await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolve) => setTimeout(() => resolve(false), 25)), + ]); + expect(settled).toBe(false); +} diff --git a/tests/integration/notification-attribution.test.ts b/tests/integration/notification-attribution.test.ts index 7f9e411b5..408ba65b0 100644 --- a/tests/integration/notification-attribution.test.ts +++ b/tests/integration/notification-attribution.test.ts @@ -141,6 +141,21 @@ describe("notification attribution (real Postgres)", () => { ).toBeNull(); }); + it("provides the created-at index used by global notification event retention", async () => { + const indexes = await getPrismaClient().$queryRaw< + Array<{ indexname: string }> + >` + SELECT indexname + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'notification_events' + `; + + expect(indexes.map(({ indexname }) => indexname)).toContain( + "notification_events_created_at_idx", + ); + }); + it("allows exactly one concurrent claim for a notification event", async () => { const recordUser = await createUser("claim-record", true); const client = getPrismaClient(); From 6ecf0cc8ffac76a0f7b68fce029af2e4668fbee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 09:37:42 +0200 Subject: [PATCH 08/13] style(02-11): format notification attribution test - Apply the repository formatter to the existing integration test --- tests/integration/notification-attribution.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integration/notification-attribution.test.ts b/tests/integration/notification-attribution.test.ts index 408ba65b0..75dd61e8d 100644 --- a/tests/integration/notification-attribution.test.ts +++ b/tests/integration/notification-attribution.test.ts @@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity"; -import { - claimNotificationEvent, -} from "@/lib/notifications/reminder-dedup"; +import { claimNotificationEvent } from "@/lib/notifications/reminder-dedup"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; let sequence = 0; From 1818bd874b90412e2419f282297c6f510469d44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 09:42:47 +0200 Subject: [PATCH 09/13] fix(02-11): authorize guardian egress under lock - Hold the managed-profile lock through every provider send\n- Add the notification event retention index and race coverage --- .../migration.sql | 5 ++ prisma/schema.prisma | 1 + .../notification-delivery-identity.test.ts | 8 +- src/lib/jobs/reminder/cleanup-handlers.ts | 8 +- src/lib/notifications/delivery-identity.ts | 83 +++++++++++++------ src/lib/notifications/dispatcher.ts | 25 ++++-- .../guardian-notification-fanout.test.ts | 28 ++++--- .../notification-attribution.test.ts | 38 ++++++++- 8 files changed, 145 insertions(+), 51 deletions(-) create mode 100644 prisma/migrations/0300_notification_event_created_at_index/migration.sql diff --git a/prisma/migrations/0300_notification_event_created_at_index/migration.sql b/prisma/migrations/0300_notification_event_created_at_index/migration.sql new file mode 100644 index 000000000..547ccd82b --- /dev/null +++ b/prisma/migrations/0300_notification_event_created_at_index/migration.sql @@ -0,0 +1,5 @@ +-- v1.37.0 — notification event retention deletes globally by created_at. +-- The dedup lookup index starts with record_user_id, so it cannot serve this +-- trailing-edge cleanup predicate efficiently. +CREATE INDEX "notification_events_created_at_idx" + ON "notification_events" ("created_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 153febee0..a8e052a70 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5821,6 +5821,7 @@ model NotificationEvent { recordUser User @relation(fields: [recordUserId], references: [id], onDelete: Cascade) @@index([recordUserId, eventType, dedupKey, createdAt(sort: Desc)]) + @@index([createdAt], map: "notification_events_created_at_idx") @@map("notification_events") } diff --git a/src/__tests__/notification-delivery-identity.test.ts b/src/__tests__/notification-delivery-identity.test.ts index f29c3f9b2..c06892cc3 100644 --- a/src/__tests__/notification-delivery-identity.test.ts +++ b/src/__tests__/notification-delivery-identity.test.ts @@ -132,7 +132,13 @@ describe("notification delivery identity", () => { message: "Record schedule", }), ); - expect(transactionMock).toHaveBeenCalledTimes(1); + // Preflight chooses recipient channels, then the provider call is + // re-authorized under the managed-profile lock for the actual egress. + expect(transactionMock).toHaveBeenCalledTimes(2); + expect(transactionMock).toHaveBeenLastCalledWith(expect.any(Function), { + maxWait: 5_000, + timeout: 60_000, + }); }); it("refuses a managed record without an explicit recipient", async () => { diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 5216507e2..e5ae5775e 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -127,10 +127,10 @@ export async function handleMoodReminderCleanup( * notification events are claims rather than delivery diagnostics, but after * the same horizon their dedup windows have elapsed and they have no reader. * - * The DELETE is unbounded by user — the index covers `created_at` - * directly, so a `WHERE created_at < cutoff` scan is bounded by the - * size of the trailing-edge of the table rather than the live working - * set. On a one-million-row table with the documented retention + * The DELETE is unbounded by user. Both ledgers therefore carry a direct + * `created_at` index, so a `WHERE created_at < cutoff` scan is bounded by the + * size of the trailing edge rather than the live working set. On a + * one-million-row table with the documented retention * window, the daily prune touches ~11k rows (1M / 90d × 1d) and * completes in milliseconds. */ diff --git a/src/lib/notifications/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts index cd82a8aac..961a49a44 100644 --- a/src/lib/notifications/delivery-identity.ts +++ b/src/lib/notifications/delivery-identity.ts @@ -80,13 +80,13 @@ export async function resolveManagedGuardianRecipientIds( /** * Resolve notification identities without treating a managed record as its * own destination. Legacy payloads remain valid only for ordinary self - * delivery; a managed record must name an active Guardian explicitly. + * delivery; a managed record must name an active Guardian explicitly. This is + * a preflight for recipient channel selection; managed delivery is authorized + * again under the same lock at each provider egress below. * - * Cross-principal authorization linearizes at the locked grant read below, - * immediately before the dispatcher selects a channel. A revocation that - * commits before that read prevents delivery; one that commits afterwards - * affects later dispatches but cannot recall provider work already in flight. - * The provider call deliberately runs outside the short database transaction. + * Cross-principal authorization linearizes at the locked grant read below. + * The egress helper keeps the same lock through the actual provider call, so + * a revocation cannot commit between Guardian authorization and delivery. */ export async function resolveNotificationDeliveryIdentity( payload: NotificationPayload, @@ -118,27 +118,60 @@ export async function resolveNotificationDeliveryIdentity( if (!isManagedGuardianFanoutAllowed(payload)) return null; - return prisma.$transaction(async (tx) => - withManagedProfileLock(tx, recordUserId, async (lockedRecord) => { - if (lockedRecord?.managedProfileAt == null) return null; + return withManagedGuardianAuthorization(recordUserId, recipientUserId, () => + Promise.resolve({ recordUserId, recipientUserId, managed: true }), + ); +} - const recipient = await tx.user.findUnique({ - where: { id: recipientUserId }, - select: { managedProfileAt: true }, - }); - if (!recipient || recipient.managedProfileAt != null) return null; +/** + * Execute a single managed Guardian channel send while holding the lifecycle + * advisory lock. The provider call intentionally remains inside this short + * transaction: managed Guardian revocation and expiry transitions take the + * same lock, so neither can commit after this authorization but before egress. + */ +export async function withManagedGuardianEgressAuthorization( + delivery: NotificationDeliveryIdentity, + send: () => Promise, +): Promise { + if (!delivery.managed) return send(); - const guardian = await tx.accountGrant.findFirst({ - where: { - grantorId: recordUserId, - granteeId: recipientUserId, - ...activeGuardianWhere(new Date()), - }, - select: { id: true }, - }); - if (!guardian) return null; + return withManagedGuardianAuthorization( + delivery.recordUserId, + delivery.recipientUserId, + send, + { maxWait: 5_000, timeout: 60_000 }, + ); +} - return { recordUserId, recipientUserId, managed: true }; - }), +async function withManagedGuardianAuthorization( + recordUserId: string, + recipientUserId: string, + operation: () => Promise, + options?: { maxWait: number; timeout: number }, +): Promise { + return prisma.$transaction( + (tx) => + withManagedProfileLock(tx, recordUserId, async (lockedRecord) => { + if (lockedRecord?.managedProfileAt == null) return null; + + const recipient = await tx.user.findUnique({ + where: { id: recipientUserId }, + select: { managedProfileAt: true }, + }); + if (!recipient || recipient.managedProfileAt != null) return null; + + const guardian = await tx.accountGrant.findFirst({ + where: { + grantorId: recordUserId, + granteeId: recipientUserId, + ...activeGuardianWhere(new Date()), + }, + select: { id: true }, + }); + if (!guardian) return null; + + return operation(); + }), + options, ); } diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 751d56af5..ea1ac9c3f 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -33,6 +33,7 @@ import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-reco import { resolveManagedGuardianRecipientIds, resolveNotificationDeliveryIdentity, + withManagedGuardianEgressAuthorization, } from "@/lib/notifications/delivery-identity"; /** @@ -298,15 +299,25 @@ export async function dispatchNotification( continue; } - channelsAttempted += 1; - try { - const outcome = await sendToChannel( - channel.type as ChannelType, - channel.config, - delivery.recipientUserId, - recipientPayload, + const outcome = await withManagedGuardianEgressAuthorization( + delivery, + () => + sendToChannel( + channel.type as ChannelType, + channel.config, + delivery.recipientUserId, + recipientPayload, + ), ); + if (outcome === null) { + getEvent()?.addWarning( + "Guardian notification authorization changed before channel egress", + ); + continue; + } + + channelsAttempted += 1; if (outcome.ok) { await recordChannelSuccess({ diff --git a/tests/integration/guardian-notification-fanout.test.ts b/tests/integration/guardian-notification-fanout.test.ts index 22eb3f266..c8b17f563 100644 --- a/tests/integration/guardian-notification-fanout.test.ts +++ b/tests/integration/guardian-notification-fanout.test.ts @@ -33,11 +33,11 @@ vi.mock("@/lib/notifications/senders/ntfy", async () => { eventType: string; title: string; message: string; - }, - ) => { - ntfyEgress.onStart?.(); - if (ntfyEgress.block) await ntfyEgress.block; - const recipientUserId = payload.recipientUserId ?? payload.userId; + }, + ) => { + ntfyEgress.onStart?.(); + if (ntfyEgress.block) await ntfyEgress.block; + const recipientUserId = payload.recipientUserId ?? payload.userId; sent.ntfy.push({ recipientUserId, title: payload.title, @@ -402,10 +402,11 @@ describe("Guardian fan-out (real Postgres)", () => { addGuardian(record.id, reserveGuardian.id), addChannel(recipient.id, "NTFY"), ]); - const recipientGrant = await getPrismaClient().accountGrant.findFirstOrThrow({ - where: { grantorId: record.id, granteeId: recipient.id }, - select: { id: true }, - }); + const recipientGrant = + await getPrismaClient().accountGrant.findFirstOrThrow({ + where: { grantorId: record.id, granteeId: recipient.id }, + select: { id: true }, + }); let releaseEgress!: () => void; let markEgressStarted!: () => void; ntfyEgress.block = new Promise((resolve) => { @@ -446,10 +447,11 @@ describe("Guardian fan-out (real Postgres)", () => { addGuardian(record.id, reserveGuardian.id), addChannel(recipient.id, "NTFY"), ]); - const recipientGrant = await getPrismaClient().accountGrant.findFirstOrThrow({ - where: { grantorId: record.id, granteeId: recipient.id }, - select: { id: true }, - }); + const recipientGrant = + await getPrismaClient().accountGrant.findFirstOrThrow({ + where: { grantorId: record.id, granteeId: recipient.id }, + select: { id: true }, + }); let releaseEgress!: () => void; let markEgressStarted!: () => void; ntfyEgress.block = new Promise((resolve) => { diff --git a/tests/integration/notification-attribution.test.ts b/tests/integration/notification-attribution.test.ts index 75dd61e8d..ba6d50e16 100644 --- a/tests/integration/notification-attribution.test.ts +++ b/tests/integration/notification-attribution.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; -import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity"; +import { + resolveNotificationDeliveryIdentity, + withManagedGuardianEgressAuthorization, +} from "@/lib/notifications/delivery-identity"; import { claimNotificationEvent } from "@/lib/notifications/reminder-dedup"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; @@ -154,6 +157,39 @@ describe("notification attribution (real Postgres)", () => { ); }); + it("refuses an expired Guardian after identity preflight but before provider egress", async () => { + const recordUser = await createUser("expiry-record", true); + const recipientUser = await createUser("expiry-recipient"); + const grant = await createGuardianGrant({ + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + expiresAt: new Date(Date.now() + 60_000), + }); + const delivery = await resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: recordUser.id, + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + title: "record reminder", + message: "record message", + }); + expect(delivery).toMatchObject({ managed: true }); + + await getPrismaClient().accountGrant.update({ + where: { id: grant.id }, + data: { expiresAt: new Date(Date.now() - 1) }, + }); + let sent = false; + + await expect( + withManagedGuardianEgressAuthorization(delivery!, async () => { + sent = true; + return { ok: true }; + }), + ).resolves.toBeNull(); + expect(sent).toBe(false); + }); + it("allows exactly one concurrent claim for a notification event", async () => { const recordUser = await createUser("claim-record", true); const client = getPrismaClient(); From 4b79f83cf7be45f4b773f9259bb8e189eb059ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 13:46:42 +0200 Subject: [PATCH 10/13] fix(02-11): release guardian lock before provider egress - Commit a final authorization claim under the lifecycle lock\n- Keep provider calls and retry outcomes outside database transactions\n- Retain authorization claims with notification delivery diagnostics --- .../migration.sql | 32 ++++ prisma/schema.prisma | 28 +++ .../notification-delivery-identity.test.ts | 86 ++++++++- .../__tests__/notification-retention.test.ts | 9 +- src/lib/jobs/reminder/cleanup-handlers.ts | 19 +- src/lib/notifications/delivery-identity.ts | 137 ++++++++++---- src/lib/notifications/dispatcher.ts | 138 ++++++++++----- .../guardian-notification-fanout.test.ts | 167 +++++++++++++++++- .../notification-attribution.test.ts | 54 +++++- 9 files changed, 560 insertions(+), 110 deletions(-) create mode 100644 prisma/migrations/0301_notification_egress_authorization/migration.sql diff --git a/prisma/migrations/0301_notification_egress_authorization/migration.sql b/prisma/migrations/0301_notification_egress_authorization/migration.sql new file mode 100644 index 000000000..3ff6c1da4 --- /dev/null +++ b/prisma/migrations/0301_notification_egress_authorization/migration.sql @@ -0,0 +1,32 @@ +-- v1.37.0 — a managed Guardian notification receives a durable final +-- authorization claim under the profile lifecycle lock before provider I/O. +-- This records only delivery principals and outcome, never health content or +-- channel credentials. Retention follows the notification attempt cleanup. +CREATE TABLE "notification_egress_authorizations" ( + "id" TEXT NOT NULL, + "record_user_id" TEXT NOT NULL, + "recipient_user_id" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "authorized_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + "outcome" TEXT, + + CONSTRAINT "notification_egress_authorizations_pkey" PRIMARY KEY ("id"), + CONSTRAINT "notification_egress_authorizations_record_user_id_fkey" + FOREIGN KEY ("record_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "notification_egress_authorizations_recipient_user_id_fkey" + FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "notification_egress_authorizations_authorized_at_idx" + ON "notification_egress_authorizations" ("authorized_at"); + +CREATE INDEX "notification_egress_authorizations_record_user_id_recipient_user_id_authorized_at_idx" + ON "notification_egress_authorizations" ( + "record_user_id", + "recipient_user_id", + "authorized_at" DESC + ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a8e052a70..021f06b0d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -863,6 +863,8 @@ model User { pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser") pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser") notificationEvents NotificationEvent[] + notificationEgressAuthorizationsForRecord NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecordUser") + notificationEgressAuthorizationsReceived NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecipientUser") telegramReminderMessages TelegramReminderMessage[] // v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total // TRIMP + running EWMA reference) so the nightly Strain engine anchors @@ -5825,6 +5827,32 @@ model NotificationEvent { @@map("notification_events") } +// A managed Guardian channel send crosses a revocable authorization boundary. +// The dispatcher creates this claim while it holds the managed-profile +// lifecycle lock, then commits before starting provider I/O. That committed +// claim is the linearization point: a revocation that commits first prevents +// the send, while a revocation that commits later does not cancel the one +// already-authorized provider operation. No notification content, channel +// credentials, or event deduplication keys are retained here. Completion is +// observational only; authorization and retry decisions never read it. +model NotificationEgressAuthorization { + id String @id @default(cuid()) + recordUserId String @map("record_user_id") + recipientUserId String @map("recipient_user_id") + channel String + eventType String @map("event_type") + authorizedAt DateTime @default(now()) @map("authorized_at") + completedAt DateTime? @map("completed_at") + outcome String? + + recordUser User @relation("NotificationEgressAuthorizationRecordUser", fields: [recordUserId], references: [id], onDelete: Cascade) + recipientUser User @relation("NotificationEgressAuthorizationRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade) + + @@index([authorizedAt], map: "notification_egress_authorizations_authorized_at_idx") + @@index([recordUserId, recipientUserId, authorizedAt(sort: Desc)]) + @@map("notification_egress_authorizations") +} + // `result` is one of `"ok"` | `"error"` | `"skipped"` — `skipped` // covers the "no recipient" / "config missing" soft cases. // diff --git a/src/__tests__/notification-delivery-identity.test.ts b/src/__tests__/notification-delivery-identity.test.ts index c06892cc3..19698e453 100644 --- a/src/__tests__/notification-delivery-identity.test.ts +++ b/src/__tests__/notification-delivery-identity.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const transactionMock = vi.fn(); +const authorizationClaimQueryMock = vi.fn(); +const authorizationUpdateMock = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { @@ -11,6 +13,9 @@ vi.mock("@/lib/db", () => ({ }, user: { findUnique: vi.fn() }, accountGrant: { findMany: vi.fn() }, + notificationEgressAuthorization: { + update: (...args: unknown[]) => authorizationUpdateMock(...args), + }, auditLog: { create: vi.fn() }, $transaction: (...args: unknown[]) => transactionMock(...args), }, @@ -66,6 +71,8 @@ const recipientUserId = "guardian-recipient"; beforeEach(() => { vi.resetAllMocks(); + authorizationClaimQueryMock.mockResolvedValue([{ id: "authorization" }]); + authorizationUpdateMock.mockResolvedValue({ id: "authorization" }); vi.mocked(prisma.user.findUnique).mockResolvedValue({ managedProfileAt: new Date(), } as never); @@ -85,7 +92,7 @@ beforeEach(() => { transactionMock.mockImplementation( async (operation: (tx: unknown) => Promise) => operation({ - $queryRaw: vi.fn(), + $queryRaw: (...args: unknown[]) => authorizationClaimQueryMock(...args), user: { findUnique: vi.fn(async ({ where }: { where: { id: string } }) => where.id === recordUserId @@ -132,12 +139,79 @@ describe("notification delivery identity", () => { message: "Record schedule", }), ); - // Preflight chooses recipient channels, then the provider call is - // re-authorized under the managed-profile lock for the actual egress. + // Preflight chooses recipient channels, then a short locked transaction + // records the final authorization claim before provider I/O starts. expect(transactionMock).toHaveBeenCalledTimes(2); - expect(transactionMock).toHaveBeenLastCalledWith(expect.any(Function), { - maxWait: 5_000, - timeout: 60_000, + expect(transactionMock).toHaveBeenLastCalledWith(expect.any(Function)); + expect(authorizationClaimQueryMock).toHaveBeenCalledTimes(3); + expect(authorizationUpdateMock).toHaveBeenCalledWith({ + where: { id: "authorization" }, + data: { completedAt: expect.any(Date), outcome: "ok" }, + }); + }); + + it("does not invoke a provider when final Guardian authorization is revoked", async () => { + let transactionCount = 0; + transactionMock.mockImplementation( + async (operation: (tx: unknown) => Promise) => { + transactionCount += 1; + return operation({ + $queryRaw: (...args: unknown[]) => + authorizationClaimQueryMock(...args), + user: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => + where.id === recordUserId + ? { managedProfileAt: new Date() } + : { managedProfileAt: null }, + ), + }, + accountGrant: { + findFirst: vi.fn(async () => + transactionCount === 1 ? { id: "grant" } : null, + ), + }, + }); + }, + ); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + recordUserId, + recipientUserId, + title: "Record content", + message: "Record schedule", + }); + + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + }); + expect(authorizationClaimQueryMock).toHaveBeenCalledTimes(2); + expect(sendViaWebPushMock).not.toHaveBeenCalled(); + }); + + it("records a provider timeout on the claim without retaining the lifecycle lock", async () => { + sendViaWebPushMock.mockRejectedValue(new Error("provider timeout")); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + recordUserId, + recipientUserId, + title: "Record content", + message: "Record schedule", + }); + + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 1, + channelsSucceeded: 0, + }); + expect(authorizationUpdateMock).toHaveBeenCalledWith({ + where: { id: "authorization" }, + data: { completedAt: expect.any(Date), outcome: "sender_threw" }, }); }); diff --git a/src/lib/jobs/reminder/__tests__/notification-retention.test.ts b/src/lib/jobs/reminder/__tests__/notification-retention.test.ts index db4ba1443..2573d6698 100644 --- a/src/lib/jobs/reminder/__tests__/notification-retention.test.ts +++ b/src/lib/jobs/reminder/__tests__/notification-retention.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const h = vi.hoisted(() => ({ pushDeleteMany: vi.fn(), eventDeleteMany: vi.fn(), + authorizationDeleteMany: vi.fn(), meta: {} as Record, })); @@ -11,6 +12,7 @@ vi.mock("../shared", async (importOriginal) => ({ getWorkerPrisma: () => ({ pushAttempt: { deleteMany: h.pushDeleteMany }, notificationEvent: { deleteMany: h.eventDeleteMany }, + notificationEgressAuthorization: { deleteMany: h.authorizationDeleteMany }, }), })); @@ -37,10 +39,11 @@ beforeEach(() => { for (const key of Object.keys(h.meta)) delete h.meta[key]; h.pushDeleteMany.mockResolvedValue({ count: 3 }); h.eventDeleteMany.mockResolvedValue({ count: 5 }); + h.authorizationDeleteMany.mockResolvedValue({ count: 7 }); }); describe("notification retention", () => { - it("prunes record event anchors with the canonical bounded attempt retention", async () => { + it("prunes notification ledgers with the canonical bounded attempt retention", async () => { await handlePushAttemptCleanup([]); expect(h.pushDeleteMany).toHaveBeenCalledWith({ @@ -49,7 +52,11 @@ describe("notification retention", () => { expect(h.eventDeleteMany).toHaveBeenCalledWith({ where: { createdAt: { lt: expect.any(Date) } }, }); + expect(h.authorizationDeleteMany).toHaveBeenCalledWith({ + where: { authorizedAt: { lt: expect.any(Date) } }, + }); expect(h.meta.push_attempt_cleanup_deleted).toBe(3); expect(h.meta.notification_event_cleanup_deleted).toBe(5); + expect(h.meta.notification_egress_authorization_cleanup_deleted).toBe(7); }); }); diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index e5ae5775e..e793bce69 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -127,9 +127,9 @@ export async function handleMoodReminderCleanup( * notification events are claims rather than delivery diagnostics, but after * the same horizon their dedup windows have elapsed and they have no reader. * - * The DELETE is unbounded by user. Both ledgers therefore carry a direct - * `created_at` index, so a `WHERE created_at < cutoff` scan is bounded by the - * size of the trailing edge rather than the live working set. On a + * The DELETE is unbounded by user. The event and attempt ledgers carry direct + * `created_at` indexes, and final egress claims carry `authorized_at`, so each + * trailing-edge scan is bounded by the size of the live working set. On a * one-million-row table with the documented retention * window, the daily prune touches ~11k rows (1M / 90d × 1d) and * completes in milliseconds. @@ -153,9 +153,20 @@ export async function handlePushAttemptCleanup( const deletedEvents = await p.notificationEvent.deleteMany({ where: { createdAt: { lt: cutoff } }, }); - const deleted = deletedAttempts.count + deletedEvents.count; + const deletedAuthorizations = + await p.notificationEgressAuthorization.deleteMany({ + where: { authorizedAt: { lt: cutoff } }, + }); + const deleted = + deletedAttempts.count + + deletedEvents.count + + deletedAuthorizations.count; evt.addMeta("push_attempt_cleanup_deleted", deletedAttempts.count); evt.addMeta("notification_event_cleanup_deleted", deletedEvents.count); + evt.addMeta( + "notification_egress_authorization_cleanup_deleted", + deletedAuthorizations.count, + ); return jobDone({ deleted }); } catch (err) { evt.addWarning(`push-attempt-cleanup failed: ${err}`); diff --git a/src/lib/notifications/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts index 961a49a44..97c9710f0 100644 --- a/src/lib/notifications/delivery-identity.ts +++ b/src/lib/notifications/delivery-identity.ts @@ -1,10 +1,13 @@ +import { randomUUID } from "node:crypto"; + import { prisma } from "@/lib/db"; import { activeGuardianWhere, withManagedProfileLock, } from "@/lib/managed-profiles/lifecycle"; +import type { Prisma } from "@/generated/prisma/client"; import type { - ManagedGuardianFanoutEvent, + ChannelType, NotificationPayload, } from "@/lib/notifications/types"; @@ -82,11 +85,11 @@ export async function resolveManagedGuardianRecipientIds( * own destination. Legacy payloads remain valid only for ordinary self * delivery; a managed record must name an active Guardian explicitly. This is * a preflight for recipient channel selection; managed delivery is authorized - * again under the same lock at each provider egress below. + * again under the same lock at each provider egress claim below. * - * Cross-principal authorization linearizes at the locked grant read below. - * The egress helper keeps the same lock through the actual provider call, so - * a revocation cannot commit between Guardian authorization and delivery. + * Cross-principal authorization preflight uses the locked grant read below. + * The final authorization is a separate short transaction that commits a + * durable egress claim immediately before the provider operation starts. */ export async function resolveNotificationDeliveryIdentity( payload: NotificationPayload, @@ -124,54 +127,112 @@ export async function resolveNotificationDeliveryIdentity( } /** - * Execute a single managed Guardian channel send while holding the lifecycle - * advisory lock. The provider call intentionally remains inside this short - * transaction: managed Guardian revocation and expiry transitions take the - * same lock, so neither can commit after this authorization but before egress. + * Create the final authorization claim for one managed Guardian channel. + * + * The claim commits while holding the same lifecycle lock as Guardian + * revocation and expiry. Its commit is the linearization point: if a + * revocation commits first, this returns null and no provider operation may + * start. If the claim commits first, it authorizes one provider operation + * that is already in flight from the lifecycle's perspective. The provider + * operation runs only after this transaction commits, so slow networks never + * retain a database transaction, advisory lock, or pooled connection. This is + * the logical send time, not a promise that a later revocation or expiry can + * recall an already-authorized provider operation before its first byte. */ -export async function withManagedGuardianEgressAuthorization( +export async function claimManagedGuardianEgressAuthorization( delivery: NotificationDeliveryIdentity, - send: () => Promise, -): Promise { - if (!delivery.managed) return send(); + channel: ChannelType, + eventType: string, +): Promise<{ id: string } | null> { + if (!delivery.managed) return null; return withManagedGuardianAuthorization( delivery.recordUserId, delivery.recipientUserId, - send, - { maxWait: 5_000, timeout: 60_000 }, + async (tx) => { + const id = randomUUID(); + const claims = await tx.$queryRaw>` + INSERT INTO "notification_egress_authorizations" ( + "id", + "record_user_id", + "recipient_user_id", + "channel", + "event_type", + "authorized_at" + ) + SELECT + ${id}, + ${delivery.recordUserId}, + ${delivery.recipientUserId}, + ${channel}, + ${eventType}, + clock_timestamp() + WHERE EXISTS ( + SELECT 1 + FROM "account_grants" + WHERE "grantor_id" = ${delivery.recordUserId} + AND "grantee_id" = ${delivery.recipientUserId} + AND "access" = 'MANAGE'::"account_grant_access" + AND "accepted_at" IS NOT NULL + AND "revoked_at" IS NULL + AND ( + "expires_at" IS NULL + OR "expires_at" > clock_timestamp() + ) + ) + RETURNING "id" + `; + return claims[0] ?? null; + }, ); } +/** + * Record a completed provider operation outside the lifecycle transaction. + * This is observability only: authorization always rechecks the active grant, + * and every retry creates a new claim regardless of a prior outcome. + */ +export async function completeManagedGuardianEgressAuthorization( + authorizationId: string, + outcome: "ok" | "hard_reject" | "transient_failure" | "sender_threw", +): Promise { + try { + await prisma.notificationEgressAuthorization.update({ + where: { id: authorizationId }, + data: { completedAt: new Date(), outcome }, + }); + } catch { + // The claim is intentionally durable even if the best-effort completion + // marker cannot be written after an otherwise independent provider call. + } +} + async function withManagedGuardianAuthorization( recordUserId: string, recipientUserId: string, - operation: () => Promise, - options?: { maxWait: number; timeout: number }, + operation: (tx: Prisma.TransactionClient) => Promise, ): Promise { - return prisma.$transaction( - (tx) => - withManagedProfileLock(tx, recordUserId, async (lockedRecord) => { - if (lockedRecord?.managedProfileAt == null) return null; + return prisma.$transaction((tx) => + withManagedProfileLock(tx, recordUserId, async (lockedRecord) => { + if (lockedRecord?.managedProfileAt == null) return null; - const recipient = await tx.user.findUnique({ - where: { id: recipientUserId }, - select: { managedProfileAt: true }, - }); - if (!recipient || recipient.managedProfileAt != null) return null; + const recipient = await tx.user.findUnique({ + where: { id: recipientUserId }, + select: { managedProfileAt: true }, + }); + if (!recipient || recipient.managedProfileAt != null) return null; - const guardian = await tx.accountGrant.findFirst({ - where: { - grantorId: recordUserId, - granteeId: recipientUserId, - ...activeGuardianWhere(new Date()), - }, - select: { id: true }, - }); - if (!guardian) return null; + const guardian = await tx.accountGrant.findFirst({ + where: { + grantorId: recordUserId, + granteeId: recipientUserId, + ...activeGuardianWhere(new Date()), + }, + select: { id: true }, + }); + if (!guardian) return null; - return operation(); - }), - options, + return operation(tx); + }), ); } diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index ea1ac9c3f..4bc30dff2 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -31,9 +31,10 @@ import { } from "@/lib/notifications/client-managed-apns"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; import { + claimManagedGuardianEgressAuthorization, + completeManagedGuardianEgressAuthorization, resolveManagedGuardianRecipientIds, resolveNotificationDeliveryIdentity, - withManagedGuardianEgressAuthorization, } from "@/lib/notifications/delivery-identity"; /** @@ -300,25 +301,54 @@ export async function dispatchNotification( } try { - const outcome = await withManagedGuardianEgressAuthorization( - delivery, - () => - sendToChannel( - channel.type as ChannelType, - channel.config, - delivery.recipientUserId, - recipientPayload, - ), + const prepared = prepareChannelSend( + channel.type as ChannelType, + channel.config, + delivery.recipientUserId, + recipientPayload, ); - if (outcome === null) { - getEvent()?.addWarning( - "Guardian notification authorization changed before channel egress", - ); - continue; + let outcome: SendOutcome; + if ("outcome" in prepared) { + outcome = prepared.outcome; + channelsAttempted += 1; + } else { + const authorization = delivery.managed + ? await claimManagedGuardianEgressAuthorization( + delivery, + channel.type as ChannelType, + recipientPayload.eventType, + ) + : null; + if (delivery.managed && authorization === null) { + getEvent()?.addWarning( + "Guardian notification authorization changed before channel egress", + ); + continue; + } + + channelsAttempted += 1; + try { + outcome = await prepared.send(); + } catch (err) { + if (authorization) { + await completeManagedGuardianEgressAuthorization( + authorization.id, + "sender_threw", + ); + } + throw err; + } + if (authorization) { + await completeManagedGuardianEgressAuthorization( + authorization.id, + outcome.ok + ? "ok" + : outcome.hardReject + ? "hard_reject" + : "transient_failure", + ); + } } - - channelsAttempted += 1; - if (outcome.ok) { await recordChannelSuccess({ id: channel.id, @@ -387,12 +417,20 @@ export async function dispatchNotification( }; } -async function sendToChannel( +type PreparedChannelSend = + { send: () => Promise } | { outcome: SendOutcome }; + +/** + * Decrypt and validate a recipient-owned channel before the final managed + * Guardian authorization claim. The returned closure is the only code that + * can begin provider I/O, and is invoked immediately after that claim commits. + */ +function prepareChannelSend( type: ChannelType, encryptedConfig: string, recipientUserId: string, payload: NotificationPayload, -): Promise { +): PreparedChannelSend { let decrypted: string; try { decrypted = decrypt(encryptedConfig); @@ -406,9 +444,11 @@ async function sendToChannel( // a key-rotation gap every channel would otherwise auto-disable for the // wrong reason. return { - ok: false, - hardReject: true, - reason: `${type.toLowerCase()}_config_decrypt_failed`, + outcome: { + ok: false, + hardReject: true, + reason: `${type.toLowerCase()}_config_decrypt_failed`, + }, }; } @@ -421,12 +461,14 @@ async function sendToChannel( getEvent()?.addWarning("Failed to parse Telegram channel config"); // Malformed config decodes the same on every retry — permanent. return { - ok: false, - hardReject: true, - reason: "telegram_config_parse_failed", + outcome: { + ok: false, + hardReject: true, + reason: "telegram_config_parse_failed", + }, }; } - return sendViaTelegram(config, payload); + return { send: () => sendViaTelegram(config, payload) }; } case "NTFY": { let config: NtfyChannelConfig; @@ -436,12 +478,14 @@ async function sendToChannel( getEvent()?.addWarning("Failed to parse ntfy channel config"); // Malformed config decodes the same on every retry — permanent. return { - ok: false, - hardReject: true, - reason: "ntfy_config_parse_failed", + outcome: { + ok: false, + hardReject: true, + reason: "ntfy_config_parse_failed", + }, }; } - return sendViaNtfy(config, payload); + return { send: () => sendViaNtfy(config, payload) }; } case "WEBHOOK": { let config: WebhookChannelConfig; @@ -451,12 +495,14 @@ async function sendToChannel( getEvent()?.addWarning("Failed to parse webhook channel config"); // Malformed config decodes the same on every retry — permanent. return { - ok: false, - hardReject: true, - reason: "webhook_config_parse_failed", + outcome: { + ok: false, + hardReject: true, + reason: "webhook_config_parse_failed", + }, }; } - return sendViaWebhook(config, payload); + return { send: () => sendViaWebhook(config, payload) }; } case "EMAIL": { let config: EmailChannelConfig; @@ -466,15 +512,17 @@ async function sendToChannel( getEvent()?.addWarning("Failed to parse email channel config"); // Malformed config decodes the same on every retry — permanent. return { - ok: false, - hardReject: true, - reason: "email_config_parse_failed", + outcome: { + ok: false, + hardReject: true, + reason: "email_config_parse_failed", + }, }; } - return sendViaEmail(config, payload); + return { send: () => sendViaEmail(config, payload) }; } case "WEB_PUSH": { - return sendViaWebPush(recipientUserId, payload); + return { send: () => sendViaWebPush(recipientUserId, payload) }; } case "APNS": { // APNs config lives on the Device row, not in the channel config. @@ -482,14 +530,16 @@ async function sendToChannel( // we just keep the symmetric path so future per-user overrides // (e.g. a custom APNs topic) can hang off the channel without // changing the dispatcher shape. - return sendViaApns(recipientUserId, payload); + return { send: () => sendViaApns(recipientUserId, payload) }; } default: getEvent()?.addWarning(`Unknown notification channel type: ${type}`); return { - ok: false, - hardReject: false, - reason: "unknown_channel_type", + outcome: { + ok: false, + hardReject: false, + reason: "unknown_channel_type", + }, }; } } diff --git a/tests/integration/guardian-notification-fanout.test.ts b/tests/integration/guardian-notification-fanout.test.ts index c8b17f563..efc49c218 100644 --- a/tests/integration/guardian-notification-fanout.test.ts +++ b/tests/integration/guardian-notification-fanout.test.ts @@ -17,6 +17,8 @@ const sent = vi.hoisted(() => ({ const ntfyEgress = vi.hoisted(() => ({ block: null as Promise | null, onStart: null as (() => void) | null, + error: null as Error | null, + outcome: null as { ok: false; reason: string } | null, })); vi.mock("@/lib/notifications/senders/ntfy", async () => { @@ -37,6 +39,8 @@ vi.mock("@/lib/notifications/senders/ntfy", async () => { ) => { ntfyEgress.onStart?.(); if (ntfyEgress.block) await ntfyEgress.block; + if (ntfyEgress.error) throw ntfyEgress.error; + if (ntfyEgress.outcome) return ntfyEgress.outcome; const recipientUserId = payload.recipientUserId ?? payload.userId; sent.ntfy.push({ recipientUserId, @@ -199,6 +203,8 @@ async function addChannel( beforeEach(async () => { ntfyEgress.block = null; ntfyEgress.onStart = null; + ntfyEgress.error = null; + ntfyEgress.outcome = null; sent.deliveries.length = 0; sent.ntfy.length = 0; sent.apns.length = 0; @@ -375,6 +381,22 @@ describe("Guardian fan-out (real Postgres)", () => { expect(attempts).toHaveLength(expected.length); expect(attempts).toEqual(expect.arrayContaining(expected)); }); + const claims = + await getPrismaClient().notificationEgressAuthorization.findMany({ + where: { eventType: "MEDICATION_REMINDER" }, + select: { + channel: true, + recordUserId: true, + recipientUserId: true, + eventType: true, + }, + }); + const expectedClaims = expected.map((claim) => ({ + ...claim, + eventType: "MEDICATION_REMINDER", + })); + expect(claims).toHaveLength(expectedClaims.length); + expect(claims).toEqual(expect.arrayContaining(expectedClaims)); await dispatchNotification({ eventType: "MEDICATION_REMINDER", @@ -391,7 +413,7 @@ describe("Guardian fan-out (real Postgres)", () => { ).resolves.toBe(expected.length); }); - it("keeps a Guardian revocation from committing while its channel egress is in flight", async () => { + it("allows Guardian revocation to commit while a claimed channel egress is in flight", async () => { const [record, recipient, reserveGuardian] = await Promise.all([ createUser({ label: "record", locale: "en", managed: true }), createUser({ label: "recipient", locale: "en" }), @@ -424,19 +446,27 @@ describe("Guardian fan-out (real Postgres)", () => { message: "record message", }); await egressStarted; + await expect( + getPrismaClient().notificationEgressAuthorization.count({ + where: { + recordUserId: record.id, + recipientUserId: recipient.id, + channel: "NTFY", + }, + }), + ).resolves.toBe(1); const revocation = revokeGrant({ grantId: recipientGrant.id, grantorId: record.id, }); - await expectPromiseToRemainPending(revocation); + await expectPromiseToSettle(revocation); releaseEgress(); await delivery; - await revocation; }); - it("keeps a Guardian expiry transition from committing while its channel egress is in flight", async () => { + it("allows Guardian expiry to commit while a claimed channel egress is in flight", async () => { const [record, recipient, reserveGuardian] = await Promise.all([ createUser({ label: "record", locale: "en", managed: true }), createUser({ label: "recipient", locale: "en" }), @@ -469,6 +499,15 @@ describe("Guardian fan-out (real Postgres)", () => { message: "record message", }); await egressStarted; + await expect( + getPrismaClient().notificationEgressAuthorization.count({ + where: { + recordUserId: record.id, + recipientUserId: recipient.id, + channel: "NTFY", + }, + }), + ).resolves.toBe(1); const expiry = getPrismaClient().$transaction((tx) => withManagedProfileLock(tx, record.id, () => @@ -478,21 +517,131 @@ describe("Guardian fan-out (real Postgres)", () => { }), ), ); - await expectPromiseToRemainPending(expiry); + await expectPromiseToSettle(expiry); releaseEgress(); await delivery; - await expiry; + }); + + it("records a timed-out provider after the authorization claim has committed", async () => { + const [record, recipient, reserveGuardian] = await Promise.all([ + createUser({ label: "record", locale: "en", managed: true }), + createUser({ label: "recipient", locale: "en" }), + createUser({ label: "reserve-guardian", locale: "de" }), + ]); + await Promise.all([ + addGuardian(record.id, recipient.id), + addGuardian(record.id, reserveGuardian.id), + addChannel(recipient.id, "NTFY"), + ]); + ntfyEgress.error = new Error("provider timeout"); + + await expect( + dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: record.id, + title: "record reminder", + message: "record message", + }), + ).resolves.toEqual({ + dispatched: false, + channelsAttempted: 1, + channelsSucceeded: 0, + }); + + await expect( + getPrismaClient().notificationEgressAuthorization.findFirstOrThrow({ + where: { + recordUserId: record.id, + recipientUserId: recipient.id, + channel: "NTFY", + }, + select: { eventType: true, outcome: true, completedAt: true }, + }), + ).resolves.toEqual({ + eventType: "MEDICATION_REMINDER", + outcome: "sender_threw", + completedAt: expect.any(Date), + }); + }); + + it("creates a new claim for each retry and stops after Guardian revocation", async () => { + const [record, recipient, reserveGuardian] = await Promise.all([ + createUser({ label: "record", locale: "en", managed: true }), + createUser({ label: "recipient", locale: "en" }), + createUser({ label: "reserve-guardian", locale: "de" }), + ]); + await Promise.all([ + addGuardian(record.id, recipient.id), + addGuardian(record.id, reserveGuardian.id), + addChannel(recipient.id, "NTFY"), + ]); + const grant = await getPrismaClient().accountGrant.findFirstOrThrow({ + where: { grantorId: record.id, granteeId: recipient.id }, + select: { id: true }, + }); + const payload = { + eventType: "MEDICATION_REMINDER" as const, + userId: record.id, + recordUserId: record.id, + recipientUserId: recipient.id, + title: "record reminder", + message: "record message", + }; + + ntfyEgress.error = new Error("provider timeout"); + await dispatchNotification(payload); + await getPrismaClient().notificationChannel.update({ + where: { userId_type: { userId: recipient.id, type: "NTFY" } }, + data: { consecutiveFailures: 0, nextRetryAt: null }, + }); + ntfyEgress.error = null; + ntfyEgress.outcome = { ok: false, reason: "provider_unavailable" }; + await dispatchNotification(payload); + + const beforeRevocation = + await getPrismaClient().notificationEgressAuthorization.findMany({ + where: { + recordUserId: record.id, + recipientUserId: recipient.id, + channel: "NTFY", + }, + select: { id: true, outcome: true }, + orderBy: { authorizedAt: "asc" }, + }); + expect(beforeRevocation).toEqual([ + { id: expect.any(String), outcome: "sender_threw" }, + { id: expect.any(String), outcome: "transient_failure" }, + ]); + expect(beforeRevocation[0]!.id).not.toBe(beforeRevocation[1]!.id); + + await revokeGrant({ grantId: grant.id, grantorId: record.id }); + ntfyEgress.outcome = null; + await expect(dispatchNotification(payload)).resolves.toEqual({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + }); + expect(sent.ntfy).toEqual([]); + await expect( + getPrismaClient().notificationEgressAuthorization.count({ + where: { + recordUserId: record.id, + recipientUserId: recipient.id, + channel: "NTFY", + }, + }), + ).resolves.toBe(2); }); }); -async function expectPromiseToRemainPending(promise: Promise) { +async function expectPromiseToSettle(promise: Promise) { const settled = await Promise.race([ promise.then( () => true, () => true, ), - new Promise((resolve) => setTimeout(() => resolve(false), 25)), + new Promise((resolve) => setTimeout(() => resolve(false), 250)), ]); - expect(settled).toBe(false); + expect(settled).toBe(true); } diff --git a/tests/integration/notification-attribution.test.ts b/tests/integration/notification-attribution.test.ts index ba6d50e16..749eccbf7 100644 --- a/tests/integration/notification-attribution.test.ts +++ b/tests/integration/notification-attribution.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { + claimManagedGuardianEgressAuthorization, resolveNotificationDeliveryIdentity, - withManagedGuardianEgressAuthorization, } from "@/lib/notifications/delivery-identity"; import { claimNotificationEvent } from "@/lib/notifications/reminder-dedup"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; @@ -157,7 +157,42 @@ describe("notification attribution (real Postgres)", () => { ); }); - it("refuses an expired Guardian after identity preflight but before provider egress", async () => { + it("refuses a revoked Guardian after identity preflight but before the final egress claim", async () => { + const recordUser = await createUser("expiry-record", true); + const recipientUser = await createUser("expiry-recipient"); + const grant = await createGuardianGrant({ + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + }); + const delivery = await resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: recordUser.id, + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + title: "record reminder", + message: "record message", + }); + expect(delivery).toMatchObject({ managed: true }); + + await getPrismaClient().accountGrant.update({ + where: { id: grant.id }, + data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + }); + + await expect( + claimManagedGuardianEgressAuthorization( + delivery!, + "NTFY", + "MEDICATION_REMINDER", + ), + ).resolves.toBeNull(); + expect( + await getPrismaClient().notificationEgressAuthorization.count(), + ).toBe(0); + expect(await getPrismaClient().pushAttempt.count()).toBe(0); + }); + + it("refuses an expired Guardian after identity preflight but before the final egress claim", async () => { const recordUser = await createUser("expiry-record", true); const recipientUser = await createUser("expiry-recipient"); const grant = await createGuardianGrant({ @@ -179,15 +214,18 @@ describe("notification attribution (real Postgres)", () => { where: { id: grant.id }, data: { expiresAt: new Date(Date.now() - 1) }, }); - let sent = false; await expect( - withManagedGuardianEgressAuthorization(delivery!, async () => { - sent = true; - return { ok: true }; - }), + claimManagedGuardianEgressAuthorization( + delivery!, + "NTFY", + "MEDICATION_REMINDER", + ), ).resolves.toBeNull(); - expect(sent).toBe(false); + expect( + await getPrismaClient().notificationEgressAuthorization.count(), + ).toBe(0); + expect(await getPrismaClient().pushAttempt.count()).toBe(0); }); it("allows exactly one concurrent claim for a notification event", async () => { From e8ce0e277a5ca1ef8a59de84be70206027a8e623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 13:47:42 +0200 Subject: [PATCH 11/13] test(02-11): verify final claim uses database time - Keep a stale application clock from authorizing an expired Guardian\n --- .../notification-attribution.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/integration/notification-attribution.test.ts b/tests/integration/notification-attribution.test.ts index 749eccbf7..16e2b5437 100644 --- a/tests/integration/notification-attribution.test.ts +++ b/tests/integration/notification-attribution.test.ts @@ -228,6 +228,46 @@ describe("notification attribution (real Postgres)", () => { expect(await getPrismaClient().pushAttempt.count()).toBe(0); }); + it("uses database time for expiry at the final egress claim", async () => { + const recordUser = await createUser("database-clock-record", true); + const recipientUser = await createUser("database-clock-recipient"); + const actualNow = Date.now(); + await createGuardianGrant({ + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + expiresAt: new Date(actualNow - 1_000), + }); + + vi.useFakeTimers(); + vi.setSystemTime(new Date(actualNow - 60_000)); + try { + const delivery = await resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: recordUser.id, + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + title: "record reminder", + message: "record message", + }); + expect(delivery).toMatchObject({ managed: true }); + + await expect( + claimManagedGuardianEgressAuthorization( + delivery!, + "NTFY", + "MEDICATION_REMINDER", + ), + ).resolves.toBeNull(); + } finally { + vi.useRealTimers(); + } + + expect( + await getPrismaClient().notificationEgressAuthorization.count(), + ).toBe(0); + expect(await getPrismaClient().pushAttempt.count()).toBe(0); + }); + it("allows exactly one concurrent claim for a notification event", async () => { const recordUser = await createUser("claim-record", true); const client = getPrismaClient(); From c56d1ca50b76b44f2a1aa420db4a34c4b9fb58b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 14:05:59 +0200 Subject: [PATCH 12/13] fix(02-11): protect guardian egress data lifecycle - Exclude and erase egress claims with recipient-side lookup support\n- Serialize data wipes with managed-profile Guardian authorization\n- Cover last-Guardian and queued-claim wipe races --- .../migration.sql | 9 + prisma/schema.prisma | 1 + .../backup-plan-classification.test.ts | 9 + src/__tests__/data-wipe-completeness.test.ts | 6 + src/app/api/settings/data/route.ts | 60 ++++-- src/lib/data-wipe/wipe-plan.ts | 5 + src/lib/export/backup-plan.ts | 2 + src/lib/managed-profiles/lifecycle.ts | 62 ++++++ tests/integration/settings-data-wipe.test.ts | 203 ++++++++++++++++++ 9 files changed, 338 insertions(+), 19 deletions(-) diff --git a/prisma/migrations/0301_notification_egress_authorization/migration.sql b/prisma/migrations/0301_notification_egress_authorization/migration.sql index 3ff6c1da4..fb76cc9c5 100644 --- a/prisma/migrations/0301_notification_egress_authorization/migration.sql +++ b/prisma/migrations/0301_notification_egress_authorization/migration.sql @@ -30,3 +30,12 @@ CREATE INDEX "notification_egress_authorizations_record_user_id_recipient_user_i "recipient_user_id", "authorized_at" DESC ); + +-- A recipient can erase their account without owning the underlying record. +-- PostgreSQL needs a recipient-first access path for that FK cascade and for +-- the explicit Delete All Data predicate over recipient_user_id. +CREATE INDEX "notification_egress_auth_recipient_authorized_at_idx" + ON "notification_egress_authorizations" ( + "recipient_user_id", + "authorized_at" DESC + ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 021f06b0d..30290011e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5850,6 +5850,7 @@ model NotificationEgressAuthorization { @@index([authorizedAt], map: "notification_egress_authorizations_authorized_at_idx") @@index([recordUserId, recipientUserId, authorizedAt(sort: Desc)]) + @@index([recipientUserId, authorizedAt(sort: Desc)], map: "notification_egress_auth_recipient_authorized_at_idx") @@map("notification_egress_authorizations") } diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index f02ba8651..00811393c 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -146,6 +146,15 @@ describe("every schema model has a backup verdict", () => { "a one-word reason is how a decision stops being reviewable", ).toEqual([]); }); + + it("excludes Guardian egress claims rather than exporting delivery metadata", () => { + expect(backupVerdict("NotificationEgressAuthorization")).toBe( + "NOT_IN_BACKUP", + ); + expect(NOT_IN_BACKUP_MODELS.NotificationEgressAuthorization).toMatch( + /no notification content, credentials, or deduplication key/i, + ); + }); }); /** diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts index 55451e619..745992427 100644 --- a/src/__tests__/data-wipe-completeness.test.ts +++ b/src/__tests__/data-wipe-completeness.test.ts @@ -341,4 +341,10 @@ describe("data-wipe completeness", () => { }); expect(wipeWhere("Measurement", "u1")).toEqual({ userId: "u1" }); }); + + it("selects both record and recipient ownership of a Guardian egress claim", () => { + expect(wipeWhere("NotificationEgressAuthorization", "u1")).toEqual({ + OR: [{ recordUserId: "u1" }, { recipientUserId: "u1" }], + }); + }); }); diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index 482fefb4a..eac7a533f 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -9,6 +9,10 @@ import { } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { invalidateUserData } from "@/lib/cache/invalidate"; +import { + LastManagedGuardianError, + protectManagedProfilesDuringDataWipe, +} from "@/lib/managed-profiles/lifecycle"; import { resolveWipeDelegate, USER_RESET, @@ -66,27 +70,45 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const userId = user.id; - const counts = await prisma.$transaction( - async (tx) => { - const perModel: Record = {}; - for (const model of WIPE_MODELS) { - const { count } = await resolveWipeDelegate(tx, model).deleteMany({ - where: { userId }, - }); - if (count > 0) perModel[wipeDelegateKey(model)] = count; - } + let counts: Record; + try { + counts = await prisma.$transaction( + async (tx) => { + // This precedes every data delete and holds the managed-record locks + // through AccountGrant removal. A queued Guardian egress claim thus + // rechecks after this wipe commits instead of sending from a grant + // this transaction has already deleted. + await protectManagedProfilesDuringDataWipe(tx, userId); - // The account row survives; the personal data carried on its own columns - // does not. Same classification contract as the model list. - await tx.user.update({ where: { id: userId }, data: USER_RESET }); + const perModel: Record = {}; + for (const model of WIPE_MODELS) { + const { count } = await resolveWipeDelegate(tx, model).deleteMany({ + where: { userId }, + }); + if (count > 0) perModel[wipeDelegateKey(model)] = count; + } - return perModel; - }, - { - timeout: WIPE_TRANSACTION_TIMEOUT_MS, - maxWait: WIPE_TRANSACTION_MAX_WAIT_MS, - }, - ); + // The account row survives; the personal data carried on its own columns + // does not. Same classification contract as the model list. + await tx.user.update({ where: { id: userId }, data: USER_RESET }); + + return perModel; + }, + { + timeout: WIPE_TRANSACTION_TIMEOUT_MS, + maxWait: WIPE_TRANSACTION_MAX_WAIT_MS, + }, + ); + } catch (error) { + if (error instanceof LastManagedGuardianError) { + return apiError( + "Add another Guardian or delete the managed profile first", + 409, + { errorCode: "managed_profile.guardian.required" }, + ); + } + throw error; + } const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index ae0ddc698..b5f14fcd2 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -162,6 +162,10 @@ export const WIPE_MODELS = [ "PushSubscription", "PushAttempt", "NotificationEvent", + // A Guardian egress claim belongs to both the record whose data prompted + // the notification and the recipient whose channel would receive it. A + // wipe by either account must remove it; see WIPE_OWNER_FIELDS below. + "NotificationEgressAuthorization", "Device", "TelegramScheduledDeletion", "TelegramPromptContext", @@ -423,6 +427,7 @@ export const WIPE_OWNER_FIELDS: Readonly> = { // import history. MedicationIntakeImportJob: ["recordUserId"], NotificationEvent: ["recordUserId"], + NotificationEgressAuthorization: ["recordUserId", "recipientUserId"], // Both sides. Wiping only the grantor side would leave the account still // holding read access to other people's records after it asked for // everything of its own to be deleted. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index ef5dee16d..3a58e8206 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -408,6 +408,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { "A 90-day delivery ledger for this host's sends. Restoring it onto another host would assert sends it never made.", NotificationEvent: "A local notification dedup anchor. Restoring it onto another host would suppress reminders and safety notices that host still owes.", + NotificationEgressAuthorization: + "A short-lived, deployment-local Guardian egress ledger. It retains no notification content, credentials, or deduplication key, and its outcome is observation rather than authorization; restoring it would nevertheless carry stale send history to a host that must recheck the live grant before every send. Exclude it so a backup never exports notification-derived privacy metadata or makes a restored host reason from another host's delivery work.", AuditLog: "An append-only record of actions taken on this instance. Merging one instance's audit trail into another's would make the trail untrue.", CoachUsage: diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 8bb929660..6865d3f52 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -121,6 +121,68 @@ export async function deleteGuardianAccountWithLifecycle( }); } +/** + * Hold every managed-record lock affected by a data wipe, then prove that the + * wipe will not leave a managed profile with no active Guardian. The caller + * keeps the transaction open through the AccountGrant deletion, so a final + * notification egress claim observes either the live grants before the wipe + * or the committed deletion afterwards; it cannot authorize in between. + * + * A data wipe keeps the account and its managed-profile marker. It therefore + * refuses rather than silently turning the last Guardian removal into an + * unmanaged profile. Deleting the managed profile remains the explicit + * product action for that case. + */ +export async function protectManagedProfilesDuringDataWipe( + tx: Transaction, + userId: string, +): Promise { + const affectedProfiles = await tx.accountGrant.findMany({ + where: { + OR: [{ grantorId: userId }, { granteeId: userId }], + grantor: { managedProfileAt: { not: null } }, + }, + select: { grantorId: true }, + distinct: ["grantorId"], + orderBy: { grantorId: "asc" }, + }); + + // Acquire every key before reading the final state. Sorting is required + // because a wipe can affect more than one managed profile. + for (const { grantorId } of affectedProfiles) { + await withManagedProfileLock(tx, grantorId, async () => undefined); + } + + // Re-read after all locks are held. A concurrent acceptance, revocation, + // expiry reduction, or notification claim uses the same key and is now + // ordered entirely before or after this transaction. + const now = new Date(); + for (const { grantorId } of affectedProfiles) { + await withManagedProfileLock(tx, grantorId, async (profile) => { + if (!profile?.managedProfileAt) return; + + const [activeGuardians, guardiansRemovedByWipe] = await Promise.all([ + tx.accountGrant.count({ + where: { grantorId: profile.id, ...activeGuardianWhere(now) }, + }), + tx.accountGrant.count({ + where: { + AND: [ + { grantorId: profile.id }, + { OR: [{ grantorId: userId }, { granteeId: userId }] }, + activeGuardianWhere(now), + ], + }, + }), + ]); + + if (activeGuardians <= guardiansRemovedByWipe) { + throw new LastManagedGuardianError(); + } + }); + } +} + export async function deleteManagedProfile(input: { profileId: string; guardianId: string; diff --git a/tests/integration/settings-data-wipe.test.ts b/tests/integration/settings-data-wipe.test.ts index 0d572d012..60449a68d 100644 --- a/tests/integration/settings-data-wipe.test.ts +++ b/tests/integration/settings-data-wipe.test.ts @@ -41,6 +41,10 @@ import { WIPE_OWNER_FIELDS, } from "@/lib/data-wipe/wipe-plan"; import type { PrismaClient } from "@/generated/prisma/client"; +import { + claimManagedGuardianEgressAuthorization, + resolveNotificationDeliveryIdentity, +} from "@/lib/notifications/delivery-identity"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -309,6 +313,18 @@ async function countRows( return Number(rows[0].n); } +async function managedProfileLockIsHeld( + prisma: PrismaClient, + profileId: string, +): Promise { + const rows = await prisma.$queryRaw>` + SELECT NOT pg_try_advisory_xact_lock( + hashtextextended(${`managed-profile:${profileId}`}, 0) + ) AS locked + `; + return rows[0]?.locked === true; +} + describe("DELETE /api/settings/data leaves nothing it promised to delete", () => { beforeEach(async () => { await truncateAllTables(getPrismaClient()); @@ -467,6 +483,182 @@ describe("DELETE /api/settings/data leaves nothing it promised to delete", () => ).toEqual([]); }); + it("refuses a wipe that would remove a managed profile's last Guardian", async () => { + const prisma = getPrismaClient(); + const [guardian, profile] = await Promise.all([ + prisma.user.create({ + data: { + username: "wipe-last-guardian", + email: "wipe-last-guardian@example.test", + }, + }), + prisma.user.create({ + data: { + username: "wipe-last-profile", + email: "wipe-last-profile@example.test", + managedProfileAt: new Date(), + }, + }), + ]); + await prisma.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId: guardian.id, + access: "MANAGE", + acceptedAt: new Date(), + }, + }); + await prisma.measurement.create({ + data: { + userId: guardian.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { userId: guardian.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const { DELETE } = await import("@/app/api/settings/data/route"); + const response = await DELETE( + new Request("http://localhost/api/settings/data", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE" }), + }) as never, + ); + + expect(response.status).toBe(409); + expect( + await prisma.accountGrant.count({ + where: { grantorId: profile.id, granteeId: guardian.id }, + }), + ).toBe(1); + expect( + await prisma.measurement.count({ where: { userId: guardian.id } }), + ).toBe(1); + }); + + it("makes a queued Guardian claim observe a completed data wipe", async () => { + const prisma = getPrismaClient(); + const [guardian, reserveGuardian, profile] = await Promise.all([ + prisma.user.create({ + data: { + username: "wipe-race-guardian", + email: "wipe-race-guardian@example.test", + }, + }), + prisma.user.create({ + data: { + username: "wipe-race-reserve", + email: "wipe-race-reserve@example.test", + }, + }), + prisma.user.create({ + data: { + username: "wipe-race-profile", + email: "wipe-race-profile@example.test", + managedProfileAt: new Date(), + }, + }), + ]); + await Promise.all([ + prisma.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId: guardian.id, + access: "MANAGE", + acceptedAt: new Date(), + }, + }), + prisma.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId: reserveGuardian.id, + access: "MANAGE", + acceptedAt: new Date(), + }, + }), + ]); + expect( + await prisma.accountGrant.count({ + where: { + grantorId: profile.id, + access: "MANAGE", + acceptedAt: { not: null }, + revokedAt: null, + }, + }), + ).toBe(2); + const measurement = await prisma.measurement.create({ + data: { + userId: guardian.id, + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { userId: guardian.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + const delivery = await resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: profile.id, + recordUserId: profile.id, + recipientUserId: guardian.id, + title: "record reminder", + message: "record message", + }); + expect(delivery).toMatchObject({ managed: true }); + + let releaseMeasurement!: () => void; + let markMeasurementLocked!: () => void; + const measurementLocked = new Promise((resolve) => { + markMeasurementLocked = resolve; + }); + const holdMeasurement = new Promise((resolve) => { + releaseMeasurement = resolve; + }); + const blocker = prisma.$transaction(async (tx) => { + await tx.measurement.update({ + where: { id: measurement.id }, + data: { value: 81 }, + }); + markMeasurementLocked(); + await holdMeasurement; + }); + await measurementLocked; + + const { DELETE } = await import("@/app/api/settings/data/route"); + const wipe = DELETE( + new Request("http://localhost/api/settings/data", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE" }), + }) as never, + ); + await vi.waitFor(async () => { + expect(await managedProfileLockIsHeld(prisma, profile.id)).toBe(true); + }); + + const claim = claimManagedGuardianEgressAuthorization( + delivery!, + "NTFY", + "MEDICATION_REMINDER", + ); + await expectPromiseToRemainPending(claim); + + releaseMeasurement(); + await blocker; + expect((await wipe).status).toBe(200); + await expect(claim).resolves.toBeNull(); + expect(await prisma.notificationEgressAuthorization.count()).toBe(0); + expect(await prisma.pushAttempt.count()).toBe(0); + }); + it("refuses without the typed confirmation and leaves the record alone", async () => { const prisma = getPrismaClient(); @@ -542,3 +734,14 @@ describe("DELETE /api/settings/data leaves nothing it promised to delete", () => expect(await resolveShareToken(rawToken)).toBeNull(); }); }); + +async function expectPromiseToRemainPending(promise: Promise) { + const settled = await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolve) => setTimeout(() => resolve(false), 25)), + ]); + expect(settled).toBe(false); +} From 2ae3a668162fde674e1ebada22f9d30e26f0f7cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 14:10:28 +0200 Subject: [PATCH 13/13] fix(02-11): bound APNs provider delivery - Time out stalled provider sends without unhandled late rejections\n- Record a transient timeout outcome for cascade fallback\n- Cover timeout fallback with unit and integration tests --- .../__tests__/dispatcher.test.ts | 36 +++++++- .../senders/__tests__/apns.test.ts | 20 +++++ src/lib/notifications/senders/apns.ts | 87 +++++++++++++++++-- tests/integration/apns-dispatch.test.ts | 36 ++++++++ 4 files changed, 170 insertions(+), 9 deletions(-) diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index 3e2deb809..f7ac2fa05 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -44,6 +44,7 @@ vi.mock("@/lib/logging/context", () => ({ const sendViaTelegramMock = vi.fn(); const sendViaNtfyMock = vi.fn(); const sendViaWebPushMock = vi.fn(); +const sendViaApnsMock = vi.fn(); vi.mock("@/lib/notifications/senders/telegram", () => ({ sendViaTelegram: (...args: unknown[]) => sendViaTelegramMock(...args), @@ -54,6 +55,9 @@ vi.mock("@/lib/notifications/senders/ntfy", () => ({ vi.mock("@/lib/notifications/senders/web-push", () => ({ sendViaWebPush: (...args: unknown[]) => sendViaWebPushMock(...args), })); +vi.mock("@/lib/notifications/senders/apns", () => ({ + sendViaApns: (...args: unknown[]) => sendViaApnsMock(...args), +})); import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { prisma } from "@/lib/db"; @@ -62,7 +66,7 @@ import { auditLog } from "@/lib/auth/audit"; type MockChannel = { id: string; userId: string; - type: "TELEGRAM" | "NTFY" | "WEB_PUSH"; + type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS"; enabled: boolean; config: string; consecutiveFailures: number; @@ -203,6 +207,36 @@ describe("dispatchNotification — soft reject (web-push 429)", () => { }); }); +describe("dispatchNotification — bounded APNs fallback", () => { + it("continues to the next channel after an APNs timeout", async () => { + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + makeChannel({ id: "apns", type: "APNS" }), + makeChannel({ id: "ntfy", type: "NTFY" }), + ] as never); + sendViaApnsMock.mockResolvedValueOnce({ + ok: false, + hardReject: false, + reason: "apns_timeout", + }); + sendViaNtfyMock.mockResolvedValueOnce({ ok: true }); + + const result = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: "u-1", + title: "t", + message: "m", + }); + + expect(sendViaApnsMock).toHaveBeenCalledTimes(1); + expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + dispatched: true, + channelsAttempted: 2, + channelsSucceeded: 1, + }); + }); +}); + describe("dispatchNotification — give-up after 5 transient failures", () => { it("auto-disables on the 5th in-a-row failure", async () => { const channel = makeChannel({ type: "NTFY", consecutiveFailures: 4 }); diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index c0cdd32f2..2294c3790 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -128,6 +128,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); setEnv(); resetApnsForTesting(); }); @@ -367,6 +368,25 @@ describe("sendApnsPush — failure classification", () => { expect(r.reason).toBe("apns_network_error"); expect(r.shouldDisable).toBeFalsy(); }); + + it("bounds a provider send that never settles", async () => { + vi.useFakeTimers(); + sendMock.mockImplementationOnce(() => new Promise(() => undefined)); + + const result = sendApnsPush({ + deviceToken: "abc", + environment: "sandbox", + payload: { alert: { title: "t", body: "b" } }, + }); + + await vi.advanceTimersByTimeAsync(5_000); + + await expect(result).resolves.toMatchObject({ + ok: false, + reason: "apns_timeout", + shouldDisable: false, + }); + }); }); describe("sendApnsPush — config disabled paths", () => { diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index cfc6cccfb..83bbccc71 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -146,6 +146,53 @@ const PERMANENT_APNS_REASONS = new Set([ */ const APNS_COLLAPSE_ID_MAX_BYTES = 64; +/** + * node-apn owns an HTTP/2 connection pool but exposes no AbortSignal on + * Provider.send(). Bound the promise at this boundary so a stuck Apple + * connection cannot prevent the dispatcher's later fallback channels. + */ +const APNS_PROVIDER_SEND_TIMEOUT_MS = 5_000; + +class ApnsProviderTimeoutError extends Error { + constructor() { + super( + `APNs provider send timed out after ${APNS_PROVIDER_SEND_TIMEOUT_MS}ms`, + ); + this.name = "ApnsProviderTimeoutError"; + } +} + +/** + * Observe both branches of the provider promise even after the timeout wins. + * That prevents a late rejection from becoming an unhandled rejection while + * retaining a hard upper bound for the dispatcher. + */ +function awaitBoundedApnsProviderSend(operation: Promise): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + reject(new ApnsProviderTimeoutError()); + }, APNS_PROVIDER_SEND_TIMEOUT_MS); + + operation.then( + (result) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(result); + }, + (error: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + let cachedConfig: ApnsConfig | null | undefined; const providers = new Map<"sandbox" | "production", apn.Provider>(); @@ -467,16 +514,28 @@ export async function sendApnsPush( const start = performance.now(); let result: Awaited>; try { - result = await provider.send(note, input.deviceToken); + result = await awaitBoundedApnsProviderSend( + provider.send(note, input.deviceToken), + ); } catch (err) { - const message = err instanceof Error ? err.message : "send_failed"; + const timedOut = err instanceof ApnsProviderTimeoutError; + const message = timedOut + ? `APNs provider send timed out after ${APNS_PROVIDER_SEND_TIMEOUT_MS}ms` + : err instanceof Error + ? err.message + : "send_failed"; getEvent()?.addExternalCall({ service: "apns", method: "send", duration_ms: Math.round(performance.now() - start), - error: message, + error: timedOut ? "timeout" : message, }); - return { ok: false, reason: "apns_network_error", message }; + return { + ok: false, + reason: timedOut ? "apns_timeout" : "apns_network_error", + shouldDisable: false, + message, + }; } const duration = Math.round(performance.now() - start); @@ -864,16 +923,28 @@ export async function sendApnsRawPush( const start = performance.now(); let result: Awaited>; try { - result = await provider.send(note, input.deviceToken); + result = await awaitBoundedApnsProviderSend( + provider.send(note, input.deviceToken), + ); } catch (err) { - const message = err instanceof Error ? err.message : "send_failed"; + const timedOut = err instanceof ApnsProviderTimeoutError; + const message = timedOut + ? `APNs provider send timed out after ${APNS_PROVIDER_SEND_TIMEOUT_MS}ms` + : err instanceof Error + ? err.message + : "send_failed"; getEvent()?.addExternalCall({ service: "apns", method: `send_${input.pushType}`, duration_ms: Math.round(performance.now() - start), - error: message, + error: timedOut ? "timeout" : message, }); - return { ok: false, reason: "apns_network_error", message }; + return { + ok: false, + reason: timedOut ? "apns_timeout" : "apns_network_error", + shouldDisable: false, + message, + }; } const duration = Math.round(performance.now() - start); diff --git a/tests/integration/apns-dispatch.test.ts b/tests/integration/apns-dispatch.test.ts index c76cf5811..03b4fe0bb 100644 --- a/tests/integration/apns-dispatch.test.ts +++ b/tests/integration/apns-dispatch.test.ts @@ -235,6 +235,42 @@ describe("APNs dispatcher integration", () => { expect(audit).toBeTruthy(); }); + it("bounds a hung APNs provider and falls through to Telegram", async () => { + const { encrypt } = await import("@/lib/crypto"); + await seedApnsChannel(); + await seedDevice({ apnsToken: "hung-apns-token" }); + await getPrismaClient().notificationChannel.create({ + data: { + userId: TEST_USER_ID, + type: "TELEGRAM", + enabled: true, + config: encrypt( + JSON.stringify({ botToken: "telegram-token", chatId: "chat-id" }), + ), + }, + }); + + apnsSendMock.mockImplementationOnce(() => new Promise(() => undefined)); + telegramSendMock.mockResolvedValueOnce({ ok: true, messageId: 42 }); + + const { dispatchNotification } = + await import("@/lib/notifications/dispatcher"); + const result = await dispatchNotification({ + userId: TEST_USER_ID, + eventType: "MEDICATION_REMINDER", + title: "fallback", + message: "fallback", + }); + + expect(apnsSendMock).toHaveBeenCalledTimes(1); + expect(telegramSendMock).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + dispatched: true, + channelsAttempted: 2, + channelsSucceeded: 1, + }); + }, 7_000); + it("falls through to Telegram when APNS is disabled", async () => { const { encrypt } = await import("@/lib/crypto"); // Pre-existing disabled APNS channel from a previous failure run.