| import { anyApi, httpRouter } from "convex/server"; |
| import { httpAction, type ActionCtx } from "./_generated/server"; |
| import { internal } from "./_generated/api"; |
| import { |
| CHECKOUT_RATE_LIMITED, |
| isCheckoutRateLimitedOutcome, |
| } from "./payments/checkoutRateLimit"; |
| import { webhookHandler } from "./payments/webhookHandlers"; |
| import { resendWebhookHandler } from "./resendWebhookHandler"; |
| import { USER_PREFS_WRITE_RATE_LIMIT } from "./constants"; |
| import { |
| INTEL_HISTORY_EMBED_DIMS, |
| INTEL_HISTORY_MAX_APPEND_RECORDS, |
| INTEL_HISTORY_MAX_RETRACT_IDENTIFIERS, |
| } from "./intelHistory"; |
|
|
| const TRUSTED = [ |
| "https://worldmonitor.app", |
| "*.worldmonitor.app", |
| "http://localhost:3000", |
| ]; |
|
|
| const EXPOSED_HEADERS = [ |
| "Retry-After", |
| "X-RateLimit-Limit", |
| "X-RateLimit-Remaining", |
| "X-RateLimit-Reset", |
| ].join(", "); |
|
|
| function matchOrigin(origin: string, pattern: string): boolean { |
| if (pattern.startsWith("*.")) { |
| return origin.endsWith(pattern.slice(1)); |
| } |
| return origin === pattern; |
| } |
|
|
| function allowedOrigin(origin: string | null, trusted: string[]): string | null { |
| if (!origin) return null; |
| return trusted.some((p) => matchOrigin(origin, p)) ? origin : null; |
| } |
|
|
| function corsHeaders(origin: string | null): Headers { |
| const headers = new Headers(); |
| const allowed = allowedOrigin(origin, TRUSTED); |
| if (allowed) { |
| headers.set("Access-Control-Allow-Origin", allowed); |
| headers.set("Access-Control-Allow-Methods", "POST, OPTIONS"); |
| headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); |
| headers.set("Access-Control-Expose-Headers", EXPOSED_HEADERS); |
| headers.set("Access-Control-Max-Age", "86400"); |
| } |
| return headers; |
| } |
|
|
| async function timingSafeEqualStrings(a: string, b: string): Promise<boolean> { |
| const enc = new TextEncoder(); |
| const keyMaterial = await crypto.subtle.generateKey( |
| { name: "HMAC", hash: "SHA-256" }, |
| false, |
| ["sign"], |
| ); |
| const [sigA, sigB] = await Promise.all([ |
| crypto.subtle.sign("HMAC", keyMaterial, enc.encode(a)), |
| crypto.subtle.sign("HMAC", keyMaterial, enc.encode(b)), |
| ]); |
| const aArr = new Uint8Array(sigA); |
| const bArr = new Uint8Array(sigB); |
| let diff = 0; |
| for (let i = 0; i < aArr.length; i++) diff |= aArr[i]! ^ bArr[i]!; |
| return diff === 0; |
| } |
|
|
| |
| async function parseJsonObjectBody<T extends object>(request: Request): Promise<T | null> { |
| try { |
| const body: unknown = await request.json(); |
| return body !== null && typeof body === "object" && !Array.isArray(body) |
| ? body as T |
| : null; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function parseConvexErrorData(err: unknown): unknown { |
| const raw = (err as { data?: unknown } | undefined)?.data; |
| if (typeof raw !== "string") return raw ?? null; |
| try { |
| return JSON.parse(raw); |
| } catch { |
| return raw; |
| } |
| } |
|
|
| function extractConvexErrorCode(err: unknown): string | null { |
| const parsed = parseConvexErrorData(err); |
| if (typeof parsed === "string") return parsed; |
| if (parsed && typeof parsed === "object") { |
| const data = parsed as Record<string, unknown>; |
| const code = data.code ?? data.kind; |
| if (typeof code === "string") return code; |
| } |
| return null; |
| } |
|
|
| function readConvexErrorNumber(err: unknown, field: string): number | null { |
| const parsed = parseConvexErrorData(err); |
| if (!parsed || typeof parsed !== "object") return null; |
| const value = (parsed as Record<string, unknown>)[field]; |
| return typeof value === "number" ? value : null; |
| } |
|
|
| type SetPreferencesResult = |
| | { ok: true; syncVersion: number } |
| | { ok: false; reason: "CONFLICT"; actualSyncVersion: number } |
| | { ok: false; reason: "BLOB_TOO_LARGE"; size: number; max: number } |
| | { ok: false; reason: "RATE_LIMITED"; limit: number; reset: number }; |
|
|
| function setRateLimitResponseHeaders(headers: Headers, limit: number, reset: number): void { |
| const retryAfter = Math.max(1, Math.ceil((reset - Date.now()) / 1000)); |
| headers.set("X-RateLimit-Limit", String(limit)); |
| headers.set("X-RateLimit-Remaining", "0"); |
| headers.set("X-RateLimit-Reset", String(reset)); |
| headers.set("Retry-After", String(retryAfter)); |
| } |
|
|
| export async function internalEntitlementsHttpHandler( |
| ctx: ActionCtx, |
| request: Request, |
| ): Promise<Response> { |
| const providedSecret = request.headers.get("x-convex-shared-secret") ?? ""; |
| const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? ""; |
| if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) { |
| return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), { |
| status: 401, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ userId?: unknown }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if ( |
| typeof body.userId !== "string" || |
| body.userId.length === 0 || |
| body.userId.length > 256 |
| ) { |
| return new Response(JSON.stringify({ error: "MISSING_USER_ID" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| let result = await ctx.runQuery( |
| internal.entitlements.getEntitlementsByUserId, |
| { userId: body.userId }, |
| ); |
| let billingStatus: |
| | "subscription_lapsed" |
| | "renewal_verification_pending" |
| | "renewal_verification_failed" |
| | undefined; |
| let retryAfterSeconds: number | undefined; |
| let renewalVerificationFreshness: |
| | { status: "not_applicable"; checkedAt: number } |
| | undefined; |
|
|
| |
| |
| |
| if (result.features.tier === 0) { |
| const verification = await ctx.runAction( |
| internal.payments.billing.verifyRecentlyStaleSubscriptionOnDemand, |
| { userId: body.userId }, |
| ); |
| if (verification.status === "not_applicable") { |
| |
| |
| |
| |
| |
| |
| |
| |
| renewalVerificationFreshness = { |
| status: "not_applicable", |
| checkedAt: Date.now(), |
| }; |
| } else { |
| |
| |
| |
| result = await ctx.runQuery( |
| internal.entitlements.getEntitlementsByUserId, |
| { userId: body.userId }, |
| ); |
| |
| |
| |
| |
| |
| const fallbackState = await ctx.runQuery( |
| internal.payments.billing.getOnDemandRenewalFallbackState, |
| { userId: body.userId, now: Date.now() }, |
| ); |
| if ( |
| result.features.tier === 0 && |
| fallbackState?.currentEntitlement |
| ) { |
| result = fallbackState.currentEntitlement; |
| } |
| const staleFeatures = fallbackState?.strongestRecentlyStaleFeatures; |
| const verificationCouldExpandCoverage = !!staleFeatures && ( |
| staleFeatures.tier > result.features.tier || |
| (staleFeatures.apiAccess && !result.features.apiAccess) || |
| (staleFeatures.mcpAccess && !result.features.mcpAccess) |
| ); |
| if ( |
| verification.status !== "active" && |
| ( |
| result.features.tier === 0 || |
| verificationCouldExpandCoverage |
| ) |
| ) { |
| billingStatus = verification.status; |
| if ("retryAfterSeconds" in verification) { |
| retryAfterSeconds = verification.retryAfterSeconds; |
| } |
| } |
| } |
| } |
|
|
| return new Response(JSON.stringify({ |
| ...result, |
| ...(billingStatus ? { billingStatus } : {}), |
| ...(retryAfterSeconds != null ? { retryAfterSeconds } : {}), |
| ...(renewalVerificationFreshness ? { renewalVerificationFreshness } : {}), |
| }), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const http = httpRouter(); |
|
|
| http.route({ |
| path: "/api/internal-entitlements", |
| method: "POST", |
| handler: httpAction(internalEntitlementsHttpHandler), |
| }); |
|
|
| http.route({ |
| path: "/api/user-prefs", |
| method: "OPTIONS", |
| handler: httpAction(async (_ctx, request) => { |
| const headers = corsHeaders(request.headers.get("Origin")); |
| return new Response(null, { status: 204, headers }); |
| }), |
| }); |
|
|
| http.route({ |
| path: "/api/user-prefs", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| const headers = corsHeaders(request.headers.get("Origin")); |
| headers.set("Content-Type", "application/json"); |
|
|
| const identity = await ctx.auth.getUserIdentity(); |
| if (!identity) { |
| return new Response(JSON.stringify({ error: "UNAUTHENTICATED" }), { |
| status: 401, |
| headers, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ |
| variant?: string; |
| data?: unknown; |
| expectedSyncVersion?: number; |
| schemaVersion?: number; |
| }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers, |
| }); |
| } |
|
|
| if ( |
| typeof body.variant !== "string" || |
| body.data === undefined || |
| typeof body.expectedSyncVersion !== "number" |
| ) { |
| return new Response(JSON.stringify({ error: "MISSING_FIELDS" }), { |
| status: 400, |
| headers, |
| }); |
| } |
|
|
| try { |
| const result = (await ctx.runMutation( |
| anyApi.userPreferences!.setPreferences as any, |
| { |
| variant: body.variant, |
| data: body.data, |
| expectedSyncVersion: body.expectedSyncVersion, |
| schemaVersion: body.schemaVersion, |
| }, |
| )) as SetPreferencesResult; |
| |
| |
| |
| if (result.ok === false) { |
| if (result.reason === "BLOB_TOO_LARGE") { |
| return new Response(JSON.stringify({ error: "BLOB_TOO_LARGE" }), { |
| status: 400, |
| headers, |
| }); |
| } |
| if (result.reason === "RATE_LIMITED") { |
| setRateLimitResponseHeaders(headers, result.limit, result.reset); |
| return new Response(JSON.stringify({ error: "RATE_LIMITED" }), { |
| status: 429, |
| headers, |
| }); |
| } |
| return new Response( |
| JSON.stringify({ |
| error: "CONFLICT", |
| actualSyncVersion: result.actualSyncVersion, |
| }), |
| { status: 409, headers }, |
| ); |
| } |
| return new Response( |
| JSON.stringify({ syncVersion: result.syncVersion }), |
| { status: 200, headers }, |
| ); |
| } catch (err: unknown) { |
| const msg = err instanceof Error ? err.message : String(err); |
| const code = extractConvexErrorCode(err); |
| |
| |
| |
| |
| if (code === "CONFLICT" || msg.includes("CONFLICT")) { |
| return new Response(JSON.stringify({ error: "CONFLICT" }), { |
| status: 409, |
| headers, |
| }); |
| } |
| if (code === "BLOB_TOO_LARGE" || msg.includes("BLOB_TOO_LARGE")) { |
| return new Response(JSON.stringify({ error: "BLOB_TOO_LARGE" }), { |
| status: 400, |
| headers, |
| }); |
| } |
| if (code === "RATE_LIMITED" || msg.includes("RATE_LIMITED")) { |
| const limit = readConvexErrorNumber(err, "limit") ?? USER_PREFS_WRITE_RATE_LIMIT; |
| const reset = readConvexErrorNumber(err, "reset") ?? Date.now() + 60_000; |
| setRateLimitResponseHeaders(headers, limit, reset); |
| return new Response(JSON.stringify({ error: "RATE_LIMITED" }), { |
| status: 429, |
| headers, |
| }); |
| } |
| throw err; |
| } |
| }), |
| }); |
|
|
| http.route({ |
| path: "/api/telegram-pair-callback", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| |
| |
| |
| |
| |
| const secret = process.env.TELEGRAM_WEBHOOK_SECRET ?? ""; |
| if (!secret) { |
| console.error( |
| "[telegram-webhook] TELEGRAM_WEBHOOK_SECRET not configured — rejecting all requests", |
| ); |
| return new Response("OK", { status: 200 }); |
| } |
| const provided = |
| request.headers.get("X-Telegram-Bot-Api-Secret-Token") ?? ""; |
| if (!provided) { |
| |
| |
| console.warn( |
| "[telegram-webhook] secret header absent — rejecting request", |
| ); |
| return new Response("OK", { status: 200 }); |
| } |
| if (!(await timingSafeEqualStrings(provided, secret))) { |
| return new Response("OK", { status: 200 }); |
| } |
|
|
| const update = await parseJsonObjectBody<{ |
| message?: { |
| chat?: { type?: string; id?: number }; |
| text?: string; |
| date?: number; |
| }; |
| }>(request); |
| if (!update) { |
| return new Response("OK", { status: 200 }); |
| } |
|
|
| const msg = update.message; |
| if (!msg) return new Response("OK", { status: 200 }); |
|
|
| if (msg.chat?.type !== "private") return new Response("OK", { status: 200 }); |
|
|
| if (!msg.date || Math.abs(Date.now() / 1000 - msg.date) > 900) { |
| return new Response("OK", { status: 200 }); |
| } |
|
|
| const text = msg.text?.trim() ?? ""; |
| const chatId = String(msg.chat.id); |
|
|
| const match = text.match(/^\/start\s+([A-Za-z0-9_-]{40,50})$/); |
| if (!match) return new Response("OK", { status: 200 }); |
|
|
| const claimed = await ctx.runMutation(anyApi.notificationChannels!.claimPairingToken as any, { |
| token: match[1], |
| chatId, |
| }); |
|
|
| |
| const botToken = process.env.TELEGRAM_BOT_TOKEN ?? ""; |
| if (claimed.ok && botToken) { |
| await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json", "User-Agent": "worldmonitor-convex/1.0" }, |
| body: JSON.stringify({ |
| chat_id: chatId, |
| text: "✅ WorldMonitor connected! You'll receive breaking news alerts here.", |
| }), |
| signal: AbortSignal.timeout(8000), |
| }).catch((err: unknown) => { |
| console.error("[telegram-webhook] sendMessage failed:", err); |
| }); |
| } |
|
|
| return new Response("OK", { status: 200 }); |
| }), |
| }); |
|
|
| 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" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ userId?: string; channelType?: string }>(request); |
| if (!body) { |
| 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" && body.channelType !== "discord" && body.channelType !== "web_push") |
| ) { |
| return new Response(JSON.stringify({ error: "MISSING_FIELDS" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| await ctx.runMutation((internal as any).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", |
| 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" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ userId?: string }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if (typeof body.userId !== "string" || !body.userId) { |
| return new Response(JSON.stringify({ error: "MISSING_USER_ID" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const channels = await ctx.runQuery((internal as any).notificationChannels.getChannelsByUserId, { |
| userId: body.userId, |
| }); |
|
|
| return new Response(JSON.stringify(channels ?? []), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| }), |
| }); |
|
|
| |
| |
| http.route({ |
| path: "/relay/notification-channels", |
| 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" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ |
| action?: string; |
| userId?: string; |
| channelType?: string; |
| chatId?: string; |
| webhookEnvelope?: string; |
| webhookLabel?: string; |
| email?: string; |
| variant?: string; |
| enabled?: boolean; |
| eventTypes?: string[]; |
| sensitivity?: string; |
| channels?: string[]; |
| slackChannelName?: string; |
| slackTeamName?: string; |
| slackConfigurationUrl?: string; |
| discordGuildId?: string; |
| discordChannelId?: string; |
| endpoint?: string; |
| p256dh?: string; |
| auth?: string; |
| userAgent?: string; |
| quietHoursEnabled?: boolean; |
| quietHoursStart?: number; |
| quietHoursEnd?: number; |
| quietHoursTimezone?: string; |
| quietHoursOverride?: string; |
| digestMode?: string; |
| digestHour?: number; |
| digestTimezone?: string; |
| aiDigestEnabled?: boolean; |
| countries?: string[]; |
| tickers?: string[]; |
| scheduleWelcome?: boolean; |
| }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const { action = "get", userId } = body; |
| if (typeof userId !== "string" || !userId) { |
| return new Response(JSON.stringify({ error: "MISSING_USER_ID" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| try { |
| if (action === "get") { |
| const [channels, alertRules] = await Promise.all([ |
| ctx.runQuery((internal as any).notificationChannels.getChannelsByUserId, { userId }), |
| ctx.runQuery((internal as any).alertRules.getAlertRulesByUserId, { userId }), |
| ]); |
| return new Response(JSON.stringify({ channels: channels ?? [], alertRules: alertRules ?? [] }), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if (action === "welcome-scheduling-capability") { |
| return new Response( |
| JSON.stringify({ durableWelcomeScheduling: true }), |
| { status: 200, headers: { "Content-Type": "application/json" } }, |
| ); |
| } |
|
|
| if (action === "create-pairing-token") { |
| const result = await ctx.runMutation((internal as any).notificationChannels.createPairingTokenForUser, { |
| userId, |
| variant: body.variant, |
| }); |
| return new Response(JSON.stringify(result), { status: 200, headers: { "Content-Type": "application/json" } }); |
| } |
|
|
| if (action === "set-channel") { |
| if (!body.channelType) { |
| return new Response(JSON.stringify({ error: "channelType required" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| const setResult = await ctx.runMutation((internal as any).notificationChannels.setChannelForUser, { |
| userId, |
| channelType: body.channelType as "telegram" | "slack" | "email" | "webhook", |
| chatId: body.chatId, |
| webhookEnvelope: body.webhookEnvelope, |
| email: body.email, |
| webhookLabel: body.webhookLabel, |
| scheduleWelcome: body.scheduleWelcome === true, |
| }); |
| return new Response(JSON.stringify({ |
| ok: true, |
| isNew: setResult.isNew, |
| durableWelcomeScheduling: body.scheduleWelcome === true, |
| }), { status: 200, headers: { "Content-Type": "application/json" } }); |
| } |
|
|
| if (action === "set-slack-oauth") { |
| if (!body.webhookEnvelope) { |
| return new Response(JSON.stringify({ error: "webhookEnvelope required" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| const oauthResult = await ctx.runMutation((internal as any).notificationChannels.setSlackOAuthChannelForUser, { |
| userId, |
| webhookEnvelope: body.webhookEnvelope, |
| slackChannelName: body.slackChannelName, |
| slackTeamName: body.slackTeamName, |
| slackConfigurationUrl: body.slackConfigurationUrl, |
| }); |
| return new Response(JSON.stringify({ ok: true, isNew: oauthResult.isNew }), { status: 200, headers: { "Content-Type": "application/json" } }); |
| } |
|
|
| if (action === "set-discord-oauth") { |
| if (!body.webhookEnvelope) { |
| return new Response(JSON.stringify({ error: "webhookEnvelope required" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| const discordResult = await ctx.runMutation((internal as any).notificationChannels.setDiscordOAuthChannelForUser, { |
| userId, |
| webhookEnvelope: body.webhookEnvelope, |
| discordGuildId: body.discordGuildId, |
| discordChannelId: body.discordChannelId, |
| }); |
| return new Response(JSON.stringify({ ok: true, isNew: discordResult.isNew }), { status: 200, headers: { "Content-Type": "application/json" } }); |
| } |
|
|
| if (action === "set-web-push") { |
| if (!body.endpoint || !body.p256dh || !body.auth) { |
| return new Response(JSON.stringify({ error: "endpoint, p256dh, auth required" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| const webPushResult = await ctx.runMutation((internal as any).notificationChannels.setWebPushChannelForUser, { |
| userId, |
| endpoint: body.endpoint, |
| p256dh: body.p256dh, |
| auth: body.auth, |
| userAgent: body.userAgent, |
| scheduleWelcome: body.scheduleWelcome === true, |
| }); |
| return new Response(JSON.stringify({ |
| ok: true, |
| isNew: webPushResult.isNew, |
| durableWelcomeScheduling: body.scheduleWelcome === true, |
| }), { status: 200, headers: { "Content-Type": "application/json" } }); |
| } |
|
|
| if (action === "delete-channel") { |
| if (!body.channelType) { |
| return new Response(JSON.stringify({ error: "channelType required" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| await ctx.runMutation((internal as any).notificationChannels.deleteChannelForUser, { |
| userId, |
| channelType: body.channelType as "telegram" | "slack" | "email" | "discord", |
| }); |
| return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }); |
| } |
|
|
| if (action === "set-alert-rules") { |
| const VALID_SENSITIVITY = new Set(["all", "high", "critical"]); |
| if ( |
| typeof body.variant !== "string" || !body.variant || |
| typeof body.enabled !== "boolean" || |
| !Array.isArray(body.eventTypes) || |
| !Array.isArray(body.channels) || |
| (body.sensitivity !== undefined && !VALID_SENSITIVITY.has(body.sensitivity as string)) || |
| (body.countries !== undefined && !Array.isArray(body.countries)) || |
| (body.tickers !== undefined && !Array.isArray(body.tickers)) |
| ) { |
| return new Response(JSON.stringify({ error: "MISSING_REQUIRED_FIELDS" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| try { |
| await ctx.runMutation((internal as any).alertRules.setAlertRulesForUser, { |
| userId, |
| variant: body.variant, |
| enabled: body.enabled, |
| eventTypes: body.eventTypes as string[], |
| |
| |
| |
| |
| |
| |
| |
| sensitivity: body.sensitivity as "all" | "high" | "critical" | undefined, |
| channels: body.channels as Array<"telegram" | "slack" | "email">, |
| aiDigestEnabled: typeof body.aiDigestEnabled === "boolean" ? body.aiDigestEnabled : undefined, |
| |
| countries: Array.isArray(body.countries) ? (body.countries as string[]) : undefined, |
| |
| tickers: Array.isArray(body.tickers) ? (body.tickers as string[]) : undefined, |
| }); |
| } catch (err: unknown) { |
| |
| |
| |
| |
| |
| |
| const code = extractConvexErrorCode(err); |
| if (code === "TICKERS_LIMIT_EXCEEDED" || code === "COUNTRIES_LIMIT_EXCEEDED") { |
| return new Response(JSON.stringify({ error: code }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| throw err; |
| } |
| return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }); |
| } |
|
|
| if (action === "set-quiet-hours") { |
| const VALID_OVERRIDE = new Set(["critical_only", "silence_all", "batch_on_wake"]); |
| if (typeof body.variant !== "string" || !body.variant || typeof body.quietHoursEnabled !== "boolean") { |
| return new Response(JSON.stringify({ error: "variant and quietHoursEnabled required" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| if (body.quietHoursOverride !== undefined && !VALID_OVERRIDE.has(body.quietHoursOverride)) { |
| return new Response(JSON.stringify({ error: "invalid quietHoursOverride" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| await ctx.runMutation((internal as any).alertRules.setQuietHoursForUser, { |
| userId, |
| variant: body.variant, |
| quietHoursEnabled: body.quietHoursEnabled, |
| quietHoursStart: body.quietHoursStart, |
| quietHoursEnd: body.quietHoursEnd, |
| quietHoursTimezone: body.quietHoursTimezone, |
| quietHoursOverride: body.quietHoursOverride as "critical_only" | "silence_all" | "batch_on_wake" | undefined, |
| countries: Array.isArray(body.countries) ? (body.countries as string[]) : undefined, |
| }); |
| return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }); |
| } |
|
|
| if (action === "set-digest-settings") { |
| const VALID_DIGEST_MODE = new Set(["realtime", "daily", "twice_daily", "weekly"]); |
| if ( |
| typeof body.variant !== "string" || !body.variant || |
| !VALID_DIGEST_MODE.has(body.digestMode as string) |
| ) { |
| return new Response(JSON.stringify({ error: "MISSING_REQUIRED_FIELDS" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| await ctx.runMutation((internal as any).alertRules.setDigestSettingsForUser, { |
| userId, |
| variant: body.variant, |
| digestMode: body.digestMode as "realtime" | "daily" | "twice_daily" | "weekly", |
| digestHour: typeof body.digestHour === "number" ? body.digestHour : undefined, |
| digestTimezone: typeof body.digestTimezone === "string" ? body.digestTimezone : undefined, |
| countries: Array.isArray(body.countries) ? (body.countries as string[]) : undefined, |
| }); |
| return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }); |
| } |
|
|
| |
| |
| |
| |
| |
| if (action === "set-notification-config") { |
| const VALID_SENSITIVITY = new Set(["all", "high", "critical"]); |
| const VALID_DIGEST_MODE = new Set(["realtime", "daily", "twice_daily", "weekly"]); |
| if (typeof body.variant !== "string" || !body.variant) { |
| return new Response(JSON.stringify({ error: "MISSING_VARIANT" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| if (body.sensitivity !== undefined && !VALID_SENSITIVITY.has(body.sensitivity as string)) { |
| return new Response(JSON.stringify({ error: "INVALID_SENSITIVITY" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| if (body.digestMode !== undefined && !VALID_DIGEST_MODE.has(body.digestMode as string)) { |
| return new Response(JSON.stringify({ error: "INVALID_DIGEST_MODE" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| if (body.countries !== undefined && !Array.isArray(body.countries)) { |
| return new Response(JSON.stringify({ error: "COUNTRIES_MUST_BE_ARRAY" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| if (body.tickers !== undefined && !Array.isArray(body.tickers)) { |
| return new Response(JSON.stringify({ error: "TICKERS_MUST_BE_ARRAY" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| try { |
| await ctx.runMutation((internal as any).alertRules.setNotificationConfigForUser, { |
| userId, |
| variant: body.variant, |
| enabled: typeof body.enabled === "boolean" ? body.enabled : undefined, |
| eventTypes: Array.isArray(body.eventTypes) ? (body.eventTypes as string[]) : undefined, |
| sensitivity: body.sensitivity as "all" | "high" | "critical" | undefined, |
| channels: Array.isArray(body.channels) ? (body.channels as Array<"telegram" | "slack" | "email" | "discord" | "webhook" | "web_push">) : undefined, |
| aiDigestEnabled: typeof body.aiDigestEnabled === "boolean" ? body.aiDigestEnabled : undefined, |
| digestMode: body.digestMode as "realtime" | "daily" | "twice_daily" | "weekly" | undefined, |
| digestHour: typeof body.digestHour === "number" ? body.digestHour : undefined, |
| digestTimezone: typeof body.digestTimezone === "string" ? body.digestTimezone : undefined, |
| countries: Array.isArray(body.countries) ? (body.countries as string[]) : undefined, |
| tickers: Array.isArray(body.tickers) ? (body.tickers as string[]) : undefined, |
| }); |
| } catch (err: unknown) { |
| |
| |
| |
| |
| |
| |
| |
| |
| const code = extractConvexErrorCode(err); |
| |
| |
| const parsed = parseConvexErrorData(err); |
| const message = (parsed && typeof parsed === "object") |
| ? (parsed as { message?: string }).message ?? "" |
| : ""; |
| if (code === "INCOMPATIBLE_DELIVERY" || code === "TICKERS_LIMIT_EXCEEDED" || code === "COUNTRIES_LIMIT_EXCEEDED") { |
| return new Response(JSON.stringify({ error: code, message }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } |
| if (code === "PRO_REQUIRED") { |
| |
| |
| |
| return new Response(JSON.stringify({ error: code, message }), { status: 402, headers: { "Content-Type": "application/json" } }); |
| } |
| throw err; |
| } |
| return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }); |
| } |
|
|
| return new Response(JSON.stringify({ error: "Unknown action" }), { status: 400, headers: { "Content-Type": "application/json" } }); |
| } catch (err: unknown) { |
| const msg = err instanceof Error ? err.message : String(err); |
| return new Response(JSON.stringify({ error: msg }), { status: 500, headers: { "Content-Type": "application/json" } }); |
| } |
| }), |
| }); |
|
|
| |
| http.route({ |
| path: "/relay/digest-rules", |
| method: "GET", |
| 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" }, |
| }); |
| } |
| const rules = await ctx.runQuery((internal as any).alertRules.getDigestRules); |
| return new Response(JSON.stringify(rules), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| }), |
| }); |
|
|
| |
| |
| |
| |
| |
| http.route({ |
| path: "/relay/enabled-rules", |
| method: "GET", |
| 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" }, |
| }); |
| } |
| const enabled = new URL(request.url).searchParams.get("enabled") !== "false"; |
| const rules = await ctx.runQuery( |
| (internal as any).alertRules.getByEnabled, |
| { enabled }, |
| ); |
| return new Response(JSON.stringify(rules), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| }), |
| }); |
|
|
| http.route({ |
| path: "/relay/user-preferences", |
| 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" }, |
| }); |
| } |
| const body = await parseJsonObjectBody<{ userId?: string; variant?: string }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| if (!body.userId || !body.variant) { |
| return new Response(JSON.stringify({ error: "userId and variant required" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| const prefs = await ctx.runQuery( |
| (internal as any).userPreferences.getPreferencesByUserId, |
| { userId: body.userId, variant: body.variant }, |
| ); |
| return new Response(JSON.stringify(prefs?.data ?? null), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| }), |
| }); |
|
|
| |
| |
| |
| |
| |
| http.route({ |
| path: "/relay/followed-countries", |
| 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" }, |
| }); |
| } |
| const body = await parseJsonObjectBody<{ userId?: unknown }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| |
| |
| |
| if ( |
| typeof body.userId !== "string" || |
| body.userId.length === 0 || |
| body.userId.length > 256 |
| ) { |
| return new Response(JSON.stringify({ error: "userId required" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| const countries = await ctx.runQuery( |
| internal.followedCountries.internalListFollowedForUser, |
| { userId: body.userId }, |
| ); |
| return new Response(JSON.stringify({ countries }), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| }), |
| }); |
|
|
| http.route({ |
| path: "/relay/entitlement", |
| 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" }, |
| }); |
| } |
| const body = await parseJsonObjectBody<{ userId?: string }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| if (!body.userId) { |
| return new Response(JSON.stringify({ error: "userId required" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| const ent = await ctx.runQuery( |
| internal.entitlements.getEntitlementsByUserId, |
| { userId: body.userId }, |
| ); |
| const tier = ent?.features?.tier ?? 0; |
| return new Response(JSON.stringify({ tier }), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| }), |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| http.route({ |
| path: "/relay/register-referral-code", |
| 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" }, |
| }); |
| } |
| const body = await parseJsonObjectBody<{ userId?: string; code?: string }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| const userId = typeof body.userId === "string" ? body.userId.trim() : ""; |
| const code = typeof body.code === "string" ? body.code.trim() : ""; |
| if (!userId || !code || code.length < 4 || code.length > 32) { |
| return new Response(JSON.stringify({ error: "userId + code required" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| const result = await ctx.runMutation( |
| (internal as any).registerInterest.registerUserReferralCode, |
| { userId, code }, |
| ); |
| return new Response(JSON.stringify(result), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| }), |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| http.route({ |
| path: "/api/internal-validate-api-key", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| const providedSecret = request.headers.get("x-convex-shared-secret") ?? ""; |
| const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? ""; |
| if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) { |
| return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), { |
| status: 401, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ keyHash?: unknown }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if (typeof body.keyHash !== "string" || body.keyHash.length === 0) { |
| return new Response(JSON.stringify({ error: "MISSING_KEY_HASH" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const result = await ctx.runQuery( |
| (internal as any).apiKeys.validateKeyByHash, |
| { keyHash: body.keyHash }, |
| ); |
|
|
| if (result) { |
| try { |
| await ctx.scheduler.runAfter(0, (internal as any).apiKeys.touchKeyLastUsed, { keyId: result.id }); |
| } catch (err) { |
| |
| |
| console.warn("[validate-api-key] touchKeyLastUsed schedule failed:", err instanceof Error ? err.message : String(err)); |
| } |
| } |
|
|
| return new Response(JSON.stringify(result), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| }), |
| }); |
|
|
| |
| |
| http.route({ |
| path: "/api/internal-get-key-owner", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| const providedSecret = request.headers.get("x-convex-shared-secret") ?? ""; |
| const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? ""; |
| if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) { |
| return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), { |
| status: 401, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ keyHash?: unknown }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if (typeof body.keyHash !== "string" || !/^[a-f0-9]{64}$/.test(body.keyHash)) { |
| return new Response(JSON.stringify({ error: "INVALID_KEY_HASH" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const result = await ctx.runQuery( |
| (internal as any).apiKeys.getKeyOwner, |
| { keyHash: body.keyHash }, |
| ); |
|
|
| return new Response(JSON.stringify(result), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| }), |
| }); |
|
|
| |
| |
| |
| |
| |
|
|
| http.route({ |
| path: "/api/internal-issue-pro-mcp-token", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| const providedSecret = request.headers.get("x-convex-shared-secret") ?? ""; |
| const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? ""; |
| if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) { |
| return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), { |
| status: 401, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ |
| userId?: unknown; |
| clientId?: unknown; |
| name?: unknown; |
| }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if (typeof body.userId !== "string" || body.userId.length === 0) { |
| return new Response(JSON.stringify({ error: "MISSING_USER_ID" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| try { |
| const result = await ctx.runMutation( |
| (internal as any).mcpProTokens.issueProMcpToken, |
| { |
| userId: body.userId, |
| clientId: typeof body.clientId === "string" ? body.clientId : undefined, |
| name: typeof body.name === "string" ? body.name : undefined, |
| }, |
| ); |
| return new Response(JSON.stringify(result), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } catch (err: unknown) { |
| const code = extractConvexErrorCode(err); |
| if (code === "PRO_REQUIRED") { |
| return new Response(JSON.stringify({ error: "PRO_REQUIRED" }), { |
| status: 403, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| if (code === "INVALID_USER_ID") { |
| return new Response(JSON.stringify({ error: "INVALID_USER_ID" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| throw err; |
| } |
| }), |
| }); |
|
|
| http.route({ |
| path: "/api/internal-validate-pro-mcp-token", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| const providedSecret = request.headers.get("x-convex-shared-secret") ?? ""; |
| const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? ""; |
| if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) { |
| return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), { |
| status: 401, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ tokenId?: unknown }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if (typeof body.tokenId !== "string" || body.tokenId.length === 0) { |
| return new Response(JSON.stringify({ error: "MISSING_TOKEN_ID" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| try { |
| const result = await ctx.runQuery( |
| (internal as any).mcpProTokens.validateProMcpToken, |
| { tokenId: body.tokenId }, |
| ); |
|
|
| if (result) { |
| try { |
| await ctx.scheduler.runAfter( |
| 0, |
| (internal as any).mcpProTokens.touchProMcpTokenLastUsed, |
| { tokenId: body.tokenId }, |
| ); |
| } catch (err) { |
| |
| |
| console.warn( |
| "[validate-pro-mcp-token] touch schedule failed:", |
| err instanceof Error ? err.message : String(err), |
| ); |
| } |
| } |
|
|
| return new Response(JSON.stringify(result), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } catch (err: unknown) { |
| |
| |
| |
| const msg = err instanceof Error ? err.message : String(err); |
| if (msg.includes("ArgumentValidationError") || msg.includes("not a valid id")) { |
| return new Response(JSON.stringify(null), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| throw err; |
| } |
| }), |
| }); |
|
|
| http.route({ |
| path: "/api/internal-revoke-pro-mcp-token", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| const providedSecret = request.headers.get("x-convex-shared-secret") ?? ""; |
| const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? ""; |
| if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) { |
| return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), { |
| status: 401, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ userId?: unknown; tokenId?: unknown }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if (typeof body.userId !== "string" || body.userId.length === 0) { |
| return new Response(JSON.stringify({ error: "MISSING_USER_ID" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| if (typeof body.tokenId !== "string" || body.tokenId.length === 0) { |
| return new Response(JSON.stringify({ error: "MISSING_TOKEN_ID" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| try { |
| const result = await ctx.runMutation( |
| (internal as any).mcpProTokens.internalRevokeProMcpToken, |
| { userId: body.userId, tokenId: body.tokenId }, |
| ); |
| return new Response(JSON.stringify(result), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } catch (err: unknown) { |
| const code = extractConvexErrorCode(err); |
| if (code === "NOT_FOUND") { |
| return new Response(JSON.stringify({ error: "NOT_FOUND" }), { |
| status: 404, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| if (code === "ALREADY_REVOKED") { |
| return new Response(JSON.stringify({ error: "ALREADY_REVOKED" }), { |
| status: 409, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| const msg = err instanceof Error ? err.message : String(err); |
| if (msg.includes("ArgumentValidationError") || msg.includes("not a valid id")) { |
| return new Response(JSON.stringify({ error: "NOT_FOUND" }), { |
| status: 404, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| throw err; |
| } |
| }), |
| }); |
|
|
| http.route({ |
| path: "/dodopayments-webhook", |
| method: "POST", |
| handler: webhookHandler, |
| }); |
|
|
| |
| |
| |
| http.route({ |
| path: "/relay/create-checkout", |
| 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" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ |
| userId?: string; |
| email?: string; |
| name?: string; |
| productId?: string; |
| returnUrl?: string; |
| discountCode?: string; |
| referralCode?: string; |
| bypassPendingGuard?: boolean; |
| }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if (!body.userId || !body.productId) { |
| return new Response( |
| JSON.stringify({ error: "MISSING_FIELDS", required: ["userId", "productId"] }), |
| { status: 400, headers: { "Content-Type": "application/json" } }, |
| ); |
| } |
|
|
| try { |
| const result = await ctx.runAction( |
| internal.payments.checkout.internalCreateCheckout, |
| { |
| userId: body.userId, |
| email: body.email, |
| name: body.name, |
| productId: body.productId, |
| returnUrl: body.returnUrl, |
| discountCode: body.discountCode, |
| referralCode: body.referralCode, |
| bypassPendingGuard: body.bypassPendingGuard, |
| }, |
| ); |
| if (isCheckoutRateLimitedOutcome(result)) { |
| return new Response( |
| JSON.stringify({ |
| error: CHECKOUT_RATE_LIMITED, |
| message: "Checkout is temporarily rate limited. Retry shortly.", |
| }), |
| { |
| status: 429, |
| headers: { |
| "Content-Type": "application/json", |
| "Retry-After": String(result.retryAfterSeconds), |
| }, |
| }, |
| ); |
| } |
| if ( |
| result && |
| typeof result === "object" && |
| "blocked" in result && |
| result.blocked === true |
| ) { |
| |
| |
| |
| |
| const blockedBody: Record<string, unknown> = { |
| error: result.code, |
| message: result.message, |
| }; |
| if ("subscription" in result) blockedBody.subscription = result.subscription; |
| if ("pendingPayment" in result) blockedBody.pendingPayment = result.pendingPayment; |
| return new Response(JSON.stringify(blockedBody), { |
| status: 409, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| return new Response(JSON.stringify(result), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : "Checkout creation failed"; |
| return new Response(JSON.stringify({ error: msg }), { |
| status: 500, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| }), |
| }); |
|
|
| |
| |
| |
| http.route({ |
| path: "/relay/customer-portal", |
| 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" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ userId?: string }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if (!body.userId) { |
| return new Response( |
| JSON.stringify({ error: "MISSING_FIELDS", required: ["userId"] }), |
| { status: 400, headers: { "Content-Type": "application/json" } }, |
| ); |
| } |
|
|
| try { |
| const result = await ctx.runAction( |
| internal.payments.billing.internalGetCustomerPortalUrl, |
| { userId: body.userId }, |
| ); |
| return new Response(JSON.stringify(result), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : "Customer portal creation failed"; |
| const status = msg === "No Dodo customer found for this user" ? 404 : 500; |
| return new Response(JSON.stringify({ error: msg }), { |
| status, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| }), |
| }); |
|
|
| |
| |
| http.route({ |
| path: "/resend-webhook", |
| method: "POST", |
| handler: resendWebhookHandler, |
| }); |
|
|
| |
| |
| http.route({ |
| path: "/relay/bulk-suppress-emails", |
| 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" }, |
| }); |
| } |
|
|
| const body = await parseJsonObjectBody<{ |
| emails: Array<{ |
| email: string; |
| reason: "bounce" | "complaint" | "manual"; |
| source?: string; |
| }>; |
| }>(request); |
| if (!body) { |
| return new Response(JSON.stringify({ error: "INVALID_JSON" }), { |
| status: 400, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| if (!Array.isArray(body.emails) || body.emails.length === 0) { |
| return new Response( |
| JSON.stringify({ error: "MISSING_FIELDS", required: ["emails"] }), |
| { status: 400, headers: { "Content-Type": "application/json" } }, |
| ); |
| } |
|
|
| try { |
| const result = await ctx.runMutation( |
| internal.emailSuppressions.bulkSuppress, |
| { emails: body.emails }, |
| ); |
| return new Response(JSON.stringify(result), { |
| status: 200, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : "Bulk suppress failed"; |
| return new Response(JSON.stringify({ error: msg }), { |
| status: 500, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
| }), |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const INTEL_HISTORY_MAX_TITLE_LEN = 500; |
| const INTEL_HISTORY_MAX_SUMMARY_LEN = 2000; |
| const INTEL_HISTORY_MAX_SOURCE_URL_LEN = 2048; |
| const INTEL_HISTORY_MAX_DEDUPE_KEY_LEN = 256; |
| const INTEL_HISTORY_MAX_COUNTRY_LEN = 8; |
| const INTEL_HISTORY_MAX_CATEGORY_LEN = 64; |
| const INTEL_HISTORY_MAX_IDENTIFIER_LEN = 128; |
|
|
| |
| function intelJson(body: unknown, status: number): Response { |
| return new Response(JSON.stringify(body), { |
| status, |
| headers: { "Content-Type": "application/json" }, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function intelRelayUnauthorized( |
| request: Request, |
| { retraction = false }: { retraction?: boolean } = {}, |
| ): Promise<boolean> { |
| const secret = retraction |
| ? (process.env.RELAY_RETRACT_SECRET ?? "") |
| : (process.env.RELAY_SHARED_SECRET ?? ""); |
| const provided = (request.headers.get("Authorization") ?? "").replace(/^Bearer\s+/, ""); |
| if (!secret) return true; |
| return !(await timingSafeEqualStrings(provided, secret)); |
| } |
|
|
| type IntelHistoryIngestRecord = { |
| dedupeKey: string; |
| country?: string; |
| category?: string; |
| title: string; |
| summary?: string; |
| sourceUrl?: string; |
| occurredAt: number; |
| embedding: number[]; |
| }; |
|
|
| type FieldResult<T> = { ok: true; value: T } | { ok: false }; |
|
|
| |
| function readOptionalString(value: unknown, max: number): FieldResult<string | undefined> { |
| if (value === undefined || value === null) return { ok: true, value: undefined }; |
| if (typeof value !== "string" || value.length === 0 || value.length > max) { |
| return { ok: false }; |
| } |
| return { ok: true, value }; |
| } |
|
|
| |
| function readOptionalNumber(value: unknown): FieldResult<number | undefined> { |
| if (value === undefined || value === null) return { ok: true, value: undefined }; |
| if (typeof value !== "number" || !Number.isFinite(value)) return { ok: false }; |
| return { ok: true, value }; |
| } |
|
|
| |
| function isValidEmbedding(value: unknown): value is number[] { |
| return ( |
| Array.isArray(value) && |
| value.length === INTEL_HISTORY_EMBED_DIMS && |
| value.every((n) => typeof n === "number" && Number.isFinite(n)) |
| ); |
| } |
|
|
| |
| function isHttpUrl(value: string): boolean { |
| try { |
| const scheme = new URL(value).protocol; |
| return scheme === "https:" || scheme === "http:"; |
| } catch { |
| return false; |
| } |
| } |
|
|
| function validateIntelHistoryRecord( |
| raw: unknown, |
| ): { ok: true; record: IntelHistoryIngestRecord } | { ok: false; reason: string } { |
| if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { |
| return { ok: false, reason: "record must be a JSON object" }; |
| } |
| const rec = raw as Record<string, unknown>; |
|
|
| if ( |
| typeof rec.dedupeKey !== "string" || |
| rec.dedupeKey.length === 0 || |
| rec.dedupeKey.length > INTEL_HISTORY_MAX_DEDUPE_KEY_LEN |
| ) { |
| return { |
| ok: false, |
| reason: `dedupeKey must be a non-empty string of at most ${INTEL_HISTORY_MAX_DEDUPE_KEY_LEN} chars`, |
| }; |
| } |
| if ( |
| typeof rec.title !== "string" || |
| rec.title.length === 0 || |
| rec.title.length > INTEL_HISTORY_MAX_TITLE_LEN |
| ) { |
| return { |
| ok: false, |
| reason: `title must be a non-empty string of at most ${INTEL_HISTORY_MAX_TITLE_LEN} chars`, |
| }; |
| } |
| if (typeof rec.occurredAt !== "number" || !Number.isFinite(rec.occurredAt)) { |
| return { ok: false, reason: "occurredAt must be a finite epoch-ms number" }; |
| } |
| if (!isValidEmbedding(rec.embedding)) { |
| return { |
| ok: false, |
| reason: `embedding must be an array of ${INTEL_HISTORY_EMBED_DIMS} finite numbers`, |
| }; |
| } |
|
|
| const country = readOptionalString(rec.country, INTEL_HISTORY_MAX_COUNTRY_LEN); |
| if (!country.ok) return { ok: false, reason: "country must be a short ISO2-ish string" }; |
| const category = readOptionalString(rec.category, INTEL_HISTORY_MAX_CATEGORY_LEN); |
| if (!category.ok) { |
| return { |
| ok: false, |
| reason: `category must be a string of at most ${INTEL_HISTORY_MAX_CATEGORY_LEN} chars`, |
| }; |
| } |
| const summary = readOptionalString(rec.summary, INTEL_HISTORY_MAX_SUMMARY_LEN); |
| if (!summary.ok) { |
| return { |
| ok: false, |
| reason: `summary must be a string of at most ${INTEL_HISTORY_MAX_SUMMARY_LEN} chars`, |
| }; |
| } |
| const sourceUrl = readOptionalString(rec.sourceUrl, INTEL_HISTORY_MAX_SOURCE_URL_LEN); |
| if (!sourceUrl.ok) { |
| return { |
| ok: false, |
| reason: `sourceUrl must be a string of at most ${INTEL_HISTORY_MAX_SOURCE_URL_LEN} chars`, |
| }; |
| } |
| |
| |
| |
| |
| |
| |
| if (sourceUrl.value !== undefined && !isHttpUrl(sourceUrl.value)) { |
| return { ok: false, reason: "sourceUrl must be an http(s) URL" }; |
| } |
|
|
| return { |
| ok: true, |
| record: { |
| dedupeKey: rec.dedupeKey, |
| title: rec.title, |
| occurredAt: rec.occurredAt, |
| embedding: rec.embedding, |
| country: country.value, |
| category: category.value, |
| summary: summary.value, |
| sourceUrl: sourceUrl.value, |
| }, |
| }; |
| } |
|
|
| |
| function readIntelQueryScope(body: Record<string, unknown>): |
| | { |
| ok: true; |
| scope: { |
| domain?: string; |
| country?: string; |
| from?: number; |
| to?: number; |
| limit?: number; |
| }; |
| } |
| | { ok: false; error: string } { |
| const domain = readOptionalString(body.domain, INTEL_HISTORY_MAX_IDENTIFIER_LEN); |
| if (!domain.ok) return { ok: false, error: "INVALID_DOMAIN" }; |
| const country = readOptionalString(body.country, INTEL_HISTORY_MAX_COUNTRY_LEN); |
| if (!country.ok) return { ok: false, error: "INVALID_COUNTRY" }; |
| const from = readOptionalNumber(body.from); |
| if (!from.ok) return { ok: false, error: "INVALID_FROM" }; |
| const to = readOptionalNumber(body.to); |
| if (!to.ok) return { ok: false, error: "INVALID_TO" }; |
| const limit = readOptionalNumber(body.limit); |
| if (!limit.ok) return { ok: false, error: "INVALID_LIMIT" }; |
|
|
| return { |
| ok: true, |
| scope: { |
| domain: domain.value, |
| country: country.value, |
| from: from.value, |
| to: to.value, |
| limit: limit.value, |
| }, |
| }; |
| } |
|
|
| http.route({ |
| path: "/relay/intel-history", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| if (await intelRelayUnauthorized(request)) { |
| return intelJson({ error: "UNAUTHORIZED" }, 401); |
| } |
|
|
| const body = await parseJsonObjectBody<{ |
| domain?: unknown; |
| resource?: unknown; |
| runId?: unknown; |
| records?: unknown; |
| }>(request); |
| if (!body) { |
| return intelJson({ error: "INVALID_JSON" }, 400); |
| } |
|
|
| const missing: string[] = []; |
| for (const field of ["domain", "resource", "runId"] as const) { |
| const value = body[field]; |
| if ( |
| typeof value !== "string" || |
| value.length === 0 || |
| value.length > INTEL_HISTORY_MAX_IDENTIFIER_LEN |
| ) { |
| missing.push(field); |
| } |
| } |
| if (missing.length > 0) { |
| return intelJson({ error: "MISSING_FIELDS", required: missing }, 400); |
| } |
| if (!Array.isArray(body.records) || body.records.length === 0) { |
| return intelJson({ error: "MISSING_FIELDS", required: ["records"] }, 400); |
| } |
| if (body.records.length > INTEL_HISTORY_MAX_APPEND_RECORDS) { |
| return intelJson( |
| { |
| error: "TOO_MANY_RECORDS", |
| max: INTEL_HISTORY_MAX_APPEND_RECORDS, |
| got: body.records.length, |
| }, |
| 400, |
| ); |
| } |
|
|
| |
| |
| const records: IntelHistoryIngestRecord[] = []; |
| for (let i = 0; i < body.records.length; i++) { |
| const validated = validateIntelHistoryRecord(body.records[i]); |
| if (!validated.ok) { |
| return intelJson( |
| { error: "INVALID_RECORD", index: i, reason: validated.reason }, |
| 400, |
| ); |
| } |
| records.push(validated.record); |
| } |
|
|
| try { |
| const result = await ctx.runMutation(internal.intelHistory.append, { |
| domain: body.domain as string, |
| resource: body.resource as string, |
| runId: body.runId as string, |
| records, |
| }); |
| return intelJson(result, 200); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : "intel history append failed"; |
| return intelJson({ error: msg }, 500); |
| } |
| }), |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const INTEL_HISTORY_MAX_REASON_LEN = 500; |
| const INTEL_HISTORY_MAX_ID_LEN = 128; |
|
|
| |
| |
| |
| |
| |
| |
| function readIdentifierList( |
| value: unknown, |
| max: number, |
| ): FieldResult<string[]> { |
| if (value === undefined || value === null) return { ok: true, value: [] }; |
| if (!Array.isArray(value)) return { ok: false }; |
| for (const entry of value) { |
| if (typeof entry !== "string" || entry.length === 0 || entry.length > max) { |
| return { ok: false }; |
| } |
| |
| |
| |
| |
| |
| |
| if (entry !== entry.trim()) return { ok: false }; |
| } |
| return { ok: true, value: value as string[] }; |
| } |
|
|
| http.route({ |
| path: "/relay/intel-history/retract", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| if (await intelRelayUnauthorized(request, { retraction: true })) { |
| return intelJson({ error: "UNAUTHORIZED" }, 401); |
| } |
|
|
| const body = await parseJsonObjectBody<Record<string, unknown>>(request); |
| if (!body) return intelJson({ error: "INVALID_JSON" }, 400); |
|
|
| const ids = readIdentifierList(body.ids, INTEL_HISTORY_MAX_ID_LEN); |
| if (!ids.ok) return intelJson({ error: "INVALID_IDS" }, 400); |
| const dedupeKeys = readIdentifierList( |
| body.dedupeKeys, |
| INTEL_HISTORY_MAX_DEDUPE_KEY_LEN, |
| ); |
| if (!dedupeKeys.ok) return intelJson({ error: "INVALID_DEDUPE_KEYS" }, 400); |
|
|
| if (ids.value.length + dedupeKeys.value.length === 0) { |
| return intelJson( |
| { error: "MISSING_IDENTIFIERS", required: ["ids", "dedupeKeys"], mode: "any_of" }, |
| 400, |
| ); |
| } |
| if ( |
| ids.value.length + dedupeKeys.value.length > |
| INTEL_HISTORY_MAX_RETRACT_IDENTIFIERS |
| ) { |
| return intelJson( |
| { |
| error: "TOO_MANY_IDENTIFIERS", |
| max: INTEL_HISTORY_MAX_RETRACT_IDENTIFIERS, |
| got: ids.value.length + dedupeKeys.value.length, |
| }, |
| 400, |
| ); |
| } |
|
|
| |
| |
| |
| const reason = |
| typeof body.reason === "string" ? body.reason.trim() : ""; |
| if (!reason || reason.length > INTEL_HISTORY_MAX_REASON_LEN) { |
| return intelJson( |
| { |
| error: "MISSING_REASON", |
| reason: `reason must be a non-empty string of at most ${INTEL_HISTORY_MAX_REASON_LEN} chars`, |
| }, |
| 400, |
| ); |
| } |
|
|
| try { |
| const result = await ctx.runMutation(internal.intelHistory.retract, { |
| ids: ids.value, |
| dedupeKeys: dedupeKeys.value, |
| reason, |
| }); |
| return intelJson(result, 200); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : "intel history retract failed"; |
| return intelJson({ error: msg }, 500); |
| } |
| }), |
| }); |
|
|
| http.route({ |
| path: "/relay/intel-history/restore", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| if (await intelRelayUnauthorized(request, { retraction: true })) { |
| return intelJson({ error: "UNAUTHORIZED" }, 401); |
| } |
|
|
| const body = await parseJsonObjectBody<Record<string, unknown>>(request); |
| if (!body) return intelJson({ error: "INVALID_JSON" }, 400); |
|
|
| |
| |
| const dedupeKeys = readIdentifierList( |
| body.dedupeKeys, |
| INTEL_HISTORY_MAX_DEDUPE_KEY_LEN, |
| ); |
| if (!dedupeKeys.ok) return intelJson({ error: "INVALID_DEDUPE_KEYS" }, 400); |
| if (dedupeKeys.value.length === 0) { |
| return intelJson({ error: "MISSING_IDENTIFIERS", required: ["dedupeKeys"] }, 400); |
| } |
| if (dedupeKeys.value.length > INTEL_HISTORY_MAX_RETRACT_IDENTIFIERS) { |
| return intelJson( |
| { |
| error: "TOO_MANY_IDENTIFIERS", |
| max: INTEL_HISTORY_MAX_RETRACT_IDENTIFIERS, |
| got: dedupeKeys.value.length, |
| }, |
| 400, |
| ); |
| } |
|
|
| try { |
| const result = await ctx.runMutation(internal.intelHistory.restore, { |
| dedupeKeys: dedupeKeys.value, |
| }); |
| return intelJson(result, 200); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : "intel history restore failed"; |
| return intelJson({ error: msg }, 500); |
| } |
| }), |
| }); |
|
|
| http.route({ |
| path: "/relay/intel-history/retractions", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| if (await intelRelayUnauthorized(request, { retraction: true })) { |
| return intelJson({ error: "UNAUTHORIZED" }, 401); |
| } |
|
|
| const body = await parseJsonObjectBody<Record<string, unknown>>(request); |
| if (!body) return intelJson({ error: "INVALID_JSON" }, 400); |
|
|
| const limit = readOptionalNumber(body.limit); |
| if (!limit.ok) return intelJson({ error: "INVALID_LIMIT" }, 400); |
|
|
| try { |
| const result = await ctx.runQuery(internal.intelHistory.listRetractions, { |
| limit: limit.value, |
| }); |
| return intelJson(result, 200); |
| } catch (err) { |
| const msg = |
| err instanceof Error ? err.message : "intel history retractions read failed"; |
| return intelJson({ error: msg }, 500); |
| } |
| }), |
| }); |
|
|
| http.route({ |
| path: "/api/internal-intel-timeline", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| const providedSecret = request.headers.get("x-convex-shared-secret") ?? ""; |
| const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? ""; |
| if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) { |
| return intelJson({ error: "UNAUTHORIZED" }, 401); |
| } |
|
|
| const body = await parseJsonObjectBody<Record<string, unknown>>(request); |
| if (!body) { |
| return intelJson({ error: "INVALID_JSON" }, 400); |
| } |
|
|
| const parsed = readIntelQueryScope(body); |
| if (!parsed.ok) { |
| return intelJson({ error: parsed.error }, 400); |
| } |
| |
| if (parsed.scope.domain === undefined && parsed.scope.country === undefined) { |
| return intelJson( |
| { error: "MISSING_SCOPE", required: ["domain", "country"], mode: "any_of" }, |
| 400, |
| ); |
| } |
|
|
| try { |
| const result = await ctx.runQuery(internal.intelHistory.timeline, parsed.scope); |
| return intelJson(result, 200); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : "intel history timeline failed"; |
| return intelJson({ error: msg }, 500); |
| } |
| }), |
| }); |
|
|
| http.route({ |
| path: "/api/internal-intel-search", |
| method: "POST", |
| handler: httpAction(async (ctx, request) => { |
| const providedSecret = request.headers.get("x-convex-shared-secret") ?? ""; |
| const expectedSecret = process.env.CONVEX_SERVER_SHARED_SECRET ?? ""; |
| if (!expectedSecret || !(await timingSafeEqualStrings(providedSecret, expectedSecret))) { |
| return intelJson({ error: "UNAUTHORIZED" }, 401); |
| } |
|
|
| const body = await parseJsonObjectBody<Record<string, unknown>>(request); |
| if (!body) { |
| return intelJson({ error: "INVALID_JSON" }, 400); |
| } |
| if (!isValidEmbedding(body.embedding)) { |
| return intelJson( |
| { error: "INVALID_EMBEDDING", expectedDimensions: INTEL_HISTORY_EMBED_DIMS }, |
| 400, |
| ); |
| } |
|
|
| const parsed = readIntelQueryScope(body); |
| if (!parsed.ok) { |
| return intelJson({ error: parsed.error }, 400); |
| } |
| const minScore = body.minScore; |
| if ( |
| minScore !== undefined && |
| (typeof minScore !== "number" || |
| !Number.isFinite(minScore) || |
| minScore < -1 || |
| minScore > 1) |
| ) { |
| return intelJson({ error: "INVALID_MIN_SCORE" }, 400); |
| } |
|
|
| try { |
| const result = await ctx.runAction(internal.intelHistory.search, { |
| embedding: body.embedding, |
| ...parsed.scope, |
| ...(typeof minScore === "number" ? { minScore } : {}), |
| }); |
| return intelJson(result, 200); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : "intel history search failed"; |
| return intelJson({ error: msg }, 500); |
| } |
| }), |
| }); |
|
|
| export default http; |
|
|