From 47fbf861eefe65ad81ae0add763270a5053d02c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D0=B8=D0=B4=20=D0=9E=D0=BC=D0=B0=D1=80=20?= =?UTF-8?q?=D0=9C=D0=B5=D0=B4=D1=85=D0=B0=D1=82=20=7C=20Zaid=20Omar=20Medh?= =?UTF-8?q?at?= Date: Fri, 10 Jul 2026 19:05:39 +0500 Subject: [PATCH] Phase 6.5: notifications service + in-app toasts; in-app voice/video messages; SVG icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications (web push now, Android FCM ready, iOS prepared): - device_tokens / notification_settings / conversation_mutes (migration 006) - REST: register/list/unregister devices, get/update settings (quiet hours, timezone, enable), mute/unmute conversation, VAPID public key - BullMQ queue: backend enqueues a job per new message; a dedicated stateless `notifications` worker service drains it - Delivery gating: skip sender, muted chats, disabled users, quiet hours, and ONLINE users (they get the message live + an in-app toast) — offline → push - Providers behind one interface: Web Push (VAPID/web-push) + FCM (firebase-admin, HTTP v1; Android + iOS via the same path). Dead tokens (404/410/unregistered) auto-retired; transient failures recorded - Web: service worker (push display + tap-to-open the originating chat), push registration/unregistration, in-app toast stack, deep-link via ?conversation= In-app voice & video messages: MediaRecorder capture in the composer (mic/video), live preview + timer, upload via the existing presigned-media path. No emojis anywhere: replaced all glyphs with inline SVG icons (shared/ui) so the UI renders identically across web/desktop/mobile; notification text is plain. Chores: dedupe ioredis (BullMQ) + pin uuid>=11.1.1 (audit clean) via pnpm overrides; reusable Centrifugo client factory for the worker. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD --- .env.example | 15 + docker-compose.override.yml | 11 + docker-compose.prod.yml | 3 + docker-compose.yml | 21 + infra/secrets/.gitignore | 4 + infra/secrets/README.md | 16 + package.json | 4 +- .../1720000000006_notifications.cjs | 76 + packages/backend/package.json | 8 +- packages/backend/src/app.ts | 38 + packages/backend/src/config.ts | 35 + packages/backend/src/db/schema.ts | 33 + .../src/modules/messages/messages.service.ts | 6 +- .../src/modules/notifications/index.ts | 15 + .../notifications/notifications.dispatch.ts | 173 ++ .../notifications/notifications.queue.ts | 45 + .../notifications/notifications.repository.ts | 191 +++ .../notifications/notifications.routes.ts | 184 +++ .../notifications/notifications.service.ts | 139 ++ .../notifications/providers/fcm.provider.ts | 46 + .../modules/notifications/providers/index.ts | 3 + .../notifications/providers/provider.ts | 11 + .../providers/web-push.provider.ts | 32 + packages/backend/src/plugins/centrifugo.ts | 66 +- packages/backend/src/worker.ts | 95 ++ packages/backend/tsup.config.ts | 2 +- packages/core/src/api/index.ts | 10 + packages/core/src/api/notifications.ts | 62 + packages/core/src/schemas/index.ts | 15 + packages/core/src/schemas/notifications.ts | 77 + packages/core/src/types/index.ts | 7 + packages/core/src/types/notification.ts | 33 + packages/web/eslint.config.mjs | 4 +- packages/web/public/sw.js | 48 + packages/web/src/app/App.tsx | 93 +- packages/web/src/app/index.css | 113 +- .../web/src/features/conversations/model.ts | 14 +- .../web/src/features/messaging/recorder.ts | 126 ++ .../src/features/messaging/ui/ChatView.tsx | 118 +- .../web/src/features/notifications/index.ts | 2 + .../web/src/features/notifications/model.tsx | 225 +++ packages/web/src/shared/ui/icons.tsx | 87 + packages/web/src/shared/ui/index.ts | 9 + pnpm-lock.yaml | 1457 ++++++++++++++++- 44 files changed, 3690 insertions(+), 82 deletions(-) create mode 100644 infra/secrets/.gitignore create mode 100644 infra/secrets/README.md create mode 100644 packages/backend/migrations/1720000000006_notifications.cjs create mode 100644 packages/backend/src/modules/notifications/index.ts create mode 100644 packages/backend/src/modules/notifications/notifications.dispatch.ts create mode 100644 packages/backend/src/modules/notifications/notifications.queue.ts create mode 100644 packages/backend/src/modules/notifications/notifications.repository.ts create mode 100644 packages/backend/src/modules/notifications/notifications.routes.ts create mode 100644 packages/backend/src/modules/notifications/notifications.service.ts create mode 100644 packages/backend/src/modules/notifications/providers/fcm.provider.ts create mode 100644 packages/backend/src/modules/notifications/providers/index.ts create mode 100644 packages/backend/src/modules/notifications/providers/provider.ts create mode 100644 packages/backend/src/modules/notifications/providers/web-push.provider.ts create mode 100644 packages/backend/src/worker.ts create mode 100644 packages/core/src/api/notifications.ts create mode 100644 packages/core/src/schemas/notifications.ts create mode 100644 packages/core/src/types/notification.ts create mode 100644 packages/web/public/sw.js create mode 100644 packages/web/src/features/messaging/recorder.ts create mode 100644 packages/web/src/features/notifications/index.ts create mode 100644 packages/web/src/features/notifications/model.tsx create mode 100644 packages/web/src/shared/ui/icons.tsx create mode 100644 packages/web/src/shared/ui/index.ts diff --git a/.env.example b/.env.example index c46d436..2fee24f 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,21 @@ MINIO_PUBLIC_URL=http://localhost:9000 # so presigning never makes a network region-lookup call. MINIO_REGION=us-east-1 +# --- notifications (Phase 6.5) --- +# Web Push (VAPID). Generate a keypair once with: +# node -e "console.log(require('web-push').generateVAPIDKeys())" +# The public key is safe to expose to the browser; keep the private key secret. +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:admin@altricade.com +# Firebase Cloud Messaging (Android now, iOS later — same code path). Point this +# at the service-account JSON mounted into the worker; leave empty to disable FCM. +FCM_SERVICE_ACCOUNT_FILE= +# iOS push is prepared but off until an APNs key is uploaded to Firebase. +APNS_ENABLED=false +# BullMQ queue name for the notifications worker. +NOTIFICATIONS_QUEUE=notifications + # --- nginx (public entrypoint) --- NGINX_HTTP_PORT=8080 diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 9918e08..8caa676 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -30,6 +30,17 @@ services: ports: - '${BACKEND_PORT:-4000}:4000' + notifications: + build: + context: . + dockerfile: packages/backend/Dockerfile + target: dev + command: ['pnpm', 'dev:worker'] + volumes: + - ./packages/backend/src:/repo/packages/backend/src + - ./packages/core/src:/repo/packages/core/src + - ./infra/secrets:/app/secrets:ro + nginx: ports: - '${NGINX_HTTP_PORT:-8080}:80' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index bd53da0..07bf987 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,6 +17,9 @@ services: backend: restart: unless-stopped + notifications: + restart: unless-stopped + nginx: restart: unless-stopped ports: diff --git a/docker-compose.yml b/docker-compose.yml index a34d6e5..cede6a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -114,6 +114,27 @@ services: condition: service_completed_successfully networks: [altricade] + # Notifications worker — drains the BullMQ queue and sends web/FCM pushes. + # Reuses the backend image (tsup emits dist/worker.js). The infra/secrets dir + # is mounted read-only so a Firebase service-account JSON can be provided + # without rebuilding; FCM stays disabled until FCM_SERVICE_ACCOUNT_FILE is set. + notifications: + build: + context: . + dockerfile: packages/backend/Dockerfile + command: ['node', 'dist/worker.js'] + env_file: .env + volumes: + - ./infra/secrets:/app/secrets:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + networks: [altricade] + nginx: image: nginx:1.27-alpine volumes: diff --git a/infra/secrets/.gitignore b/infra/secrets/.gitignore new file mode 100644 index 0000000..16d032b --- /dev/null +++ b/infra/secrets/.gitignore @@ -0,0 +1,4 @@ +# Never commit real credentials. +* +!.gitignore +!README.md diff --git a/infra/secrets/README.md b/infra/secrets/README.md new file mode 100644 index 0000000..1916e5c --- /dev/null +++ b/infra/secrets/README.md @@ -0,0 +1,16 @@ +# Secrets (git-ignored) + +Drop credential files here. This directory is mounted read-only into the +`notifications` worker at `/app/secrets`. + +## Firebase Cloud Messaging (Android push, and iOS later) + +1. Firebase Console → ⚙ Project settings → **Service accounts** → + **Generate new private key** → download the JSON. +2. Save it here as `fcm-service-account.json`. +3. In `.env`, set: + `FCM_SERVICE_ACCOUNT_FILE=/app/secrets/fcm-service-account.json` +4. Recreate the worker: `docker compose up -d --force-recreate notifications` + +Until this is set, native (FCM) push is disabled and the worker logs +"fcm disabled (no service account configured)". Web Push works without it. diff --git a/package.json b/package.json index bfd5d7c..7bfc703 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ }, "pnpm": { "overrides": { - "esbuild": ">=0.28.1" + "esbuild": ">=0.28.1", + "ioredis": "^5.11.1", + "uuid": "^11.1.1" } } } diff --git a/packages/backend/migrations/1720000000006_notifications.cjs b/packages/backend/migrations/1720000000006_notifications.cjs new file mode 100644 index 0000000..269e354 --- /dev/null +++ b/packages/backend/migrations/1720000000006_notifications.cjs @@ -0,0 +1,76 @@ +// Phase 6.5 — notifications: device registry, per-user settings, per-chat mutes. + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + // Push credentials, one row per device/subscription. `token` is the stable, + // globally-unique identity: the FCM registration token for native, or the + // Web Push endpoint URL for web (p256dh/auth hold the web subscription keys). + pgm.createTable('device_tokens', { + id: { type: 'uuid', notNull: true, default: pgm.func('gen_random_uuid()'), primaryKey: true }, + user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, + platform: { type: 'text', notNull: true }, + token: { type: 'text', notNull: true }, + web_p256dh: { type: 'text' }, + web_auth: { type: 'text' }, + failure_count: { type: 'integer', notNull: true, default: 0 }, + last_success_at: { type: 'timestamptz' }, + disabled_at: { type: 'timestamptz' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.addConstraint('device_tokens', 'device_tokens_platform_check', { + check: "platform IN ('web', 'android', 'ios')", + }); + // A token identifies exactly one device; re-registering rebinds it to its user. + pgm.addConstraint('device_tokens', 'device_tokens_token_unique', { unique: ['token'] }); + pgm.createIndex('device_tokens', 'user_id', { + name: 'device_tokens_active_by_user', + where: 'disabled_at IS NULL', + }); + + // Per-user notification preferences. Quiet hours are minutes-since-midnight in + // the user's IANA timezone; NULL start/end means "no quiet hours". + pgm.createTable('notification_settings', { + user_id: { + type: 'uuid', + notNull: true, + references: 'users', + onDelete: 'CASCADE', + primaryKey: true, + }, + enabled: { type: 'boolean', notNull: true, default: true }, + quiet_hours_start: { type: 'smallint' }, + quiet_hours_end: { type: 'smallint' }, + timezone: { type: 'text', notNull: true, default: 'UTC' }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.addConstraint('notification_settings', 'notification_settings_quiet_range_check', { + check: ` + (quiet_hours_start IS NULL OR (quiet_hours_start >= 0 AND quiet_hours_start <= 1439)) + AND (quiet_hours_end IS NULL OR (quiet_hours_end >= 0 AND quiet_hours_end <= 1439)) + `, + }); + + // Per-conversation mute. muted_until NULL means muted indefinitely. + pgm.createTable('conversation_mutes', { + conversation_id: { + type: 'uuid', + notNull: true, + references: 'conversations', + onDelete: 'CASCADE', + }, + user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, + muted_until: { type: 'timestamptz' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.addConstraint('conversation_mutes', 'conversation_mutes_pkey', { + primaryKey: ['conversation_id', 'user_id'], + }); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.dropTable('conversation_mutes'); + pgm.dropTable('notification_settings'); + pgm.dropTable('device_tokens'); +}; diff --git a/packages/backend/package.json b/packages/backend/package.json index d3a3425..d43bb8d 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -5,8 +5,10 @@ "type": "module", "scripts": { "dev": "tsx watch src/server.ts", + "dev:worker": "tsx watch src/worker.ts", "build": "tsup", "start": "node dist/server.js", + "start:worker": "node dist/worker.js", "typecheck": "tsc --noEmit", "lint": "eslint .", "migrate:up": "node-pg-migrate up", @@ -21,18 +23,22 @@ "@fastify/swagger-ui": "^6.1.0", "@node-rs/argon2": "^2.0.2", "ajv-formats": "^3.0.1", + "bullmq": "^5.80.0", "fastify": "^5.10.0", "fastify-plugin": "^6.0.0", + "firebase-admin": "^14.1.0", "ioredis": "^5.11.1", "jose": "^6.2.3", "kysely": "^0.29.3", "minio": "^8.0.7", "node-pg-migrate": "^8.0.4", - "pg": "^8.22.0" + "pg": "^8.22.0", + "web-push": "^3.6.7" }, "devDependencies": { "@types/node": "^26.1.1", "@types/pg": "^8.20.0", + "@types/web-push": "^3.6.4", "tsup": "^8.5.1", "tsx": "^4.23.0", "typescript": "^5.9.3" diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 9468c2e..eb93414 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -26,6 +26,13 @@ import { createMessagesRepository, createMessagesService, messagesRoutes } from import { createContactsRepository, createContactsService, contactsRoutes } from './modules/contacts'; import { createPresenceService, presenceRoutes } from './modules/presence'; import { createMediaService, mediaRoutes } from './modules/media'; +import { + createNotificationsRepository, + createNotificationsService, + createNotificationQueue, + createBullConnection, + notificationsRoutes, +} from './modules/notifications'; import { realtimeRoutes } from './modules/realtime'; import type { Publisher } from './shared/publisher'; @@ -98,6 +105,25 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise { + await notificationQueue.close(); + await bullConnection.quit(); + }); + app.decorate( + 'notificationsService', + createNotificationsService({ + repo: notificationsRepository, + webPushPublicKey: config.notifications.webPush?.publicKey ?? '', + isMember: (conversationId, userId) => + conversationsRepository.isMember(conversationId, userId), + }), + ); + app.decorate('usersService', createUsersService({ users: usersRepository, publish })); app.decorate( 'authService', @@ -130,6 +156,17 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise mediaService.downloadUrl(objectKey), + notify: (message) => { + void notificationQueue + .enqueueMessage({ + conversationId: message.conversationId, + messageId: message.id, + senderId: message.senderId, + }) + .catch((error: unknown) => { + app.log.error(error, 'failed to enqueue notification job'); + }); + }, }), ); app.decorate( @@ -156,6 +193,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise { @@ -68,6 +86,22 @@ const parsePort = (name: string, raw: string): number => { const parseBoolean = (raw: string): boolean => raw.toLowerCase() === 'true'; +const buildNotificationsConfig = (): NotificationsConfig => { + const publicKey = optional('VAPID_PUBLIC_KEY', ''); + const privateKey = optional('VAPID_PRIVATE_KEY', ''); + const webPush = + publicKey !== '' && privateKey !== '' + ? { publicKey, privateKey, subject: optional('VAPID_SUBJECT', 'mailto:admin@altricade.com') } + : null; + const fcmFile = optional('FCM_SERVICE_ACCOUNT_FILE', ''); + return { + webPush, + fcmServiceAccountFile: fcmFile === '' ? null : fcmFile, + apnsEnabled: parseBoolean(optional('APNS_ENABLED', 'false')), + queueName: optional('NOTIFICATIONS_QUEUE', 'notifications'), + }; +}; + // Parse a duration like "900", "15s", "15m", "1h", "30d" into seconds. const DURATION_UNITS: Record = { s: 1, m: 60, h: 3600, d: 86400 }; @@ -115,4 +149,5 @@ export const loadConfig = (): AppConfig => ({ accessTtlSeconds: parseDurationSeconds('ACCESS_TOKEN_TTL', optional('ACCESS_TOKEN_TTL', '15m')), refreshTtlSeconds: parseDurationSeconds('REFRESH_TOKEN_TTL', optional('REFRESH_TOKEN_TTL', '30d')), }, + notifications: buildNotificationsConfig(), }); diff --git a/packages/backend/src/db/schema.ts b/packages/backend/src/db/schema.ts index e4d330a..ad6ba80 100644 --- a/packages/backend/src/db/schema.ts +++ b/packages/backend/src/db/schema.ts @@ -84,6 +84,36 @@ export interface ReadStateTable { updated_at: ColumnType; } +export interface DeviceTokensTable { + id: Generated; + user_id: string; + platform: string; + token: string; + web_p256dh: string | null; + web_auth: string | null; + failure_count: Generated; + last_success_at: Date | null; + disabled_at: Date | null; + created_at: Generated; + updated_at: ColumnType; +} + +export interface NotificationSettingsTable { + user_id: string; + enabled: Generated; + quiet_hours_start: number | null; + quiet_hours_end: number | null; + timezone: Generated; + updated_at: ColumnType; +} + +export interface ConversationMutesTable { + conversation_id: string; + user_id: string; + muted_until: Date | null; + created_at: Generated; +} + export interface Database { users: UsersTable; refresh_tokens: RefreshTokensTable; @@ -93,4 +123,7 @@ export interface Database { contacts: ContactsTable; reactions: ReactionsTable; read_state: ReadStateTable; + device_tokens: DeviceTokensTable; + notification_settings: NotificationSettingsTable; + conversation_mutes: ConversationMutesTable; } diff --git a/packages/backend/src/modules/messages/messages.service.ts b/packages/backend/src/modules/messages/messages.service.ts index aabce28..8e9cd68 100644 --- a/packages/backend/src/modules/messages/messages.service.ts +++ b/packages/backend/src/modules/messages/messages.service.ts @@ -18,6 +18,8 @@ export interface MessagesServiceDeps { conversations: ConversationsRepository; deliver: Deliver; mediaDownloadUrl: (objectKey: string) => Promise; + /** Fire-and-forget push-notification hook, called for each newly-created message. */ + notify: (message: Message) => void; } export interface SentMessage { @@ -56,7 +58,7 @@ export interface MessagesService { } export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => { - const { messages, conversations, deliver, mediaDownloadUrl } = deps; + const { messages, conversations, deliver, mediaDownloadUrl, notify } = deps; const assertMember = async (conversationId: string, userId: string): Promise => { if (!(await conversations.isMember(conversationId, userId))) { @@ -110,6 +112,8 @@ export const createMessagesService = (deps: MessagesServiceDeps): MessagesServic await conversations.touchLastMessage(conversationId); const event: MessageNewEvent = { type: EventType.MessageNew, message }; await deliver(conversationId, event); + // Enqueue a push job (offline recipients only; online ones get it live). + notify(message); } return { message, created: inserted !== undefined }; }, diff --git a/packages/backend/src/modules/notifications/index.ts b/packages/backend/src/modules/notifications/index.ts new file mode 100644 index 0000000..511f9b0 --- /dev/null +++ b/packages/backend/src/modules/notifications/index.ts @@ -0,0 +1,15 @@ +export { createNotificationsRepository } from './notifications.repository'; +export type { NotificationsRepository } from './notifications.repository'; +export { createNotificationsService } from './notifications.service'; +export type { NotificationsService } from './notifications.service'; +export { createDispatcher } from './notifications.dispatch'; +export type { Dispatcher, DispatcherDeps } from './notifications.dispatch'; +export { + createNotificationQueue, + createBullConnection, + MESSAGE_JOB, +} from './notifications.queue'; +export type { NotificationQueue, MessageJobData } from './notifications.queue'; +export { createWebPushProvider, createFcmProvider } from './providers'; +export type { PushProvider } from './providers'; +export { notificationsRoutes } from './notifications.routes'; diff --git a/packages/backend/src/modules/notifications/notifications.dispatch.ts b/packages/backend/src/modules/notifications/notifications.dispatch.ts new file mode 100644 index 0000000..4ee2341 --- /dev/null +++ b/packages/backend/src/modules/notifications/notifications.dispatch.ts @@ -0,0 +1,173 @@ +import type { Message, MediaKind, PushPayload } from '@altricade/core'; +import type { MessagesRepository } from '../messages'; +import { toMessage } from '../messages'; +import type { ConversationsRepository, ConversationRow } from '../conversations'; +import type { NotificationsRepository, SettingsRow } from './notifications.repository'; +import type { PushProvider } from './providers'; +import type { MessageJobData } from './notifications.queue'; + +export interface DispatcherDeps { + repo: NotificationsRepository; + messages: Pick; + conversations: Pick; + /** True when the user has a live realtime connection → in-app handles it, no push. */ + isOnline: (userId: string) => Promise; + /** Push providers keyed by platform ('web' | 'android' | 'ios'). */ + providers: Map; + log: (message: string) => void; +} + +export interface Dispatcher { + handleMessageJob(data: MessageJobData): Promise; +} + +const BODY_MAX = 140; + +const mediaPlaceholder = (kind: MediaKind): string => { + switch (kind) { + case 'image': + return 'Photo'; + case 'video': + return 'Video'; + case 'voice': + return 'Voice message'; + default: + return 'File'; + } +}; + +const preview = (message: Message): string => { + if (message.content.length > 0) { + return message.content.length > BODY_MAX + ? `${message.content.slice(0, BODY_MAX)}…` + : message.content; + } + if (message.media !== null) { + return mediaPlaceholder(message.media.kind); + } + return 'New message'; +}; + +const buildPayload = (message: Message, conversation: ConversationRow): PushPayload => { + const text = preview(message); + const isGroup = conversation.type === 'group'; + return { + kind: 'message', + conversationId: message.conversationId, + messageId: message.id, + senderId: message.senderId, + title: isGroup ? (conversation.title ?? 'Group') : message.sender.displayName, + body: isGroup ? `${message.sender.displayName}: ${text}` : text, + }; +}; + +// Minutes since local midnight in the given IANA timezone, or null on a bad zone. +const localMinutes = (timezone: string): number | null => { + try { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(new Date()); + const hour = parts.find((part) => part.type === 'hour')?.value; + const minute = parts.find((part) => part.type === 'minute')?.value; + if (hour === undefined || minute === undefined) { + return null; + } + const hours = Number.parseInt(hour, 10); + const minutes = Number.parseInt(minute, 10); + if (Number.isNaN(hours) || Number.isNaN(minutes)) { + return null; + } + return (hours % 24) * 60 + minutes; + } catch { + return null; + } +}; + +const inQuietHours = (settings: SettingsRow | undefined): boolean => { + if (settings === undefined) { + return false; + } + const start = settings.quiet_hours_start; + const end = settings.quiet_hours_end; + if (start === null || end === null || start === end) { + return false; + } + const now = localMinutes(settings.timezone); + if (now === null) { + return false; + } + // Same-day window vs. an overnight window that wraps past midnight. + return start < end ? now >= start && now < end : now >= start || now < end; +}; + +export const createDispatcher = (deps: DispatcherDeps): Dispatcher => { + const { repo, messages, conversations, isOnline, providers, log } = deps; + + const deliverToUser = async (userId: string, payload: PushPayload): Promise => { + const devices = await repo.activeDevicesFor(userId); + for (const device of devices) { + const provider = providers.get(device.platform); + if (provider === undefined) { + continue; + } + const result = await provider.send(device, payload); + if (result.ok) { + await repo.recordSuccess(device.id); + } else if (result.disable) { + await repo.disableDevice(device.id); + log(`disabled dead ${device.platform} token ${device.id}`); + } else { + await repo.recordFailure(device.id); + } + } + }; + + return { + handleMessageJob: async (data) => { + const row = await messages.getWithSenderById(data.messageId); + if (row === undefined) { + return; + } + if (row.deleted_at !== null) { + return; + } + const message = toMessage(row); + const conversation = await conversations.findById(data.conversationId); + if (conversation === undefined) { + return; + } + const members = await conversations.listMembers(data.conversationId); + const recipients = members + .map((member) => member.user_id) + .filter((userId) => userId !== data.senderId); + if (recipients.length === 0) { + return; + } + + const settingsMap = await repo.getSettingsFor(recipients); + const mutedSet = await repo.mutedUserIds(data.conversationId, recipients); + const payload = buildPayload(message, conversation); + + for (const userId of recipients) { + const settings = settingsMap.get(userId); + if (settings !== undefined && !settings.enabled) { + continue; + } + if (mutedSet.has(userId)) { + continue; + } + if (inQuietHours(settings)) { + continue; + } + // Online users receive the message live + an in-app toast — skip push. + if (await isOnline(userId)) { + continue; + } + await deliverToUser(userId, payload); + } + }, + }; +}; diff --git a/packages/backend/src/modules/notifications/notifications.queue.ts b/packages/backend/src/modules/notifications/notifications.queue.ts new file mode 100644 index 0000000..ae148cd --- /dev/null +++ b/packages/backend/src/modules/notifications/notifications.queue.ts @@ -0,0 +1,45 @@ +import { Queue } from 'bullmq'; +import { Redis } from 'ioredis'; + +// A new message that may warrant a push. Only ids travel through the queue; the +// worker re-loads fresh state so nothing goes stale between enqueue and delivery. +export const MESSAGE_JOB = 'message'; + +export interface MessageJobData { + conversationId: string; + messageId: string; + senderId: string; +} + +export interface NotificationQueue { + enqueueMessage(data: MessageJobData): Promise; + close(): Promise; +} + +// BullMQ requires a dedicated connection with retry-per-request disabled. +export const createBullConnection = (redisUrl: string): Redis => + new Redis(redisUrl, { maxRetriesPerRequest: null }); + +export const createNotificationQueue = ( + queueName: string, + connection: Redis, +): NotificationQueue => { + const queue = new Queue(queueName, { + connection, + defaultJobOptions: { + attempts: 5, + backoff: { type: 'exponential', delay: 2000 }, + removeOnComplete: { count: 1000 }, + removeOnFail: { count: 5000 }, + }, + }); + + return { + enqueueMessage: async (data) => { + await queue.add(MESSAGE_JOB, data); + }, + close: async () => { + await queue.close(); + }, + }; +}; diff --git a/packages/backend/src/modules/notifications/notifications.repository.ts b/packages/backend/src/modules/notifications/notifications.repository.ts new file mode 100644 index 0000000..3b6cfc6 --- /dev/null +++ b/packages/backend/src/modules/notifications/notifications.repository.ts @@ -0,0 +1,191 @@ +import type { Kysely, Selectable } from 'kysely'; +import type { Database, NotificationSettingsTable } from '../../db/schema'; + +export type SettingsRow = Selectable; + +// Row shape used when actually sending (carries the push credentials). +export interface SendableDevice { + id: string; + platform: string; + token: string; + web_p256dh: string | null; + web_auth: string | null; +} + +export interface DeviceApiRow { + id: string; + platform: string; + created_at: Date; + last_success_at: Date | null; +} + +export interface UpsertDeviceInput { + userId: string; + platform: string; + token: string; + p256dh: string | null; + auth: string | null; +} + +// Snake-cased columns the settings upsert may change; camel→snake happens in the service. +export interface SettingsPatch { + enabled?: boolean; + quiet_hours_start?: number | null; + quiet_hours_end?: number | null; + timezone?: string; +} + +export interface NotificationsRepository { + upsertDevice(input: UpsertDeviceInput): Promise; + deleteDevice(userId: string, token: string): Promise; + listDevicesForApi(userId: string): Promise; + activeDevicesFor(userId: string): Promise; + disableDevice(id: string): Promise; + recordSuccess(id: string): Promise; + recordFailure(id: string): Promise; + getSettings(userId: string): Promise; + getSettingsFor(userIds: string[]): Promise>; + upsertSettings(userId: string, patch: SettingsPatch): Promise; + mute(conversationId: string, userId: string, mutedUntil: Date | null): Promise; + unmute(conversationId: string, userId: string): Promise; + mutedUserIds(conversationId: string, userIds: string[]): Promise>; +} + +export const createNotificationsRepository = (db: Kysely): NotificationsRepository => ({ + upsertDevice: async ({ userId, platform, token, p256dh, auth }) => { + await db + .insertInto('device_tokens') + .values({ user_id: userId, platform, token, web_p256dh: p256dh, web_auth: auth }) + .onConflict((oc) => + oc.column('token').doUpdateSet({ + user_id: userId, + platform, + web_p256dh: p256dh, + web_auth: auth, + disabled_at: null, + failure_count: 0, + updated_at: new Date(), + }), + ) + .execute(); + }, + + deleteDevice: async (userId, token) => { + await db + .deleteFrom('device_tokens') + .where('user_id', '=', userId) + .where('token', '=', token) + .execute(); + }, + + listDevicesForApi: (userId) => + db + .selectFrom('device_tokens') + .where('user_id', '=', userId) + .where('disabled_at', 'is', null) + .select(['id', 'platform', 'created_at', 'last_success_at']) + .orderBy('created_at', 'desc') + .execute(), + + activeDevicesFor: (userId) => + db + .selectFrom('device_tokens') + .where('user_id', '=', userId) + .where('disabled_at', 'is', null) + .select(['id', 'platform', 'token', 'web_p256dh', 'web_auth']) + .execute(), + + disableDevice: async (id) => { + await db + .updateTable('device_tokens') + .set({ disabled_at: new Date(), updated_at: new Date() }) + .where('id', '=', id) + .execute(); + }, + + recordSuccess: async (id) => { + await db + .updateTable('device_tokens') + .set({ last_success_at: new Date(), failure_count: 0, updated_at: new Date() }) + .where('id', '=', id) + .execute(); + }, + + recordFailure: async (id) => { + await db + .updateTable('device_tokens') + .set((eb) => ({ failure_count: eb('failure_count', '+', 1), updated_at: new Date() })) + .where('id', '=', id) + .execute(); + }, + + getSettings: (userId) => + db + .selectFrom('notification_settings') + .where('user_id', '=', userId) + .selectAll() + .executeTakeFirst(), + + getSettingsFor: async (userIds) => { + if (userIds.length === 0) { + return new Map(); + } + const rows = await db + .selectFrom('notification_settings') + .where('user_id', 'in', userIds) + .selectAll() + .execute(); + return new Map(rows.map((row) => [row.user_id, row])); + }, + + upsertSettings: async (userId, patch) => { + await db + .insertInto('notification_settings') + .values({ user_id: userId, ...patch }) + .onConflict((oc) => + oc.column('user_id').doUpdateSet({ ...patch, updated_at: new Date() }), + ) + .execute(); + const row = await db + .selectFrom('notification_settings') + .where('user_id', '=', userId) + .selectAll() + .executeTakeFirstOrThrow(); + return row; + }, + + mute: async (conversationId, userId, mutedUntil) => { + await db + .insertInto('conversation_mutes') + .values({ conversation_id: conversationId, user_id: userId, muted_until: mutedUntil }) + .onConflict((oc) => + oc.columns(['conversation_id', 'user_id']).doUpdateSet({ muted_until: mutedUntil }), + ) + .execute(); + }, + + unmute: async (conversationId, userId) => { + await db + .deleteFrom('conversation_mutes') + .where('conversation_id', '=', conversationId) + .where('user_id', '=', userId) + .execute(); + }, + + mutedUserIds: async (conversationId, userIds) => { + if (userIds.length === 0) { + return new Set(); + } + const now = new Date(); + const rows = await db + .selectFrom('conversation_mutes') + .where('conversation_id', '=', conversationId) + .where('user_id', 'in', userIds) + .where((eb) => + eb.or([eb('muted_until', 'is', null), eb('muted_until', '>', now)]), + ) + .select('user_id') + .execute(); + return new Set(rows.map((row) => row.user_id)); + }, +}); diff --git a/packages/backend/src/modules/notifications/notifications.routes.ts b/packages/backend/src/modules/notifications/notifications.routes.ts new file mode 100644 index 0000000..3e60ae9 --- /dev/null +++ b/packages/backend/src/modules/notifications/notifications.routes.ts @@ -0,0 +1,184 @@ +import type { FastifyInstance } from 'fastify'; +import { + registerDeviceBodySchema, + updateSettingsBodySchema, + muteConversationBodySchema, + vapidPublicKeySchema, + notificationSettingsSchema, + deviceListSchema, + errorSchema, +} from '@altricade/core'; +import type { + RegisterDeviceBody, + UpdateSettingsBody, + MuteConversationBody, +} from '@altricade/core'; + +const bearerAuth = [{ bearerAuth: [] }]; + +const unregisterBodySchema = { + type: 'object', + additionalProperties: false, + required: ['token'], + properties: { token: { type: 'string', minLength: 1, maxLength: 2048 } }, +} as const; + +const muteParamsSchema = { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', format: 'uuid' } }, +} as const; + +export const notificationsRoutes = (app: FastifyInstance): Promise => { + // Public: the VAPID public key is not a secret; the web client needs it to subscribe. + app.get( + '/notifications/vapid-public-key', + { + schema: { + tags: ['notifications'], + summary: 'Get the Web Push (VAPID) public key', + response: { 200: vapidPublicKeySchema }, + }, + }, + async (_request, reply) => + reply.send({ publicKey: app.notificationsService.vapidPublicKey() }), + ); + + app.post<{ Body: RegisterDeviceBody }>( + '/notifications/devices', + { + schema: { + tags: ['notifications'], + summary: 'Register (or re-bind) a push device', + security: bearerAuth, + body: registerDeviceBodySchema, + response: { 204: { type: 'null' }, 400: errorSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.notificationsService.registerDevice(user.id, request.body); + return reply.code(204).send(); + }, + ); + + app.delete<{ Body: { token: string } }>( + '/notifications/devices', + { + schema: { + tags: ['notifications'], + summary: 'Unregister a push device', + security: bearerAuth, + body: unregisterBodySchema, + response: { 204: { type: 'null' }, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.notificationsService.unregisterDevice(user.id, request.body.token); + return reply.code(204).send(); + }, + ); + + app.get( + '/notifications/devices', + { + schema: { + tags: ['notifications'], + summary: 'List my registered push devices', + security: bearerAuth, + response: { 200: deviceListSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.notificationsService.listDevices(user.id)); + }, + ); + + app.get( + '/notifications/settings', + { + schema: { + tags: ['notifications'], + summary: 'Get my notification settings', + security: bearerAuth, + response: { 200: notificationSettingsSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.notificationsService.getSettings(user.id)); + }, + ); + + app.patch<{ Body: UpdateSettingsBody }>( + '/notifications/settings', + { + schema: { + tags: ['notifications'], + summary: 'Update my notification settings', + security: bearerAuth, + body: updateSettingsBodySchema, + response: { 200: notificationSettingsSchema, 400: errorSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.notificationsService.updateSettings(user.id, request.body)); + }, + ); + + app.post<{ Params: { id: string }; Body: MuteConversationBody }>( + '/conversations/:id/mute', + { + schema: { + tags: ['notifications'], + summary: 'Mute a conversation', + security: bearerAuth, + params: muteParamsSchema, + body: muteConversationBodySchema, + response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.notificationsService.mute(user.id, request.params.id, request.body); + return reply.code(204).send(); + }, + ); + + app.delete<{ Params: { id: string } }>( + '/conversations/:id/mute', + { + schema: { + tags: ['notifications'], + summary: 'Unmute a conversation', + security: bearerAuth, + params: muteParamsSchema, + response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.notificationsService.unmute(user.id, request.params.id); + return reply.code(204).send(); + }, + ); + + return Promise.resolve(); +}; diff --git a/packages/backend/src/modules/notifications/notifications.service.ts b/packages/backend/src/modules/notifications/notifications.service.ts new file mode 100644 index 0000000..250a5f8 --- /dev/null +++ b/packages/backend/src/modules/notifications/notifications.service.ts @@ -0,0 +1,139 @@ +import type { + NotificationSettings, + Device, + RegisterDeviceBody, + UpdateSettingsBody, + MuteConversationBody, +} from '@altricade/core'; +import { HttpError } from '../../shared/http-error'; +import type { + NotificationsRepository, + SettingsRow, + SettingsPatch, + DeviceApiRow, +} from './notifications.repository'; + +export interface NotificationsServiceDeps { + repo: NotificationsRepository; + webPushPublicKey: string; + isMember: (conversationId: string, userId: string) => Promise; +} + +export interface NotificationsService { + vapidPublicKey(): string; + registerDevice(userId: string, body: RegisterDeviceBody): Promise; + unregisterDevice(userId: string, token: string): Promise; + listDevices(userId: string): Promise; + getSettings(userId: string): Promise; + updateSettings(userId: string, body: UpdateSettingsBody): Promise; + mute(userId: string, conversationId: string, body: MuteConversationBody): Promise; + unmute(userId: string, conversationId: string): Promise; +} + +const DEFAULT_SETTINGS: NotificationSettings = { + enabled: true, + quietHoursStart: null, + quietHoursEnd: null, + timezone: 'UTC', +}; + +const toSettings = (row: SettingsRow | undefined): NotificationSettings => { + if (row === undefined) { + return DEFAULT_SETTINGS; + } + return { + enabled: row.enabled, + quietHoursStart: row.quiet_hours_start, + quietHoursEnd: row.quiet_hours_end, + timezone: row.timezone, + }; +}; + +const toDevice = (row: DeviceApiRow): Device => { + if (row.platform !== 'web' && row.platform !== 'android' && row.platform !== 'ios') { + throw new HttpError(500, 'internal_error', 'Unknown device platform'); + } + return { + id: row.id, + platform: row.platform, + createdAt: row.created_at.toISOString(), + lastSuccessAt: row.last_success_at === null ? null : row.last_success_at.toISOString(), + }; +}; + +export const createNotificationsService = ( + deps: NotificationsServiceDeps, +): NotificationsService => { + const { repo, webPushPublicKey, isMember } = deps; + + const assertMember = async (conversationId: string, userId: string): Promise => { + if (!(await isMember(conversationId, userId))) { + throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation'); + } + }; + + return { + vapidPublicKey: () => webPushPublicKey, + + registerDevice: async (userId, body) => { + if (body.platform === 'web' && (body.p256dh === undefined || body.auth === undefined)) { + throw new HttpError(400, 'invalid_subscription', 'Web push requires p256dh and auth keys'); + } + await repo.upsertDevice({ + userId, + platform: body.platform, + token: body.token, + p256dh: body.p256dh ?? null, + auth: body.auth ?? null, + }); + }, + + unregisterDevice: async (userId, token) => { + await repo.deleteDevice(userId, token); + }, + + listDevices: async (userId) => { + const rows = await repo.listDevicesForApi(userId); + return rows.map(toDevice); + }, + + getSettings: async (userId) => toSettings(await repo.getSettings(userId)), + + updateSettings: async (userId, body) => { + const patch: SettingsPatch = {}; + if (body.enabled !== undefined) { + patch.enabled = body.enabled; + } + if (body.quietHoursStart !== undefined) { + patch.quiet_hours_start = body.quietHoursStart; + } + if (body.quietHoursEnd !== undefined) { + patch.quiet_hours_end = body.quietHoursEnd; + } + if (body.timezone !== undefined) { + patch.timezone = body.timezone; + } + return toSettings(await repo.upsertSettings(userId, patch)); + }, + + mute: async (userId, conversationId, body) => { + await assertMember(conversationId, userId); + const until = + body.mutedUntil === undefined || body.mutedUntil === null + ? null + : new Date(body.mutedUntil); + await repo.mute(conversationId, userId, until); + }, + + unmute: async (userId, conversationId) => { + await assertMember(conversationId, userId); + await repo.unmute(conversationId, userId); + }, + }; +}; + +declare module 'fastify' { + interface FastifyInstance { + notificationsService: NotificationsService; + } +} diff --git a/packages/backend/src/modules/notifications/providers/fcm.provider.ts b/packages/backend/src/modules/notifications/providers/fcm.provider.ts new file mode 100644 index 0000000..df206ce --- /dev/null +++ b/packages/backend/src/modules/notifications/providers/fcm.provider.ts @@ -0,0 +1,46 @@ +import { initializeApp, cert } from 'firebase-admin/app'; +import { getMessaging } from 'firebase-admin/messaging'; +import type { PushProvider } from './provider'; + +const errorCode = (error: unknown): string => { + if (typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string') { + return error.code; + } + return ''; +}; + +// Token errors that mean the registration is permanently invalid — retire it. +const DEAD_TOKEN_CODES = new Set([ + 'messaging/registration-token-not-registered', + 'messaging/invalid-registration-token', + 'messaging/invalid-argument', +]); + +// Firebase Cloud Messaging provider (HTTP v1 via firebase-admin). Handles both +// Android and iOS — iOS just needs an APNs key uploaded to Firebase later, with +// no code change here. `cert` reads the service-account JSON from a file path. +export const createFcmProvider = (serviceAccountFile: string): PushProvider => { + const app = initializeApp({ credential: cert(serviceAccountFile) }); + const messaging = getMessaging(app); + + return { + send: async (device, payload) => { + try { + await messaging.send({ + token: device.token, + notification: { title: payload.title, body: payload.body }, + data: { + kind: payload.kind, + conversationId: payload.conversationId, + messageId: payload.messageId ?? '', + senderId: payload.senderId, + }, + android: { priority: 'high', collapseKey: payload.conversationId }, + }); + return { ok: true }; + } catch (error) { + return { ok: false, disable: DEAD_TOKEN_CODES.has(errorCode(error)) }; + } + }, + }; +}; diff --git a/packages/backend/src/modules/notifications/providers/index.ts b/packages/backend/src/modules/notifications/providers/index.ts new file mode 100644 index 0000000..7f24ec6 --- /dev/null +++ b/packages/backend/src/modules/notifications/providers/index.ts @@ -0,0 +1,3 @@ +export type { PushProvider, PushResult } from './provider'; +export { createWebPushProvider } from './web-push.provider'; +export { createFcmProvider } from './fcm.provider'; diff --git a/packages/backend/src/modules/notifications/providers/provider.ts b/packages/backend/src/modules/notifications/providers/provider.ts new file mode 100644 index 0000000..0995594 --- /dev/null +++ b/packages/backend/src/modules/notifications/providers/provider.ts @@ -0,0 +1,11 @@ +import type { PushPayload } from '@altricade/core'; +import type { SendableDevice } from '../notifications.repository'; + +// A single delivery attempt's outcome. `disable` marks a permanently-dead token +// (unsubscribed / unregistered) so the worker can retire it; transient failures +// leave the token active for the next message. +export type PushResult = { ok: true } | { ok: false; disable: boolean }; + +export interface PushProvider { + send(device: SendableDevice, payload: PushPayload): Promise; +} diff --git a/packages/backend/src/modules/notifications/providers/web-push.provider.ts b/packages/backend/src/modules/notifications/providers/web-push.provider.ts new file mode 100644 index 0000000..4a05e54 --- /dev/null +++ b/packages/backend/src/modules/notifications/providers/web-push.provider.ts @@ -0,0 +1,32 @@ +import webpush from 'web-push'; +import type { WebPushConfig } from '../../../config'; +import type { PushProvider } from './provider'; + +// Web Push (VAPID) provider. Serializes the PushPayload as the notification body; +// the service worker parses it to render the notification and route the tap. +export const createWebPushProvider = (config: WebPushConfig): PushProvider => { + webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey); + + return { + send: async (device, payload) => { + if (device.web_p256dh === null || device.web_auth === null) { + return { ok: false, disable: true }; + } + try { + await webpush.sendNotification( + { + endpoint: device.token, + keys: { p256dh: device.web_p256dh, auth: device.web_auth }, + }, + JSON.stringify(payload), + { TTL: 600 }, + ); + return { ok: true }; + } catch (error) { + // 404/410 => the browser dropped the subscription; retire the token. + const status = error instanceof webpush.WebPushError ? error.statusCode : 0; + return { ok: false, disable: status === 404 || status === 410 }; + } + }, + }; +}; diff --git a/packages/backend/src/plugins/centrifugo.ts b/packages/backend/src/plugins/centrifugo.ts index 34feacd..48811d1 100644 --- a/packages/backend/src/plugins/centrifugo.ts +++ b/packages/backend/src/plugins/centrifugo.ts @@ -26,45 +26,39 @@ declare module 'fastify' { } // Thin wrapper over Centrifugo's server HTTP API. Centrifugo is a dumb pipe: the -// backend is the only publisher. Wired now; actually used from Phase 2 onward. +// backend is the only publisher. Exported as a plain factory so the standalone +// notifications worker can reuse it (presence checks) without a Fastify instance. +export const createCentrifugoClient = (apiUrl: string, apiKey: string): CentrifugoClient => ({ + async publish(channel, data) { + const response = await fetch(`${apiUrl}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, + body: JSON.stringify({ channel, data }), + }); + if (!response.ok) { + throw new Error(`Centrifugo publish failed with status ${String(response.status)}`); + } + }, + + async presenceStats(channel) { + const response = await fetch(`${apiUrl}/presence_stats`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, + body: JSON.stringify({ channel }), + }); + if (!response.ok) { + return 0; + } + const data: unknown = await response.json(); + return extractNumClients(data); + }, +}); + +// Wired now; actually used from Phase 2 onward. export const centrifugoPlugin = fp( (app) => { const { apiUrl, apiKey } = app.config.centrifugo; - - const client: CentrifugoClient = { - async publish(channel, data) { - const response = await fetch(`${apiUrl}/publish`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': apiKey, - }, - body: JSON.stringify({ channel, data }), - }); - if (!response.ok) { - throw new Error(`Centrifugo publish failed with status ${String(response.status)}`); - } - }, - - async presenceStats(channel) { - const response = await fetch(`${apiUrl}/presence_stats`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': apiKey, - }, - body: JSON.stringify({ channel }), - }); - if (!response.ok) { - return 0; - } - const data: unknown = await response.json(); - return extractNumClients(data); - }, - }; - - app.decorate('centrifugo', client); - + app.decorate('centrifugo', createCentrifugoClient(apiUrl, apiKey)); return Promise.resolve(); }, { name: 'centrifugo' }, diff --git a/packages/backend/src/worker.ts b/packages/backend/src/worker.ts new file mode 100644 index 0000000..ca5b908 --- /dev/null +++ b/packages/backend/src/worker.ts @@ -0,0 +1,95 @@ +import pg from 'pg'; +import { Kysely, PostgresDialect } from 'kysely'; +import { Worker } from 'bullmq'; +import { userChannel } from '@altricade/core'; +import { loadConfig } from './config'; +import type { Database } from './db/schema'; +import { createMessagesRepository } from './modules/messages'; +import { createConversationsRepository } from './modules/conversations'; +import { + createNotificationsRepository, + createDispatcher, + createBullConnection, + createWebPushProvider, + createFcmProvider, + MESSAGE_JOB, +} from './modules/notifications'; +import type { PushProvider, MessageJobData } from './modules/notifications'; +import { createCentrifugoClient } from './plugins/centrifugo'; + +// Match the backend's int8 parsing so seq/counters come back as JS numbers. +pg.types.setTypeParser(20, (value) => Number(value)); + +const log = (message: string): void => { + // Worker logs go straight to stdout for docker log capture. + process.stdout.write(`[notifications] ${message}\n`); +}; + +const start = (): void => { + const config = loadConfig(); + const pool = new pg.Pool({ connectionString: config.databaseUrl }); + const db = new Kysely({ dialect: new PostgresDialect({ pool }) }); + const centrifugo = createCentrifugoClient(config.centrifugo.apiUrl, config.centrifugo.apiKey); + + const providers = new Map(); + if (config.notifications.webPush !== null) { + providers.set('web', createWebPushProvider(config.notifications.webPush)); + log('web push provider ready'); + } else { + log('web push disabled (no VAPID keys configured)'); + } + if (config.notifications.fcmServiceAccountFile !== null) { + const fcm = createFcmProvider(config.notifications.fcmServiceAccountFile); + providers.set('android', fcm); + // iOS routes through FCM too; enabled the moment an APNs key is added to Firebase. + if (config.notifications.apnsEnabled) { + providers.set('ios', fcm); + } + log(`fcm provider ready (android${config.notifications.apnsEnabled ? ' + ios' : ''})`); + } else { + log('fcm disabled (no service account configured)'); + } + + const dispatcher = createDispatcher({ + repo: createNotificationsRepository(db), + messages: createMessagesRepository(db), + conversations: createConversationsRepository(db), + isOnline: async (userId) => (await centrifugo.presenceStats(userChannel(userId))) > 0, + providers, + log, + }); + + const connection = createBullConnection(config.redisUrl); + const worker = new Worker( + config.notifications.queueName, + async (job) => { + if (job.name === MESSAGE_JOB) { + await dispatcher.handleMessageJob(job.data); + } + }, + { connection, concurrency: 8 }, + ); + + worker.on('ready', () => { + log(`listening on queue "${config.notifications.queueName}"`); + }); + worker.on('failed', (job, error) => { + log(`job ${job?.id ?? '?'} failed: ${error.message}`); + }); + + const shutdown = async (signal: string): Promise => { + log(`received ${signal}, shutting down`); + await worker.close(); + await connection.quit(); + await db.destroy(); + process.exit(0); + }; + process.on('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + process.on('SIGINT', () => { + void shutdown('SIGINT'); + }); +}; + +start(); diff --git a/packages/backend/tsup.config.ts b/packages/backend/tsup.config.ts index 2054111..67a709b 100644 --- a/packages/backend/tsup.config.ts +++ b/packages/backend/tsup.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from 'tsup'; // self-contained ESM output for the slim runtime image. Third-party deps stay // external and are installed as production deps in the Docker runtime stage. export default defineConfig({ - entry: ['src/server.ts'], + entry: ['src/server.ts', 'src/worker.ts'], format: ['esm'], target: 'node22', platform: 'node', diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 9861cb3..14d7099 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -37,3 +37,13 @@ export { markRead } from './conversations'; export { getPresence, heartbeat } from './presence'; export { getUploadUrl, getAvatarUploadUrl, getMediaUrl, uploadToUrl } from './media'; export type { UploadTarget, AvatarTarget } from './media'; +export { + getVapidPublicKey, + registerDevice, + unregisterDevice, + listDevices, + getNotificationSettings, + updateNotificationSettings, + muteConversation, + unmuteConversation, +} from './notifications'; diff --git a/packages/core/src/api/notifications.ts b/packages/core/src/api/notifications.ts new file mode 100644 index 0000000..005952d --- /dev/null +++ b/packages/core/src/api/notifications.ts @@ -0,0 +1,62 @@ +import { + vapidPublicKeySchema, + notificationSettingsSchema, + deviceListSchema, +} from '../schemas/index'; +import type { + RegisterDeviceBody, + UpdateSettingsBody, + MuteConversationBody, +} from '../schemas/index'; +import type { NotificationSettings, Device } from '../types/notification'; +import { compileValidator, parse, requestJson } from './http'; +import type { ApiClientConfig } from './http'; + +const vapidV = compileValidator<{ publicKey: string }>(vapidPublicKeySchema); +const settingsV = compileValidator(notificationSettingsSchema); +const deviceListV = compileValidator(deviceListSchema); + +export const getVapidPublicKey = async (config: ApiClientConfig): Promise => { + const result = parse(vapidV, await requestJson(config, 'GET', '/notifications/vapid-public-key')); + return result.publicKey; +}; + +export const registerDevice = async ( + config: ApiClientConfig, + body: RegisterDeviceBody, +): Promise => { + await requestJson(config, 'POST', '/notifications/devices', body); +}; + +export const unregisterDevice = async (config: ApiClientConfig, token: string): Promise => { + await requestJson(config, 'DELETE', '/notifications/devices', { token }); +}; + +export const listDevices = async (config: ApiClientConfig): Promise => + parse(deviceListV, await requestJson(config, 'GET', '/notifications/devices')); + +export const getNotificationSettings = async ( + config: ApiClientConfig, +): Promise => + parse(settingsV, await requestJson(config, 'GET', '/notifications/settings')); + +export const updateNotificationSettings = async ( + config: ApiClientConfig, + body: UpdateSettingsBody, +): Promise => + parse(settingsV, await requestJson(config, 'PATCH', '/notifications/settings', body)); + +export const muteConversation = async ( + config: ApiClientConfig, + conversationId: string, + body: MuteConversationBody, +): Promise => { + await requestJson(config, 'POST', `/conversations/${conversationId}/mute`, body); +}; + +export const unmuteConversation = async ( + config: ApiClientConfig, + conversationId: string, +): Promise => { + await requestJson(config, 'DELETE', `/conversations/${conversationId}/mute`); +}; diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index eb58626..e0c1a86 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -26,6 +26,21 @@ export { mediaUrlSchema, } from './media'; export type { MediaRef, MediaKind, UploadUrlBody, AvatarUploadBody, SetAvatarBody } from './media'; +export { + registerDeviceBodySchema, + updateSettingsBodySchema, + muteConversationBodySchema, + vapidPublicKeySchema, + notificationSettingsSchema, + deviceSchema, + deviceListSchema, +} from './notifications'; +export type { + DevicePlatform, + RegisterDeviceBody, + UpdateSettingsBody, + MuteConversationBody, +} from './notifications'; export { publicUserSchema, publicUserListSchema, diff --git a/packages/core/src/schemas/notifications.ts b/packages/core/src/schemas/notifications.ts new file mode 100644 index 0000000..bafa0ab --- /dev/null +++ b/packages/core/src/schemas/notifications.ts @@ -0,0 +1,77 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +const PLATFORM = { type: 'string', enum: ['web', 'android', 'ios'] } as const; + +// Register (or re-bind) a push device. `token` is the FCM registration token for +// native, or the Web Push endpoint URL for web; web additionally carries the +// subscription keys (p256dh + auth). The backend validates that web has keys. +export const registerDeviceBodySchema = { + type: 'object', + additionalProperties: false, + required: ['platform', 'token'], + properties: { + platform: PLATFORM, + token: { type: 'string', minLength: 1, maxLength: 2048 }, + p256dh: { type: 'string', minLength: 1, maxLength: 512 }, + auth: { type: 'string', minLength: 1, maxLength: 512 }, + }, +} as const; + +// Partial update of notification preferences. `null` quiet-hours clears them. +export const updateSettingsBodySchema = { + type: 'object', + additionalProperties: false, + properties: { + enabled: { type: 'boolean' }, + quietHoursStart: { type: ['integer', 'null'], minimum: 0, maximum: 1439 }, + quietHoursEnd: { type: ['integer', 'null'], minimum: 0, maximum: 1439 }, + timezone: { type: 'string', minLength: 1, maxLength: 64 }, + }, +} as const; + +// Mute a conversation. `mutedUntil` null (or omitted) means mute indefinitely. +export const muteConversationBodySchema = { + type: 'object', + additionalProperties: false, + properties: { + mutedUntil: { type: ['string', 'null'], format: 'date-time' }, + }, +} as const; + +export const vapidPublicKeySchema = { + type: 'object', + additionalProperties: false, + required: ['publicKey'], + properties: { publicKey: { type: 'string' } }, +} as const; + +export const notificationSettingsSchema = { + type: 'object', + additionalProperties: false, + required: ['enabled', 'quietHoursStart', 'quietHoursEnd', 'timezone'], + properties: { + enabled: { type: 'boolean' }, + quietHoursStart: { type: ['integer', 'null'] }, + quietHoursEnd: { type: ['integer', 'null'] }, + timezone: { type: 'string' }, + }, +} as const; + +export const deviceSchema = { + type: 'object', + additionalProperties: false, + required: ['id', 'platform', 'createdAt', 'lastSuccessAt'], + properties: { + id: { type: 'string' }, + platform: PLATFORM, + createdAt: { type: 'string' }, + lastSuccessAt: { type: ['string', 'null'] }, + }, +} as const; + +export const deviceListSchema = { type: 'array', items: deviceSchema } as const; + +export type DevicePlatform = FromSchema; +export type RegisterDeviceBody = FromSchema; +export type UpdateSettingsBody = FromSchema; +export type MuteConversationBody = FromSchema; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index ef2bd53..89e9e40 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -7,3 +7,10 @@ export type { Conversation, ConversationType, ConversationMember } from './conve export type { Contact } from './contact'; export type { Message, ReactionSummary, MediaRef } from './message'; export type { Presence } from './presence'; +export type { + NotificationSettings, + Device, + DevicePlatform, + PushPayload, + PushKind, +} from './notification'; diff --git a/packages/core/src/types/notification.ts b/packages/core/src/types/notification.ts new file mode 100644 index 0000000..9ba9eec --- /dev/null +++ b/packages/core/src/types/notification.ts @@ -0,0 +1,33 @@ +import type { DevicePlatform } from '../schemas/notifications'; + +export type { DevicePlatform } from '../schemas/notifications'; + +export interface NotificationSettings { + enabled: boolean; + /** Minutes since local midnight, or null when quiet hours are off. */ + quietHoursStart: number | null; + quietHoursEnd: number | null; + /** IANA timezone the quiet-hours window is evaluated in. */ + timezone: string; +} + +export interface Device { + id: string; + platform: DevicePlatform; + createdAt: string; + lastSuccessAt: string | null; +} + +// The `data` payload delivered to a device (web SW / mobile handler). `kind` +// tells the client how to route the tap; `conversationId` drives the Telegram- +// style deep-link into the originating chat. +export type PushKind = 'message' | 'conversation'; + +export interface PushPayload { + kind: PushKind; + conversationId: string; + messageId: string | null; + senderId: string; + title: string; + body: string; +} diff --git a/packages/web/eslint.config.mjs b/packages/web/eslint.config.mjs index 5fa12fc..3db7609 100644 --- a/packages/web/eslint.config.mjs +++ b/packages/web/eslint.config.mjs @@ -32,7 +32,9 @@ const fsdLayerOverrides = FSD_LAYERS.flatMap((layer, index) => { }); export default [ - { ignores: ['dist/**'] }, + // `public/` holds static assets (incl. the service worker) served as-is; it is + // not part of the typed TS project, so keep it out of the typed-lint graph. + { ignores: ['dist/**', 'public/**'] }, ...base, { files: ['src/**/*.{ts,tsx}'], diff --git a/packages/web/public/sw.js b/packages/web/public/sw.js new file mode 100644 index 0000000..d17c796 --- /dev/null +++ b/packages/web/public/sw.js @@ -0,0 +1,48 @@ +/* Altricade service worker — Web Push display + Telegram-style tap-to-open. + Plain JS (runs in the SW global scope, outside the TS/React build). */ + +self.addEventListener('push', (event) => { + let payload = {}; + try { + payload = event.data ? event.data.json() : {}; + } catch { + payload = {}; + } + const title = typeof payload.title === 'string' ? payload.title : 'New message'; + const body = typeof payload.body === 'string' ? payload.body : ''; + const conversationId = typeof payload.conversationId === 'string' ? payload.conversationId : ''; + event.waitUntil( + self.registration.showNotification(title, { + body, + // Collapse multiple messages from the same chat into one notification. + tag: conversationId || undefined, + renotify: Boolean(conversationId), + data: { conversationId }, + }), + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const data = event.notification.data || {}; + const conversationId = typeof data.conversationId === 'string' ? data.conversationId : ''; + const url = conversationId ? `/?conversation=${conversationId}` : '/'; + event.waitUntil( + (async () => { + const clientList = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + for (const client of clientList) { + if ('focus' in client) { + await client.focus(); + client.postMessage({ type: 'notification.open', conversationId }); + return; + } + } + if (self.clients.openWindow) { + await self.clients.openWindow(url); + } + })(), + ); +}); diff --git a/packages/web/src/app/App.tsx b/packages/web/src/app/App.tsx index 9b45a4e..558fc63 100644 --- a/packages/web/src/app/App.tsx +++ b/packages/web/src/app/App.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { ReactElement, SyntheticEvent } from 'react'; -import type { Conversation, PublicUser, User } from '@altricade/core'; +import type { Conversation, Message, MediaRef, PublicUser, User } from '@altricade/core'; import { heartbeat, getAvatarUploadUrl, uploadToUrl, setAvatar } from '@altricade/core/api'; import { SessionProvider, useSession } from '../entities/session'; import { AuthForm } from '../features/auth'; @@ -8,12 +8,29 @@ import { RealtimeProvider, useRealtime } from '../features/realtime'; import { useConversations, ConversationSidebar } from '../features/conversations'; import { ContactsPanel } from '../features/contacts'; import { ChatView } from '../features/messaging'; +import { NotificationsProvider, useNotifications, unregisterWebPush } from '../features/notifications'; import { apiConfig } from '../shared/api'; import { useTheme } from '../shared/theme'; import type { ThemePreference } from '../shared/theme'; const PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system']; +const mediaLabel = (media: MediaRef | null): string => { + if (media === null) { + return 'New message'; + } + switch (media.kind) { + case 'image': + return 'Photo'; + case 'video': + return 'Video'; + case 'voice': + return 'Voice message'; + default: + return 'File'; + } +}; + const ThemeSwitch = (): ReactElement => { const { preference, setPreference } = useTheme(); return ( @@ -43,6 +60,55 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re }; const { updateUser } = useSession(); const { state } = useRealtime(); + const { notify, setOpener } = useNotifications(); + const [current, setCurrent] = useState(null); + const convRef = useRef([]); + + // Toast (or OS notification when hidden) for messages arriving in other chats. + const onIncoming = useCallback( + (message: Message): void => { + const conversation = convRef.current.find((item) => item.id === message.conversationId); + const isGroup = conversation?.type === 'group'; + const text = message.content.length > 0 ? message.content : mediaLabel(message.media); + notify({ + conversationId: message.conversationId, + title: isGroup ? (conversation.title ?? 'Group') : message.sender.displayName, + body: isGroup ? `${message.sender.displayName}: ${text}` : text, + }); + }, + [notify], + ); + + const { conversations, onlineMap, startDirect, createGroupChat } = useConversations( + user.id, + current?.id ?? null, + onIncoming, + ); + convRef.current = conversations; + + // Let notification taps (in-app toast or SW message) open the right chat. + useEffect(() => { + setOpener((conversationId) => { + const conversation = convRef.current.find((item) => item.id === conversationId); + if (conversation !== undefined) { + setCurrent(conversation); + } + }); + }, [setOpener]); + + // Deep-link: /?conversation= (from a push opened in a fresh tab). + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const target = params.get('conversation'); + if (target === null || conversations.length === 0) { + return; + } + const conversation = conversations.find((item) => item.id === target); + if (conversation !== undefined) { + setCurrent(conversation); + window.history.replaceState({}, '', window.location.pathname); + } + }, [conversations]); const onAvatar = (event: SyntheticEvent): void => { const input = event.currentTarget; @@ -58,11 +124,6 @@ const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): Re updateUser(await setAvatar(apiConfig, { objectKey: target.objectKey })); })(); }; - const [current, setCurrent] = useState(null); - const { conversations, onlineMap, startDirect, createGroupChat } = useConversations( - user.id, - current?.id ?? null, - ); // Keep-alive heartbeat so our own last-seen stays fresh while connected. useEffect(() => { @@ -144,12 +205,18 @@ const Shell = (): ReactElement => { } return ( - { - void logout(); - }} - /> + + { + // Detach this browser's push subscription before the token clears. + void (async () => { + await unregisterWebPush(); + await logout(); + })(); + }} + /> + ); }; diff --git a/packages/web/src/app/index.css b/packages/web/src/app/index.css index 9323fb3..3fab69a 100644 --- a/packages/web/src/app/index.css +++ b/packages/web/src/app/index.css @@ -479,12 +479,66 @@ body { cursor: pointer; } -.attach-btn { +.icon-btn { display: inline-flex; align-items: center; - padding: 0 0.5rem; + justify-content: center; + width: 38px; + height: 38px; + flex: 0 0 auto; + padding: 0; + border: 1px solid transparent; + border-radius: 8px; + background: none; + color: var(--color-textMuted); cursor: pointer; - font-size: 1.2rem; +} + +.icon-btn:hover { + background: var(--color-surface); + color: var(--color-text); +} + +.icon-btn.send { + background: var(--color-accent); + border-color: var(--color-accent); + color: #fff; +} + +.recording-bar { + align-items: center; +} + +.record-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #e5484d; + animation: record-pulse 1s ease-in-out infinite; +} + +.record-label { + flex: 1; + color: var(--color-text); + font-size: 0.9rem; +} + +.record-preview { + width: 72px; + height: 72px; + border-radius: 8px; + object-fit: cover; + background: #000; +} + +@keyframes record-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } } .media-img { @@ -501,5 +555,58 @@ body { } .media-file { + display: inline-flex; + align-items: center; + gap: 0.35rem; color: var(--color-accent); } + +/* In-app notification toasts */ +.toast-stack { + position: fixed; + top: 1rem; + right: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 1000; + max-width: min(360px, calc(100vw - 2rem)); +} + +.toast { + display: flex; + flex-direction: column; + gap: 0.15rem; + text-align: left; + padding: 0.75rem 1rem; + border-radius: 12px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + box-shadow: 0 6px 20px rgb(0 0 0 / 18%); + cursor: pointer; + animation: toast-in 0.18s ease; +} + +.toast-title { + font-size: 0.9rem; +} + +.toast-body { + font-size: 0.85rem; + color: var(--color-textMuted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/packages/web/src/features/conversations/model.ts b/packages/web/src/features/conversations/model.ts index 9797a51..747363d 100644 --- a/packages/web/src/features/conversations/model.ts +++ b/packages/web/src/features/conversations/model.ts @@ -32,7 +32,11 @@ const upsert = (list: Conversation[], conversation: Conversation): Conversation[ const byRecency = (a: Conversation, b: Conversation): number => b.lastMessageAt.localeCompare(a.lastMessageAt); -export const useConversations = (userId: string, currentId: string | null): UseConversations => { +export const useConversations = ( + userId: string, + currentId: string | null, + onIncoming?: (message: Message) => void, +): UseConversations => { const { subscribe, onPresence, presence } = useRealtime(); const [conversations, setConversations] = useState([]); const [loading, setLoading] = useState(true); @@ -40,6 +44,8 @@ export const useConversations = (userId: string, currentId: string | null): UseC const presentByChannel = useRef>>(new Map()); const currentIdRef = useRef(currentId); currentIdRef.current = currentId; + const onIncomingRef = useRef(onIncoming); + onIncomingRef.current = onIncoming; const recomputeOnline = useCallback((): void => { const online: Record = {}; @@ -56,13 +62,17 @@ export const useConversations = (userId: string, currentId: string | null): UseC if (message.senderId === userId) { return; } + const isCurrent = currentIdRef.current === message.conversationId; + // Online = app open, so surface an in-app toast for messages in other chats. + if (!isCurrent) { + onIncomingRef.current?.(message); + } setConversations((prev) => prev .map((c) => { if (c.id !== message.conversationId) { return c; } - const isCurrent = currentIdRef.current === c.id; return { ...c, lastMessageAt: message.createdAt, diff --git a/packages/web/src/features/messaging/recorder.ts b/packages/web/src/features/messaging/recorder.ts new file mode 100644 index 0000000..b84c39d --- /dev/null +++ b/packages/web/src/features/messaging/recorder.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export type RecordKind = 'voice' | 'video'; + +export interface Recorder { + /** The kind currently recording, or null when idle. */ + recording: RecordKind | null; + elapsedMs: number; + /** Live camera stream while recording video (for an in-composer preview). */ + previewStream: MediaStream | null; + start: (kind: RecordKind) => void; + /** Stop and send the recording. */ + finish: () => void; + /** Stop and discard. */ + cancel: () => void; +} + +// In-app voice/video message capture via MediaRecorder. Produces a webm File and +// hands it to `onComplete`, which uploads + sends it as a media message. +export const useRecorder = (onComplete: (file: File) => void): Recorder => { + const [recording, setRecording] = useState(null); + const [elapsedMs, setElapsedMs] = useState(0); + const [previewStream, setPreviewStream] = useState(null); + + const recorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(null); + const kindRef = useRef(null); + const cancelledRef = useRef(false); + const timerRef = useRef | null>(null); + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; + + const teardownStream = useCallback((): void => { + const stream = streamRef.current; + if (stream !== null) { + for (const track of stream.getTracks()) { + track.stop(); + } + streamRef.current = null; + } + if (timerRef.current !== null) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const start = useCallback( + (kind: RecordKind): void => { + if (recording !== null) { + return; + } + const constraints: MediaStreamConstraints = + kind === 'voice' ? { audio: true } : { audio: true, video: true }; + void (async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia(constraints); + streamRef.current = stream; + kindRef.current = kind; + cancelledRef.current = false; + chunksRef.current = []; + if (kind === 'video') { + setPreviewStream(stream); + } + const recorder = new MediaRecorder(stream); + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunksRef.current.push(event.data); + } + }; + recorder.onstop = () => { + teardownStream(); + const chunks = chunksRef.current; + if (!cancelledRef.current && chunks.length > 0) { + const isVideo = kindRef.current === 'video'; + const type = isVideo ? 'video/webm' : 'audio/webm'; + const name = isVideo ? 'video-message.webm' : 'voice-message.webm'; + onCompleteRef.current(new File(chunks, name, { type })); + } + recorderRef.current = null; + setRecording(null); + setPreviewStream(null); + setElapsedMs(0); + }; + recorderRef.current = recorder; + recorder.start(); + setRecording(kind); + const startedAt = Date.now(); + timerRef.current = setInterval(() => { + setElapsedMs(Date.now() - startedAt); + }, 200); + } catch { + // Permission denied or no device — reset silently. + teardownStream(); + setRecording(null); + setPreviewStream(null); + } + })(); + }, + [recording, teardownStream], + ); + + const stop = useCallback( + (cancelled: boolean): void => { + cancelledRef.current = cancelled; + const recorder = recorderRef.current; + if (recorder !== null && recorder.state !== 'inactive') { + recorder.stop(); + } else { + teardownStream(); + } + }, + [teardownStream], + ); + + const finish = useCallback((): void => { + stop(false); + }, [stop]); + const cancel = useCallback((): void => { + stop(true); + }, [stop]); + + useEffect(() => teardownStream, [teardownStream]); + + return { recording, elapsedMs, previewStream, start, finish, cancel }; +}; diff --git a/packages/web/src/features/messaging/ui/ChatView.tsx b/packages/web/src/features/messaging/ui/ChatView.tsx index 6bdfc73..d55e7ba 100644 --- a/packages/web/src/features/messaging/ui/ChatView.tsx +++ b/packages/web/src/features/messaging/ui/ChatView.tsx @@ -1,9 +1,26 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { ReactElement, SyntheticEvent } from 'react'; import type { Conversation, Message, PublicUser } from '@altricade/core'; import { getMediaUrl } from '@altricade/core/api'; import { apiConfig } from '../../../shared/api'; +import { + PaperclipIcon, + MicIcon, + VideoIcon, + StopIcon, + SendIcon, + CloseIcon, + FileIcon, +} from '../../../shared/ui'; import { useConversationMessages } from '../model'; +import { useRecorder } from '../recorder'; + +const formatElapsed = (ms: number): string => { + const total = Math.floor(ms / 1000); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${String(minutes)}:${seconds < 10 ? '0' : ''}${String(seconds)}`; +}; interface Props { conversation: Conversation; @@ -65,7 +82,8 @@ const MediaView = ({ } return ( - 📎 {media.name} + + {media.name} ); }; @@ -84,6 +102,18 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => { notifyTyping, } = useConversationMessages(conversation, me); const [text, setText] = useState(''); + const recorder = useRecorder((file) => { + void sendMedia(file, ''); + }); + const previewRef = useRef(null); + + // Mirror the live camera stream into the in-composer preview while recording. + useEffect(() => { + const element = previewRef.current; + if (element !== null) { + element.srcObject = recorder.previewStream; + } + }, [recorder.previewStream]); const submit = async (event: SyntheticEvent): Promise => { event.preventDefault(); @@ -199,26 +229,72 @@ export const ChatView = ({ conversation, me }: Props): ReactElement => { ))} {typingUserIds.length > 0 ?

