Skip to content

Commit fc67d59

Browse files
authored
fix(notify): address 5 review findings (High+Medium severity) (koala73#2517)
1 parent e0bc389 commit fc67d59

File tree

8 files changed

+148
-11
lines changed

8 files changed

+148
-11
lines changed

api/notification-channels.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,27 @@ import { ConvexHttpClient } from 'convex/browser';
1414

1515
const CONVEX_URL = process.env.CONVEX_URL ?? '';
1616

17+
// AES-256-GCM encryption using Web Crypto (matches Node crypto.cjs decrypt format).
18+
// Format stored: v1:<base64(iv[12] || tag[16] || ciphertext)>
19+
async function encryptSlackWebhook(webhookUrl: string): Promise<string> {
20+
const rawKey = process.env.NOTIFICATION_ENCRYPTION_KEY;
21+
if (!rawKey) throw new Error('NOTIFICATION_ENCRYPTION_KEY not set');
22+
const keyBytes = Uint8Array.from(atob(rawKey), (c) => c.charCodeAt(0));
23+
const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']);
24+
const iv = crypto.getRandomValues(new Uint8Array(12));
25+
const encoded = new TextEncoder().encode(webhookUrl);
26+
const result = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, encoded));
27+
// Web Crypto returns ciphertext || tag (tag is last 16 bytes)
28+
const ciphertext = result.slice(0, -16);
29+
const tag = result.slice(-16);
30+
const payload = new Uint8Array(12 + 16 + ciphertext.length);
31+
payload.set(iv, 0);
32+
payload.set(tag, 12);
33+
payload.set(ciphertext, 28);
34+
const binary = Array.from(payload, (b) => String.fromCharCode(b)).join('');
35+
return `v1:${btoa(binary)}`;
36+
}
37+
1738
function json(body: unknown, status: number, cors: Record<string, string>): Response {
1839
return new Response(JSON.stringify(body), {
1940
status,
@@ -109,7 +130,14 @@ export default async function handler(req: Request): Promise<Response> {
109130
if (!channelType) return json({ error: 'channelType required' }, 400, corsHeaders);
110131
const args: Record<string, string> = { channelType };
111132
if (email !== undefined) args.email = email;
112-
if (webhookEnvelope !== undefined) args.webhookEnvelope = webhookEnvelope;
133+
if (webhookEnvelope !== undefined) {
134+
// Encrypt the raw webhook URL before storing — relay expects AES-GCM envelope
135+
try {
136+
args.webhookEnvelope = await encryptSlackWebhook(webhookEnvelope);
137+
} catch {
138+
return json({ error: 'Encryption unavailable' }, 503, corsHeaders);
139+
}
140+
}
113141
await client.mutation('notificationChannels:setChannel' as any, args);
114142
return json({ ok: true }, 200, corsHeaders);
115143
}

api/notify.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default async function handler(req: Request): Promise<Response> {
4141
return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
4242
}
4343

44-
let body: { eventType?: unknown; payload?: unknown; severity?: unknown };
44+
let body: { eventType?: unknown; payload?: unknown; severity?: unknown; variant?: unknown };
4545
try {
4646
body = await req.json();
4747
} catch {
@@ -65,11 +65,13 @@ export default async function handler(req: Request): Promise<Response> {
6565

6666
const { eventType, payload } = body;
6767
const severity = typeof body.severity === 'string' ? body.severity : 'high';
68+
const variant = typeof body.variant === 'string' ? body.variant : undefined;
6869

6970
const msg = JSON.stringify({
7071
eventType,
7172
payload,
7273
severity,
74+
variant,
7375
publishedAt: Date.now(),
7476
userId: session.userId,
7577
});

convex/http.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,52 @@ http.route({
182182
}),
183183
});
184184

185+
http.route({
186+
path: "/relay/deactivate",
187+
method: "POST",
188+
handler: httpAction(async (ctx, request) => {
189+
const secret = process.env.RELAY_SHARED_SECRET ?? "";
190+
const provided = (request.headers.get("Authorization") ?? "").replace(/^Bearer\s+/, "");
191+
192+
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
193+
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
194+
status: 401,
195+
headers: { "Content-Type": "application/json" },
196+
});
197+
}
198+
199+
let body: { userId?: string; channelType?: string };
200+
try {
201+
body = await request.json();
202+
} catch {
203+
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
204+
status: 400,
205+
headers: { "Content-Type": "application/json" },
206+
});
207+
}
208+
209+
if (
210+
typeof body.userId !== "string" || !body.userId ||
211+
(body.channelType !== "telegram" && body.channelType !== "slack" && body.channelType !== "email")
212+
) {
213+
return new Response(JSON.stringify({ error: "MISSING_FIELDS" }), {
214+
status: 400,
215+
headers: { "Content-Type": "application/json" },
216+
});
217+
}
218+
219+
await ctx.runMutation(internal.notificationChannels.deactivateChannelForUser, {
220+
userId: body.userId,
221+
channelType: body.channelType,
222+
});
223+
224+
return new Response(JSON.stringify({ ok: true }), {
225+
status: 200,
226+
headers: { "Content-Type": "application/json" },
227+
});
228+
}),
229+
});
230+
185231
http.route({
186232
path: "/relay/channels",
187233
method: "POST",

convex/notificationChannels.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ConvexError, v } from "convex/values";
2-
import { internalQuery, mutation, query } from "./_generated/server";
2+
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
33
import { channelTypeValidator } from "./constants";
44

55
export const getChannelsByUserId = internalQuery({
@@ -104,6 +104,23 @@ export const deleteChannel = mutation({
104104
},
105105
});
106106

107+
// Called by the notification relay via /relay/deactivate HTTP action
108+
// when Telegram returns 403 or Slack returns 404/410.
109+
export const deactivateChannelForUser = internalMutation({
110+
args: { userId: v.string(), channelType: channelTypeValidator },
111+
handler: async (ctx, args) => {
112+
const existing = await ctx.db
113+
.query("notificationChannels")
114+
.withIndex("by_user_channel", (q) =>
115+
q.eq("userId", args.userId).eq("channelType", args.channelType),
116+
)
117+
.unique();
118+
if (existing) {
119+
await ctx.db.patch(existing._id, { verified: false });
120+
}
121+
},
122+
});
123+
107124
export const deactivateChannel = mutation({
108125
args: { channelType: channelTypeValidator },
109126
handler: async (ctx, args) => {

scripts/notification-relay.cjs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,25 @@ async function checkDedup(userId, eventType, title) {
5353
return result === 'OK'; // true = new, false = duplicate
5454
}
5555

56+
// ── Channel deactivation ──────────────────────────────────────────────────────
57+
58+
async function deactivateChannel(userId, channelType) {
59+
try {
60+
const res = await fetch(`${CONVEX_SITE_URL}/relay/deactivate`, {
61+
method: 'POST',
62+
headers: {
63+
'Content-Type': 'application/json',
64+
'Authorization': `Bearer ${RELAY_SECRET}`,
65+
},
66+
body: JSON.stringify({ userId, channelType }),
67+
signal: AbortSignal.timeout(10000),
68+
});
69+
if (!res.ok) console.warn(`[relay] Deactivate failed ${userId}/${channelType}: ${res.status}`);
70+
} catch (err) {
71+
console.warn(`[relay] Deactivate request failed for ${userId}/${channelType}:`, err.message);
72+
}
73+
}
74+
5675
// ── Private IP guard ─────────────────────────────────────────────────────────
5776

5877
function isPrivateIP(ip) {
@@ -72,8 +91,7 @@ async function sendTelegram(userId, chatId, text) {
7291
const body = await res.json().catch(() => ({}));
7392
if (res.status === 403 || body.description?.includes('chat not found')) {
7493
console.warn(`[relay] Telegram 403/400 for ${userId} — deactivating channel`);
75-
// deactivateChannel is auth-gated; log warning only — Phase 4 limitation
76-
console.warn(`[relay] Manual deactivation required for userId=${userId} channelType=telegram`);
94+
await deactivateChannel(userId, 'telegram');
7795
}
7896
return;
7997
}
@@ -122,8 +140,7 @@ async function sendSlack(userId, webhookEnvelope, text) {
122140
});
123141
if (res.status === 404 || res.status === 410) {
124142
console.warn(`[relay] Slack webhook gone for ${userId} — deactivating`);
125-
// deactivateChannel is auth-gated; log warning only — Phase 4 limitation
126-
console.warn(`[relay] Manual deactivation required for userId=${userId} channelType=slack`);
143+
await deactivateChannel(userId, 'slack');
127144
} else if (!res.ok) {
128145
console.warn(`[relay] Slack send failed: ${res.status}`);
129146
}
@@ -173,7 +190,8 @@ async function processEvent(event) {
173190

174191
const matching = enabledRules.filter(r =>
175192
(r.eventTypes.length === 0 || r.eventTypes.includes(event.eventType)) &&
176-
matchesSensitivity(r.sensitivity, event.severity ?? 'high')
193+
matchesSensitivity(r.sensitivity, event.severity ?? 'high') &&
194+
(!event.variant || !r.variant || r.variant === event.variant)
177195
);
178196

179197
if (matching.length === 0) return;

src/services/breaking-news-alerts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { OrefAlert } from '@/services/oref-alerts';
33
import { getSourceTier } from '@/config/feeds';
44
import { isDesktopRuntime } from '@/services/runtime';
55
import { getClerkToken } from '@/services/clerk';
6+
import { SITE_VARIANT } from '@/config/variant';
67

78
export interface BreakingAlert {
89
id: string;
@@ -167,6 +168,7 @@ function dispatchAlert(alert: BreakingAlert): void {
167168
eventType: alert.origin,
168169
payload: { title: alert.headline, source: alert.source, link: alert.link },
169170
severity: alert.threatLevel,
171+
variant: SITE_VARIANT,
170172
}),
171173
}).catch(() => {});
172174
})();

src/services/preferences-content.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,21 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult {
698698

699699
reloadNotifSection();
700700

701+
// When a new channel is linked, auto-update the rule's channels list
702+
// so it includes the new channel without requiring a manual toggle.
703+
function saveRuleWithNewChannel(newChannel: ChannelType): void {
704+
const enabledEl = container.querySelector<HTMLInputElement>('#usNotifEnabled');
705+
const sensitivityEl = container.querySelector<HTMLSelectElement>('#usNotifSensitivity');
706+
if (!enabledEl) return;
707+
const enabled = enabledEl.checked;
708+
const sensitivity = (sensitivityEl?.value ?? 'all') as 'all' | 'high' | 'critical';
709+
const existing = Array.from(container.querySelectorAll<HTMLElement>('[data-channel-type]'))
710+
.filter(el => el.querySelector('.us-notif-disconnect'))
711+
.map(el => el.dataset.channelType as ChannelType);
712+
const channels = [...new Set([...existing, newChannel])];
713+
void saveAlertRules({ variant: SITE_VARIANT, enabled, eventTypes: [], sensitivity, channels });
714+
}
715+
701716
let alertRuleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
702717
signal.addEventListener('abort', () => {
703718
if (alertRuleDebounceTimer !== null) {
@@ -758,6 +773,7 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult {
758773
getChannelsData().then((data) => {
759774
const tg = data.channels.find(c => c.channelType === 'telegram');
760775
if (tg?.verified || expired) {
776+
if (tg?.verified) saveRuleWithNewChannel('telegram');
761777
reloadNotifSection();
762778
}
763779
}).catch(() => {
@@ -780,7 +796,7 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult {
780796
return;
781797
}
782798
setEmailChannel(email).then(() => {
783-
if (!signal.aborted) reloadNotifSection();
799+
if (!signal.aborted) { saveRuleWithNewChannel('email'); reloadNotifSection(); }
784800
}).catch(() => {});
785801
return;
786802
}
@@ -799,7 +815,7 @@ export function renderPreferences(host: PreferencesHost): PreferencesResult {
799815
return;
800816
}
801817
setSlackChannel(url).then(() => {
802-
if (!signal.aborted) reloadNotifSection();
818+
if (!signal.aborted) { saveRuleWithNewChannel('slack'); reloadNotifSection(); }
803819
}).catch(() => {});
804820
return;
805821
}

src/utils/cloud-prefs-sync.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ export function install(variant: string): void {
316316
_installed = true;
317317
_currentVariant = variant;
318318

319-
// Patch localStorage.setItem to detect pref changes in this tab.
319+
// Patch localStorage.setItem and removeItem to detect pref changes in this tab.
320320
// Use _suppressPatch to prevent applyCloudBlob from triggering spurious uploads.
321321
const originalSetItem = Storage.prototype.setItem;
322322
Storage.prototype.setItem = function setItem(key: string, value: string) {
@@ -326,6 +326,14 @@ export function install(variant: string): void {
326326
}
327327
};
328328

329+
const originalRemoveItem = Storage.prototype.removeItem;
330+
Storage.prototype.removeItem = function removeItem(key: string) {
331+
originalRemoveItem.call(this, key);
332+
if (this === localStorage && !_suppressPatch && CLOUD_SYNC_KEYS.includes(key as CloudSyncKey)) {
333+
schedulePrefUpload(_currentVariant);
334+
}
335+
};
336+
329337
// Multi-tab: another tab wrote a newer syncVersion — cancel our pending upload
330338
window.addEventListener('storage', (e) => {
331339
if (e.key === KEY_SYNC_VERSION && e.newValue !== null) {

0 commit comments

Comments
 (0)