@productbrain/mcp 0.0.1-beta.149 → 0.0.1-beta.1499

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,22 +1,224 @@
1
1
  import {
2
2
  SERVER_VERSION,
3
- bootstrapHttp,
4
3
  createProductBrainServer,
5
- initFeatureFlags,
6
- runWithAuth
7
- } from "./chunk-H7XBZ4H3.js";
4
+ initFeatureFlags
5
+ } from "./chunk-GL6INEHP.js";
8
6
  import {
7
+ DEFAULT_CLOUD_URL,
8
+ bootstrapHttp,
9
+ getKeyState,
9
10
  getPostHogClient,
11
+ hashKey,
10
12
  initAnalytics,
13
+ runWithAuth,
11
14
  shutdownAnalytics
12
- } from "./chunk-MOPOQUJP.js";
15
+ } from "./chunk-AMJILQKI.js";
13
16
 
14
17
  // src/http.ts
15
- import { createHash, randomUUID } from "crypto";
18
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
16
19
  import express from "express";
17
20
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
21
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
19
22
  import rateLimit from "express-rate-limit";
23
+
24
+ // src/brand/logo-markup.ts
25
+ var SIZE_CLASSES = {
26
+ sm: "pb-logo--sm",
27
+ md: "pb-logo--md"
28
+ };
29
+ var appLogoStyles = `
30
+ .pb-logo{display:inline-flex;align-items:center;gap:8px;color:inherit}
31
+ .pb-logo__mark{
32
+ border-radius:4px;background:#1c1e24;
33
+ border:1px solid rgba(255,255,255,0.06);
34
+ display:inline-grid;place-items:center;flex-shrink:0;
35
+ }
36
+ .pb-logo__core{border-radius:50%;background:var(--accent,#c9b99a)}
37
+ .pb-logo__name{
38
+ font-family:var(--font-mono,"IBM Plex Mono",ui-monospace,monospace);
39
+ font-weight:500;text-transform:uppercase;
40
+ letter-spacing:0.22em;color:var(--fg4,#6a6560);
41
+ }
42
+ .pb-logo--sm .pb-logo__mark{width:16px;height:16px}
43
+ .pb-logo--sm .pb-logo__core{width:5px;height:5px}
44
+ .pb-logo--sm .pb-logo__name{font-size:10.5px}
45
+ .pb-logo--md .pb-logo__mark{width:24px;height:24px;border-radius:6px}
46
+ .pb-logo--md .pb-logo__core{width:8px;height:8px}
47
+ .pb-logo--md .pb-logo__name{font-size:13px}
48
+ `;
49
+ function appLogoMarkup(opts = {}) {
50
+ const size = opts.size ?? "sm";
51
+ const showWordmark = opts.showWordmark ?? true;
52
+ const cls = ["pb-logo", SIZE_CLASSES[size], opts.className].filter(Boolean).join(" ");
53
+ const wordmark = showWordmark ? `<span class="pb-logo__name">Product Brain</span>` : "";
54
+ return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
55
+ }
56
+
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
+ // src/sessionCap.ts
115
+ function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
116
+ if (cap < 1) {
117
+ throw new RangeError(`cap must be >= 1, got ${cap}`);
118
+ }
119
+ const { ttlMs, graceMs } = opts;
120
+ const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
121
+ const keyEntries = [];
122
+ for (const [id, session] of sessions2) {
123
+ if (session.keyHash === keyHash) {
124
+ keyEntries.push([id, session]);
125
+ }
126
+ }
127
+ const evictSet = /* @__PURE__ */ new Set();
128
+ for (const [id, session] of keyEntries) {
129
+ if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
130
+ evictSet.add(id);
131
+ }
132
+ }
133
+ let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
134
+ if (survivors.length >= cap) {
135
+ const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
136
+ for (const [id] of idleSurvivors) {
137
+ if (survivors.length < cap) break;
138
+ evictSet.add(id);
139
+ survivors = survivors.filter(([sid]) => sid !== id);
140
+ }
141
+ }
142
+ const evict = Array.from(evictSet);
143
+ const admit = survivors.length < cap;
144
+ let retryAfterSeconds = 0;
145
+ let retryIsPoll = false;
146
+ if (!admit) {
147
+ const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
148
+ if (graceBlockers.length > 0) {
149
+ const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
150
+ const msRemaining = graceMs - (now - minLastAccess);
151
+ retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
152
+ } else {
153
+ retryAfterSeconds = 5;
154
+ retryIsPoll = true;
155
+ }
156
+ }
157
+ return { evict, admit, retryAfterSeconds, retryIsPoll };
158
+ }
159
+ function countKeySessions(sessions2, keyHash) {
160
+ let count = 0;
161
+ for (const session of sessions2.values()) {
162
+ if (session.keyHash === keyHash) count++;
163
+ }
164
+ return count;
165
+ }
166
+
167
+ // src/httpErrors.ts
168
+ function clampSeconds(s) {
169
+ return Math.max(0, Math.round(s));
170
+ }
171
+ function build(reason, message, p) {
172
+ const seconds = clampSeconds(p.retryAfterSeconds);
173
+ return {
174
+ status: 429,
175
+ headers: { "Retry-After": String(seconds) },
176
+ body: {
177
+ jsonrpc: "2.0",
178
+ id: p.id ?? null,
179
+ error: {
180
+ code: -32e3,
181
+ message,
182
+ data: {
183
+ reason,
184
+ retryAfterSeconds: seconds,
185
+ limit: p.limit,
186
+ current: p.current
187
+ }
188
+ }
189
+ }
190
+ };
191
+ }
192
+ function buildRateLimitError(p) {
193
+ const seconds = clampSeconds(p.retryAfterSeconds);
194
+ return build(
195
+ "rate_limited",
196
+ `Too many requests \u2014 retry after ${seconds} s.`,
197
+ p
198
+ );
199
+ }
200
+ function buildAuthLockoutError(p) {
201
+ const seconds = clampSeconds(p.retryAfterSeconds);
202
+ return build(
203
+ "auth_lockout",
204
+ `Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
205
+ p
206
+ );
207
+ }
208
+ function buildSessionCapError(p) {
209
+ const seconds = clampSeconds(p.retryAfterSeconds);
210
+ const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
211
+ return build("session_cap", message, p);
212
+ }
213
+ function send429(res, built) {
214
+ if (res.headersSent) return;
215
+ for (const [k, v] of Object.entries(built.headers)) {
216
+ res.setHeader(k, v);
217
+ }
218
+ res.status(built.status).json(built.body);
219
+ }
220
+
221
+ // src/http.ts
20
222
  bootstrapHttp();
21
223
  initAnalytics();
22
224
  initFeatureFlags(getPostHogClient());
@@ -29,10 +231,10 @@ function baseUrl(req) {
29
231
  var app = express();
30
232
  app.set("trust proxy", 1);
31
233
  app.use(express.json());
32
- var ALLOWED_ORIGINS = process.env.CORS_ORIGINS?.split(",").map((o) => o.trim()).filter(Boolean);
234
+ var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
33
235
  app.use((_req, res, next) => {
34
236
  const origin = _req.headers.origin;
35
- if (ALLOWED_ORIGINS && origin && ALLOWED_ORIGINS.includes(origin)) {
237
+ if (origin && ALLOWED_ORIGINS.includes(origin)) {
36
238
  res.setHeader("Access-Control-Allow-Origin", origin);
37
239
  }
38
240
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
@@ -40,7 +242,7 @@ app.use((_req, res, next) => {
40
242
  "Access-Control-Allow-Headers",
41
243
  "Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
42
244
  );
43
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
245
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
44
246
  if (_req.method === "OPTIONS") {
45
247
  res.status(204).end();
46
248
  return;
@@ -70,11 +272,27 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
70
272
  scopes_supported: ["mcp:tools", "mcp:resources"]
71
273
  });
72
274
  });
275
+ var authLimiter = rateLimit({
276
+ windowMs: 6e4,
277
+ max: 20,
278
+ standardHeaders: true,
279
+ legacyHeaders: false,
280
+ message: { error: "Too many auth requests. Try again later." }
281
+ });
73
282
  var registeredClients = /* @__PURE__ */ new Map();
283
+ var MAX_REGISTERED_CLIENTS = 500;
74
284
  app.post(
75
285
  "/register",
286
+ authLimiter,
76
287
  express.json(),
77
288
  (req, res) => {
289
+ if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
290
+ res.status(503).json({
291
+ error: "server_error",
292
+ error_description: "Registration limit reached. Try again later."
293
+ });
294
+ return;
295
+ }
78
296
  const { redirect_uris, client_name } = req.body;
79
297
  if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
80
298
  res.status(400).json({
@@ -83,7 +301,7 @@ app.post(
83
301
  });
84
302
  return;
85
303
  }
86
- const clientId = `pb_client_${randomUUID()}`;
304
+ const clientId = `pb_client_${randomUUID2()}`;
87
305
  const client = {
88
306
  client_id: clientId,
89
307
  redirect_uris,
@@ -103,8 +321,8 @@ app.post(
103
321
  );
104
322
  var pendingCodes = /* @__PURE__ */ new Map();
105
323
  var ACCESS_TOKEN_TTL = 3600;
106
- var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
107
- var refreshTokens = /* @__PURE__ */ new Map();
324
+ var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
325
+ var accessTokens = /* @__PURE__ */ new Map();
108
326
  setInterval(() => {
109
327
  const now = Date.now();
110
328
  for (const [code, auth] of pendingCodes) {
@@ -113,74 +331,642 @@ setInterval(() => {
113
331
  for (const [id, client] of registeredClients) {
114
332
  if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
115
333
  }
116
- for (const [token, entry] of refreshTokens) {
117
- if (now - entry.createdAt > REFRESH_TOKEN_TTL_MS) refreshTokens.delete(token);
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) {
338
+ if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
339
+ authFailures.delete(ip);
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]);
346
+ }
118
347
  }
119
348
  }, 6e4);
120
349
  function esc(s) {
121
350
  return String(s ?? "").replace(
122
- /[&"<>]/g,
123
- (c) => ({ "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" })[c]
351
+ /[&"'<>]/g,
352
+ (c) => ({ "&": "&amp;", '"': "&quot;", "'": "&#39;", "<": "&lt;", ">": "&gt;" })[c]
124
353
  );
125
354
  }
126
- app.get("/authorize", (req, res) => {
127
- const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
128
- res.type("html").send(`<!DOCTYPE html>
129
- <html lang="en"><head>
355
+ function authPageShell(title, bodyContent, headExtra = "") {
356
+ return `<!DOCTYPE html>
357
+ <html lang="en" data-theme="parchment-dark"><head>
130
358
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
131
- <title>Authorize \u2014 Product Brain</title>
359
+ <title>${esc(title)} \u2014 Product Brain</title>
360
+ <link rel="preconnect" href="https://fonts.googleapis.com">
361
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
362
+ <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">
363
+ ${headExtra}
132
364
  <style>
133
- *{margin:0;padding:0;box-sizing:border-box}
134
- body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0a;color:#e5e5e5;
135
- display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1rem}
136
- .card{background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:2rem;max-width:400px;width:100%}
137
- h1{font-size:1.2rem;margin-bottom:.25rem}
138
- .sub{color:#999;font-size:.85rem;margin-bottom:1.5rem}
139
- label{display:block;font-size:.85rem;margin-bottom:.4rem;color:#ccc}
140
- input[type=password]{width:100%;padding:.6rem .75rem;background:#111;border:1px solid #444;
141
- border-radius:8px;color:#e5e5e5;font:.85rem/1.4 monospace}
142
- input:focus{outline:none;border-color:#7c3aed}
143
- button{width:100%;padding:.6rem;background:#7c3aed;color:#fff;border:none;
144
- border-radius:8px;font-size:.85rem;cursor:pointer;margin-top:1rem}
145
- button:hover{background:#6d28d9}
146
- .err{color:#ef4444;font-size:.8rem;margin-top:.5rem;display:none}
147
- </style></head><body>
148
- <div class="card">
149
- <h1>Product Brain</h1>
150
- <p class="sub">Enter your API key to connect Claude to your workspace.</p>
151
- <form method="POST" action="/authorize">
152
- <input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
153
- <input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
154
- <input type="hidden" name="code_challenge_method" value="${esc(code_challenge_method)}">
155
- <input type="hidden" name="state" value="${esc(state)}">
156
- <input type="hidden" name="client_id" value="${esc(client_id)}">
157
- <label for="k">API Key</label>
158
- <input type="password" id="k" name="api_key" placeholder="pb_sk_\u2026" required autofocus>
159
- <p class="err" id="e">Key must start with pb_sk_</p>
160
- <button type="submit">Authorize</button>
161
- </form>
365
+ :root{
366
+ --bg:#1a1917;--bg-warm:#201f1c;--surface:#262521;
367
+ --fg1:#e4e0d8;--fg2:#c4bfb4;--fg3:#9a9589;--fg4:#6a6560;--fg-bright:#ffffff;
368
+ --border:rgba(255,255,255,0.07);--border-light:rgba(255,255,255,0.04);
369
+ --accent:#c9b99a;
370
+ --btn-bg:#ffffff;--btn-fg:#1a1917;--btn-hover:#e4e0d8;
371
+ --green:#4ade80;--rose:#ef4444;
372
+ --ghost:rgba(38,37,33,0.55);
373
+ --radius-md:7px;--radius-lg:10px;
374
+ --font-display:"Source Serif 4",ui-serif,Georgia,serif;
375
+ --font-body:"IBM Plex Sans",ui-sans-serif,system-ui,sans-serif;
376
+ --font-mono:"IBM Plex Mono",ui-monospace,"SF Mono",Menlo,monospace;
377
+ }
378
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
379
+ html,body{height:100%}
380
+ body{
381
+ font-family:var(--font-body);font-size:13px;line-height:1.45;
382
+ color:var(--fg1);background:var(--bg);
383
+ min-height:100vh;display:grid;place-items:center;padding:24px;
384
+ -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
385
+ position:relative;overflow:hidden;
386
+ }
387
+ body::before{
388
+ content:"";position:fixed;inset:0;
389
+ background:radial-gradient(900px 600px at 50% 50%,rgba(228,224,216,0.025),transparent 60%);
390
+ pointer-events:none;z-index:0;
391
+ }
392
+ .top-mark{position:fixed;top:22px;left:24px;z-index:5;opacity:0.7}
393
+ ${appLogoStyles}
394
+ .stage{
395
+ width:100%;max-width:460px;text-align:center;
396
+ position:relative;z-index:1;
397
+ display:grid;
398
+ }
399
+ .panel{
400
+ grid-area:1/1;
401
+ transition:opacity 280ms ease-out,transform 380ms cubic-bezier(.2,.6,.2,1),filter 380ms ease-out;
402
+ }
403
+ .panel[hidden]{
404
+ display:block !important;
405
+ opacity:0;transform:scale(0.96) translateY(-2px);filter:blur(6px);
406
+ pointer-events:none;
407
+ }
408
+ .panel:not([hidden]){opacity:1;transform:scale(1);filter:none;pointer-events:auto}
409
+
410
+ /* eyebrows */
411
+ .eyebrow{
412
+ font-family:var(--font-mono);font-size:10px;font-weight:700;
413
+ letter-spacing:0.28em;text-transform:uppercase;color:var(--fg4);
414
+ margin-bottom:24px;display:inline-flex;align-items:center;gap:7px;
415
+ }
416
+ .eyebrow .dot{width:5px;height:5px;border-radius:50%;background:currentColor}
417
+ .eyebrow.danger{color:var(--rose)}
418
+ .eyebrow.success{color:var(--green)}
419
+ .eyebrow.success .dot{box-shadow:0 0 0 3px rgba(74,222,128,0.18)}
420
+
421
+ /* form input */
422
+ .input-wrap{
423
+ display:flex;align-items:center;background:rgba(0,0,0,0.22);
424
+ border:1px solid var(--border);border-radius:var(--radius-md);
425
+ transition:border-color 200ms ease-out,box-shadow 200ms ease-out;
426
+ }
427
+ .input-wrap:focus-within{
428
+ border-color:rgba(228,224,216,0.45);
429
+ box-shadow:0 0 0 3px rgba(228,224,216,0.10);
430
+ }
431
+ .input-wrap.has-error{
432
+ border-color:rgba(239,68,68,0.55);
433
+ box-shadow:0 0 0 3px rgba(239,68,68,0.12);
434
+ animation:shake 360ms cubic-bezier(.36,.07,.19,.97);
435
+ }
436
+ @keyframes shake{
437
+ 10%,90%{transform:translateX(-1px)}20%,80%{transform:translateX(2px)}
438
+ 30%,50%,70%{transform:translateX(-4px)}40%,60%{transform:translateX(4px)}
439
+ }
440
+ .input{
441
+ flex:1;min-width:0;background:transparent;border:0;outline:none;
442
+ padding:16px;
443
+ font-family:var(--font-mono);font-size:14px;color:var(--fg1);letter-spacing:0.02em;
444
+ }
445
+ .input::placeholder{color:var(--fg4)}
446
+ .hint{
447
+ margin-top:10px;font-family:var(--font-mono);font-size:10.5px;
448
+ letter-spacing:0.16em;text-transform:uppercase;
449
+ text-align:left;padding-left:4px;height:14px;color:var(--fg4);
450
+ transition:color 160ms ease-out;
451
+ }
452
+ .hint.is-error{color:var(--rose)}
453
+
454
+ /* primary button */
455
+ .btn-primary{
456
+ width:100%;height:48px;margin-top:14px;
457
+ border:0;border-radius:var(--radius-md);
458
+ background:var(--btn-bg);color:var(--btn-fg);
459
+ font-family:var(--font-body);font-size:14.5px;font-weight:600;letter-spacing:-0.005em;
460
+ cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:10px;
461
+ transition:background 140ms ease-out,transform 80ms ease-out,opacity 200ms ease-out;
462
+ position:relative;overflow:hidden;
463
+ }
464
+ .btn-primary:hover:not([disabled]){background:var(--btn-hover)}
465
+ .btn-primary:active:not([disabled]){transform:translateY(1px)}
466
+ .btn-primary[disabled]{opacity:0.45;cursor:default}
467
+ .spin{
468
+ width:14px;height:14px;border-radius:50%;
469
+ border:1.5px solid currentColor;border-top-color:transparent;
470
+ animation:spin 700ms linear infinite;opacity:0.85;
471
+ }
472
+ @keyframes spin{to{transform:rotate(360deg)}}
473
+
474
+ /* secondary link */
475
+ .small-link{
476
+ margin-top:18px;font-family:var(--font-mono);font-size:11px;
477
+ letter-spacing:0.18em;text-transform:uppercase;color:var(--fg4);
478
+ }
479
+ .small-link a{
480
+ color:var(--fg3);text-decoration:none;border-bottom:1px dotted currentColor;
481
+ padding-bottom:1px;transition:color 140ms;
482
+ }
483
+ .small-link a:hover{color:var(--fg1)}
484
+
485
+ /* orb */
486
+ .orb-wrap{
487
+ position:relative;width:160px;height:160px;margin:0 auto 36px;
488
+ display:grid;place-items:center;
489
+ }
490
+ .orb-ring{position:absolute;border-radius:50%}
491
+ .orb-ring.r1{inset:0;border:1px solid rgba(228,224,216,0.06);animation:drift1 40s linear infinite}
492
+ .orb-ring.r2{inset:18px;border:1px dashed rgba(228,224,216,0.10);animation:drift2 28s linear infinite}
493
+ .orb-ring.r3{inset:38px;border:1px solid rgba(74,222,128,0.20);animation:ringPulse 3.4s ease-in-out infinite}
494
+ @keyframes drift1{to{transform:rotate(360deg)}}
495
+ @keyframes drift2{to{transform:rotate(-360deg)}}
496
+ @keyframes ringPulse{0%,100%{opacity:0.6}50%{opacity:1}}
497
+
498
+ .orb-wrap.is-verifying .orb-ring.r3{
499
+ border-color:rgba(228,224,216,0.20);
500
+ animation:ringPulse 1.1s ease-in-out infinite;
501
+ }
502
+ .orb-wrap.is-verifying .orb-core{
503
+ 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);
504
+ animation:corePulseNeutral 1.6s ease-in-out infinite;
505
+ }
506
+ .orb-wrap.is-verifying .orb-dot{background:var(--fg3);box-shadow:0 0 10px rgba(228,224,216,0.4)}
507
+ @keyframes corePulseNeutral{
508
+ 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)}
509
+ 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)}
510
+ }
511
+
512
+ .orb-wrap.is-error .orb-ring.r3{border-color:rgba(239,68,68,0.30);animation:none;opacity:1}
513
+ .orb-wrap.is-error .orb-ring.r1,.orb-wrap.is-error .orb-ring.r2{animation-play-state:paused}
514
+ .orb-wrap.is-error .orb-core{
515
+ 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);
516
+ animation:none;
517
+ }
518
+ .orb-wrap.is-error .orb-dot{background:var(--rose);box-shadow:0 0 10px rgba(239,68,68,0.6)}
519
+
520
+ .sat-orbit{position:absolute;inset:0;pointer-events:none}
521
+ .sat-orbit.o1{animation:drift1 40s linear infinite}
522
+ .sat-orbit.o2{animation:drift2 56s linear infinite}
523
+ .sat{
524
+ position:absolute;top:50%;left:50%;
525
+ font-family:var(--font-mono);font-size:9px;font-weight:700;letter-spacing:0.14em;
526
+ background:var(--bg);padding:2px 6px;border-radius:3px;
527
+ border:1px solid var(--border-light);
528
+ transform-origin:0 0;opacity:0;
529
+ }
530
+ .panel:not([hidden])[data-state="connected"] .sat{animation:satIn 600ms ease-out forwards}
531
+ @keyframes satIn{from{opacity:0}to{opacity:1}}
532
+ .sat span{display:inline-block;animation:counter 40s linear infinite}
533
+ .sat-orbit.o2 .sat span{animation:counter2 56s linear infinite}
534
+ @keyframes counter{to{transform:rotate(-360deg)}}
535
+ @keyframes counter2{to{transform:rotate(360deg)}}
536
+
537
+ .orb-core{
538
+ position:relative;width:60px;height:60px;border-radius:50%;
539
+ background:radial-gradient(circle at 50% 45%,#1a1a1a 0%,#0c0c0c 60%,#050505 100%);
540
+ border:1px solid rgba(255,255,255,0.06);
541
+ display:grid;place-items:center;
542
+ 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);
543
+ animation:corePulse 3.4s ease-in-out infinite;
544
+ }
545
+ @keyframes corePulse{
546
+ 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)}
547
+ 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)}
548
+ }
549
+ .orb-dot{width:10px;height:10px;border-radius:50%;background:#4ade80;box-shadow:0 0 12px rgba(74,222,128,0.7)}
550
+
551
+ .core-shockwave{
552
+ position:absolute;inset:0;border-radius:50%;
553
+ border:1px solid rgba(74,222,128,0.6);
554
+ opacity:0;pointer-events:none;
555
+ }
556
+ .panel:not([hidden])[data-state="connected"] .core-shockwave{animation:shock 1100ms ease-out 200ms}
557
+ @keyframes shock{
558
+ 0%{opacity:0.7;transform:scale(0.4);border-width:2px}
559
+ 100%{opacity:0;transform:scale(2.4);border-width:1px}
560
+ }
561
+
562
+ /* titles */
563
+ .ok-title{
564
+ font-family:var(--font-display);font-weight:600;
565
+ font-size:38px;line-height:1.05;letter-spacing:-0.025em;
566
+ color:var(--fg-bright);opacity:0;
567
+ }
568
+ .panel:not([hidden]) .ok-title{animation:rise 600ms ease-out 380ms forwards}
569
+ .ok-lead{
570
+ margin:18px auto 0;max-width:22em;font-size:16px;line-height:1.55;color:var(--fg2);
571
+ opacity:0;
572
+ }
573
+ .panel:not([hidden]) .ok-lead{animation:rise 600ms ease-out 500ms forwards}
574
+ .ok-phrase-row{
575
+ margin-top:14px;display:flex;align-items:center;justify-content:center;
576
+ opacity:0;
577
+ }
578
+ .panel:not([hidden]) .ok-phrase-row{animation:rise 600ms ease-out 620ms forwards}
579
+ .cmd.is-copied .cmd-quote-part{display:none}
580
+ .success-cta-wrap{
581
+ margin-top:48px;width:100%;opacity:0;
582
+ }
583
+ .panel:not([hidden]) .success-cta-wrap{animation:rise 600ms ease-out 780ms forwards}
584
+ a.btn-primary.success-cta{color:var(--btn-fg);text-decoration:none}
585
+
586
+ .cmd{
587
+ display:inline-flex;align-items:center;gap:8px;vertical-align:middle;
588
+ font-family:var(--font-mono);font-size:15px;font-weight:500;color:var(--accent);
589
+ background:rgba(201,185,154,0.08);border:1px solid rgba(201,185,154,0.30);
590
+ padding:5px 10px 5px 12px;border-radius:6px;letter-spacing:0.02em;
591
+ cursor:pointer;user-select:none;
592
+ transition:background 140ms ease-out,border-color 140ms ease-out,color 140ms ease-out;
593
+ }
594
+ .cmd:hover{background:rgba(201,185,154,0.14);border-color:rgba(201,185,154,0.50);color:#dcc9a4}
595
+ .cmd.is-copied{color:var(--green);border-color:rgba(74,222,128,0.35);background:rgba(74,222,128,0.08)}
596
+ .cmd .cmd-icon{
597
+ width:12px;height:12px;color:currentColor;opacity:0.75;
598
+ display:inline-grid;place-items:center;
599
+ transition:opacity 140ms;
600
+ }
601
+ .cmd:hover .cmd-icon{opacity:1}
602
+ .cmd.is-copied .cmd-icon{color:var(--green);opacity:1}
603
+ .cmd .cmd-icon svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
604
+
605
+ /* error specifics */
606
+ .err-title{
607
+ font-family:var(--font-display);font-weight:600;
608
+ font-size:32px;line-height:1.1;letter-spacing:-0.02em;
609
+ color:var(--fg1);margin-bottom:12px;
610
+ }
611
+ .err-msg{
612
+ font-size:14.5px;line-height:1.55;color:var(--fg3);
613
+ margin-bottom:28px;
614
+ }
615
+ .err-msg code{
616
+ font-family:var(--font-mono);font-size:12px;
617
+ background:rgba(255,255,255,0.06);padding:1px 5px;border-radius:4px;color:var(--fg2);
618
+ }
619
+ .err-actions{display:flex;gap:8px}
620
+ .btn-secondary{
621
+ flex:1;height:44px;border-radius:var(--radius-md);
622
+ background:transparent;color:var(--fg2);
623
+ border:1px solid var(--border);
624
+ font-family:var(--font-body);font-size:13.5px;font-weight:500;
625
+ cursor:pointer;text-decoration:none;
626
+ display:inline-flex;align-items:center;justify-content:center;
627
+ transition:background 140ms,color 140ms,border-color 140ms;
628
+ }
629
+ .btn-secondary:hover{background:rgba(255,255,255,0.04);color:var(--fg1);border-color:rgba(255,255,255,0.14)}
630
+
631
+ /* verifying */
632
+ .verifying-eyebrow{
633
+ font-family:var(--font-mono);font-size:10px;
634
+ letter-spacing:0.28em;text-transform:uppercase;color:var(--fg3);
635
+ font-weight:700;margin-bottom:14px;
636
+ }
637
+ .verifying-title{
638
+ font-family:var(--font-display);font-weight:600;
639
+ font-size:28px;line-height:1.1;color:var(--fg1);margin-bottom:8px;
640
+ letter-spacing:-0.02em;
641
+ }
642
+ .verifying-sub{font-size:13px;color:var(--fg3);min-height:1.45em}
643
+
644
+ /* form heading */
645
+ .form-title{
646
+ font-family:var(--font-display);font-weight:600;
647
+ font-size:32px;line-height:1.1;letter-spacing:-0.02em;
648
+ color:var(--fg1);margin-bottom:10px;
649
+ }
650
+ .form-sub{font-size:13.5px;color:var(--fg3);margin-bottom:28px;line-height:1.55}
651
+
652
+ @keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
653
+
654
+ @media (prefers-reduced-motion: reduce){
655
+ *,*::before,*::after{
656
+ animation-duration:0.01ms !important;animation-iteration-count:1 !important;
657
+ transition-duration:0.01ms !important;
658
+ }
659
+ }
660
+ </style>
661
+ </head><body>
662
+ <div class="top-mark">${appLogoMarkup({ size: "sm" })}</div>
663
+ <div class="stage">${bodyContent}</div>
664
+ </body></html>`;
665
+ }
666
+ function providerDisplayName(clientName) {
667
+ const name = (clientName ?? "").trim();
668
+ if (!name) return "your assistant";
669
+ return name.length > 40 ? name.slice(0, 40) + "\u2026" : name;
670
+ }
671
+ function successPanelInner(workspaceName, redirectUrl, providerName) {
672
+ return `
673
+ <div class="orb-wrap">
674
+ <div class="orb-ring r1"></div>
675
+ <div class="orb-ring r2"></div>
676
+ <div class="orb-ring r3"></div>
677
+ <div class="sat-orbit o1">
678
+ <span class="sat" style="transform:rotate(20deg) translate(78px) rotate(-20deg);color:#4ade80;animation-delay:600ms"><span>DEC</span></span>
679
+ <span class="sat" style="transform:rotate(140deg) translate(78px) rotate(-140deg);color:#c9b99a;animation-delay:720ms"><span>WP</span></span>
680
+ <span class="sat" style="transform:rotate(260deg) translate(78px) rotate(-260deg);color:#f59e0b;animation-delay:840ms"><span>TEN</span></span>
681
+ </div>
682
+ <div class="sat-orbit o2">
683
+ <span class="sat" style="transform:rotate(80deg) translate(54px) rotate(-80deg);color:#60a5fa;animation-delay:960ms"><span>STD</span></span>
684
+ <span class="sat" style="transform:rotate(220deg) translate(54px) rotate(-220deg);color:#a78bfa;animation-delay:1080ms"><span>INS</span></span>
685
+ </div>
686
+ <div class="core-shockwave"></div>
687
+ <div class="orb-core"><div class="orb-dot"></div></div>
688
+ </div>
689
+ <div class="eyebrow success"><span class="dot"></span>Connected</div>
690
+ <h1 class="ok-title">Product Brain is Live</h1>
691
+ <p class="ok-lead">Return to your assistant, then say</p>
692
+ <div class="ok-phrase-row">
693
+ <button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy &quot;Start PB&quot; and return to ${esc(providerName)}">
694
+ <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>
695
+ <span class="cmd-icon" aria-hidden="true">
696
+ <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
+ </span>
698
+ </button>
699
+ </div>
700
+ <div class="success-cta-wrap">
701
+ <a class="btn-primary success-cta" href="${esc(redirectUrl)}">Continue in ${esc(providerName)}</a>
702
+ </div>
703
+ <!-- workspace name retained as data hook for tests, hidden from view -->
704
+ <span hidden data-field="ws-name">${esc(workspaceName)}</span>`;
705
+ }
706
+ function errorPanelInner(title, trustedDetailHtml, retryUrl) {
707
+ return `
708
+ <div class="orb-wrap is-error">
709
+ <div class="orb-ring r1"></div>
710
+ <div class="orb-ring r2"></div>
711
+ <div class="orb-ring r3"></div>
712
+ <div class="orb-core"><div class="orb-dot"></div></div>
713
+ </div>
714
+ <div class="eyebrow danger"><span class="dot"></span>Couldn't connect</div>
715
+ <h2 class="err-title" data-field="err-title">${esc(title)}</h2>
716
+ <p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
717
+ <div class="err-actions">
718
+ <a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>&larr; Try again</a>
719
+ <a href="https://productbrain.io" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key &rarr;</a>
720
+ </div>`;
721
+ }
722
+ var cmdScript = `
723
+ (function(){
724
+ function bindCmd(pill){
725
+ if(!pill||pill.__bound)return;pill.__bound=true;
726
+ var textEl=pill.querySelector('[data-cmd-text]');
727
+ var svgEl=pill.querySelector('[data-cmd-svg]');
728
+ var redirectUrl=pill.getAttribute('data-redirect')||'';
729
+ pill.addEventListener('click',function(){
730
+ var done=function(){
731
+ pill.classList.add('is-copied');
732
+ if(textEl)textEl.textContent='Copied';
733
+ if(svgEl)svgEl.innerHTML='<polyline points="4 12 10 18 20 6"/>';
734
+ setTimeout(function(){if(redirectUrl)window.location.assign(redirectUrl)},900);
735
+ };
736
+ try{
737
+ if(navigator.clipboard&&navigator.clipboard.writeText){
738
+ navigator.clipboard.writeText('Start PB').then(done,done);
739
+ }else{done()}
740
+ }catch(e){done()}
741
+ });
742
+ }
743
+ document.querySelectorAll('[data-cmd-pill]').forEach(bindCmd);
744
+ })();
745
+ `;
746
+ function authorizeFormPage(params) {
747
+ const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = params;
748
+ const providerName = providerDisplayName(params.client_name);
749
+ const body = `
750
+ <!-- \u2500\u2500\u2500 CONNECT \u2500\u2500\u2500 -->
751
+ <div class="panel" id="p-connect" data-state="connect">
752
+ <div class="eyebrow">Connect Product Brain</div>
753
+ <h1 class="form-title">Paste your API key</h1>
754
+ <p class="form-sub">Give <span data-provider>${esc(providerName)}</span> access to your workspace memory.</p>
755
+ <form method="POST" action="/authorize" id="f" autocomplete="off">
756
+ <input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
757
+ <input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
758
+ <input type="hidden" name="code_challenge_method" value="${esc(code_challenge_method)}">
759
+ <input type="hidden" name="state" value="${esc(state)}">
760
+ <input type="hidden" name="client_id" value="${esc(client_id)}">
761
+ <div class="input-wrap" id="iw">
762
+ <input type="password" id="k" name="api_key" class="input input-full" placeholder="pb_sk_\u2026" required autofocus spellcheck="false">
763
+ </div>
764
+ <div class="hint" id="hint" hidden></div>
765
+ <button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
766
+ </form>
767
+ <div class="small-link"><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">No key? Generate one &rarr;</a></div>
768
+ </div>
769
+
770
+ <!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
771
+ <div class="panel" id="p-verifying" data-state="verifying" hidden>
772
+ <div class="orb-wrap is-verifying">
773
+ <div class="orb-ring r1"></div>
774
+ <div class="orb-ring r2"></div>
775
+ <div class="orb-ring r3"></div>
776
+ <div class="orb-core"><div class="orb-dot"></div></div>
777
+ </div>
778
+ <div class="verifying-eyebrow">Handshake</div>
779
+ <h2 class="verifying-title">Verifying key\u2026</h2>
780
+ <p class="verifying-sub" id="verify-sub">Checking workspace \xB7 \u2026</p>
781
+ </div>
782
+
783
+ <!-- \u2500\u2500\u2500 CONNECTED (filled by JS from JSON response) \u2500\u2500\u2500 -->
784
+ <div class="panel" id="p-connected" data-state="connected" hidden></div>
785
+
786
+ <!-- \u2500\u2500\u2500 ERROR (filled by JS from JSON response) \u2500\u2500\u2500 -->
787
+ <div class="panel" id="p-error" data-state="error" hidden></div>
788
+
789
+ <template id="tpl-connected">${successPanelInner("__WS__", "__URL__", "__PROVIDER__")}</template>
790
+ <template id="tpl-error">${errorPanelInner("__TITLE__", "__DETAIL__", "__RETRY__")}</template>
791
+
792
+ <script>
793
+ ${cmdScript}
794
+ (function(){
795
+ 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');
796
+ var pConnect=document.getElementById('p-connect'),pVerify=document.getElementById('p-verifying'),pOk=document.getElementById('p-connected'),pErr=document.getElementById('p-error');
797
+ var verifySub=document.getElementById('verify-sub');
798
+
799
+ function show(panel){
800
+ [pConnect,pVerify,pOk,pErr].forEach(function(p){
801
+ if(p===panel){p.removeAttribute('hidden')}else{p.setAttribute('hidden','')}
802
+ });
803
+ }
804
+
805
+ function syncInput(){
806
+ sb.disabled=!k.value.trim();
807
+ iw.classList.remove('has-error');
808
+ hint.classList.remove('is-error');
809
+ hint.textContent='';
810
+ hint.setAttribute('hidden','');
811
+ }
812
+ k.addEventListener('input',syncInput);
813
+ k.addEventListener('keydown',function(e){
814
+ if(e.key==='Escape'){k.value='';syncInput()}
815
+ });
816
+
817
+ function showError(title,detailHtml){
818
+ var tpl=document.getElementById('tpl-error');
819
+ var html=tpl.innerHTML
820
+ .replace('__TITLE__',title.replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]}))
821
+ .replace('__DETAIL__',detailHtml)
822
+ .replace('__RETRY__','#');
823
+ pErr.innerHTML=html;
824
+ var retry=pErr.querySelector('[data-retry-link]');
825
+ if(retry){retry.addEventListener('click',function(e){e.preventDefault();show(pConnect);k.focus();k.select()})}
826
+ show(pErr);
827
+ }
828
+
829
+ function showSuccess(workspaceName,redirectUrl,providerName){
830
+ var tpl=document.getElementById('tpl-connected');
831
+ var safeWs=String(workspaceName||'').replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]});
832
+ var safeProv=String(providerName||'your assistant').replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]});
833
+ var safeUrl=String(redirectUrl||'').replace(/"/g,'&quot;').replace(/[<>]/g,function(c){return{'<':'&lt;','>':'&gt;'}[c]});
834
+ var html=tpl.innerHTML.split('__WS__').join(safeWs).split('__URL__').join(safeUrl).split('__PROVIDER__').join(safeProv);
835
+ pOk.innerHTML=html;
836
+ pOk.querySelectorAll('[data-cmd-pill]').forEach(function(pill){
837
+ pill.__bound=false;
838
+ });
839
+ // Re-run binder
840
+ var s=document.createElement('script');s.textContent=${JSON.stringify(cmdScript)};document.body.appendChild(s);s.remove();
841
+ show(pOk);
842
+ }
843
+
844
+ f.addEventListener('submit',function(e){
845
+ e.preventDefault();
846
+ var v=k.value.trim();
847
+ 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
+ sb.disabled=true;bt.textContent='Verifying';
850
+ show(pVerify);
851
+
852
+ var steps=['Checking workspace \xB7 \u2026','Loading chain \xB7 \u2026','Establishing memory \xB7 \u2026'];
853
+ var i=0;verifySub.textContent=steps[0];
854
+ var ti=setInterval(function(){i++;if(i>=steps.length){clearInterval(ti);return}verifySub.textContent=steps[i]},900);
855
+
856
+ var minDelay=new Promise(function(r){setTimeout(r,2800)});
857
+ var fd=new FormData(f);
858
+ var body=new URLSearchParams();
859
+ fd.forEach(function(val,key){body.append(key,String(val))});
860
+
861
+ var req=fetch('/authorize',{
862
+ method:'POST',
863
+ headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},
864
+ body:body.toString(),
865
+ credentials:'same-origin'
866
+ }).then(function(r){return r.json().then(function(j){return{status:r.status,body:j}})});
867
+
868
+ Promise.all([req,minDelay]).then(function(arr){
869
+ clearInterval(ti);
870
+ var res=arr[0];
871
+ if(res.body&&res.body.ok){
872
+ showSuccess(res.body.workspaceName,res.body.redirectUrl,res.body.providerName);
873
+ }else{
874
+ showError(res.body&&res.body.title||'Couldn\\'t connect',res.body&&res.body.detail||'Try again, or generate a new key.');
875
+ sb.disabled=false;bt.textContent='Connect';
876
+ }
877
+ }).catch(function(){
878
+ clearInterval(ti);
879
+ showError('Network error','We couldn\\'t reach Product Brain. Check your connection and try again.');
880
+ sb.disabled=false;bt.textContent='Connect';
881
+ });
882
+ });
883
+
884
+ k.focus();
885
+ })();
886
+ </script>`;
887
+ return authPageShell("Connect Product Brain", body);
888
+ }
889
+ function authorizeSuccessPage(workspaceName, redirectUrl, providerName) {
890
+ const body = `
891
+ <div class="panel" data-state="connected">
892
+ ${successPanelInner(workspaceName, redirectUrl, providerName)}
162
893
  </div>
163
- <script>document.querySelector("form").onsubmit=function(e){
164
- if(!document.getElementById("k").value.startsWith("pb_sk_")){
165
- e.preventDefault();document.getElementById("e").style.display="block"}}</script>
166
- </body></html>`);
894
+ <script>${cmdScript}</script>`;
895
+ return authPageShell("Connected", body);
896
+ }
897
+ function authorizeErrorPage(title, trustedDetailHtml, retryUrl) {
898
+ const body = `
899
+ <div class="panel" data-state="error">
900
+ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
901
+ </div>`;
902
+ return authPageShell("Connection error", body);
903
+ }
904
+ app.get("/authorize", authLimiter, (req, res) => {
905
+ const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
906
+ const cid = String(client_id ?? "");
907
+ const clientName = cid && registeredClients.has(cid) ? registeredClients.get(cid).client_name : void 0;
908
+ res.type("html").send(authorizeFormPage({
909
+ redirect_uri: String(redirect_uri ?? ""),
910
+ code_challenge: String(code_challenge ?? ""),
911
+ code_challenge_method: String(code_challenge_method ?? "S256"),
912
+ state: String(state ?? ""),
913
+ client_id: cid,
914
+ client_name: clientName
915
+ }));
167
916
  });
168
917
  app.post(
169
918
  "/authorize",
919
+ authLimiter,
170
920
  express.urlencoded({ extended: false }),
171
- (req, res) => {
172
- const { api_key, redirect_uri, code_challenge, state, client_id } = req.body;
921
+ async (req, res) => {
922
+ const { api_key, redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
923
+ const wantsJson = String(req.headers["accept"] ?? "").includes("application/json");
924
+ const retryParams = new URLSearchParams({
925
+ redirect_uri: redirect_uri ?? "",
926
+ code_challenge: code_challenge ?? "",
927
+ code_challenge_method: code_challenge_method ?? "S256",
928
+ ...state ? { state } : {},
929
+ ...client_id ? { client_id } : {}
930
+ }).toString();
931
+ const retryUrl = `/authorize?${retryParams}`;
932
+ function sendError(title, trustedDetailHtml, status = 400) {
933
+ if (wantsJson) {
934
+ res.status(status).json({ ok: false, title, detail: trustedDetailHtml });
935
+ } else {
936
+ res.status(status).type("html").send(authorizeErrorPage(title, trustedDetailHtml, retryUrl));
937
+ }
938
+ }
173
939
  if (!api_key?.startsWith("pb_sk_")) {
174
- res.status(400).send("Invalid API key");
940
+ sendError(
941
+ "Invalid key format",
942
+ "API keys start with <code>pb_sk_</code>. Check your key and try again."
943
+ );
175
944
  return;
176
945
  }
177
- if (!client_id || !registeredClients.has(client_id)) {
946
+ if (!client_id) {
178
947
  res.status(400).json({
179
948
  error: "invalid_request",
180
- error_description: "Unknown or missing client_id"
949
+ error_description: "client_id is required"
181
950
  });
182
951
  return;
183
952
  }
953
+ if (!registeredClients.has(client_id)) {
954
+ if (typeof redirect_uri === "string" && redirect_uri.startsWith("https://")) {
955
+ registeredClients.set(client_id, {
956
+ client_id,
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
+ }
969
+ }
184
970
  const client = registeredClients.get(client_id);
185
971
  if (!client.redirect_uris.includes(redirect_uri)) {
186
972
  res.status(400).json({
@@ -189,7 +975,49 @@ app.post(
189
975
  });
190
976
  return;
191
977
  }
192
- const code = randomUUID();
978
+ let workspaceName = "Your Workspace";
979
+ try {
980
+ const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
981
+ const fallbackUrls = (process.env.CONVEX_FALLBACK_URLS ?? "").split(",").map((u) => u.trim().replace(/\/$/, "")).filter(Boolean);
982
+ const candidates = [primaryUrl, ...fallbackUrls];
983
+ let foundUrl;
984
+ let anyDefinitiveReject = false;
985
+ for (const url2 of candidates) {
986
+ let checkData = null;
987
+ try {
988
+ const checkRes = await fetch(`${url2}/api/key-check`, {
989
+ method: "POST",
990
+ headers: { "Authorization": `Bearer ${api_key}`, "Content-Type": "application/json" },
991
+ signal: AbortSignal.timeout(5e3)
992
+ });
993
+ checkData = await checkRes.json();
994
+ } catch {
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 {
1018
+ process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
1019
+ }
1020
+ const code = randomUUID2();
193
1021
  pendingCodes.set(code, {
194
1022
  apiKey: api_key,
195
1023
  codeChallenge: code_challenge,
@@ -199,39 +1027,42 @@ app.post(
199
1027
  const url = new URL(redirect_uri);
200
1028
  url.searchParams.set("code", code);
201
1029
  if (state) url.searchParams.set("state", state);
202
- res.redirect(302, url.toString());
1030
+ const redirectUrl = url.toString();
1031
+ const providerName = providerDisplayName(client.client_name);
1032
+ if (wantsJson) {
1033
+ res.json({ ok: true, workspaceName, redirectUrl, providerName });
1034
+ } else {
1035
+ res.type("html").send(authorizeSuccessPage(workspaceName, redirectUrl, providerName));
1036
+ }
203
1037
  }
204
1038
  );
205
1039
  function issueTokens(apiKey) {
206
- const refreshToken = `pb_rt_${randomUUID()}`;
207
- refreshTokens.set(refreshToken, { apiKey, createdAt: Date.now() });
208
1040
  return {
209
1041
  access_token: apiKey,
210
1042
  token_type: "Bearer",
211
- expires_in: ACCESS_TOKEN_TTL,
212
- refresh_token: refreshToken
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)
213
1047
  };
214
1048
  }
215
1049
  app.post(
216
1050
  "/oauth/token",
1051
+ authLimiter,
217
1052
  express.urlencoded({ extended: false }),
218
1053
  express.json(),
219
1054
  (req, res) => {
220
1055
  const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
221
1056
  if (grant_type === "refresh_token") {
222
- const entry = refreshTokens.get(refresh_token);
223
- if (!entry) {
224
- res.status(400).json({ error: "invalid_grant", error_description: "Invalid refresh token" });
225
- return;
226
- }
227
- if (Date.now() - entry.createdAt > REFRESH_TOKEN_TTL_MS) {
228
- refreshTokens.delete(refresh_token);
229
- res.status(400).json({ error: "invalid_grant", error_description: "Refresh token expired" });
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
+ });
230
1063
  return;
231
1064
  }
232
- const apiKey = entry.apiKey;
233
- refreshTokens.delete(refresh_token);
234
- res.json(issueTokens(apiKey));
1065
+ res.json(issueTokens(verified.apiKey));
235
1066
  return;
236
1067
  }
237
1068
  if (grant_type !== "authorization_code") {
@@ -261,34 +1092,87 @@ var mcpLimiter = rateLimit({
261
1092
  max: 120,
262
1093
  standardHeaders: true,
263
1094
  legacyHeaders: false,
264
- message: { error: "Too many requests. Try again later." }
1095
+ handler: (req, res) => {
1096
+ const r = req.rateLimit;
1097
+ const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
1098
+ send429(res, buildRateLimitError({
1099
+ retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
1100
+ limit: r?.limit ?? 120,
1101
+ current: r?.used ?? 0,
1102
+ id: req.body?.id ?? null
1103
+ // mirror the JSON-RPC id when a POST body is present
1104
+ }));
1105
+ }
265
1106
  });
1107
+ 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
+ }
266
1135
  app.get("/health", (_req, res) => {
267
1136
  res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
268
1137
  });
269
1138
  var sessions = /* @__PURE__ */ new Map();
270
1139
  var SESSION_TTL_MS = 30 * 60 * 1e3;
271
1140
  var MAX_SESSIONS = 200;
1141
+ var EVICT_GRACE_MS = 6e4;
1142
+ var MAX_SESSIONS_PER_KEY = Math.max(
1143
+ 1,
1144
+ Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
1145
+ );
272
1146
  function evictStaleSessions() {
273
1147
  const now = Date.now();
274
1148
  for (const [id, entry] of sessions) {
275
1149
  if (now - entry.lastAccess > SESSION_TTL_MS) {
276
- logSessionLifecycle("session_deleted", id, "ttl");
1150
+ logSessionLifecycle(
1151
+ "session_deleted",
1152
+ id,
1153
+ entry.inFlight > 0 ? "inflight_leak" : "ttl"
1154
+ );
277
1155
  entry.transport.close().catch(() => {
278
1156
  });
279
1157
  sessions.delete(id);
280
1158
  }
281
1159
  }
282
1160
  if (sessions.size > MAX_SESSIONS) {
283
- const sorted = [...sessions.entries()].sort(
284
- (a, b) => a[1].lastAccess - b[1].lastAccess
285
- );
286
- for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
1161
+ const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
1162
+ const overflow = sessions.size - MAX_SESSIONS;
1163
+ for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
287
1164
  logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
288
1165
  sorted[i][1].transport.close().catch(() => {
289
1166
  });
290
1167
  sessions.delete(sorted[i][0]);
291
1168
  }
1169
+ if (sessions.size > MAX_SESSIONS) {
1170
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1171
+ process.stderr.write(
1172
+ `[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
1173
+ `
1174
+ );
1175
+ }
292
1176
  }
293
1177
  }
294
1178
  setInterval(evictStaleSessions, 6e4);
@@ -296,7 +1180,20 @@ function extractBearerKey(req) {
296
1180
  const header = req.headers?.authorization;
297
1181
  if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
298
1182
  const token = header.slice(7).trim();
299
- return token.startsWith("pb_sk_") ? token : null;
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;
300
1197
  }
301
1198
  function send401(req, res) {
302
1199
  const base = baseUrl(req);
@@ -319,9 +1216,17 @@ function logSessionLifecycle(event, sessionId, reason) {
319
1216
  `);
320
1217
  }
321
1218
  app.post("/mcp", mcpLimiter, async (req, res) => {
1219
+ const reqIp = req.ip ?? "unknown";
1220
+ if (checkAuthBlock(reqIp)) {
1221
+ const rec = authFailures.get(reqIp);
1222
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - Date.now()) / 1e3)) : 0;
1223
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1224
+ return;
1225
+ }
322
1226
  const apiKey = extractBearerKey(req);
