evOo-local/apps/web/src/lib/web-push.ts
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

103 lines
4.2 KiB
TypeScript

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