File size: 9,388 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
/**
 * The Pro-MCP access decision, shared by the five entitlement gates listed below.
 *
 * Five call sites previously re-implemented the same four-clause check
 * (`tier >= 1 && mcpAccess === true && validUntil >= now`, plus the null case):
 *
 *   - `api/internal/mcp-grant-context.ts` β€” renders the consent card
 *   - `api/internal/mcp-grant-mint.ts`    β€” mints the signed grant
 *   - `api/oauth/authorize-pro.ts`        β€” finishes authorization on the
 *                                          api subdomain
 *   - `api/mcp/auth.ts`                   β€” protects MCP-edge requests
 *   - `server/gateway.ts`                 β€” re-checks signed internal MCP calls
 *
 * The decision lives here so the OAuth handshake cannot authorize an account
 * that the MCP edge or gateway later rejects. Each caller keeps its own response
 * envelope and telemetry (#5622, #5653).
 *
 * What this module owns, precisely: the ACCESS decision, for all five. The
 * `ProMcpGateDenial` union is consumed as a rendered decision only by the three
 * grant-flow callers (via `proMcpGateDenialResponse`). `api/mcp/auth.ts` and
 * `server/gateway.ts` read the return value as pass/deny and render billing
 * denials through their own helpers β€” which bottom out in the same
 * `entitlement-check.ts::classifyBillingVerification`. That function, not this
 * one, is the single source for billing classification.
 *
 * SCOPE β€” this does not own every Pro-MCP check in the repo. Two sites still
 * spell the predicate out by hand and are deliberately NOT routed here:
 *
 *   - `server/_shared/premium-check.ts` (internal-MCP trusted-marker branch) β€”
 *     tier + mcpAccess only, WITHOUT the `validUntil` clause. Safe today because
 *     `server/gateway.ts` is the sole setter of the trusted markers that reach
 *     it and applies this gate β€” validUntil included β€” before minting them. It
 *     is a weaker second layer, not a mirror.
 *   - `convex/mcpProTokens.ts::issueProMcpToken` β€” all four clauses, kept inline
 *     because the Convex runtime does not import from `server/_shared`.
 *
 * Both are comment-enforced mirrors. Tighten the predicate below and you must
 * check those two by hand; "cannot drift" is a claim about the five above only.
 */

import {
  classifyBillingVerification,
  unverifiableEntitlementDenial,
  type BillingVerificationDenial,
  type BillingVerificationInput,
} from './entitlement-check';

/** The entitlement shape this gate reads. */
export type ProMcpEntitlement = {
  features: { tier: number; mcpAccess?: boolean };
  validUntil: number;
  /**
   * Some request-layer dependency types expose the marker as boolean even
   * though only literal true has billing semantics. False is normalized to
   * absence before classification below.
   */
  verificationUnavailable?: boolean;
} & Omit<BillingVerificationInput, 'verificationUnavailable'>;

export type ProMcpGateDenial =
  /**
   * The entitlement could not be verified, or a renewal re-check is in flight,
   * or the provider confirmed a lapse. `denial.retryable` distinguishes the
   * first two (retry) from the third (resubscribe) β€” callers must not flatten
   * them, that flattening is #5600.
   */
  | { kind: 'billing_verification'; denial: BillingVerificationDenial }
  /**
   * A confirmed answer that simply does not grant Pro MCP access: free tier, a
   * plan without mcpAccess, an expired validUntil, or a fail-closed null. This
   * is the honest upsell.
   */
  | { kind: 'insufficient_tier' };

/**
 * Returns null when the caller may proceed, else the reason.
 *
 * Ordering is load-bearing: an entitlement that currently grants Pro MCP access
 * is authorized even if it carries a renewal-verification marker for a stronger
 * plan, mirroring `checkEntitlementDetailed`'s tier-fallback. Classifying the
 * billing metadata first would 503 a user whose access is fine.
 */
