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