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