323
1227
  if (!apiKey) {
324
1228
  logRequest("POST", "auth_fail");
1229
+ recordAuthFailure(reqIp);
325
1230
  send401(req, res);
326
1231
  return;
327
1232
  }
@@ -331,28 +1236,99 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
331
1236
  await runWithAuth({ apiKey }, async () => {
332
1237
  if (sessionId && sessions.has(sessionId)) {
333
1238
  const entry = sessions.get(sessionId);
334
- entry.lastAccess = Date.now();
335
- await entry.transport.handleRequest(req, res, req.body);
336
- logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1239
+ if (entry.keyHash !== hashKey(apiKey)) {
1240
+ res.status(403).json({
1241
+ jsonrpc: "2.0",
1242
+ error: { code: -32e3, message: "Session key mismatch" },
1243
+ id: null
1244
+ });
1245
+ return;
1246
+ }
1247
+ try {
1248
+ entry.inFlight++;
1249
+ entry.lastAccess = Date.now();
1250
+ await entry.transport.handleRequest(req, res, req.body);
1251
+ logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1252
+ } finally {
1253
+ entry.inFlight--;
1254
+ entry.lastAccess = Date.now();
1255
+ }
337
1256
  } else if (!sessionId && isInitializeRequest(req.body)) {
1257
+ const keyH = hashKey(apiKey);
1258
+ const plan = planKeyAdmission(
1259
+ sessions,
1260
+ keyH,
1261
+ MAX_SESSIONS_PER_KEY,
1262
+ Date.now(),
1263
+ { ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
1264
+ );
1265
+ for (const sid2 of plan.evict) {
1266
+ const victim = sessions.get(sid2);
1267
+ sessions.delete(sid2);
1268
+ victim?.transport.close().catch(() => {
1269
+ });
1270
+ logSessionLifecycle("session_deleted", sid2, "lru_evict");
1271
+ }
1272
+ if (!plan.admit) {
1273
+ send429(res, buildSessionCapError({
1274
+ retryAfterSeconds: plan.retryAfterSeconds,
1275
+ limit: MAX_SESSIONS_PER_KEY,
1276
+ current: countKeySessions(sessions, keyH),
1277
+ poll: plan.retryIsPoll,
1278
+ id: req.body?.id ?? null
1279
+ }));
1280
+ return;
1281
+ }
1282
+ const sid = randomUUID2();
1283
+ let initialized = false;
338
1284
  const transport = new StreamableHTTPServerTransport({
339
- sessionIdGenerator: () => randomUUID(),
340
- onsessioninitialized: (sid) => {
341
- sessions.set(sid, { transport, lastAccess: Date.now() });
342
- logSessionLifecycle("session_created", sid);
1285
+ sessionIdGenerator: () => sid,
1286
+ onsessioninitialized: (s) => {
1287
+ initialized = true;
1288
+ logSessionLifecycle("session_created", s);
343
1289
  }
1290
+ // log only — no map write
344
1291
  });
1292
+ const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
1293
+ sessions.set(sid, reserved);
345
1294
  transport.onclose = () => {
346
- const sid = transport.sessionId;
347
- if (sid) {
348
- logSessionLifecycle("session_deleted", sid, "onclose");
349
- sessions.delete(sid);
1295
+ const s = transport.sessionId ?? sid;
1296
+ if (sessions.has(s)) {
1297
+ sessions.delete(s);
1298
+ logSessionLifecycle("session_deleted", s, "onclose");
350
1299
  }
351
1300
  };
352
- const server = createProductBrainServer();
353
- await server.connect(transport);
354
- await transport.handleRequest(req, res, req.body);
355
- logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1301
+ try {
1302
+ const server = createProductBrainServer();
1303
+ await server.connect(transport);
1304
+ await transport.handleRequest(req, res, req.body);
1305
+ logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1306
+ } catch (e) {
1307
+ transport.onclose = void 0;
1308
+ sessions.delete(sid);
1309
+ throw e;
1310
+ } finally {
1311
+ if (!initialized) {
1312
+ transport.onclose = void 0;
1313
+ if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
1314
+ } else {
1315
+ const e = sessions.get(sid);
1316
+ if (e) {
1317
+ e.inFlight--;
1318
+ e.lastAccess = Date.now();
1319
+ }
1320
+ }
1321
+ }
1322
+ } else if (sessionId) {
1323
+ process.stderr.write(
1324
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
1325
+ `
1326
+ );
1327
+ res.status(404).json({
1328
+ jsonrpc: "2.0",
1329
+ error: { code: -32001, message: "Session not found \u2014 re-initialise" },
1330
+ id: null
1331
+ });
356
1332
  } else {
357
1333
  process.stderr.write(
358
1334
  `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
@@ -377,20 +1353,44 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
377
1353
  }
378
1354
  });
379
1355
  app.get("/mcp", mcpLimiter, async (req, res) => {
1356
+ const reqIp = req.ip ?? "unknown";
1357
+ if (checkAuthBlock(reqIp)) {
1358
+ const rec = authFailures.get(reqIp);
1359
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - Date.now()) / 1e3)) : 0;
1360
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1361
+ return;
1362
+ }
380
1363
  const apiKey = extractBearerKey(req);
381
1364
  if (!apiKey) {
382
1365
  logRequest("GET", "auth_fail");
1366
+ recordAuthFailure(reqIp);
383
1367
  send401(req, res);
384
1368
  return;
385
1369
  }
386
1370
  const sessionId = req.headers["mcp-session-id"];
387
- if (!sessionId || !sessions.has(sessionId)) {
388
- res.status(400).send("Invalid or missing session ID");
1371
+ if (!sessionId) {
1372
+ res.status(400).send("Missing Mcp-Session-Id header");
1373
+ return;
1374
+ }
1375
+ if (!sessions.has(sessionId)) {
1376
+ process.stderr.write(
1377
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale GET sessionId=${sessionId}
1378
+ `
1379
+ );
1380
+ res.status(404).send("Session not found \u2014 re-initialise");
389
1381
  return;
390
1382
  }
391
1383
  try {
392
1384
  await runWithAuth({ apiKey }, async () => {
393
1385
  const entry = sessions.get(sessionId);
1386
+ if (entry.keyHash !== hashKey(apiKey)) {
1387
+ res.status(403).json({
1388
+ jsonrpc: "2.0",
1389
+ error: { code: -32e3, message: "Session key mismatch" },
1390
+ id: null
1391
+ });
1392
+ return;
1393
+ }
394
1394
  entry.lastAccess = Date.now();
395
1395
  await entry.transport.handleRequest(req, res);
396
1396
  logRequest("GET", "ok", sessionId);
@@ -400,20 +1400,44 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
400
1400
  }
401
1401
  });
402
1402
  app.delete("/mcp", mcpLimiter, async (req, res) => {
1403
+ const reqIp = req.ip ?? "unknown";
1404
+ if (checkAuthBlock(reqIp)) {
1405
+ const rec = authFailures.get(reqIp);
1406
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - Date.now()) / 1e3)) : 0;
1407
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
1408
+ return;
1409
+ }
403
1410
  const apiKey = extractBearerKey(req);
404
1411
  if (!apiKey) {
405
1412
  logRequest("DELETE", "auth_fail");
1413
+ recordAuthFailure(reqIp);
406
1414
  send401(req, res);
407
1415
  return;
408
1416
  }
409
1417
  const sessionId = req.headers["mcp-session-id"];
410
- if (!sessionId || !sessions.has(sessionId)) {
411
- res.status(400).send("Invalid or missing session ID");
1418
+ if (!sessionId) {
1419
+ res.status(400).send("Missing Mcp-Session-Id header");
1420
+ return;
1421
+ }
1422
+ if (!sessions.has(sessionId)) {
1423
+ process.stderr.write(
1424
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale DELETE sessionId=${sessionId}
1425
+ `
1426
+ );
1427
+ res.status(404).send("Session not found \u2014 re-initialise");
412
1428
  return;
413
1429
  }
414
1430
  try {
415
1431
  await runWithAuth({ apiKey }, async () => {
416
1432
  const entry = sessions.get(sessionId);
1433
+ if (entry.keyHash !== hashKey(apiKey)) {
1434
+ res.status(403).json({
1435
+ jsonrpc: "2.0",
1436
+ error: { code: -32e3, message: "Session key mismatch" },
1437
+ id: null
1438
+ });
1439
+ return;
1440
+ }
417
1441
  await entry.transport.handleRequest(req, res);
418
1442
  logRequest("DELETE", "ok", sessionId);
419
1443
  });