diff --git a/api/notification-channels.ts b/api/notification-channels.ts index 9322e3f0c9..e9902ae047 100644 --- a/api/notification-channels.ts +++ b/api/notification-channels.ts @@ -14,6 +14,27 @@ import { ConvexHttpClient } from 'convex/browser'; const CONVEX_URL = process.env.CONVEX_URL ?? ''; +// AES-256-GCM encryption using Web Crypto (matches Node crypto.cjs decrypt format). +// Format stored: v1: +async function encryptSlackWebhook(webhookUrl: string): Promise { + const rawKey = process.env.NOTIFICATION_ENCRYPTION_KEY; + if (!rawKey) throw new Error('NOTIFICATION_ENCRYPTION_KEY not set'); + const keyBytes = Uint8Array.from(atob(rawKey), (c) => c.charCodeAt(0)); + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(webhookUrl); + const result = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, encoded)); + // Web Crypto returns ciphertext || tag (tag is last 16 bytes) + const ciphertext = result.slice(0, -16); + const tag = result.slice(-16); + const payload = new Uint8Array(12 + 16 + ciphertext.length); + payload.set(iv, 0); + payload.set(tag, 12); + payload.set(ciphertext, 28); + const binary = Array.from(payload, (b) => String.fromCharCode(b)).join(''); + return `v1:${btoa(binary)}`; +} + function json(body: unknown, status: number, cors: Record): Response { return new Response(JSON.stringify(body), { status, @@ -109,7 +130,14 @@ export default async function handler(req: Request): Promise { if (!channelType) return json({ error: 'channelType required' }, 400, corsHeaders); const args: Record = { channelType }; if (email !== undefined) args.email = email; - if (webhookEnvelope !== undefined) args.webhookEnvelope = webhookEnvelope; + if (webhookEnvelope !== undefined) { + // Encrypt the raw webhook URL before storing — relay expects AES-GCM envelope + try { + args.webhookEnvelope = await encryptSlackWebhook(webhookEnvelope); + } catch { + return json({ error: 'Encryption unavailable' }, 503, corsHeaders); + } + } await client.mutation('notificationChannels:setChannel' as any, args); return json({ ok: true }, 200, corsHeaders); } diff --git a/api/notify.ts b/api/notify.ts index e8d2759deb..cc31823283 100644 --- a/api/notify.ts +++ b/api/notify.ts @@ -41,7 +41,7 @@ export default async function handler(req: Request): Promise { return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors); } - let body: { eventType?: unknown; payload?: unknown; severity?: unknown }; + let body: { eventType?: unknown; payload?: unknown; severity?: unknown; variant?: unknown }; try { body = await req.json(); } catch { @@ -65,11 +65,13 @@ export default async function handler(req: Request): Promise { const { eventType, payload } = body; const severity = typeof body.severity === 'string' ? body.severity : 'high'; + const variant = typeof body.variant === 'string' ? body.variant : undefined; const msg = JSON.stringify({ eventType, payload, severity, + variant, publishedAt: Date.now(), userId: session.userId, }); diff --git a/convex/http.ts b/convex/http.ts index 5be9edef2c..aec11170a1 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -182,6 +182,52 @@ http.route({ }), }); +http.route({ + path: "/relay/deactivate", + method: "POST", + handler: httpAction(async (ctx, request) => { + const secret = process.env.RELAY_SHARED_SECRET ?? ""; + const provided = (request.headers.get("Authorization") ?? "").replace(/^Bearer\s+/, ""); + + if (!secret || !(await timingSafeEqualStrings(provided, secret))) { + return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + let body: { userId?: string; channelType?: string }; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "INVALID_JSON" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + if ( + typeof body.userId !== "string" || !body.userId || + (body.channelType !== "telegram" && body.channelType !== "slack" && body.channelType !== "email") + ) { + return new Response(JSON.stringify({ error: "MISSING_FIELDS" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + await ctx.runMutation(internal.notificationChannels.deactivateChannelForUser, { + userId: body.userId, + channelType: body.channelType, + }); + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }), +}); + http.route({ path: "/relay/channels", method: "POST", diff --git a/convex/notificationChannels.ts b/convex/notificationChannels.ts index ef1ff4f613..0862b6a853 100644 --- a/convex/notificationChannels.ts +++ b/convex/notificationChannels.ts @@ -1,5 +1,5 @@ import { ConvexError, v } from "convex/values"; -import { internalQuery, mutation, query } from "./_generated/server"; +import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; import { channelTypeValidator } from "./constants"; export const getChannelsByUserId = internalQuery({ @@ -104,6 +104,23 @@ export const deleteChannel = mutation({ }, }); +// Called by the notification relay via /relay/deactivate HTTP action +// when Telegram returns 403 or Slack returns 404/410. +export const deactivateChannelForUser = internalMutation({ + args: { userId: v.string(), channelType: channelTypeValidator }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("notificationChannels") + .withIndex("by_user_channel", (q) => + q.eq("userId", args.userId).eq("channelType", args.channelType), + ) + .unique(); + if (existing) { + await ctx.db.patch(existing._id, { verified: false }); + } + }, +}); + export const deactivateChannel = mutation({ args: { channelType: channelTypeValidator }, handler: async (ctx, args) => { diff --git a/scripts/notification-relay.cjs b/scripts/notification-relay.cjs index 5898d68a6a..7a6dbe8783 100644 --- a/scripts/notification-relay.cjs +++ b/scripts/notification-relay.cjs @@ -53,6 +53,25 @@ async function checkDedup(userId, eventType, title) { return result === 'OK'; // true = new, false = duplicate } +// ── Channel deactivation ────────────────────────────────────────────────────── + +async function deactivateChannel(userId, channelType) { + try { + const res = await fetch(`${CONVEX_SITE_URL}/relay/deactivate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${RELAY_SECRET}`, + }, + body: JSON.stringify({ userId, channelType }), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) console.warn(`[relay] Deactivate failed ${userId}/${channelType}: ${res.status}`); + } catch (err) { + console.warn(`[relay] Deactivate request failed for ${userId}/${channelType}:`, err.message); + } +} + // ── Private IP guard ───────────────────────────────────────────────────────── function isPrivateIP(ip) { @@ -72,8 +91,7 @@ async function sendTelegram(userId, chatId, text) { const body = await res.json().catch(() => ({})); if (res.status === 403 || body.description?.includes('chat not found')) { console.warn(`[relay] Telegram 403/400 for ${userId} — deactivating channel`); - // deactivateChannel is auth-gated; log warning only — Phase 4 limitation - console.warn(`[relay] Manual deactivation required for userId=${userId} channelType=telegram`); + await deactivateChannel(userId, 'telegram'); } return; } @@ -122,8 +140,7 @@ async function sendSlack(userId, webhookEnvelope, text) { }); if (res.status === 404 || res.status === 410) { console.warn(`[relay] Slack webhook gone for ${userId} — deactivating`); - // deactivateChannel is auth-gated; log warning only — Phase 4 limitation - console.warn(`[relay] Manual deactivation required for userId=${userId} channelType=slack`); + await deactivateChannel(userId, 'slack'); } else if (!res.ok) { console.warn(`[relay] Slack send failed: ${res.status}`); } @@ -173,7 +190,8 @@ async function processEvent(event) { const matching = enabledRules.filter(r => (r.eventTypes.length === 0 || r.eventTypes.includes(event.eventType)) && - matchesSensitivity(r.sensitivity, event.severity ?? 'high') + matchesSensitivity(r.sensitivity, event.severity ?? 'high') && + (!event.variant || !r.variant || r.variant === event.variant) ); if (matching.length === 0) return; diff --git a/src/services/breaking-news-alerts.ts b/src/services/breaking-news-alerts.ts index 1a9ea891b3..5f739d5d27 100644 --- a/src/services/breaking-news-alerts.ts +++ b/src/services/breaking-news-alerts.ts @@ -3,6 +3,7 @@ import type { OrefAlert } from '@/services/oref-alerts'; import { getSourceTier } from '@/config/feeds'; import { isDesktopRuntime } from '@/services/runtime'; import { getClerkToken } from '@/services/clerk'; +import { SITE_VARIANT } from '@/config/variant'; export interface BreakingAlert { id: string; @@ -167,6 +168,7 @@ function dispatchAlert(alert: BreakingAlert): void { eventType: alert.origin, payload: { title: alert.headline, source: alert.source, link: alert.link }, severity: alert.threatLevel, + variant: SITE_VARIANT, }), }).catch(() => {}); })(); diff --git a/src/services/preferences-content.ts b/src/services/preferences-content.ts index efb63a1fba..995d0cde53 100644 --- a/src/services/preferences-content.ts +++ b/src/services/preferences-content.ts @@ -698,6 +698,21 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { reloadNotifSection(); + // When a new channel is linked, auto-update the rule's channels list + // so it includes the new channel without requiring a manual toggle. + function saveRuleWithNewChannel(newChannel: ChannelType): void { + const enabledEl = container.querySelector('#usNotifEnabled'); + const sensitivityEl = container.querySelector('#usNotifSensitivity'); + if (!enabledEl) return; + const enabled = enabledEl.checked; + const sensitivity = (sensitivityEl?.value ?? 'all') as 'all' | 'high' | 'critical'; + const existing = Array.from(container.querySelectorAll('[data-channel-type]')) + .filter(el => el.querySelector('.us-notif-disconnect')) + .map(el => el.dataset.channelType as ChannelType); + const channels = [...new Set([...existing, newChannel])]; + void saveAlertRules({ variant: SITE_VARIANT, enabled, eventTypes: [], sensitivity, channels }); + } + let alertRuleDebounceTimer: ReturnType | null = null; signal.addEventListener('abort', () => { if (alertRuleDebounceTimer !== null) { @@ -758,6 +773,7 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { getChannelsData().then((data) => { const tg = data.channels.find(c => c.channelType === 'telegram'); if (tg?.verified || expired) { + if (tg?.verified) saveRuleWithNewChannel('telegram'); reloadNotifSection(); } }).catch(() => { @@ -780,7 +796,7 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { return; } setEmailChannel(email).then(() => { - if (!signal.aborted) reloadNotifSection(); + if (!signal.aborted) { saveRuleWithNewChannel('email'); reloadNotifSection(); } }).catch(() => {}); return; } @@ -799,7 +815,7 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult { return; } setSlackChannel(url).then(() => { - if (!signal.aborted) reloadNotifSection(); + if (!signal.aborted) { saveRuleWithNewChannel('slack'); reloadNotifSection(); } }).catch(() => {}); return; } diff --git a/src/utils/cloud-prefs-sync.ts b/src/utils/cloud-prefs-sync.ts index 2bcfc51c50..c11776b752 100644 --- a/src/utils/cloud-prefs-sync.ts +++ b/src/utils/cloud-prefs-sync.ts @@ -316,7 +316,7 @@ export function install(variant: string): void { _installed = true; _currentVariant = variant; - // Patch localStorage.setItem to detect pref changes in this tab. + // Patch localStorage.setItem and removeItem to detect pref changes in this tab. // Use _suppressPatch to prevent applyCloudBlob from triggering spurious uploads. const originalSetItem = Storage.prototype.setItem; Storage.prototype.setItem = function setItem(key: string, value: string) { @@ -326,6 +326,14 @@ export function install(variant: string): void { } }; + const originalRemoveItem = Storage.prototype.removeItem; + Storage.prototype.removeItem = function removeItem(key: string) { + originalRemoveItem.call(this, key); + if (this === localStorage && !_suppressPatch && CLOUD_SYNC_KEYS.includes(key as CloudSyncKey)) { + schedulePrefUpload(_currentVariant); + } + }; + // Multi-tab: another tab wrote a newer syncVersion — cancel our pending upload window.addEventListener('storage', (e) => { if (e.key === KEY_SYNC_VERSION && e.newValue !== null) {