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
221 lines
7.8 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|