From 482ea18d268ba671e56f289729aa6c7ab6b0100e Mon Sep 17 00:00:00 2001 From: gbolly122 <306819275+gbolly122@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:22:52 +0100 Subject: [PATCH] feat(realtime): add Web Push claim notification coordinator (#299) --- src/lib/realtime/webPushClaims.test.ts | 80 +++++++++ src/lib/realtime/webPushClaims.ts | 217 +++++++++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 src/lib/realtime/webPushClaims.test.ts create mode 100644 src/lib/realtime/webPushClaims.ts diff --git a/src/lib/realtime/webPushClaims.test.ts b/src/lib/realtime/webPushClaims.test.ts new file mode 100644 index 0000000..fc769b1 --- /dev/null +++ b/src/lib/realtime/webPushClaims.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { WebPushClaimNotificationCoordinator } from './webPushClaims' + +const SAMPLE_VAPID_KEY = + 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtHQ3O4w7s9w4fH1m2P8s0k9L3vR6t5Y8u1I4o7P0a2S5d8F1g' + +describe('WebPushClaimNotificationCoordinator', () => { + it('never prompts on page_load or when opted-out/denied, and prompts on post_purchase', async () => { + let promptCalls = 0 + const coord = new WebPushClaimNotificationCoordinator({ + vapidPublicKey: SAMPLE_VAPID_KEY, + browserSupported: true, + requestBrowserPermission: async () => { + promptCalls += 1 + return 'granted' + }, + }) + + // Never prompt on page_load + expect(await coord.requestPermission('page_load')).toEqual({ + prompted: false, + status: 'default', + }) + expect(promptCalls).toBe(0) + + // Respect user opt-out + coord.setUserOptOut(true) + expect(await coord.requestPermission('post_purchase')).toEqual({ + prompted: false, + status: 'default', + }) + + // Opt back in and trigger post_purchase -> prompts once + coord.setUserOptOut(false) + expect(await coord.requestPermission('post_purchase')).toEqual({ + prompted: true, + status: 'granted', + }) + expect(promptCalls).toBe(1) + }) + + it('suppresses duplicate push when claimed by an open tab and strips sensitive fields on closed-tab push', () => { + let tabOpen = true + const coord = new WebPushClaimNotificationCoordinator({ + vapidPublicKey: SAMPLE_VAPID_KEY, + initialPermission: 'granted', + hasOpenVisibleTab: () => tabOpen, + }) + + // 1. Open tab claims event 'evt-claim-77' -> push is suppressed + coord.claimEventInOpenTab('evt-claim-77') + tabOpen = false + expect( + coord.evaluateIncomingPush({ + eventId: 'evt-claim-77', + eventType: 'claim_settled', + claimId: 'claim-77', + }).shouldDisplay, + ).toBe(false) + + // 2. Closed tab receives unclaimed event 'evt-claim-88' -> displays sanitized notification with deep link + const closedTabPush = coord.evaluateIncomingPush({ + eventId: 'evt-claim-88', + eventType: 'claim_settled', + claimId: 'claim-88', + walletAddress: 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H', + payoutAmountUsd: 125_000, + }) + + expect(closedTabPush.shouldDisplay).toBe(true) + expect(closedTabPush.notification?.deepLinkUrl).toBe( + '/claims?claimId=claim-88', + ) + const serialized = JSON.stringify(closedTabPush.notification) + expect(serialized).not.toContain( + 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H', + ) + expect(serialized).not.toContain('125000') + }) +}) diff --git a/src/lib/realtime/webPushClaims.ts b/src/lib/realtime/webPushClaims.ts new file mode 100644 index 0000000..2e235a0 --- /dev/null +++ b/src/lib/realtime/webPushClaims.ts @@ -0,0 +1,217 @@ +export type PushPromptTrigger = + | 'page_load' + | 'post_purchase' + | 'notification_settings' + +export type PushPermissionStatus = + | 'default' + | 'granted' + | 'denied' + | 'unsupported' + +export interface RawClaimPushPayload { + eventId: string + eventType: 'claim_settled' | 'claim_approved' | 'claim_denied' | 'oracle_triggered' + claimId?: string + policyId?: string + riskClass?: string + walletAddress?: string + payoutAmountUsd?: number +} + +export interface SanitizedWebPushNotification { + eventId: string + title: string + body: string + deepLinkUrl: string + tag: string +} + +export interface WebPushCoordinatorOptions { + vapidPublicKey?: string + browserSupported?: boolean + initialPermission?: PushPermissionStatus + initialOptOut?: boolean + requestBrowserPermission?: () => Promise<'granted' | 'denied' | 'default'> + hasOpenVisibleTab?: () => boolean + maxClaimedEventIds?: number +} + +const VAPID_BASE64URL_PATTERN = /^[A-Za-z0-9_-]{40,120}$/ + +export function validateVapidPublicKey(key?: string): boolean { + if (!key) return false + return VAPID_BASE64URL_PATTERN.test(key.trim()) +} + +/** + * Sanitizes an untrusted incoming Web Push payload into a lock-screen-safe + * notification summary with a deep-link route, stripping wallet addresses + * and exact financial amounts. + */ +export function buildLockScreenSafePushNotification( + raw: unknown, +): SanitizedWebPushNotification | null { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return null + } + const obj = raw as Partial + if (typeof obj.eventId !== 'string' || !obj.eventId.trim()) { + return null + } + + const eventId = obj.eventId.trim() + const safeClaimId = + typeof obj.claimId === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(obj.claimId) + ? obj.claimId + : null + + const deepLinkUrl = safeClaimId + ? `/claims?claimId=${encodeURIComponent(safeClaimId)}` + : '/claims' + + let title = 'Refract Claim Update' + let body = 'A critical update occurred on your coverage policy. Tap to view details.' + + if (obj.eventType === 'claim_settled') { + title = 'Claim Payout Settled' + body = 'Your coverage payout has been settled on Stellar. Tap to view your claim.' + } else if (obj.eventType === 'claim_approved') { + title = 'Claim Approved' + body = 'Your coverage claim was approved and is queued for settlement.' + } else if (obj.eventType === 'claim_denied') { + title = 'Claim Status Updated' + body = 'A decision has been recorded for your coverage claim.' + } else if (obj.eventType === 'oracle_triggered') { + title = 'Parametric Oracle Triggered' + body = 'An oracle trigger event was detected for a covered risk class.' + } + + return { + eventId, + title, + body, + deepLinkUrl, + tag: `refract-claim:${eventId}`, + } +} + +/** + * Coordinates Web Push permission prompting, user opt-out persistence, + * and cross-channel `eventId` claiming so open tabs never double-notify. + */ +export class WebPushClaimNotificationCoordinator { + private readonly vapidPublicKey?: string + private readonly browserSupported: boolean + private readonly requestBrowserPermission: () => Promise< + 'granted' | 'denied' | 'default' + > + private readonly hasOpenVisibleTab: () => boolean + private readonly maxClaimedIds: number + + private permission: PushPermissionStatus + private userOptOut: boolean + private readonly claimedEventIds = new Set() + private readonly claimedOrder: string[] = [] + + constructor(options: WebPushCoordinatorOptions = {}) { + this.vapidPublicKey = options.vapidPublicKey + this.browserSupported = options.browserSupported ?? true + this.permission = this.browserSupported + ? (options.initialPermission ?? 'default') + : 'unsupported' + this.userOptOut = Boolean(options.initialOptOut) + this.requestBrowserPermission = + options.requestBrowserPermission ?? (async () => 'granted') + this.hasOpenVisibleTab = options.hasOpenVisibleTab ?? (() => false) + this.maxClaimedIds = options.maxClaimedEventIds ?? 250 + } + + public isToggleVisible(): boolean { + return ( + this.browserSupported && + validateVapidPublicKey(this.vapidPublicKey) + ) + } + + public getPermissionStatus(): PushPermissionStatus { + return this.permission + } + + public isOptedOut(): boolean { + return this.userOptOut + } + + public setUserOptOut(optOut: boolean): void { + this.userOptOut = optOut + } + + /** + * Requests browser push permission ONLY on user-initiated triggers (`post_purchase` + * or `notification_settings`), never on `page_load`, and never when opted out or denied. + */ + public async requestPermission( + trigger: PushPromptTrigger, + ): Promise<{ prompted: boolean; status: PushPermissionStatus }> { + if ( + trigger === 'page_load' || + !this.isToggleVisible() || + this.userOptOut || + this.permission === 'denied' || + this.permission === 'granted' + ) { + return { prompted: false, status: this.permission } + } + + const outcome = await this.requestBrowserPermission() + this.permission = outcome + return { prompted: true, status: this.permission } + } + + /** + * Marks an `eventId` as already presented by an open in-app realtime tab + * so the Service Worker Web Push path suppresses duplicate notifications. + */ + public claimEventInOpenTab(eventId: string): void { + const clean = eventId.trim() + if (!clean || this.claimedEventIds.has(clean)) return + this.claimedEventIds.add(clean) + this.claimedOrder.push(clean) + while (this.claimedOrder.length > this.maxClaimedIds) { + const oldest = this.claimedOrder.shift() + if (oldest !== undefined) { + this.claimedEventIds.delete(oldest) + } + } + } + + /** + * Evaluates an incoming Service Worker push event and returns a sanitized + * notification descriptor only when no open tab has claimed the event. + */ + public evaluateIncomingPush( + rawPayload: unknown, + ): { shouldDisplay: boolean; notification: SanitizedWebPushNotification | null } { + if (this.userOptOut || this.permission !== 'granted') { + return { shouldDisplay: false, notification: null } + } + + const sanitized = buildLockScreenSafePushNotification(rawPayload) + if (!sanitized) { + return { shouldDisplay: false, notification: null } + } + + if ( + this.claimedEventIds.has(sanitized.eventId) || + this.hasOpenVisibleTab() + ) { + return { shouldDisplay: false, notification: null } + } + + this.claimEventInOpenTab(sanitized.eventId) + return { + shouldDisplay: true, + notification: sanitized, + } + } +}