mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
88
public/push-handler.js
Normal 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.
|
||||
}
|
||||
})());
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
141
scripts/package-lock.json
generated
141
scripts/package-lock.json
generated
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
53
src/config/push.ts
Normal 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(/=+$/, '');
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
193
src/services/push-notifications.ts
Normal file
193
src/services/push-notifications.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
312
tests/brief-web-push.test.mjs
Normal file
312
tests/brief-web-push.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user