@productbrain/mcp 0.0.1-beta.1256 → 0.0.1-beta.12571
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.mcp.example +1 -1
- package/README.md +67 -0
- package/dist/chunk-3JF3FE2K.js +1751 -0
- package/dist/chunk-3JF3FE2K.js.map +1 -0
- package/dist/chunk-QIYCSSVW.js +11969 -0
- package/dist/chunk-QIYCSSVW.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +388 -330
- package/dist/http.js.map +1 -1
- package/dist/index.js +5 -19
- package/dist/index.js.map +1 -1
- package/dist/setup-6GJWGT4B.js +204 -0
- package/dist/setup-6GJWGT4B.js.map +1 -0
- package/dist/views/src/entry-cards/index.html +85 -60
- package/dist/views/src/graph-constellation/index.html +72 -47
- package/package.json +8 -9
- package/dist/chunk-NVEL46AW.js +0 -15297
- package/dist/chunk-NVEL46AW.js.map +0 -1
- package/dist/chunk-YMF3IQ5E.js +0 -465
- package/dist/chunk-YMF3IQ5E.js.map +0 -1
- package/dist/setup-PC43F5AO.js +0 -297
- package/dist/setup-PC43F5AO.js.map +0 -1
package/dist/http.js
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
PRODUCT_NAME,
|
|
3
3
|
SERVER_VERSION,
|
|
4
|
+
createProductBrainServer
|
|
5
|
+
} from "./chunk-QIYCSSVW.js";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_CLOUD_URL,
|
|
8
|
+
PASTE_NOW,
|
|
9
|
+
apiKeysUrl,
|
|
4
10
|
bootstrapHttp,
|
|
5
|
-
|
|
11
|
+
checkKeyAgainstCandidates,
|
|
12
|
+
forgetStreamLifecycle,
|
|
6
13
|
getKeyState,
|
|
7
14
|
hashKey,
|
|
8
|
-
|
|
15
|
+
isApiKeyShaped,
|
|
16
|
+
parseFallbackUrls,
|
|
9
17
|
runWithAuth
|
|
10
|
-
} from "./chunk-
|
|
11
|
-
import {
|
|
12
|
-
getPostHogClient,
|
|
13
|
-
initAnalytics,
|
|
14
|
-
shutdownAnalytics
|
|
15
|
-
} from "./chunk-YMF3IQ5E.js";
|
|
18
|
+
} from "./chunk-3JF3FE2K.js";
|
|
16
19
|
|
|
17
20
|
// src/http.ts
|
|
18
|
-
import { createHash, randomUUID
|
|
21
|
+
import { createHash, randomUUID } from "crypto";
|
|
19
22
|
import express from "express";
|
|
20
23
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
21
24
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
22
|
-
import
|
|
25
|
+
import rateLimit2 from "express-rate-limit";
|
|
23
26
|
|
|
24
27
|
// src/brand/logo-markup.ts
|
|
25
28
|
var SIZE_CLASSES = {
|
|
@@ -50,67 +53,10 @@ function appLogoMarkup(opts = {}) {
|
|
|
50
53
|
const size = opts.size ?? "sm";
|
|
51
54
|
const showWordmark = opts.showWordmark ?? true;
|
|
52
55
|
const cls = ["pb-logo", SIZE_CLASSES[size], opts.className].filter(Boolean).join(" ");
|
|
53
|
-
const wordmark = showWordmark ? `<span class="pb-logo__name"
|
|
56
|
+
const wordmark = showWordmark ? `<span class="pb-logo__name">${PRODUCT_NAME}</span>` : "";
|
|
54
57
|
return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
// src/lib/refresh-token.ts
|
|
58
|
-
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
59
|
-
var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
|
|
60
|
-
var PREFIX = "pb_rt_";
|
|
61
|
-
var secret = (() => {
|
|
62
|
-
const fromEnv = process.env.MCP_REFRESH_SECRET;
|
|
63
|
-
if (fromEnv && fromEnv.length > 0) return Buffer.from(fromEnv, "utf8");
|
|
64
|
-
if (process.env.NODE_ENV === "production") {
|
|
65
|
-
console.warn(
|
|
66
|
-
"[HTTP] WARNING MCP_REFRESH_SECRET not set \u2014 refresh tokens will not survive restart"
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
return randomBytes(32);
|
|
70
|
-
})();
|
|
71
|
-
function sign(payloadB64) {
|
|
72
|
-
return createHmac("sha256", secret).update(payloadB64).digest();
|
|
73
|
-
}
|
|
74
|
-
function signRefreshToken(apiKey) {
|
|
75
|
-
const payload = {
|
|
76
|
-
k: apiKey,
|
|
77
|
-
i: Date.now(),
|
|
78
|
-
j: randomUUID()
|
|
79
|
-
};
|
|
80
|
-
const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
81
|
-
const sigB64 = sign(payloadB64).toString("base64url");
|
|
82
|
-
return `${PREFIX}${payloadB64}.${sigB64}`;
|
|
83
|
-
}
|
|
84
|
-
function verifyRefreshToken(token) {
|
|
85
|
-
if (typeof token !== "string" || !token.startsWith(PREFIX)) return null;
|
|
86
|
-
const body = token.slice(PREFIX.length);
|
|
87
|
-
const dot = body.indexOf(".");
|
|
88
|
-
if (dot <= 0 || dot === body.length - 1) return null;
|
|
89
|
-
const payloadB64 = body.slice(0, dot);
|
|
90
|
-
const sigB64 = body.slice(dot + 1);
|
|
91
|
-
let providedSig;
|
|
92
|
-
try {
|
|
93
|
-
providedSig = Buffer.from(sigB64, "base64url");
|
|
94
|
-
} catch {
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
const expectedSig = sign(payloadB64);
|
|
98
|
-
if (providedSig.length !== expectedSig.length) return null;
|
|
99
|
-
if (!timingSafeEqual(providedSig, expectedSig)) return null;
|
|
100
|
-
let payload;
|
|
101
|
-
try {
|
|
102
|
-
const json = Buffer.from(payloadB64, "base64url").toString("utf8");
|
|
103
|
-
payload = JSON.parse(json);
|
|
104
|
-
} catch {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
if (!payload || typeof payload.k !== "string" || typeof payload.i !== "number" || typeof payload.j !== "string") {
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
if (Date.now() - payload.i > REFRESH_TOKEN_TTL_MS) return null;
|
|
111
|
-
return { apiKey: payload.k };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
60
|
// src/sessionCap.ts
|
|
115
61
|
function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
|
|
116
62
|
if (cap < 1) {
|
|
@@ -218,11 +164,237 @@ function send429(res, built) {
|
|
|
218
164
|
res.status(built.status).json(built.body);
|
|
219
165
|
}
|
|
220
166
|
|
|
167
|
+
// src/mcpRateLimit.ts
|
|
168
|
+
import rateLimit from "express-rate-limit";
|
|
169
|
+
|
|
170
|
+
// src/authLockout.ts
|
|
171
|
+
var AUTH_FAILURE_MAX = 10;
|
|
172
|
+
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
173
|
+
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
174
|
+
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
175
|
+
function rejected(presented) {
|
|
176
|
+
return { status: "rejected", credentialId: hashKey(presented) };
|
|
177
|
+
}
|
|
178
|
+
function resolveBearerAuth(authorizationHeader) {
|
|
179
|
+
if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
|
|
180
|
+
return { status: "absent" };
|
|
181
|
+
}
|
|
182
|
+
if (!authorizationHeader.startsWith("Bearer ")) {
|
|
183
|
+
return rejected(authorizationHeader.trim());
|
|
184
|
+
}
|
|
185
|
+
const token = authorizationHeader.slice(7).trim();
|
|
186
|
+
if (!isApiKeyShaped(token)) return rejected(token);
|
|
187
|
+
return { status: "ok", apiKey: token, credentialId: hashKey(token) };
|
|
188
|
+
}
|
|
189
|
+
function resolvePastedKey(apiKey) {
|
|
190
|
+
if (apiKey === "") return { status: "absent" };
|
|
191
|
+
if (!isApiKeyShaped(apiKey)) return rejected(apiKey);
|
|
192
|
+
return { status: "ok", apiKey, credentialId: hashKey(apiKey) };
|
|
193
|
+
}
|
|
194
|
+
function isCrossSiteFormPost(origin, secFetchSite, ownOrigin) {
|
|
195
|
+
if (secFetchSite === "cross-site") return true;
|
|
196
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
197
|
+
try {
|
|
198
|
+
return new URL(origin).origin !== new URL(ownOrigin).origin;
|
|
199
|
+
} catch {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function checkAuthBlock(failures, credentialId, now) {
|
|
204
|
+
const rec = failures.get(credentialId);
|
|
205
|
+
if (!rec) return false;
|
|
206
|
+
return rec.blockedUntil > now;
|
|
207
|
+
}
|
|
208
|
+
function authLockoutError(failures, auth, now, id = null) {
|
|
209
|
+
if (auth.status === "absent") return null;
|
|
210
|
+
const rec = failures.get(auth.credentialId);
|
|
211
|
+
if (!rec || !checkAuthBlock(failures, auth.credentialId, now)) return null;
|
|
212
|
+
const retryAfterSeconds = Math.max(0, Math.ceil((rec.blockedUntil - now) / 1e3));
|
|
213
|
+
return buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec.count, id });
|
|
214
|
+
}
|
|
215
|
+
function recordAuthFailure(failures, credentialId, now) {
|
|
216
|
+
const rec = failures.get(credentialId);
|
|
217
|
+
if (!rec) {
|
|
218
|
+
if (failures.size >= MAX_AUTH_FAILURE_ENTRIES) {
|
|
219
|
+
const oldest = failures.keys().next().value;
|
|
220
|
+
if (oldest !== void 0) failures.delete(oldest);
|
|
221
|
+
}
|
|
222
|
+
failures.set(credentialId, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
226
|
+
rec.count = 1;
|
|
227
|
+
rec.firstFailure = now;
|
|
228
|
+
rec.blockedUntil = 0;
|
|
229
|
+
} else {
|
|
230
|
+
rec.count++;
|
|
231
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
232
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function keyVerdictGate(verdict, failures, credentialId, now) {
|
|
237
|
+
if (verdict === "ok" || verdict === "rate_limited") return "admit";
|
|
238
|
+
if (verdict === "rejected") {
|
|
239
|
+
recordAuthFailure(failures, credentialId, now);
|
|
240
|
+
return "unauthorized";
|
|
241
|
+
}
|
|
242
|
+
return "unavailable";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/mcpRateLimit.ts
|
|
246
|
+
var MCP_KEY_LIMIT = 120;
|
|
247
|
+
var MCP_IP_CEILING = 1200;
|
|
248
|
+
function mcpRateKey(req) {
|
|
249
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
250
|
+
return auth.status === "absent" ? `ip:${req.ip ?? ""}` : `key:${auth.credentialId}`;
|
|
251
|
+
}
|
|
252
|
+
function limiter(max, windowMs, keyGenerator) {
|
|
253
|
+
return rateLimit({
|
|
254
|
+
windowMs,
|
|
255
|
+
max,
|
|
256
|
+
standardHeaders: true,
|
|
257
|
+
legacyHeaders: false,
|
|
258
|
+
...keyGenerator ? { keyGenerator } : {},
|
|
259
|
+
handler: (req, res) => {
|
|
260
|
+
const r = req.rateLimit;
|
|
261
|
+
const reset = r?.resetTime?.getTime?.() ?? Date.now() + windowMs;
|
|
262
|
+
send429(res, buildRateLimitError({
|
|
263
|
+
retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
|
|
264
|
+
limit: r?.limit ?? max,
|
|
265
|
+
current: r?.used ?? 0,
|
|
266
|
+
id: req.body?.id ?? null
|
|
267
|
+
// mirror the JSON-RPC id when a POST body is present
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function mcpLimiters(opts = {}) {
|
|
273
|
+
const windowMs = opts.windowMs ?? 6e4;
|
|
274
|
+
return [
|
|
275
|
+
limiter(opts.ipCeiling ?? MCP_IP_CEILING, windowMs),
|
|
276
|
+
limiter(opts.keyLimit ?? MCP_KEY_LIMIT, windowMs, mcpRateKey)
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/lib/oauthRedirectAllowlist.ts
|
|
281
|
+
var DEFAULT_OAUTH_REDIRECT_URIS = [
|
|
282
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
283
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
284
|
+
"https://chatgpt.com/connector_platform_oauth_redirect",
|
|
285
|
+
"https://www.cursor.com/agents/mcp/oauth/callback"
|
|
286
|
+
];
|
|
287
|
+
var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
|
|
288
|
+
var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
289
|
+
var MAX_REDIRECT_URI_LENGTH = 2048;
|
|
290
|
+
var MAX_REGISTRATION_REDIRECTS = 10;
|
|
291
|
+
var OPERATOR_ENTRY_FORM = "OAUTH_REDIRECT_HOST_ALLOWLIST entries must be full https redirect URIs such as https://partner.example/oauth/callback (no bare host, no query, no fragment)";
|
|
292
|
+
function exactForm(url) {
|
|
293
|
+
return `${url.origin}${url.pathname}`;
|
|
294
|
+
}
|
|
295
|
+
function parseRedirectUriAllowlist(raw) {
|
|
296
|
+
if (!raw) return { uris: [], configError: null };
|
|
297
|
+
const uris = [];
|
|
298
|
+
for (const part of raw.split(",")) {
|
|
299
|
+
const trimmed = part.trim();
|
|
300
|
+
if (!trimmed) continue;
|
|
301
|
+
let url = null;
|
|
302
|
+
try {
|
|
303
|
+
url = trimmed.includes("://") ? new URL(trimmed) : null;
|
|
304
|
+
} catch {
|
|
305
|
+
url = null;
|
|
306
|
+
}
|
|
307
|
+
const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
|
|
308
|
+
if (!valid || url === null) {
|
|
309
|
+
return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
|
|
310
|
+
}
|
|
311
|
+
uris.push(exactForm(url));
|
|
312
|
+
}
|
|
313
|
+
return { uris, configError: null };
|
|
314
|
+
}
|
|
315
|
+
function resolveOAuthRedirectAllowlist(opts = {}) {
|
|
316
|
+
const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
|
|
317
|
+
return {
|
|
318
|
+
uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
|
|
319
|
+
configError: parsed.configError
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function checkRedirectUri(redirectUri, allowlist) {
|
|
323
|
+
if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
|
|
324
|
+
if (typeof redirectUri !== "string" || redirectUri.length === 0) {
|
|
325
|
+
return "redirect_uri is required";
|
|
326
|
+
}
|
|
327
|
+
if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
|
|
328
|
+
return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
|
|
329
|
+
}
|
|
330
|
+
let url;
|
|
331
|
+
try {
|
|
332
|
+
url = new URL(redirectUri);
|
|
333
|
+
} catch {
|
|
334
|
+
return "redirect_uri is not a valid absolute URL";
|
|
335
|
+
}
|
|
336
|
+
if (url.username || url.password) return "redirect_uri must not contain credentials";
|
|
337
|
+
if (url.hash) return "redirect_uri must not contain a fragment";
|
|
338
|
+
const host = url.hostname.toLowerCase();
|
|
339
|
+
if (url.protocol === "http:") {
|
|
340
|
+
return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
|
|
341
|
+
}
|
|
342
|
+
if (url.protocol === "https:") {
|
|
343
|
+
if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
|
|
344
|
+
if (url.search) return "redirect_uri must not contain a query";
|
|
345
|
+
const exact = exactForm(url);
|
|
346
|
+
return allowlist.uris.includes(exact) || CHATGPT_CONNECTOR_CALLBACK.test(exact) ? null : "redirect_uri is not an allowed callback (an operator can add the full URI with OAUTH_REDIRECT_HOST_ALLOWLIST)";
|
|
347
|
+
}
|
|
348
|
+
return "redirect_uri scheme is not allowed (use https, or http on loopback)";
|
|
349
|
+
}
|
|
350
|
+
function checkRegistrationRedirects(redirectUris, allowlist) {
|
|
351
|
+
const refused = (refusal) => ({ refusal, allowed: [], dropped: [] });
|
|
352
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) return refused("redirect_uris is required");
|
|
353
|
+
if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
|
|
354
|
+
return refused(`redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`);
|
|
355
|
+
}
|
|
356
|
+
const allowed = [];
|
|
357
|
+
const dropped = [];
|
|
358
|
+
for (const uri of redirectUris) {
|
|
359
|
+
const reason = checkRedirectUri(uri, allowlist);
|
|
360
|
+
if (reason) dropped.push({ uri, reason });
|
|
361
|
+
else allowed.push(uri);
|
|
362
|
+
}
|
|
363
|
+
return { refusal: allowed.length === 0 ? dropped[0].reason : null, allowed, dropped };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/lib/oauthAuthCode.ts
|
|
367
|
+
function normalisePastedKey(raw) {
|
|
368
|
+
if (typeof raw !== "string") return "";
|
|
369
|
+
const trimmed = raw.trim();
|
|
370
|
+
const first = trimmed[0];
|
|
371
|
+
if (trimmed.length >= 2 && (first === '"' || first === "'" || first === "`") && trimmed.endsWith(first)) {
|
|
372
|
+
return trimmed.slice(1, -1).trim();
|
|
373
|
+
}
|
|
374
|
+
return trimmed;
|
|
375
|
+
}
|
|
376
|
+
function pendingCodeRefusal(pending, redirectUri, now) {
|
|
377
|
+
if (!pending) return "unknown_code";
|
|
378
|
+
if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
|
|
379
|
+
if (now > pending.expiresAt) return "expired";
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
function tokenResponse(apiKey) {
|
|
383
|
+
return {
|
|
384
|
+
access_token: apiKey,
|
|
385
|
+
token_type: "Bearer",
|
|
386
|
+
// 1 year: validity is enforced by Convex on every call, not by this clock.
|
|
387
|
+
expires_in: 365 * 24 * 3600
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
221
391
|
// src/http.ts
|
|
222
392
|
bootstrapHttp();
|
|
223
|
-
initAnalytics();
|
|
224
|
-
initFeatureFlags(getPostHogClient());
|
|
225
393
|
var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
|
|
394
|
+
function keyCheckCandidates() {
|
|
395
|
+
const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
|
|
396
|
+
return [primaryUrl, ...parseFallbackUrls(process.env.CONVEX_FALLBACK_URLS).map((u) => u.replace(/\/$/, ""))];
|
|
397
|
+
}
|
|
226
398
|
function baseUrl(req) {
|
|
227
399
|
const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
|
|
228
400
|
const host = req.headers.host ?? `localhost:${PORT}`;
|
|
@@ -232,6 +404,7 @@ var app = express();
|
|
|
232
404
|
app.set("trust proxy", 1);
|
|
233
405
|
app.use(express.json());
|
|
234
406
|
var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
|
|
407
|
+
var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
|
|
235
408
|
app.use((_req, res, next) => {
|
|
236
409
|
const origin = _req.headers.origin;
|
|
237
410
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
@@ -266,53 +439,46 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
|
266
439
|
token_endpoint: `${base}/oauth/token`,
|
|
267
440
|
registration_endpoint: `${base}/register`,
|
|
268
441
|
response_types_supported: ["code"],
|
|
269
|
-
grant_types_supported: ["authorization_code"
|
|
442
|
+
grant_types_supported: ["authorization_code"],
|
|
443
|
+
// TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
|
|
270
444
|
code_challenge_methods_supported: ["S256"],
|
|
271
445
|
token_endpoint_auth_methods_supported: ["none"],
|
|
272
446
|
scopes_supported: ["mcp:tools", "mcp:resources"]
|
|
273
447
|
});
|
|
274
448
|
});
|
|
275
|
-
|
|
449
|
+
function logOAuthDoor(door, status, reason, uris) {
|
|
450
|
+
const list = uris === void 0 ? "" : ` uris=${JSON.stringify(uris.map(String)).slice(0, 600)}`;
|
|
451
|
+
process.stderr.write(`[oauth] door=${door} status=${status} reason=${JSON.stringify(reason)}${list}
|
|
452
|
+
`);
|
|
453
|
+
}
|
|
454
|
+
var authLimiter = rateLimit2({
|
|
276
455
|
windowMs: 6e4,
|
|
277
456
|
max: 20,
|
|
278
457
|
standardHeaders: true,
|
|
279
458
|
legacyHeaders: false,
|
|
280
|
-
|
|
459
|
+
handler: (req, res) => {
|
|
460
|
+
logOAuthDoor(`${req.method} /authorize`, 429, "rate_limited_per_ip");
|
|
461
|
+
res.status(429).json({ error: "Too many auth requests. Try again later." });
|
|
462
|
+
}
|
|
281
463
|
});
|
|
282
|
-
var registeredClients = /* @__PURE__ */ new Map();
|
|
283
|
-
var MAX_REGISTERED_CLIENTS = 500;
|
|
284
464
|
app.post(
|
|
285
465
|
"/register",
|
|
286
|
-
authLimiter,
|
|
287
466
|
express.json(),
|
|
288
467
|
(req, res) => {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
});
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
const { redirect_uris, client_name } = req.body;
|
|
297
|
-
if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
|
|
298
|
-
res.status(400).json({
|
|
299
|
-
error: "invalid_client_metadata",
|
|
300
|
-
error_description: "redirect_uris is required"
|
|
301
|
-
});
|
|
468
|
+
const { redirect_uris, client_name } = req.body ?? {};
|
|
469
|
+
const { refusal, allowed, dropped } = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
|
|
470
|
+
if (refusal) {
|
|
471
|
+
logOAuthDoor("POST /register", 400, refusal, Array.isArray(redirect_uris) ? redirect_uris : [redirect_uris]);
|
|
472
|
+
res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
|
|
302
473
|
return;
|
|
303
474
|
}
|
|
304
|
-
const clientId = `pb_client_${
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
redirect_uris,
|
|
308
|
-
client_name,
|
|
309
|
-
registeredAt: Date.now()
|
|
310
|
-
};
|
|
311
|
-
registeredClients.set(clientId, client);
|
|
475
|
+
const clientId = `pb_client_${randomUUID()}`;
|
|
476
|
+
const droppedUris = dropped.length > 0 ? dropped.map((d) => d.uri) : void 0;
|
|
477
|
+
logOAuthDoor("POST /register", 201, droppedUris ? "dropped_redirects" : "registered", droppedUris);
|
|
312
478
|
res.status(201).json({
|
|
313
479
|
client_id: clientId,
|
|
314
480
|
client_name: client_name ?? "MCP Client",
|
|
315
|
-
redirect_uris,
|
|
481
|
+
redirect_uris: allowed,
|
|
316
482
|
grant_types: ["authorization_code"],
|
|
317
483
|
response_types: ["code"],
|
|
318
484
|
token_endpoint_auth_method: "none"
|
|
@@ -320,29 +486,14 @@ app.post(
|
|
|
320
486
|
}
|
|
321
487
|
);
|
|
322
488
|
var pendingCodes = /* @__PURE__ */ new Map();
|
|
323
|
-
var ACCESS_TOKEN_TTL = 3600;
|
|
324
|
-
var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
|
|
325
|
-
var accessTokens = /* @__PURE__ */ new Map();
|
|
326
489
|
setInterval(() => {
|
|
327
490
|
const now = Date.now();
|
|
328
491
|
for (const [code, auth] of pendingCodes) {
|
|
329
492
|
if (now > auth.expiresAt) pendingCodes.delete(code);
|
|
330
493
|
}
|
|
331
|
-
for (const [
|
|
332
|
-
if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
|
|
333
|
-
}
|
|
334
|
-
for (const [token, entry] of accessTokens) {
|
|
335
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
|
|
336
|
-
}
|
|
337
|
-
for (const [ip, rec] of authFailures) {
|
|
494
|
+
for (const [credentialId, rec] of authFailures) {
|
|
338
495
|
if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
|
|
339
|
-
authFailures.delete(
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
if (authFailures.size > MAX_AUTH_FAILURE_ENTRIES) {
|
|
343
|
-
const sorted = [...authFailures.entries()].sort((a, b) => a[1].firstFailure - b[1].firstFailure);
|
|
344
|
-
for (let i = 0; i < sorted.length - MAX_AUTH_FAILURE_ENTRIES; i++) {
|
|
345
|
-
authFailures.delete(sorted[i][0]);
|
|
496
|
+
authFailures.delete(credentialId);
|
|
346
497
|
}
|
|
347
498
|
}
|
|
348
499
|
}, 6e4);
|
|
@@ -663,11 +814,7 @@ a.btn-primary.success-cta{color:var(--btn-fg);text-decoration:none}
|
|
|
663
814
|
<div class="stage">${bodyContent}</div>
|
|
664
815
|
</body></html>`;
|
|
665
816
|
}
|
|
666
|
-
|
|
667
|
-
const name = (clientName ?? "").trim();
|
|
668
|
-
if (!name) return "your assistant";
|
|
669
|
-
return name.length > 40 ? name.slice(0, 40) + "\u2026" : name;
|
|
670
|
-
}
|
|
817
|
+
var PROVIDER_NAME = "your assistant";
|
|
671
818
|
function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
672
819
|
return `
|
|
673
820
|
<div class="orb-wrap">
|
|
@@ -690,8 +837,8 @@ function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
|
690
837
|
<h1 class="ok-title">Product Brain is Live</h1>
|
|
691
838
|
<p class="ok-lead">Return to your assistant, then say</p>
|
|
692
839
|
<div class="ok-phrase-row">
|
|
693
|
-
<button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy "
|
|
694
|
-
<span class="cmd-quote-part" aria-hidden="true">“</span><span data-cmd-text
|
|
840
|
+
<button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy "${esc(PASTE_NOW)}" and return to ${esc(providerName)}">
|
|
841
|
+
<span class="cmd-quote-part" aria-hidden="true">“</span><span data-cmd-text>${esc(PASTE_NOW)}</span><span class="cmd-quote-part" aria-hidden="true">”</span>
|
|
695
842
|
<span class="cmd-icon" aria-hidden="true">
|
|
696
843
|
<svg data-cmd-svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>
|
|
697
844
|
</span>
|
|
@@ -716,7 +863,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
|
716
863
|
<p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
|
|
717
864
|
<div class="err-actions">
|
|
718
865
|
<a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>← Try again</a>
|
|
719
|
-
<a href="
|
|
866
|
+
<a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key →</a>
|
|
720
867
|
</div>`;
|
|
721
868
|
}
|
|
722
869
|
var cmdScript = `
|
|
@@ -735,7 +882,7 @@ var cmdScript = `
|
|
|
735
882
|
};
|
|
736
883
|
try{
|
|
737
884
|
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
738
|
-
navigator.clipboard.writeText(
|
|
885
|
+
navigator.clipboard.writeText(${JSON.stringify(PASTE_NOW)}).then(done,done);
|
|
739
886
|
}else{done()}
|
|
740
887
|
}catch(e){done()}
|
|
741
888
|
});
|
|
@@ -745,7 +892,7 @@ var cmdScript = `
|
|
|
745
892
|
`;
|
|
746
893
|
function authorizeFormPage(params) {
|
|
747
894
|
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = params;
|
|
748
|
-
const providerName =
|
|
895
|
+
const providerName = PROVIDER_NAME;
|
|
749
896
|
const body = `
|
|
750
897
|
<!-- \u2500\u2500\u2500 CONNECT \u2500\u2500\u2500 -->
|
|
751
898
|
<div class="panel" id="p-connect" data-state="connect">
|
|
@@ -764,7 +911,7 @@ function authorizeFormPage(params) {
|
|
|
764
911
|
<div class="hint" id="hint" hidden></div>
|
|
765
912
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
766
913
|
</form>
|
|
767
|
-
<div class="small-link"><a href="
|
|
914
|
+
<div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
768
915
|
</div>
|
|
769
916
|
|
|
770
917
|
<!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
|
|
@@ -845,7 +992,6 @@ ${cmdScript}
|
|
|
845
992
|
e.preventDefault();
|
|
846
993
|
var v=k.value.trim();
|
|
847
994
|
if(!v){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Paste your key first';hint.removeAttribute('hidden');return}
|
|
848
|
-
if(v.indexOf('pb_sk_')!==0){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Key must start with pb_sk_';hint.removeAttribute('hidden');return}
|
|
849
995
|
sb.disabled=true;bt.textContent='Verifying';
|
|
850
996
|
show(pVerify);
|
|
851
997
|
|
|
@@ -903,15 +1049,18 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
|
|
|
903
1049
|
}
|
|
904
1050
|
app.get("/authorize", authLimiter, (req, res) => {
|
|
905
1051
|
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
|
|
906
|
-
const
|
|
907
|
-
|
|
1052
|
+
const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1053
|
+
if (refusal) {
|
|
1054
|
+
logOAuthDoor("GET /authorize", 400, refusal);
|
|
1055
|
+
res.status(400).json({ error: "invalid_request", error_description: refusal });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
908
1058
|
res.type("html").send(authorizeFormPage({
|
|
909
1059
|
redirect_uri: String(redirect_uri ?? ""),
|
|
910
1060
|
code_challenge: String(code_challenge ?? ""),
|
|
911
1061
|
code_challenge_method: String(code_challenge_method ?? "S256"),
|
|
912
1062
|
state: String(state ?? ""),
|
|
913
|
-
client_id:
|
|
914
|
-
client_name: clientName
|
|
1063
|
+
client_id: String(client_id ?? "")
|
|
915
1064
|
}));
|
|
916
1065
|
});
|
|
917
1066
|
app.post(
|
|
@@ -919,7 +1068,8 @@ app.post(
|
|
|
919
1068
|
authLimiter,
|
|
920
1069
|
express.urlencoded({ extended: false }),
|
|
921
1070
|
async (req, res) => {
|
|
922
|
-
const {
|
|
1071
|
+
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
|
|
1072
|
+
const api_key = normalisePastedKey(req.body.api_key);
|
|
923
1073
|
const wantsJson = String(req.headers["accept"] ?? "").includes("application/json");
|
|
924
1074
|
const retryParams = new URLSearchParams({
|
|
925
1075
|
redirect_uri: redirect_uri ?? "",
|
|
@@ -930,94 +1080,62 @@ app.post(
|
|
|
930
1080
|
}).toString();
|
|
931
1081
|
const retryUrl = `/authorize?${retryParams}`;
|
|
932
1082
|
function sendError(title, trustedDetailHtml, status = 400) {
|
|
1083
|
+
logOAuthDoor("POST /authorize", status, title);
|
|
933
1084
|
if (wantsJson) {
|
|
934
1085
|
res.status(status).json({ ok: false, title, detail: trustedDetailHtml });
|
|
935
1086
|
} else {
|
|
936
1087
|
res.status(status).type("html").send(authorizeErrorPage(title, trustedDetailHtml, retryUrl));
|
|
937
1088
|
}
|
|
938
1089
|
}
|
|
939
|
-
if (
|
|
940
|
-
sendError(
|
|
941
|
-
"Invalid key format",
|
|
942
|
-
"API keys start with <code>pb_sk_</code>. Check your key and try again."
|
|
943
|
-
);
|
|
1090
|
+
if (isCrossSiteFormPost(req.headers["origin"], req.headers["sec-fetch-site"], baseUrl(req))) {
|
|
1091
|
+
sendError("Request refused", "This form only accepts keys sent from its own sign-in page. Open the connect link again.", 403);
|
|
944
1092
|
return;
|
|
945
1093
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1094
|
+
const nowTs = Date.now();
|
|
1095
|
+
const pasted = resolvePastedKey(api_key);
|
|
1096
|
+
const lockout = authLockoutError(authFailures, pasted, nowTs);
|
|
1097
|
+
if (lockout) {
|
|
1098
|
+
const retryAfter = lockout.headers["Retry-After"];
|
|
1099
|
+
res.setHeader("Retry-After", retryAfter);
|
|
1100
|
+
sendError("Too many attempts", `This key was not recognized too many times. Try again in ${retryAfter}s.`, 429);
|
|
951
1101
|
return;
|
|
952
1102
|
}
|
|
953
|
-
if (
|
|
954
|
-
if (
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
redirect_uris: [redirect_uri],
|
|
958
|
-
registeredAt: Date.now()
|
|
959
|
-
});
|
|
960
|
-
process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
|
|
961
|
-
`);
|
|
962
|
-
} else {
|
|
963
|
-
res.status(400).json({
|
|
964
|
-
error: "invalid_request",
|
|
965
|
-
error_description: "Unknown client_id and redirect_uri is not a valid https URL"
|
|
966
|
-
});
|
|
967
|
-
return;
|
|
968
|
-
}
|
|
1103
|
+
if (pasted.status !== "ok") {
|
|
1104
|
+
if (pasted.status === "rejected") recordAuthFailure(authFailures, pasted.credentialId, nowTs);
|
|
1105
|
+
sendError("Invalid key format", "API keys start with <code>pb_sk_</code>. Check your key and try again.");
|
|
1106
|
+
return;
|
|
969
1107
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
res.status(400).json({
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1108
|
+
if (!client_id) {
|
|
1109
|
+
logOAuthDoor("POST /authorize", 400, "client_id is required");
|
|
1110
|
+
res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1114
|
+
if (redirectRefusal) {
|
|
1115
|
+
logOAuthDoor("POST /authorize", 400, redirectRefusal);
|
|
1116
|
+
res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
|
|
976
1117
|
return;
|
|
977
1118
|
}
|
|
978
1119
|
let workspaceName = "Your Workspace";
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
continue;
|
|
996
|
-
}
|
|
997
|
-
if (checkData.ok) {
|
|
998
|
-
if (checkData.workspaceName) workspaceName = checkData.workspaceName;
|
|
999
|
-
foundUrl = checkData.deploymentUrl ?? url2;
|
|
1000
|
-
break;
|
|
1001
|
-
}
|
|
1002
|
-
anyDefinitiveReject = true;
|
|
1003
|
-
}
|
|
1004
|
-
if (!foundUrl) {
|
|
1005
|
-
if (anyDefinitiveReject) {
|
|
1006
|
-
sendError(
|
|
1007
|
-
"Key not recognized",
|
|
1008
|
-
"This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.",
|
|
1009
|
-
401
|
|
1010
|
-
);
|
|
1011
|
-
return;
|
|
1012
|
-
}
|
|
1013
|
-
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
1014
|
-
} else {
|
|
1015
|
-
getKeyState(api_key).deploymentUrl = foundUrl;
|
|
1016
|
-
}
|
|
1017
|
-
} catch {
|
|
1120
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), api_key, 5e3);
|
|
1121
|
+
const gate = keyVerdictGate(check.verdict, authFailures, pasted.credentialId, nowTs);
|
|
1122
|
+
if (check.verdict === "ok") {
|
|
1123
|
+
if (check.workspaceName) workspaceName = check.workspaceName;
|
|
1124
|
+
getKeyState(api_key).deploymentUrl = check.deploymentUrl;
|
|
1125
|
+
} else if (check.verdict === "rate_limited") {
|
|
1126
|
+
sendError(
|
|
1127
|
+
"Rate limited",
|
|
1128
|
+
`This API key is valid but temporarily rate limited. Try again in ${check.retryAfterSeconds ?? 60}s.`,
|
|
1129
|
+
429
|
|
1130
|
+
);
|
|
1131
|
+
return;
|
|
1132
|
+
} else if (gate === "unauthorized") {
|
|
1133
|
+
sendError("Key not recognized", "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.", 401);
|
|
1134
|
+
return;
|
|
1135
|
+
} else {
|
|
1018
1136
|
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
1019
1137
|
}
|
|
1020
|
-
const code =
|
|
1138
|
+
const code = randomUUID();
|
|
1021
1139
|
pendingCodes.set(code, {
|
|
1022
1140
|
apiKey: api_key,
|
|
1023
1141
|
codeChallenge: code_challenge,
|
|
@@ -1028,7 +1146,8 @@ app.post(
|
|
|
1028
1146
|
url.searchParams.set("code", code);
|
|
1029
1147
|
if (state) url.searchParams.set("state", state);
|
|
1030
1148
|
const redirectUrl = url.toString();
|
|
1031
|
-
const providerName =
|
|
1149
|
+
const providerName = PROVIDER_NAME;
|
|
1150
|
+
logOAuthDoor("POST /authorize", 200, "code_issued");
|
|
1032
1151
|
if (wantsJson) {
|
|
1033
1152
|
res.json({ ok: true, workspaceName, redirectUrl, providerName });
|
|
1034
1153
|
} else {
|
|
@@ -1036,47 +1155,34 @@ app.post(
|
|
|
1036
1155
|
}
|
|
1037
1156
|
}
|
|
1038
1157
|
);
|
|
1039
|
-
function issueTokens(apiKey) {
|
|
1040
|
-
return {
|
|
1041
|
-
access_token: apiKey,
|
|
1042
|
-
token_type: "Bearer",
|
|
1043
|
-
// 1-year TTL: actual validity enforced by Convex, not by expiry clock.
|
|
1044
|
-
// Long TTL prevents unnecessary refresh cycles after restarts.
|
|
1045
|
-
expires_in: 365 * 24 * 3600,
|
|
1046
|
-
refresh_token: signRefreshToken(apiKey)
|
|
1047
|
-
};
|
|
1048
|
-
}
|
|
1049
1158
|
app.post(
|
|
1050
1159
|
"/oauth/token",
|
|
1051
|
-
authLimiter,
|
|
1052
1160
|
express.urlencoded({ extended: false }),
|
|
1053
1161
|
express.json(),
|
|
1054
1162
|
(req, res) => {
|
|
1055
|
-
const { grant_type, code, code_verifier, redirect_uri
|
|
1056
|
-
if (grant_type === "refresh_token") {
|
|
1057
|
-
const verified = verifyRefreshToken(refresh_token);
|
|
1058
|
-
if (!verified) {
|
|
1059
|
-
res.status(400).json({
|
|
1060
|
-
error: "invalid_grant",
|
|
1061
|
-
error_description: "Invalid or expired refresh token"
|
|
1062
|
-
});
|
|
1063
|
-
return;
|
|
1064
|
-
}
|
|
1065
|
-
res.json(issueTokens(verified.apiKey));
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1163
|
+
const { grant_type, code, code_verifier, redirect_uri } = req.body ?? {};
|
|
1068
1164
|
if (grant_type !== "authorization_code") {
|
|
1165
|
+
logOAuthDoor("POST /oauth/token", 400, "unsupported_grant_type");
|
|
1069
1166
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
1070
1167
|
return;
|
|
1071
1168
|
}
|
|
1072
1169
|
const pending = pendingCodes.get(code);
|
|
1073
|
-
|
|
1170
|
+
const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
|
|
1171
|
+
if (codeRefusal === "expired") {
|
|
1172
|
+
pendingCodes.delete(code);
|
|
1173
|
+
logOAuthDoor("POST /oauth/token", 400, "expired");
|
|
1174
|
+
res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if (codeRefusal || !pending) {
|
|
1178
|
+
logOAuthDoor("POST /oauth/token", 400, codeRefusal ?? "unknown_code");
|
|
1074
1179
|
res.status(400).json({ error: "invalid_grant" });
|
|
1075
1180
|
return;
|
|
1076
1181
|
}
|
|
1077
1182
|
const challenge = createHash("sha256").update(code_verifier ?? "").digest("base64url");
|
|
1078
1183
|
if (challenge !== pending.codeChallenge) {
|
|
1079
1184
|
pendingCodes.delete(code);
|
|
1185
|
+
logOAuthDoor("POST /oauth/token", 400, "pkce_failed");
|
|
1080
1186
|
res.status(400).json({
|
|
1081
1187
|
error: "invalid_grant",
|
|
1082
1188
|
error_description: "PKCE verification failed"
|
|
@@ -1084,54 +1190,12 @@ app.post(
|
|
|
1084
1190
|
return;
|
|
1085
1191
|
}
|
|
1086
1192
|
pendingCodes.delete(code);
|
|
1087
|
-
|
|
1193
|
+
logOAuthDoor("POST /oauth/token", 200, "token_issued");
|
|
1194
|
+
res.json(tokenResponse(pending.apiKey));
|
|
1088
1195
|
}
|
|
1089
1196
|
);
|
|
1090
|
-
var
|
|
1091
|
-
windowMs: 6e4,
|
|
1092
|
-
max: 120,
|
|
1093
|
-
standardHeaders: true,
|
|
1094
|
-
legacyHeaders: false,
|
|
1095
|
-
handler: (req, res) => {
|
|
1096
|
-
const r = req.rateLimit;
|
|
1097
|
-
const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
|
|
1098
|
-
send429(res, buildRateLimitError({
|
|
1099
|
-
retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
|
|
1100
|
-
limit: r?.limit ?? 120,
|
|
1101
|
-
current: r?.used ?? 0,
|
|
1102
|
-
id: req.body?.id ?? null
|
|
1103
|
-
// mirror the JSON-RPC id when a POST body is present
|
|
1104
|
-
}));
|
|
1105
|
-
}
|
|
1106
|
-
});
|
|
1197
|
+
var mcpLimit = mcpLimiters();
|
|
1107
1198
|
var authFailures = /* @__PURE__ */ new Map();
|
|
1108
|
-
var AUTH_FAILURE_MAX = 10;
|
|
1109
|
-
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
1110
|
-
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
1111
|
-
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
1112
|
-
function checkAuthBlock(ip) {
|
|
1113
|
-
const rec = authFailures.get(ip);
|
|
1114
|
-
if (!rec) return false;
|
|
1115
|
-
return rec.blockedUntil > Date.now();
|
|
1116
|
-
}
|
|
1117
|
-
function recordAuthFailure(ip) {
|
|
1118
|
-
const now = Date.now();
|
|
1119
|
-
const rec = authFailures.get(ip);
|
|
1120
|
-
if (!rec) {
|
|
1121
|
-
authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
1122
|
-
return;
|
|
1123
|
-
}
|
|
1124
|
-
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
1125
|
-
rec.count = 1;
|
|
1126
|
-
rec.firstFailure = now;
|
|
1127
|
-
rec.blockedUntil = 0;
|
|
1128
|
-
} else {
|
|
1129
|
-
rec.count++;
|
|
1130
|
-
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
1131
|
-
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
1199
|
app.get("/health", (_req, res) => {
|
|
1136
1200
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
1137
1201
|
});
|
|
@@ -1176,25 +1240,6 @@ function evictStaleSessions() {
|
|
|
1176
1240
|
}
|
|
1177
1241
|
}
|
|
1178
1242
|
setInterval(evictStaleSessions, 6e4);
|
|
1179
|
-
function extractBearerKey(req) {
|
|
1180
|
-
const header = req.headers?.authorization;
|
|
1181
|
-
if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
|
|
1182
|
-
const token = header.slice(7).trim();
|
|
1183
|
-
if (token.startsWith("pb_sk_")) {
|
|
1184
|
-
return token;
|
|
1185
|
-
}
|
|
1186
|
-
if (token.startsWith("pb_at_")) {
|
|
1187
|
-
const entry = accessTokens.get(token);
|
|
1188
|
-
if (!entry) return null;
|
|
1189
|
-
const now = Date.now();
|
|
1190
|
-
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
|
|
1191
|
-
accessTokens.delete(token);
|
|
1192
|
-
return null;
|
|
1193
|
-
}
|
|
1194
|
-
return entry.apiKey;
|
|
1195
|
-
}
|
|
1196
|
-
return null;
|
|
1197
|
-
}
|
|
1198
1243
|
function send401(req, res) {
|
|
1199
1244
|
const base = baseUrl(req);
|
|
1200
1245
|
res.status(401).set(
|
|
@@ -1215,25 +1260,25 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1215
1260
|
process.stderr.write(`[HTTP] ${ts} ${event} session=${sessionId}${r}
|
|
1216
1261
|
`);
|
|
1217
1262
|
}
|
|
1218
|
-
app.post("/mcp",
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
send429(res,
|
|
1263
|
+
app.post("/mcp", ...mcpLimit, async (req, res) => {
|
|
1264
|
+
const nowTs = Date.now();
|
|
1265
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1266
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1267
|
+
if (lockout) {
|
|
1268
|
+
send429(res, lockout);
|
|
1224
1269
|
return;
|
|
1225
1270
|
}
|
|
1226
|
-
|
|
1227
|
-
if (!apiKey) {
|
|
1271
|
+
if (auth.status !== "ok") {
|
|
1228
1272
|
logRequest("POST", "auth_fail");
|
|
1229
|
-
recordAuthFailure(
|
|
1273
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1230
1274
|
send401(req, res);
|
|
1231
1275
|
return;
|
|
1232
1276
|
}
|
|
1277
|
+
const apiKey = auth.apiKey;
|
|
1233
1278
|
const sessionId = req.headers["mcp-session-id"];
|
|
1234
1279
|
const reqStart = Date.now();
|
|
1235
1280
|
try {
|
|
1236
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1281
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1237
1282
|
if (sessionId && sessions.has(sessionId)) {
|
|
1238
1283
|
const entry = sessions.get(sessionId);
|
|
1239
1284
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
@@ -1254,6 +1299,22 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1254
1299
|
entry.lastAccess = Date.now();
|
|
1255
1300
|
}
|
|
1256
1301
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1302
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), apiKey, 5e3);
|
|
1303
|
+
const gate = keyVerdictGate(check.verdict, authFailures, auth.credentialId, Date.now());
|
|
1304
|
+
if (gate === "unauthorized") {
|
|
1305
|
+
logRequest("POST", "auth_fail");
|
|
1306
|
+
send401(req, res);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (gate === "unavailable") {
|
|
1310
|
+
res.status(503).json({
|
|
1311
|
+
jsonrpc: "2.0",
|
|
1312
|
+
error: { code: -32e3, message: "Product Brain could not verify this API key right now. Retry shortly." },
|
|
1313
|
+
id: req.body?.id ?? null
|
|
1314
|
+
});
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (check.verdict === "ok" || check.verdict === "rate_limited") getKeyState(apiKey).deploymentUrl = check.deploymentUrl;
|
|
1257
1318
|
const keyH = hashKey(apiKey);
|
|
1258
1319
|
const plan = planKeyAdmission(
|
|
1259
1320
|
sessions,
|
|
@@ -1279,7 +1340,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1279
1340
|
}));
|
|
1280
1341
|
return;
|
|
1281
1342
|
}
|
|
1282
|
-
const sid =
|
|
1343
|
+
const sid = randomUUID();
|
|
1283
1344
|
let initialized = false;
|
|
1284
1345
|
const transport = new StreamableHTTPServerTransport({
|
|
1285
1346
|
sessionIdGenerator: () => sid,
|
|
@@ -1293,6 +1354,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1293
1354
|
sessions.set(sid, reserved);
|
|
1294
1355
|
transport.onclose = () => {
|
|
1295
1356
|
const s = transport.sessionId ?? sid;
|
|
1357
|
+
forgetStreamLifecycle(keyH, s);
|
|
1296
1358
|
if (sessions.has(s)) {
|
|
1297
1359
|
sessions.delete(s);
|
|
1298
1360
|
logSessionLifecycle("session_deleted", s, "onclose");
|
|
@@ -1352,21 +1414,21 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1352
1414
|
}
|
|
1353
1415
|
}
|
|
1354
1416
|
});
|
|
1355
|
-
app.get("/mcp",
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
send429(res,
|
|
1417
|
+
app.get("/mcp", ...mcpLimit, async (req, res) => {
|
|
1418
|
+
const nowTs = Date.now();
|
|
1419
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1420
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1421
|
+
if (lockout) {
|
|
1422
|
+
send429(res, lockout);
|
|
1361
1423
|
return;
|
|
1362
1424
|
}
|
|
1363
|
-
|
|
1364
|
-
if (!apiKey) {
|
|
1425
|
+
if (auth.status !== "ok") {
|
|
1365
1426
|
logRequest("GET", "auth_fail");
|
|
1366
|
-
recordAuthFailure(
|
|
1427
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1367
1428
|
send401(req, res);
|
|
1368
1429
|
return;
|
|
1369
1430
|
}
|
|
1431
|
+
const apiKey = auth.apiKey;
|
|
1370
1432
|
const sessionId = req.headers["mcp-session-id"];
|
|
1371
1433
|
if (!sessionId) {
|
|
1372
1434
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1381,7 +1443,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1381
1443
|
return;
|
|
1382
1444
|
}
|
|
1383
1445
|
try {
|
|
1384
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1446
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1385
1447
|
const entry = sessions.get(sessionId);
|
|
1386
1448
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1387
1449
|
res.status(403).json({
|
|
@@ -1399,21 +1461,21 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1399
1461
|
logRequest("GET", "error", sessionId);
|
|
1400
1462
|
}
|
|
1401
1463
|
});
|
|
1402
|
-
app.delete("/mcp",
|
|
1403
|
-
const
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
send429(res,
|
|
1464
|
+
app.delete("/mcp", ...mcpLimit, async (req, res) => {
|
|
1465
|
+
const nowTs = Date.now();
|
|
1466
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1467
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1468
|
+
if (lockout) {
|
|
1469
|
+
send429(res, lockout);
|
|
1408
1470
|
return;
|
|
1409
1471
|
}
|
|
1410
|
-
|
|
1411
|
-
if (!apiKey) {
|
|
1472
|
+
if (auth.status !== "ok") {
|
|
1412
1473
|
logRequest("DELETE", "auth_fail");
|
|
1413
|
-
recordAuthFailure(
|
|
1474
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1414
1475
|
send401(req, res);
|
|
1415
1476
|
return;
|
|
1416
1477
|
}
|
|
1478
|
+
const apiKey = auth.apiKey;
|
|
1417
1479
|
const sessionId = req.headers["mcp-session-id"];
|
|
1418
1480
|
if (!sessionId) {
|
|
1419
1481
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1428,7 +1490,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1428
1490
|
return;
|
|
1429
1491
|
}
|
|
1430
1492
|
try {
|
|
1431
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1493
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1432
1494
|
const entry = sessions.get(sessionId);
|
|
1433
1495
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1434
1496
|
res.status(403).json({
|
|
@@ -1463,10 +1525,6 @@ async function gracefulShutdown() {
|
|
|
1463
1525
|
await entry.transport.close().catch(() => {
|
|
1464
1526
|
});
|
|
1465
1527
|
}
|
|
1466
|
-
try {
|
|
1467
|
-
await shutdownAnalytics();
|
|
1468
|
-
} catch {
|
|
1469
|
-
}
|
|
1470
1528
|
process.exit(0);
|
|
1471
1529
|
}
|
|
1472
1530
|
var LISTEN_HOST = "0.0.0.0";
|