5 Commits

Author SHA1 Message Date
Elie Habib
1cf249c2f8 fix(security): strip importanceScore from /api/notify payload + scope fan-out by userId (#3143)
* fix(security): strip importanceScore from /api/notify payload + scope fan-out by userId

Closes todo #196 (activation blocker for IMPORTANCE_SCORE_LIVE=1).

Before this fix, any authenticated Pro user could POST to /api/notify with
`payload.importanceScore: 100` and `severity: 'critical'`, bypassing the
relay's IMPORTANCE_SCORE_MIN gate and fan-out would hit every Pro user with
matching rules (no userId filter). This was a pre-existing vulnerability
surfaced during the scoring pipeline work in PR #3069.

Two changes:

1. api/notify.ts — strip `importanceScore` and `corroborationCount` from
   the user-submitted payload before publishing to wm:events:queue. These
   fields are relay-internal (computed by ais-relay's scoring pipeline).
   Also validates `severity` against the known allowlist (critical, high,
   medium, low, info) instead of accepting any string.

2. scripts/notification-relay.cjs — scope rule matching: if the event
   carries `event.userId` (browser-submitted via /api/notify), only match
   rules where `rule.userId === event.userId`. Relay-emitted events (from
   ais-relay, regional-snapshot) have no userId and continue to fan out to
   all matching Pro users. This prevents a single user from broadcasting
   crafted events to every other Pro subscriber's notification channels.

Net effect: browser-submitted events can only reach the submitting user's
own Telegram/Slack/Email/webhook channels, and cannot carry an injected
importanceScore.

🤖 Generated with Claude Opus 4.6 via Claude Code

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): reject internal relay control events from /api/notify

Review found that `flush_quiet_held` and `channel_welcome` are internal
relay control events (dispatched by Railway cron scripts) that the public
/api/notify endpoint accepted because only eventType length was checked.
A Pro user could POST `{"eventType":"flush_quiet_held","payload":{},
"variant":"full"}` to force-drain their held quiet-hours queue on demand,
bypassing batch_on_wake behavior.

Now returns 403 for reserved event types. The denylist approach (vs
allowlist) is deliberate: new user-facing event types shouldn't require
an API change to work, while new internal events must explicitly be
added to the deny set if they carry privileged semantics.

* fix(security): exempt browser events from score gate + hoist Sets to module scope

Two review findings from Greptile on PR #3143:

P1: Once IMPORTANCE_SCORE_LIVE=1 activates, browser-submitted rss_alert
events (which had importanceScore stripped by the first commit) would
evaluate to score=0 at the relay's top-level gate and be silently
dropped before rule matching. Fix: add `&& !event.userId` to the gate
condition — browser events carry userId and have no server-computed
score, so the gate must not apply to them. Relay-emitted events (no
userId, server-computed score) are still gated as before.

P2: VALID_SEVERITIES and INTERNAL_EVENT_TYPES Sets were allocated inside
the handler on every request. Hoisted to module scope.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 11:14:25 +04:00
Elie Habib
6d9e7d6f6b feat(notifications): gate all endpoints behind PRO entitlement (#2852)
* feat(notifications): gate all notification endpoints behind PRO entitlement

Notifications were accessible to any signed-in user. Now all notification
API endpoints require tier >= 1 (Pro plan) with proper upgrade messaging
and checkout flow integration.

Backend: api/notification-channels.ts, api/notify.ts, api/slack/oauth/start.ts,
api/discord/oauth/start.ts all check getEntitlements() and return 403 with
upgradeUrl for free users.

Frontend: preferences-content.ts shows upgrade CTA with Dodo checkout overlay
instead of notification settings for non-Pro users.

* fix(notifications): use hasTier(1) and handle null entitlement state

Address Greptile review comments:
1. Replace isEntitled() with hasTier(1) to match backend tier check exactly
2. When entitlement state is null (not loaded yet), show full notification
   panel instead of upgrade CTA (backend enforces anyway)
2026-04-09 09:18:19 +04:00
Elie Habib
6cdcb375bb fix(relay): switch from pub/sub SSE to LPUSH/RPOP list queue (#2581)
* fix(relay): switch from pub/sub SSE to LPUSH/RPOP list queue

The Upstash REST pub/sub subscribe endpoint returns SSE in a format
that is difficult to verify without live debugging (comma-separated
vs JSON, streaming vs immediate-close). Switch to a list-based queue:

- Publishers: PUBLISH /wm:events:notify -> LPUSH /wm:events:queue
- Relay: SSE subscribe loop -> 1s-interval RPOP poll

Benefits: well-defined JSON response {result: '<msg>'|null}, messages
survive relay restarts (not lost if relay is down), no SSE format
ambiguity, simpler code.

* fix(relay): add missing User-Agent headers to LPUSH calls in notify and slack callback
2026-03-31 20:48:47 +04:00
Elie Habib
fc67d5956b fix(notify): address 5 review findings (High+Medium severity) (#2517) 2026-03-29 19:18:05 +04:00
Elie Habib
e0bc389300 feat(notifications): Phase 4 — notification delivery relay (#2513)
* feat(notifications): Phase 4 — notification delivery relay

- api/notify.ts: Vercel edge bridge — validates Clerk JWT, publishes
  wm:breaking-news events to Upstash wm:events:notify channel
- scripts/notification-relay.cjs: Railway relay subscribing to
  Upstash long-poll; fans out to Telegram (with 429 retry + 403
  deactivation), Slack (SSRF-guarded DNS check + AES-256-GCM decrypt),
  and Resend email; SET NX dedup prevents double-delivery within 30min
- breaking-news-alerts.ts: fire-and-forget POST to /api/notify after
  wm:breaking-news dispatch (web-only, no-op if not signed in)
- convex/notificationChannels.ts: add getChannelsByUserId query for
  relay use (fetches channels by userId without auth requirement)
- .env.example: add RESEND_FROM_EMAIL

* fix(notify-relay): address Greptile P0/P1 security findings

- Change getChannelsByUserId to internalQuery (P0: was public/unauthenticated)
- Add /relay/channels HTTP action with timing-safe RELAY_SECRET validation
- Relay now calls Convex HTTP action instead of public query
- Remove parse_mode: 'HTML' from sendTelegram (P1: injection risk)
- Add AbortSignal.timeout(10000) to sendTelegram (P1: hung connections)
- Add res.ok guard in upstashRest with warning log (P1: silent error swallow)
- Document RELAY_SECRET in .env.example (must be set in both Railway + Convex)

* fix(notify-relay): reuse RELAY_SHARED_SECRET instead of new RELAY_SECRET var
2026-03-29 17:59:01 +04:00