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