Merge notification interfaces into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
6ad9be6c1e
@ -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();
|
||||||
|
|||||||
@ -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 }) => ({
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
61
apps/web/public/sw.js
Normal 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);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
102
apps/web/src/lib/web-push.ts
Normal file
102
apps/web/src/lib/web-push.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
220
packages/adapters/src/web-push.ts
Normal file
220
packages/adapters/src/web-push.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,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";
|
||||||
|
|||||||
71
packages/contracts/src/notifications.ts
Normal file
71
packages/contracts/src/notifications.ts
Normal file
@ -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<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>;
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
48
packages/core/src/quiet-hours.ts
Normal file
48
packages/core/src/quiet-hours.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
121
packages/db/src/notification-settings.ts
Normal file
121
packages/db/src/notification-settings.ts
Normal 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;
|
||||||
|
}
|
||||||
88
packages/db/src/push-subscriptions.ts
Normal file
88
packages/db/src/push-subscriptions.ts
Normal 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 }));
|
||||||
|
}
|
||||||
@ -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
54
pnpm-lock.yaml
generated
@ -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: {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user