* 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>
* 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)
* 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
* 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