typing…

: null} -
{ - void submit(event); - }} - > - - { - setText(event.target.value); - notifyTyping(); + {recorder.recording !== null ? ( +
+ {recorder.recording === 'video' ? ( +
+ ) : ( + { + void submit(event); }} - /> - -
+ > + + + + { + setText(event.target.value); + notifyTyping(); + }} + /> + + + )} ); }; diff --git a/packages/web/src/features/notifications/index.ts b/packages/web/src/features/notifications/index.ts new file mode 100644 index 0000000..14f7983 --- /dev/null +++ b/packages/web/src/features/notifications/index.ts @@ -0,0 +1,2 @@ +export { NotificationsProvider, useNotifications, unregisterWebPush } from './model'; +export type { NotificationsContextValue, ToastInput } from './model'; diff --git a/packages/web/src/features/notifications/model.tsx b/packages/web/src/features/notifications/model.tsx new file mode 100644 index 0000000..28b1258 --- /dev/null +++ b/packages/web/src/features/notifications/model.tsx @@ -0,0 +1,225 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { getVapidPublicKey, registerDevice, unregisterDevice } from '@altricade/core/api'; +import { apiConfig } from '../../shared/api'; + +export interface ToastInput { + conversationId: string; + title: string; + body: string; +} + +interface Toast extends ToastInput { + id: string; +} + +export interface NotificationsContextValue { + /** Surface an incoming message: in-app toast when visible, OS notification when hidden. */ + notify: (toast: ToastInput) => void; + /** Register the handler that opens a conversation by id (from a tap / SW message). */ + setOpener: (open: (conversationId: string) => void) => void; +} + +const NotificationsContext = createContext(null); + +const TOAST_TTL_MS = 5000; + +const urlBase64ToKey = (base64: string): ArrayBuffer => { + const padding = '='.repeat((4 - (base64.length % 4)) % 4); + const normalized = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(normalized); + const buffer = new ArrayBuffer(raw.length); + const view = new Uint8Array(buffer); + for (let index = 0; index < raw.length; index += 1) { + view[index] = raw.charCodeAt(index); + } + return buffer; +}; + +// Register the service worker + Web Push subscription and hand the subscription +// to the backend. No-ops gracefully when the browser lacks push, the server has +// no VAPID key, or the user denies permission. +const registerWebPush = async (): Promise => { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + return; + } + const publicKey = await getVapidPublicKey(apiConfig); + if (publicKey === '') { + return; + } + const registration = await navigator.serviceWorker.register('/sw.js'); + await navigator.serviceWorker.ready; + const permission = + Notification.permission === 'default' + ? await Notification.requestPermission() + : Notification.permission; + if (permission !== 'granted') { + return; + } + const existing = await registration.pushManager.getSubscription(); + const subscription = + existing ?? + (await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToKey(publicKey), + })); + const json = subscription.toJSON(); + const endpoint = json.endpoint; + const p256dh = json.keys?.['p256dh']; + const auth = json.keys?.['auth']; + if (endpoint === undefined || p256dh === undefined || auth === undefined) { + return; + } + await registerDevice(apiConfig, { platform: 'web', token: endpoint, p256dh, auth }); +}; + +// Detach this browser's push subscription from the account (call before logout). +export const unregisterWebPush = async (): Promise => { + if (!('serviceWorker' in navigator)) { + return; + } + const registration = await navigator.serviceWorker.getRegistration(); + if (registration === undefined) { + return; + } + const subscription = await registration.pushManager.getSubscription(); + if (subscription === null) { + return; + } + try { + await unregisterDevice(apiConfig, subscription.endpoint); + } catch { + /* best-effort cleanup */ + } +}; + +export const NotificationsProvider = ({ children }: { children: ReactNode }): ReactElement => { + const [toasts, setToasts] = useState([]); + const openerRef = useRef<(conversationId: string) => void>(() => undefined); + const timers = useRef>>(new Map()); + + const dismiss = useCallback((id: string): void => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + const timer = timers.current.get(id); + if (timer !== undefined) { + clearTimeout(timer); + timers.current.delete(id); + } + }, []); + + const open = useCallback((conversationId: string): void => { + openerRef.current(conversationId); + }, []); + + const notify = useCallback( + (toast: ToastInput): void => { + // Visible tab → in-app toast. Hidden tab (but still connected) → OS notification. + if (document.visibilityState === 'visible') { + const id = crypto.randomUUID(); + setToasts((prev) => [...prev, { ...toast, id }]); + timers.current.set( + id, + setTimeout(() => { + dismiss(id); + }, TOAST_TTL_MS), + ); + return; + } + if ('Notification' in window && Notification.permission === 'granted') { + const notification = new Notification(toast.title, { + body: toast.body, + tag: toast.conversationId, + }); + notification.onclick = () => { + window.focus(); + open(toast.conversationId); + notification.close(); + }; + } + }, + [dismiss, open], + ); + + const setOpener = useCallback((fn: (conversationId: string) => void): void => { + openerRef.current = fn; + }, []); + + // Register push once on mount. + useEffect(() => { + void registerWebPush(); + }, []); + + // Taps on a real push (handled by the SW) arrive here to drive navigation. + useEffect(() => { + if (!('serviceWorker' in navigator)) { + return undefined; + } + const handler = (event: MessageEvent): void => { + const data = event.data; + if ( + typeof data === 'object' && + data !== null && + 'type' in data && + data.type === 'notification.open' && + 'conversationId' in data && + typeof data.conversationId === 'string' + ) { + open(data.conversationId); + } + }; + navigator.serviceWorker.addEventListener('message', handler); + return () => { + navigator.serviceWorker.removeEventListener('message', handler); + }; + }, [open]); + + useEffect(() => { + const map = timers.current; + return () => { + for (const timer of map.values()) { + clearTimeout(timer); + } + map.clear(); + }; + }, []); + + const value = useMemo(() => ({ notify, setOpener }), [notify, setOpener]); + + return ( + + {children} +
+ {toasts.map((toast) => ( + + ))} +
+
+ ); +}; + +export const useNotifications = (): NotificationsContextValue => { + const context = useContext(NotificationsContext); + if (context === null) { + throw new Error('useNotifications must be used within a NotificationsProvider'); + } + return context; +}; diff --git a/packages/web/src/shared/ui/icons.tsx b/packages/web/src/shared/ui/icons.tsx new file mode 100644 index 0000000..aced941 --- /dev/null +++ b/packages/web/src/shared/ui/icons.tsx @@ -0,0 +1,87 @@ +import type { ReactElement } from 'react'; + +// Inline stroke icons so glyphs render identically on every OS/browser (no +// emoji font variance). `currentColor` lets callers theme them via CSS. +interface IconProps { + size?: number; + className?: string; +} + +const base = (size: number, className: string | undefined, children: ReactElement): ReactElement => ( + +); + +export const PaperclipIcon = ({ size = 20, className }: IconProps): ReactElement => + base( + size, + className, + , + ); + +export const MicIcon = ({ size = 20, className }: IconProps): ReactElement => + base( + size, + className, + <> + + + + + , + ); + +export const VideoIcon = ({ size = 20, className }: IconProps): ReactElement => + base( + size, + className, + <> + + + , + ); + +export const StopIcon = ({ size = 20, className }: IconProps): ReactElement => + base(size, className, ); + +export const SendIcon = ({ size = 20, className }: IconProps): ReactElement => + base( + size, + className, + <> + + + , + ); + +export const CloseIcon = ({ size = 20, className }: IconProps): ReactElement => + base( + size, + className, + <> + + + , + ); + +export const FileIcon = ({ size = 20, className }: IconProps): ReactElement => + base( + size, + className, + <> + + + , + ); diff --git a/packages/web/src/shared/ui/index.ts b/packages/web/src/shared/ui/index.ts new file mode 100644 index 0000000..d25a8da --- /dev/null +++ b/packages/web/src/shared/ui/index.ts @@ -0,0 +1,9 @@ +export { + PaperclipIcon, + MicIcon, + VideoIcon, + StopIcon, + SendIcon, + CloseIcon, + FileIcon, +} from './icons'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d72411a..eee1bde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,8 @@ settings: overrides: esbuild: '>=0.28.1' + ioredis: ^5.11.1 + uuid: ^11.1.1 importers: @@ -62,12 +64,18 @@ importers: ajv-formats: specifier: ^3.0.1 version: 3.0.1(ajv@8.20.0) + bullmq: + specifier: ^5.80.0 + version: 5.80.0 fastify: specifier: ^5.10.0 version: 5.10.0 fastify-plugin: specifier: ^6.0.0 version: 6.0.0 + firebase-admin: + specifier: ^14.1.0 + version: 14.1.0 ioredis: specifier: ^5.11.1 version: 5.11.1 @@ -86,6 +94,9 @@ importers: pg: specifier: ^8.22.0 version: 8.22.0 + web-push: + specifier: ^3.6.7 + version: 3.6.7 devDependencies: '@types/node': specifier: ^26.1.1 @@ -93,6 +104,9 @@ importers: '@types/pg': specifier: ^8.20.0 version: 8.20.0 + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) @@ -444,6 +458,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@fastify/cookie@11.1.1': resolution: {integrity: sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==} @@ -480,6 +497,67 @@ packages: '@fastify/swagger@9.8.0': resolution: {integrity: sha512-GdRkUboXu++nChrmWM22SNDkabB529TL7cQ4RDsPDP76ro1kSW1cLIEZ9S8ZUbJKYUciHgLAgiX8SHzCnqZdnQ==} + '@firebase/app-check-interop-types@0.3.4': + resolution: {integrity: sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==} + + '@firebase/app-types@0.9.5': + resolution: {integrity: sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==} + + '@firebase/auth-interop-types@0.2.5': + resolution: {integrity: sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==} + + '@firebase/component@0.7.3': + resolution: {integrity: sha512-wFofIaa2879ogD/WvkjYXJxRmfnL0scen6ORgaC3na1FNOR9ASIUANQdhqQcmWu/h77/pVHY7ch5flewa5Bcew==} + engines: {node: '>=20.0.0'} + + '@firebase/database-compat@2.1.4': + resolution: {integrity: sha512-3pK35F1MAgmqFJQlf2nhQl44vtAXQO1uaCaQOEUI9kCRtLFqi7N+QRKR7lFZPg+xIZIyubgxQaxY69YgfZRZWg==} + engines: {node: '>=20.0.0'} + + '@firebase/database-types@1.0.20': + resolution: {integrity: sha512-kegbOk/w8iU64pr0q6k2ItyNGjnQBMHFhwS7ohdWI4W+pc0/zhhdGXTdFj6X1oxItRjPoYOsSQmERgBkn/ihxw==} + + '@firebase/database@1.1.3': + resolution: {integrity: sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==} + engines: {node: '>=20.0.0'} + + '@firebase/logger@0.5.1': + resolution: {integrity: sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==} + engines: {node: '>=20.0.0'} + + '@firebase/util@1.15.1': + resolution: {integrity: sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==} + engines: {node: '>=20.0.0'} + + '@google-cloud/firestore@8.6.0': + resolution: {integrity: sha512-TdvZHfwQj5B5CSDEgDqyrhdVqtOSupmBXDQPasMAJiC64tjsGvyMooNiC43fdk1TsUHeklyoZ6/vQ1TjWKVMbg==} + engines: {node: '>=18'} + + '@google-cloud/paginator@5.0.2': + resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} + engines: {node: '>=14.0.0'} + + '@google-cloud/projectify@4.0.0': + resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} + engines: {node: '>=14.0.0'} + + '@google-cloud/promisify@4.0.0': + resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} + engines: {node: '>=14'} + + '@google-cloud/storage@7.21.0': + resolution: {integrity: sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==} + engines: {node: '>=14'} + + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -503,6 +581,10 @@ packages: '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -523,10 +605,43 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -626,12 +741,20 @@ packages: resolution: {integrity: sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==} engines: {node: '>= 10'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -876,6 +999,10 @@ packages: cpu: [x64] os: [win32] + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} + '@turbo/darwin-64@2.10.4': resolution: {integrity: sha512-m1MUEI4MJ69r5CwfMYxmHi0H0rrgiYCBOp0tgBZ9x/YVvOb5uu/lRIDyDwdtH054R2yWeQaIigUGu6aCX9f8cA==} cpu: [x64] @@ -909,6 +1036,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -918,6 +1048,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} @@ -932,6 +1068,15 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@typescript-eslint/eslint-plugin@8.63.0': resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1004,6 +1149,10 @@ packages: babel-plugin-react-compiler: optional: true + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -1017,6 +1166,14 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -1035,19 +1192,40 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} anynum@1.0.1: resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1055,18 +1233,33 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.42: resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} engines: {node: '>=6.0.0'} hasBin: true + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + block-stream2@2.1.0: resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} + bn.js@4.12.5: + resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + brace-expansion@5.0.7: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} @@ -1083,6 +1276,18 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bullmq@5.80.0: + resolution: {integrity: sha512-xrCjINZcW6P4irrGZrOXPizzBbvgaP01u2oXGmqY1O5fll56z7OOf9vdnTzK7X4P34vo6ayMPQjyhJN+KwAMWA==} + engines: {node: '>=12.22.0'} + peerDependencies: + redis: '>=5.0.0' + peerDependenciesMeta: + redis: + optional: true + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1093,6 +1298,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001803: resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==} @@ -1118,6 +1327,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1144,6 +1357,10 @@ packages: resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} engines: {node: '>=22'} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1151,6 +1368,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1167,6 +1388,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -1183,12 +1408,47 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.389: resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -1259,6 +1519,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -1266,6 +1530,13 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + farmhash-modern@1.1.0: + resolution: {integrity: sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==} + engines: {node: '>=18.0.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -1306,6 +1577,10 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1315,6 +1590,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1331,6 +1610,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + firebase-admin@14.1.0: + resolution: {integrity: sha512-GdHh6vHWm9LVRt+3hINWczaA7fPwnN/l4xZdqzn+wNPYErrLI1x6u1mvAJM/5IGgYI9EqQgLt1EyBG8ok/hWCg==} + engines: {node: '>=22'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -1345,11 +1628,49 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@2.5.6: + resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==} + engines: {node: '>= 0.12'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gaxios@7.2.0: + resolution: {integrity: sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==} + engines: {node: '>=18'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + gcp-metadata@8.1.3: + resolution: {integrity: sha512-ziTrzUhhpL9Zk5k0HHzgP/KIpWDJT0VMBC/ynt/QIBvTW+UUcSivQRl6VlwTf/EilDxtSWklHoRsKy1c4k+59w==} + engines: {node: '>=18'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1358,10 +1679,23 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -1376,16 +1710,90 @@ packages: resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} engines: {node: '>=18'} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-auth-library@10.9.0: + resolution: {integrity: sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==} + engines: {node: '>=18'} + + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-gax@5.0.7: + resolution: {integrity: sha512-EhiqaWWJ+9h7sCcKJTsoo6tMcjokVHhWsbSuWCnZJT4vIBP3y4mAoFLnt9SzgkVZeq24ZsFaArr06nnYYku2yA==} + engines: {node: '>=18'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1421,12 +1829,19 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-unsafe@1.0.1: resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -1446,6 +1861,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1474,6 +1892,20 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jwks-rsa@4.1.0: + resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1562,6 +1994,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -1573,12 +2008,42 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -1586,9 +2051,20 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-memoizer@3.0.0: + resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1602,10 +2078,20 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minio@8.0.7: resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==} engines: {node: ^16 || ^18 || >=20} @@ -1620,6 +2106,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.4: + resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1631,6 +2124,31 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-pg-migrate@8.0.4: resolution: {integrity: sha512-HTlJ6fOT/2xHhAUtsqSN85PGMAqSbfGJNRwQF8+ZwQ1+sVGNUTl/ZGEshPsOI3yV22tPIyHXrKXr3S0JxeYLrg==} engines: {node: '>=20.11.0'} @@ -1650,10 +2168,17 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} @@ -1684,6 +2209,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -1802,6 +2331,10 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + proto3-json-serializer@3.0.4: + resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==} + engines: {node: '>=18'} + protobufjs@7.6.5: resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} @@ -1865,6 +2398,18 @@ packages: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} + retry-request@7.0.2: + resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} + engines: {node: '>=14'} + + retry-request@8.0.3: + resolution: {integrity: sha512-qqoc4kkGgP9cmQDWELlOpAmfgJOg0Yi7MT82ZjiPWu451ayju4itwomjM4/dBEliify8C1b3tSaeCOldugtwPQ==} + engines: {node: '>=18'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1872,6 +2417,10 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1893,6 +2442,9 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -1959,9 +2511,15 @@ packages: stream-chain@2.2.5: resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + stream-json@1.9.1: resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -1970,6 +2528,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -1977,14 +2539,29 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true + teeny-request@10.1.3: + resolution: {integrity: sha512-5yDliI1uWkYPo7W+Zvrxg6YmoWuj5iC5EydewqrRTvc68nyMTZhlPPlLg6cptUGfbQAb+N9XDPDPzF6N081lug==} + engines: {node: '>=18'} + + teeny-request@9.0.0: + resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} + engines: {node: '>=14'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2014,6 +2591,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2095,6 +2675,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + vite@8.1.4: resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2138,6 +2722,29 @@ packages: yaml: optional: true + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + websocket-driver@0.7.5: + resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2151,6 +2758,13 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} @@ -2440,6 +3054,8 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 3.1.3 + '@fastify/busboy@3.2.0': {} + '@fastify/cookie@11.1.1': dependencies: cookie: 2.0.1 @@ -2508,6 +3124,109 @@ snapshots: transitivePeerDependencies: - supports-color + '@firebase/app-check-interop-types@0.3.4': {} + + '@firebase/app-types@0.9.5': + dependencies: + '@firebase/logger': 0.5.1 + + '@firebase/auth-interop-types@0.2.5': {} + + '@firebase/component@0.7.3': + dependencies: + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/database-compat@2.1.4': + dependencies: + '@firebase/component': 0.7.3 + '@firebase/database': 1.1.3 + '@firebase/database-types': 1.0.20 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + tslib: 2.8.1 + + '@firebase/database-types@1.0.20': + dependencies: + '@firebase/app-types': 0.9.5 + '@firebase/util': 1.15.1 + + '@firebase/database@1.1.3': + dependencies: + '@firebase/app-check-interop-types': 0.3.4 + '@firebase/auth-interop-types': 0.2.5 + '@firebase/component': 0.7.3 + '@firebase/logger': 0.5.1 + '@firebase/util': 1.15.1 + faye-websocket: 0.11.4 + tslib: 2.8.1 + + '@firebase/logger@0.5.1': + dependencies: + tslib: 2.8.1 + + '@firebase/util@1.15.1': + dependencies: + tslib: 2.8.1 + + '@google-cloud/firestore@8.6.0': + dependencies: + '@opentelemetry/api': 1.9.1 + fast-deep-equal: 3.1.3 + functional-red-black-tree: 1.0.1 + google-gax: 5.0.7 + protobufjs: 7.6.5 + transitivePeerDependencies: + - supports-color + optional: true + + '@google-cloud/paginator@5.0.2': + dependencies: + arrify: 2.0.1 + extend: 3.0.2 + optional: true + + '@google-cloud/projectify@4.0.0': + optional: true + + '@google-cloud/promisify@4.0.0': + optional: true + + '@google-cloud/storage@7.21.0': + dependencies: + '@google-cloud/paginator': 5.0.2 + '@google-cloud/projectify': 4.0.0 + '@google-cloud/promisify': 4.0.0 + abort-controller: 3.0.0 + async-retry: 1.3.3 + duplexify: 4.1.3 + fast-xml-parser: 5.9.3 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + html-entities: 2.6.0 + mime: 3.0.0 + p-limit: 3.1.0 + retry-request: 7.0.2 + teeny-request: 9.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + optional: true + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + optional: true + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2526,6 +3245,16 @@ snapshots: '@ioredis/commands@1.10.0': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + optional: true + '@isaacs/cliui@9.0.0': {} '@jridgewell/gen-mapping@0.3.13': @@ -2547,8 +3276,29 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': + optional: true + '@lukeed/ms@2.0.2': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.11.1 @@ -2626,10 +3376,16 @@ snapshots: '@node-rs/argon2-win32-ia32-msvc': 2.0.2 '@node-rs/argon2-win32-x64-msvc': 2.0.2 + '@opentelemetry/api@1.9.1': + optional: true + '@oxc-project/types@0.139.0': {} '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -2776,6 +3532,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@tootallnate/once@2.0.1': + optional: true + '@turbo/darwin-64@2.10.4': optional: true @@ -2799,12 +3558,22 @@ snapshots: tslib: 2.8.1 optional: true + '@types/caseless@0.12.5': + optional: true + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 26.1.1 + + '@types/ms@2.1.0': {} + '@types/node@26.1.1': dependencies: undici-types: 8.3.0 @@ -2823,6 +3592,21 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 26.1.1 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.6 + optional: true + + '@types/tough-cookie@4.0.5': + optional: true + + '@types/web-push@3.6.4': + dependencies: + '@types/node': 26.1.1 + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@5.9.3))(eslint@10.6.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2919,6 +3703,11 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0) + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + optional: true + abstract-logging@2.0.1: {} acorn-jsx@5.3.2(acorn@8.17.0): @@ -2927,6 +3716,15 @@ snapshots: acorn@8.17.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -2947,16 +3745,40 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: + optional: true + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: + optional: true + any-promise@1.3.0: {} anynum@1.0.1: {} + arrify@2.0.1: + optional: true + + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.5 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + optional: true + async@3.2.6: {} + asynckit@0.4.0: + optional: true + atomic-sleep@1.0.0: {} avvio@9.2.0: @@ -2964,14 +3786,28 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + balanced-match@1.0.2: + optional: true + balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.42: {} + bignumber.js@9.3.1: {} + block-stream2@2.1.0: dependencies: readable-stream: 3.6.2 + bn.js@4.12.5: {} + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + optional: true + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -2988,6 +3824,19 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} + + bullmq@5.80.0: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.11.1 + msgpackr: 2.0.4 + node-abort-controller: 3.1.1 + semver: 7.8.5 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + bundle-require@5.1.0(esbuild@0.28.1): dependencies: esbuild: 0.28.1 @@ -2995,6 +3844,12 @@ snapshots: cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + optional: true + caniuse-lite@1.0.30001803: {} centrifuge@5.7.0: @@ -3020,6 +3875,11 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + optional: true + commander@4.1.1: {} confbox@0.1.8: {} @@ -3034,6 +3894,10 @@ snapshots: cookie@2.0.1: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3042,6 +3906,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3050,6 +3916,9 @@ snapshots: deep-is@0.1.4: {} + delayed-stream@1.0.0: + optional: true + denque@2.1.0: {} depd@2.0.0: {} @@ -3058,10 +3927,59 @@ snapshots: detect-libc@2.1.2: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + optional: true + + eastasianwidth@0.2.0: + optional: true + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + electron-to-chromium@1.5.389: {} emoji-regex@8.0.0: {} + emoji-regex@9.2.2: + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + + es-define-property@1.0.1: + optional: true + + es-errors@1.3.0: + optional: true + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + optional: true + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + optional: true + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -3176,10 +4094,17 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: + optional: true + eventemitter3@5.0.4: {} events@3.3.0: {} + extend@3.0.2: {} + + farmhash-modern@1.1.0: {} + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -3243,10 +4168,19 @@ snapshots: dependencies: reusify: 1.1.0 + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.5 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3264,6 +4198,23 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + firebase-admin@14.1.0: + dependencies: + '@fastify/busboy': 3.2.0 + '@firebase/database-compat': 2.1.4 + '@firebase/database-types': 1.0.20 + farmhash-modern: 1.1.0 + fast-deep-equal: 3.1.3 + google-auth-library: 10.9.0 + jsonwebtoken: 9.0.3 + jwks-rsa: 4.1.0 + optionalDependencies: + '@google-cloud/firestore': 8.6.0 + '@google-cloud/storage': 7.21.0 + transitivePeerDependencies: + - encoding + - supports-color + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -3282,17 +4233,124 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@2.5.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + optional: true + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + fsevents@2.3.3: optional: true + function-bind@1.1.2: + optional: true + + functional-red-black-tree@1.0.1: + optional: true + + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 11.1.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + optional: true + + gaxios@7.2.0: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.2.0 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.3: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + optional: true + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + optional: true + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + optional: true + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + optional: true + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -3310,12 +4368,107 @@ snapshots: globals@17.7.0: {} + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.2.0 + gcp-metadata: 8.1.3 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + optional: true + + google-auth-library@10.9.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.2.0 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + google-gax@5.0.7: + dependencies: + '@grpc/grpc-js': 1.14.4 + '@grpc/proto-loader': 0.8.1 + duplexify: 4.1.3 + google-auth-library: 10.5.0 + google-logging-utils: 1.1.3 + node-fetch: 3.3.2 + object-hash: 3.0.0 + proto3-json-serializer: 3.0.4 + protobufjs: 7.6.5 + retry-request: 8.0.3 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + optional: true + + google-logging-utils@0.0.2: + optional: true + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: + optional: true + + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + gtoken@8.0.0: + dependencies: + gaxios: 7.2.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + optional: true + + has-symbols@1.1.0: + optional: true + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + optional: true + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + optional: true + hermes-estree@0.25.1: {} hermes-parser@0.25.1: dependencies: hermes-estree: 0.25.1 + html-entities@2.6.0: + optional: true + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -3324,6 +4477,42 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-parser-js@0.5.10: {} + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.1 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + http_ece@1.2.0: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3354,10 +4543,20 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-stream@2.0.1: + optional: true + is-unsafe@1.0.1: {} isexe@2.0.0: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + optional: true + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -3370,6 +4569,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-schema-ref-resolver@3.0.0: @@ -3397,6 +4600,41 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwks-rsa@4.1.0: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 6.2.3 + limiter: 1.1.5 + lru-cache: 11.5.2 + lru-memoizer: 3.0.0 + transitivePeerDependencies: + - supports-color + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3465,6 +4703,8 @@ snapshots: lilconfig@3.1.3: {} + limiter@1.1.5: {} + lines-and-columns@1.2.4: {} load-tsconfig@0.2.5: {} @@ -3473,20 +4713,52 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: + optional: true + + lodash.clonedeep@4.5.0: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + lodash@4.18.1: {} long@5.3.2: {} + lru-cache@10.4.3: + optional: true + lru-cache@11.5.2: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lru-memoizer@3.0.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 11.5.2 + + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: + optional: true + mime-db@1.52.0: {} mime-types@2.1.35: @@ -3495,10 +4767,19 @@ snapshots: mime@3.0.0: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + optional: true + + minimist@1.2.8: {} + minio@8.0.7: dependencies: async: 3.2.6 @@ -3526,6 +4807,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.4: + optionalDependencies: + msgpackr-extract: 3.0.4 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -3536,6 +4833,26 @@ snapshots: natural-compare@1.4.0: {} + node-abort-controller@3.1.1: {} + + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + optional: true + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-pg-migrate@8.0.4(@types/pg@8.20.0)(pg@8.22.0): dependencies: glob: 11.1.0 @@ -3548,8 +4865,16 @@ snapshots: object-assign@4.1.1: {} + object-hash@3.0.0: + optional: true + on-exit-leak-free@2.1.2: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optional: true + openapi-types@12.1.3: {} optionator@0.9.4: @@ -3577,6 +4902,12 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + optional: true + path-scurry@2.0.2: dependencies: lru-cache: 11.5.2 @@ -3683,6 +5014,11 @@ snapshots: process-warning@5.0.0: {} + proto3-json-serializer@3.0.4: + dependencies: + protobufjs: 7.6.5 + optional: true + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -3741,10 +5077,36 @@ snapshots: ret@0.5.0: {} + retry-request@7.0.2: + dependencies: + '@types/request': 2.48.13 + extend: 3.0.2 + teeny-request: 9.0.0 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + + retry-request@8.0.3: + dependencies: + extend: 3.0.2 + teeny-request: 10.1.3 + transitivePeerDependencies: + - supports-color + optional: true + + retry@0.13.1: + optional: true + reusify@1.1.0: {} rfdc@1.4.1: {} + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + optional: true + rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -3805,6 +5167,8 @@ snapshots: safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + sax@1.6.0: {} scheduler@0.27.0: {} @@ -3845,10 +5209,18 @@ snapshots: stream-chain@2.2.5: {} + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + optional: true + stream-json@1.9.1: dependencies: stream-chain: 2.2.5 + stream-shift@1.0.3: + optional: true + strict-uri-encode@2.0.0: {} string-width@4.2.3: @@ -3857,6 +5229,13 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + optional: true + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -3865,10 +5244,18 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + optional: true + strnum@2.4.1: dependencies: anynum: 1.0.1 + stubs@3.0.0: + optional: true + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -3879,6 +5266,28 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + teeny-request@10.1.3: + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + stream-events: 1.0.5 + transitivePeerDependencies: + - supports-color + optional: true + + teeny-request@9.0.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + stream-events: 1.0.5 + uuid: 11.1.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -3906,6 +5315,9 @@ snapshots: toidentifier@1.0.1: {} + tr46@0.0.3: + optional: true + tree-kill@1.2.2: {} ts-algebra@2.0.0: {} @@ -3916,8 +5328,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsup@8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0): dependencies: @@ -3995,6 +5406,9 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.1: + optional: true + vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -4009,6 +5423,35 @@ snapshots: tsx: 4.23.0 yaml: 2.9.0 + 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 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: + optional: true + + websocket-driver@0.7.5: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + optional: true + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4021,6 +5464,16 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + optional: true + + wrappy@1.0.2: + optional: true + xml-naming@0.1.0: {} xml2js@0.6.2: