File size: 18,026 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 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 | // @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;
}
|