@productbrain/mcp 0.0.1-beta.83 → 0.0.1-beta.914
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/.env.mcp.example +4 -0
- package/dist/{chunk-WXT35272.js → chunk-7TU5QHN7.js} +7413 -2385
- package/dist/chunk-7TU5QHN7.js.map +1 -0
- package/dist/{chunk-RQXM3TCI.js → chunk-YMF3IQ5E.js} +205 -1
- package/dist/chunk-YMF3IQ5E.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +945 -81
- package/dist/http.js.map +1 -1
- package/dist/index.js +6 -12
- package/dist/index.js.map +1 -1
- package/dist/{setup-GQ3LQS2L.js → setup-RYYXRDPB.js} +5 -5
- package/dist/setup-RYYXRDPB.js.map +1 -0
- package/dist/views/src/graph-constellation/index.html +1 -1
- package/package.json +2 -1
- package/dist/chunk-G4JJNINW.js +0 -3441
- package/dist/chunk-G4JJNINW.js.map +0 -1
- package/dist/chunk-RQXM3TCI.js.map +0 -1
- package/dist/chunk-WXT35272.js.map +0 -1
- package/dist/setup-GQ3LQS2L.js.map +0 -1
- package/dist/smart-capture-HRJL7SGD.js +0 -41
- package/dist/smart-capture-HRJL7SGD.js.map +0 -1
package/dist/http.js
CHANGED
|
@@ -1,24 +1,117 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DEFAULT_CLOUD_URL,
|
|
2
3
|
SERVER_VERSION,
|
|
3
|
-
createProductBrainServer
|
|
4
|
-
} from "./chunk-WXT35272.js";
|
|
5
|
-
import {
|
|
6
4
|
bootstrapHttp,
|
|
5
|
+
createProductBrainServer,
|
|
6
|
+
getKeyState,
|
|
7
|
+
hashKey,
|
|
7
8
|
initFeatureFlags,
|
|
8
9
|
runWithAuth
|
|
9
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-7TU5QHN7.js";
|
|
10
11
|
import {
|
|
11
12
|
getPostHogClient,
|
|
12
13
|
initAnalytics,
|
|
13
14
|
shutdownAnalytics
|
|
14
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-YMF3IQ5E.js";
|
|
15
16
|
|
|
16
17
|
// src/http.ts
|
|
17
|
-
import { createHash, randomUUID } from "crypto";
|
|
18
|
+
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
18
19
|
import express from "express";
|
|
19
20
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
21
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
21
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
|
|
22
115
|
bootstrapHttp();
|
|
23
116
|
initAnalytics();
|
|
24
117
|
initFeatureFlags(getPostHogClient());
|
|
@@ -31,10 +124,10 @@ function baseUrl(req) {
|
|
|
31
124
|
var app = express();
|
|
32
125
|
app.set("trust proxy", 1);
|
|
33
126
|
app.use(express.json());
|
|
34
|
-
var ALLOWED_ORIGINS = process.env.CORS_ORIGINS
|
|
127
|
+
var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
|
|
35
128
|
app.use((_req, res, next) => {
|
|
36
129
|
const origin = _req.headers.origin;
|
|
37
|
-
if (
|
|
130
|
+
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
38
131
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
39
132
|
}
|
|
40
133
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
@@ -72,11 +165,27 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
|
72
165
|
scopes_supported: ["mcp:tools", "mcp:resources"]
|
|
73
166
|
});
|
|
74
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
|
+
});
|
|
75
175
|
var registeredClients = /* @__PURE__ */ new Map();
|
|
176
|
+
var MAX_REGISTERED_CLIENTS = 500;
|
|
76
177
|
app.post(
|
|
77
178
|
"/register",
|
|
179
|
+
authLimiter,
|
|
78
180
|
express.json(),
|
|
79
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
|
+
}
|
|
80
189
|
const { redirect_uris, client_name } = req.body;
|
|
81
190
|
if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
|
|
82
191
|
res.status(400).json({
|
|
@@ -85,7 +194,7 @@ app.post(
|
|
|
85
194
|
});
|
|
86
195
|
return;
|
|
87
196
|
}
|
|
88
|
-
const clientId = `pb_client_${
|
|
197
|
+
const clientId = `pb_client_${randomUUID2()}`;
|
|
89
198
|
const client = {
|
|
90
199
|
client_id: clientId,
|
|
91
200
|
redirect_uris,
|
|
@@ -105,8 +214,8 @@ app.post(
|
|
|
105
214
|
);
|
|
106
215
|
var pendingCodes = /* @__PURE__ */ new Map();
|
|
107
216
|
var ACCESS_TOKEN_TTL = 3600;
|
|
108
|
-
var
|
|
109
|
-
var
|
|
217
|
+
var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
|
|
218
|
+
var accessTokens = /* @__PURE__ */ new Map();
|
|
110
219
|
setInterval(() => {
|
|
111
220
|
const now = Date.now();
|
|
112
221
|
for (const [code, auth] of pendingCodes) {
|
|
@@ -115,67 +224,693 @@ setInterval(() => {
|
|
|
115
224
|
for (const [id, client] of registeredClients) {
|
|
116
225
|
if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
|
|
117
226
|
}
|
|
118
|
-
for (const [token, entry] of
|
|
119
|
-
if (now - entry.createdAt >
|
|
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
|
+
}
|
|
120
240
|
}
|
|
121
241
|
}, 6e4);
|
|
122
242
|
function esc(s) {
|
|
123
243
|
return String(s ?? "").replace(
|
|
124
|
-
/[&"<>]/g,
|
|
125
|
-
(c) => ({ "&": "&", '"': """, "<": "<", ">": ">" })[c]
|
|
244
|
+
/[&"'<>]/g,
|
|
245
|
+
(c) => ({ "&": "&", '"': """, "'": "'", "<": "<", ">": ">" })[c]
|
|
126
246
|
);
|
|
127
247
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<html lang="en"><head>
|
|
248
|
+
function authPageShell(title, bodyContent, headExtra = "") {
|
|
249
|
+
return `<!DOCTYPE html>
|
|
250
|
+
<html lang="en" data-theme="parchment-dark"><head>
|
|
132
251
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
133
|
-
<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}
|
|
134
257
|
<style>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 "Start PB" and return to ${esc(providerName)}">
|
|
587
|
+
<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>
|
|
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>
|
|
163
592
|
</div>
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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>← Try again</a>
|
|
612
|
+
<a href="https://productbrain.io" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key →</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 →</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>
|
|
674
|
+
</div>
|
|
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{'<':'<','>':'>','&':'&'}[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{'<':'<','>':'>','&':'&'}[c]});
|
|
725
|
+
var safeProv=String(providerName||'your assistant').replace(/[<>&]/g,function(c){return{'<':'<','>':'>','&':'&'}[c]});
|
|
726
|
+
var safeUrl=String(redirectUrl||'').replace(/"/g,'"').replace(/[<>]/g,function(c){return{'<':'<','>':'>'}[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
|
+
}));
|
|
168
809
|
});
|
|
169
810
|
app.post(
|
|
170
811
|
"/authorize",
|
|
812
|
+
authLimiter,
|
|
171
813
|
express.urlencoded({ extended: false }),
|
|
172
|
-
(req, res) => {
|
|
173
|
-
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
|
+
}
|
|
174
832
|
if (!api_key?.startsWith("pb_sk_")) {
|
|
175
|
-
|
|
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
|
+
});
|
|
176
869
|
return;
|
|
177
870
|
}
|
|
178
|
-
|
|
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();
|
|
179
914
|
pendingCodes.set(code, {
|
|
180
915
|
apiKey: api_key,
|
|
181
916
|
codeChallenge: code_challenge,
|
|
@@ -185,39 +920,42 @@ app.post(
|
|
|
185
920
|
const url = new URL(redirect_uri);
|
|
186
921
|
url.searchParams.set("code", code);
|
|
187
922
|
if (state) url.searchParams.set("state", state);
|
|
188
|
-
|
|
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
|
+
}
|
|
189
930
|
}
|
|
190
931
|
);
|
|
191
932
|
function issueTokens(apiKey) {
|
|
192
|
-
const refreshToken = `pb_rt_${randomUUID()}`;
|
|
193
|
-
refreshTokens.set(refreshToken, { apiKey, createdAt: Date.now() });
|
|
194
933
|
return {
|
|
195
934
|
access_token: apiKey,
|
|
196
935
|
token_type: "Bearer",
|
|
197
|
-
|
|
198
|
-
|
|
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)
|
|
199
940
|
};
|
|
200
941
|
}
|
|
201
942
|
app.post(
|
|
202
943
|
"/oauth/token",
|
|
944
|
+
authLimiter,
|
|
203
945
|
express.urlencoded({ extended: false }),
|
|
204
946
|
express.json(),
|
|
205
947
|
(req, res) => {
|
|
206
948
|
const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
|
|
207
949
|
if (grant_type === "refresh_token") {
|
|
208
|
-
const
|
|
209
|
-
if (!
|
|
210
|
-
res.status(400).json({
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
refreshTokens.delete(refresh_token);
|
|
215
|
-
res.status(400).json({ error: "invalid_grant", error_description: "Refresh token expired" });
|
|
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
|
+
});
|
|
216
956
|
return;
|
|
217
957
|
}
|
|
218
|
-
|
|
219
|
-
refreshTokens.delete(refresh_token);
|
|
220
|
-
res.json(issueTokens(apiKey));
|
|
958
|
+
res.json(issueTokens(verified.apiKey));
|
|
221
959
|
return;
|
|
222
960
|
}
|
|
223
961
|
if (grant_type !== "authorization_code") {
|
|
@@ -249,12 +987,41 @@ var mcpLimiter = rateLimit({
|
|
|
249
987
|
legacyHeaders: false,
|
|
250
988
|
message: { error: "Too many requests. Try again later." }
|
|
251
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
|
+
}
|
|
252
1018
|
app.get("/health", (_req, res) => {
|
|
253
1019
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
254
1020
|
});
|
|
255
1021
|
var sessions = /* @__PURE__ */ new Map();
|
|
256
1022
|
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
257
1023
|
var MAX_SESSIONS = 200;
|
|
1024
|
+
var MAX_SESSIONS_PER_KEY = 5;
|
|
258
1025
|
function evictStaleSessions() {
|
|
259
1026
|
const now = Date.now();
|
|
260
1027
|
for (const [id, entry] of sessions) {
|
|
@@ -282,7 +1049,20 @@ function extractBearerKey(req) {
|
|
|
282
1049
|
const header = req.headers?.authorization;
|
|
283
1050
|
if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
|
|
284
1051
|
const token = header.slice(7).trim();
|
|
285
|
-
|
|
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;
|
|
286
1066
|
}
|
|
287
1067
|
function send401(req, res) {
|
|
288
1068
|
const base = baseUrl(req);
|
|
@@ -305,9 +1085,15 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
305
1085
|
`);
|
|
306
1086
|
}
|
|
307
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
|
+
}
|
|
308
1093
|
const apiKey = extractBearerKey(req);
|
|
309
1094
|
if (!apiKey) {
|
|
310
1095
|
logRequest("POST", "auth_fail");
|
|
1096
|
+
recordAuthFailure(reqIp);
|
|
311
1097
|
send401(req, res);
|
|
312
1098
|
return;
|
|
313
1099
|
}
|
|
@@ -317,14 +1103,35 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
317
1103
|
await runWithAuth({ apiKey }, async () => {
|
|
318
1104
|
if (sessionId && sessions.has(sessionId)) {
|
|
319
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
|
+
}
|
|
320
1114
|
entry.lastAccess = Date.now();
|
|
321
1115
|
await entry.transport.handleRequest(req, res, req.body);
|
|
322
1116
|
logRequest("POST", "ok", sessionId, Date.now() - reqStart);
|
|
323
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
|
+
}
|
|
324
1131
|
const transport = new StreamableHTTPServerTransport({
|
|
325
|
-
sessionIdGenerator: () =>
|
|
1132
|
+
sessionIdGenerator: () => randomUUID2(),
|
|
326
1133
|
onsessioninitialized: (sid) => {
|
|
327
|
-
sessions.set(sid, { transport, lastAccess: Date.now() });
|
|
1134
|
+
sessions.set(sid, { transport, lastAccess: Date.now(), keyHash: keyH });
|
|
328
1135
|
logSessionLifecycle("session_created", sid);
|
|
329
1136
|
}
|
|
330
1137
|
});
|
|
@@ -339,6 +1146,16 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
339
1146
|
await server.connect(transport);
|
|
340
1147
|
await transport.handleRequest(req, res, req.body);
|
|
341
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
|
+
});
|
|
342
1159
|
} else {
|
|
343
1160
|
process.stderr.write(
|
|
344
1161
|
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
|
|
@@ -363,20 +1180,42 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
363
1180
|
}
|
|
364
1181
|
});
|
|
365
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
|
+
}
|
|
366
1188
|
const apiKey = extractBearerKey(req);
|
|
367
1189
|
if (!apiKey) {
|
|
368
1190
|
logRequest("GET", "auth_fail");
|
|
1191
|
+
recordAuthFailure(reqIp);
|
|
369
1192
|
send401(req, res);
|
|
370
1193
|
return;
|
|
371
1194
|
}
|
|
372
1195
|
const sessionId = req.headers["mcp-session-id"];
|
|
373
|
-
if (!sessionId
|
|
374
|
-
res.status(400).send("
|
|
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");
|
|
375
1206
|
return;
|
|
376
1207
|
}
|
|
377
1208
|
try {
|
|
378
1209
|
await runWithAuth({ apiKey }, async () => {
|
|
379
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
|
+
}
|
|
380
1219
|
entry.lastAccess = Date.now();
|
|
381
1220
|
await entry.transport.handleRequest(req, res);
|
|
382
1221
|
logRequest("GET", "ok", sessionId);
|
|
@@ -386,20 +1225,42 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
386
1225
|
}
|
|
387
1226
|
});
|
|
388
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
|
+
}
|
|
389
1233
|
const apiKey = extractBearerKey(req);
|
|
390
1234
|
if (!apiKey) {
|
|
391
1235
|
logRequest("DELETE", "auth_fail");
|
|
1236
|
+
recordAuthFailure(reqIp);
|
|
392
1237
|
send401(req, res);
|
|
393
1238
|
return;
|
|
394
1239
|
}
|
|
395
1240
|
const sessionId = req.headers["mcp-session-id"];
|
|
396
|
-
if (!sessionId
|
|
397
|
-
res.status(400).send("
|
|
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");
|
|
398
1251
|
return;
|
|
399
1252
|
}
|
|
400
1253
|
try {
|
|
401
1254
|
await runWithAuth({ apiKey }, async () => {
|
|
402
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
|
+
}
|
|
403
1264
|
await entry.transport.handleRequest(req, res);
|
|
404
1265
|
logRequest("DELETE", "ok", sessionId);
|
|
405
1266
|
});
|
|
@@ -431,8 +1292,11 @@ async function gracefulShutdown() {
|
|
|
431
1292
|
}
|
|
432
1293
|
process.exit(0);
|
|
433
1294
|
}
|
|
434
|
-
var
|
|
435
|
-
|
|
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
|
+
);
|
|
436
1300
|
});
|
|
437
1301
|
httpServer.on("error", (err) => {
|
|
438
1302
|
console.error(`[MCP HTTP] Server error: ${err.message}`);
|