feat(notifications): add push subscriptions, notification settings and web push interfaces
Push could only reach one mobile device per user (a token file), quiet hours did not exist, and the web app had no service worker, so a closed tab never heard about finished work. This lands the shared interfaces first so onboarding can build on them: - push_subscriptions (web push endpoints and Expo tokens, one row per device) and user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a per-day nudge counter), with CHECK constraints. - NotificationSettingsSchema and web push contracts, notifications.settings.get/update and notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only). - getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers. - VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted, and an SSRF-guarded web push sender built on the web-push package. - The web app links its manifest, registers a push-only service worker where supported (secure context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/ disableWebPush. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
3d180dc2dd
commit
a163447461
@ -65,6 +65,8 @@ import {
|
||||
SmtpEmailProvider,
|
||||
SpaceMemoryProviderResolver,
|
||||
toTeamChatInbound,
|
||||
VapidKeys,
|
||||
vapidEnvFromProcess,
|
||||
} from "@rakazo/adapters";
|
||||
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
|
||||
import { signupPolicyFromEnv } from "@rakazo/core";
|
||||
@ -310,6 +312,7 @@ export async function createApp(
|
||||
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
|
||||
});
|
||||
const notifications = new ExpoPushProvider(env.dataDir);
|
||||
const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env));
|
||||
const auth = createAuth(prisma, {
|
||||
secret: env.authSecret,
|
||||
baseURL: env.authUrl,
|
||||
@ -463,6 +466,7 @@ export async function createApp(
|
||||
secrets,
|
||||
oauthLogins,
|
||||
integrationSettings,
|
||||
vapid,
|
||||
mcpOAuth,
|
||||
composio: stack.composio,
|
||||
connectors: stack.connector,
|
||||
@ -567,7 +571,7 @@ export async function createApp(
|
||||
}
|
||||
const { matched, response } = await rpc.handle(c.req.raw, {
|
||||
prefix: "/rpc",
|
||||
context: { actor, signal: c.req.raw.signal },
|
||||
context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") },
|
||||
});
|
||||
if (matched) return c.newResponse(response.body, response);
|
||||
await next();
|
||||
|
||||
@ -27,6 +27,7 @@ import type {
|
||||
MemoryProviderResolver,
|
||||
PiOAuthLogins,
|
||||
RemoteConnectorDependencies,
|
||||
VapidKeys,
|
||||
} from "@rakazo/adapters";
|
||||
import {
|
||||
acquireComputerExecutionLease,
|
||||
@ -52,6 +53,7 @@ import {
|
||||
filterTeamWorkspaceEntries,
|
||||
hasActiveComputerControl,
|
||||
isAutoReviewCheckerConfigured,
|
||||
isBlockedHostname,
|
||||
isComputerScreenUnavailable,
|
||||
isProjectWorkspacePath,
|
||||
isSandboxGoneError,
|
||||
@ -123,6 +125,7 @@ import {
|
||||
findModelCredential,
|
||||
findSpaceModelTier,
|
||||
formatMessagingLinkCode,
|
||||
getNotificationSettings,
|
||||
InvalidProjectWorkspacePathError,
|
||||
InvalidSpaceNameError,
|
||||
IsolationError,
|
||||
@ -135,6 +138,7 @@ import {
|
||||
parseComputerMode,
|
||||
projectWorkspaceDto,
|
||||
releaseSpaceDeletionClaim,
|
||||
removePushSubscription,
|
||||
renewSpaceDeletionClaim,
|
||||
requireMembership,
|
||||
SPACE_DELETION_CLAIM_TIMEOUT_MS,
|
||||
@ -142,8 +146,10 @@ import {
|
||||
SpaceLimitError,
|
||||
SpaceNotEmptyError,
|
||||
SpaceNotFoundError,
|
||||
savePushSubscription,
|
||||
selectSpaceModelPreference,
|
||||
selectSpaceVoicePreference,
|
||||
updateNotificationSettings,
|
||||
wireProjectWorkspace,
|
||||
} from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
@ -457,6 +463,8 @@ export interface RouterDeps {
|
||||
secrets: EncryptedSecretStore;
|
||||
oauthLogins: PiOAuthLogins;
|
||||
integrationSettings?: IntegrationProviderSettings;
|
||||
/** The deployment's web push key pair; without it browsers cannot subscribe. */
|
||||
vapid?: Pick<VapidKeys, "publicKey">;
|
||||
composio?: ComposioProvider;
|
||||
mcpOAuth?: McpOAuthBroker;
|
||||
connectors: ConnectorRegistry;
|
||||
@ -504,7 +512,11 @@ function mapSpaceLifecycleError(error: unknown): unknown {
|
||||
}
|
||||
|
||||
export function createRouter(deps: RouterDeps) {
|
||||
const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>();
|
||||
const os = implement(appContract).$context<{
|
||||
actor: Actor | null;
|
||||
signal?: AbortSignal;
|
||||
userAgent?: string;
|
||||
}>();
|
||||
const repos = createRepos(deps.prisma);
|
||||
const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors };
|
||||
const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets);
|
||||
@ -4758,6 +4770,41 @@ export function createRouter(deps: RouterDeps) {
|
||||
await deletePushToken(deps.dataDir, context.actor.userId);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
settings: {
|
||||
get: authed.notifications.settings.get.handler(({ context }) =>
|
||||
getNotificationSettings(deps.prisma, context.actor.userId),
|
||||
),
|
||||
update: authed.notifications.settings.update.handler(({ context, input }) =>
|
||||
updateNotificationSettings(deps.prisma, context.actor.userId, input),
|
||||
),
|
||||
},
|
||||
webPush: {
|
||||
config: authed.notifications.webPush.config.handler(async () => {
|
||||
const publicKey = (await deps.vapid?.publicKey()) ?? null;
|
||||
return { available: publicKey !== null, publicKey };
|
||||
}),
|
||||
subscribe: authed.notifications.webPush.subscribe.handler(async ({ context, input }) => {
|
||||
if (isBlockedHostname(new URL(input.endpoint).hostname.replace(/^\[|\]$/g, ""))) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Endpoint must be a public push service",
|
||||
});
|
||||
}
|
||||
await savePushSubscription(deps.prisma, context.actor.userId, {
|
||||
kind: "webpush",
|
||||
endpoint: input.endpoint,
|
||||
p256dh: input.keys.p256dh,
|
||||
auth: input.keys.auth,
|
||||
userAgent: context.userAgent,
|
||||
});
|
||||
return { ok: true as const };
|
||||
}),
|
||||
unsubscribe: authed.notifications.webPush.unsubscribe.handler(
|
||||
async ({ context, input }) => {
|
||||
await removePushSubscription(deps.prisma, context.actor.userId, input.endpoint);
|
||||
return { ok: true as const };
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
search: {
|
||||
query: authed.search.query.handler(async ({ context, input }) => ({
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<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 -->
|
||||
<link rel="stylesheet" href="/evoo-brand.css" />
|
||||
<title>evOo</title>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "EvOo",
|
||||
"short_name": "EvOo",
|
||||
"name": "evOo",
|
||||
"short_name": "evOo",
|
||||
"description": "Deine Oos — immer da, immer wach.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
|
||||
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 { applyUiAppearance, watchSystemAppearance } from "./lib/ui-appearance";
|
||||
import { resolveUiLocale } from "./lib/ui-locale";
|
||||
import { registerPushServiceWorker } from "./lib/web-push";
|
||||
import "./styles.css";
|
||||
|
||||
markOnce("rk:renderer:module-evaluated");
|
||||
@ -19,6 +20,7 @@ try {
|
||||
installPreloadRecovery();
|
||||
applyUiDirection(resolveUiLocale());
|
||||
applyUiAppearance();
|
||||
void registerPushServiceWorker();
|
||||
|
||||
function PerformanceProbe() {
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
"sharp": "0.35.4",
|
||||
"shell-quote": "1.10.0",
|
||||
"undici": "8.10.2",
|
||||
"web-push": "3.6.7",
|
||||
"zod": "4.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -50,6 +51,7 @@
|
||||
"@types/jsdom": "30.0.0",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/web-push": "3.6.4",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
|
||||
@ -124,5 +124,6 @@ export * from "./voice-factory.js";
|
||||
export * from "./wakeup.js";
|
||||
export * from "./web-limits.js";
|
||||
export * from "./web-provider-factory.js";
|
||||
export * from "./web-push.js";
|
||||
export * from "./web-ssrf.js";
|
||||
export * from "./web-tools.js";
|
||||
|
||||
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))) {
|
||||
throw new Error("URL resolves to a private address");
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ export * from "./integration-settings.js";
|
||||
export * from "./local-settings.js";
|
||||
export * from "./mcp.js";
|
||||
export * from "./model-tiers.js";
|
||||
export * from "./notifications.js";
|
||||
export * from "./openai-compatible-ui.js";
|
||||
export * from "./project-goals.js";
|
||||
export * from "./project-sessions.js";
|
||||
|
||||
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,
|
||||
} from "./integration-settings.js";
|
||||
import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js";
|
||||
import {
|
||||
NotificationSettingsSchema,
|
||||
UpdateNotificationSettingsInput,
|
||||
WebPushConfigSchema,
|
||||
WebPushEndpointSchema,
|
||||
WebPushSubscriptionInput,
|
||||
} from "./notifications.js";
|
||||
import {
|
||||
CreateProjectGoalInput,
|
||||
ProjectGoalSchema,
|
||||
@ -803,6 +810,17 @@ export const appContract = {
|
||||
.input(z.object({ token: z.string().min(8).max(512) }))
|
||||
.output(z.object({ ok: z.literal(true) })),
|
||||
unregisterPush: oc.output(z.object({ ok: z.literal(true) })),
|
||||
settings: {
|
||||
get: oc.output(NotificationSettingsSchema),
|
||||
update: oc.input(UpdateNotificationSettingsInput).output(NotificationSettingsSchema),
|
||||
},
|
||||
webPush: {
|
||||
config: oc.output(WebPushConfigSchema),
|
||||
subscribe: oc.input(WebPushSubscriptionInput).output(z.object({ ok: z.literal(true) })),
|
||||
unsubscribe: oc
|
||||
.input(z.object({ endpoint: WebPushEndpointSchema }))
|
||||
.output(z.object({ ok: z.literal(true) })),
|
||||
},
|
||||
},
|
||||
search: {
|
||||
query: oc.input(z.object({ q: z.string().max(200) })).output(SearchQueryOutputSchema),
|
||||
|
||||
@ -30,6 +30,7 @@ export * from "./messaging-prompts.js";
|
||||
export * from "./model-oauth.js";
|
||||
export * from "./model-probe.js";
|
||||
export * from "./model-providers.js";
|
||||
export * from "./quiet-hours.js";
|
||||
export * from "./response-bytes.js";
|
||||
export * from "./run-state.js";
|
||||
export * from "./sandbox-command.js";
|
||||
|
||||
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);
|
||||
@ -47,6 +47,8 @@ model User {
|
||||
agentSecrets AgentSecret[]
|
||||
profile UserProfile?
|
||||
facts UserFact[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
notificationSettings UserNotificationSettings?
|
||||
|
||||
@@unique([email])
|
||||
@@map("user")
|
||||
@ -1111,6 +1113,43 @@ model NotificationPreference {
|
||||
@@map("notification_preferences")
|
||||
}
|
||||
|
||||
/// One device that receives the user's push notifications: a browser (web push) or the mobile
|
||||
/// app (an Expo push token). An endpoint belongs to the account that registered it last.
|
||||
model PushSubscription {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
kind String
|
||||
/// The web push endpoint URL or the Expo push token.
|
||||
endpoint String @unique
|
||||
p256dh String?
|
||||
auth String?
|
||||
userAgent String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
|
||||
@@index([userId])
|
||||
// CHECK constraints push_subscriptions_kind_chk and push_subscriptions_webpush_keys_chk are created in SQL.
|
||||
@@map("push_subscriptions")
|
||||
}
|
||||
|
||||
/// When the user may be interrupted. No row means no quiet hours and nudges on.
|
||||
model UserNotificationSettings {
|
||||
userId String @id
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// Minutes after local midnight in the profile time zone; both set or both null.
|
||||
quietHoursStart Int?
|
||||
quietHoursEnd Int?
|
||||
nudges Boolean @default(true)
|
||||
/// The user's local date of the last nudge and how many were sent that day (daily cap).
|
||||
nudgeDate DateTime? @db.Date
|
||||
nudgeCount Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// CHECK constraints user_notification_settings_quiet_hours_chk and _nudge_count_chk are created in SQL.
|
||||
@@map("user_notification_settings")
|
||||
}
|
||||
|
||||
model SpaceMemoryConfig {
|
||||
id String @id @default(cuid())
|
||||
spaceId String @unique
|
||||
|
||||
@ -12,8 +12,10 @@ export * from "./messages.js";
|
||||
export * from "./messaging.js";
|
||||
export * from "./model-credentials.js";
|
||||
export * from "./model-tiers.js";
|
||||
export * from "./notification-settings.js";
|
||||
export * from "./project-goals.js";
|
||||
export * from "./project-workspace.js";
|
||||
export * from "./push-subscriptions.js";
|
||||
export * from "./repos.js";
|
||||
export * from "./scope.js";
|
||||
export * from "./spaces.js";
|
||||
|
||||
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"],
|
||||
["export/bot", { botId: "missing-bot" }],
|
||||
["notifications/registerPush", { token: "ExponentPushToken[not-real]" }],
|
||||
["notifications/unregisterPush"],
|
||||
["notifications/settings/get"],
|
||||
["notifications/settings/update", { nudges: false }],
|
||||
["notifications/webPush/config"],
|
||||
["notifications/webPush/subscribe", webPushSubscriptionInput("unauthenticated")],
|
||||
["notifications/webPush/unsubscribe", { endpoint: "https://push.example.test/none" }],
|
||||
["search/query", { q: "anything" }],
|
||||
["tasks/list", {}],
|
||||
["inbox/list"],
|
||||
@ -2358,6 +2364,13 @@ interface Bot {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function webPushSubscriptionInput(device: string) {
|
||||
return {
|
||||
endpoint: `https://push.example.test/${device}`,
|
||||
keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) },
|
||||
};
|
||||
}
|
||||
|
||||
function exhaustiveProtectedCalls<
|
||||
const Calls extends ReadonlyArray<readonly [ProtectedRpcPath, unknown?]>,
|
||||
>(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:
|
||||
specifier: 8.10.2
|
||||
version: 8.10.2
|
||||
web-push:
|
||||
specifier: 3.6.7
|
||||
version: 3.6.7
|
||||
zod:
|
||||
specifier: 4.6.2
|
||||
version: 4.6.2
|
||||
@ -637,6 +640,9 @@ importers:
|
||||
'@types/pg':
|
||||
specifier: 8.23.1
|
||||
version: 8.23.1
|
||||
'@types/web-push':
|
||||
specifier: 3.6.4
|
||||
version: 3.6.4
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
@ -4343,6 +4349,9 @@ packages:
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@ -4759,6 +4768,9 @@ packages:
|
||||
asap@2.0.6:
|
||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||
|
||||
asn1.js@5.4.1:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
|
||||
asn1@0.2.6:
|
||||
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
|
||||
|
||||
@ -5020,6 +5032,9 @@ packages:
|
||||
bluebird@3.7.2:
|
||||
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||
|
||||
bn.js@4.12.5:
|
||||
resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==}
|
||||
|
||||
body-parser@2.3.0:
|
||||
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
|
||||
engines: {node: '>=18'}
|
||||
@ -6752,6 +6767,10 @@ packages:
|
||||
resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==}
|
||||
engines: {node: '>=10.19.0'}
|
||||
|
||||
http_ece@1.2.0:
|
||||
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
@ -7620,6 +7639,9 @@ packages:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
minimalistic-assert@1.0.1:
|
||||
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||
|
||||
minimatch@10.2.6:
|
||||
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@ -9759,6 +9781,11 @@ packages:
|
||||
wcwidth@1.0.1:
|
||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||
|
||||
web-push@3.6.7:
|
||||
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
|
||||
engines: {node: '>= 16'}
|
||||
hasBin: true
|
||||
|
||||
web-streams-polyfill@3.3.3:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
@ -14103,6 +14130,10 @@ snapshots:
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
dependencies:
|
||||
'@types/node': 24.13.3
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.13.3
|
||||
@ -14605,6 +14636,13 @@ snapshots:
|
||||
|
||||
asap@2.0.6: {}
|
||||
|
||||
asn1.js@5.4.1:
|
||||
dependencies:
|
||||
bn.js: 4.12.5
|
||||
inherits: 2.0.4
|
||||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
asn1@0.2.6:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@ -14947,6 +14985,8 @@ snapshots:
|
||||
|
||||
bluebird@3.7.2: {}
|
||||
|
||||
bn.js@4.12.5: {}
|
||||
|
||||
body-parser@2.3.0:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@ -17016,6 +17056,8 @@ snapshots:
|
||||
quick-lru: 5.1.1
|
||||
resolve-alpn: 1.2.1
|
||||
|
||||
http_ece@1.2.0: {}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
@ -18116,6 +18158,8 @@ snapshots:
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
|
||||
minimatch@10.2.6:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
@ -20377,6 +20421,16 @@ snapshots:
|
||||
dependencies:
|
||||
defaults: 1.0.4
|
||||
|
||||
web-push@3.6.7:
|
||||
dependencies:
|
||||
asn1.js: 5.4.1
|
||||
http_ece: 1.2.0
|
||||
https-proxy-agent: 7.0.6
|
||||
jws: 4.0.1
|
||||
minimist: 1.2.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
web-vitals@6.2.1: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user