Claude a163447461
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
2026-10-03 13:13:22 +00:00

221 lines
7.8 KiB
TypeScript

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