@productbrain/mcp 0.0.1-beta.1152 → 0.0.1-beta.11522
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-IRW2QLD6.js +11836 -0
- package/dist/chunk-IRW2QLD6.js.map +1 -0
- package/dist/chunk-JMJEKQMP.js +1649 -0
- package/dist/chunk-JMJEKQMP.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +343 -308
- 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-NYJCHTXM.js} +23 -120
- package/dist/setup-NYJCHTXM.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 -4
- package/dist/chunk-WG7H45D3.js +0 -15293
- package/dist/chunk-WG7H45D3.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,21 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
PRODUCT_NAME,
|
|
3
3
|
SERVER_VERSION,
|
|
4
|
+
createProductBrainServer
|
|
5
|
+
} from "./chunk-IRW2QLD6.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-JMJEKQMP.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";
|
|
@@ -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,199 @@ function send429(res, built) {
|
|
|
218
163
|
res.status(built.status).json(built.body);
|
|
219
164
|
}
|
|
220
165
|
|
|
166
|
+
// src/authLockout.ts
|
|
167
|
+
var AUTH_FAILURE_MAX = 10;
|
|
168
|
+
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
169
|
+
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
170
|
+
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
171
|
+
function rejected(presented) {
|
|
172
|
+
return { status: "rejected", credentialId: hashKey(presented) };
|
|
173
|
+
}
|
|
174
|
+
function resolveBearerAuth(authorizationHeader) {
|
|
175
|
+
if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
|
|
176
|
+
return { status: "absent" };
|
|
177
|
+
}
|
|
178
|
+
if (!authorizationHeader.startsWith("Bearer ")) {
|
|
179
|
+
return rejected(authorizationHeader.trim());
|
|
180
|
+
}
|
|
181
|
+
const token = authorizationHeader.slice(7).trim();
|
|
182
|
+
if (!token.startsWith("pb_sk_")) return rejected(token);
|
|
183
|
+
return { status: "ok", apiKey: token, credentialId: hashKey(token) };
|
|
184
|
+
}
|
|
185
|
+
function resolvePastedKey(apiKey) {
|
|
186
|
+
if (apiKey === "") return { status: "absent" };
|
|
187
|
+
if (!apiKey.startsWith("pb_sk_")) return rejected(apiKey);
|
|
188
|
+
return { status: "ok", apiKey, credentialId: hashKey(apiKey) };
|
|
189
|
+
}
|
|
190
|
+
function isCrossSiteFormPost(origin, secFetchSite, ownOrigin) {
|
|
191
|
+
if (secFetchSite === "cross-site") return true;
|
|
192
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
193
|
+
try {
|
|
194
|
+
return new URL(origin).origin !== new URL(ownOrigin).origin;
|
|
195
|
+
} catch {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function checkAuthBlock(failures, credentialId, now) {
|
|
200
|
+
const rec = failures.get(credentialId);
|
|
201
|
+
if (!rec) return false;
|
|
202
|
+
return rec.blockedUntil > now;
|
|
203
|
+
}
|
|
204
|
+
function authLockoutError(failures, auth, now, id = null) {
|
|
205
|
+
if (auth.status === "absent") return null;
|
|
206
|
+
const rec = failures.get(auth.credentialId);
|
|
207
|
+
if (!rec || !checkAuthBlock(failures, auth.credentialId, now)) return null;
|
|
208
|
+
const retryAfterSeconds = Math.max(0, Math.ceil((rec.blockedUntil - now) / 1e3));
|
|
209
|
+
return buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec.count, id });
|
|
210
|
+
}
|
|
211
|
+
function recordAuthFailure(failures, credentialId, now) {
|
|
212
|
+
const rec = failures.get(credentialId);
|
|
213
|
+
if (!rec) {
|
|
214
|
+
if (failures.size >= MAX_AUTH_FAILURE_ENTRIES) {
|
|
215
|
+
const oldest = failures.keys().next().value;
|
|
216
|
+
if (oldest !== void 0) failures.delete(oldest);
|
|
217
|
+
}
|
|
218
|
+
failures.set(credentialId, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
222
|
+
rec.count = 1;
|
|
223
|
+
rec.firstFailure = now;
|
|
224
|
+
rec.blockedUntil = 0;
|
|
225
|
+
} else {
|
|
226
|
+
rec.count++;
|
|
227
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
228
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function keyVerdictGate(verdict, failures, credentialId, now) {
|
|
233
|
+
if (verdict === "ok" || verdict === "rate_limited") return "admit";
|
|
234
|
+
if (verdict === "rejected") {
|
|
235
|
+
recordAuthFailure(failures, credentialId, now);
|
|
236
|
+
return "unauthorized";
|
|
237
|
+
}
|
|
238
|
+
return "unavailable";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/lib/oauthRedirectAllowlist.ts
|
|
242
|
+
var DEFAULT_OAUTH_REDIRECT_URIS = [
|
|
243
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
244
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
245
|
+
"https://chatgpt.com/connector_platform_oauth_redirect",
|
|
246
|
+
"https://www.cursor.com/agents/mcp/oauth/callback"
|
|
247
|
+
];
|
|
248
|
+
var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
|
|
249
|
+
var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
250
|
+
var MAX_REDIRECT_URI_LENGTH = 2048;
|
|
251
|
+
var MAX_REGISTRATION_REDIRECTS = 10;
|
|
252
|
+
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)";
|
|
253
|
+
function exactForm(url) {
|
|
254
|
+
return `${url.origin}${url.pathname}`;
|
|
255
|
+
}
|
|
256
|
+
function parseRedirectUriAllowlist(raw) {
|
|
257
|
+
if (!raw) return { uris: [], configError: null };
|
|
258
|
+
const uris = [];
|
|
259
|
+
for (const part of raw.split(",")) {
|
|
260
|
+
const trimmed = part.trim();
|
|
261
|
+
if (!trimmed) continue;
|
|
262
|
+
let url = null;
|
|
263
|
+
try {
|
|
264
|
+
url = trimmed.includes("://") ? new URL(trimmed) : null;
|
|
265
|
+
} catch {
|
|
266
|
+
url = null;
|
|
267
|
+
}
|
|
268
|
+
const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
|
|
269
|
+
if (!valid || url === null) {
|
|
270
|
+
return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
|
|
271
|
+
}
|
|
272
|
+
uris.push(exactForm(url));
|
|
273
|
+
}
|
|
274
|
+
return { uris, configError: null };
|
|
275
|
+
}
|
|
276
|
+
function resolveOAuthRedirectAllowlist(opts = {}) {
|
|
277
|
+
const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
|
|
278
|
+
return {
|
|
279
|
+
uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
|
|
280
|
+
configError: parsed.configError
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function checkRedirectUri(redirectUri, allowlist) {
|
|
284
|
+
if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
|
|
285
|
+
if (typeof redirectUri !== "string" || redirectUri.length === 0) {
|
|
286
|
+
return "redirect_uri is required";
|
|
287
|
+
}
|
|
288
|
+
if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
|
|
289
|
+
return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
|
|
290
|
+
}
|
|
291
|
+
let url;
|
|
292
|
+
try {
|
|
293
|
+
url = new URL(redirectUri);
|
|
294
|
+
} catch {
|
|
295
|
+
return "redirect_uri is not a valid absolute URL";
|
|
296
|
+
}
|
|
297
|
+
if (url.username || url.password) return "redirect_uri must not contain credentials";
|
|
298
|
+
if (url.hash) return "redirect_uri must not contain a fragment";
|
|
299
|
+
const host = url.hostname.toLowerCase();
|
|
300
|
+
if (url.protocol === "http:") {
|
|
301
|
+
return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
|
|
302
|
+
}
|
|
303
|
+
if (url.protocol === "https:") {
|
|
304
|
+
if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
|
|
305
|
+
if (url.search) return "redirect_uri must not contain a query";
|
|
306
|
+
const exact = exactForm(url);
|
|
307
|
+
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)";
|
|
308
|
+
}
|
|
309
|
+
return "redirect_uri scheme is not allowed (use https, or http on loopback)";
|
|
310
|
+
}
|
|
311
|
+
function checkRegistrationRedirects(redirectUris, allowlist) {
|
|
312
|
+
const refused = (refusal) => ({ refusal, allowed: [], dropped: [] });
|
|
313
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) return refused("redirect_uris is required");
|
|
314
|
+
if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
|
|
315
|
+
return refused(`redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`);
|
|
316
|
+
}
|
|
317
|
+
const allowed = [];
|
|
318
|
+
const dropped = [];
|
|
319
|
+
for (const uri of redirectUris) {
|
|
320
|
+
const reason = checkRedirectUri(uri, allowlist);
|
|
321
|
+
if (reason) dropped.push({ uri, reason });
|
|
322
|
+
else allowed.push(uri);
|
|
323
|
+
}
|
|
324
|
+
return { refusal: allowed.length === 0 ? dropped[0].reason : null, allowed, dropped };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// src/lib/oauthAuthCode.ts
|
|
328
|
+
function normalisePastedKey(raw) {
|
|
329
|
+
if (typeof raw !== "string") return "";
|
|
330
|
+
const trimmed = raw.trim();
|
|
331
|
+
const first = trimmed[0];
|
|
332
|
+
if (trimmed.length >= 2 && (first === '"' || first === "'" || first === "`") && trimmed.endsWith(first)) {
|
|
333
|
+
return trimmed.slice(1, -1).trim();
|
|
334
|
+
}
|
|
335
|
+
return trimmed;
|
|
336
|
+
}
|
|
337
|
+
function pendingCodeRefusal(pending, redirectUri, now) {
|
|
338
|
+
if (!pending) return "unknown_code";
|
|
339
|
+
if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
|
|
340
|
+
if (now > pending.expiresAt) return "expired";
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
function tokenResponse(apiKey) {
|
|
344
|
+
return {
|
|
345
|
+
access_token: apiKey,
|
|
346
|
+
token_type: "Bearer",
|
|
347
|
+
// 1 year: validity is enforced by Convex on every call, not by this clock.
|
|
348
|
+
expires_in: 365 * 24 * 3600
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
221
352
|
// src/http.ts
|
|
222
353
|
bootstrapHttp();
|
|
223
|
-
initAnalytics();
|
|
224
|
-
initFeatureFlags(getPostHogClient());
|
|
225
354
|
var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
|
|
355
|
+
function keyCheckCandidates() {
|
|
356
|
+
const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
|
|
357
|
+
return [primaryUrl, ...parseFallbackUrls(process.env.CONVEX_FALLBACK_URLS).map((u) => u.replace(/\/$/, ""))];
|
|
358
|
+
}
|
|
226
359
|
function baseUrl(req) {
|
|
227
360
|
const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
|
|
228
361
|
const host = req.headers.host ?? `localhost:${PORT}`;
|
|
@@ -232,6 +365,7 @@ var app = express();
|
|
|
232
365
|
app.set("trust proxy", 1);
|
|
233
366
|
app.use(express.json());
|
|
234
367
|
var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
|
|
368
|
+
var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
|
|
235
369
|
app.use((_req, res, next) => {
|
|
236
370
|
const origin = _req.headers.origin;
|
|
237
371
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
@@ -266,53 +400,46 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
|
266
400
|
token_endpoint: `${base}/oauth/token`,
|
|
267
401
|
registration_endpoint: `${base}/register`,
|
|
268
402
|
response_types_supported: ["code"],
|
|
269
|
-
grant_types_supported: ["authorization_code"
|
|
403
|
+
grant_types_supported: ["authorization_code"],
|
|
404
|
+
// TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
|
|
270
405
|
code_challenge_methods_supported: ["S256"],
|
|
271
406
|
token_endpoint_auth_methods_supported: ["none"],
|
|
272
407
|
scopes_supported: ["mcp:tools", "mcp:resources"]
|
|
273
408
|
});
|
|
274
409
|
});
|
|
410
|
+
function logOAuthDoor(door, status, reason, uris) {
|
|
411
|
+
const list = uris === void 0 ? "" : ` uris=${JSON.stringify(uris.map(String)).slice(0, 600)}`;
|
|
412
|
+
process.stderr.write(`[oauth] door=${door} status=${status} reason=${JSON.stringify(reason)}${list}
|
|
413
|
+
`);
|
|
414
|
+
}
|
|
275
415
|
var authLimiter = rateLimit({
|
|
276
416
|
windowMs: 6e4,
|
|
277
417
|
max: 20,
|
|
278
418
|
standardHeaders: true,
|
|
279
419
|
legacyHeaders: false,
|
|
280
|
-
|
|
420
|
+
handler: (req, res) => {
|
|
421
|
+
logOAuthDoor(`${req.method} /authorize`, 429, "rate_limited_per_ip");
|
|
422
|
+
res.status(429).json({ error: "Too many auth requests. Try again later." });
|
|
423
|
+
}
|
|
281
424
|
});
|
|
282
|
-
var registeredClients = /* @__PURE__ */ new Map();
|
|
283
|
-
var MAX_REGISTERED_CLIENTS = 500;
|
|
284
425
|
app.post(
|
|
285
426
|
"/register",
|
|
286
|
-
authLimiter,
|
|
287
427
|
express.json(),
|
|
288
428
|
(req, res) => {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
});
|
|
429
|
+
const { redirect_uris, client_name } = req.body ?? {};
|
|
430
|
+
const { refusal, allowed, dropped } = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
|
|
431
|
+
if (refusal) {
|
|
432
|
+
logOAuthDoor("POST /register", 400, refusal, Array.isArray(redirect_uris) ? redirect_uris : [redirect_uris]);
|
|
433
|
+
res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
|
|
294
434
|
return;
|
|
295
435
|
}
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
error: "invalid_client_metadata",
|
|
300
|
-
error_description: "redirect_uris is required"
|
|
301
|
-
});
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
const clientId = `pb_client_${randomUUID2()}`;
|
|
305
|
-
const client = {
|
|
306
|
-
client_id: clientId,
|
|
307
|
-
redirect_uris,
|
|
308
|
-
client_name,
|
|
309
|
-
registeredAt: Date.now()
|
|
310
|
-
};
|
|
311
|
-
registeredClients.set(clientId, client);
|
|
436
|
+
const clientId = `pb_client_${randomUUID()}`;
|
|
437
|
+
const droppedUris = dropped.length > 0 ? dropped.map((d) => d.uri) : void 0;
|
|
438
|
+
logOAuthDoor("POST /register", 201, droppedUris ? "dropped_redirects" : "registered", droppedUris);
|
|
312
439
|
res.status(201).json({
|
|
313
440
|
client_id: clientId,
|
|
314
441
|
client_name: client_name ?? "MCP Client",
|
|
315
|
-
redirect_uris,
|
|
442
|
+
redirect_uris: allowed,
|
|
316
443
|
grant_types: ["authorization_code"],
|
|
317
444
|
response_types: ["code"],
|
|
318
445
|
token_endpoint_auth_method: "none"
|
|
@@ -320,29 +447,14 @@ app.post(
|
|
|
320
447
|
}
|
|
321
448
|
);
|
|
322
449
|
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
450
|
setInterval(() => {
|
|
327
451
|
const now = Date.now();
|
|
328
452
|
for (const [code, auth] of pendingCodes) {
|
|
329
453
|
if (now > auth.expiresAt) pendingCodes.delete(code);
|
|
330
454
|
}
|
|
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) {
|
|
455
|
+
for (const [credentialId, rec] of authFailures) {
|
|
338
456
|
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]);
|
|
457
|
+
authFailures.delete(credentialId);
|
|
346
458
|
}
|
|
347
459
|
}
|
|
348
460
|
}, 6e4);
|
|
@@ -663,11 +775,7 @@ a.btn-primary.success-cta{color:var(--btn-fg);text-decoration:none}
|
|
|
663
775
|
<div class="stage">${bodyContent}</div>
|
|
664
776
|
</body></html>`;
|
|
665
777
|
}
|
|
666
|
-
|
|
667
|
-
const name = (clientName ?? "").trim();
|
|
668
|
-
if (!name) return "your assistant";
|
|
669
|
-
return name.length > 40 ? name.slice(0, 40) + "\u2026" : name;
|
|
670
|
-
}
|
|
778
|
+
var PROVIDER_NAME = "your assistant";
|
|
671
779
|
function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
672
780
|
return `
|
|
673
781
|
<div class="orb-wrap">
|
|
@@ -690,8 +798,8 @@ function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
|
690
798
|
<h1 class="ok-title">Product Brain is Live</h1>
|
|
691
799
|
<p class="ok-lead">Return to your assistant, then say</p>
|
|
692
800
|
<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
|
|
801
|
+
<button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy "${esc(PASTE_NOW)}" and return to ${esc(providerName)}">
|
|
802
|
+
<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
803
|
<span class="cmd-icon" aria-hidden="true">
|
|
696
804
|
<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
805
|
</span>
|
|
@@ -716,7 +824,7 @@ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
|
716
824
|
<p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
|
|
717
825
|
<div class="err-actions">
|
|
718
826
|
<a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>← Try again</a>
|
|
719
|
-
<a href="
|
|
827
|
+
<a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key →</a>
|
|
720
828
|
</div>`;
|
|
721
829
|
}
|
|
722
830
|
var cmdScript = `
|
|
@@ -735,7 +843,7 @@ var cmdScript = `
|
|
|
735
843
|
};
|
|
736
844
|
try{
|
|
737
845
|
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
738
|
-
navigator.clipboard.writeText(
|
|
846
|
+
navigator.clipboard.writeText(${JSON.stringify(PASTE_NOW)}).then(done,done);
|
|
739
847
|
}else{done()}
|
|
740
848
|
}catch(e){done()}
|
|
741
849
|
});
|
|
@@ -745,7 +853,7 @@ var cmdScript = `
|
|
|
745
853
|
`;
|
|
746
854
|
function authorizeFormPage(params) {
|
|
747
855
|
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = params;
|
|
748
|
-
const providerName =
|
|
856
|
+
const providerName = PROVIDER_NAME;
|
|
749
857
|
const body = `
|
|
750
858
|
<!-- \u2500\u2500\u2500 CONNECT \u2500\u2500\u2500 -->
|
|
751
859
|
<div class="panel" id="p-connect" data-state="connect">
|
|
@@ -764,7 +872,7 @@ function authorizeFormPage(params) {
|
|
|
764
872
|
<div class="hint" id="hint" hidden></div>
|
|
765
873
|
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
766
874
|
</form>
|
|
767
|
-
<div class="small-link"><a href="
|
|
875
|
+
<div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
768
876
|
</div>
|
|
769
877
|
|
|
770
878
|
<!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
|
|
@@ -845,7 +953,6 @@ ${cmdScript}
|
|
|
845
953
|
e.preventDefault();
|
|
846
954
|
var v=k.value.trim();
|
|
847
955
|
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
956
|
sb.disabled=true;bt.textContent='Verifying';
|
|
850
957
|
show(pVerify);
|
|
851
958
|
|
|
@@ -903,15 +1010,18 @@ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
|
|
|
903
1010
|
}
|
|
904
1011
|
app.get("/authorize", authLimiter, (req, res) => {
|
|
905
1012
|
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
|
|
906
|
-
const
|
|
907
|
-
|
|
1013
|
+
const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1014
|
+
if (refusal) {
|
|
1015
|
+
logOAuthDoor("GET /authorize", 400, refusal);
|
|
1016
|
+
res.status(400).json({ error: "invalid_request", error_description: refusal });
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
908
1019
|
res.type("html").send(authorizeFormPage({
|
|
909
1020
|
redirect_uri: String(redirect_uri ?? ""),
|
|
910
1021
|
code_challenge: String(code_challenge ?? ""),
|
|
911
1022
|
code_challenge_method: String(code_challenge_method ?? "S256"),
|
|
912
1023
|
state: String(state ?? ""),
|
|
913
|
-
client_id:
|
|
914
|
-
client_name: clientName
|
|
1024
|
+
client_id: String(client_id ?? "")
|
|
915
1025
|
}));
|
|
916
1026
|
});
|
|
917
1027
|
app.post(
|
|
@@ -919,7 +1029,8 @@ app.post(
|
|
|
919
1029
|
authLimiter,
|
|
920
1030
|
express.urlencoded({ extended: false }),
|
|
921
1031
|
async (req, res) => {
|
|
922
|
-
const {
|
|
1032
|
+
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
|
|
1033
|
+
const api_key = normalisePastedKey(req.body.api_key);
|
|
923
1034
|
const wantsJson = String(req.headers["accept"] ?? "").includes("application/json");
|
|
924
1035
|
const retryParams = new URLSearchParams({
|
|
925
1036
|
redirect_uri: redirect_uri ?? "",
|
|
@@ -930,94 +1041,62 @@ app.post(
|
|
|
930
1041
|
}).toString();
|
|
931
1042
|
const retryUrl = `/authorize?${retryParams}`;
|
|
932
1043
|
function sendError(title, trustedDetailHtml, status = 400) {
|
|
1044
|
+
logOAuthDoor("POST /authorize", status, title);
|
|
933
1045
|
if (wantsJson) {
|
|
934
1046
|
res.status(status).json({ ok: false, title, detail: trustedDetailHtml });
|
|
935
1047
|
} else {
|
|
936
1048
|
res.status(status).type("html").send(authorizeErrorPage(title, trustedDetailHtml, retryUrl));
|
|
937
1049
|
}
|
|
938
1050
|
}
|
|
939
|
-
if (
|
|
940
|
-
sendError(
|
|
941
|
-
"Invalid key format",
|
|
942
|
-
"API keys start with <code>pb_sk_</code>. Check your key and try again."
|
|
943
|
-
);
|
|
1051
|
+
if (isCrossSiteFormPost(req.headers["origin"], req.headers["sec-fetch-site"], baseUrl(req))) {
|
|
1052
|
+
sendError("Request refused", "This form only accepts keys sent from its own sign-in page. Open the connect link again.", 403);
|
|
944
1053
|
return;
|
|
945
1054
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1055
|
+
const nowTs = Date.now();
|
|
1056
|
+
const pasted = resolvePastedKey(api_key);
|
|
1057
|
+
const lockout = authLockoutError(authFailures, pasted, nowTs);
|
|
1058
|
+
if (lockout) {
|
|
1059
|
+
const retryAfter = lockout.headers["Retry-After"];
|
|
1060
|
+
res.setHeader("Retry-After", retryAfter);
|
|
1061
|
+
sendError("Too many attempts", `This key was not recognized too many times. Try again in ${retryAfter}s.`, 429);
|
|
951
1062
|
return;
|
|
952
1063
|
}
|
|
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
|
-
}
|
|
1064
|
+
if (pasted.status !== "ok") {
|
|
1065
|
+
if (pasted.status === "rejected") recordAuthFailure(authFailures, pasted.credentialId, nowTs);
|
|
1066
|
+
sendError("Invalid key format", "API keys start with <code>pb_sk_</code>. Check your key and try again.");
|
|
1067
|
+
return;
|
|
969
1068
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
res.status(400).json({
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1069
|
+
if (!client_id) {
|
|
1070
|
+
logOAuthDoor("POST /authorize", 400, "client_id is required");
|
|
1071
|
+
res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1075
|
+
if (redirectRefusal) {
|
|
1076
|
+
logOAuthDoor("POST /authorize", 400, redirectRefusal);
|
|
1077
|
+
res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
|
|
976
1078
|
return;
|
|
977
1079
|
}
|
|
978
1080
|
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 {
|
|
1081
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), api_key, 5e3);
|
|
1082
|
+
const gate = keyVerdictGate(check.verdict, authFailures, pasted.credentialId, nowTs);
|
|
1083
|
+
if (check.verdict === "ok") {
|
|
1084
|
+
if (check.workspaceName) workspaceName = check.workspaceName;
|
|
1085
|
+
getKeyState(api_key).deploymentUrl = check.deploymentUrl;
|
|
1086
|
+
} else if (check.verdict === "rate_limited") {
|
|
1087
|
+
sendError(
|
|
1088
|
+
"Rate limited",
|
|
1089
|
+
`This API key is valid but temporarily rate limited. Try again in ${check.retryAfterSeconds ?? 60}s.`,
|
|
1090
|
+
429
|
|
1091
|
+
);
|
|
1092
|
+
return;
|
|
1093
|
+
} else if (gate === "unauthorized") {
|
|
1094
|
+
sendError("Key not recognized", "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.", 401);
|
|
1095
|
+
return;
|
|
1096
|
+
} else {
|
|
1018
1097
|
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
1019
1098
|
}
|
|
1020
|
-
const code =
|
|
1099
|
+
const code = randomUUID();
|
|
1021
1100
|
pendingCodes.set(code, {
|
|
1022
1101
|
apiKey: api_key,
|
|
1023
1102
|
codeChallenge: code_challenge,
|
|
@@ -1028,7 +1107,8 @@ app.post(
|
|
|
1028
1107
|
url.searchParams.set("code", code);
|
|
1029
1108
|
if (state) url.searchParams.set("state", state);
|
|
1030
1109
|
const redirectUrl = url.toString();
|
|
1031
|
-
const providerName =
|
|
1110
|
+
const providerName = PROVIDER_NAME;
|
|
1111
|
+
logOAuthDoor("POST /authorize", 200, "code_issued");
|
|
1032
1112
|
if (wantsJson) {
|
|
1033
1113
|
res.json({ ok: true, workspaceName, redirectUrl, providerName });
|
|
1034
1114
|
} else {
|
|
@@ -1036,47 +1116,34 @@ app.post(
|
|
|
1036
1116
|
}
|
|
1037
1117
|
}
|
|
1038
1118
|
);
|
|
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
1119
|
app.post(
|
|
1050
1120
|
"/oauth/token",
|
|
1051
|
-
authLimiter,
|
|
1052
1121
|
express.urlencoded({ extended: false }),
|
|
1053
1122
|
express.json(),
|
|
1054
1123
|
(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
|
-
}
|
|
1124
|
+
const { grant_type, code, code_verifier, redirect_uri } = req.body ?? {};
|
|
1068
1125
|
if (grant_type !== "authorization_code") {
|
|
1126
|
+
logOAuthDoor("POST /oauth/token", 400, "unsupported_grant_type");
|
|
1069
1127
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
1070
1128
|
return;
|
|
1071
1129
|
}
|
|
1072
1130
|
const pending = pendingCodes.get(code);
|
|
1073
|
-
|
|
1131
|
+
const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
|
|
1132
|
+
if (codeRefusal === "expired") {
|
|
1133
|
+
pendingCodes.delete(code);
|
|
1134
|
+
logOAuthDoor("POST /oauth/token", 400, "expired");
|
|
1135
|
+
res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
if (codeRefusal || !pending) {
|
|
1139
|
+
logOAuthDoor("POST /oauth/token", 400, codeRefusal ?? "unknown_code");
|
|
1074
1140
|
res.status(400).json({ error: "invalid_grant" });
|
|
1075
1141
|
return;
|
|
1076
1142
|
}
|
|
1077
1143
|
const challenge = createHash("sha256").update(code_verifier ?? "").digest("base64url");
|
|
1078
1144
|
if (challenge !== pending.codeChallenge) {
|
|
1079
1145
|
pendingCodes.delete(code);
|
|
1146
|
+
logOAuthDoor("POST /oauth/token", 400, "pkce_failed");
|
|
1080
1147
|
res.status(400).json({
|
|
1081
1148
|
error: "invalid_grant",
|
|
1082
1149
|
error_description: "PKCE verification failed"
|
|
@@ -1084,7 +1151,8 @@ app.post(
|
|
|
1084
1151
|
return;
|
|
1085
1152
|
}
|
|
1086
1153
|
pendingCodes.delete(code);
|
|
1087
|
-
|
|
1154
|
+
logOAuthDoor("POST /oauth/token", 200, "token_issued");
|
|
1155
|
+
res.json(tokenResponse(pending.apiKey));
|
|
1088
1156
|
}
|
|
1089
1157
|
);
|
|
1090
1158
|
var mcpLimiter = rateLimit({
|
|
@@ -1105,33 +1173,6 @@ var mcpLimiter = rateLimit({
|
|
|
1105
1173
|
}
|
|
1106
1174
|
});
|
|
1107
1175
|
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
1176
|
app.get("/health", (_req, res) => {
|
|
1136
1177
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
1137
1178
|
});
|
|
@@ -1176,25 +1217,6 @@ function evictStaleSessions() {
|
|
|
1176
1217
|
}
|
|
1177
1218
|
}
|
|
1178
1219
|
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
1220
|
function send401(req, res) {
|
|
1199
1221
|
const base = baseUrl(req);
|
|
1200
1222
|
res.status(401).set(
|
|
@@ -1216,24 +1238,24 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
1216
1238
|
`);
|
|
1217
1239
|
}
|
|
1218
1240
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
send429(res,
|
|
1241
|
+
const nowTs = Date.now();
|
|
1242
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1243
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1244
|
+
if (lockout) {
|
|
1245
|
+
send429(res, lockout);
|
|
1224
1246
|
return;
|
|
1225
1247
|
}
|
|
1226
|
-
|
|
1227
|
-
if (!apiKey) {
|
|
1248
|
+
if (auth.status !== "ok") {
|
|
1228
1249
|
logRequest("POST", "auth_fail");
|
|
1229
|
-
recordAuthFailure(
|
|
1250
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1230
1251
|
send401(req, res);
|
|
1231
1252
|
return;
|
|
1232
1253
|
}
|
|
1254
|
+
const apiKey = auth.apiKey;
|
|
1233
1255
|
const sessionId = req.headers["mcp-session-id"];
|
|
1234
1256
|
const reqStart = Date.now();
|
|
1235
1257
|
try {
|
|
1236
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1258
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1237
1259
|
if (sessionId && sessions.has(sessionId)) {
|
|
1238
1260
|
const entry = sessions.get(sessionId);
|
|
1239
1261
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
@@ -1254,6 +1276,22 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1254
1276
|
entry.lastAccess = Date.now();
|
|
1255
1277
|
}
|
|
1256
1278
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1279
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), apiKey, 5e3);
|
|
1280
|
+
const gate = keyVerdictGate(check.verdict, authFailures, auth.credentialId, Date.now());
|
|
1281
|
+
if (gate === "unauthorized") {
|
|
1282
|
+
logRequest("POST", "auth_fail");
|
|
1283
|
+
send401(req, res);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (gate === "unavailable") {
|
|
1287
|
+
res.status(503).json({
|
|
1288
|
+
jsonrpc: "2.0",
|
|
1289
|
+
error: { code: -32e3, message: "Product Brain could not verify this API key right now. Retry shortly." },
|
|
1290
|
+
id: req.body?.id ?? null
|
|
1291
|
+
});
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
if (check.verdict === "ok" || check.verdict === "rate_limited") getKeyState(apiKey).deploymentUrl = check.deploymentUrl;
|
|
1257
1295
|
const keyH = hashKey(apiKey);
|
|
1258
1296
|
const plan = planKeyAdmission(
|
|
1259
1297
|
sessions,
|
|
@@ -1279,7 +1317,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1279
1317
|
}));
|
|
1280
1318
|
return;
|
|
1281
1319
|
}
|
|
1282
|
-
const sid =
|
|
1320
|
+
const sid = randomUUID();
|
|
1283
1321
|
let initialized = false;
|
|
1284
1322
|
const transport = new StreamableHTTPServerTransport({
|
|
1285
1323
|
sessionIdGenerator: () => sid,
|
|
@@ -1293,6 +1331,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1293
1331
|
sessions.set(sid, reserved);
|
|
1294
1332
|
transport.onclose = () => {
|
|
1295
1333
|
const s = transport.sessionId ?? sid;
|
|
1334
|
+
forgetStreamLifecycle(keyH, s);
|
|
1296
1335
|
if (sessions.has(s)) {
|
|
1297
1336
|
sessions.delete(s);
|
|
1298
1337
|
logSessionLifecycle("session_deleted", s, "onclose");
|
|
@@ -1353,20 +1392,20 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1353
1392
|
}
|
|
1354
1393
|
});
|
|
1355
1394
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
send429(res,
|
|
1395
|
+
const nowTs = Date.now();
|
|
1396
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1397
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1398
|
+
if (lockout) {
|
|
1399
|
+
send429(res, lockout);
|
|
1361
1400
|
return;
|
|
1362
1401
|
}
|
|
1363
|
-
|
|
1364
|
-
if (!apiKey) {
|
|
1402
|
+
if (auth.status !== "ok") {
|
|
1365
1403
|
logRequest("GET", "auth_fail");
|
|
1366
|
-
recordAuthFailure(
|
|
1404
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1367
1405
|
send401(req, res);
|
|
1368
1406
|
return;
|
|
1369
1407
|
}
|
|
1408
|
+
const apiKey = auth.apiKey;
|
|
1370
1409
|
const sessionId = req.headers["mcp-session-id"];
|
|
1371
1410
|
if (!sessionId) {
|
|
1372
1411
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1381,7 +1420,7 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1381
1420
|
return;
|
|
1382
1421
|
}
|
|
1383
1422
|
try {
|
|
1384
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1423
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1385
1424
|
const entry = sessions.get(sessionId);
|
|
1386
1425
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1387
1426
|
res.status(403).json({
|
|
@@ -1400,20 +1439,20 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1400
1439
|
}
|
|
1401
1440
|
});
|
|
1402
1441
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
1403
|
-
const
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
send429(res,
|
|
1442
|
+
const nowTs = Date.now();
|
|
1443
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1444
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1445
|
+
if (lockout) {
|
|
1446
|
+
send429(res, lockout);
|
|
1408
1447
|
return;
|
|
1409
1448
|
}
|
|
1410
|
-
|
|
1411
|
-
if (!apiKey) {
|
|
1449
|
+
if (auth.status !== "ok") {
|
|
1412
1450
|
logRequest("DELETE", "auth_fail");
|
|
1413
|
-
recordAuthFailure(
|
|
1451
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
1414
1452
|
send401(req, res);
|
|
1415
1453
|
return;
|
|
1416
1454
|
}
|
|
1455
|
+
const apiKey = auth.apiKey;
|
|
1417
1456
|
const sessionId = req.headers["mcp-session-id"];
|
|
1418
1457
|
if (!sessionId) {
|
|
1419
1458
|
res.status(400).send("Missing Mcp-Session-Id header");
|
|
@@ -1428,7 +1467,7 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
|
1428
1467
|
return;
|
|
1429
1468
|
}
|
|
1430
1469
|
try {
|
|
1431
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1470
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
1432
1471
|
const entry = sessions.get(sessionId);
|
|
1433
1472
|
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1434
1473
|
res.status(403).json({
|
|
@@ -1463,10 +1502,6 @@ async function gracefulShutdown() {
|
|
|
1463
1502
|
await entry.transport.close().catch(() => {
|
|
1464
1503
|
});
|
|
1465
1504
|
}
|
|
1466
|
-
try {
|
|
1467
|
-
await shutdownAnalytics();
|
|
1468
|
-
} catch {
|
|
1469
|
-
}
|
|
1470
1505
|
process.exit(0);
|
|
1471
1506
|
}
|
|
1472
1507
|
var LISTEN_HOST = "0.0.0.0";
|