diff --git a/backend/package.json b/backend/package.json index e2b80e5f..daa5cb01 100644 --- a/backend/package.json +++ b/backend/package.json @@ -49,6 +49,7 @@ "prom-client": "^15.1.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", + "web-push": "^3.6.7", "winston": "^3.11.0", "zod": "^4.4.3" }, @@ -60,6 +61,7 @@ "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", + "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^3.2.7", "eventsource": "^2.0.2", "nodemon": "^3.1.11", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 10cff32f..f0b1abc5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -245,3 +245,51 @@ model IndexerDeadLetterEvent { @@index([attempts]) @@index([eventType]) } + +// --------------------------------------------------------------------------- +// Notifications (Web Push + Telegram / Discord) +// --------------------------------------------------------------------------- + +model PushSubscription { + id String @id @default(cuid()) + address String + endpoint String @unique + p256dh String + auth String + userAgent String? + createdAt DateTime @default(now()) + + @@index([address]) +} + +model NotificationChannel { + id String @id @default(cuid()) + address String @unique + telegramChatId String? + discordWebhook String? + events String[] @default(["STREAM_CREATED", "CLIFF_REACHED", "RUNWAY_WARNING", "TOKENS_CLAIMED"]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model LinkCode { + code String @id + address String + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([address]) +} + +model NotificationLog { + id String @id @default(cuid()) + address String + event String + channel String + status String + error String? + createdAt DateTime @default(now()) + + @@index([address, createdAt]) +} diff --git a/backend/src/routes/v1/notifications.routes.ts b/backend/src/routes/v1/notifications.routes.ts new file mode 100644 index 00000000..e428922a --- /dev/null +++ b/backend/src/routes/v1/notifications.routes.ts @@ -0,0 +1,52 @@ +import { Router } from 'express'; +import { notificationService } from '../../services/notification.service.js'; + +export const notificationsRouter = Router(); + +notificationsRouter.post('/subscribe', async (req, res) => { + try { + const result = await notificationService.subscribeWebPush(req.body); + res.status(201).json(result); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Bad request' }); + } +}); + +notificationsRouter.post('/unsubscribe', async (req, res) => { + try { + await notificationService.unsubscribeWebPush(req.body.address, req.body.endpoint); + res.json({ ok: true }); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Bad request' }); + } +}); + +notificationsRouter.post('/link-code', async (req, res) => { + try { + const result = await notificationService.generateLinkCode(req.body.address); + res.json(result); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Bad request' }); + } +}); + +notificationsRouter.post('/telegram/link', async (req, res) => { + try { + const result = await notificationService.linkTelegram(req.body.code, req.body.chatId); + res.json(result); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Bad request' }); + } +}); + +notificationsRouter.post('/discord', async (req, res) => { + try { + const result = await notificationService.setDiscordWebhook( + req.body.address, + req.body.webhookUrl, + ); + res.json(result); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Bad request' }); + } +}); diff --git a/backend/src/services/notification.service.ts b/backend/src/services/notification.service.ts new file mode 100644 index 00000000..a332e314 --- /dev/null +++ b/backend/src/services/notification.service.ts @@ -0,0 +1,161 @@ +import { prisma } from '../lib/prisma.js'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +import webpush from 'web-push'; + +export type StreamEventType = + | 'STREAM_CREATED' + | 'CLIFF_REACHED' + | 'RUNWAY_WARNING' + | 'TOKENS_CLAIMED'; + +export interface StreamEvent { + type: StreamEventType; + address: string; + title: string; + body: string; + url?: string; + data?: Record; +} + +const TELEGRAM_API = 'https://api.telegram.org'; + +export class NotificationService { + private vapidReady = false; + + constructor() { + const publicKey = process.env.VAPID_PUBLIC_KEY; + const privateKey = process.env.VAPID_PRIVATE_KEY; + const subject = process.env.VAPID_SUBJECT ?? 'mailto:alerts@flowfi.app'; + if (publicKey && privateKey) { + webpush.setVapidDetails(subject, publicKey, privateKey); + this.vapidReady = true; + } + } + + async subscribeWebPush(input: { + address: string; + endpoint: string; + keys: { p256dh: string; auth: string }; + userAgent?: string; + }) { + return prisma.pushSubscription.upsert({ + where: { endpoint: input.endpoint }, + update: { + address: input.address, + p256dh: input.keys.p256dh, + auth: input.keys.auth, + userAgent: input.userAgent ?? null, + }, + create: { + address: input.address, + endpoint: input.endpoint, + p256dh: input.keys.p256dh, + auth: input.keys.auth, + userAgent: input.userAgent ?? null, + }, + }); + } + + async unsubscribeWebPush(address: string, endpoint: string) { + return prisma.pushSubscription.deleteMany({ where: { address, endpoint } }); + } + + async generateLinkCode(address: string) { + const code = Math.random().toString(36).slice(2, 8).toUpperCase(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + return prisma.linkCode.create({ data: { code, address, expiresAt } }); + } + + async linkTelegram(code: string, chatId: string) { + const record = await prisma.linkCode.findUnique({ where: { code } }); + if (!record || record.usedAt || record.expiresAt < new Date()) { + throw new Error('Link code is invalid or expired'); + } + await prisma.linkCode.update({ where: { code }, data: { usedAt: new Date() } }); + return prisma.notificationChannel.upsert({ + where: { address: record.address }, + update: { telegramChatId: chatId }, + create: { address: record.address, telegramChatId: chatId }, + }); + } + + async setDiscordWebhook(address: string, webhookUrl: string) { + if (!/^https:\/\/(discord|discordapp)\.com\/api\/webhooks\//.test(webhookUrl)) { + throw new Error('Invalid Discord webhook URL'); + } + return prisma.notificationChannel.upsert({ + where: { address }, + update: { discordWebhook: webhookUrl }, + create: { address, discordWebhook: webhookUrl }, + }); + } + + async dispatch(event: StreamEvent): Promise { + const [subs, channel] = await Promise.all([ + prisma.pushSubscription.findMany({ where: { address: event.address } }), + prisma.notificationChannel.findUnique({ where: { address: event.address } }), + ]); + + const tasks: Array> = []; + for (const sub of subs) tasks.push(this.sendWebPush(sub, event)); + if (channel?.telegramChatId) tasks.push(this.sendTelegram(channel.telegramChatId, event)); + if (channel?.discordWebhook) tasks.push(this.sendDiscord(channel.discordWebhook, event)); + + await Promise.allSettled(tasks); + } + + private async sendWebPush( + sub: { endpoint: string; p256dh: string; auth: string }, + event: StreamEvent, + ) { + if (!this.vapidReady) return; + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify({ + title: event.title, + body: event.body, + url: event.url ?? '/', + event: event.type, + tag: `flowfi-${event.type.toLowerCase()}`, + }), + ); + } catch (error) { + const statusCode = (error as { statusCode?: number })?.statusCode; + if (statusCode === 404 || statusCode === 410) { + await prisma.pushSubscription.deleteMany({ where: { endpoint: sub.endpoint } }); + } + } + } + + private async sendTelegram(chatId: string, event: StreamEvent) { + const token = process.env.TELEGRAM_BOT_TOKEN; + if (!token) return; + await fetch(`${TELEGRAM_API}/bot${token}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: `*${event.title}*\n${event.body}`, + parse_mode: 'Markdown', + }), + }).catch(() => undefined); + } + + private async sendDiscord(webhookUrl: string, event: StreamEvent) { + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + embeds: [{ + title: event.title, + description: event.body, + color: event.type === 'RUNWAY_WARNING' ? 0xf59e0b : 0x6366f1, + timestamp: new Date().toISOString(), + }], + }), + }).catch(() => undefined); + } +} + +export const notificationService = new NotificationService(); diff --git a/backend/src/workers/notification-bot.worker.ts b/backend/src/workers/notification-bot.worker.ts new file mode 100644 index 00000000..c3e99c20 --- /dev/null +++ b/backend/src/workers/notification-bot.worker.ts @@ -0,0 +1,87 @@ +import { prisma } from '../lib/prisma.js'; +import { notificationService } from '../services/notification.service.js'; + +const POLL_INTERVAL_MS = 60_000; +const RUNWAY_WARNING_HOURS = 24; + +export class NotificationBotWorker { + private timer: ReturnType | null = null; + private readonly warned = new Set(); + + start() { + if (this.timer) return; + this.timer = setInterval(() => { + void this.pollRunwayWarnings().catch(() => undefined); + }, POLL_INTERVAL_MS); + } + + stop() { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + async notify(type: string, address: string, data: Record): Promise { + const map: Record = { + STREAM_CREATED: { + title: '🌊 New stream received', + body: `You are receiving a new stream of ${data.amount ?? ''} ${data.symbol ?? ''} from ${shorten(data.sender)}`, + url: `/streams/${data.streamId ?? ''}`, + }, + CLIFF_REACHED: { + title: '🎯 Vesting cliff reached', + body: `${data.amount ?? ''} ${data.symbol ?? ''} tokens are now claimable.`, + url: `/streams/${data.streamId ?? ''}`, + }, + RUNWAY_WARNING: { + title: '⚠️ Low runway warning', + body: `Stream #${data.streamId ?? ''} has less than ${RUNWAY_WARNING_HOURS} hours of runway remaining. Refuel now.`, + url: `/streams/${data.streamId ?? ''}`, + }, + TOKENS_CLAIMED: { + title: '✅ Withdrawal confirmed', + body: `Successfully withdrawn ${data.amount ?? ''} ${data.symbol ?? ''} to your wallet.`, + url: `/streams/${data.streamId ?? ''}`, + }, + }; + const build = map[type]; + if (!build) return; + await notificationService.dispatch({ type: type as any, address, ...build, data }); + } + + private async pollRunwayWarnings(): Promise { + const now = Date.now(); + const horizon = now + RUNWAY_WARNING_HOURS * 60 * 60 * 1000; + + const streams = await prisma.stream.findMany({ + where: { + isActive: true, + isPaused: false, + endTime: { + gt: BigInt(Math.floor(now / 1000)), + lt: BigInt(Math.floor(horizon / 1000)), + }, + }, + take: 200, + }); + + for (const stream of streams) { + const key = `${stream.streamId}:${stream.sender}:RUNWAY_WARNING`; + if (this.warned.has(key)) continue; + this.warned.add(key); + await notificationService.dispatch({ + type: 'RUNWAY_WARNING', + address: stream.sender, + title: '⚠️ Stream runway below 24 hours', + body: `Stream #${stream.streamId} is running low. Refuel to avoid a payroll halt.`, + url: `/streams/${stream.streamId}`, + }); + } + } +} + +function shorten(address?: string): string { + if (!address) return 'unknown'; + return address.length > 12 ? `${address.slice(0, 6)}…${address.slice(-4)}` : address; +} + +export const notificationBotWorker = new NotificationBotWorker(); diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 00000000..fcba4261 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,48 @@ +/* FlowFi push notification service worker. */ + +self.addEventListener('install', () => self.skipWaiting()); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('push', (event) => { + let payload = { title: 'FlowFi', body: 'You have a new stream update.', url: '/' }; + if (event.data) { + try { + payload = { ...payload, ...event.data.json() }; + } catch { + payload.body = event.data.text(); + } + } + + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + icon: '/icon-192.png', + badge: '/badge-72.png', + tag: payload.tag || 'flowfi-stream', + requireInteraction: payload.event === 'RUNWAY_WARNING', + data: { url: payload.url || '/' }, + }), + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const target = (event.notification.data && event.notification.data.url) || '/'; + + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clients) => { + for (const client of clients) { + if ('focus' in client) { + client.navigate(target); + return client.focus(); + } + } + if (self.clients.openWindow) return self.clients.openWindow(target); + }), + ); +}); diff --git a/frontend/src/__tests__/notifications.test.tsx b/frontend/src/__tests__/notifications.test.tsx new file mode 100644 index 00000000..922a5423 --- /dev/null +++ b/frontend/src/__tests__/notifications.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; + +const { subscribeToPush, unsubscribeFromPush, getExistingSubscription } = vi.hoisted(() => ({ + subscribeToPush: vi.fn(async () => ({} as PushSubscription)), + unsubscribeFromPush: vi.fn(async () => undefined), + getExistingSubscription: vi.fn(async () => null as PushSubscription | null), +})); + +vi.mock('@/lib/notifications/push', async () => { + const actual = await vi.importActual( + '@/lib/notifications/push', + ); + return { + ...actual, + isPushSupported: () => true, + subscribeToPush, + unsubscribeFromPush, + getExistingSubscription, + }; +}); + +import { PushNotificationToggle } from '@/components/notifications/PushNotificationToggle'; +import { urlBase64ToUint8Array } from '@/lib/notifications/push'; + +describe('urlBase64ToUint8Array', () => { + it('decodes a base64url VAPID key to the expected bytes', () => { + // "hello" base64url = "aGVsbG8" + const bytes = urlBase64ToUint8Array('aGVsbG8'); + expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]); + }); + + it('handles url-safe characters (- and _)', () => { + const bytes = urlBase64ToUint8Array('a-_w'); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes.length).toBeGreaterThan(0); + }); +}); + +describe('PushNotificationToggle', () => { + beforeEach(() => { + subscribeToPush.mockClear(); + unsubscribeFromPush.mockClear(); + getExistingSubscription.mockClear(); + getExistingSubscription.mockResolvedValue(null); + }); + + it('renders an "Enable" button when no subscription exists', async () => { + render(); + await waitFor(() => + expect(screen.getByTestId('push-toggle')).toHaveTextContent(/Enable browser push/i), + ); + }); + + it('subscribes on click and flips to "Disable"', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('push-toggle')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByTestId('push-toggle')); + }); + + expect(subscribeToPush).toHaveBeenCalledWith('GADDRESS'); + await waitFor(() => + expect(screen.getByTestId('push-toggle')).toHaveTextContent(/Disable browser push/i), + ); + }); + + it('shows an error when subscription fails', async () => { + subscribeToPush.mockRejectedValueOnce(new Error('permission denied')); + render(); + await waitFor(() => expect(screen.getByTestId('push-toggle')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByTestId('push-toggle')); + }); + + expect(await screen.findByTestId('push-error')).toHaveTextContent(/permission denied/i); + }); + + it('renders a fallback when push is unsupported', async () => { + vi.doMock('@/lib/notifications/push', async () => { + const actual = await vi.importActual( + '@/lib/notifications/push', + ); + return { ...actual, isPushSupported: () => false }; + }); + vi.resetModules(); + const { PushNotificationToggle: Toggle } = await import( + '@/components/notifications/PushNotificationToggle' + ); + render(); + await waitFor(() => + expect(screen.queryByTestId('push-toggle')).not.toBeInTheDocument(), + ); + }); +}); diff --git a/frontend/src/app/settings/notifications/page.tsx b/frontend/src/app/settings/notifications/page.tsx new file mode 100644 index 00000000..a479cd62 --- /dev/null +++ b/frontend/src/app/settings/notifications/page.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useState } from 'react'; +import { PushNotificationToggle } from '@/components/notifications/PushNotificationToggle'; + +export default function NotificationSettingsPage() { + const [address, setAddress] = useState(''); + const [linkCode, setLinkCode] = useState(null); + const [discordUrl, setDiscordUrl] = useState(''); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + const generateCode = async () => { + setError(null); + setLinkCode(null); + try { + const res = await fetch('/api/v1/notifications/link-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address }), + }); + if (!res.ok) throw new Error('Failed to generate link code'); + const data = await res.json(); + setLinkCode(data.code); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + }; + + const saveDiscord = async () => { + setError(null); + setStatus(null); + try { + const res = await fetch('/api/v1/notifications/discord', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, webhookUrl: discordUrl }), + }); + if (!res.ok) throw new Error('Failed to save Discord webhook'); + setStatus('Discord webhook saved.'); + setDiscordUrl(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + }; + + return ( +
+

