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