import { ConvexError, v } from "convex/values"; import { internalMutation, internalQuery, type MutationCtx, mutation, query, } from "./_generated/server"; import { channelTypeValidator, digestModeValidator, quietHoursOverrideValidator, sensitivityValidator } from "./constants"; type DigestMode = "realtime" | "daily" | "twice_daily" | "weekly"; type Sensitivity = "all" | "high" | "critical"; /** * Layer-2 entitlement gate: notifications are a PRO feature, but until now * the only enforcement was at layer 1 (UI paywall) and layer 3 (relay * isUserPro filter). Layer 1 has had at least one hole — a 2026-04-28 * audit found 7 of 28 enabled `alertRules` rows belonged to free-tier * users (`tier=0`, never been PRO). The relay's PRO filter has been * masking the bug at delivery time, but it fail-opens on entitlement- * service errors and shouldn't be the only line of defense. * * This helper is the WRITE-PATH gate. Throws ConvexError with structured * `{code: "PRO_REQUIRED"}` data so the client can detect it and route to * the upgrade flow rather than surface a generic 500. * * Mirrors the FREE_TIER_DEFAULTS semantics in `convex/entitlements.ts`: * - no entitlement row → tier 0 (free) * - validUntil < Date.now() → expired, treat as tier 0 * - tier >= 1 → PRO, allowed * * Kept inline (not imported from entitlements.ts) for security-review * readability: every alertRules mutation that calls this should be * trivially auditable in one file. */ async function assertProEntitlement( ctx: MutationCtx, userId: string, ): Promise { const entitlement = await ctx.db .query("entitlements") .withIndex("by_userId", (q) => q.eq("userId", userId)) .first(); const tier = entitlement && entitlement.validUntil >= Date.now() ? entitlement.features.tier : 0; if (tier < 1) { throw new ConvexError({ code: "PRO_REQUIRED", message: "Notifications are a PRO feature. Upgrade to enable real-time and digest alerts.", }); } } // Cross-field invariant enforcement for (digestMode, sensitivity). // // Tightened rule (2026-04-27): real-time delivery is now reserved for // `critical`-tier events only. `(realtime, all)` and `(realtime, high)` are // both forbidden. Anything below `critical` lives in a digest cadence // (daily / twice_daily / weekly). // // Why tighter: even on `(realtime, high)`, `high`-severity events fire // frequently enough on busy days to overload an inbox (severe weather, // market moves, geopolitics). Real-time is for "interrupt me NOW" content // only — i.e. genuinely critical. High events still reach the user, just // batched in a digest. // // New-row defaults pick `'critical'` on realtime insert; patches preserve // existing.sensitivity when caller omits the field (no silent narrowing of // digest users). function resolveEffectivePair(args: { incomingDigestMode?: DigestMode; incomingSensitivity?: Sensitivity; existing?: { digestMode?: DigestMode | string; sensitivity?: Sensitivity | string }; }): { digestMode: DigestMode; sensitivity: Sensitivity } { const digestMode = (args.incomingDigestMode ?? (args.existing?.digestMode as DigestMode | undefined) ?? "realtime"); const sensitivity = (args.incomingSensitivity ?? (args.existing?.sensitivity as Sensitivity | undefined) ?? "critical"); // insert-only default — patch path never includes sensitivity unless caller passed it return { digestMode, sensitivity }; } function assertCompatibleDeliveryMode(pair: { digestMode: DigestMode; sensitivity: Sensitivity }) { if (pair.digestMode === "realtime" && (pair.sensitivity === "all" || pair.sensitivity === "high")) { throw new ConvexError({ code: "INCOMPATIBLE_DELIVERY", message: "Real-time delivery is for Critical events only. " + "To receive High or All events, choose a digest cadence (Daily, Twice daily, or Weekly).", }); } } // Defensive ceiling against patched-client abuse — there are ~250 ISO-3166 // countries; 50 is more than any real user opts into and well below any // validator/storage limit. const COUNTRIES_MAX = 50; /** * Shape-validate + normalize an inbound `countries` array. * - trim each entry * - uppercase * - keep only ASCII A-Z 2-letter shapes (`^[A-Z]{2}$`); silently drop the rest * - dedupe (preserves first-seen order) * - cap at COUNTRIES_MAX * * NOT a strict ISO-3166 registry check — invalid alpha-2 codes (e.g. "XX") * pass shape validation but the relay's includes() check just won't match * any real event country. We deliberately don't soft-couple this file to a * canonical registry list (that lives elsewhere as part of the * followed-countries primitive) — keep alertRules independently shippable. */ function normalizeCountries(input: string[]): string[] { const cleaned: string[] = []; const seen = new Set(); for (const raw of input) { if (typeof raw !== "string") continue; const upper = raw.trim().toUpperCase(); if (!/^[A-Z]{2}$/.test(upper)) continue; if (seen.has(upper)) continue; seen.add(upper); cleaned.push(upper); } if (cleaned.length > COUNTRIES_MAX) { throw new ConvexError({ code: "COUNTRIES_LIMIT_EXCEEDED", message: `countries list capped at ${COUNTRIES_MAX} entries`, }); } return cleaned; } // Same defensive ceiling as COUNTRIES_MAX — mirrors the client-side market // watchlist cap (src/services/market-watchlist.ts stops at 50 entries). const TICKERS_MAX = 50; /** * Shape-validate + normalize an inbound `tickers` array (#4922 U3). * Modeled EXACTLY on normalizeCountries above: * - trim each entry * - uppercase * - keep only `^[A-Z][A-Z0-9&-]{0,11}(\.[A-Z]{1,3})?$` shapes — plain * symbols (AAPL), share-class/conglomerate forms (BRK-B, M&M.NS) and * dot-suffixed exchange listings (RELIANCE.NS, BHARTIARTL.NS — NSE * bases run to 10 chars); silently drop the rest (^GSPC indices, GC=F * futures, cashtags, prose). Every non-index shared/stocks.json symbol * must pass — the extractor emits exactly those for company-name hits, * and a dropped shape here means that ticker can never alert. * - dedupe (preserves first-seen order) * - cap at TICKERS_MAX * * NOT a registry check against shared/stocks.json — a shape-valid symbol * the extractor never emits simply never intersects at the relay. Keeps * alertRules independently shippable, same rationale as countries. */ function normalizeTickers(input: string[]): string[] { const cleaned: string[] = []; const seen = new Set(); for (const raw of input) { if (typeof raw !== "string") continue; const upper = raw.trim().toUpperCase(); if (!/^[A-Z][A-Z0-9&-]{0,11}(\.[A-Z]{1,3})?$/.test(upper)) continue; if (seen.has(upper)) continue; seen.add(upper); cleaned.push(upper); } if (cleaned.length > TICKERS_MAX) { throw new ConvexError({ code: "TICKERS_LIMIT_EXCEEDED", message: `tickers list capped at ${TICKERS_MAX} entries`, }); } return cleaned; } export const getAlertRules = query({ args: {}, handler: async (ctx) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) return []; return await ctx.db .query("alertRules") .withIndex("by_user", (q) => q.eq("userId", identity.subject)) .collect(); }, }); export const setAlertRules = mutation({ args: { variant: v.string(), enabled: v.boolean(), eventTypes: v.array(v.string()), sensitivity: v.optional(sensitivityValidator), channels: v.array(channelTypeValidator), aiDigestEnabled: v.optional(v.boolean()), // Optional country-scope (ISO-3166 alpha-2). Omit to preserve existing. // Pass [] to explicitly reset to "all countries". Pass [...] to restrict. countries: v.optional(v.array(v.string())), // Optional watchlist ticker-scope (#4922 U3). Omit to preserve existing; // [] resets. Unlike countries, [] means "no watchlist story alerts". tickers: v.optional(v.array(v.string())), }, handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new ConvexError("UNAUTHENTICATED"); const userId = identity.subject; await assertProEntitlement(ctx, userId); const existing = await ctx.db .query("alertRules") .withIndex("by_user_variant", (q) => q.eq("userId", userId).eq("variant", args.variant), ) .unique(); const pair = resolveEffectivePair({ incomingSensitivity: args.sensitivity, existing: existing ?? undefined, }); assertCompatibleDeliveryMode(pair); const normalizedCountries = args.countries !== undefined ? normalizeCountries(args.countries) : undefined; const normalizedTickers = args.tickers !== undefined ? normalizeTickers(args.tickers) : undefined; const now = Date.now(); if (existing) { const patch: Record = { enabled: args.enabled, eventTypes: args.eventTypes, channels: args.channels, updatedAt: now, }; // Only patch sensitivity when caller explicitly supplied it — never silently // narrow an existing digest user with sensitivity:'all' just because this // mutation got called without the field. if (args.sensitivity !== undefined) patch.sensitivity = args.sensitivity; if (args.aiDigestEnabled !== undefined) patch.aiDigestEnabled = args.aiDigestEnabled; // Preserve-on-omit, mirror sensitivity/digestMode pattern. Caller passing // countries:[] writes [] (explicit "all countries" reset). Caller omitting // the field leaves the existing value intact. if (normalizedCountries !== undefined) patch.countries = normalizedCountries; // Same preserve-on-omit contract for tickers ([] = explicit opt-out). if (normalizedTickers !== undefined) patch.tickers = normalizedTickers; await ctx.db.patch(existing._id, patch); } else { await ctx.db.insert("alertRules", { userId, variant: args.variant, enabled: args.enabled, eventTypes: args.eventTypes, sensitivity: pair.sensitivity, channels: args.channels, aiDigestEnabled: args.aiDigestEnabled ?? true, // On insert, store undefined when caller omitted the field so existing // backward-compat tests (rule has no `countries` field) still hold. countries: normalizedCountries, tickers: normalizedTickers, updatedAt: now, }); } }, }); export const setDigestSettings = mutation({ args: { variant: v.string(), digestMode: digestModeValidator, digestHour: v.optional(v.number()), digestTimezone: v.optional(v.string()), }, handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new ConvexError("UNAUTHENTICATED"); const userId = identity.subject; await assertProEntitlement(ctx, userId); if (args.digestHour !== undefined && (args.digestHour < 0 || args.digestHour > 23 || !Number.isInteger(args.digestHour))) { throw new ConvexError("digestHour must be an integer 0–23"); } if (args.digestTimezone !== undefined) { try { Intl.DateTimeFormat(undefined, { timeZone: args.digestTimezone }); } catch { throw new ConvexError("digestTimezone must be a valid IANA timezone (e.g. America/New_York)"); } } const existing = await ctx.db .query("alertRules") .withIndex("by_user_variant", (q) => q.eq("userId", userId).eq("variant", args.variant), ) .unique(); const pair = resolveEffectivePair({ incomingDigestMode: args.digestMode, existing: existing ?? undefined, }); assertCompatibleDeliveryMode(pair); const now = Date.now(); const patch = { digestMode: args.digestMode, digestHour: args.digestHour, digestTimezone: args.digestTimezone, updatedAt: now, }; if (existing) { await ctx.db.patch(existing._id, patch); } else { await ctx.db.insert("alertRules", { userId, variant: args.variant, enabled: true, eventTypes: [], sensitivity: pair.sensitivity, channels: [], ...patch, }); } }, }); export const getAlertRulesByUserId = internalQuery({ args: { userId: v.string() }, handler: async (ctx, args) => { return await ctx.db .query("alertRules") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .collect(); }, }); export const setAlertRulesForUser = internalMutation({ args: { userId: v.string(), variant: v.string(), enabled: v.boolean(), eventTypes: v.array(v.string()), sensitivity: v.optional(sensitivityValidator), channels: v.array(channelTypeValidator), aiDigestEnabled: v.optional(v.boolean()), countries: v.optional(v.array(v.string())), tickers: v.optional(v.array(v.string())), }, handler: async (ctx, args) => { const { userId, ...rest } = args; const existing = await ctx.db .query("alertRules") .withIndex("by_user_variant", (q) => q.eq("userId", userId).eq("variant", rest.variant), ) .unique(); const pair = resolveEffectivePair({ incomingSensitivity: rest.sensitivity, existing: existing ?? undefined, }); assertCompatibleDeliveryMode(pair); const normalizedCountries = rest.countries !== undefined ? normalizeCountries(rest.countries) : undefined; const normalizedTickers = rest.tickers !== undefined ? normalizeTickers(rest.tickers) : undefined; const now = Date.now(); if (existing) { const patch: Record = { enabled: rest.enabled, eventTypes: rest.eventTypes, channels: rest.channels, updatedAt: now, }; // Only patch sensitivity when caller explicitly supplied it — preserves // existing.sensitivity for digest users on omitted-field calls. if (rest.sensitivity !== undefined) patch.sensitivity = rest.sensitivity; if (rest.aiDigestEnabled !== undefined) patch.aiDigestEnabled = rest.aiDigestEnabled; if (normalizedCountries !== undefined) patch.countries = normalizedCountries; if (normalizedTickers !== undefined) patch.tickers = normalizedTickers; await ctx.db.patch(existing._id, patch); } else { await ctx.db.insert("alertRules", { userId, variant: rest.variant, enabled: rest.enabled, eventTypes: rest.eventTypes, sensitivity: pair.sensitivity, channels: rest.channels, aiDigestEnabled: rest.aiDigestEnabled, countries: normalizedCountries, tickers: normalizedTickers, updatedAt: now, }); } }, }); const QUIET_HOURS_ARGS = { variant: v.string(), quietHoursEnabled: v.boolean(), quietHoursStart: v.optional(v.number()), quietHoursEnd: v.optional(v.number()), quietHoursTimezone: v.optional(v.string()), quietHoursOverride: v.optional(quietHoursOverrideValidator), } as const; function validateQuietHoursArgs(args: { quietHoursStart?: number; quietHoursEnd?: number; quietHoursTimezone?: string; }) { if (args.quietHoursStart !== undefined && (args.quietHoursStart < 0 || args.quietHoursStart > 23 || !Number.isInteger(args.quietHoursStart))) { throw new ConvexError("quietHoursStart must be an integer 0–23"); } if (args.quietHoursEnd !== undefined && (args.quietHoursEnd < 0 || args.quietHoursEnd > 23 || !Number.isInteger(args.quietHoursEnd))) { throw new ConvexError("quietHoursEnd must be an integer 0–23"); } if (args.quietHoursTimezone !== undefined) { try { Intl.DateTimeFormat(undefined, { timeZone: args.quietHoursTimezone }); } catch { throw new ConvexError("quietHoursTimezone must be a valid IANA timezone (e.g. America/New_York)"); } } } export const setQuietHours = mutation({ args: QUIET_HOURS_ARGS, handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new ConvexError("UNAUTHENTICATED"); const userId = identity.subject; await assertProEntitlement(ctx, userId); validateQuietHoursArgs(args); const existing = await ctx.db .query("alertRules") .withIndex("by_user_variant", (q) => q.eq("userId", userId).eq("variant", args.variant), ) .unique(); // Only enforce start !== end when quiet hours are effectively enabled const effectiveEnabled = args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false; if (effectiveEnabled) { const effectiveStart = args.quietHoursStart ?? existing?.quietHoursStart; const effectiveEnd = args.quietHoursEnd ?? existing?.quietHoursEnd; if (effectiveStart !== undefined && effectiveEnd !== undefined && effectiveStart === effectiveEnd) { throw new ConvexError("quietHoursStart and quietHoursEnd must differ (same value = no quiet window)"); } } // resolveEffectivePair supplies sensitivity:'critical' on fresh insert (compatible // by construction under the tightened rule). We DO NOT call assertCompatibleDeliveryMode here — quiet-hours // mutations don't touch the (digestMode, sensitivity) pair, so blocking unrelated // quiet-hours updates on pre-migration forbidden rows would surface as confusing // generic 500s ('set-quiet-hours' HTTP action has no INCOMPATIBLE_DELIVERY // passthrough). The relay coerce-at-read protects delivery for in-flight forbidden // rows; the migration drains them. // See docs/archive/plans/forbid-realtime-all-events.md + PR #3461 Greptile P1. const pair = resolveEffectivePair({ existing: existing ?? undefined }); const now = Date.now(); const patch = { quietHoursEnabled: args.quietHoursEnabled, quietHoursStart: args.quietHoursStart, quietHoursEnd: args.quietHoursEnd, quietHoursTimezone: args.quietHoursTimezone, quietHoursOverride: args.quietHoursOverride, updatedAt: now, }; if (existing) { await ctx.db.patch(existing._id, patch); } else { await ctx.db.insert("alertRules", { userId, variant: args.variant, enabled: true, eventTypes: [], sensitivity: pair.sensitivity, channels: [], ...patch, }); } }, }); export const setDigestSettingsForUser = internalMutation({ args: { userId: v.string(), variant: v.string(), digestMode: digestModeValidator, digestHour: v.optional(v.number()), digestTimezone: v.optional(v.string()), countries: v.optional(v.array(v.string())), }, handler: async (ctx, args) => { const { userId, variant, countries, ...digest } = args; if (digest.digestHour !== undefined && (digest.digestHour < 0 || digest.digestHour > 23 || !Number.isInteger(digest.digestHour))) { throw new ConvexError("digestHour must be an integer 0–23"); } if (digest.digestTimezone !== undefined) { try { Intl.DateTimeFormat(undefined, { timeZone: digest.digestTimezone }); } catch { throw new ConvexError("digestTimezone must be a valid IANA timezone (e.g. America/New_York)"); } } const existing = await ctx.db .query("alertRules") .withIndex("by_user_variant", (q) => q.eq("userId", userId).eq("variant", variant), ) .unique(); const pair = resolveEffectivePair({ incomingDigestMode: digest.digestMode, existing: existing ?? undefined, }); assertCompatibleDeliveryMode(pair); const now = Date.now(); const normalizedCountries = countries !== undefined ? normalizeCountries(countries) : undefined; if (existing) { const patch: Record = { ...digest, updatedAt: now }; if (normalizedCountries !== undefined) patch.countries = normalizedCountries; await ctx.db.patch(existing._id, patch); } else { await ctx.db.insert("alertRules", { userId, variant, enabled: true, eventTypes: [], sensitivity: pair.sensitivity, channels: [], ...digest, countries: normalizedCountries, updatedAt: now, }); } }, }); export const setQuietHoursForUser = internalMutation({ args: { userId: v.string(), ...QUIET_HOURS_ARGS, countries: v.optional(v.array(v.string())) }, handler: async (ctx, args) => { const { userId, countries, ...rest } = args; validateQuietHoursArgs(rest); const existing = await ctx.db .query("alertRules") .withIndex("by_user_variant", (q) => q.eq("userId", userId).eq("variant", rest.variant), ) .unique(); // Only enforce start !== end when quiet hours are effectively enabled const effectiveEnabled = rest.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false; if (effectiveEnabled) { const effectiveStart = rest.quietHoursStart ?? existing?.quietHoursStart; const effectiveEnd = rest.quietHoursEnd ?? existing?.quietHoursEnd; if (effectiveStart !== undefined && effectiveEnd !== undefined && effectiveStart === effectiveEnd) { throw new ConvexError("quietHoursStart and quietHoursEnd must differ (same value = no quiet window)"); } } // No assertCompatibleDeliveryMode here — quiet-hours mutations don't touch // the (digestMode, sensitivity) pair. See setQuietHours above for the full // rationale. resolveEffectivePair still supplies sensitivity:'critical' on fresh // insert (compatible by construction under the tightened rule). const pair = resolveEffectivePair({ existing: existing ?? undefined }); const now = Date.now(); const normalizedCountries = countries !== undefined ? normalizeCountries(countries) : undefined; const patch = { quietHoursEnabled: rest.quietHoursEnabled, quietHoursStart: rest.quietHoursStart, quietHoursEnd: rest.quietHoursEnd, quietHoursTimezone: rest.quietHoursTimezone, quietHoursOverride: rest.quietHoursOverride, updatedAt: now, ...(normalizedCountries !== undefined ? { countries: normalizedCountries } : {}), }; if (existing) { await ctx.db.patch(existing._id, patch); } else { await ctx.db.insert("alertRules", { userId, variant: rest.variant, enabled: true, eventTypes: [], sensitivity: pair.sensitivity, channels: [], ...patch, }); } }, }); /** * Atomic internal mutation that updates BOTH digestMode and sensitivity together * (plus optional alert-rule + digest-schedule fields). Used by the settings UI's * delivery-mode change flow to avoid the two-call race in setDigestSettings → * setAlertRules where switching from `daily+all` to `realtime` would otherwise * trip the cross-field validator on the first call. * * All fields optional — caller passes only what changed. Patch logic preserves * existing.sensitivity when caller omits it (no silent narrowing of digest users). * * See docs/archive/plans/forbid-realtime-all-events.md §1d. */ export const setNotificationConfigForUser = internalMutation({ args: { userId: v.string(), variant: v.string(), enabled: v.optional(v.boolean()), eventTypes: v.optional(v.array(v.string())), sensitivity: v.optional(sensitivityValidator), channels: v.optional(v.array(channelTypeValidator)), aiDigestEnabled: v.optional(v.boolean()), digestMode: v.optional(digestModeValidator), digestHour: v.optional(v.number()), digestTimezone: v.optional(v.string()), countries: v.optional(v.array(v.string())), tickers: v.optional(v.array(v.string())), }, handler: async (ctx, args) => { const { userId, variant } = args; // Layer-2 gate: this internal mutation is reachable from the public // `set-notification-config` HTTP action in convex/http.ts. Even though // the HTTP action verifies the Clerk JWT, the entitlement check belongs // on the same transaction as the write — defense-in-depth against any // future caller (a different HTTP action, a webhook handler, etc.). // Sister *ForUser internal mutations (setAlertRulesForUser, etc.) are // INTENTIONALLY left ungated: they are invoked by trusted operator paths // (admin migration scripts, cleanup jobs) where the operator's intent // is "manage another user's settings," and entitlement-gating those // would block the very cleanup we need to do for free-tier rows that // got created before this gate existed. await assertProEntitlement(ctx, userId); if (args.digestHour !== undefined && (args.digestHour < 0 || args.digestHour > 23 || !Number.isInteger(args.digestHour))) { throw new ConvexError("digestHour must be an integer 0–23"); } if (args.digestTimezone !== undefined) { try { Intl.DateTimeFormat(undefined, { timeZone: args.digestTimezone }); } catch { throw new ConvexError("digestTimezone must be a valid IANA timezone (e.g. America/New_York)"); } } const existing = await ctx.db .query("alertRules") .withIndex("by_user_variant", (q) => q.eq("userId", userId).eq("variant", variant)) .unique(); const pair = resolveEffectivePair({ incomingDigestMode: args.digestMode, incomingSensitivity: args.sensitivity, existing: existing ?? undefined, }); assertCompatibleDeliveryMode(pair); const normalizedCountries = args.countries !== undefined ? normalizeCountries(args.countries) : undefined; const normalizedTickers = args.tickers !== undefined ? normalizeTickers(args.tickers) : undefined; const now = Date.now(); if (existing) { const patch: Record = { updatedAt: now }; if (args.enabled !== undefined) patch.enabled = args.enabled; if (args.eventTypes !== undefined) patch.eventTypes = args.eventTypes; // Only patch sensitivity when caller explicitly supplied it. if (args.sensitivity !== undefined) patch.sensitivity = args.sensitivity; if (args.channels !== undefined) patch.channels = args.channels; if (args.aiDigestEnabled !== undefined) patch.aiDigestEnabled = args.aiDigestEnabled; if (args.digestMode !== undefined) patch.digestMode = args.digestMode; if (args.digestHour !== undefined) patch.digestHour = args.digestHour; if (args.digestTimezone !== undefined) patch.digestTimezone = args.digestTimezone; if (normalizedCountries !== undefined) patch.countries = normalizedCountries; if (normalizedTickers !== undefined) patch.tickers = normalizedTickers; await ctx.db.patch(existing._id, patch); } else { await ctx.db.insert("alertRules", { userId, variant, enabled: args.enabled ?? true, eventTypes: args.eventTypes ?? [], sensitivity: pair.sensitivity, channels: args.channels ?? [], aiDigestEnabled: args.aiDigestEnabled, digestMode: args.digestMode, digestHour: args.digestHour, digestTimezone: args.digestTimezone, countries: normalizedCountries, tickers: normalizedTickers, updatedAt: now, }); } }, }); /** Returns all enabled rules that have a non-realtime digestMode set. */ export const getDigestRules = internalQuery({ args: {}, handler: async (ctx) => { const enabled = await ctx.db .query("alertRules") .withIndex("by_enabled", (q) => q.eq("enabled", true)) .collect(); return enabled.filter( (r) => r.digestMode !== undefined && r.digestMode !== "realtime", ); }, }); // INTERNAL ONLY — GHSA-r649-4cqj-w93h. This scans the `by_enabled` index and // returns rows across EVERY user with no per-caller scope, so it must never be // a public `query()`: that surface is reachable by any anonymous client that // knows the (non-secret) deployment URL, leaking all users' watchlists + // Clerk subjects. Service consumers reach it via `ctx.runQuery` behind the // shared-secret `/relay/enabled-rules` HTTP action (convex/http.ts), or via a // deploy-key-authenticated `npx convex run` (the migration scripts). Sibling // `getAlertRules` may stay public because it gates on getUserIdentity() + the // `by_user` index; this one cannot. export const getByEnabled = internalQuery({ args: { enabled: v.boolean() }, handler: async (ctx, args) => { return await ctx.db .query("alertRules") .withIndex("by_enabled", (q) => q.eq("enabled", args.enabled)) .collect(); }, });