+ Notification settings +

+

+ Receive alerts for new streams, cliffs, low runway, and withdrawals. +

+ +
+

Stellar address

+ setAddress(e.target.value)} + placeholder="G..." + data-testid="address-input" + className="mt-2 h-9 w-full rounded-lg border border-slate-300 px-3 text-sm dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100" + /> +
+ +
+

Browser push

+
+ +
+
+ +
+

Telegram

+

+ Generate a code, then open @FlowFiAlertsBot and send{' '} + /link <code>. +

+ + {linkCode ? ( +

+ {linkCode} +

+ ) : null} +
+ +
+

Discord webhook

+ setDiscordUrl(e.target.value)} + placeholder="https://discord.com/api/webhooks/..." + data-testid="discord-input" + className="mt-2 h-9 w-full rounded-lg border border-slate-300 px-3 text-sm dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100" + /> + +
+ + {status ?

{status}

: null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/frontend/src/components/notifications/PushNotificationToggle.tsx b/frontend/src/components/notifications/PushNotificationToggle.tsx new file mode 100644 index 00000000..d5e4393b --- /dev/null +++ b/frontend/src/components/notifications/PushNotificationToggle.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + getExistingSubscription, + isPushSupported, + subscribeToPush, + unsubscribeFromPush, +} from '@/lib/notifications/push'; + +export interface PushNotificationToggleProps { + address: string; +} + +export function PushNotificationToggle({ address }: PushNotificationToggleProps) { + const [supported, setSupported] = useState(true); + const [enabled, setEnabled] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setSupported(isPushSupported()); + void getExistingSubscription() + .then((sub) => setEnabled(Boolean(sub))) + .catch(() => undefined); + }, []); + + const toggle = async () => { + setBusy(true); + setError(null); + try { + if (enabled) { + await unsubscribeFromPush(address); + setEnabled(false); + } else { + await subscribeToPush(address); + setEnabled(true); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update notifications'); + } finally { + setBusy(false); + } + }; + + if (!supported) { + return ( +

+ Browser push notifications are not supported in this browser. +

+ ); + } + + return ( +
+ + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +export default PushNotificationToggle; diff --git a/frontend/src/lib/notifications/push.ts b/frontend/src/lib/notifications/push.ts new file mode 100644 index 00000000..8c133066 --- /dev/null +++ b/frontend/src/lib/notifications/push.ts @@ -0,0 +1,79 @@ +const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? ''; + +/** Convert the VAPID public key (base64url) into the Uint8Array PushManager expects. */ +export function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = atob(base64); + const output = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; i += 1) output[i] = rawData.charCodeAt(i); + return output; +} + +export function isPushSupported(): boolean { + return ( + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + ); +} + +export async function registerServiceWorker(): Promise { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return null; + return navigator.serviceWorker.register('/sw.js'); +} + +export async function getExistingSubscription(): Promise { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return null; + const registration = await navigator.serviceWorker.ready; + return registration.pushManager.getSubscription(); +} + +export async function subscribeToPush(address: string): Promise { + if (!isPushSupported()) throw new Error('Push notifications are not supported in this browser'); + if (!VAPID_PUBLIC_KEY) throw new Error('VAPID public key is not configured'); + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') throw new Error('Notification permission was denied'); + + const registration = await registerServiceWorker(); + if (!registration) throw new Error('Failed to register service worker'); + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) as BufferSource, + }); + + const json = subscription.toJSON(); + const response = await fetch('/api/v1/notifications/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + address, + endpoint: json.endpoint, + keys: { p256dh: json.keys?.p256dh, auth: json.keys?.auth }, + userAgent: navigator.userAgent, + }), + }); + + if (!response.ok) { + await subscription.unsubscribe().catch(() => undefined); + throw new Error('Failed to register subscription with the server'); + } + + return subscription; +} + +export async function unsubscribeFromPush(address: string): Promise { + const subscription = await getExistingSubscription(); + if (!subscription) return; + + await fetch('/api/v1/notifications/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, endpoint: subscription.endpoint }), + }).catch(() => undefined); + + await subscription.unsubscribe(); +} diff --git a/package-lock.json b/package-lock.json index bf2848a4..742b4e8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "prom-client": "^15.1.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", + "web-push": "^3.6.7", "winston": "^3.11.0", "zod": "^4.4.3" }, @@ -67,6 +68,7 @@ "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", + "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^3.2.7", "eventsource": "^2.0.2", "nodemon": "^3.1.11", @@ -583,7 +585,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -593,7 +595,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -627,7 +629,7 @@ "version": "7.29.9", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.8" @@ -687,7 +689,7 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -5882,7 +5884,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -5957,6 +5959,16 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -7154,6 +7166,18 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -7382,6 +7406,12 @@ "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", "license": "MIT" }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -7501,6 +7531,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-image-size": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", @@ -8713,6 +8749,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -10540,6 +10585,15 @@ "dev": true, "license": "MIT" }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -11549,6 +11603,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11840,7 +11915,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.25.4", @@ -12003,6 +12078,12 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -12020,7 +12101,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -16346,6 +16426,25 @@ "node": ">=18" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",