File size: 2,897 Bytes
20f83d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * Deterministic referral code generation (Phase 9 / Todo #223).
 *
 * Signed-in Clerk users don't have a storage-backed referral code the
 * way the pre-signup `registrations` table does — Clerk userId is the
 * only stable identifier we have. Rather than provisioning a new
 * `userReferrals` table, we derive an 8-char code from
 * HMAC(secret, userId) so:
 *
 *   - the code is stable for the life of the Clerk account
 *   - the same user sees the same code across devices with no sync
 *   - nothing needs to be written on login
 *   - a guessed code can only fish for a user's invite count, not
 *     anything identifying — the reverse lookup is a Convex query
 *     keyed on email, not on the code itself
 *
 * Collision risk is negligible at our scale (8 hex chars = 4B slots)
 * but we reject the 3 codes that conflict with reserved keywords to
 * keep the landing-page share URLs clean.
 *
 * NOTE: this is a DIFFERENT code space than the `registrations` table's
 * referralCode column. Those are 6-char codes keyed to an email row
 * (pre-signup). Clerk codes are 8-char (to separate the namespaces at
 * a glance). The register-interest.js endpoint already accepts
 * `referredBy` as an arbitrary string, so the two code spaces merge
 * cleanly at attribution time.
 */

function hexHmac(secret: string, message: string): Promise<string> {
  return (async () => {
    const enc = new TextEncoder();
    const key = await crypto.subtle.importKey(
      'raw',
      enc.encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign'],
    );
    const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message));
    const bytes = new Uint8Array(sig);
    let hex = '';
    for (let i = 0; i < bytes.length; i++) hex += bytes[i]!.toString(16).padStart(2, '0');
    return hex;
  })();
}

export async function getReferralCodeForUser(
  userId: string,
  secret: string,
): Promise<string> {
  if (typeof userId !== 'string' || userId.length === 0) {
    throw new Error('invalid_user_id');
  }
  if (typeof secret !== 'string' || secret.length === 0) {
    throw new Error('missing_secret');
  }
  const hex = await hexHmac(secret, `referral:v1:${userId}`);
  // 8 hex chars = 32-bit namespace. Plenty for the lifetime of the
  // product; rotate the secret + migrate if we ever approach the
  // birthday-collision zone (~65k users).
  //
  // Note: the output alphabet is [0-9a-f] so there's no risk of the
  // code colliding with URL-path keywords like 'admin' / 'robots'
  // — those contain non-hex characters. (An earlier draft carried
  // a RESERVED_CODES guard for this case; it was dead code and was
  // removed after review.)
  return hex.slice(0, 8);
}

export function buildShareUrl(baseUrl: string, code: string): string {
  const trimmed = baseUrl.replace(/\/+$/, '');
  return `${trimmed}/pro?ref=${encodeURIComponent(code)}`;
}