@productbrain/mcp 0.0.1-beta.20 → 0.0.1-beta.201

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