File size: 6,689 Bytes
3464008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
/**
 * 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=<code> 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=<code> 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<void> {
  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<unknown>) => void },
): Promise<Response> {
  if (isDisallowedOrigin(req)) {
    return jsonResponse({ error: 'Origin not allowed' }, 403);
  }
  const cors = getCorsHeaders(req, 'GET, OPTIONS') as Record<string, string>;

  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=<code> 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,
  );
}