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