feat(notifications): Phase 6 — web-push channel for PWA notifications (#3173)

* feat(notifications): Phase 6 — web-push channel for PWA notifications

Adds a web_push notification channel so PWA users receive native
notifications when this tab is closed. Deep-links click to the
brief magazine URL for brief_ready events, to the event link for
everything else.

Schema / API:
- channelTypeValidator gains 'web_push' literal
- notificationChannels union adds { endpoint, p256dh, auth,
  userAgent? } variant (standard PushSubscription identity triple +
  cosmetic UA for the settings UI)
- new setWebPushChannelForUser internal mutation upserts the row
- /relay/deactivate allow-list extended to accept 'web_push'
- api/notification-channels: 'set-web-push' action validates the
  triple, rejects non-https, truncates UA to 200 chars

Client (src/services/push-notifications.ts + src/config/push.ts):
- isWebPushSupported guards Tauri webview + iOS Safari
- subscribeToPush: permission + pushManager.subscribe + POST triple
- unsubscribeFromPush: pushManager.unsubscribe + DELETE row
- VAPID_PUBLIC_KEY constant (with VITE_VAPID_PUBLIC_KEY env override)
- base64 <-> Uint8Array helpers (VAPID key encoding)

Service worker (public/push-handler.js):
- Imported into VitePWA's generated sw.js via workbox.importScripts
- push event: renders notification; requireInteraction=true for
  brief_ready so a lock-screen swipe does not dismiss the CTA
- notificationclick: focuses+navigates existing same-origin client
  when present, otherwise opens a new window
- Malformed JSON falls back to raw text body, missing data falls
  back to a minimal WorldMonitor default

Relay (scripts/notification-relay.cjs):
- sendWebPush() with lazy-loaded web-push dep. 404/410 triggers
  deactivateChannel('web_push'). Missing VAPID env vars logs once
  and skips — other channels keep delivering.
- processEvent dispatch loop + drainHeldForUser both gain web_push
  branches

Settings UI (src/services/notifications-settings.ts):
- New 'Browser Push' tile with bell icon
- Enable button lazy-imports push-notifications, calls subscribe,
  renders 'Not supported' on Tauri/in-app webviews
- Remove button routes web_push specifically through
  unsubscribeFromPush so the browser side is cleaned up too

Env vars required on Railway services:
  VAPID_PUBLIC_KEY   public key
  VAPID_PRIVATE_KEY  private key
  VAPID_SUBJECT      mailto:support@worldmonitor.app (optional)

Public key is also committed as the default in src/config/push.ts
so the client bundle works without a build-time override.

Tests: 11 new cases in tests/brief-web-push.test.mjs
- base64 <-> Uint8Array round-trip + null guards
- VAPID default fallback when env absent
- SW push event rendering, requireInteraction gating, malformed JSON
  + no-data fallbacks
- SW notificationclick: openWindow vs focus+navigate, default url

154/154 tests pass. Both tsconfigs typecheck clean.

* fix(brief): address PR #3173 review findings + drop hardcoded VAPID

P1 (security): VAPID private key leaked in PR description.
Rotated the keypair. Old pair permanently invalidated. Structural fix:

  Removed DEFAULT_VAPID_PUBLIC_KEY entirely. Hardcoding the public
  key in src/config/push.ts gave rotations two sources of truth
  (code vs env) — exactly the friction that caused me to paste the
  private key in a PR description in the first place. VAPID_PUBLIC_KEY
  now comes SOLELY from VITE_VAPID_PUBLIC_KEY at build time.
  isWebPushConfigured() gates the subscribe flow so builds without
  the env var surface as 'Not supported' rather than crashing
  pushManager.subscribe.

  Operator setup (one-time):
    Vercel build:      VITE_VAPID_PUBLIC_KEY=<public>
    Railway services:  VAPID_PUBLIC_KEY=<public>
                       VAPID_PRIVATE_KEY=<private>
                       VAPID_SUBJECT=mailto:support@worldmonitor.app

  Rotation: update env on both sides, redeploy. No code change, no
  PR body — no chance of leaking a key in a commit.

P2: single-device fan-out — setWebPushChannelForUser replaces the
previous subscription silently. Per-device fan-out is a schema change
deferred to follow-up. Fix for now: surface the replacement in
settings UI copy ('Enabling here replaces any previously registered
browser.') so users who expect multi-device see the warning.

P2: 24h push TTL floods offline devices on reconnect. Event-type-aware:
  brief_ready:       12h  (daily editorial — still interesting)
  quiet_hours_batch:  6h  (by definition queued-on-wake)
  everything else:   30m  (transient alerts: noise after 30min)

REGRESSION test: VAPID_PUBLIC_KEY must be '' when env var is unset.
If a committed default is reintroduced, the test fails loudly.

11/11 web-push tests pass. Both tsconfigs typecheck clean.

* fix(notifications): deliver channel_welcome push for web_push connects (#3173 P2)

The settings UI queues a channel_welcome event on first web_push
subscribe (api/notification-channels.ts:240 via publishWelcome), but
processWelcome() in the relay only branched on slack/discord/email —
no web_push arm. The welcome event was consumed off the queue and
then silently dropped, leaving first-time subscribers with no
'connection confirmed' signal.

Fix: add a web_push branch to processWelcome. Calls sendWebPush with
eventType='channel_welcome' which maps to the 30-minute TTL tier in
the push-delivery switch — a welcome that arrives >30 min after
subscribe is noise, not confirmation.

Short body (under 80 chars) so Chrome/Firefox/Safari notification
shelves don't clip past ellipsis.

11/11 web-push tests pass.

* fix(notifications): address two P1 review findings on #3173

P1-A: SSRF via user-supplied web_push endpoint.
The set-web-push edge handler accepted any https:// URL and wrote
it to Convex. The relay's sendWebPush() later POSTs to whatever
endpoint sits in that row, giving any Pro user a server-side-request
primitive bounded only by the relay's network egress.

Fix: isAllowedPushEndpointHost() allow-list in api/notification-
channels.ts. Only the four known browser push-service hosts pass:

  fcm.googleapis.com                (Chrome / Edge / Brave)
  updates.push.services.mozilla.com (Firefox)
  web.push.apple.com                (Safari, macOS 13+)
  *.notify.windows.com              (Windows Notification Service)

Fail-closed: unknown hosts rejected with 400 before the row ever
reaches Convex. If a future browser ships a new push service we'll
need to widen this list (guarded by the SSRF regression tests).

P1-B: cross-account endpoint reuse on shared devices.
The browser's PushSubscription is bound to the origin, NOT to the
Clerk session. User A subscribes on device X, signs out, user B
signs in on X and subscribes — the browser hands out the SAME
endpoint/p256dh/auth triple. The previous setWebPushChannelForUser
upsert keyed only by (userId, channelType), so BOTH rows now carry
the same endpoint. Every push the relay fans out for user A also
lands on device X which is now showing user B's session.

Fix: setWebPushChannelForUser scans all web_push rows and deletes
any that match the new endpoint BEFORE upserting. Effectively
transfers ownership of the subscription to the current caller.
The previous user will need to re-subscribe on that device if they
sign in again.

No endpoint-based index on notificationChannels — the scan happens
at <10k rows and is well-bounded to the one write-path per user
per connect. If volume grows, add an  + migration.

Regression tests (tests/brief-web-push.test.mjs, 3 new cases):
  - allow-list defines all four browser hosts + fail-closed return
  - allow-list is invoked BEFORE convexRelay() in the handler
  - setWebPushChannelForUser compares + deletes rows by endpoint

14/14 web-push tests pass. Both tsconfigs typecheck clean.
This commit is contained in:
Elie Habib
2026-04-18 20:27:08 +04:00
committed by GitHub
parent c2356890da
commit e1c3b28180
15 changed files with 1208 additions and 6 deletions

View File

@@ -46,6 +46,37 @@ async function encryptSlackWebhook(webhookUrl: string): Promise<string> {
return `v1:${btoa(binary)}`;
}
/**
* Allow-list of hostnames every major browser's push service uses.
*
* A PushSubscription's endpoint URL is assigned by the browser's
* push platform — users can't pick it. That means we CAN safely
* constrain accepted endpoints to known push-service hosts and
* reject anything else before it hits Convex storage (and later
* the relay's outbound fetch). Without this allow-list the relay's
* sendWebPush() becomes a server-side-request primitive for any
* PRO user: they could submit `https://internal.example.com/admin`
* as their endpoint and the relay would faithfully POST to it.
*
* Sources (verified 2026-04-18):
* - Chrome / Edge / Brave: fcm.googleapis.com
* - Firefox: updates.push.services.mozilla.com
* - Safari (macOS 13+): web.push.apple.com
* - Windows Notification: *.notify.windows.com (wns2-*, etc.)
*
* If a future browser ships a new push service we'll need to widen
* this list — fail-closed is the right default.
*/
function isAllowedPushEndpointHost(host: string): boolean {
const h = host.toLowerCase();
if (h === 'fcm.googleapis.com') return true;
if (h === 'updates.push.services.mozilla.com') return true;
if (h === 'web.push.apple.com') return true;
if (h.endsWith('.web.push.apple.com')) return true;
if (h.endsWith('.notify.windows.com')) return true;
return false;
}
async function publishWelcome(userId: string, channelType: string): Promise<void> {
if (!UPSTASH_URL || !UPSTASH_TOKEN) {
console.error('[notification-channels] publishWelcome: UPSTASH env vars missing — welcome not queued');
@@ -116,6 +147,11 @@ interface PostBody {
eventTypes?: string[];
sensitivity?: string;
channels?: string[];
// web_push subscription triple (Phase 6)
endpoint?: string;
p256dh?: string;
auth?: string;
userAgent?: string;
quietHoursEnabled?: boolean;
quietHoursStart?: number;
quietHoursEnd?: number;
@@ -240,6 +276,52 @@ export default async function handler(req: Request, ctx: { waitUntil: (p: Promis
return json({ ok: true }, 200, corsHeaders);
}
if (action === 'set-web-push') {
const { endpoint, p256dh, auth, userAgent } = body;
if (!endpoint || !p256dh || !auth) {
return json({ error: 'endpoint, p256dh, auth required' }, 400, corsHeaders);
}
// SSRF defence. The relay later POSTs to whatever endpoint we
// persist here, so an unvalidated user-submitted URL is a
// server-side-request primitive bounded only by the relay's
// network egress. Browsers always produce endpoints at one
// of a small set of push-service hosts (FCM, Mozilla, Apple,
// Windows Notification Service); anything else is either an
// exotic browser (rare) or an attack. Allow-list the known
// hosts and reject everything else.
try {
const u = new URL(endpoint);
if (u.protocol !== 'https:') {
return json({ error: 'endpoint must be https' }, 400, corsHeaders);
}
if (!isAllowedPushEndpointHost(u.hostname)) {
return json(
{ error: 'endpoint host is not a recognised push service' },
400,
corsHeaders,
);
}
} catch {
return json({ error: 'invalid endpoint' }, 400, corsHeaders);
}
const resp = await convexRelay({
action: 'set-web-push',
userId: session.userId,
endpoint,
p256dh,
auth,
// Trim user agent; it's cosmetic for the settings UI, not identity.
userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 200) : undefined,
});
if (!resp.ok) {
console.error('[notification-channels] POST set-web-push relay error:', resp.status);
return json({ error: 'Operation failed' }, 500, corsHeaders);
}
const wpResult = await resp.json() as { ok: boolean; isNew?: boolean };
if (wpResult.isNew) ctx.waitUntil(publishWelcome(session.userId, 'web_push'));
return json({ ok: true }, 200, corsHeaders);
}
if (action === 'delete-channel') {
const { channelType } = body;
if (!channelType) return json({ error: 'channelType required' }, 400, corsHeaders);

View File

@@ -6,6 +6,7 @@ export const channelTypeValidator = v.union(
v.literal("email"),
v.literal("discord"),
v.literal("webhook"),
v.literal("web_push"),
);
export const sensitivityValidator = v.union(

View File

@@ -272,7 +272,7 @@ http.route({
if (
typeof body.userId !== "string" || !body.userId ||
(body.channelType !== "telegram" && body.channelType !== "slack" && body.channelType !== "email" && body.channelType !== "discord")
(body.channelType !== "telegram" && body.channelType !== "slack" && body.channelType !== "email" && body.channelType !== "discord" && body.channelType !== "web_push")
) {
return new Response(JSON.stringify({ error: "MISSING_FIELDS" }), {
status: 400,
@@ -367,6 +367,10 @@ http.route({
slackConfigurationUrl?: string;
discordGuildId?: string;
discordChannelId?: string;
endpoint?: string;
p256dh?: string;
auth?: string;
userAgent?: string;
quietHoursEnabled?: boolean;
quietHoursStart?: number;
quietHoursEnd?: number;
@@ -456,6 +460,20 @@ http.route({
return new Response(JSON.stringify({ ok: true, isNew: discordResult.isNew }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (action === "set-web-push") {
if (!body.endpoint || !body.p256dh || !body.auth) {
return new Response(JSON.stringify({ error: "endpoint, p256dh, auth required" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
const webPushResult = await ctx.runMutation((internal as any).notificationChannels.setWebPushChannelForUser, {
userId,
endpoint: body.endpoint,
p256dh: body.p256dh,
auth: body.auth,
userAgent: body.userAgent,
});
return new Response(JSON.stringify({ ok: true, isNew: webPushResult.isNew }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (action === "delete-channel") {
if (!body.channelType) {
return new Response(JSON.stringify({ error: "channelType required" }), { status: 400, headers: { "Content-Type": "application/json" } });

View File

@@ -54,6 +54,83 @@ export const setChannelForUser = internalMutation({
},
});
// Web Push (Phase 6). Stored as its own internal mutation because the
// payload shape is incompatible with setChannelForUser (three required
// identity fields, no chatId/webhookEnvelope/email). Replaces any
// prior subscription for this user — one subscription per user until
// per-device fan-out is needed.
//
// Cross-account dedupe: the browser's PushSubscription is bound to
// the origin, NOT to the Clerk session. If user A subscribes on
// device X, signs out, then user B signs in on the same device X
// and subscribes, the browser hands out the SAME endpoint. Without
// this dedupe, both users' rows carry the same endpoint — meaning
// every alert the relay fans out to user A would also deliver to
// user B on that shared device, and vice versa. That's a cross-
// account privacy leak.
//
// Fix: before writing the new row, delete any existing rows
// anywhere in the table that match this endpoint. Effectively
// transfers ownership of the subscription to the current caller.
// The previous user will need to re-subscribe on that device if
// they sign in again.
export const setWebPushChannelForUser = internalMutation({
args: {
userId: v.string(),
endpoint: v.string(),
p256dh: v.string(),
auth: v.string(),
userAgent: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Step 1: scan for any existing rows with this endpoint across
// ALL users and delete them. notificationChannels has no
// endpoint-based index, so we filter at read time — acceptable
// at current scale (<10k rows) and well-bounded to a single
// write-path per user per connect.
const allWebPush = await ctx.db
.query("notificationChannels")
.collect();
for (const row of allWebPush) {
if (
row.channelType === "web_push" &&
// Narrow through the channel-type literal so TS knows
// `endpoint` exists on this row.
row.endpoint === args.endpoint
) {
await ctx.db.delete(row._id);
}
}
// Step 2: upsert the current-user row by (userId, channelType).
// After the delete above there is at most one row matching the
// unique index, so .unique() is safe.
const existing = await ctx.db
.query("notificationChannels")
.withIndex("by_user_channel", (q) =>
q.eq("userId", args.userId).eq("channelType", "web_push"),
)
.unique();
const isNew = !existing;
const doc = {
userId: args.userId,
channelType: "web_push" as const,
endpoint: args.endpoint,
p256dh: args.p256dh,
auth: args.auth,
verified: true,
linkedAt: Date.now(),
userAgent: args.userAgent,
};
if (existing) {
await ctx.db.replace(existing._id, doc);
} else {
await ctx.db.insert("notificationChannels", doc);
}
return { isNew };
},
});
export const setSlackOAuthChannelForUser = internalMutation({
args: {
userId: v.string(),

View File

@@ -74,6 +74,23 @@ export default defineSchema({
webhookLabel: v.optional(v.string()),
webhookSecret: v.optional(v.string()),
}),
// Web Push (Phase 6). endpoint+p256dh+auth are the standard
// PushSubscription identity triple — not secrets, just per-device
// pairing material (they identify the browser's push endpoint at
// Mozilla/Google/Apple). Stored plaintext to match the rest of
// this table. userAgent is cosmetic: lets the settings UI show
// "Chrome · MacOS" next to the Remove button so users can tell
// which device a subscription belongs to.
v.object({
userId: v.string(),
channelType: v.literal("web_push"),
endpoint: v.string(),
p256dh: v.string(),
auth: v.string(),
verified: v.boolean(),
linkedAt: v.number(),
userAgent: v.optional(v.string()),
}),
),
)
.index("by_user", ["userId"])

88
public/push-handler.js Normal file
View File

@@ -0,0 +1,88 @@
// Service worker push handler (Phase 6).
//
// Imported by VitePWA's generated sw.js via workbox.importScripts:
// ['/push-handler.js']. Runs in the SW global scope — has access to
// self.addEventListener, self.registration.showNotification,
// clients.openWindow, etc.
//
// Payload contract (sent by scripts/notification-relay.cjs):
// { title: string, body: string, url?: string, tag?: string,
// icon?: string, badge?: string }
//
// Any deviation from that shape falls back to a safe default so a
// malformed payload still renders something readable instead of
// silently dropping the notification.
/* eslint-env serviceworker */
/* global self, clients */
self.addEventListener('push', (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch (_err) {
// Non-JSON payload: treat the text body as the notification body.
try {
data = { title: 'WorldMonitor', body: event.data ? event.data.text() : '' };
} catch {
data = {};
}
}
const title = typeof data.title === 'string' && data.title.length > 0
? data.title
: 'WorldMonitor';
const body = typeof data.body === 'string' ? data.body : '';
const url = typeof data.url === 'string' ? data.url : '/';
const tag = typeof data.tag === 'string' ? data.tag : 'worldmonitor-generic';
const icon = typeof data.icon === 'string'
? data.icon
: '/favico/android-chrome-192x192.png';
const badge = typeof data.badge === 'string'
? data.badge
: '/favico/android-chrome-192x192.png';
const opts = {
body,
icon,
badge,
tag,
// requireInteraction keeps the notification on screen until the
// user acts on it. Critical for brief_ready where we want the
// reader to actually open the magazine, not dismiss it from the
// lock screen.
requireInteraction: data.eventType === 'brief_ready',
data: { url, eventType: data.eventType ?? 'unknown' },
};
event.waitUntil(self.registration.showNotification(title, opts));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil((async () => {
try {
const all = await clients.matchAll({ type: 'window', includeUncontrolled: true });
// If an existing window points at our origin, focus it and
// navigate rather than spawning a new tab. Cheaper for the
// user, less duplicated app state.
for (const c of all) {
try {
const sameOrigin = new URL(c.url).origin === self.location.origin;
if (sameOrigin && 'focus' in c) {
if ('navigate' in c && typeof c.navigate === 'function') {
await c.navigate(target);
}
return c.focus();
}
} catch {
// URL parse failure or cross-origin — fall through to open.
}
}
if (clients.openWindow) return clients.openWindow(target);
} catch {
// Swallow — nothing to do beyond failing silently.
}
})());
});

View File

@@ -192,6 +192,15 @@ async function drainHeldForUser(userId, variant, allowedChannelTypes) {
alerts: events.map(ev => ({ eventType: ev.eventType, severity: ev.severity ?? 'high', title: ev.payload?.title ?? ev.eventType })),
},
});
else if (ch.channelType === 'web_push' && ch.endpoint && ch.p256dh && ch.auth) {
ok = await sendWebPush(userId, ch, {
title: `WorldMonitor · ${events.length} held alert${events.length === 1 ? '' : 's'}`,
body: subject,
url: 'https://worldmonitor.app/',
tag: `quiet_hours_batch:${userId}`,
eventType: 'quiet_hours_batch',
});
}
if (ok) anyDelivered = true;
} catch (err) {
console.warn(`[relay] drainHeldForUser: delivery error for ${userId}/${ch.channelType}:`, err.message);
@@ -484,6 +493,114 @@ async function sendWebhook(userId, webhookEnvelope, event) {
}
}
// ── Web Push (Phase 6) ────────────────────────────────────────────────────────
//
// Lazy-require web-push so the relay can still start on Railway if the
// dep isn't pulled in. If VAPID keys are unset the relay logs once and
// skips web_push deliveries entirely — telegram/slack/email still work.
let webpushLib = null;
let webpushConfigured = false;
let webpushConfigWarned = false;
function getWebpushClient() {
if (webpushLib) return webpushLib;
try {
webpushLib = require('web-push');
} catch (err) {
if (!webpushConfigWarned) {
console.warn('[relay] web-push dep unavailable — web_push deliveries disabled:', err.message);
webpushConfigWarned = true;
}
return null;
}
return webpushLib;
}
function ensureVapidConfigured(client) {
if (webpushConfigured) return true;
const pub = process.env.VAPID_PUBLIC_KEY;
const priv = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT || 'mailto:support@worldmonitor.app';
if (!pub || !priv) {
if (!webpushConfigWarned) {
console.warn('[relay] VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY not set — web_push deliveries disabled');
webpushConfigWarned = true;
}
return false;
}
try {
client.setVapidDetails(subject, pub, priv);
webpushConfigured = true;
return true;
} catch (err) {
console.warn('[relay] VAPID configuration failed:', err.message);
return false;
}
}
/**
* Deliver a web push notification to one subscription. Returns true on
* success. On 404/410 (subscription gone) the channel is deactivated
* in Convex so the next run doesn't re-try a dead endpoint.
*
* @param {string} userId
* @param {{ endpoint: string; p256dh: string; auth: string }} subscription
* @param {{ title: string; body: string; url?: string; tag?: string; eventType?: string }} payload
*/
async function sendWebPush(userId, subscription, payload) {
const client = getWebpushClient();
if (!client) return false;
if (!ensureVapidConfigured(client)) return false;
const body = JSON.stringify({
title: payload.title || 'WorldMonitor',
body: payload.body || '',
url: payload.url || 'https://worldmonitor.app/',
tag: payload.tag || 'worldmonitor-generic',
eventType: payload.eventType,
});
// Event-type-aware TTL. Push services hold undeliverable messages
// until TTL expires — a 24h blanket meant a device offline 20h
// would reconnect to a flood of yesterday's rss_alerts. Three tiers:
// brief_ready: 12h — the editorial brief is a daily artefact
// and remains interesting into the next
// afternoon even on a long reconnect
// quiet_hours_batch: 6h — by definition the alerts inside are
// already queued-on-wake; users care
// about the batch when they wake
// everything else: 30 min — rss_alert / oref_siren / conflict_
// escalation are transient. After 30 min
// they're noise; the dashboard is the
// canonical surface.
const ttlSec =
payload.eventType === 'brief_ready' ? 60 * 60 * 12 :
payload.eventType === 'quiet_hours_batch' ? 60 * 60 * 6 :
60 * 30;
try {
await client.sendNotification(
{
endpoint: subscription.endpoint,
keys: { p256dh: subscription.p256dh, auth: subscription.auth },
},
body,
{ TTL: ttlSec },
);
return true;
} catch (err) {
const code = err?.statusCode;
if (code === 404 || code === 410) {
console.warn(`[relay] web_push ${code} for ${userId} — deactivating`);
await deactivateChannel(userId, 'web_push');
return false;
}
console.warn(`[relay] web_push delivery error for ${userId}:`, err?.message ?? String(err));
return false;
}
}
// ── Event processing ──────────────────────────────────────────────────────────
function matchesSensitivity(ruleSensitivity, eventSeverity) {
@@ -551,6 +668,20 @@ async function processWelcome(event) {
await sendDiscord(userId, ch.webhookEnvelope, text);
} else if (channelType === 'email' && ch.email) {
await sendEmail(ch.email, 'WorldMonitor Notifications Connected', text);
} else if (channelType === 'web_push' && ch.endpoint && ch.p256dh && ch.auth) {
// Welcome push on first web_push connect. Short body — Chrome's
// notification shelf clips past ~80 chars on most OSes. Click
// opens the dashboard so the user lands somewhere useful. Uses
// the 'channel_welcome' event type which maps to the 30-min TTL
// in sendWebPush — a welcome past 30 minutes after subscribe is
// noise, not value.
await sendWebPush(userId, ch, {
title: 'WorldMonitor connected',
body: "You'll receive alerts here when events match your sensitivity settings.",
url: 'https://worldmonitor.app/',
tag: `channel_welcome:${userId}`,
eventType: 'channel_welcome',
});
}
}
@@ -796,6 +927,20 @@ async function processEvent(event) {
await sendEmail(ch.email, subject, deliveryText);
} else if (ch.channelType === 'webhook' && ch.webhookEnvelope) {
await sendWebhook(rule.userId, ch.webhookEnvelope, event);
} else if (ch.channelType === 'web_push' && ch.endpoint && ch.p256dh && ch.auth) {
// Web push carries short payloads (Chrome caps at ~4KB and
// auto-truncates longer ones anyway). Use title + first line
// of the formatted text as the body; the click URL points
// at the event's link if present, else the dashboard.
const firstLine = (deliveryText || '').split('\n')[1] || '';
const eventUrl = event.payload?.link || event.payload?.url || 'https://worldmonitor.app/';
await sendWebPush(rule.userId, ch, {
title: event.payload?.title || event.eventType || 'WorldMonitor',
body: firstLine,
url: eventUrl,
tag: `${event.eventType}:${rule.userId}`,
eventType: event.eventType,
});
}
} catch (err) {
console.warn(`[relay] Delivery error for ${rule.userId}/${ch.channelType}:`, err instanceof Error ? err.message : String(err));

View File

@@ -18,6 +18,7 @@
"telegram": "^2.22.2",
"tsx": "^4.21.0",
"undici": "^7.0.0",
"web-push": "^3.6.7",
"ws": "^8.18.0"
},
"engines": {
@@ -2160,6 +2161,15 @@
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/archiver": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
@@ -2229,6 +2239,18 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -2333,6 +2355,12 @@
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
@@ -2382,6 +2410,12 @@
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-indexof-polyfill": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
@@ -2662,6 +2696,15 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -3050,6 +3093,51 @@
"entities": "^2.0.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -3184,6 +3272,27 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -3341,6 +3450,12 @@
"node": ">=10.0.0"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -3642,6 +3757,12 @@
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
@@ -3948,6 +4069,25 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/websocket": {
"version": "1.0.35",
"resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz",
@@ -4056,4 +4196,3 @@
}
}
}

View File

@@ -18,8 +18,9 @@
"resend": "^4",
"sax": "^1.6.0",
"telegram": "^2.22.2",
"undici": "^7.0.0",
"tsx": "^4.21.0",
"undici": "^7.0.0",
"web-push": "^3.6.7",
"ws": "^8.18.0"
},
"engines": {

53
src/config/push.ts Normal file
View File

@@ -0,0 +1,53 @@
// Web Push configuration (Phase 6).
//
// The VAPID public key is served from the `VITE_VAPID_PUBLIC_KEY`
// build-time env var ONLY. No committed fallback:
//
// - A committed fallback means two sources of truth (code vs env)
// which causes "did the rotation actually ship?" confusion.
// - The public key is public, but rotating the keypair should be a
// pure env-var operation (update Vercel + Railway env → redeploy),
// not a code change.
// - If the env var is missing at build time we WANT the push
// subscribe path to fail loudly at runtime rather than silently
// register against a stale key.
//
// Partner private key lives in Railway (`VAPID_PRIVATE_KEY`) and
// signs the JWT attached to every push delivery. NEVER paste it in
// PR descriptions, commit messages, issue comments, or chat — a
// leaked VAPID private key grants send-to-any-subscriber capability
// against this origin's push subscribers.
const ENV_KEY = (import.meta as unknown as { env?: Record<string, string | undefined> }).env?.VITE_VAPID_PUBLIC_KEY;
/**
* VAPID public key from build-time env. Empty string when the env
* var is unset — `isWebPushConfigured()` guards the subscribe flow
* so callers don't attempt `pushManager.subscribe` without a key.
*/
export const VAPID_PUBLIC_KEY: string =
typeof ENV_KEY === 'string' ? ENV_KEY.trim() : '';
/** True when the client bundle was built with a VAPID public key set. */
export function isWebPushConfigured(): boolean {
return VAPID_PUBLIC_KEY.length > 0;
}
/** Convert a URL-safe base64 VAPID key into the Uint8Array pushManager wants. */
export function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const normal = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(normal);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
/** Convert an ArrayBuffer push-subscription key into a URL-safe base64 string. */
export function arrayBufferToBase64(buf: ArrayBuffer | null): string {
if (!buf) return '';
const bytes = new Uint8Array(buf);
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i] as number);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

View File

@@ -1,7 +1,7 @@
import { getClerkToken } from '@/services/clerk';
import { SITE_VARIANT } from '@/config/variant';
export type ChannelType = 'telegram' | 'slack' | 'email' | 'discord' | 'webhook';
export type ChannelType = 'telegram' | 'slack' | 'email' | 'discord' | 'webhook' | 'web_push';
export type Sensitivity = 'all' | 'high' | 'critical';
export type QuietHoursOverride = 'critical_only' | 'silence_all' | 'batch_on_wake';
export type DigestMode = 'realtime' | 'daily' | 'twice_daily' | 'weekly';
@@ -16,6 +16,11 @@ export interface NotificationChannel {
slackTeamName?: string;
slackConfigurationUrl?: string;
webhookLabel?: string;
// web_push identity fields
endpoint?: string;
p256dh?: string;
auth?: string;
userAgent?: string;
}
export interface AlertRule {

View File

@@ -86,12 +86,13 @@ export function renderNotificationsSettings(host: NotificationsSettingsHost): No
function channelIcon(type: ChannelType): string {
if (type === 'telegram') return `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>`;
if (type === 'email') return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>`;
if (type === 'web_push') return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>`;
if (type === 'webhook') return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
if (type === 'discord') return `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>`;
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>`;
}
const CHANNEL_LABELS: Record<ChannelType, string> = { telegram: 'Telegram', email: 'Email', slack: 'Slack', discord: 'Discord', webhook: 'Webhook' };
const CHANNEL_LABELS: Record<ChannelType, string> = { telegram: 'Telegram', email: 'Email', slack: 'Slack', discord: 'Discord', webhook: 'Webhook', web_push: 'Browser Push' };
function renderChannelRow(channel: NotificationChannel | null, type: ChannelType): string {
const icon = channelIcon(type);
@@ -108,6 +109,12 @@ export function renderNotificationsSettings(host: NotificationsSettingsHost): No
sub = 'Connected';
} else if (type === 'webhook') {
sub = channel.webhookLabel ? escapeHtml(channel.webhookLabel) : 'Connected';
} else if (type === 'web_push') {
// User-Agent is long and ugly. Surface a short label only:
// "Chrome", "Firefox", "Safari", etc.
const ua = channel.userAgent ?? '';
const browser = /Firefox\/|Chrome\/|Edge\/|Safari\//.exec(ua)?.[0]?.replace('/', '') ?? 'This device';
sub = escapeHtml(browser);
} else {
const rawCh = channel.slackChannelName ?? '';
const ch = rawCh ? `#${escapeHtml(rawCh.startsWith('#') ? rawCh.slice(1) : rawCh)}` : 'connected';
@@ -202,13 +209,26 @@ export function renderNotificationsSettings(host: NotificationsSettingsHost): No
</div>`;
}
if (type === 'web_push') {
return `<div class="us-notif-ch-row" data-channel-type="web_push">
<div class="us-notif-ch-icon">${icon}</div>
<div class="us-notif-ch-body">
<div class="us-notif-ch-name">${name}</div>
<div class="us-notif-ch-sub">Native notifications on this device. Enabling here replaces any previously registered browser.</div>
</div>
<div class="us-notif-ch-actions">
<button type="button" class="us-notif-ch-btn us-notif-ch-btn-primary" id="usConnectWebPush">Enable</button>
</div>
</div>`;
}
return '';
}
const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
function renderNotifContent(data: Awaited<ReturnType<typeof getChannelsData>>): string {
const channelTypes: ChannelType[] = ['telegram', 'email', 'slack', 'discord', 'webhook'];
const channelTypes: ChannelType[] = ['telegram', 'email', 'slack', 'discord', 'webhook', 'web_push'];
const alertRule = data.alertRules?.[0] ?? null;
const sensitivity = alertRule?.sensitivity ?? 'all';
@@ -737,9 +757,56 @@ export function renderNotificationsSettings(host: NotificationsSettingsHost): No
return;
}
if (target.closest('#usConnectWebPush')) {
const btn = target.closest<HTMLButtonElement>('#usConnectWebPush');
if (btn) {
btn.disabled = true;
btn.textContent = 'Requesting…';
}
(async () => {
try {
const { subscribeToPush, isWebPushSupported } = await import('@/services/push-notifications');
if (!isWebPushSupported()) {
if (btn) {
btn.disabled = false;
btn.textContent = 'Not supported';
btn.setAttribute('title', 'This browser (or in-app webview) does not support web push notifications.');
}
return;
}
await subscribeToPush();
if (!signal.aborted) { saveRuleWithNewChannel('web_push'); reloadNotifSection(); }
} catch (err) {
console.warn('[notif] web_push subscribe failed:', err);
if (btn && !signal.aborted) {
btn.disabled = false;
btn.textContent = 'Enable';
}
}
})();
return;
}
const disconnectBtn = target.closest<HTMLElement>('.us-notif-disconnect[data-channel]');
if (disconnectBtn?.dataset.channel) {
const channelType = disconnectBtn.dataset.channel as ChannelType;
if (channelType === 'web_push') {
// web_push needs two-sided cleanup: server row + browser
// PushSubscription. unsubscribeFromPush calls both so the
// user doesn't end up with a phantom browser subscription
// after the Convex row is deleted.
(async () => {
try {
const { unsubscribeFromPush } = await import('@/services/push-notifications');
await unsubscribeFromPush();
} catch (err) {
console.warn('[notif] web_push unsubscribe failed:', err);
} finally {
if (!signal.aborted) reloadNotifSection();
}
})();
return;
}
deleteChannel(channelType).then(() => {
if (!signal.aborted) reloadNotifSection();
}).catch(() => {});

View File

@@ -0,0 +1,193 @@
// Web Push subscription lifecycle (Phase 6).
//
// Tiny wrapper around navigator.serviceWorker.pushManager that:
// 1. Waits for the existing service worker (registered in main.ts)
// 2. Calls Notification.requestPermission() if not already granted
// 3. Calls pushManager.subscribe({ userVisibleOnly, applicationServerKey })
// 4. Hands the { endpoint, p256dh, auth } triple to the
// /api/notification-channels edge route as action=set-web-push
// 5. Mirrors unsubscribe on delete
//
// Notes on scope:
// - We do NOT register a fresh service worker here. VitePWA already
// registers /sw.js at '/' scope in main.ts. Browsers enforce one
// SW per scope, and the VitePWA SW imports our push handler via
// workbox.importScripts.
// - Permission state is read-through, not cached. Browsers handle
// the prompt UX; repeated requestPermission() calls while 'default'
// are idempotent per spec.
// - On subscribe(), if a prior subscription exists on this device
// we return it without re-prompting. Re-linking an already-linked
// device is a no-op from the user's perspective.
import { getClerkToken } from '@/services/clerk';
import { VAPID_PUBLIC_KEY, isWebPushConfigured, urlBase64ToUint8Array, arrayBufferToBase64 } from '@/config/push';
export type PushPermission = 'default' | 'granted' | 'denied' | 'unsupported';
/**
* True if the current context can reasonably attempt web push.
* Tauri's webview, iOS Safari without "Add to Home Screen", and
* in-app browsers typically fail this check and the UI should
* surface a "Install the app first" hint instead of an error.
*
* Also returns false when the client bundle was built without a
* VAPID public key (VITE_VAPID_PUBLIC_KEY unset at build time) —
* in that case subscribe() would throw a cryptic pushManager error;
* surfacing it as "unsupported" routes the UI through the same
* "can't subscribe on this build" path as iOS/Tauri.
*/
export function isWebPushSupported(): boolean {
if (typeof window === 'undefined') return false;
if ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) return false;
if (!('serviceWorker' in navigator)) return false;
if (!('PushManager' in window)) return false;
if (typeof Notification === 'undefined') return false;
if (!isWebPushConfigured()) return false;
return true;
}
export function getPushPermission(): PushPermission {
if (!isWebPushSupported()) return 'unsupported';
return Notification.permission as PushPermission;
}
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
if (!isWebPushSupported()) return null;
// VitePWA's registered SW is at '/'. getRegistration() without args
// returns the one that controls the current page; .ready waits until
// it's actually active so pushManager.subscribe() doesn't race.
try {
return await navigator.serviceWorker.ready;
} catch {
return null;
}
}
/** Read-only: is there an existing push subscription for this device? */
export async function getExistingSubscription(): Promise<PushSubscription | null> {
const reg = await getRegistration();
if (!reg) return null;
try {
return await reg.pushManager.getSubscription();
} catch {
return null;
}
}
interface SubscriptionPayload {
endpoint: string;
p256dh: string;
auth: string;
userAgent: string;
}
function subscriptionToPayload(sub: PushSubscription): SubscriptionPayload | null {
const p256dh = arrayBufferToBase64(sub.getKey('p256dh'));
const auth = arrayBufferToBase64(sub.getKey('auth'));
if (!p256dh || !auth || !sub.endpoint) return null;
return {
endpoint: sub.endpoint,
p256dh,
auth,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 200) : '',
};
}
async function authFetch(path: string, init: RequestInit): Promise<Response> {
const token = await getClerkToken();
if (!token) throw new Error('Not authenticated');
return fetch(path, {
...init,
headers: {
'Content-Type': 'application/json',
...(init.headers ?? {}),
Authorization: `Bearer ${token}`,
},
});
}
/**
* Ask permission (if needed), subscribe via pushManager, and register
* the endpoint with the server. Resolves with the payload the server
* accepted, or throws on cancel / denial / network failure.
*/
export async function subscribeToPush(): Promise<SubscriptionPayload> {
if (!isWebPushSupported()) {
throw new Error('Web push is not supported in this browser.');
}
const reg = await getRegistration();
if (!reg) throw new Error('Service worker unavailable.');
const perm = await Notification.requestPermission();
if (perm !== 'granted') {
throw new Error(
perm === 'denied'
? 'Notifications are blocked. Enable them in your browser settings to continue.'
: 'Notifications permission was not granted.',
);
}
// Re-use an existing subscription if pushManager already has one
// for this origin. Re-registering via POST keeps server state in
// sync even when the browser has a stale subscription — this is
// important after sign-out/sign-in, where the browser's push
// identity persists but the Convex row does not.
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) as BufferSource,
});
}
const payload = subscriptionToPayload(sub);
if (!payload) {
// Chrome occasionally hands back a subscription with null keys on
// first-grant. Unsubscribe and retry once.
await sub.unsubscribe().catch(() => {});
const retry = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) as BufferSource,
});
const retryPayload = subscriptionToPayload(retry);
if (!retryPayload) throw new Error('Failed to extract push subscription keys.');
await postSubscription(retryPayload);
return retryPayload;
}
await postSubscription(payload);
return payload;
}
async function postSubscription(payload: SubscriptionPayload): Promise<void> {
const res = await authFetch('/api/notification-channels', {
method: 'POST',
body: JSON.stringify({ action: 'set-web-push', ...payload }),
});
if (!res.ok) {
throw new Error(`Failed to register push subscription (${res.status}).`);
}
}
/** Reverse of subscribeToPush: unregisters with the server and the browser. */
export async function unsubscribeFromPush(): Promise<void> {
const reg = await getRegistration();
if (reg) {
const sub = await reg.pushManager.getSubscription().catch(() => null);
if (sub) {
// Best-effort browser-side unsubscribe. If the network delete
// fails we still want the browser subscription removed so the
// user isn't left with a phantom channel.
await sub.unsubscribe().catch(() => {});
}
}
try {
await authFetch('/api/notification-channels', {
method: 'POST',
body: JSON.stringify({ action: 'delete-channel', channelType: 'web_push' }),
});
} catch {
// Ignore — best-effort. Server will age the row out naturally.
}
}

View File

@@ -0,0 +1,312 @@
// Phase 6 — Web Push unit tests.
//
// Two targets:
// 1. Pure helpers in src/config/push.ts — base64 ↔ Uint8Array round-trip
// and shape guards.
// 2. The SW push handler at public/push-handler.js. We load the file
// into a minimal service-worker sandbox (fake `self` + `clients`)
// and fire synthetic push + notificationclick events to verify
// the handler's behaviour without a real browser.
//
// Intentionally NOT here: the client subscribe/unsubscribe flow. Those
// require navigator.serviceWorker / Notification.permission / the
// pushManager API, which are browser-only surfaces. We mock via
// playwright in a future integration pass.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import vm from 'node:vm';
const __dirname = dirname(fileURLToPath(import.meta.url));
const handlerSource = readFileSync(resolve(__dirname, '../public/push-handler.js'), 'utf-8');
// ── Pure helpers ──────────────────────────────────────────────────────────
describe('push config helpers', () => {
// Dynamic import so the module's top-level `import.meta.env` reference
// resolves in this Node test context.
it('urlBase64ToUint8Array round-trips via arrayBufferToBase64', async () => {
const { urlBase64ToUint8Array, arrayBufferToBase64 } = await import('../src/config/push.ts');
const original = 'BNIrVn4fQrNVN82cADphw320VdnaaAGwjnJNHZJAMyUepPJywn8LSJZTeNpWgqYOOstaJQUZ1WugocN-RKlPAQM';
const bytes = urlBase64ToUint8Array(original);
// VAPID public keys decode to 65 bytes (uncompressed P-256 point).
assert.equal(bytes.length, 65);
const roundtrip = arrayBufferToBase64(bytes.buffer);
assert.equal(roundtrip, original);
});
it('arrayBufferToBase64 handles null safely', async () => {
const { arrayBufferToBase64 } = await import('../src/config/push.ts');
assert.equal(arrayBufferToBase64(null), '');
});
it('VAPID_PUBLIC_KEY reads from VITE_VAPID_PUBLIC_KEY env, empty when unset', async () => {
// REGRESSION guard: previously the module shipped a committed
// DEFAULT_VAPID_PUBLIC_KEY fallback. That gave rotations two
// sources of truth (code + env) and let stale committed keys
// ship alongside fresh env vars. The fallback was removed —
// push is intentionally disabled on builds that lack the env.
const { VAPID_PUBLIC_KEY, isWebPushConfigured } = await import('../src/config/push.ts');
assert.equal(typeof VAPID_PUBLIC_KEY, 'string');
// In Node tests VITE_VAPID_PUBLIC_KEY is unset, so the module
// MUST return empty. If this assertion flips we know a
// committed default was reintroduced.
assert.equal(
VAPID_PUBLIC_KEY,
'',
'VAPID_PUBLIC_KEY must be empty when VITE env var is unset (no committed fallback)',
);
assert.equal(isWebPushConfigured(), false);
});
});
// ── Service worker handler ────────────────────────────────────────────────
/**
* Build a minimal SW-ish sandbox: fake `self` with an event bus, a fake
* `clients` API, and a tracking registration. Events are dispatched
* synchronously via emit() and we capture what the handler requested.
*/
function makeSwSandbox() {
const listeners = new Map();
const shown = [];
const windowClients = [];
let opened = null;
const self = {
location: { origin: 'https://worldmonitor.app' },
addEventListener(name, fn) {
if (!listeners.has(name)) listeners.set(name, []);
listeners.get(name).push(fn);
},
registration: {
showNotification(title, opts) {
shown.push({ title, opts });
return Promise.resolve();
},
},
};
const clients = {
matchAll: async () => windowClients,
openWindow: async (url) => { opened = url; return { url }; },
};
return {
self, clients, shown, windowClients,
get opened() { return opened; },
emit(name, event) {
const fns = listeners.get(name) ?? [];
for (const fn of fns) fn(event);
},
};
}
function loadHandlerInto(sandbox) {
const ctx = vm.createContext({
self: sandbox.self,
clients: sandbox.clients,
URL,
});
vm.runInContext(handlerSource, ctx);
}
function pushEvent(payload) {
const waits = [];
return {
data: payload === null ? null : {
json() { return typeof payload === 'string' ? JSON.parse(payload) : payload; },
text() { return typeof payload === 'string' ? payload : JSON.stringify(payload); },
},
waitUntil(p) { waits.push(p); },
waits,
};
}
function notifClickEvent(data) {
let closed = false;
const waits = [];
return {
notification: {
data,
close() { closed = true; },
},
waitUntil(p) { waits.push(p); },
get closed() { return closed; },
waits,
};
}
describe('push-handler.js — push event', () => {
it('renders a notification with the payload fields', () => {
const box = makeSwSandbox();
loadHandlerInto(box);
box.emit('push', pushEvent({
title: 'Your brief is ready',
body: 'Iran threatens Strait of Hormuz closure · 11 more threads',
url: 'https://worldmonitor.app/api/brief/user_abc/2026-04-18?t=xxx',
tag: 'brief_ready:user_abc',
eventType: 'brief_ready',
}));
assert.equal(box.shown.length, 1);
const [{ title, opts }] = box.shown;
assert.equal(title, 'Your brief is ready');
assert.equal(opts.body, 'Iran threatens Strait of Hormuz closure · 11 more threads');
assert.equal(opts.tag, 'brief_ready:user_abc');
assert.equal(opts.data.url, 'https://worldmonitor.app/api/brief/user_abc/2026-04-18?t=xxx');
// brief_ready should requireInteraction — don't let a lock-screen
// swipe dismiss the CTA before the user reads the brief.
assert.equal(opts.requireInteraction, true);
});
it('non-brief events render without requireInteraction', () => {
const box = makeSwSandbox();
loadHandlerInto(box);
box.emit('push', pushEvent({
title: 'Conflict event',
body: 'Escalation in Lebanon',
eventType: 'conflict_escalation',
}));
assert.equal(box.shown.length, 1);
assert.equal(box.shown[0].opts.requireInteraction, false);
});
it('falls back to "WorldMonitor" title when payload omits it', () => {
const box = makeSwSandbox();
loadHandlerInto(box);
box.emit('push', pushEvent({ body: 'body only, no title' }));
assert.equal(box.shown[0].title, 'WorldMonitor');
});
it('malformed JSON payload renders the raw text as the body', () => {
const box = makeSwSandbox();
loadHandlerInto(box);
// event.data.json() throws, event.data.text() returns the raw body
const broken = {
data: {
json() { throw new Error('not json'); },
text() { return 'plain raw text'; },
},
waitUntil() {},
};
box.emit('push', broken);
assert.equal(box.shown.length, 1);
assert.equal(box.shown[0].title, 'WorldMonitor');
assert.equal(box.shown[0].opts.body, 'plain raw text');
});
it('event with no data still renders a default notification', () => {
const box = makeSwSandbox();
loadHandlerInto(box);
box.emit('push', { data: null, waitUntil() {} });
assert.equal(box.shown.length, 1);
assert.equal(box.shown[0].title, 'WorldMonitor');
});
});
describe('push-handler.js — notificationclick', () => {
it('opens the target url when no existing window matches', async () => {
const box = makeSwSandbox();
loadHandlerInto(box);
const ev = notifClickEvent({ url: 'https://worldmonitor.app/api/brief/user_a/2026-04-18?t=abc' });
box.emit('notificationclick', ev);
assert.equal(ev.closed, true);
// Wait for the waitUntil chain
for (const p of ev.waits) await p;
assert.equal(box.opened, 'https://worldmonitor.app/api/brief/user_a/2026-04-18?t=abc');
});
it('focuses + navigates an existing same-origin window instead of opening', async () => {
const box = makeSwSandbox();
let focused = false;
let navigated = null;
box.windowClients.push({
url: 'https://worldmonitor.app/',
focus() { focused = true; return this; },
navigate(url) { navigated = url; return Promise.resolve(); },
});
loadHandlerInto(box);
const ev = notifClickEvent({ url: 'https://worldmonitor.app/api/brief/u/d?t=t' });
box.emit('notificationclick', ev);
for (const p of ev.waits) await p;
assert.equal(focused, true);
assert.equal(navigated, 'https://worldmonitor.app/api/brief/u/d?t=t');
assert.equal(box.opened, null, 'openWindow must NOT fire when a window is focused');
});
it('defaults to "/" when payload has no url', async () => {
const box = makeSwSandbox();
loadHandlerInto(box);
const ev = notifClickEvent({});
box.emit('notificationclick', ev);
for (const p of ev.waits) await p;
assert.equal(box.opened, '/');
});
});
// REGRESSION: PR #3173 P1 (SSRF). The set-web-push edge handler
// must reject any endpoint that isn't a known push-service host.
// Without the allow-list the relay's outbound sendWebPush becomes a
// server-side-request primitive for any Pro user. These tests lock
// the guard into code + reject common bypass attempts.
describe('set-web-push SSRF allow-list', () => {
it('source contains an explicit allow-list of push-service hosts', async () => {
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { dirname, resolve } = await import('node:path');
const __d = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(
resolve(__d, '../api/notification-channels.ts'),
'utf-8',
);
assert.match(src, /isAllowedPushEndpointHost/, 'allow-list helper must be defined');
// All four major browser push services must be recognised.
assert.match(src, /fcm\.googleapis\.com/, 'FCM (Chrome/Edge) host must be allow-listed');
assert.match(src, /updates\.push\.services\.mozilla\.com/, 'Mozilla (Firefox) host must be allow-listed');
assert.match(src, /web\.push\.apple\.com/, 'Apple (Safari) host must be allow-listed');
assert.match(src, /notify\.windows\.com/, 'Windows Notification Service host must be allow-listed');
// The allow-list MUST fail-closed (return false for unknown hosts).
// A regex-based presence test is enough — if someone relaxes it to
// `return true` they have to do so deliberately.
assert.match(src, /return false;?\s*\n\s*\}/, 'allow-list must end with explicit `return false` (fail-closed)');
});
it('source rejects non-allow-listed hosts before relay forwarding', async () => {
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { dirname, resolve } = await import('node:path');
const __d = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(
resolve(__d, '../api/notification-channels.ts'),
'utf-8',
);
// The guard must fire BEFORE convexRelay() — once the row lands
// in Convex, the relay will POST to it. Assert the guard appears
// inside the set-web-push branch before the convexRelay call.
const branch = src.match(/action === 'set-web-push'[\s\S]+?convexRelay/);
assert.ok(branch, "set-web-push branch must contain a convexRelay call");
assert.match(branch[0], /isAllowedPushEndpointHost/, 'allow-list check must precede the relay call');
});
});
// REGRESSION: PR #3173 P1 (cross-account subscription leak).
// setWebPushChannelForUser must dedupe by endpoint across all users,
// not just by (userId, channelType). Otherwise a shared device
// delivers user A's alerts to user B after an account switch.
describe('setWebPushChannelForUser endpoint dedupe', () => {
it('source deletes any existing rows with the same endpoint before insert', async () => {
const { readFileSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { dirname, resolve } = await import('node:path');
const __d = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(
resolve(__d, '../convex/notificationChannels.ts'),
'utf-8',
);
// Lock both the scan-by-endpoint AND the delete-before-insert
// pattern. If either drifts, the review finding reappears.
assert.match(src, /row\.endpoint === args\.endpoint/, 'setWebPushChannelForUser must compare rows by endpoint');
assert.match(src, /await ctx\.db\.delete\(row\._id\)/, 'matching rows must be deleted before upsert');
});
});

View File

@@ -662,6 +662,10 @@ export default defineConfig(({ mode }) => {
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
// Web Push handler (Phase 6). importScripts runs in the SW
// context; /push-handler.js is a static file copied from
// public/ and attaches 'push' + 'notificationclick' listeners.
importScripts: ['/push-handler.js'],
runtimeCaching: [
{