@productbrain/mcp 0.0.1-beta.111 → 0.0.1-beta.11101

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/dist/http.js CHANGED
@@ -1,17 +1,23 @@
1
1
  import {
2
+ PRODUCT_NAME,
2
3
  SERVER_VERSION,
3
- createProductBrainServer
4
- } from "./chunk-O337N4MC.js";
4
+ createProductBrainServer,
5
+ initFeatureFlags
6
+ } from "./chunk-ZOZLMZ5V.js";
5
7
  import {
8
+ DEFAULT_CLOUD_URL,
9
+ apiKeysUrl,
6
10
  bootstrapHttp,
7
- initFeatureFlags,
8
- runWithAuth
9
- } from "./chunk-DHBJFEAT.js";
10
- import {
11
+ checkKeyAgainstCandidates,
12
+ forgetStreamLifecycle,
13
+ getKeyState,
11
14
  getPostHogClient,
15
+ hashKey,
12
16
  initAnalytics,
17
+ parseFallbackUrls,
18
+ runWithAuth,
13
19
  shutdownAnalytics
14
- } from "./chunk-RQXM3TCI.js";
20
+ } from "./chunk-JHI5IP57.js";
15
21
 
16
22
  // src/http.ts
17
23
  import { createHash, randomUUID } from "crypto";
@@ -19,10 +25,337 @@ import express from "express";
19
25
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
20
26
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
21
27
  import rateLimit from "express-rate-limit";
28
+
29
+ // src/brand/logo-markup.ts
30
+ var SIZE_CLASSES = {
31
+ sm: "pb-logo--sm",
32
+ md: "pb-logo--md"
33
+ };
34
+ var appLogoStyles = `
35
+ .pb-logo{display:inline-flex;align-items:center;gap:8px;color:inherit}
36
+ .pb-logo__mark{
37
+ border-radius:4px;background:#1c1e24;
38
+ border:1px solid rgba(255,255,255,0.06);
39
+ display:inline-grid;place-items:center;flex-shrink:0;
40
+ }
41
+ .pb-logo__core{border-radius:50%;background:var(--accent,#c9b99a)}
42
+ .pb-logo__name{
43
+ font-family:var(--font-mono,"IBM Plex Mono",ui-monospace,monospace);
44
+ font-weight:500;text-transform:uppercase;
45
+ letter-spacing:0.22em;color:var(--fg4,#6a6560);
46
+ }
47
+ .pb-logo--sm .pb-logo__mark{width:16px;height:16px}
48
+ .pb-logo--sm .pb-logo__core{width:5px;height:5px}
49
+ .pb-logo--sm .pb-logo__name{font-size:10.5px}
50
+ .pb-logo--md .pb-logo__mark{width:24px;height:24px;border-radius:6px}
51
+ .pb-logo--md .pb-logo__core{width:8px;height:8px}
52
+ .pb-logo--md .pb-logo__name{font-size:13px}
53
+ `;
54
+ function appLogoMarkup(opts = {}) {
55
+ const size = opts.size ?? "sm";
56
+ const showWordmark = opts.showWordmark ?? true;
57
+ const cls = ["pb-logo", SIZE_CLASSES[size], opts.className].filter(Boolean).join(" ");
58
+ const wordmark = showWordmark ? `<span class="pb-logo__name">${PRODUCT_NAME}</span>` : "";
59
+ return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
60
+ }
61
+
62
+ // src/sessionCap.ts
63
+ function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
64
+ if (cap < 1) {
65
+ throw new RangeError(`cap must be >= 1, got ${cap}`);
66
+ }
67
+ const { ttlMs, graceMs } = opts;
68
+ const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
69
+ const keyEntries = [];
70
+ for (const [id, session] of sessions2) {
71
+ if (session.keyHash === keyHash) {
72
+ keyEntries.push([id, session]);
73
+ }
74
+ }
75
+ const evictSet = /* @__PURE__ */ new Set();
76
+ for (const [id, session] of keyEntries) {
77
+ if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
78
+ evictSet.add(id);
79
+ }
80
+ }
81
+ let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
82
+ if (survivors.length >= cap) {
83
+ const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
84
+ for (const [id] of idleSurvivors) {
85
+ if (survivors.length < cap) break;
86
+ evictSet.add(id);
87
+ survivors = survivors.filter(([sid]) => sid !== id);
88
+ }
89
+ }
90
+ const evict = Array.from(evictSet);
91
+ const admit = survivors.length < cap;
92
+ let retryAfterSeconds = 0;
93
+ let retryIsPoll = false;
94
+ if (!admit) {
95
+ const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
96
+ if (graceBlockers.length > 0) {
97
+ const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
98
+ const msRemaining = graceMs - (now - minLastAccess);
99
+ retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
100
+ } else {
101
+ retryAfterSeconds = 5;
102
+ retryIsPoll = true;
103
+ }
104
+ }
105
+ return { evict, admit, retryAfterSeconds, retryIsPoll };
106
+ }
107
+ function countKeySessions(sessions2, keyHash) {
108
+ let count = 0;
109
+ for (const session of sessions2.values()) {
110
+ if (session.keyHash === keyHash) count++;
111
+ }
112
+ return count;
113
+ }
114
+
115
+ // src/httpErrors.ts
116
+ function clampSeconds(s) {
117
+ return Math.max(0, Math.round(s));
118
+ }
119
+ function build(reason, message, p) {
120
+ const seconds = clampSeconds(p.retryAfterSeconds);
121
+ return {
122
+ status: 429,
123
+ headers: { "Retry-After": String(seconds) },
124
+ body: {
125
+ jsonrpc: "2.0",
126
+ id: p.id ?? null,
127
+ error: {
128
+ code: -32e3,
129
+ message,
130
+ data: {
131
+ reason,
132
+ retryAfterSeconds: seconds,
133
+ limit: p.limit,
134
+ current: p.current
135
+ }
136
+ }
137
+ }
138
+ };
139
+ }
140
+ function buildRateLimitError(p) {
141
+ const seconds = clampSeconds(p.retryAfterSeconds);
142
+ return build(
143
+ "rate_limited",
144
+ `Too many requests \u2014 retry after ${seconds} s.`,
145
+ p
146
+ );
147
+ }
148
+ function buildAuthLockoutError(p) {
149
+ const seconds = clampSeconds(p.retryAfterSeconds);
150
+ return build(
151
+ "auth_lockout",
152
+ `Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
153
+ p
154
+ );
155
+ }
156
+ function buildSessionCapError(p) {
157
+ const seconds = clampSeconds(p.retryAfterSeconds);
158
+ const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
159
+ return build("session_cap", message, p);
160
+ }
161
+ function send429(res, built) {
162
+ if (res.headersSent) return;
163
+ for (const [k, v] of Object.entries(built.headers)) {
164
+ res.setHeader(k, v);
165
+ }
166
+ res.status(built.status).json(built.body);
167
+ }
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/lib/oauthRedirectAllowlist.ts
245
+ var DEFAULT_OAUTH_REDIRECT_URIS = [
246
+ "https://claude.ai/api/mcp/auth_callback",
247
+ "https://claude.com/api/mcp/auth_callback",
248
+ "https://chatgpt.com/connector_platform_oauth_redirect"
249
+ ];
250
+ var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
251
+ var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
252
+ var MAX_REDIRECT_URI_LENGTH = 2048;
253
+ var MAX_REGISTRATION_REDIRECTS = 10;
254
+ 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)";
255
+ function exactForm(url) {
256
+ return `${url.origin}${url.pathname}`;
257
+ }
258
+ function parseRedirectUriAllowlist(raw) {
259
+ if (!raw) return { uris: [], configError: null };
260
+ const uris = [];
261
+ for (const part of raw.split(",")) {
262
+ const trimmed = part.trim();
263
+ if (!trimmed) continue;
264
+ let url = null;
265
+ try {
266
+ url = trimmed.includes("://") ? new URL(trimmed) : null;
267
+ } catch {
268
+ url = null;
269
+ }
270
+ const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
271
+ if (!valid || url === null) {
272
+ return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
273
+ }
274
+ uris.push(exactForm(url));
275
+ }
276
+ return { uris, configError: null };
277
+ }
278
+ function resolveOAuthRedirectAllowlist(opts = {}) {
279
+ const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
280
+ return {
281
+ uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
282
+ configError: parsed.configError
283
+ };
284
+ }
285
+ function checkRedirectUri(redirectUri, allowlist) {
286
+ if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
287
+ if (typeof redirectUri !== "string" || redirectUri.length === 0) {
288
+ return "redirect_uri is required";
289
+ }
290
+ if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
291
+ return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
292
+ }
293
+ let url;
294
+ try {
295
+ url = new URL(redirectUri);
296
+ } catch {
297
+ return "redirect_uri is not a valid absolute URL";
298
+ }
299
+ if (url.username || url.password) return "redirect_uri must not contain credentials";
300
+ if (url.hash) return "redirect_uri must not contain a fragment";
301
+ const host = url.hostname.toLowerCase();
302
+ if (url.protocol === "http:") {
303
+ return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
304
+ }
305
+ if (url.protocol === "https:") {
306
+ if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
307
+ if (url.search) return "redirect_uri must not contain a query";
308
+ const exact = exactForm(url);
309
+ 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)";
310
+ }
311
+ return "redirect_uri scheme is not allowed (use https, or http on loopback)";
312
+ }
313
+ function checkRegistrationRedirects(redirectUris, allowlist) {
314
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) return "redirect_uris is required";
315
+ if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
316
+ return `redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`;
317
+ }
318
+ for (const uri of redirectUris) {
319
+ const reason = checkRedirectUri(uri, allowlist);
320
+ if (reason) return reason;
321
+ }
322
+ return null;
323
+ }
324
+
325
+ // src/lib/oauthAuthCode.ts
326
+ function normalisePastedKey(raw) {
327
+ if (typeof raw !== "string") return "";
328
+ const trimmed = raw.trim();
329
+ const first = trimmed[0];
330
+ if (trimmed.length >= 2 && (first === '"' || first === "'" || first === "`") && trimmed.endsWith(first)) {
331
+ return trimmed.slice(1, -1).trim();
332
+ }
333
+ return trimmed;
334
+ }
335
+ function pendingCodeRefusal(pending, redirectUri, now) {
336
+ if (!pending) return "unknown_code";
337
+ if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
338
+ if (now > pending.expiresAt) return "expired";
339
+ return null;
340
+ }
341
+ function tokenResponse(apiKey) {
342
+ return {
343
+ access_token: apiKey,
344
+ token_type: "Bearer",
345
+ // 1 year: validity is enforced by Convex on every call, not by this clock.
346
+ expires_in: 365 * 24 * 3600
347
+ };
348
+ }
349
+
350
+ // src/http.ts
22
351
  bootstrapHttp();
