Skip to content

fix(notify): address 5 post-merge review findings#2517

Merged
koala73 merged 1 commit intomainfrom
fix/notify-review-findings
Mar 29, 2026
Merged

fix(notify): address 5 post-merge review findings#2517
koala73 merged 1 commit intomainfrom
fix/notify-review-findings

Conversation

@koala73
Copy link
Copy Markdown
Owner

@koala73 koala73 commented Mar 29, 2026

Summary

Fixes 5 findings from a review of the notification delivery feature (Phases 2-4).

High

  • Slack webhook stored plaintext: The edge endpoint now encrypts the raw URL with AES-256-GCM (Web Crypto API, matching the relay's crypto.cjs decrypt format) before storing as webhookEnvelope in Convex. NOTIFICATION_ENCRYPTION_KEY must be set on Vercel.

  • Variant scoping lost: /api/notify now accepts and forwards variant in the published Upstash event. breaking-news-alerts includes SITE_VARIANT in the POST body. The relay filters alert rules by r.variant === event.variant.

Medium

  • New channel not added to existing rule: Connect handlers (Telegram/email/Slack) now call saveRuleWithNewChannel() immediately after linking, so the newly connected channel is included in the active rule's channels array without requiring a manual toggle.

  • Cloud sync misses removeItem (deletes not propagated): cloud-prefs-sync.ts now patches Storage.prototype.removeItem alongside setItem, so watchlist resets and layout resets trigger a cloud upload.

  • Dead channel auto-deactivation was log-only: Added deactivateChannelForUser internalMutation in Convex + a /relay/deactivate HTTP action (protected by RELAY_SHARED_SECRET). Relay calls it when Telegram returns 403/400 or Slack returns 404/410 instead of just logging.

Post-Deploy Monitoring & Validation

  • Slack: Link a Slack channel in settings, check Convex dashboard that webhookEnvelope starts with v1: (not a raw URL)
  • Variant: Trigger a breaking alert on tech.worldmonitor.app, confirm relay only delivers to rules with variant=tech
  • Dead channel: Manually set a Telegram chatId to an invalid value, trigger an alert, confirm verified: false in Convex
  • Removes: Reset the watchlist, sign out + in on a new device, confirm the watchlist is empty (not restored from stale cloud state)

High - Slack webhook stored plaintext:
  Encrypt URL with AES-256-GCM (Web Crypto) in edge endpoint before
  storing as webhookEnvelope. Matches relay's decrypt format.
  NOTIFICATION_ENCRYPTION_KEY must be set on Vercel.

High - Variant scoping lost in publish/relay path:
  api/notify accepts + forwards variant in published event.
  breaking-news-alerts includes SITE_VARIANT in POST body.
  Relay filters rules by r.variant === event.variant.

Medium - New channel not added to existing alert rule:
  Connect handlers (Telegram/email/Slack) call saveRuleWithNewChannel()
  so the newly linked channel is immediately in the rule's channels.

Medium - Cloud sync misses removeItem (deletes not propagated):
  Patch Storage.prototype.removeItem alongside setItem so watchlist
  resets and layout resets trigger a cloud upload.

Medium - Dead channel auto-deactivation was log-only:
  Add deactivateChannelForUser internalMutation + /relay/deactivate
  HTTP action (RELAY_SHARED_SECRET protected). Relay calls it on
  Telegram 403/400 and Slack 404/410.
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Mar 29, 2026 2:29pm

Request Review

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 29, 2026

Greptile Summary

This PR resolves five post-merge findings from the notification delivery feature. It closes real gaps: Slack webhooks are now AES-256-GCM encrypted before storage, variant scoping is propagated end-to-end, newly linked channels are immediately added to the active alert rule, localStorage.removeItem is now intercepted for cloud-prefs sync, and dead channels trigger an authenticated Convex mutation rather than just logging a warning.

Key changes:

  • api/notification-channels.ts: Encrypts raw Slack webhook URLs with Web Crypto AES-256-GCM before writing to Convex. The byte layout (iv || tag || ciphertext) matches the relay's crypto.cjs decrypt format exactly.
  • api/notify.ts: Forwards the optional variant field in Upstash events; absent variant serialises as nothing (backward-compatible).
  • scripts/notification-relay.cjs: Adds deactivateChannel() helper and calls it on Telegram 403/400 and Slack 404/410 instead of logging only. Variant filter is permissive (only enforced when both event and rule have a variant set).
  • convex/http.ts + convex/notificationChannels.ts: New /relay/deactivate HTTP action authenticated by RELAY_SHARED_SECRET; new deactivateChannelForUser internalMutation sets verified: false via the by_user_channel index.
  • src/services/preferences-content.ts: saveRuleWithNewChannel() auto-updates the alert rule's channels array immediately after linking; called correctly for email and Slack paths, but missing clearNotifPoll() on the Telegram success path causes redundant repeated saves until the countdown expires.
  • src/utils/cloud-prefs-sync.ts: Patches Storage.prototype.removeItem alongside setItem with the same _suppressPatch guard so watchlist and layout resets trigger a cloud upload.
  • src/services/breaking-news-alerts.ts: Adds variant: SITE_VARIANT to the /api/notify POST body.

Confidence Score: 5/5

Safe to merge; both remaining findings are P2 quality/efficiency issues that don't affect data correctness or security.

All five original findings are addressed. The two new observations (missing key-length check and repeated Telegram saves) are non-critical: the key-length issue is a deployment-configuration concern that fails loudly at the relay, and the repeated saves are idempotent with no data-corruption risk. No P0 or P1 issues were found.

api/notification-channels.ts (key-length guard) and src/services/preferences-content.ts (Telegram poll clear-on-success) are the only files with open comments.

Important Files Changed

Filename Overview
api/notification-channels.ts Adds AES-256-GCM encryption for Slack webhook URLs before Convex storage. Crypto layout matches the relay's crypto.cjs. Minor: key-length is not validated at the edge, so a misconfigured key silently breaks all Slack delivery at decrypt time.
api/notify.ts Adds optional variant field to the Upstash event publish. Type-safe extraction, JSON.stringify drops undefined so absent variant is invisible to the relay — backward-compatible.
convex/http.ts New /relay/deactivate HTTP action protected by timing-safe Bearer check; delegates to the new deactivateChannelForUser internalMutation. Auth and input validation look correct.
convex/notificationChannels.ts Adds deactivateChannelForUser internalMutation that sets verified: false via the existing by_user_channel index; correctly scoped as internal so only the HTTP action can invoke it.
scripts/notification-relay.cjs Adds deactivateChannel() helper and wires it for Telegram 403/400 and Slack 404/410. Variant filter uses a three-way permissive check (no variant on event OR rule → pass), which is the correct backward-compatible behaviour.
src/services/preferences-content.ts Adds saveRuleWithNewChannel() and calls it for Telegram/email/Slack connects. Email and Slack paths are correct. Telegram path is missing clearNotifPoll() on success, causing repeated saves until the countdown timer expires.
src/utils/cloud-prefs-sync.ts Patches Storage.prototype.removeItem alongside setItem to propagate watchlist/layout resets to cloud sync. _suppressPatch and this === localStorage guards are correctly applied.
src/services/breaking-news-alerts.ts Adds variant: SITE_VARIANT to the /api/notify POST body so the relay can scope alerts to the correct site variant.

Sequence Diagram

sequenceDiagram
    participant Client as Browser (breaking-news-alerts)
    participant Notify as /api/notify (Edge)
    participant Upstash as Upstash Pub/Sub
    participant Relay as notification-relay.cjs
    participant Convex as Convex DB

    Client->>Notify: POST {eventType, payload, severity, variant}
    Notify->>Upstash: PUBLISH wm:events:notify {…, variant}
    Upstash-->>Relay: message event
    Relay->>Convex: getByEnabled alertRules
    Convex-->>Relay: rules[]
    Note over Relay: filter by eventType, severity,<br/>and variant (if both set)
    Relay->>Convex: GET /relay/channels {userId}
    Convex-->>Relay: channels[]
    alt Telegram 403/400
        Relay->>Convex: POST /relay/deactivate {userId, telegram}
        Convex->>Convex: deactivateChannelForUser → verified=false
    else Slack 404/410
        Relay->>Convex: POST /relay/deactivate {userId, slack}
        Convex->>Convex: deactivateChannelForUser → verified=false
    end
    Relay->>Relay: sendTelegram / sendSlack / sendEmail
Loading

Reviews (1): Last reviewed commit: "fix(notify): address 5 review findings (..." | Re-trigger Greptile

Comment on lines 773 to 778
getChannelsData().then((data) => {
const tg = data.channels.find(c => c.channelType === 'telegram');
if (tg?.verified || expired) {
if (tg?.verified) saveRuleWithNewChannel('telegram');
reloadNotifSection();
}
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();
}

Comment on lines +19 to +36
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)}`;
}
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']);

@koala73 koala73 merged commit fc67d59 into main Mar 29, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant