/** * Signed-in user's referral profile (Phase 9 / Todo #223). * * GET /api/referral/me * Bearer-auth via Clerk JWT. * -> 200 { code, shareUrl } * -> 401 on missing/invalid bearer * -> 503 if BRIEF_URL_SIGNING_SECRET is not configured (we reuse * it as the HMAC secret for referral codes — see handler body). * * `code` is a deterministic 8-char hash of the Clerk userId (stable * for the life of the account). * * Stats are privacy-safe: the route returns counts only, never the * referred users' emails or identities. * * Convex binding is fire-and-forget via ctx.waitUntil (see handler). * An earlier iteration blocked on the binding and returned 503 on * any failure — that turned a single flaky Convex call into a * homepage-wide 503 outage for every PRO user (all homepage loads * fetch this within the 5-minute client cache window). The mutation * is idempotent; the next fetch re-attempts, and a receiver's * signup at /pro?ref= only needs the binding to have landed * SOMETIME before that receiver completes signup, not on every * share-button mount. Missed attribution beats homepage 503. */ export const config = { runtime: 'edge' }; // @ts-expect-error — JS module, no declaration file import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js'; // @ts-expect-error — JS module, no declaration file import { jsonResponse } from '../_json-response.js'; // @ts-expect-error — JS module, no declaration file import { captureSilentError } from '../_sentry-edge.js'; import { validateBearerToken } from '../../server/auth-session'; import { getReferralCodeForUser, buildShareUrl } from '../../server/_shared/referral-code'; const PUBLIC_BASE = process.env.WORLDMONITOR_PUBLIC_BASE_URL ?? 'https://worldmonitor.app'; /** * Bind the Clerk-derived share code to the userId in Convex so that * future /pro?ref= signups can actually credit the sharer. * * Fire-and-forget via the caller's ctx.waitUntil — never blocks the * 200 response on this path. The mutation is idempotent, so the next * /api/referral/me fetch (or signup-side lookup) re-attempts. A * missed binding degrades to "receiver's signup isn't attributed" * which is strictly less bad than the prior behaviour of 503'ing * every PRO homepage load while Convex is slow or misconfigured. * * Resolves on success. Does NOT throw on failure — the caller relies * on waitUntil to catch + log so a background failure can't surface * as an unhandled rejection. */ async function registerReferralCodeInConvex(userId: string, code: string): Promise { const convexSite = process.env.CONVEX_SITE_URL ?? (process.env.CONVEX_URL ?? '').replace('.convex.cloud', '.convex.site'); const relaySecret = process.env.RELAY_SHARED_SECRET ?? ''; if (!convexSite || !relaySecret) { throw new Error('convex_relay_not_configured'); } const res = await fetch(`${convexSite}/relay/register-referral-code`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${relaySecret}`, 'User-Agent': 'worldmonitor-edge/1.0', }, body: JSON.stringify({ userId, code }), signal: AbortSignal.timeout(5000), }); if (!res.ok) { throw new Error(`register_referral_code_${res.status}`); } } export default async function handler( req: Request, ctx: { waitUntil: (p: Promise) => void }, ): Promise { if (isDisallowedOrigin(req)) { return jsonResponse({ error: 'Origin not allowed' }, 403); } const cors = getCorsHeaders(req, 'GET, OPTIONS') as Record; if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers: cors }); } if (req.method !== 'GET') { return jsonResponse({ error: 'Method not allowed' }, 405, cors); } const authHeader = req.headers.get('Authorization') ?? ''; const jwt = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; if (!jwt) return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors); const session = await validateBearerToken(jwt); if (!session.valid || !session.userId) { return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors); } // Reuse BRIEF_URL_SIGNING_SECRET as the HMAC secret for referral // codes. Same secret, different message namespace (`referral:v1:` // vs `brief:...`) so code spaces don't collide. Avoids provisioning // yet another Railway env var — referral codes are low-stakes and // the consequence of secret rotation is "existing share links stop // counting", not "user-visible breakage". const secret = process.env.BRIEF_URL_SIGNING_SECRET ?? ''; if (!secret) { console.error('[api/referral/me] BRIEF_URL_SIGNING_SECRET is not configured'); return jsonResponse({ error: 'service_unavailable' }, 503, cors); } let code: string; try { code = await getReferralCodeForUser(session.userId, secret); } catch (err) { console.error('[api/referral/me] code generation failed:', (err as Error).message); captureSilentError(err, { tags: { route: 'api/referral/me', step: 'code-generation' }, ctx }); return jsonResponse({ error: 'service_unavailable' }, 503, cors); } // Bind the code to the userId in Convex in the background so future // /pro?ref= signups can credit the sharer. FIRE-AND-FORGET // via ctx.waitUntil — the response doesn't wait, and a binding // failure (Convex outage, bad env, non-2xx, timeout) logs a warning // but never turns into a 503. See module docstring for the // rationale; an earlier blocking design caused homepage-wide // outages on every flake. The mutation is idempotent so the next // request retries. ctx.waitUntil( registerReferralCodeInConvex(session.userId, code).catch((err: unknown) => { // Narrow rather than cast — a future path that throws a // non-Error value must not turn this warning into "failed: // undefined". The helper today only throws Error instances, // so the instanceof branch is the common path. console.warn( '[api/referral/me] binding failed (non-blocking):', err instanceof Error ? err.message : String(err), ); }), ); // No invite/conversion count is returned on the response. The // waitlist path (userReferralCredits) now credits correctly, but // the Dodopayments checkout path (affonso_referral) still doesn't // flow into Convex. Counting only one of the two attribution // paths would mislead. Metrics will surface in a follow-up that // unifies both. return jsonResponse( { code, shareUrl: buildShareUrl(PUBLIC_BASE, code), }, 200, cors, ); }