File size: 7,832 Bytes
3464008 | 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 | import {
dailyCounterKey,
PRO_DAILY_QUOTA_LIMIT,
PRO_DAILY_QUOTA_TTL_SECONDS,
} from '../../server/_shared/pro-mcp-token';
import type { PipelineFn, QuotaRejected, QuotaReserved } from './types';
// ---------------------------------------------------------------------------
// Daily quota helpers (Pro-only). INCR-first reservation runs synchronously
// on the critical path BEFORE tool dispatch β never inside `waitUntil`.
// On pre-dispatch cap rejection we best-effort DECR. Once dispatch begins,
// callers keep the slot charged even if execution later errors or exceeds
// budget.
//
// The cap itself is plan-driven (plan 2026-07-25-001 U3): the caller passes the
// allowance resolved from the entitlement, and `PRO_DAILY_QUOTA_LIMIT` is the
// fallback for anyone who can't supply one.
// ---------------------------------------------------------------------------
/**
* Normalise a plan-resolved allowance into the value this module enforces.
*
* `null` (unlimited) passes through; a finite non-negative number is honoured
* verbatim β including `0`, which is a real "no allowance" and must not be
* mistaken for a missing one. EVERYTHING else β undefined, a legacy row with no
* `planLimits`, NaN/Infinity, a negative, a stringified number β resolves to
* `PRO_DAILY_QUOTA_LIMIT`. That direction is deliberate: an unreadable limit
* must never buy a caller a HIGHER cap than the plan default.
*
* Exported because the settings-UI reader (`api/user/mcp-quota.ts`) must DISPLAY
* exactly the limit this module ENFORCES. A second copy of this normalisation
* would be the drift the endpoint's whole reason for existing is to prevent.
*/
export function resolveDailyLimit(planDailyLimit?: number | null): number | null {
if (planDailyLimit === null) return null;
if (typeof planDailyLimit === 'number' && Number.isFinite(planDailyLimit) && planDailyLimit >= 0) {
return planDailyLimit;
}
return PRO_DAILY_QUOTA_LIMIT;
}
/**
* Plans whose catalog `mcpCallsPerDay` must NOT drive the daily cap on the
* pro (OAuth) MCP context. The KTD6 boundary is a PLAN boundary, not a
* credential boundary: API-tier subscribers can mint pro OAuth tokens too
* (tier>=1 + mcpAccess), and without this gate their catalog allowance
* (1000/10000) would leak through the OAuth door while their `user_key`
* stays hardcoded at 50. Raising API-tier MCP allowances is a deliberate
* follow-up; until then both credential classes must agree on the cap.
*/
const API_TIER_MCP_CAPPED_PLAN_KEYS = new Set([
'api_starter',
'api_starter_annual',
'api_business',
'api_business_annual',
]);
/**
* Gate a plan-resolved MCP allowance on plan family: API-tier plans report
* `undefined` (β the 50/day default via `resolveDailyLimit`); every other
* plan's allowance passes through verbatim β pro/pro_business plan-driven
* numbers, enterprise's `null` (unlimited), free's `0`.
*
* Shared by the enforcement path (`checkMcpEntitlementGate`) and the
* settings display (`api/user/mcp-quota.ts`) so the number a user reads is
* the number the reservation applies.
*/
export function resolvePlanDrivenMcpAllowance(
planKey: string | undefined,
mcpCallsPerDay: number | null | undefined,
): number | null | undefined {
if (planKey && API_TIER_MCP_CAPPED_PLAN_KEYS.has(planKey)) return undefined;
return mcpCallsPerDay;
}
export async function reserveQuota(
userId: string,
pipeline: PipelineFn,
planDailyLimit?: number | null,
): Promise<QuotaReserved | QuotaRejected> {
// `null` = unlimited: the counter still moves (metering is not optional) but
// the rejection branch below is skipped entirely.
const limit = resolveDailyLimit(planDailyLimit);
const key = dailyCounterKey(userId);
if (!key) return { ok: false, reason: 'redis-unavailable' };
let pipeResult: Array<{ result: unknown }> | null;
try {
pipeResult = await pipeline([
['INCR', key],
['EXPIRE', key, PRO_DAILY_QUOTA_TTL_SECONDS],
]);
} catch {
pipeResult = null;
}
if (!pipeResult || !Array.isArray(pipeResult) || pipeResult.length === 0) {
// Hard cap correctness: NEVER dispatch on reservation failure.
return { ok: false, reason: 'redis-unavailable' };
}
const incrRaw = pipeResult[0]?.result;
const newCount = typeof incrRaw === 'number' ? incrRaw : Number(incrRaw);
if (!Number.isFinite(newCount) || newCount < 1) {
return { ok: false, reason: 'redis-unavailable' };
}
// Build idempotent rollback. `await rollback()` runs DECR once; subsequent
// calls are no-ops.
let rolledBack = false;
const rollback = async (): Promise<void> => {
if (rolledBack) return;
rolledBack = true;
try {
await pipeline([['DECR', key]]);
} catch {
// Best-effort: a transient Redis failure means the counter overshoots
// by 1, which is the cost-protection-correct direction.
}
};
if (limit !== null && newCount > limit) {
// Reject and roll back immediately so the floor stays at the limit
// (or wherever concurrent rollbacks land it).
await rollback();
// Counter-clamp (F4): if multiple DECR rollbacks have failed during
// a Redis hiccup, the counter can overshoot indefinitely (e.g. land
// at 2x the limit). Without clamping, every subsequent INCR for the
// rest of the UTC day yields >limit β the user is locked out until
// the 48h key TTL expires. The clamp target is the RESOLVED limit,
// not the plan default β clamping a 250/day caller down to 50 would
// hand them 200 free calls on the next Redis hiccup.
//
// After the rollback, peek at the post-DECR count via a single
// best-effort INCR-then-DECR pair β if it's STILL above the limit,
// we know the rollback didn't land. Force a defensive
// `SET key <limit> KEEPTTL` so the next legitimate INCR (next UTC
// day OR next request after the hiccup) starts at limit+1 β 429,
// not limit+N β 429-forever.
//
// Why use INCR-then-DECR instead of GET? Keeps the helper to the
// same pipeline contract (the tests' makePipelineMock supports
// INCR/DECR/EXPIRE only) and avoids adding a new verb. The probe
// costs one round-trip but only on the rejection path.
if (newCount > limit + 1) {
try {
const probe = await pipeline([['INCR', key], ['DECR', key]]);
const probeIncrRaw = probe?.[0]?.result;
const postRollbackCount = typeof probeIncrRaw === 'number' ? probeIncrRaw - 1 : Number.NaN;
if (Number.isFinite(postRollbackCount) && postRollbackCount > limit) {
// Rollback chain has overshot β force the counter back to the
// limit via SET KEEPTTL. This is fail-soft: a concurrent INCR
// immediately after this SET will land at limit+1 and 429
// normally, which is the desired behavior.
//
// Use DECR repeatedly as the pipeline-supported clamp (avoids
// adding a new verb to test mocks). DECR N times where N is
// the overshoot delta. Cap at 100 DECRs to bound the worst-
// case round-trip cost.
const overshoot = postRollbackCount - limit;
const decrs = Math.min(overshoot, 100);
const clamp = Array.from({ length: decrs }, () => ['DECR', key] as Array<string | number>);
// Best-effort: failure here is the cost-protection-correct
// direction (counter stays high β users 429, no DoS exposure).
await pipeline(clamp).catch(() => {});
}
} catch {
// Probe failed β leave counter as-is. Worst case the user 429s
// until UTC midnight; never under-cap, never DoS exposure.
}
}
return { ok: false, reason: 'cap-exceeded', floor: limit };
}
return { ok: true, newCount, rollback };
}
|