export function checkProMcpAccess(
  entitlements: ProMcpEntitlement | null | undefined,
  now: number,
  opts?: { backendConfigured?: boolean },
): ProMcpGateDenial | null {
  if (
    entitlements &&
    entitlements.features &&
    entitlements.features.tier >= 1 &&
    entitlements.features.mcpAccess === true &&
    entitlements.validUntil >= now
  ) {
    return null;
  }

  // An absent row is a verdict only when a lookup could actually run. With the
  // entitlement backend unconfigured, getEntitlements returns null before
  // attempting one β€” for everyone β€” and INSUFFICIENT_TIER then tells a paying
  // subscriber to buy the plan they own, on the OAuth consent card that has no
  // client-side entitlement snapshot to contradict it (#5619 item 3).
  //
  // Passed in rather than read from the environment so this stays a pure
  // predicate: the gateway's internal-MCP re-check and this file's unit tests
  // keep their deterministic behavior, and a caller opts in by supplying it.
  // Omitting the option preserves the previous behavior exactly.
  if (!entitlements && opts?.backendConfigured === false) {
    return { kind: 'billing_verification', denial: unverifiableEntitlementDenial() };
  }

  // Spread, never a hand-copied field list: every member of
  // BillingVerificationInput must reach the classifier by construction. That
  // Pick has grown before (#5622 added two of its three members), and because
  // its members are all OPTIONAL a literal that forgets a future one stays
  // assignable β€” typecheck passes while the field is silently dropped and a
  // retryable state renders as terminal. `premium-check.ts` (see the
  // verificationUnavailable comment there) documents that exact regression
  // already shipping once as #5600.
  //
  // Only the marker is overridden: ProMcpEntitlement widens it to `boolean` for
  // request-layer dependency types, while BillingVerificationInput wants the
  // literal `true`. False normalizes to absence, matching the truthiness test
  // the classifier already applied. The annotation is load-bearing β€” it supplies
  // the contextual type that stops that `true` from widening back to `boolean`.
  // Spread members are exempt from excess-property checking, so the extra
  // `features` / `validUntil` riding along are fine.
  const billingInput: BillingVerificationInput | null | undefined = entitlements
    ? {
        ...entitlements,
        verificationUnavailable: entitlements.verificationUnavailable === true ? true : undefined,
      }
    : entitlements;
  const denial = classifyBillingVerification(billingInput);
  return denial ? { kind: 'billing_verification', denial } : { kind: 'insufficient_tier' };
}

// ---------------------------------------------------------------------------
// JSON rendering for the two `api/internal/mcp-grant-*` handshake endpoints
// ---------------------------------------------------------------------------

/**
 * The ONE new error code the grant handshake gained in #5622.
 *
 * Why only one, when the shared contract has three retryable states: the two
 * grant endpoints exist to keep the apex `/mcp-grant` SPA "on a single canonical
 * contract" (see each file's header), and inside an OAuth handshake the only
 * distinction the SPA can act on is retry-vs-don't. The precise reason still
 * travels, in `X-Billing-Verification` and `error_description`, for monitoring
 * and support β€” it just does not fork the SPA's control flow three ways.
 *
 * `INSUFFICIENT_TIER` deliberately keeps covering a provider-confirmed lapse: it
 * IS a confirmed insufficient tier, retrying cannot fix it, and every existing
 * SPA/consumer branch for that code stays correct. Only the header is added, so
 * a lapse is distinguishable from a plain free account in logs.
 */
export const GRANT_VERIFICATION_UNAVAILABLE_CODE = 'TIER_VERIFICATION_UNAVAILABLE';

const NO_STORE_JSON: Record<string, string> = {
  'Content-Type': 'application/json',
  'Cache-Control': 'no-store',
};

/**
 * Renders a gate denial in the grant handshake's `{error, error_description}`
 * vocabulary. Shared so `mcp-grant-mint.ts` and `mcp-grant-context.ts` cannot
 * answer the same entitlement state two different ways β€” the SPA branches on
 * `error`, so a divergence would show the user a different outcome depending on
 * whether they had clicked Authorize yet.
 */
export function proMcpGateDenialResponse(gate: ProMcpGateDenial): Response {
  if (gate.kind === 'insufficient_tier') {
    return jsonError('INSUFFICIENT_TIER', 'A WorldMonitor Pro subscription is required.', 403, {});
  }

  const { denial } = gate;
  if (!denial.retryable) {
    return jsonError(
      'INSUFFICIENT_TIER',
      'Your WorldMonitor Pro subscription is no longer active. Renew it, then start the connection again.',
      403,
      { 'X-Billing-Verification': denial.code },
    );
  }

  return jsonError(
    GRANT_VERIFICATION_UNAVAILABLE_CODE,
    `Your Pro subscription could not be verified just now (${denial.code}). `
    + 'This is temporary β€” retry in a moment.',
    503,
    {
      'X-Billing-Verification': denial.code,
      'Retry-After': String(denial.retryAfterSeconds),
    },
  );
}

function jsonError(
  error: string,
  error_description: string,
  status: number,
  extraHeaders: Record<string, string>,
): Response {
  return new Response(JSON.stringify({ error, error_description }), {
    status,
    headers: { ...NO_STORE_JSON, ...extraHeaders },
  });
}