GenerAI / worldmonitor /server /_shared /pro-entitlement.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 5)
20f83d9 verified
Raw
History Blame Contribute Delete
3.22 kB
/**
* 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),
};
}