GenerAI / worldmonitor /server /_shared /premium-check.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
18 kB
// @ts-expect-error β€” JS module, no declaration file
import { validateApiKey } from '../../api/_api-key.js';
// @ts-expect-error β€” JS module, no declaration file
import { timingSafeIncludes } from '../../api/_crypto.js';
import { validateBearerToken } from '../auth-session';
import {
classifyBillingVerification,
getEntitlements,
isEntitlementBackendConfigured,
unverifiableEntitlementDenial,
type BillingVerificationDenial,
type BillingVerificationInput,
} from './entitlement-check';
import {
INTERNAL_MCP_VERIFIED_HEADER,
TRUSTED_USER_ID_HEADER,
getInternalMcpVerifiedNonce,
} from './mcp-internal-hmac';
import { validateUserApiKey } from './user-api-key';
export type PremiumCallerIdentity =
| { isPremium: true; userId: string; kind: 'internal-mcp'; quotaExempt: true }
| { isPremium: true; userId: string; kind: 'user-api-key' | 'bearer'; quotaExempt: false }
| { isPremium: true; userId: null; kind: 'enterprise'; quotaExempt: true }
| {
isPremium: false;
userId: null;
kind: null;
quotaExempt: false;
/**
* The billing-verification classification behind this denial, when the
* denial rests on something OTHER than a confirmed non-premium answer
* (#5622) β€” a lookup that failed, a renewal re-check in flight, or a
* provider-confirmed lapse. Absent for a genuine free/unauthenticated
* caller.
*
* The field is additive and optional on purpose: `isPremium: false` keeps
* its exact meaning ("do not grant premium"), so all ~25 existing callers
* β€” including every `isCallerPremium()` boolean consumer β€” are unaffected.
* A caller that wants the retryable posture opts in by reading this and
* rendering it via renderBillingVerificationDenial instead of a terminal 403.
*
* It carries the whole classification rather than a boolean because there
* are FOUR of these states, not one. An earlier version of this field was
* `verificationUnavailable?: true`, which silently dropped
* `renewal_verification_pending` / `renewal_verification_failed` β€” states
* convex/http.ts really does emit β€” back onto the terminal upsell, i.e. the
* exact #5600 failure mode this field exists to remove.
*/
billingDenial?: BillingVerificationDenial;
/**
* True when the denial rests on the ABSENCE of a usable credential (#5619)
* β€” nothing was presented, or what was presented did not validate β€” rather
* than on a verdict about an identified account's plan.
*
* Without it every denial looked the same, so `api/chat-analyst.ts` told a
* signed-out visitor to buy a Pro subscription. The fix for that caller is a
* session, not a purchase, and the client classifier has carried a
* `sign_in_required` verdict since #5608 that no 403 on this route could
* ever reach.
*
* Additive and optional for the same reason as `billingDenial` above:
* `isPremium: false` keeps its exact meaning, so every existing consumer β€”
* including all `isCallerPremium()` boolean callers β€” is unaffected. A
* caller opts in by rendering 401 instead of the Pro 403.
*
* Mutually exclusive with `billingDenial` by construction: a billing
* classification only exists once a userId was resolved and looked up.
*/
unauthenticated?: true;
};
/** The deny arm of the union, named so `{ ...DENIED, billingDenial }` stays in it. */
type DeniedIdentity = Extract<PremiumCallerIdentity, { isPremium: false }>;
/**
* Deny with no information about WHY β€” a confirmed non-premium caller.
*
* Frozen because this is now ONE shared object returned by reference from
* several deny arms, where the pre-#5622 code built a fresh literal at each
* site. A caller that stamped a field onto a returned identity would otherwise
* poison every subsequent denial in the isolate. `denyFor`'s
* `{ ...DENIED, billingDenial }` spread still produces a fresh mutable copy.
*/
const DENIED: DeniedIdentity = Object.freeze({
isPremium: false,
userId: null,
kind: null,
quotaExempt: false,
});
/**
* Deny because no usable credential arrived (#5619).
*
* Reached by exactly two paths: a bearer token that failed validation, and the
* fall-through at the end of the resolver β€” no bearer, plus whatever other
* credential was tried (an unknown `wm_` key, a spoofed internal-MCP marker, a
* rejected `X-WorldMonitor-Key`) having failed. Every one of those is a
* statement about the credential, never about a plan, so none of them may
* produce an upsell.
*
* Frozen for the same reason as `DENIED`.
*/
const UNAUTHENTICATED: DeniedIdentity = Object.freeze({
isPremium: false,
userId: null,
kind: null,
quotaExempt: false,
unauthenticated: true,
});
/**
* A deny-side entitlement answer, tagged with its billing classification when
* the row carries one.
*
* Same authorization outcome either way β€” nothing is granted. The tag only lets
* a caller choose retryable-vs-terminal wording.
*/
function denyFor(entitlements: BillingVerificationInput | null): DeniedIdentity {
// An absent row is a verdict about the account 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 a bare DENIED renders as the Pro upsell, selling
// subscribers the plan they already own because of OUR deploy defect. This is
// the same guard pro-entitlement.ts applies at the browser gates; every arm
// that reaches here has already resolved an identity, so the caller is a
// known user and this is never the anonymous path (#5619, precedent #5600).
if (!entitlements && !isEntitlementBackendConfigured()) {
return { ...DENIED, billingDenial: unverifiableEntitlementDenial() };
}
const billingDenial = classifyBillingVerification(entitlements);
return billingDenial ? { ...DENIED, billingDenial } : DENIED;
}
type RpcApiErrorLike = Error & {
statusCode: number;
body: string;
retryAfter?: number;
exposeMessage?: boolean;
};
type RpcApiErrorConstructor<T extends RpcApiErrorLike> =
new (statusCode: number, message: string, body: string) => T;
type PremiumRpcBillingApiError<T extends RpcApiErrorLike> = T & {
billingVerificationCode: BillingVerificationDenial['code'];
};
/**
* RPC billing denials have two transport shapes:
* - response-envelope RPCs use `ServiceError` for retryable verification
* states and `AuthError` for the provider-confirmed terminal lapse;
* - exception-style RPCs throw their generated service's own `ApiError`.
*
* Both put the stable billing code in `statusDetail`/`ApiError.body`. Confirmed
* free and unauthenticated callers have no billing denial and keep the
* handler's existing Pro-required rendering.
*/
export function getPremiumRpcBillingErrorType(
denial: BillingVerificationDenial,
): 'AuthError' | 'ServiceError' {
return denial.retryable ? 'ServiceError' : 'AuthError';
}
function createPremiumRpcBillingDenialError<T extends RpcApiErrorLike>(
identity: PremiumCallerIdentity,
ApiErrorConstructor: RpcApiErrorConstructor<T>,
): PremiumRpcBillingApiError<T> | null {
if (identity.isPremium || !identity.billingDenial) return null;
const denial = identity.billingDenial;
const error = new ApiErrorConstructor(
denial.status,
denial.message,
denial.code,
) as PremiumRpcBillingApiError<T>;
error.billingVerificationCode = denial.code;
if (denial.status === 503) {
error.retryAfter = denial.retryAfterSeconds;
error.exposeMessage = true;
}
return error;
}
/**
* Enforces a hard-denying premium RPC gate while preserving why verification
* failed. The generated constructor keeps `instanceof ApiError` service-local;
* the fallback message preserves each endpoint's existing `PRO`/`Pro` copy.
*/
export async function requirePremiumRpcAccess<T extends RpcApiErrorLike>(
request: Request,
ApiErrorConstructor: RpcApiErrorConstructor<T>,
fallbackMessage: string,
): Promise<void> {
const identity = await resolvePremiumCallerIdentity(request);
if (identity.isPremium) return;
const billingError = createPremiumRpcBillingDenialError(identity, ApiErrorConstructor);
if (billingError) throw billingError;
throw new ApiErrorConstructor(403, fallbackMessage, '');
}
/**
* Resolves premium status and the user-bound identity for spend controls.
*/
export async function resolvePremiumCallerIdentity(request: Request): Promise<PremiumCallerIdentity> {
// Internal-MCP context: trusted markers are set by the gateway AFTER an
// HMAC verification on `X-WM-MCP-Internal` succeeds. Inbound copies of
// these headers are stripped at the gateway entry (defense-in-depth) so
// a client cannot reach this branch by injecting them directly.
//
// The verified-marker value is a per-process-startup random nonce. We
// compare with timing-safe equality, not just `=== '1'`, so an attacker
// hitting a direct (non-gateway-routed) edge function with a spoofed
// marker fails closed β€” the gateway is the ONLY entity that knows the
// nonce, and only it produces the value.
//
// Defensive re-fetch of getEntitlements (cache-hot, ~free): catches any
// future code path where someone forgets to verify upstream, and any
// mid-request entitlement lapse (tier just dropped to 0). The gateway
// already entitlement-checks before propagating, so this is belt-and-
// suspenders β€” but cheap and worth it for a security-critical gate.
const verifiedMarker = request.headers.get(INTERNAL_MCP_VERIFIED_HEADER);
const trustedUserId = request.headers.get(TRUSTED_USER_ID_HEADER);
if (verifiedMarker && trustedUserId) {
const expectedNonce = getInternalMcpVerifiedNonce();
// Length-safe-then-byte-compare. JS strings cannot leak per-char timing
// the way C strcmp does, but we still avoid early-exit branches.
let diff = verifiedMarker.length ^ expectedNonce.length;
const len = Math.max(verifiedMarker.length, expectedNonce.length);
for (let i = 0; i < len; i++) {
const a = i < verifiedMarker.length ? verifiedMarker.charCodeAt(i) : 0;
const b = i < expectedNonce.length ? expectedNonce.charCodeAt(i) : 0;
diff |= a ^ b;
}
if (diff === 0) {
const ent = await getEntitlements(trustedUserId);
if (
ent &&
ent.features.tier >= 1 &&
// mcpAccess lands in U10. Until then the field is undefined for
// existing entitlement rows; treat undefined as false (fail-closed)
// so a misconfigured / pre-U10 row cannot grant premium semantics
// through the internal-MCP path.
(ent.features as { mcpAccess?: boolean }).mcpAccess === true
) {
return { isPremium: true, userId: trustedUserId, kind: 'internal-mcp', quotaExempt: true };
}
return denyFor(ent);
}
// Marker present but nonce mismatch: do NOT short-circuit. Fall
// through to the normal auth flow β€” an attacker spoofing the marker
// gets exactly the same auth surface as one without the marker, no
// information leak about the nonce.
}
// Browser tester keys β€” validateApiKey returns required:false for trusted origins
// even when a valid key is present, so we check the header directly first.
const wmKey =
request.headers.get('X-WorldMonitor-Key') ??
request.headers.get('X-Api-Key') ??
'';
// Set when the wm_-key lookup could not COMPLETE (below). Read only at the
// terminal fall-through, so a co-present bearer still wins if it resolves.
let userKeyLookupUnavailable = false;
if (wmKey) {
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS ?? '')
.split(',').map((k) => k.trim()).filter(Boolean);
if (await timingSafeIncludes(wmKey, validKeys)) {
return { isPremium: true, userId: null, kind: 'enterprise', quotaExempt: true };
}
// Check user-owned API keys (wm_ prefix) via Convex lookup.
// Key existence alone is not sufficient β€” verify the owner's entitlement.
// Transient validation outages throw UserApiKeyUnavailableError β€” do not
// treat them as invalid keys; fall through so a co-present bearer can still
// grant premium, and fail closed for the user-key path itself.
try {
const userKey = await validateUserApiKey(wmKey);
if (userKey) {
const ent = await getEntitlements(userKey.userId);
if (ent && ent.features.apiAccess === true) {
return { isPremium: true, userId: userKey.userId, kind: 'user-api-key', quotaExempt: false };
}
// Preserve main's billing-verification tag on confirmed denials (#5622).
return denyFor(ent);
}
} catch {
// Transient validation outage: do not grant premium, but fall through so
// a co-present bearer can still resolve. Matches UserApiKeyUnavailableError
// semantics from the negative-cache fix (#5384 / #5599).
//
// Remember it. Without this the fall-through below answers UNAUTHENTICATED
// β€” telling a machine client holding a perfectly good key that the key is
// bad and retrying is futile, which is exactly what user-api-key.ts says
// these outages must never become (#5619 follow-up).
userKeyLookupUnavailable = true;
}
}
const keyCheck = (await validateApiKey(request, {})) as { valid: boolean; required: boolean };
// Only treat as premium when an explicit API key was validated (required: true).
// Trusted-origin short-circuits (required: false) do NOT imply PRO entitlement.
if (keyCheck.valid && keyCheck.required) {
return { isPremium: true, userId: null, kind: 'enterprise', quotaExempt: true };
}
const authHeader = request.headers.get('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const session = await validateBearerToken(authHeader.slice(7));
// An invalid token is a confirmed answer about the CREDENTIAL, not a failed
// entitlement lookup β€” and not a statement about any plan either, so it
// denies as unauthenticated rather than as a free account (#5619).
//
// But `valid: false` alone does not mean the token was judged: a missing
// issuer domain or a failed JWKS fetch lands here too, and neither says
// anything about the credential. Only a CONFIRMED-bad token may be told
// that signing in again is the fix.
if (!session.valid) {
if (session.reason === 'unverifiable') {
return { ...DENIED, billingDenial: unverifiableEntitlementDenial() };
}
return UNAUTHENTICATED;
}
if (session.role === 'pro' && session.userId) {
return { isPremium: true, userId: session.userId, kind: 'bearer', quotaExempt: false };
}
// Clerk role isn't 'pro' β€” check Dodo entitlement tier as second signal.
// A Dodo subscriber (tier >= 1) is premium regardless of Clerk role.
if (session.userId) {
const ent = await getEntitlements(session.userId);
if (ent && ent.features.tier >= 1) {
return { isPremium: true, userId: session.userId, kind: 'bearer', quotaExempt: false };
}
return denyFor(ent);
}
}
// A wm_ key was presented and its lookup never completed. That is an outage,
// not a missing credential, so it takes the retryable contract rather than the
// 401 below β€” checked first because the credential WAS supplied.
if (userKeyLookupUnavailable) {
return { ...DENIED, billingDenial: unverifiableEntitlementDenial() };
}
// No credential resolved an identity: no bearer at all, a bearer that carried
// no subject, an unknown `wm_` key, a rejected `X-WorldMonitor-Key`, or a
// spoofed internal-MCP marker that fell through. Every arm that DID identify
// someone, or that failed for a reason other than the credential, has already
// returned above, so this is the credential denial (#5619).
return UNAUTHENTICATED;
}
/**
* Returns true when the caller has a valid API key OR a PRO bearer token.
* Used by handlers where the RPC endpoint is public but certain fields
* (e.g. framework/systemAppend) should only be honored for premium callers.
*
* DELIBERATELY LOSSY (#5622): a boolean cannot express "we could not verify".
* That is acceptable for this function's actual job β€” the majority of its ~25
* callers use it to decide whether to *enrich* a public response (honor
* `framework`, return populated vs empty arrays), where the worst case of a
* transient failure is a degraded payload rather than a wrong verdict about the
* user's plan.
*
* It is NOT acceptable for a caller that turns `false` into a terminal
* "Pro subscription required" 403 β€” that flattens a backend blip into a
* misleading upsell for a paying customer. Those callers must use
* `resolvePremiumCallerIdentity()` and render `identity.billingDenial` via
* `getBillingVerificationDenial` instead (see api/chat-analyst.ts). Threading
* the signal through this boolean would mean changing its return type and every
* caller, which is why the identity API carries it instead.
*
* Known remaining hard-deniers on this boolean, tracked in #5652: the RPC
* surfaces under server/worldmonitor/. They share this flattening, but NOT one
* response shape β€” the #5652 fix has to handle both:
* - an in-body `errorType: 'AuthError'` (only summarize-article.ts does this)
* - a thrown `ApiError(403, ...)`, which server/error-mapper.ts renders as a
* plain `{ message }` with no `errorType` at all (run-scenario.ts,
* trigger-simulation.ts, get-scenario-status.ts, route-intelligence.ts,
* shipping/v2/{list-webhooks,register-webhook}.ts)
* Neither envelope has an HTTP status of its own, so the fix is a different
* shape than the two edge routes and is deliberately not bundled here.
*/
export async function isCallerPremium(request: Request): Promise<boolean> {
return (await resolvePremiumCallerIdentity(request)).isPremium;
}