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: {}