@productbrain/mcp 0.0.1-beta.181 → 0.0.1-beta.1815

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,25 +1,277 @@
1
1
  import {
2
- DEFAULT_CLOUD_URL,
3
2
  SERVER_VERSION,
4
- bootstrapHttp,
5
3
  createProductBrainServer,
6
- getKeyState,
7
- hashKey,
8
- initFeatureFlags,
9
- runWithAuth
10
- } from "./chunk-XPFSARXP.js";
4
+ initFeatureFlags
5
+ } from "./chunk-VRAAJA3M.js";
11
6
  import {
7
+ DEFAULT_CLOUD_URL,
8
+ bootstrapHttp,
9
+ getKeyState,
12
10
  getPostHogClient,
11
+ hashKey,
13
12
  initAnalytics,
13
+ runWithAuth,
14
14
  shutdownAnalytics
15
- } from "./chunk-YMF3IQ5E.js";
15
+ } from "./chunk-AMJILQKI.js";
16
16
 
17
17
  // src/http.ts
18
- import { createHash, randomUUID } from "crypto";
18
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
19
19
  import express from "express";
20
20
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21
21
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
22
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
23
275
  bootstrapHttp();
24
276
  initAnalytics();
25
277
  initFeatureFlags(getPostHogClient());
@@ -32,10 +284,10 @@ function baseUrl(req) {
32
284
  var app = express();
33
285
  app.set("trust proxy", 1);
34
286
  app.use(express.json());
35
- 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);
36
288
  app.use((_req, res, next) => {
37
289
  const origin = _req.headers.origin;
38
- if (ALLOWED_ORIGINS && origin && ALLOWED_ORIGINS.includes(origin)) {
290
+ if (origin && ALLOWED_ORIGINS.includes(origin)) {
39
291
  res.setHeader("Access-Control-Allow-Origin", origin);
40
292
  }
41
293
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
@@ -43,7 +295,7 @@ app.use((_req, res, next) => {
43
295
  "Access-Control-Allow-Headers",
44
296
  "Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
45
297
  );
46
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
298
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
47
299
  if (_req.method === "OPTIONS") {
48
300
  res.status(204).end();
49
301
  return;
@@ -102,7 +354,7 @@ app.post(
102
354
  });
103
355
  return;
104
356
  }
105
- const clientId = `pb_client_${randomUUID()}`;
357
+ const clientId = `pb_client_${randomUUID2()}`;
106
358
  const client = {
107
359
  client_id: clientId,
108
360
  redirect_uris,
@@ -123,10 +375,6 @@ app.post(
123
375
  var pendingCodes = /* @__PURE__ */ new Map();
124
376
  var ACCESS_TOKEN_TTL = 3600;
125
377
  var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
126
- var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
127
- var refreshTokens = /* @__PURE__ */ new Map();
128
- var MAX_REFRESH_TOKENS = 2e3;
129
- var MAX_REFRESH_TOKENS_PER_KEY = 20;
130
378
  var accessTokens = /* @__PURE__ */ new Map();
131
379
  setInterval(() => {
132
380
  const now = Date.now();
@@ -136,15 +384,6 @@ setInterval(() => {
136
384
  for (const [id, client] of registeredClients) {
137
385
  if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
138
386
  }
139
- for (const [token, entry] of refreshTokens) {
140
- if (now - entry.createdAt > REFRESH_TOKEN_TTL_MS) refreshTokens.delete(token);
141
- }
142
- if (refreshTokens.size > MAX_REFRESH_TOKENS) {
143
- const sorted = [...refreshTokens.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
144
- for (let i = 0; i < sorted.length - MAX_REFRESH_TOKENS; i++) {
145
- refreshTokens.delete(sorted[i][0]);
146
- }
147
- }
148
387
  for (const [token, entry] of accessTokens) {
149
388
  if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
150
389
  }
@@ -168,145 +407,564 @@ function esc(s) {
168
407
  }
169
408
  function authPageShell(title, bodyContent, headExtra = "") {
170
409
  return `<!DOCTYPE html>
171
- <html lang="en"><head>
410
+ <html lang="en" data-theme="parchment-dark"><head>
172
411
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
173
- <title>${title} \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">
174
416
  ${headExtra}
175
417
  <style>
176
- :root{--bg:#0d0c10;--surface:#18161e;--border:#2d2840;--border-focus:#7c3aed;--text:#e8e4f0;--muted:#7b7590;--accent:#7c3aed;--accent-h:#6d28d9;--err:#f87171;--ok:#34d399}
177
- *{margin:0;padding:0;box-sizing:border-box}
178
- body{font-family:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1.5rem}
179
- body::before{content:'';position:fixed;inset:0;background-image:radial-gradient(circle,#3d3560 1px,transparent 1px);background-size:28px 28px;opacity:.22;pointer-events:none;z-index:0}
180
- .card{position:relative;z-index:1;background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:2rem 2rem 1.75rem;max-width:380px;width:100%}
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
+ }
181
713
  </style>
182
714
  </head><body>
183
- <div class="card">${bodyContent}</div>
715
+ <div class="top-mark">${appLogoMarkup({ size: "sm" })}</div>
716
+ <div class="stage">${bodyContent}</div>
184
717
  </body></html>`;
185
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>
752
+ </div>
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
+ `;
186
799
  function authorizeFormPage(params) {
187
800
  const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = params;
801
+ const providerName = providerDisplayName(params.client_name);
188
802
  const body = `
189
- <style>
190
- .brand{display:flex;align-items:center;gap:.55rem;margin-bottom:1.5rem}
191
- .brand-icon{width:30px;height:30px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:1rem;flex-shrink:0}
192
- .brand-name{font-size:.88rem;font-weight:600;letter-spacing:-.01em}
193
- h1{font-size:1.3rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.35rem;line-height:1.3}
194
- .sub{font-size:.8rem;color:var(--muted);margin-bottom:1.75rem;line-height:1.55}
195
- label{display:block;font-size:.72rem;font-weight:600;color:#a89fc4;margin-bottom:.4rem;letter-spacing:.04em;text-transform:uppercase}
196
- .inp-wrap{position:relative}
197
- input[type=password]{width:100%;padding:.65rem .85rem;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font:.875rem/1.4 'JetBrains Mono','Fira Code',ui-monospace,monospace;outline:none;transition:border-color .15s,box-shadow .15s}
198
- input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,58,237,.15)}
199
- input.err{border-color:var(--err);box-shadow:0 0 0 3px rgba(248,113,113,.12)}
200
- .field-row{display:flex;align-items:center;justify-content:space-between;margin-top:.4rem}
201
- .hint{font-size:.73rem;color:var(--muted)}
202
- .hint code{font-size:.72rem;background:rgba(255,255,255,.06);padding:.1rem .3rem;border-radius:4px}
203
- .get-key{font-size:.73rem;color:var(--accent);text-decoration:none;opacity:.85;transition:opacity .1s}
204
- .get-key:hover{opacity:1;text-decoration:underline}
205
- .err-msg{font-size:.73rem;color:var(--err);margin-top:.35rem;display:none}
206
- .err-msg.show{display:block}
207
- .btn{width:100%;padding:.7rem;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:.88rem;font-weight:600;cursor:pointer;margin-top:1.25rem;letter-spacing:-.01em;transition:background .15s,transform .1s;display:flex;align-items:center;justify-content:center;gap:.5rem}
208
- .btn:hover{background:var(--accent-h)}
209
- .btn:active{transform:scale(.98)}
210
- .btn:disabled{opacity:.6;cursor:not-allowed;transform:none}
211
- .spinner{width:15px;height:15px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;display:none}
212
- @keyframes spin{to{transform:rotate(360deg)}}
213
- .divider{height:1px;background:var(--border);margin:1.5rem -2rem}
214
- .footer{font-size:.7rem;color:var(--muted);text-align:center;padding-top:.5rem;line-height:1.6}
215
- .footer a{color:var(--muted);text-decoration:underline}
216
- </style>
217
- <div class="brand"><div class="brand-icon">&#11041;</div><span class="brand-name">Product Brain</span></div>
218
- <h1>Connect to Claude</h1>
219
- <p class="sub">Enter your API key to give Claude access to your workspace.</p>
220
- <form method="POST" action="/authorize" id="f">
221
- <input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
222
- <input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
223
- <input type="hidden" name="code_challenge_method" value="${esc(code_challenge_method)}">
224
- <input type="hidden" name="state" value="${esc(state)}">
225
- <input type="hidden" name="client_id" value="${esc(client_id)}">
226
- <label for="k">API Key</label>
227
- <div class="inp-wrap">
228
- <input type="password" id="k" name="api_key" placeholder="pb_sk_\u2026" required autofocus autocomplete="off">
229
- </div>
230
- <div class="field-row">
231
- <span class="hint">Starts with <code>pb_sk_</code></span>
232
- <a href="https://productbrain.io" target="_blank" rel="noopener noreferrer" class="get-key">No key? Get one &rarr;</a>
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>
233
830
  </div>
234
- <p class="err-msg" id="em">Key must start with <code>pb_sk_</code></p>
235
- <button type="submit" class="btn" id="sb">
236
- <span id="bt">Authorize</span>
237
- <div class="spinner" id="sp"></div>
238
- </button>
239
- </form>
240
- <div class="divider"></div>
241
- <p class="footer">Product Brain gives Claude access to your knowledge graph.<br><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">Learn more</a></p>
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
+
242
845
  <script>
243
- var f=document.getElementById('f'),k=document.getElementById('k'),em=document.getElementById('em'),sb=document.getElementById('sb'),bt=document.getElementById('bt'),sp=document.getElementById('sp');
244
- k.addEventListener('input',function(){var v=k.value.trim();if(v&&!v.startsWith('pb_sk_')){k.classList.add('err');em.classList.add('show')}else{k.classList.remove('err');em.classList.remove('show')}});
245
- f.addEventListener('submit',function(e){var v=k.value.trim();if(!v.startsWith('pb_sk_')){e.preventDefault();k.classList.add('err');em.classList.add('show');k.focus();return}sb.disabled=true;bt.textContent='Verifying\u2026';sp.style.display='block'});
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
+ })();
246
939
  </script>`;
247
- return authPageShell("Connect to Claude", body);
940
+ return authPageShell("Connect Product Brain", body);
248
941
  }
249
- function authorizeSuccessPage(workspaceName, redirectUrl) {
250
- const safeUrl = JSON.stringify(redirectUrl).replace(/<\/script>/gi, "<\\/script>");
942
+ function authorizeSuccessPage(workspaceName, redirectUrl, providerName) {
251
943
  const body = `
252
- <style>
253
- .card{text-align:center;padding:2.5rem 2rem}
254
- .ring{width:60px;height:60px;background:rgba(52,211,153,.1);border:1px solid rgba(52,211,153,.25);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 1.4rem;animation:pop .5s cubic-bezier(.175,.885,.32,1.275) .15s both}
255
- @keyframes pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
256
- svg{animation:draw .6s ease .5s both}
257
- @keyframes draw{from{stroke-dashoffset:30}to{stroke-dashoffset:0}}
258
- h1{font-size:1.3rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.3rem}
259
- .ws{font-size:.8rem;color:var(--ok);font-weight:500;margin-bottom:1.5rem}
260
- .redirect-msg{font-size:.78rem;color:var(--muted);margin-bottom:1.4rem}
261
- .dots::after{content:'';animation:dots 1.4s steps(4,end) infinite}
262
- @keyframes dots{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}}
263
- .continue{display:inline-flex;align-items:center;gap:.4rem;padding:.6rem 1.2rem;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:.84rem;font-weight:600;cursor:pointer;text-decoration:none;transition:background .15s}
264
- .continue:hover{background:var(--accent-h)}
265
- @keyframes fadein{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
266
- .card{animation:fadein .35s ease}
267
- </style>
268
- <div class="ring">
269
- <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="30"><polyline points="20 6 9 17 4 12"/></svg>
944
+ <div class="panel" data-state="connected">
945
+ ${successPanelInner(workspaceName, redirectUrl, providerName)}
270
946
  </div>
271
- <h1>Claude is connected</h1>
272
- <p class="ws">${esc(workspaceName)}</p>
273
- <p class="redirect-msg">Returning to Claude<span class="dots"></span></p>
274
- <a href="${esc(redirectUrl)}" class="continue">Continue to Claude &rarr;</a>
275
- <script>setTimeout(function(){window.location.href=${safeUrl}},1800)</script>`;
947
+ <script>${cmdScript}</script>`;
276
948
  return authPageShell("Connected", body);
277
949
  }
278
950
  function authorizeErrorPage(title, trustedDetailHtml, retryUrl) {
279
951
  const body = `
280
- <style>
281
- .card{text-align:center;padding:2.25rem 2rem}
282
- .x-ring{width:52px;height:52px;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 1.25rem;font-size:1.4rem;animation:shake .4s ease .1s}
283
- @keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px)}75%{transform:translateX(5px)}}
284
- h1{font-size:1.2rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.5rem;color:var(--err)}
285
- .detail{font-size:.8rem;color:var(--muted);line-height:1.6;margin-bottom:1.5rem}
286
- .detail code{font-size:.75rem;background:rgba(255,255,255,.06);padding:.1rem .3rem;border-radius:4px}
287
- .actions{display:flex;gap:.75rem;justify-content:center;flex-wrap:wrap}
288
- .btn-retry{display:inline-flex;align-items:center;padding:.55rem 1.1rem;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:.82rem;font-weight:600;cursor:pointer;text-decoration:none;transition:background .15s}
289
- .btn-retry:hover{background:var(--accent-h)}
290
- .btn-key{display:inline-flex;align-items:center;padding:.55rem 1rem;background:transparent;color:var(--muted);border:1px solid var(--border);border-radius:10px;font-size:.82rem;text-decoration:none;transition:border-color .15s,color .15s}
291
- .btn-key:hover{border-color:var(--accent);color:var(--text)}
292
- </style>
293
- <div class="x-ring">&#10005;</div>
294
- <h1>${esc(title)}</h1>
295
- <p class="detail">${trustedDetailHtml}</p>
296
- <div class="actions">
297
- <a href="${esc(retryUrl)}" class="btn-retry">&larr; Try again</a>
298
- <a href="https://productbrain.io" target="_blank" rel="noopener noreferrer" class="btn-key">Get an API key &rarr;</a>
952
+ <div class="panel" data-state="error">
953
+ ${errorPanelInner(title, trustedDetailHtml, retryUrl)}
299
954
  </div>`;
300
955
  return authPageShell("Connection error", body);
301
956
  }
302
957
  app.get("/authorize", authLimiter, (req, res) => {
303
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;
304
961
  res.type("html").send(authorizeFormPage({
305
962
  redirect_uri: String(redirect_uri ?? ""),
306
963
  code_challenge: String(code_challenge ?? ""),
307
964
  code_challenge_method: String(code_challenge_method ?? "S256"),
308
965
  state: String(state ?? ""),
309
- client_id: String(client_id ?? "")
966
+ client_id: cid,
967
+ client_name: clientName
310
968
  }));
311
969
  });
312
970
  app.post(
@@ -315,6 +973,7 @@ app.post(
315
973
  express.urlencoded({ extended: false }),
316
974
  async (req, res) => {
317
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");
318
977
  const retryParams = new URLSearchParams({
319
978
  redirect_uri: redirect_uri ?? "",
320
979
  code_challenge: code_challenge ?? "",
@@ -323,21 +982,44 @@ app.post(
323
982
  ...client_id ? { client_id } : {}
324
983
  }).toString();
325
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
+ }
326
992
  if (!api_key?.startsWith("pb_sk_")) {
327
- res.type("html").send(authorizeErrorPage(
993
+ sendError(
328
994
  "Invalid key format",
329
- "API keys start with <code>pb_sk_</code>. Check your key and try again.",
330
- retryUrl
331
- ));
995
+ "API keys start with <code>pb_sk_</code>. Check your key and try again."
996
+ );
332
997
  return;
333
998
  }
334
- if (!client_id || !registeredClients.has(client_id)) {
999
+ if (!client_id) {
335
1000
  res.status(400).json({
336
1001
  error: "invalid_request",
337
- error_description: "Unknown or missing client_id"
1002
+ error_description: "client_id is required"
338
1003
  });
339
1004
  return;
340
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
+ }
341
1023
  const client = registeredClients.get(client_id);
342
1024
  if (!client.redirect_uris.includes(redirect_uri)) {
343
1025
  res.status(400).json({
@@ -374,11 +1056,11 @@ app.post(
374
1056
  }
375
1057
  if (!foundUrl) {
376
1058
  if (anyDefinitiveReject) {
377
- res.type("html").send(authorizeErrorPage(
1059
+ sendError(
378
1060
  "Key not recognized",
379
- "This API key wasn't found in Product Brain. Check your API Keys in Studio and try again.",
380
- retryUrl
381
- ));
1061
+ "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.",
1062
+ 401
1063
+ );
382
1064
  return;
383
1065
  }
384
1066
  process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
@@ -388,7 +1070,7 @@ app.post(
388
1070
  } catch {
389
1071
  process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
390
1072
  }
391
- const code = randomUUID();
1073
+ const code = randomUUID2();
392
1074
  pendingCodes.set(code, {
393
1075
  apiKey: api_key,
394
1076
  codeChallenge: code_challenge,
@@ -399,46 +1081,22 @@ app.post(
399
1081
  url.searchParams.set("code", code);
400
1082
  if (state) url.searchParams.set("state", state);
401
1083
  const redirectUrl = url.toString();
402
- res.type("html").send(authorizeSuccessPage(workspaceName, redirectUrl));
1084
+ const providerName = providerDisplayName(client.client_name);
1085
+ if (wantsJson) {
1086
+ res.json({ ok: true, workspaceName, redirectUrl, providerName });
1087
+ } else {
1088
+ res.type("html").send(authorizeSuccessPage(workspaceName, redirectUrl, providerName));
1089
+ }
403
1090
  }
404
1091
  );
405
1092
  function issueTokens(apiKey) {
406
- const now = Date.now();
407
- const refreshToken = `pb_rt_${randomUUID()}`;
408
- let perKeyCount = 0;
409
- let oldestKeyForApiKey = null;
410
- let oldestAtForApiKey = Infinity;
411
- for (const [k, v] of refreshTokens) {
412
- if (v.apiKey === apiKey) {
413
- perKeyCount++;
414
- if (v.createdAt < oldestAtForApiKey) {
415
- oldestAtForApiKey = v.createdAt;
416
- oldestKeyForApiKey = k;
417
- }
418
- }
419
- }
420
- if (perKeyCount >= MAX_REFRESH_TOKENS_PER_KEY && oldestKeyForApiKey) {
421
- refreshTokens.delete(oldestKeyForApiKey);
422
- }
423
- if (refreshTokens.size >= MAX_REFRESH_TOKENS) {
424
- let oldestKey = null;
425
- let oldestAt = Infinity;
426
- for (const [k, v] of refreshTokens) {
427
- if (v.createdAt < oldestAt) {
428
- oldestAt = v.createdAt;
429
- oldestKey = k;
430
- }
431
- }
432
- if (oldestKey) refreshTokens.delete(oldestKey);
433
- }
434
- refreshTokens.set(refreshToken, { apiKey, createdAt: now });
435
1093
  return {
436
1094
  access_token: apiKey,
437
1095
  token_type: "Bearer",
438
1096
  // 1-year TTL: actual validity enforced by Convex, not by expiry clock.
439
1097
  // Long TTL prevents unnecessary refresh cycles after restarts.
440
1098
  expires_in: 365 * 24 * 3600,
441
- refresh_token: refreshToken
1099
+ refresh_token: signRefreshToken(apiKey)
442
1100
  };
443
1101
  }
444
1102
  app.post(
@@ -449,19 +1107,15 @@ app.post(
449
1107
  (req, res) => {
450
1108
  const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
451
1109
  if (grant_type === "refresh_token") {
452
- const entry = refreshTokens.get(refresh_token);
453
- if (!entry) {
454
- res.status(400).json({ error: "invalid_grant", error_description: "Invalid refresh token" });
455
- return;
456
- }
457
- if (Date.now() - entry.createdAt > REFRESH_TOKEN_TTL_MS) {
458
- refreshTokens.delete(refresh_token);
459
- res.status(400).json({ error: "invalid_grant", error_description: "Refresh token expired" });
1110
+ const verified = verifyRefreshToken(refresh_token);
1111
+ if (!verified) {
1112
+ res.status(400).json({
1113
+ error: "invalid_grant",
1114
+ error_description: "Invalid or expired refresh token"
1115
+ });
460
1116
  return;
461
1117
  }
462
- const apiKey = entry.apiKey;
463
- refreshTokens.delete(refresh_token);
464
- res.json(issueTokens(apiKey));
1118
+ res.json(issueTokens(verified.apiKey));
465
1119
  return;
466
1120
  }
467
1121
  if (grant_type !== "authorization_code") {
@@ -491,85 +1145,63 @@ var mcpLimiter = rateLimit({
491
1145
  max: 120,
492
1146
  standardHeaders: true,
493
1147
  legacyHeaders: false,
494
- message: { error: "Too many requests. Try again later." }
1148
+ handler: (req, res) => {
1149
+ const r = req.rateLimit;
1150
+ const reset = r?.resetTime?.getTime?.() ?? Date.now() + 6e4;
1151
+ send429(res, buildRateLimitError({
1152
+ retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
1153
+ limit: r?.limit ?? 120,
1154
+ current: r?.used ?? 0,
1155
+ id: req.body?.id ?? null
1156
+ // mirror the JSON-RPC id when a POST body is present
1157
+ }));
1158
+ }
495
1159
  });
496
1160
  var authFailures = /* @__PURE__ */ new Map();
497
- var AUTH_FAILURE_MAX = 10;
498
- var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
499
- var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
500
- var MAX_AUTH_FAILURE_ENTRIES = 1e4;
501
- function checkAuthBlock(ip) {
502
- const rec = authFailures.get(ip);
503
- if (!rec) return false;
504
- return rec.blockedUntil > Date.now();
505
- }
506
- function recordAuthFailure(ip) {
507
- const now = Date.now();
508
- const rec = authFailures.get(ip);
509
- if (!rec) {
510
- authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
511
- return;
512
- }
513
- if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
514
- rec.count = 1;
515
- rec.firstFailure = now;
516
- rec.blockedUntil = 0;
517
- } else {
518
- rec.count++;
519
- if (rec.count >= AUTH_FAILURE_MAX) {
520
- rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
521
- }
522
- }
523
- }
524
1161
  app.get("/health", (_req, res) => {
525
1162
  res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
526
1163
  });
527
1164
  var sessions = /* @__PURE__ */ new Map();
528
1165
  var SESSION_TTL_MS = 30 * 60 * 1e3;
529
1166
  var MAX_SESSIONS = 200;
530
- var MAX_SESSIONS_PER_KEY = 5;
1167
+ var EVICT_GRACE_MS = 6e4;
1168
+ var MAX_SESSIONS_PER_KEY = Math.max(
1169
+ 1,
1170
+ Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
1171
+ );
531
1172
  function evictStaleSessions() {
532
1173
  const now = Date.now();
533
1174
  for (const [id, entry] of sessions) {
534
1175
  if (now - entry.lastAccess > SESSION_TTL_MS) {
535
- logSessionLifecycle("session_deleted", id, "ttl");
1176
+ logSessionLifecycle(
1177
+ "session_deleted",
1178
+ id,
1179
+ entry.inFlight > 0 ? "inflight_leak" : "ttl"
1180
+ );
536
1181
  entry.transport.close().catch(() => {
537
1182
  });
538
1183
  sessions.delete(id);
539
1184
  }
540
1185
  }
541
1186
  if (sessions.size > MAX_SESSIONS) {
542
- const sorted = [...sessions.entries()].sort(
543
- (a, b) => a[1].lastAccess - b[1].lastAccess
544
- );
545
- for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
1187
+ const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
1188
+ const overflow = sessions.size - MAX_SESSIONS;
1189
+ for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
546
1190
  logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
547
1191
  sorted[i][1].transport.close().catch(() => {
548
1192
  });
549
1193
  sessions.delete(sorted[i][0]);
550
1194
  }
551
- }
552
- }
553
- setInterval(evictStaleSessions, 6e4);
554
- function extractBearerKey(req) {
555
- const header = req.headers?.authorization;
556
- if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
557
- const token = header.slice(7).trim();
558
- if (token.startsWith("pb_sk_")) {
559
- return token;
560
- }
561
- if (token.startsWith("pb_at_")) {
562
- const entry = accessTokens.get(token);
563
- if (!entry) return null;
564
- const now = Date.now();
565
- if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
566
- accessTokens.delete(token);
567
- return null;
1195
+ if (sessions.size > MAX_SESSIONS) {
1196
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
1197
+ process.stderr.write(
1198
+ `[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
1199
+ `
1200
+ );
568
1201
  }
569
- return entry.apiKey;
570
1202
  }
571
- return null;
572
1203
  }
1204
+ setInterval(evictStaleSessions, 6e4);
573
1205
  function send401(req, res) {
574
1206
  const base = baseUrl(req);
575
1207
  res.status(401).set(
@@ -592,17 +1224,21 @@ function logSessionLifecycle(event, sessionId, reason) {
592
1224
  }
593
1225
  app.post("/mcp", mcpLimiter, async (req, res) => {
594
1226
  const reqIp = req.ip ?? "unknown";
595
- if (checkAuthBlock(reqIp)) {
596
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1227
+ const nowTs = Date.now();
1228
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1229
+ const rec = authFailures.get(reqIp);
1230
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1231
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
597
1232
  return;
598
1233
  }
599
- const apiKey = extractBearerKey(req);
600
- if (!apiKey) {
1234
+ const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
1235
+ if (auth.status !== "ok") {
601
1236
  logRequest("POST", "auth_fail");
602
- recordAuthFailure(reqIp);
1237
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
603
1238
  send401(req, res);
604
1239
  return;
605
1240
  }
1241
+ const apiKey = auth.apiKey;
606
1242
  const sessionId = req.headers["mcp-session-id"];
607
1243
  const reqStart = Date.now();
608
1244
  try {
@@ -617,41 +1253,91 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
617
1253
  });
618
1254
  return;
619
1255
  }
620
- entry.lastAccess = Date.now();
621
- await entry.transport.handleRequest(req, res, req.body);
622
- logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1256
+ try {
1257
+ entry.inFlight++;
1258
+ entry.lastAccess = Date.now();
1259
+ await entry.transport.handleRequest(req, res, req.body);
1260
+ logRequest("POST", "ok", sessionId, Date.now() - reqStart);
1261
+ } finally {
1262
+ entry.inFlight--;
1263
+ entry.lastAccess = Date.now();
1264
+ }
623
1265
  } else if (!sessionId && isInitializeRequest(req.body)) {
624
1266
  const keyH = hashKey(apiKey);
625
- let keySessionCount = 0;
626
- for (const entry of sessions.values()) {
627
- if (entry.keyHash === keyH) keySessionCount++;
628
- }
629
- if (keySessionCount >= MAX_SESSIONS_PER_KEY) {
630
- res.status(429).json({
631
- jsonrpc: "2.0",
632
- error: { code: -32e3, message: "Too many sessions for this API key" },
633
- id: null
1267
+ const plan = planKeyAdmission(
1268
+ sessions,
1269
+ keyH,
1270
+ MAX_SESSIONS_PER_KEY,
1271
+ Date.now(),
1272
+ { ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
1273
+ );
1274
+ for (const sid2 of plan.evict) {
1275
+ const victim = sessions.get(sid2);
1276
+ sessions.delete(sid2);
1277
+ victim?.transport.close().catch(() => {
634
1278
  });
1279
+ logSessionLifecycle("session_deleted", sid2, "lru_evict");
1280
+ }
1281
+ if (!plan.admit) {
1282
+ send429(res, buildSessionCapError({
1283
+ retryAfterSeconds: plan.retryAfterSeconds,
1284
+ limit: MAX_SESSIONS_PER_KEY,
1285
+ current: countKeySessions(sessions, keyH),
1286
+ poll: plan.retryIsPoll,
1287
+ id: req.body?.id ?? null
1288
+ }));
635
1289
  return;
636
1290
  }
1291
+ const sid = randomUUID2();
1292
+ let initialized = false;
637
1293
  const transport = new StreamableHTTPServerTransport({
638
- sessionIdGenerator: () => randomUUID(),
639
- onsessioninitialized: (sid) => {
640
- sessions.set(sid, { transport, lastAccess: Date.now(), keyHash: keyH });
641
- logSessionLifecycle("session_created", sid);
1294
+ sessionIdGenerator: () => sid,
1295
+ onsessioninitialized: (s) => {
1296
+ initialized = true;
1297
+ logSessionLifecycle("session_created", s);
642
1298
  }
1299
+ // log only — no map write
643
1300
  });
1301
+ const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
1302
+ sessions.set(sid, reserved);
644
1303
  transport.onclose = () => {
645
- const sid = transport.sessionId;
646
- if (sid) {
647
- logSessionLifecycle("session_deleted", sid, "onclose");
648
- sessions.delete(sid);
1304
+ const s = transport.sessionId ?? sid;
1305
+ if (sessions.has(s)) {
1306
+ sessions.delete(s);
1307
+ logSessionLifecycle("session_deleted", s, "onclose");
649
1308
  }
650
1309
  };
651
- const server = createProductBrainServer();
652
- await server.connect(transport);
653
- await transport.handleRequest(req, res, req.body);
654
- logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1310
+ try {
1311
+ const server = createProductBrainServer();
1312
+ await server.connect(transport);
1313
+ await transport.handleRequest(req, res, req.body);
1314
+ logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1315
+ } catch (e) {
1316
+ transport.onclose = void 0;
1317
+ sessions.delete(sid);
1318
+ throw e;
1319
+ } finally {
1320
+ if (!initialized) {
1321
+ transport.onclose = void 0;
1322
+ if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
1323
+ } else {
1324
+ const e = sessions.get(sid);
1325
+ if (e) {
1326
+ e.inFlight--;
1327
+ e.lastAccess = Date.now();
1328
+ }
1329
+ }
1330
+ }
1331
+ } else if (sessionId) {
1332
+ process.stderr.write(
1333
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
1334
+ `
1335
+ );
1336
+ res.status(404).json({
1337
+ jsonrpc: "2.0",
1338
+ error: { code: -32001, message: "Session not found \u2014 re-initialise" },
1339
+ id: null
1340
+ });
655
1341
  } else {
656
1342
  process.stderr.write(
657
1343
  `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
@@ -677,20 +1363,32 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
677
1363
  });
678
1364
  app.get("/mcp", mcpLimiter, async (req, res) => {
679
1365
  const reqIp = req.ip ?? "unknown";
680
- if (checkAuthBlock(reqIp)) {
681
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1366
+ const nowTs = Date.now();
1367
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1368
+ const rec = authFailures.get(reqIp);
1369
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1370
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
682
1371
  return;
683
1372
  }
684
- const apiKey = extractBearerKey(req);
685
- if (!apiKey) {
1373
+ const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
1374
+ if (auth.status !== "ok") {
686
1375
  logRequest("GET", "auth_fail");
687
- recordAuthFailure(reqIp);
1376
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
688
1377
  send401(req, res);
689
1378
  return;
690
1379
  }
1380
+ const apiKey = auth.apiKey;
691
1381
  const sessionId = req.headers["mcp-session-id"];
692
- if (!sessionId || !sessions.has(sessionId)) {
693
- res.status(400).send("Invalid or missing session ID");
1382
+ if (!sessionId) {
1383
+ res.status(400).send("Missing Mcp-Session-Id header");
1384
+ return;
1385
+ }
1386
+ if (!sessions.has(sessionId)) {
1387
+ process.stderr.write(
1388
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale GET sessionId=${sessionId}
1389
+ `
1390
+ );
1391
+ res.status(404).send("Session not found \u2014 re-initialise");
694
1392
  return;
695
1393
  }
696
1394
  try {
@@ -714,20 +1412,32 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
714
1412
  });
715
1413
  app.delete("/mcp", mcpLimiter, async (req, res) => {
716
1414
  const reqIp = req.ip ?? "unknown";
717
- if (checkAuthBlock(reqIp)) {
718
- res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
1415
+ const nowTs = Date.now();
1416
+ if (checkAuthBlock(authFailures, reqIp, nowTs)) {
1417
+ const rec = authFailures.get(reqIp);
1418
+ const retryAfterSeconds = rec ? Math.max(0, Math.ceil((rec.blockedUntil - nowTs) / 1e3)) : 0;
1419
+ send429(res, buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec?.count ?? AUTH_FAILURE_MAX, id: req.body?.id ?? null }));
719
1420
  return;
720
1421
  }
721
- const apiKey = extractBearerKey(req);
722
- if (!apiKey) {
1422
+ const auth = resolveBearerAuth(req.headers?.authorization, accessTokens, nowTs, ACCESS_TOKEN_TTL_MS);
1423
+ if (auth.status !== "ok") {
723
1424
  logRequest("DELETE", "auth_fail");
724
- recordAuthFailure(reqIp);
1425
+ if (auth.status === "rejected") recordAuthFailure(authFailures, reqIp, nowTs);
725
1426
  send401(req, res);
726
1427
  return;
727
1428
  }
1429
+ const apiKey = auth.apiKey;
728
1430
  const sessionId = req.headers["mcp-session-id"];
729
- if (!sessionId || !sessions.has(sessionId)) {
730
- res.status(400).send("Invalid or missing session ID");
1431
+ if (!sessionId) {
1432
+ res.status(400).send("Missing Mcp-Session-Id header");
1433
+ return;
1434
+ }
1435
+ if (!sessions.has(sessionId)) {
1436
+ process.stderr.write(
1437
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale DELETE sessionId=${sessionId}
1438
+ `
1439
+ );
1440
+ res.status(404).send("Session not found \u2014 re-initialise");
731
1441
  return;
732
1442
  }
733
1443
  try {