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