File size: 3,221 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
75
76
77
78
79
80
81
82
83
/**
 * Canonical entitlement decisions for standalone tier-1 JSON endpoints.
 *
 * Content-only Pro access has two equivalent signals:
 *   - Clerk session role === 'pro' (complimentary, tester, or legacy grants)
 *   - a resolved Convex entitlement with tier >= 1
 *
 * Notification-backed workflows deliberately require the second signal because
 * their configuration and relay delivery paths also require a Convex tier.
 */
import {
  getBillingVerificationDenial,
  getEntitlements,
  isEntitlementBackendConfigured,
  renderBillingVerificationDenial,
  unverifiableEntitlementDenial,
  type EntitlementCheckOptions,
} from './entitlement-check';

type ProEntitlementDecision =
  | { allowed: true }
  | { allowed: false; billingDenial: Response | null };

type EntitlementLoader = typeof getEntitlements;

export async function checkProEntitlement(
  userId: string,
  clerkRole: EntitlementCheckOptions['clerkRole'],
  corsHeaders: Record<string, string>,
  loadEntitlements: EntitlementLoader = getEntitlements,
): Promise<ProEntitlementDecision> {
  // Avoid turning a complimentary Clerk grant into a dependency on a Convex
  // row it does not have. This also avoids an unnecessary backend lookup for
  // role-only Pro.
  if (clerkRole === 'pro') return { allowed: true };

  return checkTierProEntitlement(userId, corsHeaders, loadEntitlements);
}

export async function checkTierProEntitlement(
  userId: string,
  corsHeaders: Record<string, string>,
  loadEntitlements: EntitlementLoader = getEntitlements,
): Promise<ProEntitlementDecision> {
  // Preserves the exact tier check these handlers already ran
  // inline (tier >= 1, no validUntil check) — this intentionally does NOT
  // match checkEntitlementDetailed, which additionally requires
  // `validUntil >= Date.now()`. Unifying that gap is a separate concern from
  // this PR's Clerk-role fix.
  const entitlements = await loadEntitlements(userId);
  if (entitlements && entitlements.features.tier >= 1) {
    return { allowed: true };
  }

  // An absent row is a verdict ("this account has no entitlement") only when a
  // lookup could actually run. With CONVEX_SITE_URL or the shared secret
  // missing, getEntitlements returns null before attempting one — for everyone,
  // paying customers included — and rendering that as `pro_required` sells the
  // plan they already own back to them because of OUR deploy defect (#5619).
  //
  // The null deliberately stays a null upstream: server/gateway.ts detects this
  // same state with isEntitlementBackendConfigured() and serves wm_-key traffic
  // fail-open, because 503ing a missing env var turns a config regression into
  // a fleet-wide API outage. That exception is for key-authenticated machine
  // traffic; a browser gate has no such trade-off to make, so it answers the
  // honest retryable contract instead.
  if (!entitlements && !isEntitlementBackendConfigured()) {
    return {
      allowed: false,
      billingDenial: renderBillingVerificationDenial(
        unverifiableEntitlementDenial(),
        corsHeaders,
        1,
      ),
    };
  }

  return {
    allowed: false,
    billingDenial: getBillingVerificationDenial(entitlements, corsHeaders, 1),
  };
}