@frankieai/server 0.1.5 → 0.2.1
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-UZSHFMJN.js +800 -0
- package/dist/chunk-UZSHFMJN.js.map +1 -0
- package/dist/{chunk-K75HPI2W.js → chunk-WFENEYOV.js} +622 -341
- package/dist/chunk-WFENEYOV.js.map +1 -0
- package/dist/{chunk-2BTCA3F3.js → chunk-XCIM765D.js} +40 -101
- package/dist/chunk-XCIM765D.js.map +1 -0
- package/dist/control-server.js +2 -2
- package/dist/index.d.ts +49 -2
- package/dist/index.js +7 -7
- package/dist/server.js +3 -3
- package/package.json +2 -2
- package/dist/chunk-2BTCA3F3.js.map +0 -1
- package/dist/chunk-3J66REZR.js +0 -458
- package/dist/chunk-3J66REZR.js.map +0 -1
- package/dist/chunk-K75HPI2W.js.map +0 -1
|
@@ -1,110 +1,87 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
this.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
sessionKey;
|
|
21
|
-
userKey;
|
|
22
|
-
jwks;
|
|
23
|
-
/** Verify a host-signed user token. Throws `AuthError` on any failure. */
|
|
24
|
-
async verifyUserToken(token) {
|
|
25
|
-
const options = {
|
|
26
|
-
...this.auth.jwtIssuer ? { issuer: this.auth.jwtIssuer } : {},
|
|
27
|
-
...this.auth.jwtAudience ? { audience: this.auth.jwtAudience } : {},
|
|
28
|
-
clockTolerance: 30
|
|
29
|
-
};
|
|
30
|
-
let payload;
|
|
31
|
-
try {
|
|
32
|
-
if (this.auth.jwksUrl) {
|
|
33
|
-
this.jwks ??= createRemoteJWKSet(new URL(this.auth.jwksUrl));
|
|
34
|
-
({ payload } = await jwtVerify(token, this.jwks, options));
|
|
35
|
-
} else if (this.userKey) {
|
|
36
|
-
({ payload } = await jwtVerify(token, this.userKey, { ...options, algorithms: ["HS256"] }));
|
|
37
|
-
} else {
|
|
38
|
-
throw new AuthError("This tenant is not configured to accept user tokens.");
|
|
39
|
-
}
|
|
40
|
-
} catch (error) {
|
|
41
|
-
if (error instanceof AuthError) throw error;
|
|
42
|
-
throw new AuthError(`Could not verify the user token: ${error.message}`);
|
|
1
|
+
// src/rate-limit.ts
|
|
2
|
+
var MemoryRateLimiter = class {
|
|
3
|
+
hits = /* @__PURE__ */ new Map();
|
|
4
|
+
lastSweep = Date.now();
|
|
5
|
+
/**
|
|
6
|
+
* The longest window any caller has asked for. The sweep horizon has to cover
|
|
7
|
+
* it, or sweeping would discard hits the window still counts.
|
|
8
|
+
*/
|
|
9
|
+
maxWindowMs = 0;
|
|
10
|
+
async take(key, limit, windowSeconds) {
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
const windowMs = windowSeconds * 1e3;
|
|
13
|
+
if (windowMs > this.maxWindowMs) this.maxWindowMs = windowMs;
|
|
14
|
+
const cutoff = now - windowMs;
|
|
15
|
+
this.sweep(now);
|
|
16
|
+
const timestamps = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
|
|
17
|
+
if (timestamps.length >= limit) {
|
|
18
|
+
this.hits.set(key, timestamps);
|
|
19
|
+
return false;
|
|
43
20
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return
|
|
47
|
-
id: subject,
|
|
48
|
-
displayName: stringClaim(payload, "name") ?? stringClaim(payload, "preferred_username"),
|
|
49
|
-
email: stringClaim(payload, "email"),
|
|
50
|
-
anonymous: false,
|
|
51
|
-
claims: payload
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
/** Mint the bearer token the widget uses for the rest of the conversation. */
|
|
55
|
-
async issueSessionToken(input) {
|
|
56
|
-
const expiresAt = new Date(Date.now() + this.auth.sessionTtlSeconds * 1e3);
|
|
57
|
-
const token = await new SignJWT({
|
|
58
|
-
sid: input.sessionId,
|
|
59
|
-
uid: input.user.id,
|
|
60
|
-
anon: input.user.anonymous,
|
|
61
|
-
tid: input.tenantId
|
|
62
|
-
}).setProtectedHeader({ alg: "HS256", typ: "JWT" }).setIssuedAt().setIssuer("frankie").setAudience("frankie-widget").setExpirationTime(Math.floor(expiresAt.getTime() / 1e3)).sign(this.sessionKey);
|
|
63
|
-
return { token, expiresAt };
|
|
21
|
+
timestamps.push(now);
|
|
22
|
+
this.hits.set(key, timestamps);
|
|
23
|
+
return true;
|
|
64
24
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
throw new AuthError("Your chat session has expired. Reload the page to start a new one.");
|
|
25
|
+
/**
|
|
26
|
+
* Trim stale hits and drop empty buckets occasionally, so a long-lived process
|
|
27
|
+
* does not grow. The horizon tracks the longest window in use rather than a
|
|
28
|
+
* fixed hour: a shorter one would drop timestamps that window still counts,
|
|
29
|
+
* silently handing the caller a fresh budget mid-window.
|
|
30
|
+
*/
|
|
31
|
+
sweep(now) {
|
|
32
|
+
if (now - this.lastSweep < 6e4) return;
|
|
33
|
+
this.lastSweep = now;
|
|
34
|
+
const horizon = now - this.maxWindowMs;
|
|
35
|
+
for (const [key, timestamps] of this.hits) {
|
|
36
|
+
const live = timestamps.filter((t) => t > horizon);
|
|
37
|
+
if (live.length === 0) this.hits.delete(key);
|
|
38
|
+
else this.hits.set(key, live);
|
|
80
39
|
}
|
|
81
40
|
}
|
|
82
41
|
};
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return value.trim().replace(/\/+$/, "").toLowerCase();
|
|
42
|
+
|
|
43
|
+
// src/agent/models.ts
|
|
44
|
+
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
|
|
45
|
+
import { hasApi } from "@earendil-works/pi-ai";
|
|
46
|
+
var ModelResolutionError = class extends Error {
|
|
47
|
+
};
|
|
48
|
+
function resolveModel(config) {
|
|
49
|
+
const models = builtinModels();
|
|
50
|
+
const provider = models.getProvider(config.model.provider);
|
|
51
|
+
if (!provider) {
|
|
52
|
+
const known = models.getProviders().map((entry) => entry.id).sort().join(", ");
|
|
53
|
+
throw new ModelResolutionError(
|
|
54
|
+
`Unknown model provider "${config.model.provider}". Known providers: ${known}.`
|
|
55
|
+
);
|
|
98
56
|
}
|
|
57
|
+
const found = models.getModel(config.model.provider, config.model.model);
|
|
58
|
+
if (!found) {
|
|
59
|
+
throw new ModelResolutionError(
|
|
60
|
+
`Provider "${config.model.provider}" has no model "${config.model.model}". Available: ${provider.getModels().map((entry) => entry.id).slice(0, 10).join(", ")}\u2026`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const model = {
|
|
64
|
+
...found,
|
|
65
|
+
...config.model.baseUrl ? { baseUrl: config.model.baseUrl } : {},
|
|
66
|
+
maxTokens: Math.min(config.model.maxTokens, found.maxTokens)
|
|
67
|
+
};
|
|
68
|
+
return { models, model };
|
|
99
69
|
}
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
70
|
+
function resolveModelChoice(catalogue, config, requestedId) {
|
|
71
|
+
const choice = config.features.modelPicker.models.find((entry) => entry.id === requestedId);
|
|
72
|
+
const isDefault = requestedId === config.model.model;
|
|
73
|
+
if (!choice && !isDefault) return null;
|
|
74
|
+
const provider = choice?.provider ?? config.model.provider;
|
|
75
|
+
const found = catalogue.getModel(provider, requestedId);
|
|
76
|
+
if (!found) return null;
|
|
77
|
+
return {
|
|
78
|
+
...found,
|
|
79
|
+
...config.model.baseUrl ? { baseUrl: config.model.baseUrl } : {},
|
|
80
|
+
maxTokens: Math.min(config.model.maxTokens, found.maxTokens)
|
|
81
|
+
};
|
|
105
82
|
}
|
|
106
|
-
function
|
|
107
|
-
return
|
|
83
|
+
function modelHasApi(model, api) {
|
|
84
|
+
return hasApi(model, api);
|
|
108
85
|
}
|
|
109
86
|
|
|
110
87
|
// src/agent/headers.ts
|
|
@@ -125,141 +102,105 @@ function buildForwardedHeaders(source, session) {
|
|
|
125
102
|
return headers;
|
|
126
103
|
}
|
|
127
104
|
|
|
128
|
-
// src/agent/
|
|
129
|
-
import {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
import {
|
|
133
|
-
import {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
-
options.onWarning?.("mcp_connect_failed", { server: server.name, error: message });
|
|
146
|
-
failed.push({ name: server.name, error: message });
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
149
|
-
})
|
|
150
|
-
);
|
|
151
|
-
for (const result of results) {
|
|
152
|
-
if (!result) continue;
|
|
153
|
-
clients.push(result.client);
|
|
154
|
-
try {
|
|
155
|
-
const listed = await withTimeout(result.client.listTools(), options.connectTimeoutMs ?? 1e4, "MCP tool discovery timed out.");
|
|
156
|
-
for (const tool of listed.tools) {
|
|
157
|
-
if (result.server.selectedTools && !result.server.selectedTools.includes(tool.name)) continue;
|
|
158
|
-
const name = `${MCP_TOOL_PREFIX}${result.server.name}__${tool.name}`;
|
|
159
|
-
if (!evaluateToolPolicy(name, options.policy).allowed) continue;
|
|
160
|
-
tools.push(adaptTool(name, tool, result.client, options.callTimeoutMs));
|
|
161
|
-
}
|
|
162
|
-
} catch (error) {
|
|
163
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
164
|
-
options.onWarning?.("mcp_list_tools_failed", { server: result.server.name, error: message });
|
|
165
|
-
failed.push({ name: result.server.name, error: message });
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return {
|
|
169
|
-
tools,
|
|
170
|
-
failed,
|
|
171
|
-
async close() {
|
|
172
|
-
await Promise.all(clients.map((client) => client.close().catch(() => void 0)));
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
async function connectOne(server, options) {
|
|
177
|
-
const headers = buildForwardedHeaders(server, options.session);
|
|
178
|
-
const url = new URL(server.url);
|
|
179
|
-
const transport = server.transport === "sse" ? new SSEClientTransport(url, { requestInit: { headers } }) : new StreamableHTTPClientTransport(url, { requestInit: { headers } });
|
|
180
|
-
const client = new Client({ name: "frankie", version: "0.1.0" }, { capabilities: {} });
|
|
181
|
-
await withTimeout(
|
|
182
|
-
client.connect(transport),
|
|
183
|
-
options.connectTimeoutMs ?? 1e4,
|
|
184
|
-
`MCP server "${server.name}" did not complete the handshake in time.`
|
|
185
|
-
);
|
|
186
|
-
return client;
|
|
187
|
-
}
|
|
188
|
-
function adaptTool(name, tool, client, callTimeoutMs = 6e4) {
|
|
189
|
-
return {
|
|
190
|
-
name,
|
|
191
|
-
label: describeTool(name, {}),
|
|
192
|
-
description: tool.description ?? `The ${tool.name} tool.`,
|
|
193
|
-
// MCP gives us JSON Schema; `Type.Unsafe` passes it through untouched
|
|
194
|
-
// rather than round-tripping it into a typebox equivalent and losing
|
|
195
|
-
// whatever the server actually declared.
|
|
196
|
-
parameters: Type.Unsafe(normaliseSchema(tool.inputSchema)),
|
|
197
|
-
async execute(_toolCallId, params, signal) {
|
|
198
|
-
const response = await withTimeout(
|
|
199
|
-
client.callTool(
|
|
200
|
-
{ name: tool.name, arguments: params ?? {} },
|
|
201
|
-
void 0,
|
|
202
|
-
signal ? { signal } : void 0
|
|
203
|
-
),
|
|
204
|
-
callTimeoutMs,
|
|
205
|
-
`The ${tool.name} tool did not respond in time.`
|
|
206
|
-
);
|
|
207
|
-
const content = toAgentContent(response.content);
|
|
208
|
-
if (response.isError) {
|
|
209
|
-
throw new Error(textOf(content) || `The ${tool.name} tool reported an error.`);
|
|
210
|
-
}
|
|
211
|
-
return { content, details: response.structuredContent ?? null };
|
|
212
|
-
}
|
|
213
|
-
};
|
|
105
|
+
// src/agent/openapi.ts
|
|
106
|
+
import { readFile } from "fs/promises";
|
|
107
|
+
|
|
108
|
+
// src/connector-fetch.ts
|
|
109
|
+
import { lookup } from "dns/promises";
|
|
110
|
+
import { isIP } from "net";
|
|
111
|
+
import { request as httpsRequest } from "https";
|
|
112
|
+
import { Readable, Transform } from "stream";
|
|
113
|
+
function isPublicAddress(address) {
|
|
114
|
+
if (isIP(address) === 4) {
|
|
115
|
+
const [a, b] = address.split(".").map(Number);
|
|
116
|
+
return !(a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && [0, 168].includes(b) || a === 198 && [18, 19, 51].includes(b) || a === 203 && b === 0);
|
|
117
|
+
}
|
|
118
|
+
if (isIP(address) !== 6) return false;
|
|
119
|
+
const first = Number.parseInt(address.split(":")[0], 16);
|
|
120
|
+
return first >= 8192 && first <= 16383 && first !== 8194 && !(first === 8193 && Number.parseInt(address.split(":")[1] || "0", 16) <= 511) && !address.toLowerCase().startsWith("2001:db8:") && !address.toLowerCase().startsWith("3fff:");
|
|
214
121
|
}
|
|
215
|
-
function
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return
|
|
122
|
+
function assertConnectorUrl(value) {
|
|
123
|
+
const url = new URL(value);
|
|
124
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new Error("Connectors require HTTPS URLs without embedded credentials.");
|
|
125
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
126
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || isIP(host) && !isPublicAddress(host)) {
|
|
127
|
+
throw new Error("Connectors cannot access local or private networks.");
|
|
128
|
+
}
|
|
129
|
+
return url;
|
|
223
130
|
}
|
|
224
|
-
function
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (typeof part !== "object" || part === null) continue;
|
|
229
|
-
const block = part;
|
|
230
|
-
if (block["type"] === "text" && typeof block["text"] === "string") {
|
|
231
|
-
out.push({ type: "text", text: block["text"] });
|
|
232
|
-
} else if (block["type"] === "image" && typeof block["data"] === "string" && typeof block["mimeType"] === "string") {
|
|
233
|
-
out.push({ type: "image", data: block["data"], mimeType: block["mimeType"] });
|
|
234
|
-
} else if (block["type"] === "resource") {
|
|
235
|
-
const resource = block["resource"];
|
|
236
|
-
const text = typeof resource === "object" && resource !== null && typeof resource["text"] === "string" ? String(resource["text"]) : "[resource omitted]";
|
|
237
|
-
out.push({ type: "text", text });
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return out.length > 0 ? out : [{ type: "text", text: "" }];
|
|
131
|
+
async function resolvePublicHost(host, resolve2 = lookup) {
|
|
132
|
+
const addresses = await resolve2(host, { all: true, verbatim: true });
|
|
133
|
+
if (!addresses.length || addresses.some((entry) => !isPublicAddress(entry.address))) throw new Error("Connector DNS must resolve only to public addresses.");
|
|
134
|
+
return addresses[0];
|
|
241
135
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
136
|
+
var publicConnectorFetch = async (input, init) => {
|
|
137
|
+
const source = new Request(input, init);
|
|
138
|
+
const url = assertConnectorUrl(source.url);
|
|
139
|
+
const headers = Object.fromEntries(source.headers);
|
|
140
|
+
for (const key of ["host", "connection", "content-length", "transfer-encoding", "proxy-authorization", "upgrade"]) delete headers[key];
|
|
141
|
+
const outgoing = source.body ? await readLimited(source.body, 2e6) : void 0;
|
|
142
|
+
return new Promise((resolve2, reject) => {
|
|
143
|
+
const req = httpsRequest(url, {
|
|
144
|
+
method: source.method,
|
|
145
|
+
headers,
|
|
146
|
+
signal: source.signal,
|
|
147
|
+
lookup(host, options, callback) {
|
|
148
|
+
const done = callback;
|
|
149
|
+
void resolvePublicHost(host).then((entry) => options.all ? done(null, [entry]) : done(null, entry.address, entry.family), (error) => done(error));
|
|
150
|
+
}
|
|
151
|
+
}, (response) => {
|
|
152
|
+
const status = response.statusCode ?? 502;
|
|
153
|
+
if (status >= 300 && status < 400) {
|
|
154
|
+
response.destroy();
|
|
155
|
+
reject(new Error("Connector redirects are disabled. Configure the final HTTPS endpoint."));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const responseHeaders = new Headers();
|
|
159
|
+
for (const [name, value] of Object.entries(response.headers)) if (value) responseHeaders.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
160
|
+
let bytes = 0;
|
|
161
|
+
const limit = new Transform({ transform(chunk, _encoding, done) {
|
|
162
|
+
bytes += chunk.length;
|
|
163
|
+
done(bytes > 8e6 ? new Error("Connector response exceeds the size limit.") : null, chunk);
|
|
164
|
+
} });
|
|
165
|
+
response.on("error", (error) => limit.destroy(error));
|
|
166
|
+
limit.on("error", () => response.destroy());
|
|
167
|
+
const body = [204, 205, 304].includes(status) || source.method === "HEAD" ? null : Readable.toWeb(response.pipe(limit));
|
|
168
|
+
if (!body) response.resume();
|
|
169
|
+
resolve2(new Response(body, { status, headers: responseHeaders }));
|
|
170
|
+
});
|
|
171
|
+
const timer = setTimeout(() => req.destroy(new Error("Connector request timed out.")), 6e4);
|
|
172
|
+
timer.unref();
|
|
173
|
+
req.on("close", () => clearTimeout(timer));
|
|
174
|
+
req.on("error", reject);
|
|
175
|
+
req.end(outgoing);
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
async function readLimited(stream, max) {
|
|
179
|
+
const reader = stream.getReader();
|
|
180
|
+
const chunks = [];
|
|
181
|
+
let bytes = 0;
|
|
247
182
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
183
|
+
for (; ; ) {
|
|
184
|
+
const { done, value } = await reader.read();
|
|
185
|
+
if (done) break;
|
|
186
|
+
bytes += value.length;
|
|
187
|
+
if (bytes > max) {
|
|
188
|
+
await reader.cancel();
|
|
189
|
+
throw new Error("Connector request exceeds the size limit.");
|
|
190
|
+
}
|
|
191
|
+
chunks.push(value);
|
|
192
|
+
}
|
|
193
|
+
return Buffer.concat(chunks);
|
|
254
194
|
} finally {
|
|
255
|
-
|
|
195
|
+
reader.releaseLock();
|
|
256
196
|
}
|
|
257
197
|
}
|
|
198
|
+
var trustedLocalConnectors = () => process.env["FRANKIE_TRUST_LOCAL_CONNECTORS"] === "true";
|
|
199
|
+
var connectorFetch = (input, init) => trustedLocalConnectors() ? fetch(input, init) : publicConnectorFetch(input, init);
|
|
258
200
|
|
|
259
201
|
// src/agent/openapi.ts
|
|
260
|
-
import {
|
|
261
|
-
import {
|
|
262
|
-
import { describeTool as describeTool2, evaluateToolPolicy as evaluateToolPolicy2 } from "@frankieai/core";
|
|
202
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
203
|
+
import { describeTool, evaluateToolPolicy } from "@frankieai/core";
|
|
263
204
|
import { parse as parseYaml } from "yaml";
|
|
264
205
|
var API_TOOL_PREFIX = "api__";
|
|
265
206
|
var OpenApiError = class extends Error {
|
|
@@ -279,7 +220,7 @@ async function inspectApiCapabilities(source, options = {}) {
|
|
|
279
220
|
const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `${method}_${slug(path)}`;
|
|
280
221
|
if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
|
|
281
222
|
const toolName = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
|
|
282
|
-
if (options.policy && !
|
|
223
|
+
if (options.policy && !evaluateToolPolicy(toolName, options.policy).allowed) continue;
|
|
283
224
|
const summary = typeof operation["summary"] === "string" ? operation["summary"] : void 0;
|
|
284
225
|
capabilities.push({ operationId, method: httpMethod, path, ...summary ? { summary } : {} });
|
|
285
226
|
}
|
|
@@ -309,7 +250,7 @@ async function buildOne(source, options) {
|
|
|
309
250
|
);
|
|
310
251
|
}
|
|
311
252
|
const headers = buildForwardedHeaders(source, options.session);
|
|
312
|
-
const doFetch = options.fetchImpl ??
|
|
253
|
+
const doFetch = options.fetchImpl ?? connectorFetch;
|
|
313
254
|
const allowedMethods = new Set(source.methods);
|
|
314
255
|
const tools = [];
|
|
315
256
|
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
@@ -323,14 +264,14 @@ async function buildOne(source, options) {
|
|
|
323
264
|
if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
|
|
324
265
|
if (source.selectedTools && !source.selectedTools.includes(operationId)) continue;
|
|
325
266
|
const name = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
|
|
326
|
-
if (!
|
|
267
|
+
if (!evaluateToolPolicy(name, options.policy).allowed) continue;
|
|
327
268
|
const params = collectParameters(doc, item, operation);
|
|
328
269
|
const body = collectRequestBody(doc, operation);
|
|
329
270
|
tools.push({
|
|
330
271
|
name,
|
|
331
|
-
label:
|
|
272
|
+
label: describeTool(name, {}),
|
|
332
273
|
description: describeOperation(operation, method, path),
|
|
333
|
-
parameters:
|
|
274
|
+
parameters: Type.Unsafe(toolSchema(params, body)),
|
|
334
275
|
async execute(_id, args, signal) {
|
|
335
276
|
const input = isRecord(args) ? args : {};
|
|
336
277
|
const url = buildUrl(baseUrl, path, params, input);
|
|
@@ -364,12 +305,13 @@ async function loadSpec(spec, fetchImpl) {
|
|
|
364
305
|
if (spec.trimStart().startsWith("{")) {
|
|
365
306
|
raw = spec;
|
|
366
307
|
} else if (/^https?:\/\//i.test(spec)) {
|
|
367
|
-
const response = await (fetchImpl ??
|
|
308
|
+
const response = await (fetchImpl ?? connectorFetch)(spec, {
|
|
368
309
|
headers: { accept: "application/json, text/yaml, */*" }
|
|
369
310
|
});
|
|
370
311
|
if (!response.ok) throw new OpenApiError(`Could not fetch ${spec}: HTTP ${response.status}.`);
|
|
371
312
|
raw = await response.text();
|
|
372
313
|
} else {
|
|
314
|
+
if (!trustedLocalConnectors()) throw new OpenApiError("Local OpenAPI files are disabled. Paste JSON or use a public HTTPS document.");
|
|
373
315
|
raw = await readFile(spec, "utf8").catch(() => {
|
|
374
316
|
throw new OpenApiError(`Could not read the OpenAPI document at ${spec}.`);
|
|
375
317
|
});
|
|
@@ -382,128 +324,377 @@ async function loadSpec(spec, fetchImpl) {
|
|
|
382
324
|
throw new OpenApiError(`Could not parse the OpenAPI document at ${spec}: ${error.message}`);
|
|
383
325
|
}
|
|
384
326
|
}
|
|
385
|
-
function specBaseUrl(doc) {
|
|
386
|
-
const url = doc.servers?.[0]?.url;
|
|
387
|
-
return typeof url === "string" && /^https?:\/\//i.test(url) ? url : void 0;
|
|
327
|
+
function specBaseUrl(doc) {
|
|
328
|
+
const url = doc.servers?.[0]?.url;
|
|
329
|
+
return typeof url === "string" && /^https?:\/\//i.test(url) ? url : void 0;
|
|
330
|
+
}
|
|
331
|
+
function collectParameters(doc, item, operation) {
|
|
332
|
+
const raw = [...asArray(item["parameters"]), ...asArray(operation["parameters"])];
|
|
333
|
+
const params = [];
|
|
334
|
+
for (const entry of raw) {
|
|
335
|
+
const resolved = resolveRef(doc, entry);
|
|
336
|
+
if (!isRecord(resolved)) continue;
|
|
337
|
+
const name = resolved["name"];
|
|
338
|
+
const location = resolved["in"];
|
|
339
|
+
if (typeof name !== "string") continue;
|
|
340
|
+
if (location !== "path" && location !== "query") continue;
|
|
341
|
+
const schema = resolveRef(doc, resolved["schema"]);
|
|
342
|
+
params.push({
|
|
343
|
+
name,
|
|
344
|
+
location,
|
|
345
|
+
required: resolved["required"] === true || location === "path",
|
|
346
|
+
schema: isRecord(schema) ? schema : { type: "string" },
|
|
347
|
+
...typeof resolved["description"] === "string" ? { description: resolved["description"] } : {}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return params;
|
|
351
|
+
}
|
|
352
|
+
function collectRequestBody(doc, operation) {
|
|
353
|
+
const body = resolveRef(doc, operation["requestBody"]);
|
|
354
|
+
if (!isRecord(body)) return void 0;
|
|
355
|
+
const content = body["content"];
|
|
356
|
+
if (!isRecord(content)) return void 0;
|
|
357
|
+
const json = content["application/json"];
|
|
358
|
+
if (!isRecord(json)) return void 0;
|
|
359
|
+
const schema = resolveRef(doc, json["schema"]);
|
|
360
|
+
return isRecord(schema) ? schema : void 0;
|
|
361
|
+
}
|
|
362
|
+
function toolSchema(params, body) {
|
|
363
|
+
const properties = {};
|
|
364
|
+
const required = [];
|
|
365
|
+
for (const param of params) {
|
|
366
|
+
properties[param.name] = param.description ? { ...param.schema, description: param.description } : param.schema;
|
|
367
|
+
if (param.required) required.push(param.name);
|
|
368
|
+
}
|
|
369
|
+
if (body) {
|
|
370
|
+
properties["body"] = body;
|
|
371
|
+
required.push("body");
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
type: "object",
|
|
375
|
+
properties,
|
|
376
|
+
...required.length ? { required } : {},
|
|
377
|
+
additionalProperties: false
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function buildUrl(baseUrl, path, params, input) {
|
|
381
|
+
let resolved = path;
|
|
382
|
+
for (const param of params) {
|
|
383
|
+
if (param.location !== "path") continue;
|
|
384
|
+
const value = input[param.name];
|
|
385
|
+
resolved = resolved.replace(`{${param.name}}`, encodeURIComponent(String(value ?? "")));
|
|
386
|
+
}
|
|
387
|
+
const url = new URL(`${baseUrl}${resolved.startsWith("/") ? "" : "/"}${resolved}`);
|
|
388
|
+
for (const param of params) {
|
|
389
|
+
if (param.location !== "query") continue;
|
|
390
|
+
const value = input[param.name];
|
|
391
|
+
if (value === void 0 || value === null) continue;
|
|
392
|
+
if (Array.isArray(value)) {
|
|
393
|
+
for (const entry of value) url.searchParams.append(param.name, String(entry));
|
|
394
|
+
} else {
|
|
395
|
+
url.searchParams.set(param.name, String(value));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return url.toString();
|
|
399
|
+
}
|
|
400
|
+
function describeOperation(operation, method, path) {
|
|
401
|
+
const summary = typeof operation["summary"] === "string" ? operation["summary"] : "";
|
|
402
|
+
const description = typeof operation["description"] === "string" ? operation["description"] : "";
|
|
403
|
+
const text = [summary, description].filter(Boolean).join(" - ");
|
|
404
|
+
return text || `${method.toUpperCase()} ${path}`;
|
|
405
|
+
}
|
|
406
|
+
function resolveRef(doc, node, depth = 0) {
|
|
407
|
+
if (!isRecord(node) || depth > 10) return node;
|
|
408
|
+
const ref = node["$ref"];
|
|
409
|
+
if (typeof ref !== "string" || !ref.startsWith("#/")) return node;
|
|
410
|
+
let current = doc;
|
|
411
|
+
for (const segment of ref.slice(2).split("/")) {
|
|
412
|
+
if (!isRecord(current)) return void 0;
|
|
413
|
+
current = current[segment.replace(/~1/g, "/").replace(/~0/g, "~")];
|
|
414
|
+
}
|
|
415
|
+
return resolveRef(doc, current, depth + 1);
|
|
416
|
+
}
|
|
417
|
+
function matchPath(glob, path) {
|
|
418
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
419
|
+
const trailingDeep = escaped.endsWith("/**");
|
|
420
|
+
const body = trailingDeep ? escaped.slice(0, -3) : escaped;
|
|
421
|
+
const pattern = body.split("**").map((chunk) => chunk.replace(/\*/g, "[^/]*")).join(".*") + (trailingDeep ? "(?:/.*)?" : "");
|
|
422
|
+
return new RegExp(`^${pattern}$`).test(path);
|
|
423
|
+
}
|
|
424
|
+
function sanitize(value) {
|
|
425
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48);
|
|
426
|
+
}
|
|
427
|
+
function slug(path) {
|
|
428
|
+
return path.replace(/[{}]/g, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_|_$/g, "");
|
|
429
|
+
}
|
|
430
|
+
function truncate(value, max) {
|
|
431
|
+
return value.length <= max ? value : `${value.slice(0, max)}
|
|
432
|
+
[truncated]`;
|
|
433
|
+
}
|
|
434
|
+
function asArray(value) {
|
|
435
|
+
return Array.isArray(value) ? value : [];
|
|
436
|
+
}
|
|
437
|
+
function isRecord(value) {
|
|
438
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/agent/mcp.ts
|
|
442
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
443
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
444
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
445
|
+
import { Type as Type2 } from "@earendil-works/pi-ai";
|
|
446
|
+
import { describeTool as describeTool2, evaluateToolPolicy as evaluateToolPolicy2 } from "@frankieai/core";
|
|
447
|
+
var MCP_TOOL_PREFIX = "mcp__";
|
|
448
|
+
async function connectMcpTools(options) {
|
|
449
|
+
const clients = [];
|
|
450
|
+
const tools = [];
|
|
451
|
+
const failed = [];
|
|
452
|
+
const results = await Promise.all(
|
|
453
|
+
options.servers.filter((server) => server.enabled).map(async (server) => {
|
|
454
|
+
try {
|
|
455
|
+
return { server, client: await connectOne(server, options) };
|
|
456
|
+
} catch (error) {
|
|
457
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
458
|
+
options.onWarning?.("mcp_connect_failed", { server: server.name, error: message });
|
|
459
|
+
failed.push({ name: server.name, error: message });
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
})
|
|
463
|
+
);
|
|
464
|
+
for (const result of results) {
|
|
465
|
+
if (!result) continue;
|
|
466
|
+
clients.push(result.client);
|
|
467
|
+
try {
|
|
468
|
+
const listed = await withTimeout(result.client.listTools(), options.connectTimeoutMs ?? 1e4, "MCP tool discovery timed out.");
|
|
469
|
+
for (const tool of listed.tools) {
|
|
470
|
+
if (result.server.selectedTools && !result.server.selectedTools.includes(tool.name)) continue;
|
|
471
|
+
const name = `${MCP_TOOL_PREFIX}${result.server.name}__${tool.name}`;
|
|
472
|
+
if (!evaluateToolPolicy2(name, options.policy).allowed) continue;
|
|
473
|
+
tools.push(adaptTool(name, tool, result.client, options.callTimeoutMs));
|
|
474
|
+
}
|
|
475
|
+
} catch (error) {
|
|
476
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
477
|
+
options.onWarning?.("mcp_list_tools_failed", { server: result.server.name, error: message });
|
|
478
|
+
failed.push({ name: result.server.name, error: message });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
tools,
|
|
483
|
+
failed,
|
|
484
|
+
async close() {
|
|
485
|
+
await Promise.all(clients.map((client) => client.close().catch(() => void 0)));
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
async function connectOne(server, options) {
|
|
490
|
+
const headers = buildForwardedHeaders(server, options.session);
|
|
491
|
+
const url = new URL(server.url);
|
|
492
|
+
const transport = server.transport === "sse" ? new SSEClientTransport(url, { requestInit: { headers }, fetch: connectorFetch, eventSourceInit: { fetch: connectorFetch } }) : new StreamableHTTPClientTransport(url, { requestInit: { headers }, fetch: connectorFetch });
|
|
493
|
+
const client = new Client({ name: "frankie", version: "0.1.0" }, { capabilities: {} });
|
|
494
|
+
try {
|
|
495
|
+
await withTimeout(
|
|
496
|
+
client.connect(transport),
|
|
497
|
+
options.connectTimeoutMs ?? 1e4,
|
|
498
|
+
`MCP server "${server.name}" did not complete the handshake in time.`
|
|
499
|
+
);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
await client.close().catch(() => void 0);
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
return client;
|
|
505
|
+
}
|
|
506
|
+
function adaptTool(name, tool, client, callTimeoutMs = 6e4) {
|
|
507
|
+
return {
|
|
508
|
+
name,
|
|
509
|
+
label: describeTool2(name, {}),
|
|
510
|
+
description: tool.description ?? `The ${tool.name} tool.`,
|
|
511
|
+
// MCP gives us JSON Schema; `Type.Unsafe` passes it through untouched
|
|
512
|
+
// rather than round-tripping it into a typebox equivalent and losing
|
|
513
|
+
// whatever the server actually declared.
|
|
514
|
+
parameters: Type2.Unsafe(normaliseSchema(tool.inputSchema)),
|
|
515
|
+
async execute(_toolCallId, params, signal) {
|
|
516
|
+
const response = await withTimeout(
|
|
517
|
+
client.callTool(
|
|
518
|
+
{ name: tool.name, arguments: params ?? {} },
|
|
519
|
+
void 0,
|
|
520
|
+
signal ? { signal } : void 0
|
|
521
|
+
),
|
|
522
|
+
callTimeoutMs,
|
|
523
|
+
`The ${tool.name} tool did not respond in time.`
|
|
524
|
+
);
|
|
525
|
+
const content = toAgentContent(response.content);
|
|
526
|
+
if (response.isError) {
|
|
527
|
+
throw new Error(textOf(content) || `The ${tool.name} tool reported an error.`);
|
|
528
|
+
}
|
|
529
|
+
return { content, details: response.structuredContent ?? null };
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function normaliseSchema(schema) {
|
|
534
|
+
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
|
|
535
|
+
return { type: "object", properties: {} };
|
|
536
|
+
}
|
|
537
|
+
const copy = { ...schema };
|
|
538
|
+
if (copy["type"] !== "object") copy["type"] = "object";
|
|
539
|
+
if (typeof copy["properties"] !== "object" || copy["properties"] === null) copy["properties"] = {};
|
|
540
|
+
return copy;
|
|
541
|
+
}
|
|
542
|
+
function toAgentContent(content) {
|
|
543
|
+
if (!Array.isArray(content)) return [{ type: "text", text: "" }];
|
|
544
|
+
const out = [];
|
|
545
|
+
for (const part of content) {
|
|
546
|
+
if (typeof part !== "object" || part === null) continue;
|
|
547
|
+
const block = part;
|
|
548
|
+
if (block["type"] === "text" && typeof block["text"] === "string") {
|
|
549
|
+
out.push({ type: "text", text: block["text"] });
|
|
550
|
+
} else if (block["type"] === "image" && typeof block["data"] === "string" && typeof block["mimeType"] === "string") {
|
|
551
|
+
out.push({ type: "image", data: block["data"], mimeType: block["mimeType"] });
|
|
552
|
+
} else if (block["type"] === "resource") {
|
|
553
|
+
const resource = block["resource"];
|
|
554
|
+
const text = typeof resource === "object" && resource !== null && typeof resource["text"] === "string" ? String(resource["text"]) : "[resource omitted]";
|
|
555
|
+
out.push({ type: "text", text });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return out.length > 0 ? out : [{ type: "text", text: "" }];
|
|
559
|
+
}
|
|
560
|
+
function textOf(content) {
|
|
561
|
+
return content.map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n").trim();
|
|
388
562
|
}
|
|
389
|
-
function
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
params.push({
|
|
401
|
-
name,
|
|
402
|
-
location,
|
|
403
|
-
required: resolved["required"] === true || location === "path",
|
|
404
|
-
schema: isRecord(schema) ? schema : { type: "string" },
|
|
405
|
-
...typeof resolved["description"] === "string" ? { description: resolved["description"] } : {}
|
|
406
|
-
});
|
|
563
|
+
async function withTimeout(promise, ms, message) {
|
|
564
|
+
let timer;
|
|
565
|
+
try {
|
|
566
|
+
return await Promise.race([
|
|
567
|
+
promise,
|
|
568
|
+
new Promise((_resolve, reject) => {
|
|
569
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
570
|
+
})
|
|
571
|
+
]);
|
|
572
|
+
} finally {
|
|
573
|
+
if (timer) clearTimeout(timer);
|
|
407
574
|
}
|
|
408
|
-
return params;
|
|
409
|
-
}
|
|
410
|
-
function collectRequestBody(doc, operation) {
|
|
411
|
-
const body = resolveRef(doc, operation["requestBody"]);
|
|
412
|
-
if (!isRecord(body)) return void 0;
|
|
413
|
-
const content = body["content"];
|
|
414
|
-
if (!isRecord(content)) return void 0;
|
|
415
|
-
const json = content["application/json"];
|
|
416
|
-
if (!isRecord(json)) return void 0;
|
|
417
|
-
const schema = resolveRef(doc, json["schema"]);
|
|
418
|
-
return isRecord(schema) ? schema : void 0;
|
|
419
575
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
576
|
+
|
|
577
|
+
// src/auth.ts
|
|
578
|
+
import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose";
|
|
579
|
+
var AuthError = class extends Error {
|
|
580
|
+
constructor(message, code = "unauthorized") {
|
|
581
|
+
super(message);
|
|
582
|
+
this.code = code;
|
|
426
583
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
584
|
+
code;
|
|
585
|
+
};
|
|
586
|
+
var TokenService = class {
|
|
587
|
+
constructor(auth, secrets) {
|
|
588
|
+
this.auth = auth;
|
|
589
|
+
this.sessionKey = new TextEncoder().encode(secrets.sessionSecret);
|
|
590
|
+
this.userKey = secrets.jwtSecret ? new TextEncoder().encode(secrets.jwtSecret) : void 0;
|
|
591
|
+
if (!this.userKey && !auth.jwksUrl && !auth.allowAnonymous) {
|
|
592
|
+
throw new Error("Frankie auth needs either a JWT secret, a JWKS URL, or allowAnonymous.");
|
|
593
|
+
}
|
|
430
594
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
595
|
+
auth;
|
|
596
|
+
sessionKey;
|
|
597
|
+
userKey;
|
|
598
|
+
jwks;
|
|
599
|
+
/** Verify a host-signed user token. Throws `AuthError` on any failure. */
|
|
600
|
+
async verifyUserToken(token) {
|
|
601
|
+
const options = {
|
|
602
|
+
...this.auth.jwtIssuer ? { issuer: this.auth.jwtIssuer } : {},
|
|
603
|
+
...this.auth.jwtAudience ? { audience: this.auth.jwtAudience } : {},
|
|
604
|
+
clockTolerance: 30
|
|
605
|
+
};
|
|
606
|
+
let payload;
|
|
607
|
+
try {
|
|
608
|
+
if (this.auth.jwksUrl) {
|
|
609
|
+
this.jwks ??= createRemoteJWKSet(new URL(this.auth.jwksUrl));
|
|
610
|
+
({ payload } = await jwtVerify(token, this.jwks, options));
|
|
611
|
+
} else if (this.userKey) {
|
|
612
|
+
({ payload } = await jwtVerify(token, this.userKey, { ...options, algorithms: ["HS256"] }));
|
|
613
|
+
} else {
|
|
614
|
+
throw new AuthError("This tenant is not configured to accept user tokens.");
|
|
615
|
+
}
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (error instanceof AuthError) throw error;
|
|
618
|
+
throw new AuthError(`Could not verify the user token: ${error.message}`);
|
|
619
|
+
}
|
|
620
|
+
const subject = typeof payload.sub === "string" ? payload.sub : void 0;
|
|
621
|
+
if (!subject) throw new AuthError("The user token has no `sub` claim.");
|
|
622
|
+
return {
|
|
623
|
+
id: subject,
|
|
624
|
+
displayName: stringClaim(payload, "name") ?? stringClaim(payload, "preferred_username"),
|
|
625
|
+
email: stringClaim(payload, "email"),
|
|
626
|
+
anonymous: false,
|
|
627
|
+
claims: payload
|
|
628
|
+
};
|
|
444
629
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
630
|
+
/** Mint the bearer token the widget uses for the rest of the conversation. */
|
|
631
|
+
async issueSessionToken(input) {
|
|
632
|
+
const expiresAt = new Date(Date.now() + this.auth.sessionTtlSeconds * 1e3);
|
|
633
|
+
const token = await new SignJWT({
|
|
634
|
+
sid: input.sessionId,
|
|
635
|
+
uid: input.user.id,
|
|
636
|
+
anon: input.user.anonymous,
|
|
637
|
+
tid: input.tenantId
|
|
638
|
+
}).setProtectedHeader({ alg: "HS256", typ: "JWT" }).setIssuedAt().setIssuer("frankie").setAudience("frankie-widget").setExpirationTime(Math.floor(expiresAt.getTime() / 1e3)).sign(this.sessionKey);
|
|
639
|
+
return { token, expiresAt };
|
|
640
|
+
}
|
|
641
|
+
async verifySessionToken(token) {
|
|
642
|
+
try {
|
|
643
|
+
const { payload } = await jwtVerify(token, this.sessionKey, {
|
|
644
|
+
algorithms: ["HS256"],
|
|
645
|
+
issuer: "frankie",
|
|
646
|
+
audience: "frankie-widget"
|
|
647
|
+
});
|
|
648
|
+
const sessionId = stringClaim(payload, "sid");
|
|
649
|
+
const userId = stringClaim(payload, "uid");
|
|
650
|
+
const tenantId = stringClaim(payload, "tid");
|
|
651
|
+
if (!sessionId || !userId || !tenantId) throw new AuthError("Malformed session token.");
|
|
652
|
+
return { sessionId, userId, tenantId };
|
|
653
|
+
} catch (error) {
|
|
654
|
+
if (error instanceof AuthError) throw error;
|
|
655
|
+
throw new AuthError("Your chat session has expired. Reload the page to start a new one.");
|
|
454
656
|
}
|
|
455
657
|
}
|
|
456
|
-
|
|
658
|
+
};
|
|
659
|
+
function stringClaim(payload, key) {
|
|
660
|
+
const value = payload[key];
|
|
661
|
+
return typeof value === "string" ? value : void 0;
|
|
457
662
|
}
|
|
458
|
-
function
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
return text || `${method.toUpperCase()} ${path}`;
|
|
663
|
+
function isOriginAllowed(origin, allowed) {
|
|
664
|
+
if (allowed.length === 0) return origin === void 0;
|
|
665
|
+
if (!origin) return false;
|
|
666
|
+
return allowed.some((entry) => normaliseOrigin(entry) === normaliseOrigin(origin));
|
|
463
667
|
}
|
|
464
|
-
function
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (!isRecord(current)) return void 0;
|
|
471
|
-
current = current[segment.replace(/~1/g, "/").replace(/~0/g, "~")];
|
|
668
|
+
function normaliseOrigin(value) {
|
|
669
|
+
try {
|
|
670
|
+
const url = new URL(value);
|
|
671
|
+
return url.origin.toLowerCase();
|
|
672
|
+
} catch {
|
|
673
|
+
return value.trim().replace(/\/+$/, "").toLowerCase();
|
|
472
674
|
}
|
|
473
|
-
return resolveRef(doc, current, depth + 1);
|
|
474
|
-
}
|
|
475
|
-
function matchPath(glob, path) {
|
|
476
|
-
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
477
|
-
const trailingDeep = escaped.endsWith("/**");
|
|
478
|
-
const body = trailingDeep ? escaped.slice(0, -3) : escaped;
|
|
479
|
-
const pattern = body.split("**").map((chunk) => chunk.replace(/\*/g, "[^/]*")).join(".*") + (trailingDeep ? "(?:/.*)?" : "");
|
|
480
|
-
return new RegExp(`^${pattern}$`).test(path);
|
|
481
|
-
}
|
|
482
|
-
function sanitize(value) {
|
|
483
|
-
return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48);
|
|
484
|
-
}
|
|
485
|
-
function slug(path) {
|
|
486
|
-
return path.replace(/[{}]/g, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_|_$/g, "");
|
|
487
|
-
}
|
|
488
|
-
function truncate(value, max) {
|
|
489
|
-
return value.length <= max ? value : `${value.slice(0, max)}
|
|
490
|
-
[truncated]`;
|
|
491
675
|
}
|
|
492
|
-
function
|
|
493
|
-
|
|
676
|
+
function safeEqual(a, b) {
|
|
677
|
+
if (a.length !== b.length) return false;
|
|
678
|
+
let diff = 0;
|
|
679
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
680
|
+
return diff === 0;
|
|
494
681
|
}
|
|
495
|
-
function
|
|
496
|
-
return
|
|
682
|
+
function anonymousUser(seed) {
|
|
683
|
+
return { id: `anon_${seed}`, anonymous: true, claims: {} };
|
|
497
684
|
}
|
|
498
685
|
|
|
499
686
|
// src/control/store.ts
|
|
500
|
-
import { mkdir, readFile as readFile2, readdir, rm, writeFile } from "fs/promises";
|
|
687
|
+
import { mkdir, readFile as readFile2, readdir, rename, rm, writeFile } from "fs/promises";
|
|
688
|
+
import { randomUUID } from "crypto";
|
|
501
689
|
import { join, resolve } from "path";
|
|
502
690
|
import { parseFrankieConfig } from "@frankieai/core";
|
|
503
691
|
var MEMBERS_FILE = "__members.json";
|
|
504
692
|
function toPublicTenant(record) {
|
|
505
693
|
const { config } = record;
|
|
506
694
|
return {
|
|
695
|
+
skillHistory: record.skillHistory ?? [],
|
|
696
|
+
...record.apiSpecSync ? { apiSpecSync: record.apiSpecSync } : {},
|
|
697
|
+
skills: config.skills,
|
|
507
698
|
id: record.id,
|
|
508
699
|
...record.ownerId ? { ownerId: record.ownerId } : {},
|
|
509
700
|
name: config.branding.name,
|
|
@@ -547,6 +738,40 @@ function toPublicTenant(record) {
|
|
|
547
738
|
};
|
|
548
739
|
}
|
|
549
740
|
var FileTenantStore = class _FileTenantStore {
|
|
741
|
+
async listOrganizations() {
|
|
742
|
+
await this.load();
|
|
743
|
+
return [...new Set([...this.byId.values()].flatMap((record) => record.ownerId ? [record.ownerId] : []).concat([...this.members.values()].map((member) => member.org)))].sort();
|
|
744
|
+
}
|
|
745
|
+
adminPath(ownerId) {
|
|
746
|
+
if (!/^github:[1-9]\d*$/.test(ownerId)) throw new Error("Invalid platform user ID.");
|
|
747
|
+
return join(this.dir, "platform-admins", ownerId.slice(7) + ".json");
|
|
748
|
+
}
|
|
749
|
+
async getPlatformAdmin(ownerId) {
|
|
750
|
+
if (!/^github:[1-9]\d*$/.test(ownerId)) return null;
|
|
751
|
+
try {
|
|
752
|
+
return JSON.parse(await readFile2(this.adminPath(ownerId), "utf8"));
|
|
753
|
+
} catch (error) {
|
|
754
|
+
if (error.code === "ENOENT") return null;
|
|
755
|
+
throw error;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
async listPlatformAdmins() {
|
|
759
|
+
const dir = join(this.dir, "platform-admins");
|
|
760
|
+
await mkdir(dir, { recursive: true });
|
|
761
|
+
const files = (await readdir(dir)).filter((name) => /^[1-9]\d*\.json$/.test(name));
|
|
762
|
+
const records = await Promise.all(files.map((file) => this.getPlatformAdmin("github:" + file.slice(0, -5))));
|
|
763
|
+
return records.filter((record) => Boolean(record));
|
|
764
|
+
}
|
|
765
|
+
async putPlatformAdmin(admin) {
|
|
766
|
+
const path = this.adminPath(admin.ownerId);
|
|
767
|
+
await mkdir(join(this.dir, "platform-admins"), { recursive: true });
|
|
768
|
+
const temporary = path + "." + randomUUID() + ".tmp";
|
|
769
|
+
await writeFile(temporary, JSON.stringify(admin), { mode: 384 });
|
|
770
|
+
await rename(temporary, path);
|
|
771
|
+
}
|
|
772
|
+
async deletePlatformAdmin(ownerId) {
|
|
773
|
+
await rm(this.adminPath(ownerId), { force: true });
|
|
774
|
+
}
|
|
550
775
|
dir;
|
|
551
776
|
providerKeyEnv;
|
|
552
777
|
env;
|
|
@@ -648,6 +873,14 @@ ${login.toLowerCase()}`;
|
|
|
648
873
|
await mkdir(this.dir, { recursive: true });
|
|
649
874
|
await writeFile(join(this.dir, `${record.id}.json`), JSON.stringify(record, null, 2), "utf8");
|
|
650
875
|
}
|
|
876
|
+
async compareAndSwap(previous, next) {
|
|
877
|
+
await this.load();
|
|
878
|
+
if (this.byId.get(previous.id) !== previous) return false;
|
|
879
|
+
this.remember(next);
|
|
880
|
+
await mkdir(this.dir, { recursive: true });
|
|
881
|
+
await writeFile(join(this.dir, `${next.id}.json`), JSON.stringify(next, null, 2), "utf8");
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
651
884
|
async delete(id) {
|
|
652
885
|
await this.load();
|
|
653
886
|
const record = this.byId.get(id);
|
|
@@ -661,6 +894,7 @@ ${login.toLowerCase()}`;
|
|
|
661
894
|
// src/control/postgres-store.ts
|
|
662
895
|
import { parseFrankieConfig as parseFrankieConfig2 } from "@frankieai/core";
|
|
663
896
|
var PostgresTenantStore = class {
|
|
897
|
+
snapshots = /* @__PURE__ */ new WeakMap();
|
|
664
898
|
db;
|
|
665
899
|
providerKeyEnv;
|
|
666
900
|
env;
|
|
@@ -680,6 +914,9 @@ var PostgresTenantStore = class {
|
|
|
680
914
|
}
|
|
681
915
|
/** Create the table if it isn't there yet. Safe to call repeatedly. */
|
|
682
916
|
async init() {
|
|
917
|
+
await this.db.query(`CREATE TABLE IF NOT EXISTS frankie_platform_admins (
|
|
918
|
+
owner_id text PRIMARY KEY, login text, granted_by text NOT NULL, created_at text NOT NULL
|
|
919
|
+
)`);
|
|
683
920
|
await this.db.query(
|
|
684
921
|
`CREATE TABLE IF NOT EXISTS frankie_tenants (
|
|
685
922
|
id text PRIMARY KEY,
|
|
@@ -712,6 +949,36 @@ var PostgresTenantStore = class {
|
|
|
712
949
|
createdAt: String(row.created_at)
|
|
713
950
|
};
|
|
714
951
|
}
|
|
952
|
+
adminRow(row) {
|
|
953
|
+
return { ownerId: String(row.owner_id), ...row.login ? { login: String(row.login) } : {}, grantedBy: String(row.granted_by), createdAt: String(row.created_at) };
|
|
954
|
+
}
|
|
955
|
+
async listOrganizations() {
|
|
956
|
+
await this.ensureReady();
|
|
957
|
+
const { rows } = await this.db.query("SELECT owner_id AS org FROM frankie_tenants WHERE owner_id IS NOT NULL UNION SELECT org FROM frankie_org_members");
|
|
958
|
+
return rows.map((row) => String(row.org)).sort();
|
|
959
|
+
}
|
|
960
|
+
async listPlatformAdmins() {
|
|
961
|
+
await this.ensureReady();
|
|
962
|
+
const { rows } = await this.db.query("SELECT * FROM frankie_platform_admins ORDER BY created_at ASC");
|
|
963
|
+
return rows.map((row) => this.adminRow(row));
|
|
964
|
+
}
|
|
965
|
+
async getPlatformAdmin(ownerId) {
|
|
966
|
+
await this.ensureReady();
|
|
967
|
+
const { rows } = await this.db.query("SELECT * FROM frankie_platform_admins WHERE owner_id = $1", [ownerId]);
|
|
968
|
+
return rows[0] ? this.adminRow(rows[0]) : null;
|
|
969
|
+
}
|
|
970
|
+
async putPlatformAdmin(admin) {
|
|
971
|
+
await this.ensureReady();
|
|
972
|
+
await this.db.query(
|
|
973
|
+
`INSERT INTO frankie_platform_admins (owner_id, login, granted_by, created_at)
|
|
974
|
+
VALUES ($1, $2, $3, $4) ON CONFLICT (owner_id) DO NOTHING`,
|
|
975
|
+
[admin.ownerId, admin.login ?? null, admin.grantedBy, admin.createdAt]
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
async deletePlatformAdmin(ownerId) {
|
|
979
|
+
await this.ensureReady();
|
|
980
|
+
await this.db.query("DELETE FROM frankie_platform_admins WHERE owner_id = $1", [ownerId]);
|
|
981
|
+
}
|
|
715
982
|
async listMembers(org) {
|
|
716
983
|
await this.ensureReady();
|
|
717
984
|
const { rows } = await this.db.query(
|
|
@@ -740,6 +1007,7 @@ var PostgresTenantStore = class {
|
|
|
740
1007
|
}
|
|
741
1008
|
parse(row) {
|
|
742
1009
|
const record = JSON.parse(String(row.record));
|
|
1010
|
+
this.snapshots.set(record, String(row.record));
|
|
743
1011
|
record.config = parseFrankieConfig2(record.config);
|
|
744
1012
|
return record;
|
|
745
1013
|
}
|
|
@@ -799,10 +1067,17 @@ var PostgresTenantStore = class {
|
|
|
799
1067
|
await this.ensureReady();
|
|
800
1068
|
await this.db.query(`DELETE FROM frankie_tenants WHERE id = $1`, [id]);
|
|
801
1069
|
}
|
|
1070
|
+
async compareAndSwap(previous, next) {
|
|
1071
|
+
await this.ensureReady();
|
|
1072
|
+
const snapshot = this.snapshots.get(previous);
|
|
1073
|
+
if (!snapshot || next.id !== previous.id || next.config.auth.publishableKey !== previous.config.auth.publishableKey) return false;
|
|
1074
|
+
const { rows } = await this.db.query("UPDATE frankie_tenants SET record = $1, updated_at = $2 WHERE id = $3 AND record = $4 RETURNING id", [JSON.stringify(next), next.updatedAt, previous.id, snapshot]);
|
|
1075
|
+
return rows.length === 1;
|
|
1076
|
+
}
|
|
802
1077
|
};
|
|
803
1078
|
|
|
804
1079
|
// src/sessions.ts
|
|
805
|
-
import { randomUUID } from "crypto";
|
|
1080
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
806
1081
|
var MemorySessionStore = class {
|
|
807
1082
|
constructor(ttlMs = 6 * 60 * 60 * 1e3) {
|
|
808
1083
|
this.ttlMs = ttlMs;
|
|
@@ -844,7 +1119,7 @@ var MemorySessionStore = class {
|
|
|
844
1119
|
}
|
|
845
1120
|
};
|
|
846
1121
|
function newSessionId() {
|
|
847
|
-
return
|
|
1122
|
+
return randomUUID2();
|
|
848
1123
|
}
|
|
849
1124
|
|
|
850
1125
|
// src/postgres-sessions.ts
|
|
@@ -959,20 +1234,26 @@ async function resolveTenantStore(env = process.env) {
|
|
|
959
1234
|
}
|
|
960
1235
|
|
|
961
1236
|
export {
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1237
|
+
connectorFetch,
|
|
1238
|
+
MemoryRateLimiter,
|
|
1239
|
+
ModelResolutionError,
|
|
1240
|
+
resolveModel,
|
|
1241
|
+
resolveModelChoice,
|
|
1242
|
+
modelHasApi,
|
|
967
1243
|
buildForwardedHeaders,
|
|
968
|
-
MCP_TOOL_PREFIX,
|
|
969
|
-
connectMcpTools,
|
|
970
1244
|
API_TOOL_PREFIX,
|
|
971
1245
|
OpenApiError,
|
|
972
1246
|
inspectApiCapabilities,
|
|
973
1247
|
buildApiTools,
|
|
974
1248
|
loadSpec,
|
|
975
1249
|
matchPath,
|
|
1250
|
+
MCP_TOOL_PREFIX,
|
|
1251
|
+
connectMcpTools,
|
|
1252
|
+
AuthError,
|
|
1253
|
+
TokenService,
|
|
1254
|
+
isOriginAllowed,
|
|
1255
|
+
safeEqual,
|
|
1256
|
+
anonymousUser,
|
|
976
1257
|
toPublicTenant,
|
|
977
1258
|
FileTenantStore,
|
|
978
1259
|
PostgresTenantStore,
|
|
@@ -982,4 +1263,4 @@ export {
|
|
|
982
1263
|
resolveStores,
|
|
983
1264
|
resolveTenantStore
|
|
984
1265
|
};
|
|
985
|
-
//# sourceMappingURL=chunk-
|
|
1266
|
+
//# sourceMappingURL=chunk-WFENEYOV.js.map
|