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