Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion api/notification-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<base64(iv[12] || tag[16] || ciphertext)>
async function encryptSlackWebhook(webhookUrl: string): Promise<string> {
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)}`;
}
Comment on lines +19 to +36
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing key-length guard before encryption

encryptSlackWebhook decodes the base64 key and imports it without checking it is exactly 32 bytes. The relay's crypto.cjs has an explicit guard, so if an operator accidentally sets a 16-byte or 24-byte key on Vercel, the edge function will happily encrypt with AES-128/192-GCM while the relay will throw on every decrypt attempt. The result is silent Slack delivery failure for all users, with no error surfaced on the write path.

Adding the same check here surfaces the misconfiguration at link-time (a 503 back to the client) rather than silently at alert delivery time:

  const keyBytes = Uint8Array.from(atob(rawKey), (c) => c.charCodeAt(0));
  if (keyBytes.length !== 32) throw new Error('NOTIFICATION_ENCRYPTION_KEY must be 32 bytes');
  const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']);


function json(body: unknown, status: number, cors: Record<string, string>): Response {
return new Response(JSON.stringify(body), {
status,
Expand Down Expand Up @@ -109,7 +130,14 @@ export default async function handler(req: Request): Promise<Response> {
if (!channelType) return json({ error: 'channelType required' }, 400, corsHeaders);
const args: Record<string, string> = { 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);
}
Expand Down
4 changes: 3 additions & 1 deletion api/notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default async function handler(req: Request): Promise<Response> {
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 {
Expand All @@ -65,11 +65,13 @@ export default async function handler(req: Request): Promise<Response> {

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,
});
Expand Down
46 changes: 46 additions & 0 deletions convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion convex/notificationChannels.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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) => {
Expand Down
28 changes: 23 additions & 5 deletions scripts/notification-relay.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/services/breaking-news-alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => {});
})();
Expand Down
20 changes: 18 additions & 2 deletions src/services/preferences-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>('#usNotifEnabled');
const sensitivityEl = container.querySelector<HTMLSelectElement>('#usNotifSensitivity');
if (!enabledEl) return;
const enabled = enabledEl.checked;
const sensitivity = (sensitivityEl?.value ?? 'all') as 'all' | 'high' | 'critical';
const existing = Array.from(container.querySelectorAll<HTMLElement>('[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<typeof setTimeout> | null = null;
signal.addEventListener('abort', () => {
if (alertRuleDebounceTimer !== null) {
Expand Down Expand Up @@ -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();
}
Comment on lines 773 to 778
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 saveRuleWithNewChannel fires on every subsequent poll tick after pairing succeeds

clearNotifPoll() is only called when expired is true. Once tg?.verified becomes true the condition stays true for every remaining 3-second tick, so saveRuleWithNewChannel('telegram') (and reloadNotifSection()) are invoked repeatedly until the countdown hits zero — potentially 30+ redundant saveAlertRules API calls for a freshly-linked Telegram channel.

Clearing the poll on the success path prevents the repeated calls:

Suggested change
getChannelsData().then((data) => {
const tg = data.channels.find(c => c.channelType === 'telegram');
if (tg?.verified || expired) {
if (tg?.verified) saveRuleWithNewChannel('telegram');
reloadNotifSection();
}
if (tg?.verified || expired) {
if (tg?.verified) { clearNotifPoll(); saveRuleWithNewChannel('telegram'); }
reloadNotifSection();
}

}).catch(() => {
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
10 changes: 9 additions & 1 deletion src/utils/cloud-prefs-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
Loading