/** * 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, loadEntitlements: EntitlementLoader = getEntitlements, ): Promise { // 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, loadEntitlements: EntitlementLoader = getEntitlements, ): Promise { // 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), }; }