GenerAI / worldmonitor /server /_shared /pro-mcp-gate.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
9.39 kB
/**
* 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 },
});
}