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