Merge the Phase 2 integration branch into the coach workstream

Picks up the shared clock-time schema and the notification interfaces so
later merges of the coach work stay conflict-free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:20:58 +00:00
commit a99e58be00
No known key found for this signature in database
25 changed files with 957 additions and 6 deletions

View File

@ -65,6 +65,8 @@ import {
SmtpEmailProvider, SmtpEmailProvider,
SpaceMemoryProviderResolver, SpaceMemoryProviderResolver,
toTeamChatInbound, toTeamChatInbound,
VapidKeys,
vapidEnvFromProcess,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import { blockedAuthPaths, createAuth } from "@rakazo/auth"; import { blockedAuthPaths, createAuth } from "@rakazo/auth";
import { signupPolicyFromEnv } from "@rakazo/core"; import { signupPolicyFromEnv } from "@rakazo/core";
@ -310,6 +312,7 @@ export async function createApp(
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined, sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
}); });
const notifications = new ExpoPushProvider(env.dataDir); const notifications = new ExpoPushProvider(env.dataDir);
const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env));
const auth = createAuth(prisma, { const auth = createAuth(prisma, {
secret: env.authSecret, secret: env.authSecret,
baseURL: env.authUrl, baseURL: env.authUrl,
@ -463,6 +466,7 @@ export async function createApp(
secrets, secrets,
oauthLogins, oauthLogins,
integrationSettings, integrationSettings,
vapid,
mcpOAuth, mcpOAuth,
composio: stack.composio, composio: stack.composio,
connectors: stack.connector, connectors: stack.connector,
@ -567,7 +571,7 @@ export async function createApp(
} }
const { matched, response } = await rpc.handle(c.req.raw, { const { matched, response } = await rpc.handle(c.req.raw, {
prefix: "/rpc", 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); if (matched) return c.newResponse(response.body, response);
await next(); await next();

View File

@ -27,6 +27,7 @@ import type {
MemoryProviderResolver, MemoryProviderResolver,
PiOAuthLogins, PiOAuthLogins,
RemoteConnectorDependencies, RemoteConnectorDependencies,
VapidKeys,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import { import {
acquireComputerExecutionLease, acquireComputerExecutionLease,
@ -52,6 +53,7 @@ import {
filterTeamWorkspaceEntries, filterTeamWorkspaceEntries,
hasActiveComputerControl, hasActiveComputerControl,
isAutoReviewCheckerConfigured, isAutoReviewCheckerConfigured,
isBlockedHostname,
isComputerScreenUnavailable, isComputerScreenUnavailable,
isProjectWorkspacePath, isProjectWorkspacePath,
isSandboxGoneError, isSandboxGoneError,
@ -123,6 +125,7 @@ import {
findModelCredential, findModelCredential,
findSpaceModelTier, findSpaceModelTier,
formatMessagingLinkCode, formatMessagingLinkCode,
getNotificationSettings,
InvalidProjectWorkspacePathError, InvalidProjectWorkspacePathError,
InvalidSpaceNameError, InvalidSpaceNameError,
IsolationError, IsolationError,
@ -135,6 +138,7 @@ import {
parseComputerMode, parseComputerMode,
projectWorkspaceDto, projectWorkspaceDto,
releaseSpaceDeletionClaim, releaseSpaceDeletionClaim,
removePushSubscription,
renewSpaceDeletionClaim, renewSpaceDeletionClaim,
requireMembership, requireMembership,
SPACE_DELETION_CLAIM_TIMEOUT_MS, SPACE_DELETION_CLAIM_TIMEOUT_MS,
@ -142,8 +146,10 @@ import {
SpaceLimitError, SpaceLimitError,
SpaceNotEmptyError, SpaceNotEmptyError,
SpaceNotFoundError, SpaceNotFoundError,
savePushSubscription,
selectSpaceModelPreference, selectSpaceModelPreference,
selectSpaceVoicePreference, selectSpaceVoicePreference,
updateNotificationSettings,
wireProjectWorkspace, wireProjectWorkspace,
} from "@rakazo/db"; } from "@rakazo/db";
import { getLogger } from "@rakazo/logging"; import { getLogger } from "@rakazo/logging";
@ -457,6 +463,8 @@ export interface RouterDeps {
secrets: EncryptedSecretStore; secrets: EncryptedSecretStore;
oauthLogins: PiOAuthLogins; oauthLogins: PiOAuthLogins;
integrationSettings?: IntegrationProviderSettings; integrationSettings?: IntegrationProviderSettings;
/** The deployment's web push key pair; without it browsers cannot subscribe. */
vapid?: Pick<VapidKeys, "publicKey">;
composio?: ComposioProvider; composio?: ComposioProvider;
mcpOAuth?: McpOAuthBroker; mcpOAuth?: McpOAuthBroker;
connectors: ConnectorRegistry; connectors: ConnectorRegistry;
@ -504,7 +512,11 @@ function mapSpaceLifecycleError(error: unknown): unknown {
} }
export function createRouter(deps: RouterDeps) { 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 repos = createRepos(deps.prisma);
const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors }; const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors };
const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets); 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); await deletePushToken(deps.dataDir, context.actor.userId);
return { ok: true as const }; 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: { search: {
query: authed.search.query.handler(async ({ context, input }) => ({ query: authed.search.query.handler(async ({ context, input }) => ({

View File

@ -9,6 +9,7 @@
<link rel="icon" href="/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Fonts loaded locally via @fontsource-variable (Inter + Geist) — no external CDN --> <!-- Fonts loaded locally via @fontsource-variable (Inter + Geist) — no external CDN -->
<link rel="stylesheet" href="/evoo-brand.css" /> <link rel="stylesheet" href="/evoo-brand.css" />
<title>evOo</title> <title>evOo</title>

View File

@ -1,6 +1,6 @@
{ {
"name": "EvOo", "name": "evOo",
"short_name": "EvOo", "short_name": "evOo",
"description": "Deine Oos — immer da, immer wach.", "description": "Deine Oos — immer da, immer wach.",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",

61
apps/web/public/sw.js Normal file
View File

@ -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);
})(),
);
});

View File

@ -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<void> {
if (!webPushSupported()) return;
await navigator.serviceWorker
.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE })
.catch(() => undefined);
}
async function currentSubscription(): Promise<PushSubscription | null> {
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<boolean> {
if (!webPushSupported() || Notification.permission !== "granted") return false;
return Boolean(await currentSubscription().catch(() => null));
}
function base64UrlToBytes(value: string): Uint8Array<ArrayBuffer> {
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<void> {
if (!webPushSupported()) return;
const subscription = await currentSubscription();
if (!subscription) return;
await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint });
await subscription.unsubscribe().catch(() => undefined);
}

View File

@ -9,6 +9,7 @@ import { markAfterPaint, markOnce } from "./lib/performance";
import { installPreloadRecovery } from "./lib/preload-recovery"; import { installPreloadRecovery } from "./lib/preload-recovery";
import { applyUiAppearance, watchSystemAppearance } from "./lib/ui-appearance"; import { applyUiAppearance, watchSystemAppearance } from "./lib/ui-appearance";
import { resolveUiLocale } from "./lib/ui-locale"; import { resolveUiLocale } from "./lib/ui-locale";
import { registerPushServiceWorker } from "./lib/web-push";
import "./styles.css"; import "./styles.css";
markOnce("rk:renderer:module-evaluated"); markOnce("rk:renderer:module-evaluated");
@ -19,6 +20,7 @@ try {
installPreloadRecovery(); installPreloadRecovery();
applyUiDirection(resolveUiLocale()); applyUiDirection(resolveUiLocale());
applyUiAppearance(); applyUiAppearance();
void registerPushServiceWorker();
function PerformanceProbe() { function PerformanceProbe() {
useLayoutEffect(() => { useLayoutEffect(() => {

View File

@ -42,6 +42,7 @@
"sharp": "0.35.4", "sharp": "0.35.4",
"shell-quote": "1.10.0", "shell-quote": "1.10.0",
"undici": "8.10.2", "undici": "8.10.2",
"web-push": "3.6.7",
"zod": "4.6.2" "zod": "4.6.2"
}, },
"devDependencies": { "devDependencies": {
@ -50,6 +51,7 @@
"@types/jsdom": "30.0.0", "@types/jsdom": "30.0.0",
"@types/nodemailer": "8.0.1", "@types/nodemailer": "8.0.1",
"@types/pg": "8.23.1", "@types/pg": "8.23.1",
"@types/web-push": "3.6.4",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "4.1.11" "vitest": "4.1.11"
} }

View File

@ -125,5 +125,6 @@ export * from "./voice-factory.js";
export * from "./wakeup.js"; export * from "./wakeup.js";
export * from "./web-limits.js"; export * from "./web-limits.js";
export * from "./web-provider-factory.js"; export * from "./web-provider-factory.js";
export * from "./web-push.js";
export * from "./web-ssrf.js"; export * from "./web-ssrf.js";
export * from "./web-tools.js"; export * from "./web-tools.js";

View File

@ -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<string, string | undefined>): 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<VapidDetails> | undefined;
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: EncryptedSecretStore,
private readonly env: VapidEnv = {},
) {}
details(): Promise<VapidDetails> {
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<string | null> {
try {
return (await this.details()).publicKey;
} catch (error) {
getLogger().error("web push keys unavailable", error);
return null;
}
}
private async resolve(): Promise<VapidDetails> {
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<VapidKeys, "details">,
private readonly options: WebPushSenderOptions = {},
) {}
async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise<PushOutcome> {
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);
}
}
}

View File

@ -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))) { if (addresses.length === 0 || addresses.some((entry) => isPrivateAddress(entry.address))) {
throw new Error("URL resolves to a private address"); throw new Error("URL resolves to a private address");
} }

View File

@ -0,0 +1,19 @@
import * as z from "zod";
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)}`;
}

View File

@ -1,4 +1,5 @@
import * as z from "zod"; import * as z from "zod";
import { ClockTimeSchema } from "./clock-time.js";
import { Id } from "./ids.js"; import { Id } from "./ids.js";
export const COACH_TITLE_MAX_LENGTH = 200; export const COACH_TITLE_MAX_LENGTH = 200;
@ -38,7 +39,6 @@ export const LocalDateSchema = z
.refine(isCalendarDate, "Invalid date"); .refine(isCalendarDate, "Invalid date");
/** A 24-hour wall-clock time in the user's time zone, e.g. "07:30". */ /** A 24-hour wall-clock time in the user's time zone, e.g. "07:30". */
export const ClockTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM");
/** When the main bot checks in with the user; null turns a check-in off. */ /** When the main bot checks in with the user; null turns a check-in off. */
export const CoachRhythmSchema = z.object({ export const CoachRhythmSchema = z.object({

View File

@ -1,6 +1,7 @@
export * from "./ai-consent.js"; export * from "./ai-consent.js";
export * from "./attachments.js"; export * from "./attachments.js";
export * from "./bot-secrets.js"; export * from "./bot-secrets.js";
export * from "./clock-time.js";
export * from "./coach.js"; export * from "./coach.js";
export * from "./desktop.js"; export * from "./desktop.js";
export * from "./domain.js"; export * from "./domain.js";
@ -11,6 +12,7 @@ export * from "./integration-settings.js";
export * from "./local-settings.js"; export * from "./local-settings.js";
export * from "./mcp.js"; export * from "./mcp.js";
export * from "./model-tiers.js"; export * from "./model-tiers.js";
export * from "./notifications.js";
export * from "./openai-compatible-ui.js"; export * from "./openai-compatible-ui.js";
export * from "./project-goals.js"; export * from "./project-goals.js";
export * from "./project-sessions.js"; export * from "./project-sessions.js";

View File

@ -0,0 +1,54 @@
import * as z from "zod";
import { ClockTimeSchema } from "./clock-time.js";
/** How many nudges the main agent may send per day in the user's time zone. */
export const NUDGES_PER_DAY = 3;
/** 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<typeof QuietHoursSchema>;
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<typeof NotificationSettingsSchema>;
export const UpdateNotificationSettingsInput = NotificationSettingsSchema.partial();
export type NotificationSettingsPatch = z.infer<typeof UpdateNotificationSettingsInput>;
/** 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<typeof WebPushConfigSchema>;
/** 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<typeof WebPushSubscriptionInput>;

View File

@ -83,6 +83,13 @@ import {
IntegrationSetupStateSchema, IntegrationSetupStateSchema,
} from "./integration-settings.js"; } from "./integration-settings.js";
import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js"; import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js";
import {
NotificationSettingsSchema,
UpdateNotificationSettingsInput,
WebPushConfigSchema,
WebPushEndpointSchema,
WebPushSubscriptionInput,
} from "./notifications.js";
import { import {
CreateProjectGoalInput, CreateProjectGoalInput,
ProjectGoalSchema, ProjectGoalSchema,
@ -803,6 +810,17 @@ export const appContract = {
.input(z.object({ token: z.string().min(8).max(512) })) .input(z.object({ token: z.string().min(8).max(512) }))
.output(z.object({ ok: z.literal(true) })), .output(z.object({ ok: z.literal(true) })),
unregisterPush: oc.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: { search: {
query: oc.input(z.object({ q: z.string().max(200) })).output(SearchQueryOutputSchema), query: oc.input(z.object({ q: z.string().max(200) })).output(SearchQueryOutputSchema),

View File

@ -31,6 +31,7 @@ export * from "./messaging-prompts.js";
export * from "./model-oauth.js"; export * from "./model-oauth.js";
export * from "./model-probe.js"; export * from "./model-probe.js";
export * from "./model-providers.js"; export * from "./model-providers.js";
export * from "./quiet-hours.js";
export * from "./response-bytes.js"; export * from "./response-bytes.js";
export * from "./run-state.js"; export * from "./run-state.js";
export * from "./sandbox-command.js"; export * from "./sandbox-command.js";

View File

@ -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);
}

View File

@ -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);

View File

@ -51,6 +51,8 @@ model User {
habits Habit[] habits Habit[]
checkIns CheckIn[] checkIns CheckIn[]
dailyBriefs DailyBrief[] dailyBriefs DailyBrief[]
pushSubscriptions PushSubscription[]
notificationSettings UserNotificationSettings?
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
@ -1120,6 +1122,43 @@ model NotificationPreference {
@@map("notification_preferences") @@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 { model SpaceMemoryConfig {
id String @id @default(cuid()) id String @id @default(cuid())
spaceId String @unique spaceId String @unique

View File

@ -13,8 +13,10 @@ export * from "./messages.js";
export * from "./messaging.js"; export * from "./messaging.js";
export * from "./model-credentials.js"; export * from "./model-credentials.js";
export * from "./model-tiers.js"; export * from "./model-tiers.js";
export * from "./notification-settings.js";
export * from "./project-goals.js"; export * from "./project-goals.js";
export * from "./project-workspace.js"; export * from "./project-workspace.js";
export * from "./push-subscriptions.js";
export * from "./repos.js"; export * from "./repos.js";
export * from "./scope.js"; export * from "./scope.js";
export * from "./spaces.js"; export * from "./spaces.js";

View File

@ -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<NotificationSettings> {
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<NotificationSettings> {
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<NotificationPolicy> {
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<boolean> {
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;
}

View File

@ -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<void> {
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<void> {
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<void> {
await prisma.pushSubscription.deleteMany({ where: { userId, kind } });
}
export async function listPushSubscriptions(
prisma: PrismaClient,
userId: string,
): Promise<StoredPushSubscription[]> {
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 }));
}

View File

@ -192,6 +192,12 @@ describeWithDatabase("API authorization and resource isolation", () => {
["usage/summary"], ["usage/summary"],
["export/bot", { botId: "missing-bot" }], ["export/bot", { botId: "missing-bot" }],
["notifications/registerPush", { token: "ExponentPushToken[not-real]" }], ["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" }], ["search/query", { q: "anything" }],
["tasks/list", {}], ["tasks/list", {}],
["inbox/list"], ["inbox/list"],
@ -2358,6 +2364,13 @@ interface Bot {
id: string; 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< function exhaustiveProtectedCalls<
const Calls extends ReadonlyArray<readonly [ProtectedRpcPath, unknown?]>, const Calls extends ReadonlyArray<readonly [ProtectedRpcPath, unknown?]>,
>(calls: Calls & (ProtectedRpcPath extends Calls[number][0] ? unknown : never)): Calls { >(calls: Calls & (ProtectedRpcPath extends Calls[number][0] ? unknown : never)): Calls {

54
pnpm-lock.yaml generated
View File

@ -618,6 +618,9 @@ importers:
undici: undici:
specifier: 8.10.2 specifier: 8.10.2
version: 8.10.2 version: 8.10.2
web-push:
specifier: 3.6.7
version: 3.6.7
zod: zod:
specifier: 4.6.2 specifier: 4.6.2
version: 4.6.2 version: 4.6.2
@ -637,6 +640,9 @@ importers:
'@types/pg': '@types/pg':
specifier: 8.23.1 specifier: 8.23.1
version: 8.23.1 version: 8.23.1
'@types/web-push':
specifier: 3.6.4
version: 3.6.4
typescript: typescript:
specifier: 7.0.2 specifier: 7.0.2
version: 7.0.2 version: 7.0.2
@ -4343,6 +4349,9 @@ packages:
'@types/unist@3.0.3': '@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/web-push@3.6.4':
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
'@types/ws@8.18.1': '@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@ -4759,6 +4768,9 @@ packages:
asap@2.0.6: asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
asn1@0.2.6: asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
@ -5020,6 +5032,9 @@ packages:
bluebird@3.7.2: bluebird@3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} 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: body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -6752,6 +6767,10 @@ packages:
resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==}
engines: {node: '>=10.19.0'} 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: https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -7620,6 +7639,9 @@ packages:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.6: minimatch@10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@ -9759,6 +9781,11 @@ packages:
wcwidth@1.0.1: wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} 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: web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -14103,6 +14130,10 @@ snapshots:
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
'@types/web-push@3.6.4':
dependencies:
'@types/node': 24.13.3
'@types/ws@8.18.1': '@types/ws@8.18.1':
dependencies: dependencies:
'@types/node': 24.13.3 '@types/node': 24.13.3
@ -14605,6 +14636,13 @@ snapshots:
asap@2.0.6: {} 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: asn1@0.2.6:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@ -14947,6 +14985,8 @@ snapshots:
bluebird@3.7.2: {} bluebird@3.7.2: {}
bn.js@4.12.5: {}
body-parser@2.3.0: body-parser@2.3.0:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@ -17016,6 +17056,8 @@ snapshots:
quick-lru: 5.1.1 quick-lru: 5.1.1
resolve-alpn: 1.2.1 resolve-alpn: 1.2.1
http_ece@1.2.0: {}
https-proxy-agent@5.0.1: https-proxy-agent@5.0.1:
dependencies: dependencies:
agent-base: 6.0.2 agent-base: 6.0.2
@ -18116,6 +18158,8 @@ snapshots:
mimic-response@3.1.0: {} mimic-response@3.1.0: {}
minimalistic-assert@1.0.1: {}
minimatch@10.2.6: minimatch@10.2.6:
dependencies: dependencies:
brace-expansion: 5.0.9 brace-expansion: 5.0.9
@ -20377,6 +20421,16 @@ snapshots:
dependencies: dependencies:
defaults: 1.0.4 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-streams-polyfill@3.3.3: {}
web-vitals@6.2.1: {} web-vitals@6.2.1: {}