@productbrain/mcp 0.0.1-beta.124 → 0.0.1-beta.12402
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 +5 -1
- package/README.md +67 -0
- package/dist/chunk-3U2ECUFG.js +1764 -0
- package/dist/chunk-3U2ECUFG.js.map +1 -0
- package/dist/chunk-V2WCCCRI.js +11948 -0
- package/dist/chunk-V2WCCCRI.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +1259 -163
- package/dist/http.js.map +1 -1
- package/dist/index.js +5 -25
- package/dist/index.js.map +1 -1
- package/dist/setup-OMHPPHKE.js +204 -0
- package/dist/setup-OMHPPHKE.js.map +1 -0
- package/dist/views/src/entry-cards/index.html +85 -60
- package/dist/views/src/graph-constellation/index.html +72 -47
- package/package.json +8 -8
- package/dist/chunk-2ZWGQ6PK.js +0 -278
- package/dist/chunk-2ZWGQ6PK.js.map +0 -1
- package/dist/chunk-SDLQPEK6.js +0 -11531
- package/dist/chunk-SDLQPEK6.js.map +0 -1
- package/dist/chunk-UUI7P743.js +0 -3647
- package/dist/chunk-UUI7P743.js.map +0 -1
- package/dist/setup-2JWIZ3QO.js +0 -297
- package/dist/setup-2JWIZ3QO.js.map +0 -1
- package/dist/smart-capture-LJ6JF5DC.js +0 -41
- package/dist/smart-capture-LJ6JF5DC.js.map +0 -1
package/dist/http.js
CHANGED
|
@@ -1,28 +1,400 @@
|
|
|
1
1
|
import {
|
|
2
|
+
PRODUCT_NAME,
|
|
2
3
|
SERVER_VERSION,
|
|
3
4
|
createProductBrainServer
|
|
4
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-V2WCCCRI.js";
|
|
5
6
|
import {
|
|
7
|
+
DEFAULT_CLOUD_URL,
|
|
8
|
+
PASTE_NOW,
|
|
9
|
+
apiKeysUrl,
|
|
6
10
|
bootstrapHttp,
|
|
7
|
-
|
|
11
|
+
checkKeyAgainstCandidates,
|
|
12
|
+
forgetStreamLifecycle,
|
|
13
|
+
getKeyState,
|
|
14
|
+
hashKey,
|
|
15
|
+
isApiKeyShaped,
|
|
16
|
+
parseFallbackUrls,
|
|
8
17
|
runWithAuth
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import {
|
|
11
|
-
getPostHogClient,
|
|
12
|
-
initAnalytics,
|
|
13
|
-
shutdownAnalytics
|
|
14
|
-
} from "./chunk-2ZWGQ6PK.js";
|
|
18
|
+
} from "./chunk-3U2ECUFG.js";
|
|
15
19
|
|
|
16
20
|
// src/http.ts
|
|
17
21
|
import { createHash, randomUUID } from "crypto";
|
|
18
22
|
import express from "express";
|
|
19
23
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
24
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
25
|
+
import rateLimit2 from "express-rate-limit";
|
|
26
|
+
|
|
27
|
+
// src/brand/logo-markup.ts
|
|
28
|
+
var SIZE_CLASSES = {
|
|
29
|
+
sm: "pb-logo--sm",
|
|
30
|
+
md: "pb-logo--md"
|
|
31
|
+
};
|
|
32
|
+
var appLogoStyles = `
|
|
33
|
+
.pb-logo{display:inline-flex;align-items:center;gap:8px;color:inherit}
|
|
34
|
+
.pb-logo__mark{
|
|
35
|
+
border-radius:4px;background:#1c1e24;
|
|
36
|
+
border:1px solid rgba(255,255,255,0.06);
|
|
37
|
+
display:inline-grid;place-items:center;flex-shrink:0;
|
|
38
|
+
}
|
|
39
|
+
.pb-logo__core{border-radius:50%;background:var(--accent,#c9b99a)}
|
|
40
|
+
.pb-logo__name{
|
|
41
|
+
font-family:var(--font-mono,"IBM Plex Mono",ui-monospace,monospace);
|
|
42
|
+
font-weight:500;text-transform:uppercase;
|
|
43
|
+
letter-spacing:0.22em;color:var(--fg4,#6a6560);
|
|
44
|
+
}
|
|
45
|
+
.pb-logo--sm .pb-logo__mark{width:16px;height:16px}
|
|
46
|
+
.pb-logo--sm .pb-logo__core{width:5px;height:5px}
|
|
47
|
+
.pb-logo--sm .pb-logo__name{font-size:10.5px}
|
|
48
|
+
.pb-logo--md .pb-logo__mark{width:24px;height:24px;border-radius:6px}
|
|
49
|
+
.pb-logo--md .pb-logo__core{width:8px;height:8px}
|
|
50
|
+
.pb-logo--md .pb-logo__name{font-size:13px}
|
|
51
|
+
`;
|
|
52
|
+
function appLogoMarkup(opts = {}) {
|
|
53
|
+
const size = opts.size ?? "sm";
|
|
54
|
+
const showWordmark = opts.showWordmark ?? true;
|
|
55
|
+
const cls = ["pb-logo", SIZE_CLASSES[size], opts.className].filter(Boolean).join(" ");
|
|
56
|
+
const wordmark = showWordmark ? `<span class="pb-logo__name">${PRODUCT_NAME}</span>` : "";
|
|
57
|
+
return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/sessionCap.ts
|
|
61
|
+
function planKeyAdmission(sessions2, keyHash, cap, now, opts) {
|
|
62
|
+
if (cap < 1) {
|
|
63
|
+
throw new RangeError(`cap must be >= 1, got ${cap}`);
|
|
64
|
+
}
|
|
65
|
+
const { ttlMs, graceMs } = opts;
|
|
66
|
+
const isProtected = (s) => s.inFlight > 0 || now - s.lastAccess < graceMs;
|
|
67
|
+
const keyEntries = [];
|
|
68
|
+
for (const [id, session] of sessions2) {
|
|
69
|
+
if (session.keyHash === keyHash) {
|
|
70
|
+
keyEntries.push([id, session]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const evictSet = /* @__PURE__ */ new Set();
|
|
74
|
+
for (const [id, session] of keyEntries) {
|
|
75
|
+
if (!isProtected(session) && now - session.lastAccess >= ttlMs) {
|
|
76
|
+
evictSet.add(id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let survivors = keyEntries.filter(([id]) => !evictSet.has(id));
|
|
80
|
+
if (survivors.length >= cap) {
|
|
81
|
+
const idleSurvivors = survivors.filter(([, s]) => !isProtected(s)).sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
|
|
82
|
+
for (const [id] of idleSurvivors) {
|
|
83
|
+
if (survivors.length < cap) break;
|
|
84
|
+
evictSet.add(id);
|
|
85
|
+
survivors = survivors.filter(([sid]) => sid !== id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const evict = Array.from(evictSet);
|
|
89
|
+
const admit = survivors.length < cap;
|
|
90
|
+
let retryAfterSeconds = 0;
|
|
91
|
+
let retryIsPoll = false;
|
|
92
|
+
if (!admit) {
|
|
93
|
+
const graceBlockers = survivors.filter(([, s]) => s.inFlight === 0);
|
|
94
|
+
if (graceBlockers.length > 0) {
|
|
95
|
+
const minLastAccess = Math.min(...graceBlockers.map(([, s]) => s.lastAccess));
|
|
96
|
+
const msRemaining = graceMs - (now - minLastAccess);
|
|
97
|
+
retryAfterSeconds = Math.max(1, Math.ceil(msRemaining / 1e3));
|
|
98
|
+
} else {
|
|
99
|
+
retryAfterSeconds = 5;
|
|
100
|
+
retryIsPoll = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { evict, admit, retryAfterSeconds, retryIsPoll };
|
|
104
|
+
}
|
|
105
|
+
function countKeySessions(sessions2, keyHash) {
|
|
106
|
+
let count = 0;
|
|
107
|
+
for (const session of sessions2.values()) {
|
|
108
|
+
if (session.keyHash === keyHash) count++;
|
|
109
|
+
}
|
|
110
|
+
return count;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/httpErrors.ts
|
|
114
|
+
function clampSeconds(s) {
|
|
115
|
+
return Math.max(0, Math.round(s));
|
|
116
|
+
}
|
|
117
|
+
function build(reason, message, p) {
|
|
118
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
119
|
+
return {
|
|
120
|
+
status: 429,
|
|
121
|
+
headers: { "Retry-After": String(seconds) },
|
|
122
|
+
body: {
|
|
123
|
+
jsonrpc: "2.0",
|
|
124
|
+
id: p.id ?? null,
|
|
125
|
+
error: {
|
|
126
|
+
code: -32e3,
|
|
127
|
+
message,
|
|
128
|
+
data: {
|
|
129
|
+
reason,
|
|
130
|
+
retryAfterSeconds: seconds,
|
|
131
|
+
limit: p.limit,
|
|
132
|
+
current: p.current
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function buildRateLimitError(p) {
|
|
139
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
140
|
+
return build(
|
|
141
|
+
"rate_limited",
|
|
142
|
+
`Too many requests \u2014 retry after ${seconds} s.`,
|
|
143
|
+
p
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
function buildAuthLockoutError(p) {
|
|
147
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
148
|
+
return build(
|
|
149
|
+
"auth_lockout",
|
|
150
|
+
`Too many failed auth attempts \u2014 locked out, retry after ${seconds} s.`,
|
|
151
|
+
p
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
function buildSessionCapError(p) {
|
|
155
|
+
const seconds = clampSeconds(p.retryAfterSeconds);
|
|
156
|
+
const message = p.poll ? "Server busy \u2014 all sessions active; retry shortly." : `Too many active sessions for this key \u2014 retry after ${seconds} s.`;
|
|
157
|
+
return build("session_cap", message, p);
|
|
158
|
+
}
|
|
159
|
+
function send429(res, built) {
|
|
160
|
+
if (res.headersSent) return;
|
|
161
|
+
for (const [k, v] of Object.entries(built.headers)) {
|
|
162
|
+
res.setHeader(k, v);
|
|
163
|
+
}
|
|
164
|
+
res.status(built.status).json(built.body);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/mcpRateLimit.ts
|
|
21
168
|
import rateLimit from "express-rate-limit";
|
|
169
|
+
|
|
170
|
+
// src/authLockout.ts
|
|
171
|
+
var AUTH_FAILURE_MAX = 10;
|
|
172
|
+
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
173
|
+
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
174
|
+
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
175
|
+
function rejected(presented) {
|
|
176
|
+
return { status: "rejected", credentialId: hashKey(presented) };
|
|
177
|
+
}
|
|
178
|
+
function resolveBearerAuth(authorizationHeader) {
|
|
179
|
+
if (typeof authorizationHeader !== "string" || authorizationHeader.trim() === "") {
|
|
180
|
+
return { status: "absent" };
|
|
181
|
+
}
|
|
182
|
+
if (!authorizationHeader.startsWith("Bearer ")) {
|
|
183
|
+
return rejected(authorizationHeader.trim());
|
|
184
|
+
}
|
|
185
|
+
const token = authorizationHeader.slice(7).trim();
|
|
186
|
+
if (!isApiKeyShaped(token)) return rejected(token);
|
|
187
|
+
return { status: "ok", apiKey: token, credentialId: hashKey(token) };
|
|
188
|
+
}
|
|
189
|
+
function resolvePastedKey(apiKey) {
|
|
190
|
+
if (apiKey === "") return { status: "absent" };
|
|
191
|
+
if (!isApiKeyShaped(apiKey)) return rejected(apiKey);
|
|
192
|
+
return { status: "ok", apiKey, credentialId: hashKey(apiKey) };
|
|
193
|
+
}
|
|
194
|
+
function isCrossSiteFormPost(origin, secFetchSite, ownOrigin) {
|
|
195
|
+
if (secFetchSite === "cross-site") return true;
|
|
196
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
197
|
+
try {
|
|
198
|
+
return new URL(origin).origin !== new URL(ownOrigin).origin;
|
|
199
|
+
} catch {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function checkAuthBlock(failures, credentialId, now) {
|
|
204
|
+
const rec = failures.get(credentialId);
|
|
205
|
+
if (!rec) return false;
|
|
206
|
+
return rec.blockedUntil > now;
|
|
207
|
+
}
|
|
208
|
+
function authLockoutError(failures, auth, now, id = null) {
|
|
209
|
+
if (auth.status === "absent") return null;
|
|
210
|
+
const rec = failures.get(auth.credentialId);
|
|
211
|
+
if (!rec || !checkAuthBlock(failures, auth.credentialId, now)) return null;
|
|
212
|
+
const retryAfterSeconds = Math.max(0, Math.ceil((rec.blockedUntil - now) / 1e3));
|
|
213
|
+
return buildAuthLockoutError({ retryAfterSeconds, limit: AUTH_FAILURE_MAX, current: rec.count, id });
|
|
214
|
+
}
|
|
215
|
+
function recordAuthFailure(failures, credentialId, now) {
|
|
216
|
+
const rec = failures.get(credentialId);
|
|
217
|
+
if (!rec) {
|
|
218
|
+
if (failures.size >= MAX_AUTH_FAILURE_ENTRIES) {
|
|
219
|
+
const oldest = failures.keys().next().value;
|
|
220
|
+
if (oldest !== void 0) failures.delete(oldest);
|
|
221
|
+
}
|
|
222
|
+
failures.set(credentialId, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
226
|
+
rec.count = 1;
|
|
227
|
+
rec.firstFailure = now;
|
|
228
|
+
rec.blockedUntil = 0;
|
|
229
|
+
} else {
|
|
230
|
+
rec.count++;
|
|
231
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
232
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function keyVerdictGate(verdict, failures, credentialId, now) {
|
|
237
|
+
if (verdict === "ok" || verdict === "rate_limited") return "admit";
|
|
238
|
+
if (verdict === "rejected") {
|
|
239
|
+
recordAuthFailure(failures, credentialId, now);
|
|
240
|
+
return "unauthorized";
|
|
241
|
+
}
|
|
242
|
+
return "unavailable";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/mcpRateLimit.ts
|
|
246
|
+
var MCP_KEY_LIMIT = 120;
|
|
247
|
+
var MCP_IP_CEILING = 1200;
|
|
248
|
+
function mcpRateKey(req) {
|
|
249
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
250
|
+
return auth.status === "absent" ? `ip:${req.ip ?? ""}` : `key:${auth.credentialId}`;
|
|
251
|
+
}
|
|
252
|
+
function limiter(max, windowMs, keyGenerator) {
|
|
253
|
+
return rateLimit({
|
|
254
|
+
windowMs,
|
|
255
|
+
max,
|
|
256
|
+
standardHeaders: true,
|
|
257
|
+
legacyHeaders: false,
|
|
258
|
+
...keyGenerator ? { keyGenerator } : {},
|
|
259
|
+
handler: (req, res) => {
|
|
260
|
+
const r = req.rateLimit;
|
|
261
|
+
const reset = r?.resetTime?.getTime?.() ?? Date.now() + windowMs;
|
|
262
|
+
send429(res, buildRateLimitError({
|
|
263
|
+
retryAfterSeconds: Math.max(1, Math.ceil((reset - Date.now()) / 1e3)),
|
|
264
|
+
limit: r?.limit ?? max,
|
|
265
|
+
current: r?.used ?? 0,
|
|
266
|
+
id: req.body?.id ?? null
|
|
267
|
+
// mirror the JSON-RPC id when a POST body is present
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function mcpLimiters(opts = {}) {
|
|
273
|
+
const windowMs = opts.windowMs ?? 6e4;
|
|
274
|
+
return [
|
|
275
|
+
limiter(opts.ipCeiling ?? MCP_IP_CEILING, windowMs),
|
|
276
|
+
limiter(opts.keyLimit ?? MCP_KEY_LIMIT, windowMs, mcpRateKey)
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/lib/oauthRedirectAllowlist.ts
|
|
281
|
+
var DEFAULT_OAUTH_REDIRECT_URIS = [
|
|
282
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
283
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
284
|
+
"https://chatgpt.com/connector_platform_oauth_redirect",
|
|
285
|
+
"https://www.cursor.com/agents/mcp/oauth/callback"
|
|
286
|
+
];
|
|
287
|
+
var CHATGPT_CONNECTOR_CALLBACK = /^https:\/\/chatgpt\.com\/connector\/oauth\/[A-Za-z0-9_-]+$/;
|
|
288
|
+
var LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
289
|
+
var MAX_REDIRECT_URI_LENGTH = 2048;
|
|
290
|
+
var MAX_REGISTRATION_REDIRECTS = 10;
|
|
291
|
+
var OPERATOR_ENTRY_FORM = "OAUTH_REDIRECT_HOST_ALLOWLIST entries must be full https redirect URIs such as https://partner.example/oauth/callback (no bare host, no query, no fragment)";
|
|
292
|
+
function exactForm(url) {
|
|
293
|
+
return `${url.origin}${url.pathname}`;
|
|
294
|
+
}
|
|
295
|
+
function parseRedirectUriAllowlist(raw) {
|
|
296
|
+
if (!raw) return { uris: [], configError: null };
|
|
297
|
+
const uris = [];
|
|
298
|
+
for (const part of raw.split(",")) {
|
|
299
|
+
const trimmed = part.trim();
|
|
300
|
+
if (!trimmed) continue;
|
|
301
|
+
let url = null;
|
|
302
|
+
try {
|
|
303
|
+
url = trimmed.includes("://") ? new URL(trimmed) : null;
|
|
304
|
+
} catch {
|
|
305
|
+
url = null;
|
|
306
|
+
}
|
|
307
|
+
const valid = url !== null && url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash && (url.port === "" || url.port === "443") && url.pathname !== "/";
|
|
308
|
+
if (!valid || url === null) {
|
|
309
|
+
return { uris: [], configError: `${OPERATOR_ENTRY_FORM}; got "${trimmed}"` };
|
|
310
|
+
}
|
|
311
|
+
uris.push(exactForm(url));
|
|
312
|
+
}
|
|
313
|
+
return { uris, configError: null };
|
|
314
|
+
}
|
|
315
|
+
function resolveOAuthRedirectAllowlist(opts = {}) {
|
|
316
|
+
const parsed = parseRedirectUriAllowlist(opts.explicitAllowlist);
|
|
317
|
+
return {
|
|
318
|
+
uris: [.../* @__PURE__ */ new Set([...DEFAULT_OAUTH_REDIRECT_URIS, ...parsed.uris])],
|
|
319
|
+
configError: parsed.configError
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function checkRedirectUri(redirectUri, allowlist) {
|
|
323
|
+
if (allowlist.configError) return `OAuth is misconfigured: ${allowlist.configError}`;
|
|
324
|
+
if (typeof redirectUri !== "string" || redirectUri.length === 0) {
|
|
325
|
+
return "redirect_uri is required";
|
|
326
|
+
}
|
|
327
|
+
if (redirectUri.length > MAX_REDIRECT_URI_LENGTH) {
|
|
328
|
+
return `redirect_uri is longer than ${MAX_REDIRECT_URI_LENGTH} characters`;
|
|
329
|
+
}
|
|
330
|
+
let url;
|
|
331
|
+
try {
|
|
332
|
+
url = new URL(redirectUri);
|
|
333
|
+
} catch {
|
|
334
|
+
return "redirect_uri is not a valid absolute URL";
|
|
335
|
+
}
|
|
336
|
+
if (url.username || url.password) return "redirect_uri must not contain credentials";
|
|
337
|
+
if (url.hash) return "redirect_uri must not contain a fragment";
|
|
338
|
+
const host = url.hostname.toLowerCase();
|
|
339
|
+
if (url.protocol === "http:") {
|
|
340
|
+
return LOOPBACK_HOSTS.includes(host) ? null : "redirect_uri must use https (http is allowed only for localhost, 127.0.0.1 or [::1])";
|
|
341
|
+
}
|
|
342
|
+
if (url.protocol === "https:") {
|
|
343
|
+
if (url.port && url.port !== "443") return "redirect_uri must use the default https port (443)";
|
|
344
|
+
if (url.search) return "redirect_uri must not contain a query";
|
|
345
|
+
const exact = exactForm(url);
|
|
346
|
+
return allowlist.uris.includes(exact) || CHATGPT_CONNECTOR_CALLBACK.test(exact) ? null : "redirect_uri is not an allowed callback (an operator can add the full URI with OAUTH_REDIRECT_HOST_ALLOWLIST)";
|
|
347
|
+
}
|
|
348
|
+
return "redirect_uri scheme is not allowed (use https, or http on loopback)";
|
|
349
|
+
}
|
|
350
|
+
function checkRegistrationRedirects(redirectUris, allowlist) {
|
|
351
|
+
const refused = (refusal) => ({ refusal, allowed: [], dropped: [] });
|
|
352
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) return refused("redirect_uris is required");
|
|
353
|
+
if (redirectUris.length > MAX_REGISTRATION_REDIRECTS) {
|
|
354
|
+
return refused(`redirect_uris may hold at most ${MAX_REGISTRATION_REDIRECTS} entries`);
|
|
355
|
+
}
|
|
356
|
+
const allowed = [];
|
|
357
|
+
const dropped = [];
|
|
358
|
+
for (const uri of redirectUris) {
|
|
359
|
+
const reason = checkRedirectUri(uri, allowlist);
|
|
360
|
+
if (reason) dropped.push({ uri, reason });
|
|
361
|
+
else allowed.push(uri);
|
|
362
|
+
}
|
|
363
|
+
return { refusal: allowed.length === 0 ? dropped[0].reason : null, allowed, dropped };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/lib/oauthAuthCode.ts
|
|
367
|
+
function normalisePastedKey(raw) {
|
|
368
|
+
if (typeof raw !== "string") return "";
|
|
369
|
+
const trimmed = raw.trim();
|
|
370
|
+
const first = trimmed[0];
|
|
371
|
+
if (trimmed.length >= 2 && (first === '"' || first === "'" || first === "`") && trimmed.endsWith(first)) {
|
|
372
|
+
return trimmed.slice(1, -1).trim();
|
|
373
|
+
}
|
|
374
|
+
return trimmed;
|
|
375
|
+
}
|
|
376
|
+
function pendingCodeRefusal(pending, redirectUri, now) {
|
|
377
|
+
if (!pending) return "unknown_code";
|
|
378
|
+
if (pending.redirectUri !== redirectUri) return "redirect_mismatch";
|
|
379
|
+
if (now > pending.expiresAt) return "expired";
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
function tokenResponse(apiKey) {
|
|
383
|
+
return {
|
|
384
|
+
access_token: apiKey,
|
|
385
|
+
token_type: "Bearer",
|
|
386
|
+
// 1 year: validity is enforced by Convex on every call, not by this clock.
|
|
387
|
+
expires_in: 365 * 24 * 3600
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/http.ts
|
|
22
392
|
bootstrapHttp();
|
|
23
|
-
initAnalytics();
|
|
24
|
-
initFeatureFlags(getPostHogClient());
|
|
25
393
|
var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
|
|
394
|
+
function keyCheckCandidates() {
|
|
395
|
+
const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
|
|
396
|
+
return [primaryUrl, ...parseFallbackUrls(process.env.CONVEX_FALLBACK_URLS).map((u) => u.replace(/\/$/, ""))];
|
|
397
|
+
}
|
|
26
398
|
function baseUrl(req) {
|
|
27
399
|
const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
|
|
28
400
|
const host = req.headers.host ?? `localhost:${PORT}`;
|
|
@@ -31,10 +403,11 @@ function baseUrl(req) {
|
|
|
31
403
|
var app = express();
|
|
32
404
|
app.set("trust proxy", 1);
|
|
33
405
|
app.use(express.json());
|
|
34
|
-
var ALLOWED_ORIGINS = process.env.CORS_ORIGINS
|
|
406
|
+
var ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "https://claude.ai").split(",").map((o) => o.trim()).filter(Boolean);
|
|
407
|
+
var OAUTH_REDIRECT_ALLOWLIST = resolveOAuthRedirectAllowlist({ explicitAllowlist: process.env.OAUTH_REDIRECT_HOST_ALLOWLIST });
|
|
35
408
|
app.use((_req, res, next) => {
|
|
36
409
|
const origin = _req.headers.origin;
|
|
37
|
-
if (
|
|
410
|
+
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
38
411
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
39
412
|
}
|
|
40
413
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
@@ -42,7 +415,7 @@ app.use((_req, res, next) => {
|
|
|
42
415
|
"Access-Control-Allow-Headers",
|
|
43
416
|
"Content-Type, Authorization, Mcp-Session-Id, Last-Event-Id"
|
|
44
417
|
);
|
|
45
|
-
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
418
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, Retry-After");
|
|
46
419
|
if (_req.method === "OPTIONS") {
|
|
47
420
|
res.status(204).end();
|
|
48
421
|
return;
|
|
@@ -66,37 +439,46 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
|
66
439
|
token_endpoint: `${base}/oauth/token`,
|
|
67
440
|
registration_endpoint: `${base}/register`,
|
|
68
441
|
response_types_supported: ["code"],
|
|
69
|
-
grant_types_supported: ["authorization_code"
|
|
442
|
+
grant_types_supported: ["authorization_code"],
|
|
443
|
+
// TEN-4548: no refresh grant (lib/oauthAuthCode.ts tokenResponse)
|
|
70
444
|
code_challenge_methods_supported: ["S256"],
|
|
71
445
|
token_endpoint_auth_methods_supported: ["none"],
|
|
72
446
|
scopes_supported: ["mcp:tools", "mcp:resources"]
|
|
73
447
|
});
|
|
74
448
|
});
|
|
75
|
-
|
|
449
|
+
function logOAuthDoor(door, status, reason, uris) {
|
|
450
|
+
const list = uris === void 0 ? "" : ` uris=${JSON.stringify(uris.map(String)).slice(0, 600)}`;
|
|
451
|
+
process.stderr.write(`[oauth] door=${door} status=${status} reason=${JSON.stringify(reason)}${list}
|
|
452
|
+
`);
|
|
453
|
+
}
|
|
454
|
+
var authLimiter = rateLimit2({
|
|
455
|
+
windowMs: 6e4,
|
|
456
|
+
max: 20,
|
|
457
|
+
standardHeaders: true,
|
|
458
|
+
legacyHeaders: false,
|
|
459
|
+
handler: (req, res) => {
|
|
460
|
+
logOAuthDoor(`${req.method} /authorize`, 429, "rate_limited_per_ip");
|
|
461
|
+
res.status(429).json({ error: "Too many auth requests. Try again later." });
|
|
462
|
+
}
|
|
463
|
+
});
|
|
76
464
|
app.post(
|
|
77
465
|
"/register",
|
|
78
466
|
express.json(),
|
|
79
467
|
(req, res) => {
|
|
80
|
-
const { redirect_uris, client_name } = req.body;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
});
|
|
468
|
+
const { redirect_uris, client_name } = req.body ?? {};
|
|
469
|
+
const { refusal, allowed, dropped } = checkRegistrationRedirects(redirect_uris, OAUTH_REDIRECT_ALLOWLIST);
|
|
470
|
+
if (refusal) {
|
|
471
|
+
logOAuthDoor("POST /register", 400, refusal, Array.isArray(redirect_uris) ? redirect_uris : [redirect_uris]);
|
|
472
|
+
res.status(400).json({ error: "invalid_redirect_uri", error_description: refusal });
|
|
86
473
|
return;
|
|
87
474
|
}
|
|
88
475
|
const clientId = `pb_client_${randomUUID()}`;
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
redirect_uris,
|
|
92
|
-
client_name,
|
|
93
|
-
registeredAt: Date.now()
|
|
94
|
-
};
|
|
95
|
-
registeredClients.set(clientId, client);
|
|
476
|
+
const droppedUris = dropped.length > 0 ? dropped.map((d) => d.uri) : void 0;
|
|
477
|
+
logOAuthDoor("POST /register", 201, droppedUris ? "dropped_redirects" : "registered", droppedUris);
|
|
96
478
|
res.status(201).json({
|
|
97
479
|
client_id: clientId,
|
|
98
480
|
client_name: client_name ?? "MCP Client",
|
|
99
|
-
redirect_uris,
|
|
481
|
+
redirect_uris: allowed,
|
|
100
482
|
grant_types: ["authorization_code"],
|
|
101
483
|
response_types: ["code"],
|
|
102
484
|
token_endpoint_auth_method: "none"
|
|
@@ -104,76 +486,654 @@ app.post(
|
|
|
104
486
|
}
|
|
105
487
|
);
|
|
106
488
|
var pendingCodes = /* @__PURE__ */ new Map();
|
|
107
|
-
var ACCESS_TOKEN_TTL = 3600;
|
|
108
|
-
var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
|
|
109
|
-
var refreshTokens = /* @__PURE__ */ new Map();
|
|
110
489
|
setInterval(() => {
|
|
111
490
|
const now = Date.now();
|
|
112
491
|
for (const [code, auth] of pendingCodes) {
|
|
113
492
|
if (now > auth.expiresAt) pendingCodes.delete(code);
|
|
114
493
|
}
|
|
115
|
-
for (const [
|
|
116
|
-
if (now
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (now - entry.createdAt > REFRESH_TOKEN_TTL_MS) refreshTokens.delete(token);
|
|
494
|
+
for (const [credentialId, rec] of authFailures) {
|
|
495
|
+
if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
|
|
496
|
+
authFailures.delete(credentialId);
|
|
497
|
+
}
|
|
120
498
|
}
|
|
121
499
|
}, 6e4);
|
|
122
500
|
function esc(s) {
|
|
123
501
|
return String(s ?? "").replace(
|
|
124
|
-
/[&"<>]/g,
|
|
125
|
-
(c) => ({ "&": "&", '"': """, "<": "<", ">": ">" })[c]
|
|
502
|
+
/[&"'<>]/g,
|
|
503
|
+
(c) => ({ "&": "&", '"': """, "'": "'", "<": "<", ">": ">" })[c]
|
|
126
504
|
);
|
|
127
505
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
<html lang="en"><head>
|
|
506
|
+
function authPageShell(title, bodyContent, headExtra = "") {
|
|
507
|
+
return `<!DOCTYPE html>
|
|
508
|
+
<html lang="en" data-theme="parchment-dark"><head>
|
|
132
509
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
133
|
-
<title
|
|
510
|
+
<title>${esc(title)} \u2014 Product Brain</title>
|
|
511
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
512
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
513
|
+
<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">
|
|
514
|
+
${headExtra}
|
|
134
515
|
<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
|
-
|
|
516
|
+
:root{
|
|
517
|
+
--bg:#1a1917;--bg-warm:#201f1c;--surface:#262521;
|
|
518
|
+
--fg1:#e4e0d8;--fg2:#c4bfb4;--fg3:#9a9589;--fg4:#6a6560;--fg-bright:#ffffff;
|
|
519
|
+
--border:rgba(255,255,255,0.07);--border-light:rgba(255,255,255,0.04);
|
|
520
|
+
--accent:#c9b99a;
|
|
521
|
+
--btn-bg:#ffffff;--btn-fg:#1a1917;--btn-hover:#e4e0d8;
|
|
522
|
+
--green:#4ade80;--rose:#ef4444;
|
|
523
|
+
--ghost:rgba(38,37,33,0.55);
|
|
524
|
+
--radius-md:7px;--radius-lg:10px;
|
|
525
|
+
--font-display:"Source Serif 4",ui-serif,Georgia,serif;
|
|
526
|
+
--font-body:"IBM Plex Sans",ui-sans-serif,system-ui,sans-serif;
|
|
527
|
+
--font-mono:"IBM Plex Mono",ui-monospace,"SF Mono",Menlo,monospace;
|
|
528
|
+
}
|
|
529
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
530
|
+
html,body{height:100%}
|
|
531
|
+
body{
|
|
532
|
+
font-family:var(--font-body);font-size:13px;line-height:1.45;
|
|
533
|
+
color:var(--fg1);background:var(--bg);
|
|
534
|
+
min-height:100vh;display:grid;place-items:center;padding:24px;
|
|
535
|
+
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
|
|
536
|
+
position:relative;overflow:hidden;
|
|
537
|
+
}
|
|
538
|
+
body::before{
|
|
539
|
+
content:"";position:fixed;inset:0;
|
|
540
|
+
background:radial-gradient(900px 600px at 50% 50%,rgba(228,224,216,0.025),transparent 60%);
|
|
541
|
+
pointer-events:none;z-index:0;
|
|
542
|
+
}
|
|
543
|
+
.top-mark{position:fixed;top:22px;left:24px;z-index:5;opacity:0.7}
|
|
544
|
+
${appLogoStyles}
|
|
545
|
+
.stage{
|
|
546
|
+
width:100%;max-width:460px;text-align:center;
|
|
547
|
+
position:relative;z-index:1;
|
|
548
|
+
display:grid;
|
|
549
|
+
}
|
|
550
|
+
.panel{
|
|
551
|
+
grid-area:1/1;
|
|
552
|
+
transition:opacity 280ms ease-out,transform 380ms cubic-bezier(.2,.6,.2,1),filter 380ms ease-out;
|
|
553
|
+
}
|
|
554
|
+
.panel[hidden]{
|
|
555
|
+
display:block !important;
|
|
556
|
+
opacity:0;transform:scale(0.96) translateY(-2px);filter:blur(6px);
|
|
557
|
+
pointer-events:none;
|
|
558
|
+
}
|
|
559
|
+
.panel:not([hidden]){opacity:1;transform:scale(1);filter:none;pointer-events:auto}
|
|
560
|
+
|
|
561
|
+
/* eyebrows */
|
|
562
|
+
.eyebrow{
|
|
563
|
+
font-family:var(--font-mono);font-size:10px;font-weight:700;
|
|
564
|
+
letter-spacing:0.28em;text-transform:uppercase;color:var(--fg4);
|
|
565
|
+
margin-bottom:24px;display:inline-flex;align-items:center;gap:7px;
|
|
566
|
+
}
|
|
567
|
+
.eyebrow .dot{width:5px;height:5px;border-radius:50%;background:currentColor}
|
|
568
|
+
.eyebrow.danger{color:var(--rose)}
|
|
569
|
+
.eyebrow.success{color:var(--green)}
|
|
570
|
+
.eyebrow.success .dot{box-shadow:0 0 0 3px rgba(74,222,128,0.18)}
|
|
571
|
+
|
|
572
|
+
/* form input */
|
|
573
|
+
.input-wrap{
|
|
574
|
+
display:flex;align-items:center;background:rgba(0,0,0,0.22);
|
|
575
|
+
border:1px solid var(--border);border-radius:var(--radius-md);
|
|
576
|
+
transition:border-color 200ms ease-out,box-shadow 200ms ease-out;
|
|
577
|
+
}
|
|
578
|
+
.input-wrap:focus-within{
|
|
579
|
+
border-color:rgba(228,224,216,0.45);
|
|
580
|
+
box-shadow:0 0 0 3px rgba(228,224,216,0.10);
|
|
581
|
+
}
|
|
582
|
+
.input-wrap.has-error{
|
|
583
|
+
border-color:rgba(239,68,68,0.55);
|
|
584
|
+
box-shadow:0 0 0 3px rgba(239,68,68,0.12);
|
|
585
|
+
animation:shake 360ms cubic-bezier(.36,.07,.19,.97);
|
|
586
|
+
}
|
|
587
|
+
@keyframes shake{
|
|
588
|
+
10%,90%{transform:translateX(-1px)}20%,80%{transform:translateX(2px)}
|
|
589
|
+
30%,50%,70%{transform:translateX(-4px)}40%,60%{transform:translateX(4px)}
|
|
590
|
+
}
|
|
591
|
+
.input{
|
|
592
|
+
flex:1;min-width:0;background:transparent;border:0;outline:none;
|
|
593
|
+
padding:16px;
|
|
594
|
+
font-family:var(--font-mono);font-size:14px;color:var(--fg1);letter-spacing:0.02em;
|
|
595
|
+
}
|
|
596
|
+
.input::placeholder{color:var(--fg4)}
|
|
597
|
+
.hint{
|
|
598
|
+
margin-top:10px;font-family:var(--font-mono);font-size:10.5px;
|
|
599
|
+
letter-spacing:0.16em;text-transform:uppercase;
|
|
600
|
+
text-align:left;padding-left:4px;height:14px;color:var(--fg4);
|
|
601
|
+
transition:color 160ms ease-out;
|
|
602
|
+
}
|
|
603
|
+
.hint.is-error{color:var(--rose)}
|
|
604
|
+
|
|
605
|
+
/* primary button */
|
|
606
|
+
.btn-primary{
|
|
607
|
+
width:100%;height:48px;margin-top:14px;
|
|
608
|
+
border:0;border-radius:var(--radius-md);
|
|
609
|
+
background:var(--btn-bg);color:var(--btn-fg);
|
|
610
|
+
font-family:var(--font-body);font-size:14.5px;font-weight:600;letter-spacing:-0.005em;
|
|
611
|
+
cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:10px;
|
|
612
|
+
transition:background 140ms ease-out,transform 80ms ease-out,opacity 200ms ease-out;
|
|
613
|
+
position:relative;overflow:hidden;
|
|
614
|
+
}
|
|
615
|
+
.btn-primary:hover:not([disabled]){background:var(--btn-hover)}
|
|
616
|
+
.btn-primary:active:not([disabled]){transform:translateY(1px)}
|
|
617
|
+
.btn-primary[disabled]{opacity:0.45;cursor:default}
|
|
618
|
+
.spin{
|
|
619
|
+
width:14px;height:14px;border-radius:50%;
|
|
620
|
+
border:1.5px solid currentColor;border-top-color:transparent;
|
|
621
|
+
animation:spin 700ms linear infinite;opacity:0.85;
|
|
622
|
+
}
|
|
623
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
624
|
+
|
|
625
|
+
/* secondary link */
|
|
626
|
+
.small-link{
|
|
627
|
+
margin-top:18px;font-family:var(--font-mono);font-size:11px;
|
|
628
|
+
letter-spacing:0.18em;text-transform:uppercase;color:var(--fg4);
|
|
629
|
+
}
|
|
630
|
+
.small-link a{
|
|
631
|
+
color:var(--fg3);text-decoration:none;border-bottom:1px dotted currentColor;
|
|
632
|
+
padding-bottom:1px;transition:color 140ms;
|
|
633
|
+
}
|
|
634
|
+
.small-link a:hover{color:var(--fg1)}
|
|
635
|
+
|
|
636
|
+
/* orb */
|
|
637
|
+
.orb-wrap{
|
|
638
|
+
position:relative;width:160px;height:160px;margin:0 auto 36px;
|
|
639
|
+
display:grid;place-items:center;
|
|
640
|
+
}
|
|
641
|
+
.orb-ring{position:absolute;border-radius:50%}
|
|
642
|
+
.orb-ring.r1{inset:0;border:1px solid rgba(228,224,216,0.06);animation:drift1 40s linear infinite}
|
|
643
|
+
.orb-ring.r2{inset:18px;border:1px dashed rgba(228,224,216,0.10);animation:drift2 28s linear infinite}
|
|
644
|
+
.orb-ring.r3{inset:38px;border:1px solid rgba(74,222,128,0.20);animation:ringPulse 3.4s ease-in-out infinite}
|
|
645
|
+
@keyframes drift1{to{transform:rotate(360deg)}}
|
|
646
|
+
@keyframes drift2{to{transform:rotate(-360deg)}}
|
|
647
|
+
@keyframes ringPulse{0%,100%{opacity:0.6}50%{opacity:1}}
|
|
648
|
+
|
|
649
|
+
.orb-wrap.is-verifying .orb-ring.r3{
|
|
650
|
+
border-color:rgba(228,224,216,0.20);
|
|
651
|
+
animation:ringPulse 1.1s ease-in-out infinite;
|
|
652
|
+
}
|
|
653
|
+
.orb-wrap.is-verifying .orb-core{
|
|
654
|
+
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);
|
|
655
|
+
animation:corePulseNeutral 1.6s ease-in-out infinite;
|
|
656
|
+
}
|
|
657
|
+
.orb-wrap.is-verifying .orb-dot{background:var(--fg3);box-shadow:0 0 10px rgba(228,224,216,0.4)}
|
|
658
|
+
@keyframes corePulseNeutral{
|
|
659
|
+
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)}
|
|
660
|
+
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)}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.orb-wrap.is-error .orb-ring.r3{border-color:rgba(239,68,68,0.30);animation:none;opacity:1}
|
|
664
|
+
.orb-wrap.is-error .orb-ring.r1,.orb-wrap.is-error .orb-ring.r2{animation-play-state:paused}
|
|
665
|
+
.orb-wrap.is-error .orb-core{
|
|
666
|
+
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);
|
|
667
|
+
animation:none;
|
|
668
|
+
}
|
|
669
|
+
.orb-wrap.is-error .orb-dot{background:var(--rose);box-shadow:0 0 10px rgba(239,68,68,0.6)}
|
|
670
|
+
|
|
671
|
+
.sat-orbit{position:absolute;inset:0;pointer-events:none}
|
|
672
|
+
.sat-orbit.o1{animation:drift1 40s linear infinite}
|
|
673
|
+
.sat-orbit.o2{animation:drift2 56s linear infinite}
|
|
674
|
+
.sat{
|
|
675
|
+
position:absolute;top:50%;left:50%;
|
|
676
|
+
font-family:var(--font-mono);font-size:9px;font-weight:700;letter-spacing:0.14em;
|
|
677
|
+
background:var(--bg);padding:2px 6px;border-radius:3px;
|
|
678
|
+
border:1px solid var(--border-light);
|
|
679
|
+
transform-origin:0 0;opacity:0;
|
|
680
|
+
}
|
|
681
|
+
.panel:not([hidden])[data-state="connected"] .sat{animation:satIn 600ms ease-out forwards}
|
|
682
|
+
@keyframes satIn{from{opacity:0}to{opacity:1}}
|
|
683
|
+
.sat span{display:inline-block;animation:counter 40s linear infinite}
|
|
684
|
+
.sat-orbit.o2 .sat span{animation:counter2 56s linear infinite}
|
|
685
|
+
@keyframes counter{to{transform:rotate(-360deg)}}
|
|
686
|
+
@keyframes counter2{to{transform:rotate(360deg)}}
|
|
687
|
+
|
|
688
|
+
.orb-core{
|
|
689
|
+
position:relative;width:60px;height:60px;border-radius:50%;
|
|
690
|
+
background:radial-gradient(circle at 50% 45%,#1a1a1a 0%,#0c0c0c 60%,#050505 100%);
|
|
691
|
+
border:1px solid rgba(255,255,255,0.06);
|
|
692
|
+
display:grid;place-items:center;
|
|
693
|
+
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);
|
|
694
|
+
animation:corePulse 3.4s ease-in-out infinite;
|
|
695
|
+
}
|
|
696
|
+
@keyframes corePulse{
|
|
697
|
+
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)}
|
|
698
|
+
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)}
|
|
699
|
+
}
|
|
700
|
+
.orb-dot{width:10px;height:10px;border-radius:50%;background:#4ade80;box-shadow:0 0 12px rgba(74,222,128,0.7)}
|
|
701
|
+
|
|
702
|
+
.core-shockwave{
|
|
703
|
+
position:absolute;inset:0;border-radius:50%;
|
|
704
|
+
border:1px solid rgba(74,222,128,0.6);
|
|
705
|
+
opacity:0;pointer-events:none;
|
|
706
|
+
}
|
|
707
|
+
.panel:not([hidden])[data-state="connected"] .core-shockwave{animation:shock 1100ms ease-out 200ms}
|
|
708
|
+
@keyframes shock{
|
|
709
|
+
0%{opacity:0.7;transform:scale(0.4);border-width:2px}
|
|
710
|
+
100%{opacity:0;transform:scale(2.4);border-width:1px}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/* titles */
|
|
714
|
+
.ok-title{
|
|
715
|
+
font-family:var(--font-display);font-weight:600;
|
|
716
|
+
font-size:38px;line-height:1.05;letter-spacing:-0.025em;
|
|
717
|
+
color:var(--fg-bright);opacity:0;
|
|
718
|
+
}
|
|
719
|
+
.panel:not([hidden]) .ok-title{animation:rise 600ms ease-out 380ms forwards}
|
|
720
|
+
.ok-lead{
|
|
721
|
+
margin:18px auto 0;max-width:22em;font-size:16px;line-height:1.55;color:var(--fg2);
|
|
722
|
+
opacity:0;
|
|
723
|
+
}
|
|
724
|
+
.panel:not([hidden]) .ok-lead{animation:rise 600ms ease-out 500ms forwards}
|
|
725
|
+
.ok-phrase-row{
|
|
726
|
+
margin-top:14px;display:flex;align-items:center;justify-content:center;
|
|
727
|
+
opacity:0;
|
|
728
|
+
}
|
|
729
|
+
.panel:not([hidden]) .ok-phrase-row{animation:rise 600ms ease-out 620ms forwards}
|
|
730
|
+
.cmd.is-copied .cmd-quote-part{display:none}
|
|
731
|
+
.success-cta-wrap{
|
|
732
|
+
margin-top:48px;width:100%;opacity:0;
|
|
733
|
+
}
|
|
734
|
+
.panel:not([hidden]) .success-cta-wrap{animation:rise 600ms ease-out 780ms forwards}
|
|
735
|
+
a.btn-primary.success-cta{color:var(--btn-fg);text-decoration:none}
|
|
736
|
+
|
|
737
|
+
.cmd{
|
|
738
|
+
display:inline-flex;align-items:center;gap:8px;vertical-align:middle;
|
|
739
|
+
font-family:var(--font-mono);font-size:15px;font-weight:500;color:var(--accent);
|
|
740
|
+
background:rgba(201,185,154,0.08);border:1px solid rgba(201,185,154,0.30);
|
|
741
|
+
padding:5px 10px 5px 12px;border-radius:6px;letter-spacing:0.02em;
|
|
742
|
+
cursor:pointer;user-select:none;
|
|
743
|
+
transition:background 140ms ease-out,border-color 140ms ease-out,color 140ms ease-out;
|
|
744
|
+
}
|
|
745
|
+
.cmd:hover{background:rgba(201,185,154,0.14);border-color:rgba(201,185,154,0.50);color:#dcc9a4}
|
|
746
|
+
.cmd.is-copied{color:var(--green);border-color:rgba(74,222,128,0.35);background:rgba(74,222,128,0.08)}
|
|
747
|
+
.cmd .cmd-icon{
|
|
748
|
+
width:12px;height:12px;color:currentColor;opacity:0.75;
|
|
749
|
+
display:inline-grid;place-items:center;
|
|
750
|
+
transition:opacity 140ms;
|
|
751
|
+
}
|
|
752
|
+
.cmd:hover .cmd-icon{opacity:1}
|
|
753
|
+
.cmd.is-copied .cmd-icon{color:var(--green);opacity:1}
|
|
754
|
+
.cmd .cmd-icon svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
|
|
755
|
+
|
|
756
|
+
/* error specifics */
|
|
757
|
+
.err-title{
|
|
758
|
+
font-family:var(--font-display);font-weight:600;
|
|
759
|
+
font-size:32px;line-height:1.1;letter-spacing:-0.02em;
|
|
760
|
+
color:var(--fg1);margin-bottom:12px;
|
|
761
|
+
}
|
|
762
|
+
.err-msg{
|
|
763
|
+
font-size:14.5px;line-height:1.55;color:var(--fg3);
|
|
764
|
+
margin-bottom:28px;
|
|
765
|
+
}
|
|
766
|
+
.err-msg code{
|
|
767
|
+
font-family:var(--font-mono);font-size:12px;
|
|
768
|
+
background:rgba(255,255,255,0.06);padding:1px 5px;border-radius:4px;color:var(--fg2);
|
|
769
|
+
}
|
|
770
|
+
.err-actions{display:flex;gap:8px}
|
|
771
|
+
.btn-secondary{
|
|
772
|
+
flex:1;height:44px;border-radius:var(--radius-md);
|
|
773
|
+
background:transparent;color:var(--fg2);
|
|
774
|
+
border:1px solid var(--border);
|
|
775
|
+
font-family:var(--font-body);font-size:13.5px;font-weight:500;
|
|
776
|
+
cursor:pointer;text-decoration:none;
|
|
777
|
+
display:inline-flex;align-items:center;justify-content:center;
|
|
778
|
+
transition:background 140ms,color 140ms,border-color 140ms;
|
|
779
|
+
}
|
|
780
|
+
.btn-secondary:hover{background:rgba(255,255,255,0.04);color:var(--fg1);border-color:rgba(255,255,255,0.14)}
|
|
781
|
+
|
|
782
|
+
/* verifying */
|
|
783
|
+
.verifying-eyebrow{
|
|
784
|
+
font-family:var(--font-mono);font-size:10px;
|
|
785
|
+
letter-spacing:0.28em;text-transform:uppercase;color:var(--fg3);
|
|
786
|
+
font-weight:700;margin-bottom:14px;
|
|
787
|
+
}
|
|
788
|
+
.verifying-title{
|
|
789
|
+
font-family:var(--font-display);font-weight:600;
|
|
790
|
+
font-size:28px;line-height:1.1;color:var(--fg1);margin-bottom:8px;
|
|
791
|
+
letter-spacing:-0.02em;
|
|
792
|
+
}
|
|
793
|
+
.verifying-sub{font-size:13px;color:var(--fg3);min-height:1.45em}
|
|
794
|
+
|
|
795
|
+
/* form heading */
|
|
796
|
+
.form-title{
|
|
797
|
+
font-family:var(--font-display);font-weight:600;
|
|
798
|
+
font-size:32px;line-height:1.1;letter-spacing:-0.02em;
|
|
799
|
+
color:var(--fg1);margin-bottom:10px;
|
|
800
|
+
}
|
|
801
|
+
.form-sub{font-size:13.5px;color:var(--fg3);margin-bottom:28px;line-height:1.55}
|
|
802
|
+
|
|
803
|
+
@keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
|
804
|
+
|
|
805
|
+
@media (prefers-reduced-motion: reduce){
|
|
806
|
+
*,*::before,*::after{
|
|
807
|
+
animation-duration:0.01ms !important;animation-iteration-count:1 !important;
|
|
808
|
+
transition-duration:0.01ms !important;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
</style>
|
|
812
|
+
</head><body>
|
|
813
|
+
<div class="top-mark">${appLogoMarkup({ size: "sm" })}</div>
|
|
814
|
+
<div class="stage">${bodyContent}</div>
|
|
815
|
+
</body></html>`;
|
|
816
|
+
}
|
|
817
|
+
var PROVIDER_NAME = "your assistant";
|
|
818
|
+
function successPanelInner(workspaceName, redirectUrl, providerName) {
|
|
819
|
+
return `
|
|
820
|
+
<div class="orb-wrap">
|
|
821
|
+
<div class="orb-ring r1"></div>
|
|
822
|
+
<div class="orb-ring r2"></div>
|
|
823
|
+
<div class="orb-ring r3"></div>
|
|
824
|
+
<div class="sat-orbit o1">
|
|
825
|
+
<span class="sat" style="transform:rotate(20deg) translate(78px) rotate(-20deg);color:#4ade80;animation-delay:600ms"><span>DEC</span></span>
|
|
826
|
+
<span class="sat" style="transform:rotate(140deg) translate(78px) rotate(-140deg);color:#c9b99a;animation-delay:720ms"><span>WP</span></span>
|
|
827
|
+
<span class="sat" style="transform:rotate(260deg) translate(78px) rotate(-260deg);color:#f59e0b;animation-delay:840ms"><span>TEN</span></span>
|
|
828
|
+
</div>
|
|
829
|
+
<div class="sat-orbit o2">
|
|
830
|
+
<span class="sat" style="transform:rotate(80deg) translate(54px) rotate(-80deg);color:#60a5fa;animation-delay:960ms"><span>STD</span></span>
|
|
831
|
+
<span class="sat" style="transform:rotate(220deg) translate(54px) rotate(-220deg);color:#a78bfa;animation-delay:1080ms"><span>INS</span></span>
|
|
832
|
+
</div>
|
|
833
|
+
<div class="core-shockwave"></div>
|
|
834
|
+
<div class="orb-core"><div class="orb-dot"></div></div>
|
|
835
|
+
</div>
|
|
836
|
+
<div class="eyebrow success"><span class="dot"></span>Connected</div>
|
|
837
|
+
<h1 class="ok-title">Product Brain is Live</h1>
|
|
838
|
+
<p class="ok-lead">Return to your assistant, then say</p>
|
|
839
|
+
<div class="ok-phrase-row">
|
|
840
|
+
<button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy "${esc(PASTE_NOW)}" and return to ${esc(providerName)}">
|
|
841
|
+
<span class="cmd-quote-part" aria-hidden="true">“</span><span data-cmd-text>${esc(PASTE_NOW)}</span><span class="cmd-quote-part" aria-hidden="true">”</span>
|
|
842
|
+
<span class="cmd-icon" aria-hidden="true">
|
|
843
|
+
<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>
|
|
844
|
+
</span>
|
|
845
|
+
</button>
|
|
163
846
|
</div>
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
847
|
+
<div class="success-cta-wrap">
|
|
848
|
+
<a class="btn-primary success-cta" href="${esc(redirectUrl)}">Continue in ${esc(providerName)}</a>
|
|
849
|
+
</div>
|
|
850
|
+
<!-- workspace name retained as data hook for tests, hidden from view -->
|
|
851
|
+
<span hidden data-field="ws-name">${esc(workspaceName)}</span>`;
|
|
852
|
+
}
|
|
853
|
+
function errorPanelInner(title, trustedDetailHtml, retryUrl) {
|
|
854
|
+
return `
|
|
855
|
+
<div class="orb-wrap is-error">
|
|
856
|
+
<div class="orb-ring r1"></div>
|
|
857
|
+
<div class="orb-ring r2"></div>
|
|
858
|
+
<div class="orb-ring r3"></div>
|
|
859
|
+
<div class="orb-core"><div class="orb-dot"></div></div>
|
|
860
|
+
</div>
|
|
861
|
+
<div class="eyebrow danger"><span class="dot"></span>Couldn't connect</div>
|
|
862
|
+
<h2 class="err-title" data-field="err-title">${esc(title)}</h2>
|
|
863
|
+
<p class="err-msg" data-field="err-msg">${trustedDetailHtml}</p>
|
|
864
|
+
<div class="err-actions">
|
|
865
|
+
<a href="${esc(retryUrl)}" class="btn-secondary" data-retry-link>← Try again</a>
|
|
866
|
+
<a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer" class="btn-secondary">Get an API key →</a>
|
|
867
|
+
</div>`;
|
|
868
|
+
}
|
|
869
|
+
var cmdScript = `
|
|
870
|
+
(function(){
|
|
871
|
+
function bindCmd(pill){
|
|
872
|
+
if(!pill||pill.__bound)return;pill.__bound=true;
|
|
873
|
+
var textEl=pill.querySelector('[data-cmd-text]');
|
|
874
|
+
var svgEl=pill.querySelector('[data-cmd-svg]');
|
|
875
|
+
var redirectUrl=pill.getAttribute('data-redirect')||'';
|
|
876
|
+
pill.addEventListener('click',function(){
|
|
877
|
+
var done=function(){
|
|
878
|
+
pill.classList.add('is-copied');
|
|
879
|
+
if(textEl)textEl.textContent='Copied';
|
|
880
|
+
if(svgEl)svgEl.innerHTML='<polyline points="4 12 10 18 20 6"/>';
|
|
881
|
+
setTimeout(function(){if(redirectUrl)window.location.assign(redirectUrl)},900);
|
|
882
|
+
};
|
|
883
|
+
try{
|
|
884
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
885
|
+
navigator.clipboard.writeText(${JSON.stringify(PASTE_NOW)}).then(done,done);
|
|
886
|
+
}else{done()}
|
|
887
|
+
}catch(e){done()}
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
document.querySelectorAll('[data-cmd-pill]').forEach(bindCmd);
|
|
891
|
+
})();
|
|
892
|
+
`;
|
|
893
|
+
function authorizeFormPage(params) {
|
|
894
|
+
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = params;
|
|
895
|
+
const providerName = PROVIDER_NAME;
|
|
896
|
+
const body = `
|
|
897
|
+
<!-- \u2500\u2500\u2500 CONNECT \u2500\u2500\u2500 -->
|
|
898
|
+
<div class="panel" id="p-connect" data-state="connect">
|
|
899
|
+
<div class="eyebrow">Connect Product Brain</div>
|
|
900
|
+
<h1 class="form-title">Paste your API key</h1>
|
|
901
|
+
<p class="form-sub">Give <span data-provider>${esc(providerName)}</span> access to your workspace memory.</p>
|
|
902
|
+
<form method="POST" action="/authorize" id="f" autocomplete="off">
|
|
903
|
+
<input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
|
|
904
|
+
<input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
|
|
905
|
+
<input type="hidden" name="code_challenge_method" value="${esc(code_challenge_method)}">
|
|
906
|
+
<input type="hidden" name="state" value="${esc(state)}">
|
|
907
|
+
<input type="hidden" name="client_id" value="${esc(client_id)}">
|
|
908
|
+
<div class="input-wrap" id="iw">
|
|
909
|
+
<input type="password" id="k" name="api_key" class="input input-full" placeholder="pb_sk_\u2026" required autofocus spellcheck="false">
|
|
910
|
+
</div>
|
|
911
|
+
<div class="hint" id="hint" hidden></div>
|
|
912
|
+
<button type="submit" class="btn-primary" id="sb" disabled><span id="bt">Connect</span></button>
|
|
913
|
+
</form>
|
|
914
|
+
<div class="small-link"><a href="${esc(apiKeysUrl())}" target="_blank" rel="noopener noreferrer">No key? Generate one →</a></div>
|
|
915
|
+
</div>
|
|
916
|
+
|
|
917
|
+
<!-- \u2500\u2500\u2500 VERIFYING \u2500\u2500\u2500 -->
|
|
918
|
+
<div class="panel" id="p-verifying" data-state="verifying" hidden>
|
|
919
|
+
<div class="orb-wrap is-verifying">
|
|
920
|
+
<div class="orb-ring r1"></div>
|
|
921
|
+
<div class="orb-ring r2"></div>
|
|
922
|
+
<div class="orb-ring r3"></div>
|
|
923
|
+
<div class="orb-core"><div class="orb-dot"></div></div>
|
|
924
|
+
</div>
|
|
925
|
+
<div class="verifying-eyebrow">Handshake</div>
|
|
926
|
+
<h2 class="verifying-title">Verifying key\u2026</h2>
|
|
927
|
+
<p class="verifying-sub" id="verify-sub">Checking workspace \xB7 \u2026</p>
|
|
928
|
+
</div>
|
|
929
|
+
|
|
930
|
+
<!-- \u2500\u2500\u2500 CONNECTED (filled by JS from JSON response) \u2500\u2500\u2500 -->
|
|
931
|
+
<div class="panel" id="p-connected" data-state="connected" hidden></div>
|
|
932
|
+
|
|
933
|
+
<!-- \u2500\u2500\u2500 ERROR (filled by JS from JSON response) \u2500\u2500\u2500 -->
|
|
934
|
+
<div class="panel" id="p-error" data-state="error" hidden></div>
|
|
935
|
+
|
|
936
|
+
<template id="tpl-connected">${successPanelInner("__WS__", "__URL__", "__PROVIDER__")}</template>
|
|
937
|
+
<template id="tpl-error">${errorPanelInner("__TITLE__", "__DETAIL__", "__RETRY__")}</template>
|
|
938
|
+
|
|
939
|
+
<script>
|
|
940
|
+
${cmdScript}
|
|
941
|
+
(function(){
|
|
942
|
+
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');
|
|
943
|
+
var pConnect=document.getElementById('p-connect'),pVerify=document.getElementById('p-verifying'),pOk=document.getElementById('p-connected'),pErr=document.getElementById('p-error');
|
|
944
|
+
var verifySub=document.getElementById('verify-sub');
|
|
945
|
+
|
|
946
|
+
function show(panel){
|
|
947
|
+
[pConnect,pVerify,pOk,pErr].forEach(function(p){
|
|
948
|
+
if(p===panel){p.removeAttribute('hidden')}else{p.setAttribute('hidden','')}
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function syncInput(){
|
|
953
|
+
sb.disabled=!k.value.trim();
|
|
954
|
+
iw.classList.remove('has-error');
|
|
955
|
+
hint.classList.remove('is-error');
|
|
956
|
+
hint.textContent='';
|
|
957
|
+
hint.setAttribute('hidden','');
|
|
958
|
+
}
|
|
959
|
+
k.addEventListener('input',syncInput);
|
|
960
|
+
k.addEventListener('keydown',function(e){
|
|
961
|
+
if(e.key==='Escape'){k.value='';syncInput()}
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
function showError(title,detailHtml){
|
|
965
|
+
var tpl=document.getElementById('tpl-error');
|
|
966
|
+
var html=tpl.innerHTML
|
|
967
|
+
.replace('__TITLE__',title.replace(/[<>&]/g,function(c){return{'<':'<','>':'>','&':'&'}[c]}))
|
|
968
|
+
.replace('__DETAIL__',detailHtml)
|
|
969
|
+
.replace('__RETRY__','#');
|
|
970
|
+
pErr.innerHTML=html;
|
|
971
|
+
var retry=pErr.querySelector('[data-retry-link]');
|
|
972
|
+
if(retry){retry.addEventListener('click',function(e){e.preventDefault();show(pConnect);k.focus();k.select()})}
|
|
973
|
+
show(pErr);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function showSuccess(workspaceName,redirectUrl,providerName){
|
|
977
|
+
var tpl=document.getElementById('tpl-connected');
|
|
978
|
+
var safeWs=String(workspaceName||'').replace(/[<>&]/g,function(c){return{'<':'<','>':'>','&':'&'}[c]});
|
|
979
|
+
var safeProv=String(providerName||'your assistant').replace(/[<>&]/g,function(c){return{'<':'<','>':'>','&':'&'}[c]});
|
|
980
|
+
var safeUrl=String(redirectUrl||'').replace(/"/g,'"').replace(/[<>]/g,function(c){return{'<':'<','>':'>'}[c]});
|
|
981
|
+
var html=tpl.innerHTML.split('__WS__').join(safeWs).split('__URL__').join(safeUrl).split('__PROVIDER__').join(safeProv);
|
|
982
|
+
pOk.innerHTML=html;
|
|
983
|
+
pOk.querySelectorAll('[data-cmd-pill]').forEach(function(pill){
|
|
984
|
+
pill.__bound=false;
|
|
985
|
+
});
|
|
986
|
+
// Re-run binder
|
|
987
|
+
var s=document.createElement('script');s.textContent=${JSON.stringify(cmdScript)};document.body.appendChild(s);s.remove();
|
|
988
|
+
show(pOk);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
f.addEventListener('submit',function(e){
|
|
992
|
+
e.preventDefault();
|
|
993
|
+
var v=k.value.trim();
|
|
994
|
+
if(!v){iw.classList.add('has-error');hint.classList.add('is-error');hint.textContent='Paste your key first';hint.removeAttribute('hidden');return}
|
|
995
|
+
sb.disabled=true;bt.textContent='Verifying';
|
|
996
|
+
show(pVerify);
|
|
997
|
+
|
|
998
|
+
var steps=['Checking workspace \xB7 \u2026','Loading chain \xB7 \u2026','Establishing memory \xB7 \u2026'];
|
|
999
|
+
var i=0;verifySub.textContent=steps[0];
|
|
1000
|
+
var ti=setInterval(function(){i++;if(i>=steps.length){clearInterval(ti);return}verifySub.textContent=steps[i]},900);
|
|
1001
|
+
|
|
1002
|
+
var minDelay=new Promise(function(r){setTimeout(r,2800)});
|
|
1003
|
+
var fd=new FormData(f);
|
|
1004
|
+
var body=new URLSearchParams();
|
|
1005
|
+
fd.forEach(function(val,key){body.append(key,String(val))});
|
|
1006
|
+
|
|
1007
|
+
var req=fetch('/authorize',{
|
|
1008
|
+
method:'POST',
|
|
1009
|
+
headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},
|
|
1010
|
+
body:body.toString(),
|
|
1011
|
+
credentials:'same-origin'
|
|
1012
|
+
}).then(function(r){return r.json().then(function(j){return{status:r.status,body:j}})});
|
|
1013
|
+
|
|
1014
|
+
Promise.all([req,minDelay]).then(function(arr){
|
|
1015
|
+
clearInterval(ti);
|
|
1016
|
+
var res=arr[0];
|
|
1017
|
+
if(res.body&&res.body.ok){
|
|
1018
|
+
showSuccess(res.body.workspaceName,res.body.redirectUrl,res.body.providerName);
|
|
1019
|
+
}else{
|
|
1020
|
+
showError(res.body&&res.body.title||'Couldn\\'t connect',res.body&&res.body.detail||'Try again, or generate a new key.');
|
|
1021
|
+
sb.disabled=false;bt.textContent='Connect';
|
|
1022
|
+
}
|
|
1023
|
+
}).catch(function(){
|
|
1024
|
+
clearInterval(ti);
|
|
1025
|
+
showError('Network error','We couldn\\'t reach Product Brain. Check your connection and try again.');
|
|
1026
|
+
sb.disabled=false;bt.textContent='Connect';
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
k.focus();
|
|
1031
|
+
})();
|
|
1032
|
+
</script>`;
|
|
1033
|
+
return authPageShell("Connect Product Brain", body);
|
|
1034
|
+
}
|
|
1035
|
+
function authorizeSuccessPage(workspaceName, redirectUrl, providerName) {
|
|
1036
|
+
const body = `
|
|
1037
|
+
<div class="panel" data-state="connected">
|
|
1038
|
+
${successPanelInner(workspaceName, redirectUrl, providerName)}
|
|
1039
|
+
</div>
|
|
1040
|
+
<script>${cmdScript}</script>`;
|
|
1041
|
+
return authPageShell("Connected", body);
|
|
1042
|
+
}
|
|
1043
|
+
function authorizeErrorPage(title, trustedDetailHtml, retryUrl) {
|
|
1044
|
+
const body = `
|
|
1045
|
+
<div class="panel" data-state="error">
|
|
1046
|
+
${errorPanelInner(title, trustedDetailHtml, retryUrl)}
|
|
1047
|
+
</div>`;
|
|
1048
|
+
return authPageShell("Connection error", body);
|
|
1049
|
+
}
|
|
1050
|
+
app.get("/authorize", authLimiter, (req, res) => {
|
|
1051
|
+
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
|
|
1052
|
+
const refusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1053
|
+
if (refusal) {
|
|
1054
|
+
logOAuthDoor("GET /authorize", 400, refusal);
|
|
1055
|
+
res.status(400).json({ error: "invalid_request", error_description: refusal });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
res.type("html").send(authorizeFormPage({
|
|
1059
|
+
redirect_uri: String(redirect_uri ?? ""),
|
|
1060
|
+
code_challenge: String(code_challenge ?? ""),
|
|
1061
|
+
code_challenge_method: String(code_challenge_method ?? "S256"),
|
|
1062
|
+
state: String(state ?? ""),
|
|
1063
|
+
client_id: String(client_id ?? "")
|
|
1064
|
+
}));
|
|
168
1065
|
});
|
|
169
1066
|
app.post(
|
|
170
1067
|
"/authorize",
|
|
1068
|
+
authLimiter,
|
|
171
1069
|
express.urlencoded({ extended: false }),
|
|
172
|
-
(req, res) => {
|
|
173
|
-
const {
|
|
174
|
-
|
|
175
|
-
|
|
1070
|
+
async (req, res) => {
|
|
1071
|
+
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
|
|
1072
|
+
const api_key = normalisePastedKey(req.body.api_key);
|
|
1073
|
+
const wantsJson = String(req.headers["accept"] ?? "").includes("application/json");
|
|
1074
|
+
const retryParams = new URLSearchParams({
|
|
1075
|
+
redirect_uri: redirect_uri ?? "",
|
|
1076
|
+
code_challenge: code_challenge ?? "",
|
|
1077
|
+
code_challenge_method: code_challenge_method ?? "S256",
|
|
1078
|
+
...state ? { state } : {},
|
|
1079
|
+
...client_id ? { client_id } : {}
|
|
1080
|
+
}).toString();
|
|
1081
|
+
const retryUrl = `/authorize?${retryParams}`;
|
|
1082
|
+
function sendError(title, trustedDetailHtml, status = 400) {
|
|
1083
|
+
logOAuthDoor("POST /authorize", status, title);
|
|
1084
|
+
if (wantsJson) {
|
|
1085
|
+
res.status(status).json({ ok: false, title, detail: trustedDetailHtml });
|
|
1086
|
+
} else {
|
|
1087
|
+
res.status(status).type("html").send(authorizeErrorPage(title, trustedDetailHtml, retryUrl));
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (isCrossSiteFormPost(req.headers["origin"], req.headers["sec-fetch-site"], baseUrl(req))) {
|
|
1091
|
+
sendError("Request refused", "This form only accepts keys sent from its own sign-in page. Open the connect link again.", 403);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const nowTs = Date.now();
|
|
1095
|
+
const pasted = resolvePastedKey(api_key);
|
|
1096
|
+
const lockout = authLockoutError(authFailures, pasted, nowTs);
|
|
1097
|
+
if (lockout) {
|
|
1098
|
+
const retryAfter = lockout.headers["Retry-After"];
|
|
1099
|
+
res.setHeader("Retry-After", retryAfter);
|
|
1100
|
+
sendError("Too many attempts", `This key was not recognized too many times. Try again in ${retryAfter}s.`, 429);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (pasted.status !== "ok") {
|
|
1104
|
+
if (pasted.status === "rejected") recordAuthFailure(authFailures, pasted.credentialId, nowTs);
|
|
1105
|
+
sendError("Invalid key format", "API keys start with <code>pb_sk_</code>. Check your key and try again.");
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (!client_id) {
|
|
1109
|
+
logOAuthDoor("POST /authorize", 400, "client_id is required");
|
|
1110
|
+
res.status(400).json({ error: "invalid_request", error_description: "client_id is required" });
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const redirectRefusal = checkRedirectUri(redirect_uri, OAUTH_REDIRECT_ALLOWLIST);
|
|
1114
|
+
if (redirectRefusal) {
|
|
1115
|
+
logOAuthDoor("POST /authorize", 400, redirectRefusal);
|
|
1116
|
+
res.status(400).json({ error: "invalid_request", error_description: redirectRefusal });
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
let workspaceName = "Your Workspace";
|
|
1120
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), api_key, 5e3);
|
|
1121
|
+
const gate = keyVerdictGate(check.verdict, authFailures, pasted.credentialId, nowTs);
|
|
1122
|
+
if (check.verdict === "ok") {
|
|
1123
|
+
if (check.workspaceName) workspaceName = check.workspaceName;
|
|
1124
|
+
getKeyState(api_key).deploymentUrl = check.deploymentUrl;
|
|
1125
|
+
} else if (check.verdict === "rate_limited") {
|
|
1126
|
+
sendError(
|
|
1127
|
+
"Rate limited",
|
|
1128
|
+
`This API key is valid but temporarily rate limited. Try again in ${check.retryAfterSeconds ?? 60}s.`,
|
|
1129
|
+
429
|
|
1130
|
+
);
|
|
176
1131
|
return;
|
|
1132
|
+
} else if (gate === "unauthorized") {
|
|
1133
|
+
sendError("Key not recognized", "This API key wasn't found in Product Brain. Check your API Keys in Cortex and try again.", 401);
|
|
1134
|
+
return;
|
|
1135
|
+
} else {
|
|
1136
|
+
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
177
1137
|
}
|
|
178
1138
|
const code = randomUUID();
|
|
179
1139
|
pendingCodes.set(code, {
|
|
@@ -185,53 +1145,44 @@ app.post(
|
|
|
185
1145
|
const url = new URL(redirect_uri);
|
|
186
1146
|
url.searchParams.set("code", code);
|
|
187
1147
|
if (state) url.searchParams.set("state", state);
|
|
188
|
-
|
|
1148
|
+
const redirectUrl = url.toString();
|
|
1149
|
+
const providerName = PROVIDER_NAME;
|
|
1150
|
+
logOAuthDoor("POST /authorize", 200, "code_issued");
|
|
1151
|
+
if (wantsJson) {
|
|
1152
|
+
res.json({ ok: true, workspaceName, redirectUrl, providerName });
|
|
1153
|
+
} else {
|
|
1154
|
+
res.type("html").send(authorizeSuccessPage(workspaceName, redirectUrl, providerName));
|
|
1155
|
+
}
|
|
189
1156
|
}
|
|
190
1157
|
);
|
|
191
|
-
function issueTokens(apiKey) {
|
|
192
|
-
const refreshToken = `pb_rt_${randomUUID()}`;
|
|
193
|
-
refreshTokens.set(refreshToken, { apiKey, createdAt: Date.now() });
|
|
194
|
-
return {
|
|
195
|
-
access_token: apiKey,
|
|
196
|
-
token_type: "Bearer",
|
|
197
|
-
expires_in: ACCESS_TOKEN_TTL,
|
|
198
|
-
refresh_token: refreshToken
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
1158
|
app.post(
|
|
202
1159
|
"/oauth/token",
|
|
203
1160
|
express.urlencoded({ extended: false }),
|
|
204
1161
|
express.json(),
|
|
205
1162
|
(req, res) => {
|
|
206
|
-
const { grant_type, code, code_verifier, redirect_uri
|
|
207
|
-
if (grant_type === "refresh_token") {
|
|
208
|
-
const entry = refreshTokens.get(refresh_token);
|
|
209
|
-
if (!entry) {
|
|
210
|
-
res.status(400).json({ error: "invalid_grant", error_description: "Invalid refresh token" });
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
if (Date.now() - entry.createdAt > REFRESH_TOKEN_TTL_MS) {
|
|
214
|
-
refreshTokens.delete(refresh_token);
|
|
215
|
-
res.status(400).json({ error: "invalid_grant", error_description: "Refresh token expired" });
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
const apiKey = entry.apiKey;
|
|
219
|
-
refreshTokens.delete(refresh_token);
|
|
220
|
-
res.json(issueTokens(apiKey));
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
1163
|
+
const { grant_type, code, code_verifier, redirect_uri } = req.body ?? {};
|
|
223
1164
|
if (grant_type !== "authorization_code") {
|
|
1165
|
+
logOAuthDoor("POST /oauth/token", 400, "unsupported_grant_type");
|
|
224
1166
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
225
1167
|
return;
|
|
226
1168
|
}
|
|
227
1169
|
const pending = pendingCodes.get(code);
|
|
228
|
-
|
|
1170
|
+
const codeRefusal = pendingCodeRefusal(pending, redirect_uri, Date.now());
|
|
1171
|
+
if (codeRefusal === "expired") {
|
|
1172
|
+
pendingCodes.delete(code);
|
|
1173
|
+
logOAuthDoor("POST /oauth/token", 400, "expired");
|
|
1174
|
+
res.status(400).json({ error: "invalid_grant", error_description: "authorization code expired" });
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if (codeRefusal || !pending) {
|
|
1178
|
+
logOAuthDoor("POST /oauth/token", 400, codeRefusal ?? "unknown_code");
|
|
229
1179
|
res.status(400).json({ error: "invalid_grant" });
|
|
230
1180
|
return;
|
|
231
1181
|
}
|
|
232
1182
|
const challenge = createHash("sha256").update(code_verifier ?? "").digest("base64url");
|
|
233
1183
|
if (challenge !== pending.codeChallenge) {
|
|
234
1184
|
pendingCodes.delete(code);
|
|
1185
|
+
logOAuthDoor("POST /oauth/token", 400, "pkce_failed");
|
|
235
1186
|
res.status(400).json({
|
|
236
1187
|
error: "invalid_grant",
|
|
237
1188
|
error_description: "PKCE verification failed"
|
|
@@ -239,51 +1190,56 @@ app.post(
|
|
|
239
1190
|
return;
|
|
240
1191
|
}
|
|
241
1192
|
pendingCodes.delete(code);
|
|
242
|
-
|
|
1193
|
+
logOAuthDoor("POST /oauth/token", 200, "token_issued");
|
|
1194
|
+
res.json(tokenResponse(pending.apiKey));
|
|
243
1195
|
}
|
|
244
1196
|
);
|
|
245
|
-
var
|
|
246
|
-
|
|
247
|
-
max: 120,
|
|
248
|
-
standardHeaders: true,
|
|
249
|
-
legacyHeaders: false,
|
|
250
|
-
message: { error: "Too many requests. Try again later." }
|
|
251
|
-
});
|
|
1197
|
+
var mcpLimit = mcpLimiters();
|
|
1198
|
+
var authFailures = /* @__PURE__ */ new Map();
|
|
252
1199
|
app.get("/health", (_req, res) => {
|
|
253
1200
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
254
1201
|
});
|
|
255
1202
|
var sessions = /* @__PURE__ */ new Map();
|
|
256
1203
|
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
257
1204
|
var MAX_SESSIONS = 200;
|
|
1205
|
+
var EVICT_GRACE_MS = 6e4;
|
|
1206
|
+
var MAX_SESSIONS_PER_KEY = Math.max(
|
|
1207
|
+
1,
|
|
1208
|
+
Math.min(MAX_SESSIONS - 1, Number(process.env.MCP_MAX_SESSIONS_PER_KEY) || 25)
|
|
1209
|
+
);
|
|
258
1210
|
function evictStaleSessions() {
|
|
259
1211
|
const now = Date.now();
|
|
260
1212
|
for (const [id, entry] of sessions) {
|
|
261
1213
|
if (now - entry.lastAccess > SESSION_TTL_MS) {
|
|
262
|
-
logSessionLifecycle(
|
|
1214
|
+
logSessionLifecycle(
|
|
1215
|
+
"session_deleted",
|
|
1216
|
+
id,
|
|
1217
|
+
entry.inFlight > 0 ? "inflight_leak" : "ttl"
|
|
1218
|
+
);
|
|
263
1219
|
entry.transport.close().catch(() => {
|
|
264
1220
|
});
|
|
265
1221
|
sessions.delete(id);
|
|
266
1222
|
}
|
|
267
1223
|
}
|
|
268
1224
|
if (sessions.size > MAX_SESSIONS) {
|
|
269
|
-
const sorted = [...sessions.entries()].sort(
|
|
270
|
-
|
|
271
|
-
);
|
|
272
|
-
for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
|
|
1225
|
+
const sorted = [...sessions.entries()].filter(([, e]) => e.inFlight === 0).sort((a, b) => a[1].lastAccess - b[1].lastAccess);
|
|
1226
|
+
const overflow = sessions.size - MAX_SESSIONS;
|
|
1227
|
+
for (let i = 0; i < Math.min(overflow, sorted.length); i++) {
|
|
273
1228
|
logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
|
|
274
1229
|
sorted[i][1].transport.close().catch(() => {
|
|
275
1230
|
});
|
|
276
1231
|
sessions.delete(sorted[i][0]);
|
|
277
1232
|
}
|
|
1233
|
+
if (sessions.size > MAX_SESSIONS) {
|
|
1234
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
1235
|
+
process.stderr.write(
|
|
1236
|
+
`[HTTP] ${ts} session_cap_breach size=${sessions.size} max=${MAX_SESSIONS} reason=all_inflight (no idle sessions to evict; TTL backstop will reclaim)
|
|
1237
|
+
`
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
278
1240
|
}
|
|
279
1241
|
}
|
|
280
1242
|
setInterval(evictStaleSessions, 6e4);
|
|
281
|
-
function extractBearerKey(req) {
|
|
282
|
-
const header = req.headers?.authorization;
|
|
283
|
-
if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
|
|
284
|
-
const token = header.slice(7).trim();
|
|
285
|
-
return token.startsWith("pb_sk_") ? token : null;
|
|
286
|
-
}
|
|
287
1243
|
function send401(req, res) {
|
|
288
1244
|
const base = baseUrl(req);
|
|
289
1245
|
res.status(401).set(
|
|
@@ -304,41 +1260,137 @@ function logSessionLifecycle(event, sessionId, reason) {
|
|
|
304
1260
|
process.stderr.write(`[HTTP] ${ts} ${event} session=${sessionId}${r}
|
|
305
1261
|
`);
|
|
306
1262
|
}
|
|
307
|
-
app.post("/mcp",
|
|
308
|
-
const
|
|
309
|
-
|
|
1263
|
+
app.post("/mcp", ...mcpLimit, async (req, res) => {
|
|
1264
|
+
const nowTs = Date.now();
|
|
1265
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1266
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1267
|
+
if (lockout) {
|
|
1268
|
+
send429(res, lockout);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
if (auth.status !== "ok") {
|
|
310
1272
|
logRequest("POST", "auth_fail");
|
|
1273
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
311
1274
|
send401(req, res);
|
|
312
1275
|
return;
|
|
313
1276
|
}
|
|
1277
|
+
const apiKey = auth.apiKey;
|
|
314
1278
|
const sessionId = req.headers["mcp-session-id"];
|
|
315
1279
|
const reqStart = Date.now();
|
|
316
1280
|
try {
|
|
317
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1281
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
318
1282
|
if (sessionId && sessions.has(sessionId)) {
|
|
319
1283
|
const entry = sessions.get(sessionId);
|
|
320
|
-
entry.
|
|
321
|
-
|
|
322
|
-
|
|
1284
|
+
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1285
|
+
res.status(403).json({
|
|
1286
|
+
jsonrpc: "2.0",
|
|
1287
|
+
error: { code: -32e3, message: "Session key mismatch" },
|
|
1288
|
+
id: null
|
|
1289
|
+
});
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
entry.inFlight++;
|
|
1294
|
+
entry.lastAccess = Date.now();
|
|
1295
|
+
await entry.transport.handleRequest(req, res, req.body);
|
|
1296
|
+
logRequest("POST", "ok", sessionId, Date.now() - reqStart);
|
|
1297
|
+
} finally {
|
|
1298
|
+
entry.inFlight--;
|
|
1299
|
+
entry.lastAccess = Date.now();
|
|
1300
|
+
}
|
|
323
1301
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
1302
|
+
const check = await checkKeyAgainstCandidates(keyCheckCandidates(), apiKey, 5e3);
|
|
1303
|
+
const gate = keyVerdictGate(check.verdict, authFailures, auth.credentialId, Date.now());
|
|
1304
|
+
if (gate === "unauthorized") {
|
|
1305
|
+
logRequest("POST", "auth_fail");
|
|
1306
|
+
send401(req, res);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (gate === "unavailable") {
|
|
1310
|
+
res.status(503).json({
|
|
1311
|
+
jsonrpc: "2.0",
|
|
1312
|
+
error: { code: -32e3, message: "Product Brain could not verify this API key right now. Retry shortly." },
|
|
1313
|
+
id: req.body?.id ?? null
|
|
1314
|
+
});
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (check.verdict === "ok" || check.verdict === "rate_limited") getKeyState(apiKey).deploymentUrl = check.deploymentUrl;
|
|
1318
|
+
const keyH = hashKey(apiKey);
|
|
1319
|
+
const plan = planKeyAdmission(
|
|
1320
|
+
sessions,
|
|
1321
|
+
keyH,
|
|
1322
|
+
MAX_SESSIONS_PER_KEY,
|
|
1323
|
+
Date.now(),
|
|
1324
|
+
{ ttlMs: SESSION_TTL_MS, graceMs: EVICT_GRACE_MS }
|
|
1325
|
+
);
|
|
1326
|
+
for (const sid2 of plan.evict) {
|
|
1327
|
+
const victim = sessions.get(sid2);
|
|
1328
|
+
sessions.delete(sid2);
|
|
1329
|
+
victim?.transport.close().catch(() => {
|
|
1330
|
+
});
|
|
1331
|
+
logSessionLifecycle("session_deleted", sid2, "lru_evict");
|
|
1332
|
+
}
|
|
1333
|
+
if (!plan.admit) {
|
|
1334
|
+
send429(res, buildSessionCapError({
|
|
1335
|
+
retryAfterSeconds: plan.retryAfterSeconds,
|
|
1336
|
+
limit: MAX_SESSIONS_PER_KEY,
|
|
1337
|
+
current: countKeySessions(sessions, keyH),
|
|
1338
|
+
poll: plan.retryIsPoll,
|
|
1339
|
+
id: req.body?.id ?? null
|
|
1340
|
+
}));
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
const sid = randomUUID();
|
|
1344
|
+
let initialized = false;
|
|
324
1345
|
const transport = new StreamableHTTPServerTransport({
|
|
325
|
-
sessionIdGenerator: () =>
|
|
326
|
-
onsessioninitialized: (
|
|
327
|
-
|
|
328
|
-
logSessionLifecycle("session_created",
|
|
1346
|
+
sessionIdGenerator: () => sid,
|
|
1347
|
+
onsessioninitialized: (s) => {
|
|
1348
|
+
initialized = true;
|
|
1349
|
+
logSessionLifecycle("session_created", s);
|
|
329
1350
|
}
|
|
1351
|
+
// log only — no map write
|
|
330
1352
|
});
|
|
1353
|
+
const reserved = { transport, lastAccess: Date.now(), keyHash: keyH, inFlight: 1 };
|
|
1354
|
+
sessions.set(sid, reserved);
|
|
331
1355
|
transport.onclose = () => {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
sessions.delete(
|
|
1356
|
+
const s = transport.sessionId ?? sid;
|
|
1357
|
+
forgetStreamLifecycle(keyH, s);
|
|
1358
|
+
if (sessions.has(s)) {
|
|
1359
|
+
sessions.delete(s);
|
|
1360
|
+
logSessionLifecycle("session_deleted", s, "onclose");
|
|
336
1361
|
}
|
|
337
1362
|
};
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
1363
|
+
try {
|
|
1364
|
+
const server = createProductBrainServer();
|
|
1365
|
+
await server.connect(transport);
|
|
1366
|
+
await transport.handleRequest(req, res, req.body);
|
|
1367
|
+
logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
|
|
1368
|
+
} catch (e) {
|
|
1369
|
+
transport.onclose = void 0;
|
|
1370
|
+
sessions.delete(sid);
|
|
1371
|
+
throw e;
|
|
1372
|
+
} finally {
|
|
1373
|
+
if (!initialized) {
|
|
1374
|
+
transport.onclose = void 0;
|
|
1375
|
+
if (sessions.delete(sid)) logSessionLifecycle("session_deleted", sid, "init_rejected");
|
|
1376
|
+
} else {
|
|
1377
|
+
const e = sessions.get(sid);
|
|
1378
|
+
if (e) {
|
|
1379
|
+
e.inFlight--;
|
|
1380
|
+
e.lastAccess = Date.now();
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
} else if (sessionId) {
|
|
1385
|
+
process.stderr.write(
|
|
1386
|
+
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
|
|
1387
|
+
`
|
|
1388
|
+
);
|
|
1389
|
+
res.status(404).json({
|
|
1390
|
+
jsonrpc: "2.0",
|
|
1391
|
+
error: { code: -32001, message: "Session not found \u2014 re-initialise" },
|
|
1392
|
+
id: null
|
|
1393
|
+
});
|
|
342
1394
|
} else {
|
|
343
1395
|
process.stderr.write(
|
|
344
1396
|
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
|
|
@@ -362,21 +1414,45 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
362
1414
|
}
|
|
363
1415
|
}
|
|
364
1416
|
});
|
|
365
|
-
app.get("/mcp",
|
|
366
|
-
const
|
|
367
|
-
|
|
1417
|
+
app.get("/mcp", ...mcpLimit, async (req, res) => {
|
|
1418
|
+
const nowTs = Date.now();
|
|
1419
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1420
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1421
|
+
if (lockout) {
|
|
1422
|
+
send429(res, lockout);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (auth.status !== "ok") {
|
|
368
1426
|
logRequest("GET", "auth_fail");
|
|
1427
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
369
1428
|
send401(req, res);
|
|
370
1429
|
return;
|
|
371
1430
|
}
|
|
1431
|
+
const apiKey = auth.apiKey;
|
|
372
1432
|
const sessionId = req.headers["mcp-session-id"];
|
|
373
|
-
if (!sessionId
|
|
374
|
-
res.status(400).send("
|
|
1433
|
+
if (!sessionId) {
|
|
1434
|
+
res.status(400).send("Missing Mcp-Session-Id header");
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
if (!sessions.has(sessionId)) {
|
|
1438
|
+
process.stderr.write(
|
|
1439
|
+
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale GET sessionId=${sessionId}
|
|
1440
|
+
`
|
|
1441
|
+
);
|
|
1442
|
+
res.status(404).send("Session not found \u2014 re-initialise");
|
|
375
1443
|
return;
|
|
376
1444
|
}
|
|
377
1445
|
try {
|
|
378
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1446
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
379
1447
|
const entry = sessions.get(sessionId);
|
|
1448
|
+
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1449
|
+
res.status(403).json({
|
|
1450
|
+
jsonrpc: "2.0",
|
|
1451
|
+
error: { code: -32e3, message: "Session key mismatch" },
|
|
1452
|
+
id: null
|
|
1453
|
+
});
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
380
1456
|
entry.lastAccess = Date.now();
|
|
381
1457
|
await entry.transport.handleRequest(req, res);
|
|
382
1458
|
logRequest("GET", "ok", sessionId);
|
|
@@ -385,21 +1461,45 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
385
1461
|
logRequest("GET", "error", sessionId);
|
|
386
1462
|
}
|
|
387
1463
|
});
|
|
388
|
-
app.delete("/mcp",
|
|
389
|
-
const
|
|
390
|
-
|
|
1464
|
+
app.delete("/mcp", ...mcpLimit, async (req, res) => {
|
|
1465
|
+
const nowTs = Date.now();
|
|
1466
|
+
const auth = resolveBearerAuth(req.headers?.authorization);
|
|
1467
|
+
const lockout = authLockoutError(authFailures, auth, nowTs, req.body?.id ?? null);
|
|
1468
|
+
if (lockout) {
|
|
1469
|
+
send429(res, lockout);
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (auth.status !== "ok") {
|
|
391
1473
|
logRequest("DELETE", "auth_fail");
|
|
1474
|
+
if (auth.status === "rejected") recordAuthFailure(authFailures, auth.credentialId, nowTs);
|
|
392
1475
|
send401(req, res);
|
|
393
1476
|
return;
|
|
394
1477
|
}
|
|
1478
|
+
const apiKey = auth.apiKey;
|
|
395
1479
|
const sessionId = req.headers["mcp-session-id"];
|
|
396
|
-
if (!sessionId
|
|
397
|
-
res.status(400).send("
|
|
1480
|
+
if (!sessionId) {
|
|
1481
|
+
res.status(400).send("Missing Mcp-Session-Id header");
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if (!sessions.has(sessionId)) {
|
|
1485
|
+
process.stderr.write(
|
|
1486
|
+
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale DELETE sessionId=${sessionId}
|
|
1487
|
+
`
|
|
1488
|
+
);
|
|
1489
|
+
res.status(404).send("Session not found \u2014 re-initialise");
|
|
398
1490
|
return;
|
|
399
1491
|
}
|
|
400
1492
|
try {
|
|
401
|
-
await runWithAuth({ apiKey }, async () => {
|
|
1493
|
+
await runWithAuth({ apiKey, mcpSessionId: sessionId }, async () => {
|
|
402
1494
|
const entry = sessions.get(sessionId);
|
|
1495
|
+
if (entry.keyHash !== hashKey(apiKey)) {
|
|
1496
|
+
res.status(403).json({
|
|
1497
|
+
jsonrpc: "2.0",
|
|
1498
|
+
error: { code: -32e3, message: "Session key mismatch" },
|
|
1499
|
+
id: null
|
|
1500
|
+
});
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
403
1503
|
await entry.transport.handleRequest(req, res);
|
|
404
1504
|
logRequest("DELETE", "ok", sessionId);
|
|
405
1505
|
});
|
|
@@ -425,10 +1525,6 @@ async function gracefulShutdown() {
|
|
|
425
1525
|
await entry.transport.close().catch(() => {
|
|
426
1526
|
});
|
|
427
1527
|
}
|
|
428
|
-
try {
|
|
429
|
-
await shutdownAnalytics();
|
|
430
|
-
} catch {
|
|
431
|
-
}
|
|
432
1528
|
process.exit(0);
|
|
433
1529
|
}
|
|
434
1530
|
var LISTEN_HOST = "0.0.0.0";
|