Files
worldmonitor/convex/http.ts
Elie Habib b5824d0512 feat(brief): Phase 9 / Todo #223 — share button + referral attribution (#3175)
* feat(brief): Phase 9 / Todo #223 — share button + referral attribution

Adds a Share button to the dashboard Brief panel so PRO users can
spread WorldMonitor virally. Built on the existing referral plumbing
(registrations.referralCode + referredBy fields; api/register-interest
already passes referredBy through) — this PR fills in the last mile:
a stable referral code for signed-in Clerk users, a share URL, and
a client-side share sheet.

Files:

  server/_shared/referral-code.ts (new)
    Deterministic 8-char hex code: HMAC(BRIEF_URL_SIGNING_SECRET,
    'referral:v1:' + userId). Same Clerk userId always produces the
    same code. No DB write on login, no schema migration, stable for
    the life of the account.

  api/referral/me.ts (new)
    GET -> { code, shareUrl, invitedCount }. Bearer-auth via Clerk.
    Reuses BRIEF_URL_SIGNING_SECRET to avoid another Railway env
    var. Stats fail gracefully to 0 on Convex outage.

  convex/registerInterest.ts + convex/http.ts
    New internal query getReferralStatsByCode({referralCode}) counts
    registrations rows that named this code as their referredBy.
    Exposed via POST /relay/referral-stats (RELAY_SHARED_SECRET auth).

  src/services/referral.ts (new)
    getReferralProfile: 5-min cache, profile is effectively immutable
    shareReferral: Web Share API primary (mobile native sheet),
    clipboard fallback on desktop. Returns 'shared'/'copied'/'blocked'
    /'error'. AbortError is treated as 'blocked', not failure.
    clearReferralCache for account-switch hygiene.

  src/components/LatestBriefPanel.ts + src/styles/panels.css
    New share row below the brief cover card. Button disabled until
    /api/referral/me resolves; if fetch fails the row removes itself.
    invitedCount > 0 renders as 'N invited' next to the button.
    Referral cache invalidated alongside Clerk token cache on account
    switch (otherwise user B would see user A's share link for 5 min).

Tests: 10 new cases in tests/brief-referral-code.test.mjs
  - getReferralCodeForUser: hex shape, determinism, uniqueness,
    secret-rotation invalidates, input guards
  - buildShareUrl: path shape, trailing-slash trim, URL-encoding

153/153 brief + deploy tests pass. Both tsconfigs typecheck clean.

Attribution flow (already working end-to-end):
  1. Share button -> worldmonitor.app/pro?ref={code}
  2. /pro landing page already reads ?ref= and passes to
     /api/register-interest as referredBy
  3. convex registerInterest:register increments the referrer's
     referralCount and stores referredBy on the new row
  4. /api/referral/me reads the count back via the relay query
  5. 'N invited' updates on next 5-min cache refresh

Scope boundaries (deferred):
  - Convex conversion tracking (invited -> PRO subscribed). Needs a
    join from registrations.referredBy to subscriptions.userId via
    email. Surface 'N converted' in a follow-up.
  - Referral-credit / reward system: viral loop works today, reward
    logic is a separate product decision.

* fix(brief): address three P2 review findings on #3175

- api/referral/me.ts JSDoc said '503 if REFERRAL_SIGNING_SECRET is
  not configured' but the handler actually reads BRIEF_URL_SIGNING_SECRET.
  Updated the docstring so an operator chasing a 503 doesn't look for
  an env var that doesn't exist.

- server/_shared/referral-code.ts carried a RESERVED_CODES Set to
  avoid collisions with URL-path keywords ('index', 'robots', 'admin').
  The guard is dead code: the code alphabet is [0-9a-f] (hex output
  of the HMAC) so none of those non-hex keywords can ever appear.
  Removed the Set + the while loop; left a comment explaining why it
  was unnecessary so nobody re-adds it.

- src/components/LatestBriefPanel.ts passed disabled: 'true' (string)
  to the h() helper. DOM-utils' h() calls setAttribute for unknown
  props, which does disable the button — but it's inconsistent with
  the later .disabled = false property write. Fixed to the boolean
  disabled: true so the attribute and the IDL property agree.

10/10 referral-code tests pass. Both tsconfigs typecheck clean.

* fix(brief): address two review findings on #3175 — drop misleading count + fix user-agnostic cache

P1: invitedCount wired to the wrong attribution store.
The share URL is /pro?ref=<code>. On /pro the 'ref' feeds
Dodopayments checkout metadata (affonso_referral), NOT
registrations.referredBy. /api/referral/me counted only the
waitlist path, so the panel would show 0 invited for anyone who
converted direct-to-checkout — misleading.

Rather than ship a count that measures only one of two attribution
paths (and the less-common one at that), the count is removed
entirely. The share button itself still works. A proper metric
requires unifying the waitlist + Dodo-metadata paths into a single
attribution store, which is a follow-up.

Changes:
  - api/referral/me.ts: response shape is { code, shareUrl } — no
    invitedCount / convertedCount
  - convex/registerInterest.ts: removed getReferralStatsByCode
    internal query
  - convex/http.ts: removed /relay/referral-stats route
  - src/services/referral.ts: ReferralProfile interface no longer
    has invitedCount; fetch call unchanged in behaviour
  - src/components/LatestBriefPanel.ts: dropped the 'N invited'
    render branch

P2: referral cache was user-agnostic.
Module-global _cached had no userId key, so a stale cache primed by
user A would hand user B user A's share link for up to 5 min after
an account switch — if no panel is mounted at the transition moment
to call clearReferralCache(). Per the reviewer's point, this is a
real race.

Fix: two-part.
  (a) Cache entry carries the userId it was computed for; reads
      check the current Clerk userId and only accept hits when they
      match. Mismatch → drop + re-fetch.
  (b) src/services/referral.ts self-subscribes to auth-state at
      module load (ensureAuthSubscription). On any id transition
      _cached is dropped. Module-level subscription means the
      invalidation works even when no panel is currently mounted.
  (c) Belt-and-suspenders: post-fetch, re-check the current user
      before caching. Protects against account switches that happen
      mid-flight between 'read cache → ask network → write cache'.

Panel's local clearReferralCache() call removed — module now
self-invalidates.

10/10 referral-code tests pass. Both tsconfigs typecheck clean.

* fix(referral): address P1 review finding — share codes now actually credit the sharer

The earlier head generated 8-char Clerk-derived HMAC codes for the
share button, but the waitlist register mutation only looked up
registrations.by_referral_code (6-char email-generated codes).
Codes issued by the share button NEVER resolved to a sharer — the
'referral attribution' half of the feature was non-functional.

Fix (schema-level, honest attribution path):

  convex/schema.ts
    - userReferralCodes   { userId, code, createdAt }  + by_code, by_user
    - userReferralCredits { referrerUserId, refereeEmail, createdAt }
                          + by_referrer, by_referrer_email

  convex/registerInterest.ts
    - register mutation: after the existing registrations.by_referral_code
      lookup, falls through to userReferralCodes.by_code. On match,
      inserts a userReferralCredits row (the Clerk user has no
      registrations row to increment, so credit needs its own
      table). Dedupes by (referrer, refereeEmail) so returning
      visitors can't double-credit.
    - new internalMutation registerUserReferralCode({userId, code})
      idempotent binding of a code to a userId. Collisions logged
      and ignored (keeps first writer).

  convex/http.ts
    - new POST /relay/register-referral-code (RELAY_SHARED_SECRET auth)
      that runs the mutation above.

  api/referral/me.ts
    - signature gains a ctx.waitUntil handle
    - after generating the user's code, fire-and-forget POSTs to
      /relay/register-referral-code so the binding is live by the
      time anyone clicks a shared link. Idempotent — a failure just
      means the NEXT call re-registers.

Still deferred: display of 'N credited' / 'N converted' in the
LatestBriefPanel. The waitlist side now resolves correctly, but the
Dodopayments checkout path (/pro?ref=<code> → affonso_referral) is
tracked in Dodo, not Convex. Surfacing a unified count requires a
separate follow-up to pull Dodo metadata into Convex.

Regression tests (3 new cases in tests/brief-referral-code.test.mjs):
  - register mutation extends to userReferralCodes + inserts credits
  - schema declares both tables with the right indexes
  - /api/referral/me registers the binding via waitUntil

13/13 referral tests pass. Both tsconfigs typecheck clean.

* fix(referral): address two P1 review findings — checkout attribution + dead-link prevention

P1: share URL didn't credit on the /pro?ref= checkout path.
The earlier PR wired Clerk codes into the waitlist path
(/api/register-interest -> userReferralCodes -> userReferralCredits)
but a visitor landing on /pro?ref=<code> and going straight to
Dodo checkout forwarded the code only into Dodo metadata
(affonso_referral). Nothing on our side credited the sharer.

Fix: convex/payments/subscriptionHelpers.ts handleSubscriptionActive
now reads data.metadata.affonso_referral when inserting a NEW
subscription row. If the code resolves in userReferralCodes, a
userReferralCredits row crediting the sharer is inserted (deduped
by (referrer, refereeEmail) so webhook replays don't double-credit).
The credit only lands on first-activation — the else-branch of the
existing/new split guards against replays.

P1: /api/referral/me returned 200 + share link even when the
(code, userId) binding failed.
ctx.waitUntil(registerReferralCodeInConvex(...)) ran the binding
asynchronously, swallowing missing env + non-2xx + network errors.
Users got a share URL that the waitlist lookup could never
resolve — dead link.

Fix: registerReferralCodeInConvex is now BLOCKING (throws on any
failure) and the handler awaits it before returning. On failure
the endpoint responds 503 service_unavailable rather than a 200
with a non-functional URL. Mutation is idempotent so client
retries are safe.

Regression tests (2 updated/new in tests/brief-referral-code.test.mjs):
  - asserts the binding is awaited, not ctx.waitUntil'd; asserts
    the failure path returns 503
  - asserts subscriptionHelpers reads affonso_referral, resolves via
    userReferralCodes.by_code, inserts a userReferralCredits row,
    and dedupes by (referrer, refereeEmail)

14/14 referral tests pass. Both tsconfigs typecheck clean.

Net effect: /pro?ref=<code> visitors who convert (direct checkout)
now credit the sharer on webhook receipt, same as waitlist
signups. The share button is no longer a dead-end UI.
2026-04-18 20:39:55 +04:00

1022 lines
36 KiB
TypeScript

import { anyApi, httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import { webhookHandler } from "./payments/webhookHandlers";
import { resendWebhookHandler } from "./resendWebhookHandler";
const TRUSTED = [
"https://worldmonitor.app",
"*.worldmonitor.app",
"http://localhost:3000",
];
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-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;
}
const http = httpRouter();
http.route({
path: "/api/internal-entitlements",
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" },
});
}
let body: { userId?: unknown };
try {
body = await request.json() as { userId?: unknown };
} catch {
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" },
});
}
const result = await ctx.runQuery(
internal.entitlements.getEntitlementsByUserId,
{ userId: body.userId },
);
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
});
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,
});
}
let body: {
variant?: string;
data?: unknown;
expectedSyncVersion?: number;
schemaVersion?: number;
};
try {
body = await request.json() as typeof body;
} catch {
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,
},
);
return new Response(JSON.stringify(result), { status: 200, headers });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("CONFLICT")) {
return new Response(JSON.stringify({ error: "CONFLICT" }), {
status: 409,
headers,
});
}
if (msg.includes("BLOB_TOO_LARGE")) {
return new Response(JSON.stringify({ error: "BLOB_TOO_LARGE" }), {
status: 400,
headers,
});
}
throw err;
}
}),
});
http.route({
path: "/api/telegram-pair-callback",
method: "POST",
handler: httpAction(async (ctx, request) => {
// Always return 200 — non-200 triggers Telegram retry storm
const secret = process.env.TELEGRAM_WEBHOOK_SECRET ?? "";
const provided =
request.headers.get("X-Telegram-Bot-Api-Secret-Token") ?? "";
// Drop only when a secret header IS provided but doesn't match (spoofing).
// If the header is absent, Telegram's secret_token registration may have
// silently failed — the pairing token (43-char, single-use, 15-min TTL)
// provides sufficient defence against token guessing.
if (provided && secret && !(await timingSafeEqualStrings(provided, secret))) {
return new Response("OK", { status: 200 });
}
if (!provided) console.warn("[telegram-webhook] secret header absent — relying on pairing token auth");
let update: {
message?: {
chat?: { type?: string; id?: number };
text?: string;
date?: number;
};
};
try {
update = await request.json() as typeof update;
} catch {
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,
});
// Send welcome on successful first/re-pair — must be awaited in HTTP actions
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" },
});
}
let body: { userId?: string; channelType?: string };
try {
body = await request.json() as typeof body;
} catch {
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" },
});
}
let body: { userId?: string };
try {
body = await request.json() as typeof body;
} catch {
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" },
});
}),
});
// Service-to-service notification channel management (no user JWT required).
// Authenticated via RELAY_SHARED_SECRET; caller supplies the validated userId.
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" },
});
}
let body: {
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;
};
try {
body = await request.json() as typeof body;
} catch {
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 === "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,
});
return new Response(JSON.stringify({ ok: true, isNew: setResult.isNew }), { 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,
});
return new Response(JSON.stringify({ ok: true, isNew: webPushResult.isNew }), { 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))
) {
return new Response(JSON.stringify({ error: "MISSING_REQUIRED_FIELDS" }), { status: 400, headers: { "Content-Type": "application/json" } });
}
await ctx.runMutation((internal as any).alertRules.setAlertRulesForUser, {
userId,
variant: body.variant,
enabled: body.enabled,
eventTypes: body.eventTypes as string[],
sensitivity: (body.sensitivity ?? "all") as "all" | "high" | "critical",
channels: body.channels as Array<"telegram" | "slack" | "email">,
aiDigestEnabled: typeof body.aiDigestEnabled === "boolean" ? body.aiDigestEnabled : undefined,
});
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,
});
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,
});
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" } });
}
}),
});
// Service-to-service: Railway digest cron fetches due rules (no user JWT required).
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/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" },
});
}
let body: { userId?: string; variant?: string };
try {
body = await request.json() as typeof body;
} catch {
return new Response(JSON.stringify({ error: "INVALID_BODY" }), {
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/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" },
});
}
let body: { userId?: string };
try {
body = await request.json() as typeof body;
} catch {
return new Response(JSON.stringify({ error: "INVALID_BODY" }), {
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" },
});
}),
});
// ---------------------------------------------------------------------------
// Referral code registration (Phase 9 / Todo #223)
// ---------------------------------------------------------------------------
// Edge-route companion for /api/referral/me. Binds a Clerk-derived
// 8-char share code to the signed-in user's Clerk userId so future
// /pro?ref=<code> signups can credit the sharer via the
// userReferralCredits path in registerInterest:register. Auth is
// server-to-server via RELAY_SHARED_SECRET — the edge route already
// validated the caller's Clerk bearer before hitting this.
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" },
});
}
let body: { userId?: string; code?: string };
try {
body = await request.json() as typeof body;
} catch {
return new Response(JSON.stringify({ error: "INVALID_BODY" }), {
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" },
});
}),
});
// ---------------------------------------------------------------------------
// User API key validation (service-to-service only)
// ---------------------------------------------------------------------------
// Service-to-service: validate a user API key by its SHA-256 hash.
// Called by the Vercel edge gateway to look up user-owned keys.
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" },
});
}
let body: { keyHash?: unknown };
try {
body = await request.json() as { keyHash?: unknown };
} catch {
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) {
// Fire-and-forget: update lastUsedAt (don't await, don't block response)
void ctx.runMutation((internal as any).apiKeys.touchKeyLastUsed, { keyId: result.id });
}
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
});
// Service-to-service: look up the owner of a key by hash (regardless of revoked status).
// Used by the cache-invalidation endpoint to verify tenancy boundaries.
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" },
});
}
let body: { keyHash?: unknown };
try {
body = await request.json() as { keyHash?: unknown };
} catch {
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: "/dodopayments-webhook",
method: "POST",
handler: webhookHandler,
});
// Service-to-service: Vercel edge gateway creates Dodo checkout sessions.
// Authenticated via RELAY_SHARED_SECRET; edge endpoint validates Clerk JWT
// and forwards the verified userId.
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" },
});
}
let body: {
userId?: string;
email?: string;
name?: string;
productId?: string;
returnUrl?: string;
discountCode?: string;
referralCode?: string;
};
try {
body = await request.json() as typeof body;
} catch {
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,
},
);
if (
result &&
typeof result === "object" &&
"blocked" in result &&
result.blocked === true
) {
return new Response(
JSON.stringify({
error: result.code,
message: result.message,
subscription: result.subscription,
}),
{
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" },
});
}
}),
});
// Service-to-service: Vercel edge gateway creates Dodo customer portal sessions.
// Authenticated via RELAY_SHARED_SECRET; edge endpoint validates Clerk JWT
// and forwards the verified userId.
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" },
});
}
let body: { userId?: string };
try {
body = await request.json() as typeof body;
} catch {
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" },
});
}
}),
});
// Resend webhook: captures bounce/complaint events and suppresses emails.
// Signature verification + internal mutation, same pattern as Dodo webhook.
http.route({
path: "/resend-webhook",
method: "POST",
handler: resendWebhookHandler,
});
// Bulk email suppression: service-to-service, authenticated via RELAY_SHARED_SECRET.
// Used by the one-time import script (scripts/import-bounced-emails.mjs).
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" },
});
}
let body: {
emails: Array<{
email: string;
reason: "bounce" | "complaint" | "manual";
source?: string;
}>;
};
try {
body = (await request.json()) as typeof body;
} catch {
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" },
});
}
}),
});
export default http;