23
352
  initAnalytics();
24
353
  initFeatureFlags(getPostHogClient());
25
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
+ }
26
359
  function baseUrl(req) {
27
360
  const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
28
361
  const host = req.headers.host ?? `localhost:${PORT}`;
@@ -31,10 +364,11 @@ function baseUrl(req) {
31
364
  var app = express();
32
365
  app.set("trust proxy", 1);
33
366
  app.use(express.json());
34
- var ALLOWED_ORIGINS = process.env.CORS_ORIGINS?.split(",").map((o) => o.trim()).filter(Boolean);
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 });
35
369
  app.use((_req, res, next) => {
36
370
  const origin = _req.headers.origin;
37
- if (ALLOWED_ORIGINS && origin && ALLOWED_ORIGINS.includes(origin)) {
371
+ if (origin && ALLOWED_ORIGINS.includes(origin)) {
38
372
  res.setHeader("Access-Control-Allow-Origin", origin);
39
373
  }
40
374
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
@@ -42,7 +376,7 @@ app.use((_req, res, next) => {
42
376
  "Access-Control-Allow-Headers",
43
377
  "Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
44
378
  );
45
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
379
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
46
380
  if (_req.method === "OPTIONS") {
47
381
  res.status(204).end();
48
382
  return;
@@ -66,25 +400,40 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
66
400
  token_endpoint: `${base}/oauth/token`,
67
401
  registration_endpoint: `${base}/register`,
68
402
  response_types_supported: ["code"],
69
- grant_types_supported: ["authorization_code", "refresh_token"],
403
+ grant_types_supported: ["authorization_code"],
404
+ // TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
70
405
  code_challenge_methods_supported: ["S256"],
71
406
  token_endpoint_auth_methods_supported: ["none"],
72
407
  scopes_supported: ["mcp:tools", "mcp:resources"]
73
408
  });
74
409
  });
410
+ var authLimiter = rateLimit({
411
+ windowMs: 6e4,
412
+ max: 20,
413
+ standardHeaders: true,
414
+ legacyHeaders: false,
415
+ message: { error: "Too many auth requests. Try again later." }
416
+ });
75
417
  var registeredClients = /* @__PURE__ */ new Map();
418
+ var MAX_REGISTERED_CLIENTS = 500;
76
419
  app.post(
77
420
  "/register",
421
+ authLimiter,
78
422
  express.json(),
79
423
  (req, res) => {
80
- const { redirect_uris, client_name } = req.body;
81
- if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
82
- res.status(400).json({
83
- error: "invalid_client_metadata",
84
- error_description: "redirect_uris is required"
424
+ if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
425
+ res.status(503).json({
426
+ error: "server_error",
427
+ error_description: "Registration limit reached. Try again later."
85
428
  });
86
429
  return;
87
430
  }
431
+ const { redirect_uris, client_name } = req.body;
432
+ const refusal = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
433
+ if (refusal) {
434
+ res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
435
+ return;
436
+ }
88
437
  const clientId = `pb_client_${randomUUID()}`;
89
438
  const client = {
90
439
  client_id: clientId,
@@ -104,9 +453,6 @@ app.post(
104
453
  }
105
454
  );
106
455
  var pendingCodes = /* @__PURE__ */ new Map();
107
- var ACCESS_TOKEN_TTL = 3600;
108
- var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
109
- var refreshTokens = /* @__PURE__ */ new Map();
110
456
  setInterval(() => {
111
457
  const now = Date.now();
112
458
  for (const [code, auth] of pendingCodes) {
@@ -115,66 +461,670 @@ setInterval(() => {
115
461
  for (const [id, client] of registeredClients) {
116
462
  if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
117
463
  }
118
- for (const [token, entry] of refreshTokens) {
119
- if (now - entry.createdAt > REFRESH_TOKEN_TTL_MS) refreshTokens.delete(token);
464
+ for (const [credentialId, rec] of authFailures) {
465
+ if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
466
+ authFailures.delete(credentialId);
467
+ }
120
468
  }
121
469
  }, 6e4);
122
470
  function esc(s) {
123
471
  return String(s ?? "").replace(
124
- /[&"<>]/g,
125
- (c) => ({ "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" })[c]
472
+ /[&"'<>]/g,
473
+ (c) => ({ "&": "&amp;", '"': "&quot;", "'": "&#39;", "<": "&lt;", ">": "&gt;" })[c]
126
474
  );
127
475
  }
128
- app.get("/authorize", (req, res) => {
129
- const { redirect_uri, code_challenge, code_challenge_method, state } = req.query;
130
- res.type("html").send(`<!DOCTYPE html>
131
- <html lang="en"><head>
476
+ function authPageShell(title, bodyContent, headExtra = "") {
477
+ return `<!DOCTYPE html>
478
+ <html lang="en" data-theme="parchment-dark"><head>
132
479
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
133
- <title>Authorize \u2014 Product Brain</title>
480
+ <title>${esc(title)} \u2014 Product Brain</title>
481
+ <link rel="preconnect" href="https://fonts.googleapis.com">
482
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
483
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,600&family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;700&display=swap">
484
+ ${headExtra}
134
485
  <style>
135
- *{margin:0;padding:0;box-sizing:border-box}
136
- body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0a;color:#e5e5e5;
137
- display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1rem}
138
- .card{background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:2rem;max-width:400px;width:100%}
139
- h1{font-size:1.2rem;margin-bottom:.25rem}
140
- .sub{color:#999;font-size:.85rem;margin-bottom:1.5rem}
141
- label{display:block;font-size:.85rem;margin-bottom:.4rem;color:#ccc}
142
- input[type=password]{width:100%;padding:.6rem .75rem;background:#111;border:1px solid #444;
143
- border-radius:8px;color:#e5e5e5;font:.85rem/1.4 monospace}
144
- input:focus{outline:none;border-color:#7c3aed}
145
- button{width:100%;padding:.6rem;background:#7c3aed;color:#fff;border:none;
146
- border-radius:8px;font-size:.85rem;cursor:pointer;margin-top:1rem}
147
- button:hover{background:#6d28d9}
148
- .err{color:#ef4444;font-size:.8rem;margin-top:.5rem;display:none}
149
- </style></head><body>
150
- <div class="card">
151
- <h1>Product Brain</h1>
152
- <p class="sub">Enter your API key to connect Claude to your workspace.</p>
153
- <form method="POST" action="/authorize">
154
- <input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
155
- <input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
156
- <input type="hidden" name="code_challenge_method" value="${esc(code_challenge_method)}">
157
- <input type="hidden" name="state" value="${esc(state)}">
158
- <label for="k">API Key</label>
159
- <input type="password" id="k" name="api_key" placeholder="pb_sk_\u2026" required autofocus>
160
- <p class="err" id="e">Key must start with pb_sk_</p>
161
- <button type="submit">Authorize</button>
162
- </form>
486
+ :root{
487
+ --bg:#1a1917;--bg-warm:#201f1c;--surface:#262521;
488
+ --fg1:#e4e0d8;--fg2:#c4bfb4;--fg3:#9a9589;--fg4:#6a6560;--fg-bright:#ffffff;
489
+ --border:rgba(255,255,255,0.07);--border-light:rgba(255,255,255,0.04);
490
+ --accent:#c9b99a;
491
+ --btn-bg:#ffffff;--btn-fg:#1a1917;--btn-hover:#e4e0d8;
492
+ --green:#4ade80;--rose:#ef4444;
493
+ --ghost:rgba(38,37,33,0.55);
494
+ --radius-md:7px;--radius-lg:10px;
495
+ --font-display:"Source Serif 4",ui-serif,Georgia,serif;
496
+ --font-body:"IBM Plex Sans",ui-sans-serif,system-ui,sans-serif;
497
+ --font-mono:"IBM Plex Mono",ui-monospace,"SF Mono",Menlo,monospace;
498
+ }
499
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
500
+ html,body{height:100%}
501
+ body{
502
+ font-family:var(--font-body);font-size:13px;line-height:1.45;
503
+ color:var(--fg1);background:var(--bg);
504
+ min-height:100vh;display:grid;place-items:center;padding:24px;
505
+ -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
506
+ position:relative;overflow:hidden;
507
+ }
508
+ body::before{
509
+ content:"";position:fixed;inset:0;
510
+ background:radial-gradient(900px 600px at 50% 50%,rgba(228,224,216,0.025),transparent 60%);
511
+ pointer-events:none;z-index:0;
512
+ }
513
+ .top-mark{position:fixed;top:22px;left:24px;z-index:5;opacity:0.7}
514
+ ${appLogoStyles}
515
+ .stage{
516
+ width:100%;max-width:460px;text-align:center;
517
+ position:relative;z-index:1;
518
+ display:grid;
519
+ }
520
+ .panel{
521
+ grid-area:1/1;
522
+ transition:opacity 280ms ease-out,transform 380ms cubic-bezier(.2,.6,.2,1),filter 380ms ease-out;
523
+ }
524
+ .panel[hidden]{
525
+ display:block !important;
526
+ opacity:0;transform:scale(0.96) translateY(-2px);filter:blur(6px);
527
+ pointer-events:none;
528
+ }
529
+ .panel:not([hidden]){opacity:1;transform:scale(1);filter:none;pointer-events:auto}
530
+
531
+ /* eyebrows */
532
+ .eyebrow{
533
+ font-family:var(--font-mono);font-size:10px;font-weight:700;
534
+ letter-spacing:0.28em;text-transform:uppercase;color:var(--fg4);
535
+ margin-bottom:24px;display:inline-flex;align-items:center;gap:7px;
536
+ }
537
+ .eyebrow .dot{width:5px;height:5px;border-radius:50%;background:currentColor}
538
+ .eyebrow.danger{color:var(--rose)}
539
+ .eyebrow.success{color:var(--green)}
540
+ .eyebrow.success .dot{box-shadow:0 0 0 3px rgba(74,222,128,0.18)}
541
+
542
+ /* form input */
543
+ .input-wrap{
544
+ display:flex;align-items:center;background:rgba(0,0,0,0.22);
545
+ border:1px solid var(--border);border-radius:var(--radius-md);
546
+ transition:border-color 200ms ease-out,box-shadow 200ms ease-out;
547
+ }
548
+ .input-wrap:focus-within{
549
+ border-color:rgba(228,224,216,0.45);
550
+ box-shadow:0 0 0 3px rgba(228,224,216,0.10);
551
+ }
552
+ .input-wrap.has-error{
553
+ border-color:rgba(239,68,68,0.55);
554
+ box-shadow:0 0 0 3px rgba(239,68,68,0.12);
555
+ animation:shake 360ms cubic-bezier(.36,.07,.19,.97);
556
+ }
557
+ @keyframes shake{
558
+ 10%,90%{transform:translateX(-1px)}20%,80%{transform:translateX(2px)}
559
+ 30%,50%,70%{transform:translateX(-4px)}40%,60%{transform:translateX(4px)}
560
+ }
561
+ .input{
562
+ flex:1;min-width:0;background:transparent;border:0;outline:none;
563
+ padding:16px;
564
+ font-family:var(--font-mono);font-size:14px;color:var(--fg1);letter-spacing:0.02em;
565
+ }
566
+ .input::placeholder{color:var(--fg4)}
567
+ .hint{
568
+ margin-top:10px;font-family:var(--font-mono);font-size:10.5px;
569
+ letter-spacing:0.16em;text-transform:uppercase;
570
+ text-align:left;padding-left:4px;height:14px;color:var(--fg4);
571
+ transition:color 160ms ease-out;
572
+ }
573
+ .hint.is-error{color:var(--rose)}
574
+
575
+ /* primary button */
576
+ .btn-primary{
577
+ width:100%;height:48px;margin-top:14px;
578
+ border:0;border-radius:var(--radius-md);
579
+ background:var(--btn-bg);color:var(--btn-fg);
580
+ font-family:var(--font-body);font-size:14.5px;font-weight:600;letter-spacing:-0.005em;
581
+ cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:10px;
582
+ transition:background 140ms ease-out,transform 80ms ease-out,opacity 200ms ease-out;
583
+ position:relative;overflow:hidden;
584
+ }
585
+ .btn-primary:hover:not([disabled]){background:var(--btn-hover)}
586
+ .btn-primary:active:not([disabled]){transform:translateY(1px)}
587
+ .btn-primary[disabled]{opacity:0.45;cursor:default}
588
+ .spin{
589
+ width:14px;height:14px;border-radius:50%;
590
+ border:1.5px solid currentColor;border-top-color:transparent;
591
+ animation:spin 700ms linear infinite;opacity:0.85;
592
+ }
593
+ @keyframes spin{to{transform:rotate(360deg)}}
594
+
595
+ /* secondary link */
596
+ .small-link{
597
+ margin-top:18px;font-family:var(--font-mono);font-size:11px;
598
+ letter-spacing:0.18em;text-transform:uppercase;color:var(--fg4);
599
+ }
600
+ .small-link a{
601
+ color:var(--fg3);text-decoration:none;border-bottom:1px dotted currentColor;
602
+ padding-bottom:1px;transition:color 140ms;
603
+ }
604
+ .small-link a:hover{color:var(--fg1)}
605
+
606
+ /* orb */
607
+ .orb-wrap{
608
+ position:relative;width:160px;height:160px;margin:0 auto 36px;
609
+ display:grid;place-items:center;
610
+ }
611
+ .orb-ring{position:absolute;border-radius:50%}
612
+ .orb-ring.r1{inset:0;border:1px solid rgba(228,224,216,0.06);animation:drift1 40s linear infinite}
613
+ .orb-ring.r2{inset:18px;border:1px dashed rgba(228,224,216,0.10);animation:drift2 28s linear infinite}
614
+ .orb-ring.r3{inset:38px;border:1px solid rgba(74,222,128,0.20);animation:ringPulse 3.4s ease-in-out infinite}
615
+ @keyframes drift1{to{transform:rotate(360deg)}}
616
+ @keyframes drift2{to{transform:rotate(-360deg)}}
617
+ @keyframes ringPulse{0%,100%{opacity:0.6}50%{opacity:1}}
618
+
619
+ .orb-wrap.is-verifying .orb-ring.r3{
620
+ border-color:rgba(228,224,216,0.20);
621
+ animation:ringPulse 1.1s ease-in-out infinite;
622
+ }
623
+ .orb-wrap.is-verifying .orb-core{
624
+ box-shadow:0 0 0 6px rgba(228,224,216,0.04),0 0 22px rgba(228,224,216,0.10),inset 0 0 16px rgba(228,224,216,0.06);
625
+ animation:corePulseNeutral 1.6s ease-in-out infinite;
626
+ }
627
+ .orb-wrap.is-verifying .orb-dot{background:var(--fg3);box-shadow:0 0 10px rgba(228,224,216,0.4)}
628
+ @keyframes corePulseNeutral{
629
+ 0%,100%{box-shadow:0 0 0 6px rgba(228,224,216,0.04),0 0 22px rgba(228,224,216,0.10),inset 0 0 16px rgba(228,224,216,0.06)}
630
+ 50%{box-shadow:0 0 0 9px rgba(228,224,216,0.07),0 0 32px rgba(228,224,216,0.18),inset 0 0 22px rgba(228,224,216,0.10)}
631
+ }
632
+
633
+ .orb-wrap.is-error .orb-ring.r3{border-color:rgba(239,68,68,0.30);animation:none;opacity:1}
634
+ .orb-wrap.is-error .orb-ring.r1,.orb-wrap.is-error .orb-ring.r2{animation-play-state:paused}
635
+ .orb-wrap.is-error .orb-core{
636
+ box-shadow:0 0 0 6px rgba(239,68,68,0.05),0 0 22px rgba(239,68,68,0.20),inset 0 0 16px rgba(239,68,68,0.08);
637
+ animation:none;
638
+ }
639
+ .orb-wrap.is-error .orb-dot{background:var(--rose);box-shadow:0 0 10px rgba(239,68,68,0.6)}
640
+
641
+ .sat-orbit{position:absolute;inset:0;pointer-events:none}
642
+ .sat-orbit.o1{animation:drift1 40s linear infinite}
643
+ .sat-orbit.o2{animation:drift2 56s linear infinite}
644
+ .sat{
645
+ position:absolute;top:50%;left:50%;
646
+ font-family:var(--font-mono);font-size:9px;font-weight:700;letter-spacing:0.14em;
647
+ background:var(--bg);padding:2px 6px;border-radius:3px;
648
+ border:1px solid var(--border-light);
649
+ transform-origin:0 0;opacity:0;
650
+ }
651
+ .panel:not([hidden])[data-state="connected"] .sat{animation:satIn 600ms ease-out forwards}
652
+ @keyframes satIn{from{opacity:0}to{opacity:1}}
653
+ .sat span{display:inline-block;animation:counter 40s linear infinite}
654
+ .sat-orbit.o2 .sat span{animation:counter2 56s linear infinite}
655
+ @keyframes counter{to{transform:rotate(-360deg)}}
656
+ @keyframes counter2{to{transform:rotate(360deg)}}
657
+
658
+ .orb-core{
659
+ position:relative;width:60px;height:60px;border-radius:50%;
660
+ background:radial-gradient(circle at 50% 45%,#1a1a1a 0%,#0c0c0c 60%,#050505 100%);
661
+ border:1px solid rgba(255,255,255,0.06);
662
+ display:grid;place-items:center;
663
+ box-shadow:0 0 0 6px rgba(74,222,128,0.04),0 0 28px rgba(74,222,128,0.20),inset 0 0 18px rgba(74,222,128,0.08);
664
+ animation:corePulse 3.4s ease-in-out infinite;
665
+ }
666
+ @keyframes corePulse{
667
+ 0%,100%{box-shadow:0 0 0 6px rgba(74,222,128,0.04),0 0 24px rgba(74,222,128,0.18),inset 0 0 16px rgba(74,222,128,0.06)}
668
+ 50%{box-shadow:0 0 0 9px rgba(74,222,128,0.06),0 0 40px rgba(74,222,128,0.32),inset 0 0 22px rgba(74,222,128,0.14)}
669
+ }
670
+ .orb-dot{width:10px;height:10px;border-radius:50%;background:#4ade80;box-shadow:0 0 12px rgba(74,222,128,0.7)}
671
+
672
+ .core-shockwave{
673
+ position:absolute;inset:0;border-radius:50%;
674
+ border:1px solid rgba(74,222,128,0.6);
675
+ opacity:0;pointer-events:none;
676
+ }
677
+ .panel:not([hidden])[data-state="connected"] .core-shockwave{animation:shock 1100ms ease-out 200ms}
678
+ @keyframes shock{
679
+ 0%{opacity:0.7;transform:scale(0.4);border-width:2px}
680
+ 100%{opacity:0;transform:scale(2.4);border-width:1px}
681
+ }
682
+
683
+ /* titles */
684
+ .ok-title{
685
+ font-family:var(--font-display);font-weight:600;
686
+ font-size:38px;line-height:1.05;letter-spacing:-0.025em;
687
+ color:var(--fg-bright);opacity:0;
688
+ }
689
+ .panel:not([hidden]) .ok-title{animation:rise 600ms ease-out 380ms forwards}
690
+ .ok-lead{
691
+ margin:18px auto 0;max-width:22em;font-size:16px;line-height:1.55;color:var(--fg2);
692
+ opacity:0;
693
+ }
694
+ .panel:not([hidden]) .ok-lead{animation:rise 600ms ease-out 500ms forwards}
695
+ .ok-phrase-row{
696
+ margin-top:14px;display:flex;align-items:center;justify-content:center;
697
+ opacity:0;
698
+ }
699
+ .panel:not([hidden]) .ok-phrase-row{animation:rise 600ms ease-out 620ms forwards}
700
+ .cmd.is-copied .cmd-quote-part{display:none}
701
+ .success-cta-wrap{
702
+ margin-top:48px;width:100%;opacity:0;
703
+ }
704
+ .panel:not([hidden]) .success-cta-wrap{animation:rise 600ms ease-out 780ms forwards}
705
+ a.btn-primary.success-cta{color:var(--btn-fg);text-decoration:none}
706
+
707
+ .cmd{
708
+ display:inline-flex;align-items:center;gap:8px;vertical-align:middle;
709
+ font-family:var(--font-mono);font-size:15px;font-weight:500;color:var(--accent);
710
+ background:rgba(201,185,154,0.08);border:1px solid rgba(201,185,154,0.30);
711
+ padding:5px 10px 5px 12px;border-radius:6px;letter-spacing:0.02em;
712
+ cursor:pointer;user-select:none;
713
+ transition:background 140ms ease-out,border-color 140ms ease-out,color 140ms ease-out;
714
+ }
715
+ .cmd:hover{background:rgba(201,185,154,0.14);border-color:rgba(201,185,154,0.50);color:#dcc9a4}
716
+ .cmd.is-copied{color:var(--green);border-color:rgba(74,222,128,0.35);background:rgba(74,222,128,0.08)}
717
+ .cmd .cmd-icon{
718
+ width:12px;height:12px;color:currentColor;opacity:0.75;
719
+ display:inline-grid;place-items:center;
720
+ transition:opacity 140ms;
721
+ }
722
+ .cmd:hover .cmd-icon{opacity:1}
723
+ .cmd.is-copied .cmd-icon{color:var(--green);opacity:1}
724
+ .cmd .cmd-icon svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
725
+
726
+ /* error specifics */
727
+ .err-title{
728
+ font-family:var(--font-display);font-weight:600;
729
+ font-size:32px;line-height:1.1;letter-spacing:-0.02em;
730
+ color:var(--fg1);margin-bottom:12px;
731
+ }
732
+ .err-msg{
733
+ font-size:14.5px;line-height:1.55;color:var(--fg3);
734
+ margin-bottom:28px;
735
+ }
736
+ .err-msg code{
737
+ font-family:var(--font-mono);font-size:12px;
738
+ background:rgba(255,255,255,0.06);padding:1px 5px;border-radius:4px;color:var(--fg2);
739
+ }
740
+ .err-actions{display:flex;gap:8px}
741
+ .btn-secondary{
742
+ flex:1;height:44px;border-radius:var(--radius-md);
743
+ background:transparent;color:var(--fg2);
744
+ border:1px solid var(--border);
745
+ font-family:var(--font-body);font-size:13.5px;font-weight:500;
746
+ cursor:pointer;text-decoration:none;
747
+ display:inline-flex;align-items:center;justify-content:center;
748
+ transition:background 140ms,color 140ms,border-color 140ms;
749
+ }
750
+ .btn-secondary:hover{background:rgba(255,255,255,0.04);color:var(--fg1);border-color:rgba(255,255,255,0.14)}
751
+
752
+ /* verifying */
753
+ .verifying-eyebrow{
754
+ font-family:var(--font-mono);font-size:10px;
755
+ letter-spacing:0.28em;text-transform:uppercase;color:var(--fg3);
756
+ font-weight:700;margin-bottom:14px;
757
+ }
758
+ .verifying-title{
759
+ font-family:var(--font-display);font-weight:600;
760
+ font-size:28px;line-height:1.1;color:var(--fg1);margin-bottom:8px;
761
+ letter-spacing:-0.02em;
762
+ }
763
+ .verifying-sub{font-size:13px;color:var(--fg3);min-height:1.45em}
764
+
765
+ /* form heading */
766
+ .form-title{
767
+ font-family:var(--font-display);font-weight:600;
768
+ font-size:32px;line-height:1.1;letter-spacing:-0.02em;
769
+ color:var(--fg1);margin-bottom:10px;
770
+ }
771
+ .form-sub{font-size:13.5px;color:var(--fg3);margin-bottom:28px;line-height:1.55}
772
+
773
+ @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
774
+
775
+ @media (prefers-reduced-motion: reduce){
776
+ *,*::before,*::after{
777
+ animation-duration:0.01ms !important;animation-iteration-count:1 !important;
778
+ transition-duration:0.01ms !important;
779
+ }
780
+ }
781
+ </style>
782
+ </head><body>
783
+ <div class="top-mark">${appLogoMarkup({ size: "sm" })}</div>
784
+ <div class="stage">${bodyContent}</div>
785
+ </body></html>`;
786
+ }
787
+ function providerDisplayName(clientName) {
788
+ const name = (clientName ?? "").trim();
789
+ if (!name) return "your assistant";
790
+ return name.length > 40 ? name.slice(0, 40) + "\u2026" : name;
791
+ }
792
+ function successPanelInner(workspaceName, redirectUrl, providerName) {
793
+ return `
794
+ <div class="orb-wrap">
795
+ <div class="orb-ring r1"></div>
796
+ <div class="orb-ring r2"></div>
797
+ <div class="orb-ring r3"></div>
798
+ <div class="sat-orbit o1">
799
+ <span class="sat" style="transform:rotate(20deg) translate(78px) rotate(-20deg);color:#4ade80;animation-delay:600ms"><span>DEC</span></span>
800
+ <span class="sat" style="transform:rotate(140deg) translate(78px) rotate(-140deg);color:#c9b99a;animation-delay:720ms"><span>WP</span></span>
801
+ <span class="sat" style="transform:rotate(260deg) translate(78px) rotate(-260deg);color:#f59e0b;animation-delay:840ms"><span>TEN</span></span>
802
+ </div>
803
+ <div class="sat-orbit o2">
804
+ <span class="sat" style="transform:rotate(80deg) translate(54px) rotate(-80deg);color:#60a5fa;animation-delay:960ms"><span>STD</span></span>
805
+ <span class="sat" style="transform:rotate(220deg) translate(54px) rotate(-220deg);color:#a78bfa;animation-delay:1080ms"><span>INS</span></span>
806
+ </div>
807
+ <div class="core-shockwave"></div>
808
+ <div class="orb-core"><div class="orb-dot"></div></div>
809
+ </div>
810
+ <div class="eyebrow success"><span class="dot"></span>Connected</div>
811
+ <h1 class="ok-title">Product Brain is Live</h1>
812
+ <p class="ok-lead">Return to your assistant, then say</p>
813
+ <div class="ok-phrase-row">
814
+ <button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy &quot;Start PB&quot; and return to ${esc(providerName)}">
815
+ <span class="cmd-quote-part" aria-hidden="true">&ldquo;</span><span data-cmd-text>Start PB</span><span class="cmd-quote-part" aria-hidden="true">&rdquo;</span>
816
+ <span class="cmd-icon" aria-hidden="true">
817
+ <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>
818
+ </span>
819
+ </button>
820
+ </div>
821
+ <div class="success-cta-wrap">
822
+ <a class="btn-primary success-cta" href="${esc(redirectUrl)}">Continue in ${esc(providerName)}</a>
823
+ </div>
824
+ <!-- workspace name retained as data hook for tests, hidden from view -->
825
+ <span hidden data-field="ws-name">${esc(workspaceName)}</span>`;
826
+ }
827
+ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
828
+ return `
829
+ <div class="orb-wrap is-error">
830
+ <div class="orb-ring r1"></div>
831
+ <div class="orb-ring r2"></div>
832
+ <div class="orb-ring r3"></div>
833
+ <div class="orb-core"><div class="orb-dot"></div></div>
834
+ </div>
835
+ <div class="eyebrow danger"><span class="dot"></span>Couldn't connect</div>
836
+ <h2 class="err-title" data-field="err-title">${esc(title)}</h2>
837
+ <p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
838
+ <div class="err-actions">
839
+ <a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>&larr; Try again</a>
840
+ <a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key &rarr;</a>
841
+ </div>`;
842
+ }
843
+ var cmdScript = `
844
+ (function(){
845
+ function bindCmd(pill){
846
+ if(!pill||pill.__bound)return;pill.__bound=true;
847
+ var textEl=pill.querySelector('[data-cmd-text]');
848
+ var svgEl=pill.querySelector('[data-cmd-svg]');
849
+ var redirectUrl=pill.getAttribute('data-redirect')||'';
850
+ pill.addEventListener('click',function(){
851
+ var done=function(){
852
+ pill.classList.add('is-copied');
853
+ if(textEl)textEl.textContent='Copied';
854
+ if(svgEl)svgEl.innerHTML='<polyline points="4 12 10 18 20 6"/>';
855
+ setTimeout(function(){if(redirectUrl)window.location.assign(redirectUrl)},900);
856
+ };
857
+ try{
858
+ if(navigator.clipboard&&navigator.clipboard.writeText){
859
+ navigator.clipboard.writeText('Start PB').then(done,done);
860
+ }else{done()}
861
+ }catch(e){done()}
862
+ });
863
+ }
864
+ document.querySelectorAll('[data-cmd-pill]').forEach(bindCmd);
865
+ })();
866
+ `;
867
+ function authorizeFormPage(params) {
868
+ const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = params;
869
+ const providerName = providerDisplayName(params.client_name);
870
+ const body = `
871
+ <!-- \u2500\u2500\u2500 CONNECT \u2500\u2500\u2500 -->
872
+ <div class="panel" id="p-connect" data-state="connect">
873
+ <div class="eyebrow">Connect Product Brain</div>
874
+ <h1 class="form-title">Paste your API key</h1>
875
+ <p class="form-sub">Give <span data-provider>${esc(providerName)}</span> access to your workspace memory.</p>
876
+ <form method="POST" action="/authorize" id="f" autocomplete="off">
877
+ <input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
878
+ <input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
879
+ <input type="hidden" name="code_challenge_method" value="${esc(code_challenge_method)}">
880
+ <input type="hidden" name="state" value="${esc(state)}">
881
+ <input type="hidden" name="client_id" value="${esc(client_id)}">
882
+ <div class="input-wrap" id="iw">
883
+ <input type="password" id="k" name="api_key" class="input input-full" placeholder="pb_sk_\u2026" required autofocus spellcheck="false">
884
+ </div>
885
+ <div class="hint" id="hint" hidden></div>
886
+ <button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
887
+ </form>
888
+ <div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one &rarr;</a></div>
889
+ </div>
890
+
891
+ <!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
892
+ <div class="panel" id="p-verifying" data-state="verifying" hidden>
893
+ <div class="orb-wrap is-verifying">
894
+ <div class="orb-ring r1"></div>
895
+ <div class="orb-ring r2"></div>
896
+ <div class="orb-ring r3"></div>
897
+ <div class="orb-core"><div class="orb-dot"></div></div>
898
+ </div>
899
+ <div class="verifying-eyebrow">Handshake</div>
900
+ <h2 class="verifying-title">Verifying key\u2026</h2>
901
+ <p class="verifying-sub" id="verify-sub">Checking workspace \xB7 \u2026</p>
902
+ </div>
903
+
904
+ <!-- \u2500\u2500\u2500 CONNECTED (filled by JS from JSON response) \u2500\u2500\u2500 -->
905
+ <div class="panel" id="p-connected" data-state="connected" hidden></div>
906
+
907
+ <!-- \u2500\u2500\u2500 ERROR (filled by JS from JSON response) \u2500\u2500\u2500 -->
908
+ <div class="panel" id="p-error" data-state="error" hidden></div>
909
+
910
+ <template id="tpl-connected">${successPanelInner("__WS__", "__URL__", "__PROVIDER__")}</template>
911
+ <template id="tpl-error">${errorPanelInner("__TITLE__", "__DETAIL__", "__RETRY__")}</template>
912
+
913
+ <script>
914
+ ${cmdScript}
915
+ (function(){
916
+ var f=document.getElementById('f'),k=document.getElementById('k'),iw=document.getElementById('iw'),hint=document.getElementById('hint'),sb=document.getElementById('sb'),bt=document.getElementById('bt');
917
+ var pConnect=document.getElementById('p-connect'),pVerify=document.getElementById('p-verifying'),pOk=document.getElementById('p-connected'),pErr=document.getElementById('p-error');
918
+ var verifySub=document.getElementById('verify-sub');
919
+
920
+ function show(panel){
921
+ [pConnect,pVerify,pOk,pErr].forEach(function(p){
922
+ if(p===panel){p.removeAttribute('hidden')}else{p.setAttribute('hidden','')}
923
+ });
924
+ }
925
+
926
+ function syncInput(){
927
+ sb.disabled=!k.value.trim();
928
+ iw.classList.remove('has-error');
929
+ hint.classList.remove('is-error');
930
+ hint.textContent='';
931
+ hint.setAttribute('hidden','');
932
+ }
933
+ k.addEventListener('input',syncInput);
934
+ k.addEventListener('keydown',function(e){
935
+ if(e.key==='Escape'){k.value='';syncInput()}
936
+ });
937
+
938
+ function showError(title,detailHtml){
939
+ var tpl=document.getElementById('tpl-error');
940
+ var html=tpl.innerHTML
941
+ .replace('__TITLE__',title.replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]}))
942
+ .replace('__DETAIL__',detailHtml)
943
+ .replace('__RETRY__','#');
944
+ pErr.innerHTML=html;
945
+ var retry=pErr.querySelector('[data-retry-link]');
946
+ if(retry){retry.addEventListener('click',function(e){e.preventDefault();show(pConnect);k.focus();k.select()})}
947
+ show(pErr);
948
+ }
949
+
950
+ function showSuccess(workspaceName,redirectUrl,providerName){
951
+ var tpl=document.getElementById('tpl-connected');
952
+ var safeWs=String(workspaceName||'').replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]});
953
+ var safeProv=String(providerName||'your assistant').replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]});
954
+ var safeUrl=String(redirectUrl||'').replace(/"/g,'&quot;').replace(/[<>]/g,function(c){return{'<':'&lt;','>':'&gt;'}[c]});
955
+ var html=tpl.innerHTML.split('__WS__').join(safeWs).split('__URL__').join(safeUrl).split('__PROVIDER__').join(safeProv);
956
+ pOk.innerHTML=html;
957
+ pOk.querySelectorAll('[data-cmd-pill]').forEach(function(pill){
958
+ pill.__bound=false;
959
+ });
960
+ // Re-run binder
961
+ var s=document.createElement('script');s.textContent=${JSON.stringify(cmdScript)};document.body.appendChild(s);s.remove();
962
+ show(pOk);
963
+ }
964
+
965
+ f.addEventListener('submit',function(e){
966
+ e.preventDefault();
967
+ var v=k.value.trim();
968
+ if(!v){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Paste your key first';hint.removeAttribute('hidden');return}
969
+ sb.disabled=true;bt.textContent='Verifying';
970
+ show(pVerify);
971
+
972
+ var steps=['Checking workspace \xB7 \u2026','Loading chain \xB7 \u2026','Establishing memory \xB7 \u2026'];
973
+ var i=0;verifySub.textContent=steps[0];
974
+ var ti=setInterval(function(){i++;if(i>=steps.length){clearInterval(ti);return}verifySub.textContent=steps[i]},900);
975
+
976
+ var minDelay=new Promise(function(r){setTimeout(r,2800)});
977
+ var fd=new FormData(f);
978
+ var body=new URLSearchParams();
979
+ fd.forEach(function(val,key){body.append(key,String(val))});
980
+
981
+ var req=fetch('/authorize',{
982
+ method:'POST',
983
+ headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},
984
+ body:body.toString(),
985
+ credentials:'same-origin'
986
+ }).then(function(r){return r.json().then(function(j){return{status:r.status,body:j}})});
987
+
988
+ Promise.all([req,minDelay]).then(function(arr){
989
+ clearInterval(ti);
990
+ var res=arr[0];
991
+ if(res.body&&res.body.ok){
992
+ showSuccess(res.body.workspaceName,res.body.redirectUrl,res.body.providerName);
993
+ }else{
994
+ showError(res.body&&res.body.title||'Couldn\\'t connect',res.body&&res.body.detail||'Try again, or generate a new key.');
995
+ sb.disabled=false;bt.textContent='Connect';
996
+ }
997
+ }).catch(function(){
998
+ clearInterval(ti);
999
+ showError('Network error','We couldn\\'t reach Product Brain. Check your connection and try again.');
1000
+ sb.disabled=false;bt.textContent='Connect';
1001
+ });
1002
+ });
1003
+
1004
+ k.focus();
1005
+ })();
1006
+ </script>`;
1007
+ return authPageShell("Connect Product Brain", body);
1008
+ }
1009
+ function authorizeSuccessPage(workspaceName, redirectUrl, providerName) {
1010
+ const body = `
1011
+ <div class="panel" data-state="connected">
1012
+ ${successPanelInner(workspaceName, redirectUrl, providerName)}
163
1013
  </div>
164
- <script>document.querySelector("form").onsubmit=function(e){
165
- if(!document.getElementById("k").value.startsWith("pb_sk_")){
166
- e.preventDefault();document.getElementById("e").style.display="block"}}</script>
167
- </body></html>`);
1014
+ <script>${cmdScript}</script>`;
1015
+ return authPageShell("Connected", body);
1016
+ }
1017
+ function authorizeErrorPage(title, trustedDetailHtml, retryUrl) {
1018
+ const body = `
1019
+ <div class="panel" data-state="error">
1020
+ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
1021
+ </div>`;
1022
+ return authPageShell("Connection error", body);
1023
+ }
1024
+ app.get("/authorize", authLimiter, (req, res) => {
1025
+ const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
1026
+ const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
1027
+ if (refusal) {
1028
+ res.status(400).json({ error: "invalid_request", error_description: refusal });
1029
+ return;
1030
+ }
1031
+ const cid = String(client_id ?? "");
1032
+ const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
1033
+ res.type("html").send(authorizeFormPage({
1034
+ redirect_uri: String(redirect_uri ?? ""),
1035
+ code_challenge: String(code_challenge ?? ""),
1036
+ code_challenge_method: String(code_challenge_method ?? "S256"),
1037
+ state: String(state ?? ""),
1038
+ client_id: cid,
1039
+ client_name: clientName
1040
+ }));
168
1041
  });
169
1042
  app.post(
170
1043
  "/authorize",
1044
+ authLimiter,
171
1045
  express.urlencoded({ extended: false }),
172
- (req, res) => {
173
- const { api_key, redirect_uri, code_challenge, state } = req.body;
174
- if (!api_key?.startsWith("pb_sk_")) {
175
- res.status(400).send("Invalid API key");
1046
+ async (req, res) => {
1047
+ const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
1048
+ const api_key = normalisePastedKey(req.body.api_key);
1049
+ const wantsJson = String(req.headers["accept"] ?? "").includes("application/json");
1050
+ const retryParams = new URLSearchParams({
1051
+ redirect_uri: redirect_uri ?? "",
1052
+ code_challenge: code_challenge ?? "",
1053
+ code_challenge_method: code_challenge_method ?? "S256",
1054
+ ...state ? { state } : {},
1055
+ ...client_id ? { client_id } : {}
1056
+ }).toString();
1057
+ const retryUrl = `/authorize?${retryParams}`;
1058
+ function sendError(title, trustedDetailHtml, status = 400) {
1059
+ if (wantsJson) {
1060
+ res.status(status).json({ ok: false, title, detail: trustedDetailHtml });
1061
+ } else {
1062
+ res.status(status).type("html").send(authorizeErrorPage(title, trustedDetailHtml, retryUrl));
1063
+ }
1064
+ }
1065
+ if (isCrossSiteFormPost(req.headers["origin"], req.headers["sec-fetch-site"], baseUrl(req))) {
1066
+ sendError("Request refused", "This form only accepts keys sent from its own sign-in page. Open the connect link again.", 403);
1067
+ return;
1068
+ }
1069
+ const nowTs = Date.now();
1070
+ const pasted = resolvePastedKey(api_key);
1071
+ const lockout = authLockoutError(authFailures, pasted, nowTs);
1072
+ if (lockout) {
1073
+ const retryAfter = lockout.headers["Retry-After"];
1074
+ res.setHeader("Retry-After", retryAfter);
1075
+ sendError("Too many attempts", `This key was not recognized too many times. Try again in ${retryAfter}s.`, 429);
1076
+ return;
1077
+ }
1078
+ if (pasted.status !== "ok") {
1079
+ if (pasted.status === "rejected") recordAuthFailure(authFailures, pasted.credentialId, nowTs);
1080
+ sendError("Invalid key format", "API keys start with <code>pb_sk_</code>. Check your key and try again.");
1081
+ return;
1082
+ }
1083
+ if (!client_id) {
1084
+ res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
1085
+ return;
1086
+ }
1087
+ const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
1088
+ if (redirectRefusal) {
1089
+ res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
176
1090
  return;
177
1091
  }
1092
+ if (!registeredClients.has(client_id)) {
1093
+ if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
1094
+ res.status(503).json({ error: "server_error", error_description: "Registration limit reached. Try again later." });
1095
+ return;
1096
+ }
1097
+ registeredClients.set(client_id, { client_id, redirect_uris: [redirect_uri], registeredAt: Date.now() });
1098
+ process.stderr.write(`[authorize] auto-re-registered stale client_id after restart
1099
+ `);
1100
+ }
1101
+ const client = registeredClients.get(client_id);
1102
+ if (!client.redirect_uris.includes(redirect_uri)) {
1103
+ res.status(400).json({
1104
+ error: "invalid_request",
1105
+ error_description: "redirect_uri does not match any registered redirect for this client"
1106
+ });
1107
+ return;
1108
+ }
1109
+ let workspaceName = "Your Workspace";
1110
+ const check = await checkKeyAgainstCandidates(keyCheckCandidates(), api_key, 5e3);
1111
+ const gate = keyVerdictGate(check.verdict, authFailures, pasted.credentialId, nowTs);
1112
+ if (check.verdict === "ok") {
1113
+ if (check.workspaceName) workspaceName = check.workspaceName;
1114
+ getKeyState(api_key).deploymentUrl = check.deploymentUrl;
1115
+ } else if (check.verdict === "rate_limited") {
1116
+ sendError(
1117
+ "Rate limited",
1118
+ `This API key is valid but temporarily rate limited. Try again in ${check.retryAfterSeconds ?? 60}s.`,
1119
+ 429
1120
+ );
1121
+ return;
1122
+ } else if (gate === "unauthorized") {
1123
+ sendError("Key not recognized", "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.", 401);
1124
+ return;
1125
+ } else {
1126
+ process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
1127
+ }
178
1128
  const code = randomUUID();
179
1129
  pendingCodes.set(code, {
180
1130
  apiKey: api_key,
@@ -185,47 +1135,34 @@ app.post(
185
1135
  const url = new URL(redirect_uri);
186
1136
  url.searchParams.set("code", code);
187
1137
  if (state) url.searchParams.set("state", state);
188
- res.redirect(302, url.toString());
1138
+ const redirectUrl = url.toString();
1139
+ const providerName = providerDisplayName(client.client_name);
1140
+ if (wantsJson) {
1141
+ res.json({ ok: true, workspaceName, redirectUrl, providerName });
1142
+ } else {
1143
+ res.type("html").send(authorizeSuccessPage(workspaceName, redirectUrl, providerName));
1144
+ }
189
1145
  }
190
1146
  );
191
- function issueTokens(apiKey) {
192
- const refreshToken = `pb_rt_${randomUUID()}`;
193
- refreshTokens.set(refreshToken, { apiKey, createdAt: Date.now() });
194
- return {
195
- access_token: apiKey,
196
- token_type: "Bearer",
197
- expires_in: ACCESS_TOKEN_TTL,
198
- refresh_token: refreshToken
199
- };
200
- }
201
1147
  app.post(
202
1148
  "/oauth/token",
1149
+ authLimiter,
203
1150
  express.urlencoded({ extended: false }),
204
1151
  express.json(),
205
1152
  (req, res) => {
206
- const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
207
- if (grant_type === "refresh_token") {
208
- const entry = refreshTokens.get(refresh_token);
209
- if (!entry) {
210
- res.status(400).json({ error: "invalid_grant", error_description: "Invalid refresh token" });
211
- return;
212
- }
213
- if (Date.now() - entry.createdAt > REFRESH_TOKEN_TTL_MS) {
214
- refreshTokens.delete(refresh_token);
215
- res.status(400).json({ error: "invalid_grant", error_description: "Refresh token expired" });
216
- return;
217
- }
218
- const apiKey = entry.apiKey;
219
- refreshTokens.delete(refresh_token);
220
- res.json(issueTokens(apiKey));
221
- return;
222
- }
1153
+ const { grant_type, code, code_verifier, redirect_uri } = req.body;
223
1154
  if (grant_type !== "authorization_code") {
224
1155
  res.status(400).json({ error: "unsupported_grant_type" });
225
1156
  return;
226
1157
  }
227
1158
  const pending = pendingCodes.get(code);
228
- if (!pending || pending.redirectUri !== redirect_uri) {
1159
+ const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
1160
+ if (codeRefusal === "expired") {
1161
+ pendingCodes.delete(code);
1162
+ res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
1163
+ return;
1164
+ }
1165
+ if (codeRefusal || !pending) {
229
1166
  res.status(400).json({ error: "invalid_grant" });
230
1167
  return;
231
1168
  }
@@ -239,7 +1176,7 @@ app.post(
239
1176
  return;
240
1177
  }
241
1178
  pendingCodes.delete(code);
242
- res.json(issueTokens(pending.apiKey));
1179
+ res.json(tokenResponse(pending.apiKey));
243
1180
  }
244
1181
  );
245
1182
  var mcpLimiter = rateLimit({
@@ -247,43 +1184,63 @@ var mcpLimiter = rateLimit({
247
1184
  max: 120,
248
1185
  standardHeaders: true,
249
1186
  legacyHeaders: false,
250
- message: { error: "Too many requests. Try again later." }
1187
+ handler: (req, res) => {
1188
+ const r = req.rateLimit;
1189
+ const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
1190
+ send429(res, buildRateLimitError({
1191
+ retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
1192
+ limit: r?.limit ?? 120,
1193
+ current: r?.used ?? 0,
1194
+ id: req.body?.id ?? null
1195
+ // mirror the JSON-RPC id when a POST body is present
1196
+ }));
1197
+ }
251
1198
  });
1199
+ var authFailures = /* @__PURE__ */ new Map();
252
1200
  app.get("/health", (_req, res) => {
253
1201
  res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
254
1202
  });
255
1203
  var sessions = /* @__PURE__ */ new Map();
256
1204
  var SESSION_TTL_MS = 30 * 60 * 1e3;
257
1205
  var MAX_SESSIONS = 200;
1206
+ var EVICT_GRACE_MS = 6e4;
1207
+ var MAX_SESSIONS_PER_KEY = Math.max(
1208
+ 1,
1209
+ Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
1210
+ );
258
1211
  function evictStaleSessions() {
259
1212
  const now = Date.now();
260
1213
  for (const [id, entry] of sessions) {
261
1214
  if (now - entry.lastAccess > SESSION_TTL_MS) {
262
- logSessionLifecycle("session_deleted", id, "ttl");
1215
+ logSessionLifecycle(
1216
+ "session_deleted",
1217
+ id,
1218
+ entry.inFlight > 0 ? "inflight_leak" : "ttl"
1219
+ );
263
1220
  entry.transport.close().catch(() => {
264
1221
  });
265
1222
  sessions.delete(id);
266
1223
  }
267
1224
  }
268
1225
  if (sessions.size > MAX_SESSIONS) {
269
- const sorted = [...sessions.entries()].sort(
270
- (a, b) => a[1].lastAccess - b[1].lastAccess
271
- );
272
- for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
1226
+ const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
1227
+ const overflow = sessions.size - MAX_SESSIONS;
1228
+ for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
273
1229
  logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
274
1230
  sorted[i][1].transport.close().catch(() => {
275
1231
  });
276
1232
  sessions.delete(sorted[i][0]);
277
1233
  }
1234
+ if (sessions.size > MAX_SESSIONS) {
1235
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1236
+ process.stderr.write(
1237
+ `[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
1238
+ `
1239
+ );
1240
+ }
278
1241
  }
279
1242
  }
280
1243
  setInterval(evictStaleSessions, 6e4);
281
- function extractBearerKey(req) {
282
- const header = req.headers?.authorization;
283
- if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
284
- const token = header.slice(7).trim();
285
- return token.startsWith("pb_sk_") ? token : null;
286
- }
287
1244
  function send401(req, res) {
288
1245
  const base = baseUrl(req);
289
1246
  res.status(401).set(
@@ -305,40 +1262,136 @@ function logSessionLifecycle(event, sessionId, reason) {
305
1262
  `);
306
1263
  }
307
1264
  app.post("/mcp", mcpLimiter, async (req, res) => {
308
- const apiKey = extractBearerKey(req);
309
- if (!apiKey) {
1265
+ const nowTs = Date.now();
1266
+ const auth = resolveBearerAuth(req.headers?.authorization);
1267
+ const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
1268
+ if (lockout) {
1269
+ send429(res, lockout);
1270
+ return;
1271
+ }
1272
+ if (auth.status !== "ok") {
310
1273
  logRequest("POST", "auth_fail");
1274
+ if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
311
1275
  send401(req, res);
312
1276
  return;
313
1277
  }
1278
+ const apiKey = auth.apiKey;
314
1279
  const sessionId = req.headers["mcp-session-id"];
315
1280
  const reqStart = Date.now();
316
1281
  try {
317
- await runWithAuth({ apiKey }, async () => {
1282
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
318
1283
  if (sessionId && sessions.has(sessionId)) {
319
1284
  const entry = sessions.get(sessionId);
320
- entry.lastAccess = Date.now();
321
- await entry.transport.handleRequest(req, res, req.body);
322
- logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1285
+ if (entry.keyHash !== hashKey(apiKey)) {
1286
+ res.status(403).json({
1287
+ jsonrpc: "2.0",
1288
+ error: { code: -32e3, message: "Session key mismatch" },
1289
+ id: null
1290
+ });
1291
+ return;
1292
+ }
1293
+ try {
1294
+ entry.inFlight++;
1295
+ entry.lastAccess = Date.now();
1296
+ await entry.transport.handleRequest(req, res, req.body);
1297
+ logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1298
+ } finally {
1299
+ entry.inFlight--;
1300
+ entry.lastAccess = Date.now();
1301
+ }
323
1302
  } else if (!sessionId && isInitializeRequest(req.body)) {
1303
+ const check = await checkKeyAgainstCandidates(keyCheckCandidates(), apiKey, 5e3);
1304
+ const gate = keyVerdictGate(check.verdict, authFailures, auth.credentialId, Date.now());
1305
+ if (gate === "unauthorized") {
1306
+ logRequest("POST", "auth_fail");
1307
+ send401(req, res);
1308
+ return;
1309
+ }
1310
+ if (gate === "unavailable") {
1311
+ res.status(503).json({
1312
+ jsonrpc: "2.0",
1313
+ error: { code: -32e3, message: "Product Brain could not verify this API key right now. Retry shortly." },
1314
+ id: req.body?.id ?? null
1315
+ });
1316
+ return;
1317
+ }
1318
+ if (check.verdict === "ok" || check.verdict === "rate_limited") getKeyState(apiKey).deploymentUrl = check.deploymentUrl;
1319
+ const keyH = hashKey(apiKey);
1320
+ const plan = planKeyAdmission(
1321
+ sessions,
1322
+ keyH,
1323
+ MAX_SESSIONS_PER_KEY,
1324
+ Date.now(),
1325
+ { ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
1326
+ );
1327
+ for (const sid2 of plan.evict) {
1328
+ const victim = sessions.get(sid2);
1329
+ sessions.delete(sid2);
1330
+ victim?.transport.close().catch(() => {
1331
+ });
1332
+ logSessionLifecycle("session_deleted", sid2, "lru_evict");
1333
+ }
1334
+ if (!plan.admit) {
1335
+ send429(res, buildSessionCapError({
1336
+ retryAfterSeconds: plan.retryAfterSeconds,
1337
+ limit: MAX_SESSIONS_PER_KEY,
1338
+ current: countKeySessions(sessions, keyH),
1339
+ poll: plan.retryIsPoll,
1340
+ id: req.body?.id ?? null
1341
+ }));
1342
+ return;
1343
+ }
1344
+ const sid = randomUUID();
1345
+ let initialized = false;
324
1346
  const transport = new StreamableHTTPServerTransport({
325
- sessionIdGenerator: () => randomUUID(),
326
- onsessioninitialized: (sid) => {
327
- sessions.set(sid, { transport, lastAccess: Date.now() });
328
- logSessionLifecycle("session_created", sid);
1347
+ sessionIdGenerator: () => sid,
1348
+ onsessioninitialized: (s) => {
1349
+ initialized = true;
1350
+ logSessionLifecycle("session_created", s);
329
1351
  }
1352
+ // log only — no map write
330
1353
  });
1354
+ const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
1355
+ sessions.set(sid, reserved);
331
1356
  transport.onclose = () => {
332
- const sid = transport.sessionId;
333
- if (sid) {
334
- logSessionLifecycle("session_deleted", sid, "onclose");
335
- sessions.delete(sid);
1357
+ const s = transport.sessionId ?? sid;
1358
+ forgetStreamLifecycle(keyH, s);
1359
+ if (sessions.has(s)) {
1360
+ sessions.delete(s);
1361
+ logSessionLifecycle("session_deleted", s, "onclose");
336
1362
  }
337
1363
  };
338
- const server = createProductBrainServer();
339
- await server.connect(transport);
340
- await transport.handleRequest(req, res, req.body);
341
- logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1364
+ try {
1365
+ const server = createProductBrainServer();
1366
+ await server.connect(transport);
1367
+ await transport.handleRequest(req, res, req.body);
1368
+ logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1369
+ } catch (e) {
1370
+ transport.onclose = void 0;
1371
+ sessions.delete(sid);
1372
+ throw e;
1373
+ } finally {
1374
+ if (!initialized) {
1375
+ transport.onclose = void 0;
1376
+ if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
1377
+ } else {
1378
+ const e = sessions.get(sid);
1379
+ if (e) {
1380
+ e.inFlight--;
1381
+ e.lastAccess = Date.now();
1382
+ }
1383
+ }
1384
+ }
1385
+ } else if (sessionId) {
1386
+ process.stderr.write(
1387
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
1388
+ `
1389
+ );
1390
+ res.status(404).json({
1391
+ jsonrpc: "2.0",
1392
+ error: { code: -32001, message: "Session not found \u2014 re-initialise" },
1393
+ id: null
1394
+ });
342
1395
  } else {
343
1396
  process.stderr.write(
344
1397
  `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
@@ -363,20 +1416,44 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
363
1416
  }
364
1417
  });
365
1418
  app.get("/mcp", mcpLimiter, async (req, res) => {
366
- const apiKey = extractBearerKey(req);
367
- if (!apiKey) {
1419
+ const nowTs = Date.now();
1420
+ const auth = resolveBearerAuth(req.headers?.authorization);
1421
+ const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
1422
+ if (lockout) {
1423
+ send429(res, lockout);
1424
+ return;
1425
+ }
1426
+ if (auth.status !== "ok") {
368
1427
  logRequest("GET", "auth_fail");
1428
+ if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
369
1429
  send401(req, res);
370
1430
  return;
371
1431
  }
1432
+ const apiKey = auth.apiKey;
372
1433
  const sessionId = req.headers["mcp-session-id"];
373
- if (!sessionId || !sessions.has(sessionId)) {
374
- res.status(400).send("Invalid or missing session ID");
1434
+ if (!sessionId) {
1435
+ res.status(400).send("Missing Mcp-Session-Id header");
1436
+ return;
1437
+ }
1438
+ if (!sessions.has(sessionId)) {
1439
+ process.stderr.write(
1440
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale GET sessionId=${sessionId}
1441
+ `
1442
+ );
1443
+ res.status(404).send("Session not found \u2014 re-initialise");
375
1444
  return;
376
1445
  }
377
1446
  try {
378
- await runWithAuth({ apiKey }, async () => {
1447
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
379
1448
  const entry = sessions.get(sessionId);
1449
+ if (entry.keyHash !== hashKey(apiKey)) {
1450
+ res.status(403).json({
1451
+ jsonrpc: "2.0",
1452
+ error: { code: -32e3, message: "Session key mismatch" },
1453
+ id: null
1454
+ });
1455
+ return;
1456
+ }
380
1457
  entry.lastAccess = Date.now();
381
1458
  await entry.transport.handleRequest(req, res);
382
1459
  logRequest("GET", "ok", sessionId);
@@ -386,20 +1463,44 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
386
1463
  }
387
1464
  });
388
1465
  app.delete("/mcp", mcpLimiter, async (req, res) => {
389
- const apiKey = extractBearerKey(req);
390
- if (!apiKey) {
1466
+ const nowTs = Date.now();
1467
+ const auth = resolveBearerAuth(req.headers?.authorization);
1468
+ const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
1469
+ if (lockout) {
1470
+ send429(res, lockout);
1471
+ return;
1472
+ }
1473
+ if (auth.status !== "ok") {
391
1474
  logRequest("DELETE", "auth_fail");
1475
+ if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
392
1476
  send401(req, res);
393
1477
  return;
394
1478
  }
1479
+ const apiKey = auth.apiKey;
395
1480
  const sessionId = req.headers["mcp-session-id"];
396
- if (!sessionId || !sessions.has(sessionId)) {
397
- res.status(400).send("Invalid or missing session ID");
1481
+ if (!sessionId) {
1482
+ res.status(400).send("Missing Mcp-Session-Id header");
1483
+ return;
1484
+ }
1485
+ if (!sessions.has(sessionId)) {
1486
+ process.stderr.write(
1487
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale DELETE sessionId=${sessionId}
1488
+ `
1489
+ );
1490
+ res.status(404).send("Session not found \u2014 re-initialise");
398
1491
  return;
399
1492
  }
400
1493
  try {
401
- await runWithAuth({ apiKey }, async () => {
1494
+ await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
402
1495
  const entry = sessions.get(sessionId);
1496
+ if (entry.keyHash !== hashKey(apiKey)) {
1497
+ res.status(403).json({
1498
+ jsonrpc: "2.0",
1499
+ error: { code: -32e3, message: "Session key mismatch" },
1500
+ id: null
1501
+ });
1502
+ return;
1503
+ }
403
1504
  await entry.transport.handleRequest(req, res);
404
1505
  logRequest("DELETE", "ok", sessionId);
405
1506
  });
@@ -431,8 +1532,11 @@ async function gracefulShutdown() {
431
1532
  }
432
1533
  process.exit(0);
433
1534
  }
434
- var httpServer = app.listen(PORT, "127.0.0.1", () => {
435
- console.log(`Product Brain MCP HTTP server v${SERVER_VERSION} listening on port ${PORT}`);
1535
+ var LISTEN_HOST = "0.0.0.0";
1536
+ var httpServer = app.listen(PORT, LISTEN_HOST, () => {
1537
+ console.log(
1538
+ `Product Brain MCP HTTP server v${SERVER_VERSION} listening on ${LISTEN_HOST}:${PORT}`
1539
+ );
436
1540
  });
437
1541
  httpServer.on("error", (err) => {
438
1542
  console.error(`[MCP HTTP] Server error: ${err.message}`);