From a16344746137525f741eb86a82e478025cb50674 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:13:22 +0000 Subject: [PATCH] feat(notifications): add push subscriptions, notification settings and web push interfaces Push could only reach one mobile device per user (a token file), quiet hours did not exist, and the web app had no service worker, so a closed tab never heard about finished work. This lands the shared interfaces first so onboarding can build on them: - push_subscriptions (web push endpoints and Expo tokens, one row per device) and user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a per-day nudge counter), with CHECK constraints. - NotificationSettingsSchema and web push contracts, notifications.settings.get/update and notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only). - getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers. - VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted, and an SSRF-guarded web push sender built on the web-push package. - The web app links its manifest, registers a push-only service worker where supported (secure context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/ disableWebPush. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/app.ts | 6 +- apps/api/src/router.ts | 49 +++- apps/web/index.html | 1 + apps/web/public/site.webmanifest | 4 +- apps/web/public/sw.js | 61 +++++ apps/web/src/lib/web-push.ts | 102 ++++++++ apps/web/src/main.tsx | 2 + packages/adapters/package.json | 2 + packages/adapters/src/index.ts | 1 + packages/adapters/src/web-push.ts | 220 ++++++++++++++++++ packages/adapters/src/web-ssrf.ts | 2 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/notifications.ts | 71 ++++++ packages/contracts/src/rpc.ts | 18 ++ packages/core/src/index.ts | 1 + packages/core/src/quiet-hours.ts | 48 ++++ .../migration.sql | 52 +++++ packages/db/prisma/schema.prisma | 39 ++++ packages/db/src/index.ts | 2 + packages/db/src/notification-settings.ts | 121 ++++++++++ packages/db/src/push-subscriptions.ts | 88 +++++++ packages/testkit/src/authorization.test.ts | 13 ++ pnpm-lock.yaml | 54 +++++ 23 files changed, 953 insertions(+), 5 deletions(-) create mode 100644 apps/web/public/sw.js create mode 100644 apps/web/src/lib/web-push.ts create mode 100644 packages/adapters/src/web-push.ts create mode 100644 packages/contracts/src/notifications.ts create mode 100644 packages/core/src/quiet-hours.ts create mode 100644 packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql create mode 100644 packages/db/src/notification-settings.ts create mode 100644 packages/db/src/push-subscriptions.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 948d735..c8b5057 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -65,6 +65,8 @@ import { SmtpEmailProvider, SpaceMemoryProviderResolver, toTeamChatInbound, + VapidKeys, + vapidEnvFromProcess, } from "@rakazo/adapters"; import { blockedAuthPaths, createAuth } from "@rakazo/auth"; import { signupPolicyFromEnv } from "@rakazo/core"; @@ -310,6 +312,7 @@ export async function createApp( sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined, }); const notifications = new ExpoPushProvider(env.dataDir); + const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env)); const auth = createAuth(prisma, { secret: env.authSecret, baseURL: env.authUrl, @@ -463,6 +466,7 @@ export async function createApp( secrets, oauthLogins, integrationSettings, + vapid, mcpOAuth, composio: stack.composio, connectors: stack.connector, @@ -567,7 +571,7 @@ export async function createApp( } const { matched, response } = await rpc.handle(c.req.raw, { prefix: "/rpc", - context: { actor, signal: c.req.raw.signal }, + context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") }, }); if (matched) return c.newResponse(response.body, response); await next(); diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..b6c7bc6 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -27,6 +27,7 @@ import type { MemoryProviderResolver, PiOAuthLogins, RemoteConnectorDependencies, + VapidKeys, } from "@rakazo/adapters"; import { acquireComputerExecutionLease, @@ -52,6 +53,7 @@ import { filterTeamWorkspaceEntries, hasActiveComputerControl, isAutoReviewCheckerConfigured, + isBlockedHostname, isComputerScreenUnavailable, isProjectWorkspacePath, isSandboxGoneError, @@ -123,6 +125,7 @@ import { findModelCredential, findSpaceModelTier, formatMessagingLinkCode, + getNotificationSettings, InvalidProjectWorkspacePathError, InvalidSpaceNameError, IsolationError, @@ -135,6 +138,7 @@ import { parseComputerMode, projectWorkspaceDto, releaseSpaceDeletionClaim, + removePushSubscription, renewSpaceDeletionClaim, requireMembership, SPACE_DELETION_CLAIM_TIMEOUT_MS, @@ -142,8 +146,10 @@ import { SpaceLimitError, SpaceNotEmptyError, SpaceNotFoundError, + savePushSubscription, selectSpaceModelPreference, selectSpaceVoicePreference, + updateNotificationSettings, wireProjectWorkspace, } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; @@ -457,6 +463,8 @@ export interface RouterDeps { secrets: EncryptedSecretStore; oauthLogins: PiOAuthLogins; integrationSettings?: IntegrationProviderSettings; + /** The deployment's web push key pair; without it browsers cannot subscribe. */ + vapid?: Pick; composio?: ComposioProvider; mcpOAuth?: McpOAuthBroker; connectors: ConnectorRegistry; @@ -504,7 +512,11 @@ function mapSpaceLifecycleError(error: unknown): unknown { } export function createRouter(deps: RouterDeps) { - const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>(); + const os = implement(appContract).$context<{ + actor: Actor | null; + signal?: AbortSignal; + userAgent?: string; + }>(); const repos = createRepos(deps.prisma); const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors }; const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets); @@ -4758,6 +4770,41 @@ export function createRouter(deps: RouterDeps) { await deletePushToken(deps.dataDir, context.actor.userId); return { ok: true as const }; }), + settings: { + get: authed.notifications.settings.get.handler(({ context }) => + getNotificationSettings(deps.prisma, context.actor.userId), + ), + update: authed.notifications.settings.update.handler(({ context, input }) => + updateNotificationSettings(deps.prisma, context.actor.userId, input), + ), + }, + webPush: { + config: authed.notifications.webPush.config.handler(async () => { + const publicKey = (await deps.vapid?.publicKey()) ?? null; + return { available: publicKey !== null, publicKey }; + }), + subscribe: authed.notifications.webPush.subscribe.handler(async ({ context, input }) => { + if (isBlockedHostname(new URL(input.endpoint).hostname.replace(/^\[|\]$/g, ""))) { + throw new ORPCError("BAD_REQUEST", { + message: "Endpoint must be a public push service", + }); + } + await savePushSubscription(deps.prisma, context.actor.userId, { + kind: "webpush", + endpoint: input.endpoint, + p256dh: input.keys.p256dh, + auth: input.keys.auth, + userAgent: context.userAgent, + }); + return { ok: true as const }; + }), + unsubscribe: authed.notifications.webPush.unsubscribe.handler( + async ({ context, input }) => { + await removePushSubscription(deps.prisma, context.actor.userId, input.endpoint); + return { ok: true as const }; + }, + ), + }, }, search: { query: authed.search.query.handler(async ({ context, input }) => ({ diff --git a/apps/web/index.html b/apps/web/index.html index c626fbd..df71503 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,7 @@ + evOo diff --git a/apps/web/public/site.webmanifest b/apps/web/public/site.webmanifest index 90dd32d..c57083f 100644 --- a/apps/web/public/site.webmanifest +++ b/apps/web/public/site.webmanifest @@ -1,6 +1,6 @@ { - "name": "EvOo", - "short_name": "EvOo", + "name": "evOo", + "short_name": "evOo", "description": "Deine Oos — immer da, immer wach.", "start_url": "/", "display": "standalone", diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..374172c --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,61 @@ +// Shows the server's web push notifications and opens the conversation they belong to. +// No fetch handler: the app always loads from the network. + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +function appPath(value) { + return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") + ? value + : "/app"; +} + +self.addEventListener("push", (event) => { + event.waitUntil( + (async () => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = {}; + } + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + // The app in front of the user already shows what happened. + if (windows.some((client) => client.focused && client.visibilityState === "visible")) return; + await self.registration.showNotification( + typeof data.title === "string" && data.title ? data.title : "evOo", + { + body: typeof data.body === "string" ? data.body : "", + // Same tag as the in-app notification, so one replaces the other. + tag: typeof data.tag === "string" && data.tag ? data.tag : undefined, + icon: "/icon-192.png", + data: { url: appPath(data.url) }, + }, + ); + })(), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil( + (async () => { + const target = new URL(appPath(event.notification.data?.url), self.location.origin).href; + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const open = + windows.find((client) => client.url === target) ?? + windows.find((client) => new URL(client.url).origin === self.location.origin); + if (!open) { + await self.clients.openWindow(target); + return; + } + const focused = await open.focus(); + if (focused.url !== target) await focused.navigate(target).catch(() => undefined); + })(), + ); +}); diff --git a/apps/web/src/lib/web-push.ts b/apps/web/src/lib/web-push.ts new file mode 100644 index 0000000..2dd999d --- /dev/null +++ b/apps/web/src/lib/web-push.ts @@ -0,0 +1,102 @@ +import { desktopBridge } from "./desktop"; +import { rpc } from "./rpc"; + +const SERVICE_WORKER_URL = "/sw.js"; +const SERVICE_WORKER_SCOPE = "/"; + +/** + * Web push needs the Push API, a service worker and a secure context (https or localhost). The + * desktop app keeps its in-app notifications instead. + */ +export function webPushSupported(): boolean { + return ( + typeof window !== "undefined" && + window.isSecureContext && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window && + !desktopBridge() + ); +} + +/** Registers the push service worker where web push works; safe to call on every start. */ +export async function registerPushServiceWorker(): Promise { + if (!webPushSupported()) return; + await navigator.serviceWorker + .register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }) + .catch(() => undefined); +} + +async function currentSubscription(): Promise { + const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE); + return (await registration?.pushManager.getSubscription()) ?? null; +} + +/** True when this browser has granted notifications and holds a push subscription. */ +export async function webPushEnabled(): Promise { + if (!webPushSupported() || Notification.permission !== "granted") return false; + return Boolean(await currentSubscription().catch(() => null)); +} + +function base64UrlToBytes(value: string): Uint8Array { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +function sameKey(current: ArrayBuffer | null, expected: Uint8Array): boolean { + if (!current || current.byteLength !== expected.byteLength) return false; + const bytes = new Uint8Array(current); + return bytes.every((byte, index) => byte === expected[index]); +} + +/** + * Turns on push for this browser and registers it with the server. Call it straight from a click: + * browsers only show the permission prompt during a user gesture. Throws when the server cannot + * be reached. + */ +export async function enableWebPush(): Promise<"enabled" | "denied" | "unsupported"> { + if (!webPushSupported()) return "unsupported"; + const permission = + Notification.permission === "default" + ? await Notification.requestPermission() + : Notification.permission; + if (permission !== "granted") return "denied"; + const config = await rpc.notifications.webPush.config(); + if (!config.available || !config.publicKey) return "unsupported"; + const applicationServerKey = base64UrlToBytes(config.publicKey); + let subscription: PushSubscription; + try { + await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }); + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + // A subscription made for other server keys cannot receive this server's pushes. + if (existing && !sameKey(existing.options.applicationServerKey, applicationServerKey)) { + await existing.unsubscribe(); + } + subscription = + existing && sameKey(existing.options.applicationServerKey, applicationServerKey) + ? existing + : await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); + } catch { + // Browsers without a push service (or with it switched off) reject the subscription. + return "unsupported"; + } + const keys = subscription.toJSON().keys ?? {}; + await rpc.notifications.webPush.subscribe({ + endpoint: subscription.endpoint, + keys: { p256dh: keys.p256dh ?? "", auth: keys.auth ?? "" }, + }); + return "enabled"; +} + +/** Stops push for this browser, on the server first so no push arrives after it returns. */ +export async function disableWebPush(): Promise { + if (!webPushSupported()) return; + const subscription = await currentSubscription(); + if (!subscription) return; + await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint }); + await subscription.unsubscribe().catch(() => undefined); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 1b03bf7..6060637 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import { markAfterPaint, markOnce } from "./lib/performance"; import { installPreloadRecovery } from "./lib/preload-recovery"; import { applyUiAppearance, watchSystemAppearance } from "./lib/ui-appearance"; import { resolveUiLocale } from "./lib/ui-locale"; +import { registerPushServiceWorker } from "./lib/web-push"; import "./styles.css"; markOnce("rk:renderer:module-evaluated"); @@ -19,6 +20,7 @@ try { installPreloadRecovery(); applyUiDirection(resolveUiLocale()); applyUiAppearance(); +void registerPushServiceWorker(); function PerformanceProbe() { useLayoutEffect(() => { diff --git a/packages/adapters/package.json b/packages/adapters/package.json index c9b7855..701d625 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -42,6 +42,7 @@ "sharp": "0.35.4", "shell-quote": "1.10.0", "undici": "8.10.2", + "web-push": "3.6.7", "zod": "4.6.2" }, "devDependencies": { @@ -50,6 +51,7 @@ "@types/jsdom": "30.0.0", "@types/nodemailer": "8.0.1", "@types/pg": "8.23.1", + "@types/web-push": "3.6.4", "typescript": "7.0.2", "vitest": "4.1.11" } diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 7b4ecd0..ecf21b9 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -124,5 +124,6 @@ export * from "./voice-factory.js"; export * from "./wakeup.js"; export * from "./web-limits.js"; export * from "./web-provider-factory.js"; +export * from "./web-push.js"; export * from "./web-ssrf.js"; export * from "./web-tools.js"; diff --git a/packages/adapters/src/web-push.ts b/packages/adapters/src/web-push.ts new file mode 100644 index 0000000..3abb071 --- /dev/null +++ b/packages/adapters/src/web-push.ts @@ -0,0 +1,220 @@ +import { lookup } from "node:dns/promises"; +import type { NotificationMessage } from "@rakazo/adapter-kit"; +import type { PrismaClient } from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; +import { Agent } from "undici"; +import webPush from "web-push"; +import { combineSignals } from "./connector-safety.js"; +import { createAddressCheckedLookup, type ResolveHostname } from "./network-address.js"; +import type { EncryptedSecretStore } from "./secrets.js"; +import { dispatcherFetch } from "./undici-fetch.js"; +import { assertPublicAddresses, assertSafeWebUrl } from "./web-ssrf.js"; + +/** Generated keys live encrypted in the deployment-wide settings table under this id. */ +const VAPID_RECORD_ID = "web-push-vapid"; +/** RFC 8292 wants a contact; deployments set VAPID_SUBJECT or serve the web app over https. */ +const FALLBACK_SUBJECT = "mailto:admin@localhost"; +const WEB_PUSH_TIMEOUT_MS = 15_000; +/** Undelivered pushes expire after a day; older news belongs in the inbox. */ +const WEB_PUSH_TTL_SECONDS = 86_400; + +export interface VapidDetails { + subject: string; + publicKey: string; + privateKey: string; +} + +export interface VapidEnv { + publicKey?: string; + privateKey?: string; + subject?: string; +} + +/** VAPID settings from the environment; the web origin is the contact when it is https. */ +export function vapidEnvFromProcess(source: Record): VapidEnv { + const value = (raw: string | undefined) => raw?.trim() || undefined; + const origin = value(source.WEB_ORIGIN); + return { + publicKey: value(source.VAPID_PUBLIC_KEY), + privateKey: value(source.VAPID_PRIVATE_KEY), + subject: value(source.VAPID_SUBJECT) ?? (origin?.startsWith("https://") ? origin : undefined), + }; +} + +function assertVapidDetails(details: VapidDetails): VapidDetails { + // Validates subject and both keys; the error messages never contain the private key. + webPush.getVapidHeaders( + "https://push.invalid", + details.subject, + details.publicKey, + details.privateKey, + "aes128gcm", + ); + return details; +} + +/** + * The deployment's VAPID key pair: the configured one, or one generated on first use and stored + * encrypted, so the API and every worker sign with the same keys. + */ +export class VapidKeys { + private pending: Promise | undefined; + + constructor( + private readonly prisma: PrismaClient, + private readonly secrets: EncryptedSecretStore, + private readonly env: VapidEnv = {}, + ) {} + + details(): Promise { + this.pending ??= this.resolve().catch((error: unknown) => { + this.pending = undefined; + throw error; + }); + return this.pending; + } + + /** The public key browsers subscribe with, or null when no usable key pair exists. */ + async publicKey(): Promise { + try { + return (await this.details()).publicKey; + } catch (error) { + getLogger().error("web push keys unavailable", error); + return null; + } + } + + private async resolve(): Promise { + const subject = this.env.subject ?? FALLBACK_SUBJECT; + if (this.env.publicKey && this.env.privateKey) { + return assertVapidDetails({ + subject, + publicKey: this.env.publicKey, + privateKey: this.env.privateKey, + }); + } + if (this.env.publicKey || this.env.privateKey) { + getLogger().warn("VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be set together; ignoring"); + } + const stored = await this.load(); + if (stored) return assertVapidDetails({ subject, ...stored }); + const generated = webPush.generateVAPIDKeys(); + const sealed = await this.secrets.put( + JSON.stringify({ publicKey: generated.publicKey, privateKey: generated.privateKey }), + { + operationId: "web-push.keys", + traceId: "web-push.keys", + spaceId: "", + userId: "", + signal: AbortSignal.timeout(WEB_PUSH_TIMEOUT_MS), + }, + VAPID_RECORD_ID, + ); + // When the API and a worker generate at once, the first insert wins and both read it back. + await this.prisma.integrationProviderConfig.createMany({ + data: [{ id: VAPID_RECORD_ID, ciphertext: sealed.ciphertext }], + skipDuplicates: true, + }); + const winner = await this.load(); + if (!winner) throw new Error("Web push keys could not be stored"); + return assertVapidDetails({ subject, ...winner }); + } + + private async load(): Promise<{ publicKey: string; privateKey: string } | null> { + const row = await this.prisma.integrationProviderConfig.findUnique({ + where: { id: VAPID_RECORD_ID }, + select: { ciphertext: true }, + }); + if (!row) return null; + const parsed = JSON.parse(this.secrets.load(row.ciphertext, VAPID_RECORD_ID)) as { + publicKey?: unknown; + privateKey?: unknown; + }; + if (typeof parsed.publicKey !== "string" || typeof parsed.privateKey !== "string") { + throw new Error("Stored web push keys are malformed"); + } + return { publicKey: parsed.publicKey, privateKey: parsed.privateKey }; + } +} + +/** What a device shows: the notification plus where a tap leads (a path in the web app). */ +export interface PushPayload extends NotificationMessage { + url: string; +} + +/** "gone": the push service no longer knows this device, so its registration can be dropped. */ +export type PushOutcome = "sent" | "gone"; + +export interface PushTarget { + endpoint: string; + p256dh: string | null; + auth: string | null; +} + +export interface WebPushSenderOptions { + fetch?: typeof globalThis.fetch; + resolveHostname?: ResolveHostname; + timeoutMs?: number; +} + +const defaultResolveHostname: ResolveHostname = (hostname) => + lookup(hostname, { all: true, verbatim: true }); + +/** + * Sends encrypted web push messages (RFC 8030/8291/8292). The endpoint comes from a browser, so + * the request only ever connects to public addresses, pinned at connect time. + */ +export class WebPushSender { + constructor( + private readonly vapid: Pick, + private readonly options: WebPushSenderOptions = {}, + ) {} + + async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise { + if (!target.p256dh || !target.auth) return "gone"; + const request = webPush.generateRequestDetails( + { endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } }, + JSON.stringify({ + title: payload.title, + body: payload.body, + url: payload.url, + tag: payload.threadId, + kind: payload.kind, + }), + { vapidDetails: await this.vapid.details(), TTL: WEB_PUSH_TTL_SECONDS }, + ); + const resolve = this.options.resolveHostname ?? defaultResolveHostname; + const deadline = combineSignals( + signal, + AbortSignal.timeout(this.options.timeoutMs ?? WEB_PUSH_TIMEOUT_MS), + ); + const url = await assertSafeWebUrl(request.endpoint, resolve, deadline); + if (url.protocol !== "https:") throw new Error("Web push endpoint must use https"); + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (name.toLowerCase() !== "content-length") headers.set(name, String(value)); + } + const dispatcher = new Agent({ + connect: { lookup: createAddressCheckedLookup(resolve, assertPublicAddresses) }, + }); + try { + const response = await (this.options.fetch ?? dispatcherFetch)(url.href, { + method: "POST", + headers, + body: request.body, + redirect: "manual", + signal: deadline, + dispatcher, + } as RequestInit & { dispatcher: Agent }); + await response.body?.cancel().catch(() => undefined); + if (response.status === 404 || response.status === 410) return "gone"; + if (response.status < 200 || response.status >= 300) { + // Status only: push service bodies and endpoints identify the device. + throw new Error(`Web push failed (${response.status})`); + } + return "sent"; + } finally { + await dispatcher.close().catch(() => undefined); + } + } +} diff --git a/packages/adapters/src/web-ssrf.ts b/packages/adapters/src/web-ssrf.ts index 869cb80..25b7c3d 100644 --- a/packages/adapters/src/web-ssrf.ts +++ b/packages/adapters/src/web-ssrf.ts @@ -229,7 +229,7 @@ export async function readBodyCapped( }); } -function assertPublicAddresses(addresses: ResolvedAddress[]): void { +export function assertPublicAddresses(addresses: ResolvedAddress[]): void { if (addresses.length === 0 || addresses.some((entry) => isPrivateAddress(entry.address))) { throw new Error("URL resolves to a private address"); } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3865e1b..f562a16 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,6 +10,7 @@ export * from "./integration-settings.js"; export * from "./local-settings.js"; export * from "./mcp.js"; export * from "./model-tiers.js"; +export * from "./notifications.js"; export * from "./openai-compatible-ui.js"; export * from "./project-goals.js"; export * from "./project-sessions.js"; diff --git a/packages/contracts/src/notifications.ts b/packages/contracts/src/notifications.ts new file mode 100644 index 0000000..5cb6287 --- /dev/null +++ b/packages/contracts/src/notifications.ts @@ -0,0 +1,71 @@ +import * as z from "zod"; + +/** How many nudges the main agent may send per day in the user's time zone. */ +export const NUDGES_PER_DAY = 3; + +const CLOCK_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** A 24-hour wall-clock time, e.g. "22:30". */ +export const ClockTimeSchema = z.string().regex(CLOCK_TIME, "Use HH:MM"); + +/** Minutes after midnight for a "HH:MM" time. */ +export function clockTimeToMinutes(value: string): number { + const match = CLOCK_TIME.exec(value); + if (!match) throw new Error("Use HH:MM"); + return Number(match[1]) * 60 + Number(match[2]); +} + +/** "HH:MM" for minutes after midnight. */ +export function minutesToClockTime(minutes: number): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; +} + +/** A daily range in the user's profile time zone; it may cross midnight ("22:00"–"07:00"). */ +export const QuietHoursSchema = z + .object({ start: ClockTimeSchema, end: ClockTimeSchema }) + .refine((range) => range.start !== range.end, "Start and end must differ"); +export type QuietHours = z.infer; + +export const NotificationSettingsSchema = z.object({ + /** Every push is held back during quiet hours; the inbox still lists what happened. */ + quietHours: QuietHoursSchema.nullable(), + /** Whether the main agent may notify the user on its own initiative. */ + nudges: z.boolean(), +}); +export type NotificationSettings = z.infer; + +export const UpdateNotificationSettingsInput = NotificationSettingsSchema.partial(); +export type NotificationSettingsPatch = z.infer; + +/** What a browser needs to subscribe; `available` is false when the server cannot send. */ +export const WebPushConfigSchema = z.object({ + available: z.boolean(), + publicKey: z.string().nullable(), +}); +export type WebPushConfig = z.infer; + +/** A push service URL. Browsers only hand out https endpoints. */ +export const WebPushEndpointSchema = z + .string() + .max(2048) + .refine((value) => { + try { + const url = new URL(value); + return url.protocol === "https:" && !url.username && !url.password; + } catch { + return false; + } + }, "Endpoint must be an https URL"); + +/** The JSON form of a browser PushSubscription (RFC 8291 keys, base64url). */ +export const WebPushSubscriptionInput = z.object({ + endpoint: WebPushEndpointSchema, + keys: z.object({ + /** An uncompressed P-256 public key: 65 bytes. */ + p256dh: z.string().regex(/^[A-Za-z0-9_-]{87}=?$/, "Invalid p256dh key"), + /** A 16-byte authentication secret. */ + auth: z.string().regex(/^[A-Za-z0-9_-]{22}(==)?$/, "Invalid auth secret"), + }), +}); +export type WebPushSubscriptionJson = z.infer; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..1dc59cb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -83,6 +83,13 @@ import { IntegrationSetupStateSchema, } from "./integration-settings.js"; import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js"; +import { + NotificationSettingsSchema, + UpdateNotificationSettingsInput, + WebPushConfigSchema, + WebPushEndpointSchema, + WebPushSubscriptionInput, +} from "./notifications.js"; import { CreateProjectGoalInput, ProjectGoalSchema, @@ -803,6 +810,17 @@ export const appContract = { .input(z.object({ token: z.string().min(8).max(512) })) .output(z.object({ ok: z.literal(true) })), unregisterPush: oc.output(z.object({ ok: z.literal(true) })), + settings: { + get: oc.output(NotificationSettingsSchema), + update: oc.input(UpdateNotificationSettingsInput).output(NotificationSettingsSchema), + }, + webPush: { + config: oc.output(WebPushConfigSchema), + subscribe: oc.input(WebPushSubscriptionInput).output(z.object({ ok: z.literal(true) })), + unsubscribe: oc + .input(z.object({ endpoint: WebPushEndpointSchema })) + .output(z.object({ ok: z.literal(true) })), + }, }, search: { query: oc.input(z.object({ q: z.string().max(200) })).output(SearchQueryOutputSchema), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c870847..eb5edb1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -30,6 +30,7 @@ export * from "./messaging-prompts.js"; export * from "./model-oauth.js"; export * from "./model-probe.js"; export * from "./model-providers.js"; +export * from "./quiet-hours.js"; export * from "./response-bytes.js"; export * from "./run-state.js"; export * from "./sandbox-command.js"; diff --git a/packages/core/src/quiet-hours.ts b/packages/core/src/quiet-hours.ts new file mode 100644 index 0000000..11b7c4d --- /dev/null +++ b/packages/core/src/quiet-hours.ts @@ -0,0 +1,48 @@ +import { knownTimeZone } from "./cron.js"; + +/** Minutes after local midnight; `start` > `end` means the range crosses midnight. */ +export interface MinuteRange { + start: number; + end: number; +} + +/** The local calendar date ("YYYY-MM-DD") and minute of day at `now`, UTC when the zone is unknown. */ +export function localDateAndMinute( + now: Date, + timeZone: string | null | undefined, +): { date: string; minute: number } { + const parts = new Map( + new Intl.DateTimeFormat("en-US", { + timeZone: knownTimeZone(timeZone) ?? "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }) + .formatToParts(now) + .map((part) => [part.type, part.value]), + ); + return { + date: `${parts.get("year")}-${parts.get("month")}-${parts.get("day")}`, + minute: Number(parts.get("hour")) * 60 + Number(parts.get("minute")), + }; +} + +/** True when `minute` lies in the range; the start is inclusive, the end exclusive. */ +export function isQuietMinute(range: MinuteRange | null, minute: number): boolean { + if (!range || range.start === range.end) return false; + return range.start < range.end + ? minute >= range.start && minute < range.end + : minute >= range.start || minute < range.end; +} + +/** True when `now` falls in the quiet hours of a user in `timeZone`. */ +export function isQuietTime( + range: MinuteRange | null, + timeZone: string | null | undefined, + now: Date, +): boolean { + return isQuietMinute(range, localDateAndMinute(now, timeZone).minute); +} diff --git a/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql new file mode 100644 index 0000000..c684498 --- /dev/null +++ b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql @@ -0,0 +1,52 @@ +-- CreateTable +CREATE TABLE "push_subscriptions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "p256dh" TEXT, + "auth" TEXT, + "userAgent" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3), + + CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_notification_settings" ( + "userId" TEXT NOT NULL, + "quietHoursStart" INTEGER, + "quietHoursEnd" INTEGER, + "nudges" BOOLEAN NOT NULL DEFAULT true, + "nudgeDate" DATE, + "nudgeCount" INTEGER NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_notification_settings_pkey" PRIMARY KEY ("userId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "push_subscriptions_endpoint_key" ON "push_subscriptions"("endpoint"); + +-- CreateIndex +CREATE INDEX "push_subscriptions_userId_idx" ON "push_subscriptions"("userId"); + +-- AddForeignKey +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Both tables are new and empty, so their constraints are valid from the start. +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_kind_chk" CHECK ("kind" IN ('webpush', 'expo')); +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_webpush_keys_chk" CHECK ("kind" <> 'webpush' OR ("p256dh" IS NOT NULL AND "auth" IS NOT NULL)); +ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_quiet_hours_chk" CHECK ( + ("quietHoursStart" IS NULL AND "quietHoursEnd" IS NULL) + OR ( + "quietHoursStart" BETWEEN 0 AND 1439 + AND "quietHoursEnd" BETWEEN 0 AND 1439 + AND "quietHoursStart" <> "quietHoursEnd" + ) +); +ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_nudge_count_chk" CHECK ("nudgeCount" >= 0); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a6dc085..19bede8 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -47,6 +47,8 @@ model User { agentSecrets AgentSecret[] profile UserProfile? facts UserFact[] + pushSubscriptions PushSubscription[] + notificationSettings UserNotificationSettings? @@unique([email]) @@map("user") @@ -1111,6 +1113,43 @@ model NotificationPreference { @@map("notification_preferences") } +/// One device that receives the user's push notifications: a browser (web push) or the mobile +/// app (an Expo push token). An endpoint belongs to the account that registered it last. +model PushSubscription { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + kind String + /// The web push endpoint URL or the Expo push token. + endpoint String @unique + p256dh String? + auth String? + userAgent String @default("") + createdAt DateTime @default(now()) + lastUsedAt DateTime? + + @@index([userId]) + // CHECK constraints push_subscriptions_kind_chk and push_subscriptions_webpush_keys_chk are created in SQL. + @@map("push_subscriptions") +} + +/// When the user may be interrupted. No row means no quiet hours and nudges on. +model UserNotificationSettings { + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// Minutes after local midnight in the profile time zone; both set or both null. + quietHoursStart Int? + quietHoursEnd Int? + nudges Boolean @default(true) + /// The user's local date of the last nudge and how many were sent that day (daily cap). + nudgeDate DateTime? @db.Date + nudgeCount Int @default(0) + updatedAt DateTime @updatedAt + + // CHECK constraints user_notification_settings_quiet_hours_chk and _nudge_count_chk are created in SQL. + @@map("user_notification_settings") +} + model SpaceMemoryConfig { id String @id @default(cuid()) spaceId String @unique diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3a251fa..3ed7e3a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -12,8 +12,10 @@ export * from "./messages.js"; export * from "./messaging.js"; export * from "./model-credentials.js"; export * from "./model-tiers.js"; +export * from "./notification-settings.js"; export * from "./project-goals.js"; export * from "./project-workspace.js"; +export * from "./push-subscriptions.js"; export * from "./repos.js"; export * from "./scope.js"; export * from "./spaces.js"; diff --git a/packages/db/src/notification-settings.ts b/packages/db/src/notification-settings.ts new file mode 100644 index 0000000..050f61e --- /dev/null +++ b/packages/db/src/notification-settings.ts @@ -0,0 +1,121 @@ +import type { NotificationSettings, NotificationSettingsPatch } from "@rakazo/contracts"; +import { + clockTimeToMinutes, + minutesToClockTime, + NUDGES_PER_DAY, + UpdateNotificationSettingsInput, +} from "@rakazo/contracts"; +import { isQuietMinute, localDateAndMinute } from "@rakazo/core"; +import type { PrismaClient } from "./client.js"; + +const settingsSelect = { quietHoursStart: true, quietHoursEnd: true, nudges: true } as const; + +type SettingsRow = { + quietHoursStart: number | null; + quietHoursEnd: number | null; + nudges: boolean; +}; + +function quietRange(row: SettingsRow | null) { + return row && row.quietHoursStart !== null && row.quietHoursEnd !== null + ? { start: row.quietHoursStart, end: row.quietHoursEnd } + : null; +} + +function toSettings(row: SettingsRow | null): NotificationSettings { + const range = quietRange(row); + return { + quietHours: range + ? { start: minutesToClockTime(range.start), end: minutesToClockTime(range.end) } + : null, + nudges: row?.nudges ?? true, + }; +} + +/** The user's notification settings; without a stored row: no quiet hours, nudges on. */ +export async function getNotificationSettings( + prisma: PrismaClient, + userId: string, +): Promise { + return toSettings( + await prisma.userNotificationSettings.findUnique({ where: { userId }, select: settingsSelect }), + ); +} + +/** Applies the fields present in `patch`; `quietHours: null` turns quiet hours off. */ +export async function updateNotificationSettings( + prisma: PrismaClient, + userId: string, + patch: NotificationSettingsPatch, +): Promise { + const valid = UpdateNotificationSettingsInput.parse(patch); + const data: { quietHoursStart?: number | null; quietHoursEnd?: number | null; nudges?: boolean } = + {}; + if (valid.quietHours !== undefined) { + data.quietHoursStart = valid.quietHours ? clockTimeToMinutes(valid.quietHours.start) : null; + data.quietHoursEnd = valid.quietHours ? clockTimeToMinutes(valid.quietHours.end) : null; + } + if (valid.nudges !== undefined) data.nudges = valid.nudges; + return toSettings( + await prisma.userNotificationSettings.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + select: settingsSelect, + }), + ); +} + +export interface NotificationPolicy { + /** True during the user's quiet hours: no push may go out. */ + quiet: boolean; + nudges: boolean; + /** The user's local date ("YYYY-MM-DD"), the day the nudge cap counts against. */ + localDate: string; +} + +/** What may reach the user at `now`, judged in their profile time zone (UTC when unknown). */ +export async function loadNotificationPolicy( + prisma: PrismaClient, + userId: string, + now: Date = new Date(), +): Promise { + const [row, profile] = await Promise.all([ + prisma.userNotificationSettings.findUnique({ where: { userId }, select: settingsSelect }), + prisma.userProfile.findUnique({ where: { userId }, select: { timeZone: true } }), + ]); + const local = localDateAndMinute(now, profile?.timeZone); + return { + quiet: isQuietMinute(quietRange(row), local.minute), + nudges: row?.nudges ?? true, + localDate: local.date, + }; +} + +/** + * Counts one nudge against the user's daily cap and returns whether it may go out. Each step is + * a single conditional UPDATE, so concurrent nudges cannot exceed the cap. + */ +export async function claimDailyNudge( + prisma: PrismaClient, + userId: string, + localDate: string, + limit: number = NUDGES_PER_DAY, +): Promise { + const day = new Date(`${localDate}T00:00:00.000Z`); + await prisma.userNotificationSettings.createMany({ data: [{ userId }], skipDuplicates: true }); + // Two rounds: a concurrent first nudge of the day can move the date between the statements. + for (let round = 0; round < 2; round += 1) { + const sameDay = await prisma.userNotificationSettings.updateMany({ + where: { userId, nudgeDate: day, nudgeCount: { lt: limit } }, + data: { nudgeCount: { increment: 1 } }, + }); + if (sameDay.count === 1) return true; + const newDay = await prisma.userNotificationSettings.updateMany({ + where: { userId, OR: [{ nudgeDate: null }, { nudgeDate: { not: day } }] }, + data: { nudgeDate: day, nudgeCount: 1 }, + }); + if (newDay.count === 1) return true; + } + return false; +} diff --git a/packages/db/src/push-subscriptions.ts b/packages/db/src/push-subscriptions.ts new file mode 100644 index 0000000..885f337 --- /dev/null +++ b/packages/db/src/push-subscriptions.ts @@ -0,0 +1,88 @@ +import type { PrismaClient } from "./client.js"; + +export type PushSubscriptionKind = "webpush" | "expo"; + +/** Devices one user keeps registered; the least recently registered ones are dropped first. */ +export const PUSH_SUBSCRIPTION_LIMIT = 20; + +export interface StoredPushSubscription { + id: string; + kind: PushSubscriptionKind; + endpoint: string; + p256dh: string | null; + auth: string | null; +} + +export interface NewPushSubscription { + kind: PushSubscriptionKind; + /** Web push endpoint URL or Expo push token. */ + endpoint: string; + p256dh?: string | null; + auth?: string | null; + userAgent?: string; +} + +/** + * Registers a device for the user. An endpoint registered by another account moves to this one: + * a device delivers to whoever signed in on it last. + */ +export async function savePushSubscription( + prisma: PrismaClient, + userId: string, + input: NewPushSubscription, +): Promise { + const data = { + userId, + kind: input.kind, + p256dh: input.p256dh ?? null, + auth: input.auth ?? null, + userAgent: (input.userAgent ?? "").slice(0, 256), + }; + await prisma.pushSubscription.upsert({ + where: { endpoint: input.endpoint }, + create: { endpoint: input.endpoint, ...data }, + // A renewed registration counts as the newest device for the per-user limit. + update: { ...data, createdAt: new Date() }, + }); + const stale = await prisma.pushSubscription.findMany({ + where: { userId }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + skip: PUSH_SUBSCRIPTION_LIMIT, + select: { id: true }, + }); + if (stale.length > 0) { + await prisma.pushSubscription.deleteMany({ + where: { id: { in: stale.map((row) => row.id) } }, + }); + } +} + +/** Removes one of the user's devices; another user's endpoint is left alone. */ +export async function removePushSubscription( + prisma: PrismaClient, + userId: string, + endpoint: string, +): Promise { + await prisma.pushSubscription.deleteMany({ where: { userId, endpoint } }); +} + +/** Removes every device of one kind, e.g. all mobile app tokens on sign-out. */ +export async function removePushSubscriptionsOfKind( + prisma: PrismaClient, + userId: string, + kind: PushSubscriptionKind, +): Promise { + await prisma.pushSubscription.deleteMany({ where: { userId, kind } }); +} + +export async function listPushSubscriptions( + prisma: PrismaClient, + userId: string, +): Promise { + const rows = await prisma.pushSubscription.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { id: true, kind: true, endpoint: true, p256dh: true, auth: true }, + }); + return rows.map((row) => ({ ...row, kind: row.kind as PushSubscriptionKind })); +} diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index ce1e785..c640e4d 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -192,6 +192,12 @@ describeWithDatabase("API authorization and resource isolation", () => { ["usage/summary"], ["export/bot", { botId: "missing-bot" }], ["notifications/registerPush", { token: "ExponentPushToken[not-real]" }], + ["notifications/unregisterPush"], + ["notifications/settings/get"], + ["notifications/settings/update", { nudges: false }], + ["notifications/webPush/config"], + ["notifications/webPush/subscribe", webPushSubscriptionInput("unauthenticated")], + ["notifications/webPush/unsubscribe", { endpoint: "https://push.example.test/none" }], ["search/query", { q: "anything" }], ["tasks/list", {}], ["inbox/list"], @@ -2358,6 +2364,13 @@ interface Bot { id: string; } +function webPushSubscriptionInput(device: string) { + return { + endpoint: `https://push.example.test/${device}`, + keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }, + }; +} + function exhaustiveProtectedCalls< const Calls extends ReadonlyArray, >(calls: Calls & (ProtectedRpcPath extends Calls[number][0] ? unknown : never)): Calls { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2acb6e..5736d79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -618,6 +618,9 @@ importers: undici: specifier: 8.10.2 version: 8.10.2 + web-push: + specifier: 3.6.7 + version: 3.6.7 zod: specifier: 4.6.2 version: 4.6.2 @@ -637,6 +640,9 @@ importers: '@types/pg': specifier: 8.23.1 version: 8.23.1 + '@types/web-push': + specifier: 3.6.4 + version: 3.6.4 typescript: specifier: 7.0.2 version: 7.0.2 @@ -4343,6 +4349,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -4759,6 +4768,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -5020,6 +5032,9 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + bn.js@4.12.5: + resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -6752,6 +6767,10 @@ packages: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} + 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'} @@ -7620,6 +7639,9 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -9759,6 +9781,11 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + 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'} @@ -14103,6 +14130,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/web-push@3.6.4': + dependencies: + '@types/node': 24.13.3 + '@types/ws@8.18.1': dependencies: '@types/node': 24.13.3 @@ -14605,6 +14636,13 @@ snapshots: asap@2.0.6: {} + 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 + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -14947,6 +14985,8 @@ snapshots: bluebird@3.7.2: {} + bn.js@4.12.5: {} + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -17016,6 +17056,8 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + http_ece@1.2.0: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -18116,6 +18158,8 @@ snapshots: mimic-response@3.1.0: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -20377,6 +20421,16 @@ snapshots: dependencies: defaults: 1.0.4 + 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: {} web-vitals@6.2.1: {}