@productbrain/mcp 0.0.1-beta.27 → 0.0.1-beta.2721

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