From 4744b403345f34e5a60f076af7d6c12c44974892 Mon Sep 17 00:00:00 2001 From: Anubhav Singh Date: Mon, 29 Jun 2026 09:19:15 +0530 Subject: [PATCH] feat: upload integrity, file_message event, push mute, device presence - Verify ciphertext SHA256 on send_message; reject mismatches (AEAD tag remains primary integrity; server hash is transport check) - Emit file_message WS event for file/image/video/audio content; file bytes flow over HTTP via GET /files/:id, not the socket - Respect mute settings before push dispatch: check conversation_members.isMuted and per-device pushEnabled toggle - Derive user presence from user_devices.lastSeenAt; online if any non-revoked device has recent activity; expose lastSeen via GET /users/:id/presence and presence_update WS event --- apps/backend/drizzle/0009_push_enabled.sql | 1 + apps/backend/drizzle/meta/_journal.json | 7 ++ apps/backend/package.json | 2 + apps/backend/src/db/schema.ts | 1 + apps/backend/src/index.ts | 54 ++++++++++++-- apps/backend/src/middleware/socketAuth.ts | 2 + apps/backend/src/routes/users.ts | 20 ++++-- apps/backend/src/services/heartbeat.ts | 23 +++++- apps/backend/src/services/presence.ts | 46 +++++++++++- apps/backend/src/services/push.ts | 83 ++++++++++++++++++++++ apps/backend/src/socket/messaging.ts | 50 ++++++++++++- pnpm-lock.yaml | 71 ++++++++++++++++++ 12 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 apps/backend/drizzle/0009_push_enabled.sql create mode 100644 apps/backend/src/services/push.ts diff --git a/apps/backend/drizzle/0009_push_enabled.sql b/apps/backend/drizzle/0009_push_enabled.sql new file mode 100644 index 0000000..d0e8b08 --- /dev/null +++ b/apps/backend/drizzle/0009_push_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE "user_devices" ADD COLUMN "push_enabled" boolean DEFAULT true NOT NULL; diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 6f5a899..df8e4c0 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1783000000000, "tag": "0008_extend_messages", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1785000000000, + "tag": "0009_push_enabled", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index b7b4c69..42fcfa8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -29,6 +29,7 @@ "@aws-sdk/s3-request-presigner": "^3.1075.0", "@socket.io/redis-adapter": "^8.3.0", "@stellar/stellar-sdk": "^15.1.0", + "@types/web-push": "^3.6.4", "cors": "^2.8.6", "dotenv": "^17.3.1", "drizzle-orm": "^0.45.2", @@ -40,6 +41,7 @@ "postgres": "^3.4.9", "redis": "^6.0.0", "socket.io": "^4.8.3", + "web-push": "^3.6.7", "zod": "^4.4.3" }, "devDependencies": { diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 3305884..78d14ed 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -214,6 +214,7 @@ export const userDevices = pgTable( identityPublicKey: text('identity_public_key').notNull(), registrationId: integer('registration_id'), lastSeenAt: timestamp('last_seen_at'), + pushEnabled: boolean('push_enabled').notNull().default(true), revokedAt: timestamp('revoked_at'), createdAt: timestamp('created_at').notNull().defaultNow(), }, diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 3335e09..8cfe67e 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -3,15 +3,15 @@ import { Server } from 'socket.io'; import { createAdapter } from '@socket.io/redis-adapter'; import { createClient } from 'redis'; import dotenv from 'dotenv'; -import { eq } from 'drizzle-orm'; +import { eq, isNull, and } from 'drizzle-orm'; import { db } from './db/index.js'; -import { conversationMembers, users } from './db/schema.js'; +import { conversationMembers, users, userDevices } from './db/schema.js'; import { socketAuthMiddleware, type AuthSocket } from './middleware/socketAuth.js'; import { registerMessagingHandlers } from './socket/messaging.js'; import { app } from './app.js'; import { redis as appRedis } from './lib/redis.js'; import { setSocketServer } from './lib/socket.js'; -import { setOnline, setOffline } from './services/presence.js'; +import { setOnline, setOffline, deriveDevicePresence } from './services/presence.js'; import { startHeartbeatTimer, clearHeartbeatTimer } from './services/heartbeat.js'; import { registerDeviceSocket, @@ -51,6 +51,7 @@ io.use(socketAuthMiddleware); io.on('connection', async (socket: AuthSocket) => { const userId = socket.auth!.userId; const deviceId = socket.auth!.deviceId; + const identityPublicKey = socket.identityPublicKey; console.log('User connected:', userId, socket.id); // Register socket for device-revocation tracking (cross-instance via Redis pub/sub). @@ -59,7 +60,25 @@ io.on('connection', async (socket: AuthSocket) => { } // Start the server-side heartbeat watchdog (90 s timeout). - startHeartbeatTimer(socket, userId, deviceId, appRedis, io); + startHeartbeatTimer(socket, userId, deviceId, appRedis, io, identityPublicKey); + + // Update user_devices.lastSeenAt for device-based presence derivation. + if (identityPublicKey) { + try { + await db + .update(userDevices) + .set({ lastSeenAt: new Date() }) + .where( + and( + eq(userDevices.userId, userId), + eq(userDevices.identityPublicKey, identityPublicKey), + isNull(userDevices.revokedAt), + ), + ); + } catch { + // Non-critical update; ignore errors. + } + } // Per-socket middleware: intercept every incoming event before handlers. const EXCLUDED_EVENTS = new Set(['heartbeat']); @@ -139,6 +158,24 @@ io.on('connection', async (socket: AuthSocket) => { unregisterForBackpressure(socket); clearViolations(socket.id); + // Update user_devices.lastSeenAt on disconnect. + if (identityPublicKey) { + try { + await db + .update(userDevices) + .set({ lastSeenAt: new Date() }) + .where( + and( + eq(userDevices.userId, userId), + eq(userDevices.identityPublicKey, identityPublicKey), + isNull(userDevices.revokedAt), + ), + ); + } catch { + // Non-critical update; ignore errors. + } + } + if (appRedis) { const fullyOffline = await setOffline(appRedis, userId, socket.id); if (fullyOffline) { @@ -153,9 +190,16 @@ io.on('connection', async (socket: AuthSocket) => { where: eq(conversationMembers.userId, userId), columns: { conversationId: true }, }); + + const { lastSeen } = await deriveDevicePresence(userId); + for (const m of memberships) { io.to(m.conversationId).emit('user_offline', { userId }); - io.to(m.conversationId).emit('presence_update', { userId, online: false }); + io.to(m.conversationId).emit('presence_update', { + userId, + online: false, + ...(lastSeen ? { lastSeen } : {}), + }); } } } diff --git a/apps/backend/src/middleware/socketAuth.ts b/apps/backend/src/middleware/socketAuth.ts index b866b7c..99c5e2e 100644 --- a/apps/backend/src/middleware/socketAuth.ts +++ b/apps/backend/src/middleware/socketAuth.ts @@ -6,6 +6,7 @@ import { devices } from '../db/schema.js'; export interface AuthSocket extends Socket { auth?: JwtPayload; + identityPublicKey?: string; } export async function socketAuthMiddleware( @@ -40,5 +41,6 @@ export async function socketAuthMiddleware( } socket.auth = payload; + socket.identityPublicKey = device.identityPublicKey; next(); } diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index c40d949..2a5178b 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -5,7 +5,7 @@ import { db } from '../db/index.js'; import { users, wallets, devices, conversationMembers } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { redis } from '../lib/redis.js'; -import { isOnline } from '../services/presence.js'; +import { isOnline, deriveDevicePresence } from '../services/presence.js'; import { getSocketServer } from '../lib/socket.js'; export const usersRouter: RouterType = Router(); @@ -163,12 +163,22 @@ usersRouter.get('/:id/presence', async (req: AuthRequest, res) => { return; } - if (!redis) { + // Check Redis for active WS connections first. + if (redis) { + const online = await isOnline(redis, id); + if (online) { + res.json({ online: true }); + return; + } + } + + // Fall back to device-based presence from user_devices.lastSeenAt. + try { + const { online, lastSeen } = await deriveDevicePresence(id); + res.json({ online, ...(lastSeen ? { lastSeen } : {}) }); + } catch { res.json({ online: false }); - return; } - const online = await isOnline(redis, id); - res.json({ online }); } catch { res.status(404).json({ error: 'User not found' }); } diff --git a/apps/backend/src/services/heartbeat.ts b/apps/backend/src/services/heartbeat.ts index 5adad87..7d3fbbd 100644 --- a/apps/backend/src/services/heartbeat.ts +++ b/apps/backend/src/services/heartbeat.ts @@ -2,8 +2,8 @@ import type { Server } from 'socket.io'; import type { Redis } from 'ioredis'; import type { AuthSocket } from '../middleware/socketAuth.js'; import { db } from '../db/index.js'; -import { devices } from '../db/schema.js'; -import { eq } from 'drizzle-orm'; +import { devices, userDevices } from '../db/schema.js'; +import { eq, and, isNull } from 'drizzle-orm'; import { refreshPresence, markDeviceOffline } from './presence.js'; const HEARTBEAT_TIMEOUT_MS = 90_000; @@ -18,6 +18,7 @@ export function startHeartbeatTimer( deviceId: string, redis: Redis | null, io: Server, + identityPublicKey?: string, ): void { const schedule = () => { clearTimeout(timers.get(socket.id)); @@ -61,6 +62,24 @@ export function startHeartbeatTimer( } catch { // Non-critical update; ignore errors. } + + // Update user_devices.lastSeenAt for device-based presence derivation. + if (identityPublicKey) { + try { + await db + .update(userDevices) + .set({ lastSeenAt: new Date() }) + .where( + and( + eq(userDevices.userId, userId), + eq(userDevices.identityPublicKey, identityPublicKey), + isNull(userDevices.revokedAt), + ), + ); + } catch { + // Non-critical update; ignore errors. + } + } } schedule(); diff --git a/apps/backend/src/services/presence.ts b/apps/backend/src/services/presence.ts index 1013131..3c44e5f 100644 --- a/apps/backend/src/services/presence.ts +++ b/apps/backend/src/services/presence.ts @@ -8,9 +8,16 @@ * - On connect: add socketId to `presence:{userId}` set, set TTL 60s * - On heartbeat: refresh TTL to 60s * - On disconnect: remove socketId from set, if set empty → user_offline - * - GET /users/:id/presence → { online: boolean } + * - GET /users/:id/presence → { online: boolean, lastSeen?: string } + * + * User presence is derived from device presence: a user is online when any + * non-expired device entry exists (Redis OR user_devices.lastSeenAt within + * the window). When offline, lastSeen reflects the most recent device activity. */ import type { Redis } from 'ioredis'; +import { isNull, eq, and, gte, desc } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { userDevices } from '../db/schema.js'; const PRESENCE_TTL = 90; // seconds @@ -71,3 +78,40 @@ export async function isOnline(redis: Redis, userId: string): Promise { const count = await redis.scard(key); return count > 0; } + +const DEVICE_PRESENCE_WINDOW_MS = 90_000; + +/** + * Derive user presence from device presence: a user is considered online + * if any non-revoked device has a lastSeenAt within the presence window. + * When offline, returns the most recent lastSeenAt across all devices. + */ +export async function deriveDevicePresence( + userId: string, +): Promise<{ online: boolean; lastSeen: string | null }> { + const windowStart = new Date(Date.now() - DEVICE_PRESENCE_WINDOW_MS); + + const activeDevice = await db.query.userDevices.findFirst({ + where: and( + eq(userDevices.userId, userId), + isNull(userDevices.revokedAt), + gte(userDevices.lastSeenAt, windowStart), + ), + columns: { id: true }, + }); + + if (activeDevice) { + return { online: true, lastSeen: null }; + } + + const mostRecent = await db.query.userDevices.findFirst({ + where: and(eq(userDevices.userId, userId), isNull(userDevices.revokedAt)), + orderBy: desc(userDevices.lastSeenAt), + columns: { lastSeenAt: true }, + }); + + return { + online: false, + lastSeen: mostRecent?.lastSeenAt?.toISOString() ?? null, + }; +} diff --git a/apps/backend/src/services/push.ts b/apps/backend/src/services/push.ts new file mode 100644 index 0000000..3569e64 --- /dev/null +++ b/apps/backend/src/services/push.ts @@ -0,0 +1,83 @@ +import webpush from 'web-push'; +import { eq, and, isNull } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { conversationMembers, pushSubscriptions, userDevices } from '../db/schema.js'; +import { redis } from '../lib/redis.js'; +import { isOnline } from './presence.js'; + +const VAPID_SUBJECT = process.env['VAPID_SUBJECT'] || 'mailto:admin@clicked.app'; + +if (process.env['VAPID_PUBLIC_KEY'] && process.env['VAPID_PRIVATE_KEY']) { + webpush.setVapidDetails( + VAPID_SUBJECT, + process.env['VAPID_PUBLIC_KEY'], + process.env['VAPID_PRIVATE_KEY'], + ); +} + +export interface PushContext { + conversationId: string; + messageId: string; + senderId: string; +} + +export async function sendPushForMessage(ctx: PushContext): Promise { + if (!process.env['VAPID_PUBLIC_KEY'] || !process.env['VAPID_PRIVATE_KEY']) { + return; + } + + try { + const allMembers = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, ctx.conversationId), + columns: { userId: true, isMuted: true }, + }); + + for (const member of allMembers) { + if (member.userId === ctx.senderId) continue; + if (member.isMuted) continue; + + // Skip online users (active WS connection). + if (redis) { + const online = await isOnline(redis, member.userId); + if (online) continue; + } + + // Get non-revoked devices with push enabled. + const devices = await db.query.userDevices.findMany({ + where: and( + eq(userDevices.userId, member.userId), + eq(userDevices.pushEnabled, true), + isNull(userDevices.revokedAt), + ), + columns: { id: true }, + }); + + for (const device of devices) { + const sub = await db.query.pushSubscriptions.findFirst({ + where: eq(pushSubscriptions.deviceId, device.id), + columns: { endpoint: true, p256dh: true, auth: true }, + }); + + if (!sub) continue; + + try { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { p256dh: sub.p256dh, auth: sub.auth }, + }, + JSON.stringify({ + type: 'new_message', + conversationId: ctx.conversationId, + messageId: ctx.messageId, + }), + ); + } catch { + // Push delivery failures are non-critical. + } + } + } + } catch { + // Push is best-effort; never let it break message delivery. + } +} diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 47b1471..a678254 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -1,4 +1,5 @@ import type { Server } from 'socket.io'; +import { createHash } from 'node:crypto'; import { and, eq, lt, desc, sql, inArray } from 'drizzle-orm'; import { db } from '../db/index.js'; import { @@ -12,6 +13,7 @@ import type { AuthSocket } from '../middleware/socketAuth.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { serializeMessage } from '../lib/messages.js'; import { redis } from '../lib/redis.js'; +import { sendPushForMessage } from '../services/push.js'; const PAGE_SIZE = 30; @@ -41,8 +43,13 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }); // ── send_message ─────────────────────────────────────────────────────────── - // Payload: { conversationId, messageId, contentType, ciphertext, envelopes } + // Payload: { conversationId, messageId, contentType, ciphertext, envelopes, ciphertextSha256? } // Persists the message and broadcasts it to all room members. + // + // Integrity: when `ciphertextSha256` is present the server computes + // SHA-256 over the stored ciphertext and rejects the message on mismatch. + // This is a transport-corruption check; the AEAD tag inside the ciphertext + // remains the primary integrity mechanism for clients at decryption time. socket.on( 'send_message', async (payload: { @@ -50,9 +57,11 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void messageId: string; contentType?: string; ciphertext?: string; + ciphertextSha256?: string; envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; }) => { - const { conversationId, messageId, contentType, ciphertext, envelopes } = payload; + const { conversationId, messageId, contentType, ciphertext, ciphertextSha256, envelopes } = + payload; const deviceId = socket.auth!.deviceId; if (!messageId) { @@ -91,6 +100,18 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } + // Verify ciphertext integrity when a sha256 is provided. + if (ciphertextSha256 && ciphertext) { + const computed = createHash('sha256').update(ciphertext, 'utf8').digest('hex'); + if (computed !== ciphertextSha256) { + socket.emit('error', { + event: 'integrity_error', + message: 'Ciphertext sha256 mismatch', + }); + return; + } + } + const [message] = await db .insert(messages) .values({ @@ -132,12 +153,37 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void io.to(conversationId).emit('new_message', message); + // Emit a file_message event for file-type content so recipients + // know to fetch file bytes via GET /files/:id over HTTP. + const ct = contentType || 'text/plain'; + if ( + ct.startsWith('file/') || + ct === 'file' || + ct.startsWith('image/') || + ct.startsWith('video/') || + ct.startsWith('audio/') + ) { + io.to(conversationId).emit('file_message', { + messageId, + conversationId, + fileId: messageId, + }); + } + const members = await db.query.conversationMembers.findMany({ where: eq(conversationMembers.conversationId, conversationId), columns: { userId: true }, }); await invalidateConversationCaches(members.map((member) => member.userId)); + + // Dispatch push notifications to offline members who + // haven't muted the conversation and have push enabled. + sendPushForMessage({ + conversationId, + messageId, + senderId: userId, + }); }, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1ac0a3..76c3623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@stellar/stellar-sdk': specifier: ^15.1.0 version: 15.1.0 + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 cors: specifier: ^2.8.6 version: 2.8.6 @@ -62,6 +65,9 @@ importers: socket.io: specifier: ^4.8.3 version: 4.8.3 + web-push: + specifier: ^3.6.7 + version: 3.6.7 zod: specifier: ^4.4.3 version: 4.4.3 @@ -1561,6 +1567,9 @@ packages: '@types/supertest@7.2.0': resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -1837,6 +1846,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -1889,6 +1902,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1970,6 +1986,9 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bn.js@4.12.4: + resolution: {integrity: sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -2731,6 +2750,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3138,6 +3165,9 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -3906,6 +3936,11 @@ packages: jsdom: optional: true + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -5234,6 +5269,10 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.10 + '@types/web-push@3.6.4': + dependencies: + '@types/node': 20.19.37 + '@types/ws@8.18.1': dependencies: '@types/node': 20.19.37 @@ -5554,6 +5593,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -5640,6 +5681,13 @@ snapshots: asap@2.0.6: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.4 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-types-flow@0.0.8: {} @@ -5702,6 +5750,8 @@ snapshots: bignumber.js@9.3.1: {} + bn.js@4.12.4: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -6612,6 +6662,15 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http_ece@1.2.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -6991,6 +7050,8 @@ snapshots: mime@2.6.0: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -7915,6 +7976,16 @@ snapshots: transitivePeerDependencies: - msw + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0