@frankieai/server 0.2.0 → 0.2.2

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.
@@ -1,1197 +0,0 @@
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;
20
- }
21
- timestamps.push(now);
22
- this.hits.set(key, timestamps);
23
- return true;
24
- }
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);
39
- }
40
- }
41
- };
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
- );
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 };
69
- }
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
- };
82
- }
83
- function modelHasApi(model, api) {
84
- return hasApi(model, api);
85
- }
86
-
87
- // src/auth.ts
88
- import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose";
89
- var AuthError = class extends Error {
90
- constructor(message, code = "unauthorized") {
91
- super(message);
92
- this.code = code;
93
- }
94
- code;
95
- };
96
- var TokenService = class {
97
- constructor(auth, secrets) {
98
- this.auth = auth;
99
- this.sessionKey = new TextEncoder().encode(secrets.sessionSecret);
100
- this.userKey = secrets.jwtSecret ? new TextEncoder().encode(secrets.jwtSecret) : void 0;
101
- if (!this.userKey && !auth.jwksUrl && !auth.allowAnonymous) {
102
- throw new Error("Frankie auth needs either a JWT secret, a JWKS URL, or allowAnonymous.");
103
- }
104
- }
105
- auth;
106
- sessionKey;
107
- userKey;
108
- jwks;
109
- /** Verify a host-signed user token. Throws `AuthError` on any failure. */
110
- async verifyUserToken(token) {
111
- const options = {
112
- ...this.auth.jwtIssuer ? { issuer: this.auth.jwtIssuer } : {},
113
- ...this.auth.jwtAudience ? { audience: this.auth.jwtAudience } : {},
114
- clockTolerance: 30
115
- };
116
- let payload;
117
- try {
118
- if (this.auth.jwksUrl) {
119
- this.jwks ??= createRemoteJWKSet(new URL(this.auth.jwksUrl));
120
- ({ payload } = await jwtVerify(token, this.jwks, options));
121
- } else if (this.userKey) {
122
- ({ payload } = await jwtVerify(token, this.userKey, { ...options, algorithms: ["HS256"] }));
123
- } else {
124
- throw new AuthError("This tenant is not configured to accept user tokens.");
125
- }
126
- } catch (error) {
127
- if (error instanceof AuthError) throw error;
128
- throw new AuthError(`Could not verify the user token: ${error.message}`);
129
- }
130
- const subject = typeof payload.sub === "string" ? payload.sub : void 0;
131
- if (!subject) throw new AuthError("The user token has no `sub` claim.");
132
- return {
133
- id: subject,
134
- displayName: stringClaim(payload, "name") ?? stringClaim(payload, "preferred_username"),
135
- email: stringClaim(payload, "email"),
136
- anonymous: false,
137
- claims: payload
138
- };
139
- }
140
- /** Mint the bearer token the widget uses for the rest of the conversation. */
141
- async issueSessionToken(input) {
142
- const expiresAt = new Date(Date.now() + this.auth.sessionTtlSeconds * 1e3);
143
- const token = await new SignJWT({
144
- sid: input.sessionId,
145
- uid: input.user.id,
146
- anon: input.user.anonymous,
147
- tid: input.tenantId
148
- }).setProtectedHeader({ alg: "HS256", typ: "JWT" }).setIssuedAt().setIssuer("frankie").setAudience("frankie-widget").setExpirationTime(Math.floor(expiresAt.getTime() / 1e3)).sign(this.sessionKey);
149
- return { token, expiresAt };
150
- }
151
- async verifySessionToken(token) {
152
- try {
153
- const { payload } = await jwtVerify(token, this.sessionKey, {
154
- algorithms: ["HS256"],
155
- issuer: "frankie",
156
- audience: "frankie-widget"
157
- });
158
- const sessionId = stringClaim(payload, "sid");
159
- const userId = stringClaim(payload, "uid");
160
- const tenantId = stringClaim(payload, "tid");
161
- if (!sessionId || !userId || !tenantId) throw new AuthError("Malformed session token.");
162
- return { sessionId, userId, tenantId };
163
- } catch (error) {
164
- if (error instanceof AuthError) throw error;
165
- throw new AuthError("Your chat session has expired. Reload the page to start a new one.");
166
- }
167
- }
168
- };
169
- function stringClaim(payload, key) {
170
- const value = payload[key];
171
- return typeof value === "string" ? value : void 0;
172
- }
173
- function isOriginAllowed(origin, allowed) {
174
- if (allowed.length === 0) return origin === void 0;
175
- if (!origin) return false;
176
- return allowed.some((entry) => normaliseOrigin(entry) === normaliseOrigin(origin));
177
- }
178
- function normaliseOrigin(value) {
179
- try {
180
- const url = new URL(value);
181
- return url.origin.toLowerCase();
182
- } catch {
183
- return value.trim().replace(/\/+$/, "").toLowerCase();
184
- }
185
- }
186
- function safeEqual(a, b) {
187
- if (a.length !== b.length) return false;
188
- let diff = 0;
189
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
190
- return diff === 0;
191
- }
192
- function anonymousUser(seed) {
193
- return { id: `anon_${seed}`, anonymous: true, claims: {} };
194
- }
195
-
196
- // src/agent/headers.ts
197
- function buildForwardedHeaders(source, session) {
198
- const headers = { ...source.headers };
199
- if (source.forwardUserTokenAs && session.userToken) {
200
- headers[source.forwardUserTokenAs] = source.forwardUserTokenAs.toLowerCase() === "authorization" ? `Bearer ${session.userToken}` : session.userToken;
201
- }
202
- headers["x-frankie-user-id"] = session.user.id;
203
- headers["x-frankie-session-id"] = session.id;
204
- headers["x-frankie-tenant-id"] = session.tenantId;
205
- for (const claim of source.forwardUserClaims) {
206
- const value = session.user.claims[claim];
207
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
208
- headers[`x-frankie-${claim.toLowerCase()}`] = String(value);
209
- }
210
- }
211
- return headers;
212
- }
213
-
214
- // src/agent/mcp.ts
215
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
216
-
217
- // src/connector-fetch.ts
218
- import { lookup } from "dns/promises";
219
- import { isIP } from "net";
220
- import { request as httpsRequest } from "https";
221
- import { Readable, Transform } from "stream";
222
- function isPublicAddress(address) {
223
- if (isIP(address) === 4) {
224
- const [a, b] = address.split(".").map(Number);
225
- 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);
226
- }
227
- if (isIP(address) !== 6) return false;
228
- const first = Number.parseInt(address.split(":")[0], 16);
229
- 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:");
230
- }
231
- function assertConnectorUrl(value) {
232
- const url = new URL(value);
233
- if (url.protocol !== "https:" || url.username || url.password) throw new Error("Connectors require HTTPS URLs without embedded credentials.");
234
- const host = url.hostname.replace(/^\[|\]$/g, "");
235
- if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || isIP(host) && !isPublicAddress(host)) {
236
- throw new Error("Connectors cannot access local or private networks.");
237
- }
238
- return url;
239
- }
240
- async function resolvePublicHost(host, resolve2 = lookup) {
241
- const addresses = await resolve2(host, { all: true, verbatim: true });
242
- if (!addresses.length || addresses.some((entry) => !isPublicAddress(entry.address))) throw new Error("Connector DNS must resolve only to public addresses.");
243
- return addresses[0];
244
- }
245
- var publicConnectorFetch = async (input, init) => {
246
- const source = new Request(input, init);
247
- const url = assertConnectorUrl(source.url);
248
- const headers = Object.fromEntries(source.headers);
249
- for (const key of ["host", "connection", "content-length", "transfer-encoding", "proxy-authorization", "upgrade"]) delete headers[key];
250
- const outgoing = source.body ? await readLimited(source.body, 2e6) : void 0;
251
- return new Promise((resolve2, reject) => {
252
- const req = httpsRequest(url, {
253
- method: source.method,
254
- headers,
255
- signal: source.signal,
256
- lookup(host, options, callback) {
257
- const done = callback;
258
- void resolvePublicHost(host).then((entry) => options.all ? done(null, [entry]) : done(null, entry.address, entry.family), (error) => done(error));
259
- }
260
- }, (response) => {
261
- const status = response.statusCode ?? 502;
262
- if (status >= 300 && status < 400) {
263
- response.destroy();
264
- reject(new Error("Connector redirects are disabled. Configure the final HTTPS endpoint."));
265
- return;
266
- }
267
- const responseHeaders = new Headers();
268
- for (const [name, value] of Object.entries(response.headers)) if (value) responseHeaders.set(name, Array.isArray(value) ? value.join(", ") : value);
269
- let bytes = 0;
270
- const limit = new Transform({ transform(chunk, _encoding, done) {
271
- bytes += chunk.length;
272
- done(bytes > 8e6 ? new Error("Connector response exceeds the size limit.") : null, chunk);
273
- } });
274
- response.on("error", (error) => limit.destroy(error));
275
- limit.on("error", () => response.destroy());
276
- const body = [204, 205, 304].includes(status) || source.method === "HEAD" ? null : Readable.toWeb(response.pipe(limit));
277
- if (!body) response.resume();
278
- resolve2(new Response(body, { status, headers: responseHeaders }));
279
- });
280
- const timer = setTimeout(() => req.destroy(new Error("Connector request timed out.")), 6e4);
281
- timer.unref();
282
- req.on("close", () => clearTimeout(timer));
283
- req.on("error", reject);
284
- req.end(outgoing);
285
- });
286
- };
287
- async function readLimited(stream, max) {
288
- const reader = stream.getReader();
289
- const chunks = [];
290
- let bytes = 0;
291
- try {
292
- for (; ; ) {
293
- const { done, value } = await reader.read();
294
- if (done) break;
295
- bytes += value.length;
296
- if (bytes > max) {
297
- await reader.cancel();
298
- throw new Error("Connector request exceeds the size limit.");
299
- }
300
- chunks.push(value);
301
- }
302
- return Buffer.concat(chunks);
303
- } finally {
304
- reader.releaseLock();
305
- }
306
- }
307
- var trustedLocalConnectors = () => process.env["FRANKIE_TRUST_LOCAL_CONNECTORS"] === "true";
308
- var connectorFetch = (input, init) => trustedLocalConnectors() ? fetch(input, init) : publicConnectorFetch(input, init);
309
-
310
- // src/agent/mcp.ts
311
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
312
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
313
- import { Type } from "@earendil-works/pi-ai";
314
- import { describeTool, evaluateToolPolicy } from "@frankieai/core";
315
- var MCP_TOOL_PREFIX = "mcp__";
316
- async function connectMcpTools(options) {
317
- const clients = [];
318
- const tools = [];
319
- const failed = [];
320
- const results = await Promise.all(
321
- options.servers.filter((server) => server.enabled).map(async (server) => {
322
- try {
323
- return { server, client: await connectOne(server, options) };
324
- } catch (error) {
325
- const message = error instanceof Error ? error.message : String(error);
326
- options.onWarning?.("mcp_connect_failed", { server: server.name, error: message });
327
- failed.push({ name: server.name, error: message });
328
- return null;
329
- }
330
- })
331
- );
332
- for (const result of results) {
333
- if (!result) continue;
334
- clients.push(result.client);
335
- try {
336
- const listed = await withTimeout(result.client.listTools(), options.connectTimeoutMs ?? 1e4, "MCP tool discovery timed out.");
337
- for (const tool of listed.tools) {
338
- if (result.server.selectedTools && !result.server.selectedTools.includes(tool.name)) continue;
339
- const name = `${MCP_TOOL_PREFIX}${result.server.name}__${tool.name}`;
340
- if (!evaluateToolPolicy(name, options.policy).allowed) continue;
341
- tools.push(adaptTool(name, tool, result.client, options.callTimeoutMs));
342
- }
343
- } catch (error) {
344
- const message = error instanceof Error ? error.message : String(error);
345
- options.onWarning?.("mcp_list_tools_failed", { server: result.server.name, error: message });
346
- failed.push({ name: result.server.name, error: message });
347
- }
348
- }
349
- return {
350
- tools,
351
- failed,
352
- async close() {
353
- await Promise.all(clients.map((client) => client.close().catch(() => void 0)));
354
- }
355
- };
356
- }
357
- async function connectOne(server, options) {
358
- const headers = buildForwardedHeaders(server, options.session);
359
- const url = new URL(server.url);
360
- const transport = server.transport === "sse" ? new SSEClientTransport(url, { requestInit: { headers }, fetch: connectorFetch, eventSourceInit: { fetch: connectorFetch } }) : new StreamableHTTPClientTransport(url, { requestInit: { headers }, fetch: connectorFetch });
361
- const client = new Client({ name: "frankie", version: "0.1.0" }, { capabilities: {} });
362
- try {
363
- await withTimeout(
364
- client.connect(transport),
365
- options.connectTimeoutMs ?? 1e4,
366
- `MCP server "${server.name}" did not complete the handshake in time.`
367
- );
368
- } catch (error) {
369
- await client.close().catch(() => void 0);
370
- throw error;
371
- }
372
- return client;
373
- }
374
- function adaptTool(name, tool, client, callTimeoutMs = 6e4) {
375
- return {
376
- name,
377
- label: describeTool(name, {}),
378
- description: tool.description ?? `The ${tool.name} tool.`,
379
- // MCP gives us JSON Schema; `Type.Unsafe` passes it through untouched
380
- // rather than round-tripping it into a typebox equivalent and losing
381
- // whatever the server actually declared.
382
- parameters: Type.Unsafe(normaliseSchema(tool.inputSchema)),
383
- async execute(_toolCallId, params, signal) {
384
- const response = await withTimeout(
385
- client.callTool(
386
- { name: tool.name, arguments: params ?? {} },
387
- void 0,
388
- signal ? { signal } : void 0
389
- ),
390
- callTimeoutMs,
391
- `The ${tool.name} tool did not respond in time.`
392
- );
393
- const content = toAgentContent(response.content);
394
- if (response.isError) {
395
- throw new Error(textOf(content) || `The ${tool.name} tool reported an error.`);
396
- }
397
- return { content, details: response.structuredContent ?? null };
398
- }
399
- };
400
- }
401
- function normaliseSchema(schema) {
402
- if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
403
- return { type: "object", properties: {} };
404
- }
405
- const copy = { ...schema };
406
- if (copy["type"] !== "object") copy["type"] = "object";
407
- if (typeof copy["properties"] !== "object" || copy["properties"] === null) copy["properties"] = {};
408
- return copy;
409
- }
410
- function toAgentContent(content) {
411
- if (!Array.isArray(content)) return [{ type: "text", text: "" }];
412
- const out = [];
413
- for (const part of content) {
414
- if (typeof part !== "object" || part === null) continue;
415
- const block = part;
416
- if (block["type"] === "text" && typeof block["text"] === "string") {
417
- out.push({ type: "text", text: block["text"] });
418
- } else if (block["type"] === "image" && typeof block["data"] === "string" && typeof block["mimeType"] === "string") {
419
- out.push({ type: "image", data: block["data"], mimeType: block["mimeType"] });
420
- } else if (block["type"] === "resource") {
421
- const resource = block["resource"];
422
- const text = typeof resource === "object" && resource !== null && typeof resource["text"] === "string" ? String(resource["text"]) : "[resource omitted]";
423
- out.push({ type: "text", text });
424
- }
425
- }
426
- return out.length > 0 ? out : [{ type: "text", text: "" }];
427
- }
428
- function textOf(content) {
429
- return content.map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n").trim();
430
- }
431
- async function withTimeout(promise, ms, message) {
432
- let timer;
433
- try {
434
- return await Promise.race([
435
- promise,
436
- new Promise((_resolve, reject) => {
437
- timer = setTimeout(() => reject(new Error(message)), ms);
438
- })
439
- ]);
440
- } finally {
441
- if (timer) clearTimeout(timer);
442
- }
443
- }
444
-
445
- // src/agent/openapi.ts
446
- import { readFile } from "fs/promises";
447
- import { Type as Type2 } from "@earendil-works/pi-ai";
448
- import { describeTool as describeTool2, evaluateToolPolicy as evaluateToolPolicy2 } from "@frankieai/core";
449
- import { parse as parseYaml } from "yaml";
450
- var API_TOOL_PREFIX = "api__";
451
- var OpenApiError = class extends Error {
452
- };
453
- async function inspectApiCapabilities(source, options = {}) {
454
- const doc = await loadSpec(source.spec, options.fetchImpl);
455
- const allowedMethods = new Set(source.methods);
456
- const capabilities = [];
457
- for (const [path, item] of Object.entries(doc.paths ?? {})) {
458
- if (!isRecord(item)) continue;
459
- if (source.includePaths.length > 0 && !source.includePaths.some((glob) => matchPath(glob, path))) continue;
460
- for (const method of ["get", "post", "put", "patch", "delete"]) {
461
- const operation = item[method];
462
- if (!isRecord(operation)) continue;
463
- const httpMethod = method.toUpperCase();
464
- if (!allowedMethods.has(httpMethod)) continue;
465
- const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `${method}_${slug(path)}`;
466
- if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
467
- const toolName = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
468
- if (options.policy && !evaluateToolPolicy2(toolName, options.policy).allowed) continue;
469
- const summary = typeof operation["summary"] === "string" ? operation["summary"] : void 0;
470
- capabilities.push({ operationId, method: httpMethod, path, ...summary ? { summary } : {} });
471
- }
472
- }
473
- return capabilities;
474
- }
475
- async function buildApiTools(options) {
476
- const tools = [];
477
- const failed = [];
478
- for (const source of options.sources.filter((source2) => source2.enabled)) {
479
- try {
480
- tools.push(...await buildOne(source, options));
481
- } catch (error) {
482
- const message = error instanceof Error ? error.message : String(error);
483
- options.onWarning?.("api_spec_failed", { api: source.name, error: message });
484
- failed.push({ name: source.name, error: message });
485
- }
486
- }
487
- return { tools, failed };
488
- }
489
- async function buildOne(source, options) {
490
- const doc = await loadSpec(source.spec, options.fetchImpl);
491
- const baseUrl = (source.baseUrl ?? specBaseUrl(doc))?.replace(/\/+$/, "");
492
- if (!baseUrl) {
493
- throw new OpenApiError(
494
- `API "${source.name}" needs a baseUrl: its spec declares no \`servers\` entry to fall back to.`
495
- );
496
- }
497
- const headers = buildForwardedHeaders(source, options.session);
498
- const doFetch = options.fetchImpl ?? connectorFetch;
499
- const allowedMethods = new Set(source.methods);
500
- const tools = [];
501
- for (const [path, item] of Object.entries(doc.paths ?? {})) {
502
- if (!isRecord(item)) continue;
503
- if (source.includePaths.length > 0 && !source.includePaths.some((glob) => matchPath(glob, path))) continue;
504
- for (const method of ["get", "post", "put", "patch", "delete"]) {
505
- const operation = item[method];
506
- if (!isRecord(operation)) continue;
507
- if (!allowedMethods.has(method.toUpperCase())) continue;
508
- const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `${method}_${slug(path)}`;
509
- if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
510
- if (source.selectedTools && !source.selectedTools.includes(operationId)) continue;
511
- const name = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
512
- if (!evaluateToolPolicy2(name, options.policy).allowed) continue;
513
- const params = collectParameters(doc, item, operation);
514
- const body = collectRequestBody(doc, operation);
515
- tools.push({
516
- name,
517
- label: describeTool2(name, {}),
518
- description: describeOperation(operation, method, path),
519
- parameters: Type2.Unsafe(toolSchema(params, body)),
520
- async execute(_id, args, signal) {
521
- const input = isRecord(args) ? args : {};
522
- const url = buildUrl(baseUrl, path, params, input);
523
- const requestBody = body ? input["body"] : void 0;
524
- const response = await doFetch(url, {
525
- method: method.toUpperCase(),
526
- headers: {
527
- ...headers,
528
- accept: "application/json, text/plain;q=0.9, */*;q=0.8",
529
- ...requestBody !== void 0 ? { "content-type": "application/json" } : {}
530
- },
531
- ...requestBody !== void 0 ? { body: JSON.stringify(requestBody) } : {},
532
- signal: signal ?? AbortSignal.timeout(source.timeoutMs)
533
- });
534
- const text = truncate(await response.text().catch(() => ""), source.maxResponseChars);
535
- if (!response.ok) {
536
- throw new Error(`${operationId} failed with HTTP ${response.status}: ${truncate(text, 400)}`);
537
- }
538
- return { content: [{ type: "text", text: text || "(empty response)" }], details: null };
539
- }
540
- });
541
- }
542
- }
543
- if (tools.length === 0) {
544
- options.onWarning?.("api_no_tools", { api: source.name, baseUrl });
545
- }
546
- return tools;
547
- }
548
- async function loadSpec(spec, fetchImpl) {
549
- let raw;
550
- if (spec.trimStart().startsWith("{")) {
551
- raw = spec;
552
- } else if (/^https?:\/\//i.test(spec)) {
553
- const response = await (fetchImpl ?? connectorFetch)(spec, {
554
- headers: { accept: "application/json, text/yaml, */*" }
555
- });
556
- if (!response.ok) throw new OpenApiError(`Could not fetch ${spec}: HTTP ${response.status}.`);
557
- raw = await response.text();
558
- } else {
559
- if (!trustedLocalConnectors()) throw new OpenApiError("Local OpenAPI files are disabled. Paste JSON or use a public HTTPS document.");
560
- raw = await readFile(spec, "utf8").catch(() => {
561
- throw new OpenApiError(`Could not read the OpenAPI document at ${spec}.`);
562
- });
563
- }
564
- try {
565
- const parsed = raw.trimStart().startsWith("{") ? JSON.parse(raw) : parseYaml(raw);
566
- if (!isRecord(parsed)) throw new Error("the document is not an object");
567
- return parsed;
568
- } catch (error) {
569
- throw new OpenApiError(`Could not parse the OpenAPI document at ${spec}: ${error.message}`);
570
- }
571
- }
572
- function specBaseUrl(doc) {
573
- const url = doc.servers?.[0]?.url;
574
- return typeof url === "string" && /^https?:\/\//i.test(url) ? url : void 0;
575
- }
576
- function collectParameters(doc, item, operation) {
577
- const raw = [...asArray(item["parameters"]), ...asArray(operation["parameters"])];
578
- const params = [];
579
- for (const entry of raw) {
580
- const resolved = resolveRef(doc, entry);
581
- if (!isRecord(resolved)) continue;
582
- const name = resolved["name"];
583
- const location = resolved["in"];
584
- if (typeof name !== "string") continue;
585
- if (location !== "path" && location !== "query") continue;
586
- const schema = resolveRef(doc, resolved["schema"]);
587
- params.push({
588
- name,
589
- location,
590
- required: resolved["required"] === true || location === "path",
591
- schema: isRecord(schema) ? schema : { type: "string" },
592
- ...typeof resolved["description"] === "string" ? { description: resolved["description"] } : {}
593
- });
594
- }
595
- return params;
596
- }
597
- function collectRequestBody(doc, operation) {
598
- const body = resolveRef(doc, operation["requestBody"]);
599
- if (!isRecord(body)) return void 0;
600
- const content = body["content"];
601
- if (!isRecord(content)) return void 0;
602
- const json = content["application/json"];
603
- if (!isRecord(json)) return void 0;
604
- const schema = resolveRef(doc, json["schema"]);
605
- return isRecord(schema) ? schema : void 0;
606
- }
607
- function toolSchema(params, body) {
608
- const properties = {};
609
- const required = [];
610
- for (const param of params) {
611
- properties[param.name] = param.description ? { ...param.schema, description: param.description } : param.schema;
612
- if (param.required) required.push(param.name);
613
- }
614
- if (body) {
615
- properties["body"] = body;
616
- required.push("body");
617
- }
618
- return {
619
- type: "object",
620
- properties,
621
- ...required.length ? { required } : {},
622
- additionalProperties: false
623
- };
624
- }
625
- function buildUrl(baseUrl, path, params, input) {
626
- let resolved = path;
627
- for (const param of params) {
628
- if (param.location !== "path") continue;
629
- const value = input[param.name];
630
- resolved = resolved.replace(`{${param.name}}`, encodeURIComponent(String(value ?? "")));
631
- }
632
- const url = new URL(`${baseUrl}${resolved.startsWith("/") ? "" : "/"}${resolved}`);
633
- for (const param of params) {
634
- if (param.location !== "query") continue;
635
- const value = input[param.name];
636
- if (value === void 0 || value === null) continue;
637
- if (Array.isArray(value)) {
638
- for (const entry of value) url.searchParams.append(param.name, String(entry));
639
- } else {
640
- url.searchParams.set(param.name, String(value));
641
- }
642
- }
643
- return url.toString();
644
- }
645
- function describeOperation(operation, method, path) {
646
- const summary = typeof operation["summary"] === "string" ? operation["summary"] : "";
647
- const description = typeof operation["description"] === "string" ? operation["description"] : "";
648
- const text = [summary, description].filter(Boolean).join(" - ");
649
- return text || `${method.toUpperCase()} ${path}`;
650
- }
651
- function resolveRef(doc, node, depth = 0) {
652
- if (!isRecord(node) || depth > 10) return node;
653
- const ref = node["$ref"];
654
- if (typeof ref !== "string" || !ref.startsWith("#/")) return node;
655
- let current = doc;
656
- for (const segment of ref.slice(2).split("/")) {
657
- if (!isRecord(current)) return void 0;
658
- current = current[segment.replace(/~1/g, "/").replace(/~0/g, "~")];
659
- }
660
- return resolveRef(doc, current, depth + 1);
661
- }
662
- function matchPath(glob, path) {
663
- const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
664
- const trailingDeep = escaped.endsWith("/**");
665
- const body = trailingDeep ? escaped.slice(0, -3) : escaped;
666
- const pattern = body.split("**").map((chunk) => chunk.replace(/\*/g, "[^/]*")).join(".*") + (trailingDeep ? "(?:/.*)?" : "");
667
- return new RegExp(`^${pattern}$`).test(path);
668
- }
669
- function sanitize(value) {
670
- return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48);
671
- }
672
- function slug(path) {
673
- return path.replace(/[{}]/g, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_|_$/g, "");
674
- }
675
- function truncate(value, max) {
676
- return value.length <= max ? value : `${value.slice(0, max)}
677
- [truncated]`;
678
- }
679
- function asArray(value) {
680
- return Array.isArray(value) ? value : [];
681
- }
682
- function isRecord(value) {
683
- return typeof value === "object" && value !== null && !Array.isArray(value);
684
- }
685
-
686
- // src/control/store.ts
687
- import { mkdir, readFile as readFile2, readdir, rm, writeFile } from "fs/promises";
688
- import { join, resolve } from "path";
689
- import { parseFrankieConfig } from "@frankieai/core";
690
- var MEMBERS_FILE = "__members.json";
691
- function toPublicTenant(record) {
692
- const { config } = record;
693
- return {
694
- skillHistory: record.skillHistory ?? [],
695
- skills: config.skills,
696
- id: record.id,
697
- ...record.ownerId ? { ownerId: record.ownerId } : {},
698
- name: config.branding.name,
699
- publishableKey: config.auth.publishableKey,
700
- allowedOrigins: config.auth.allowedOrigins,
701
- allowAnonymous: config.auth.allowAnonymous,
702
- connection: config.apis.length > 0 ? "openapi" : config.mcpServers.length > 0 ? "mcp" : "knowledge",
703
- mcpServers: config.mcpServers.map((s) => s.name),
704
- apis: config.apis.map((a) => a.name),
705
- connections: [
706
- ...config.mcpServers.map((s) => ({
707
- kind: "mcp",
708
- name: s.name,
709
- url: s.url,
710
- discoveredTools: s.discoveredTools,
711
- ...s.selectedTools ? { selectedTools: s.selectedTools } : {},
712
- forwardsToken: Boolean(s.forwardUserTokenAs),
713
- enabled: s.enabled && config.agent.tools.allow.some((entry) => entry === `mcp__${s.name}` || entry.startsWith(`mcp__${s.name}__`)),
714
- status: "configured"
715
- })),
716
- ...config.apis.map((a) => ({
717
- kind: "openapi",
718
- name: a.name,
719
- spec: a.spec,
720
- ...a.baseUrl ? { baseUrl: a.baseUrl } : {},
721
- methods: a.methods,
722
- ...a.selectedTools ? { selectedTools: a.selectedTools } : {},
723
- exposeAsMcp: a.exposeAsMcp,
724
- capabilities: a.capabilities.map((capability) => ({ ...capability })),
725
- forwardsToken: Boolean(a.forwardUserTokenAs),
726
- enabled: a.enabled && config.agent.tools.allow.some((entry) => entry === `api__${a.name}` || entry.startsWith(`api__${a.name}__`)),
727
- status: a.capabilities.length ? "ready" : "needs_attention"
728
- }))
729
- ],
730
- locked: record.locked ?? false,
731
- ...record.repositoryInstall ? { repositoryInstall: record.repositoryInstall } : {},
732
- theme: config.theme,
733
- features: config.features,
734
- createdAt: record.createdAt,
735
- updatedAt: record.updatedAt
736
- };
737
- }
738
- var FileTenantStore = class _FileTenantStore {
739
- dir;
740
- providerKeyEnv;
741
- env;
742
- index = /* @__PURE__ */ new Map();
743
- // by publishable key
744
- byId = /* @__PURE__ */ new Map();
745
- members = /* @__PURE__ */ new Map();
746
- // by `${org}\n${login}`
747
- loaded = false;
748
- static memberKey(org, login) {
749
- return `${org}
750
- ${login.toLowerCase()}`;
751
- }
752
- constructor(options) {
753
- this.dir = resolve(options.dir);
754
- this.providerKeyEnv = options.providerKeyEnv ?? "FIREWORKS_API_KEY";
755
- this.env = options.env ?? process.env;
756
- }
757
- async load() {
758
- if (this.loaded) return;
759
- await mkdir(this.dir, { recursive: true });
760
- const files = await readdir(this.dir).catch(() => []);
761
- for (const file of files) {
762
- if (!file.endsWith(".json")) continue;
763
- if (file === MEMBERS_FILE) continue;
764
- try {
765
- const record = JSON.parse(await readFile2(join(this.dir, file), "utf8"));
766
- record.config = parseFrankieConfig(record.config);
767
- this.remember(record);
768
- } catch {
769
- }
770
- }
771
- try {
772
- const raw = await readFile2(join(this.dir, MEMBERS_FILE), "utf8");
773
- for (const member of JSON.parse(raw)) {
774
- this.members.set(_FileTenantStore.memberKey(member.org, member.login), member);
775
- }
776
- } catch {
777
- }
778
- this.loaded = true;
779
- }
780
- async persistMembers() {
781
- await mkdir(this.dir, { recursive: true });
782
- await writeFile(join(this.dir, MEMBERS_FILE), JSON.stringify([...this.members.values()], null, 2), "utf8");
783
- }
784
- async listMembers(org) {
785
- await this.load();
786
- return [...this.members.values()].filter((m) => m.org === org).sort((a, b) => a.login.localeCompare(b.login));
787
- }
788
- async putMember(member) {
789
- await this.load();
790
- this.members.set(_FileTenantStore.memberKey(member.org, member.login), { ...member, login: member.login.toLowerCase() });
791
- await this.persistMembers();
792
- }
793
- async deleteMember(org, login) {
794
- await this.load();
795
- if (this.members.delete(_FileTenantStore.memberKey(org, login))) await this.persistMembers();
796
- }
797
- async membershipsForLogin(login) {
798
- await this.load();
799
- const lower = login.toLowerCase();
800
- return [...this.members.values()].filter((m) => m.login === lower);
801
- }
802
- remember(record) {
803
- this.byId.set(record.id, record);
804
- this.index.set(record.config.auth.publishableKey, record);
805
- }
806
- async byPublishableKey(key) {
807
- await this.load();
808
- const record = this.index.get(key);
809
- if (!record) return null;
810
- const providerApiKey = this.env[record.config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];
811
- if (!providerApiKey) return null;
812
- return {
813
- config: record.config,
814
- secrets: {
815
- ...record.jwtSecret ? { jwtSecret: record.jwtSecret } : {},
816
- sessionSecret: record.sessionSecret,
817
- providerApiKey
818
- }
819
- };
820
- }
821
- /** All tenants, or — when an owner is given — only that owner's. */
822
- async list(ownerId) {
823
- await this.load();
824
- return [...this.byId.values()].filter((record) => ownerId === void 0 || record.ownerId === ownerId).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
825
- }
826
- async get(id) {
827
- await this.load();
828
- return this.byId.get(id) ?? null;
829
- }
830
- async put(record) {
831
- await this.load();
832
- const existing = this.index.get(record.config.auth.publishableKey);
833
- if (existing && existing.id !== record.id) {
834
- throw new Error(`Publishable key already belongs to tenant "${existing.id}".`);
835
- }
836
- this.remember(record);
837
- await mkdir(this.dir, { recursive: true });
838
- await writeFile(join(this.dir, `${record.id}.json`), JSON.stringify(record, null, 2), "utf8");
839
- }
840
- async compareAndSwap(previous, next) {
841
- await this.load();
842
- if (this.byId.get(previous.id) !== previous) return false;
843
- this.remember(next);
844
- await mkdir(this.dir, { recursive: true });
845
- await writeFile(join(this.dir, `${next.id}.json`), JSON.stringify(next, null, 2), "utf8");
846
- return true;
847
- }
848
- async delete(id) {
849
- await this.load();
850
- const record = this.byId.get(id);
851
- if (!record) return;
852
- this.byId.delete(id);
853
- this.index.delete(record.config.auth.publishableKey);
854
- await rm(join(this.dir, `${id}.json`), { force: true });
855
- }
856
- };
857
-
858
- // src/control/postgres-store.ts
859
- import { parseFrankieConfig as parseFrankieConfig2 } from "@frankieai/core";
860
- var PostgresTenantStore = class {
861
- snapshots = /* @__PURE__ */ new WeakMap();
862
- db;
863
- providerKeyEnv;
864
- env;
865
- ready;
866
- constructor(options) {
867
- this.db = options.db;
868
- this.providerKeyEnv = options.providerKeyEnv ?? "FIREWORKS_API_KEY";
869
- this.env = options.env ?? process.env;
870
- }
871
- /**
872
- * Create the table on first use, once. Lazy so the server can start listening
873
- * before the database is reachable — a failed connection then surfaces on a
874
- * request (logged, a 502) instead of crashing boot on Cloud Run.
875
- */
876
- ensureReady() {
877
- return this.ready ??= this.init();
878
- }
879
- /** Create the table if it isn't there yet. Safe to call repeatedly. */
880
- async init() {
881
- await this.db.query(
882
- `CREATE TABLE IF NOT EXISTS frankie_tenants (
883
- id text PRIMARY KEY,
884
- owner_id text,
885
- publishable_key text NOT NULL UNIQUE,
886
- record text NOT NULL,
887
- created_at text NOT NULL,
888
- updated_at text NOT NULL
889
- )`
890
- );
891
- await this.db.query(`CREATE INDEX IF NOT EXISTS frankie_tenants_owner ON frankie_tenants (owner_id)`);
892
- await this.db.query(
893
- `CREATE TABLE IF NOT EXISTS frankie_org_members (
894
- org text NOT NULL,
895
- login text NOT NULL,
896
- role text NOT NULL,
897
- invited_by text,
898
- created_at text NOT NULL,
899
- PRIMARY KEY (org, login)
900
- )`
901
- );
902
- await this.db.query(`CREATE INDEX IF NOT EXISTS frankie_org_members_login ON frankie_org_members (login)`);
903
- }
904
- memberRow(row) {
905
- return {
906
- org: String(row.org),
907
- login: String(row.login),
908
- role: row.role === "admin" ? "admin" : "member",
909
- ...row.invited_by ? { invitedBy: String(row.invited_by) } : {},
910
- createdAt: String(row.created_at)
911
- };
912
- }
913
- async listMembers(org) {
914
- await this.ensureReady();
915
- const { rows } = await this.db.query(
916
- `SELECT * FROM frankie_org_members WHERE org = $1 ORDER BY login ASC`,
917
- [org]
918
- );
919
- return rows.map((row) => this.memberRow(row));
920
- }
921
- async putMember(member) {
922
- await this.ensureReady();
923
- await this.db.query(
924
- `INSERT INTO frankie_org_members (org, login, role, invited_by, created_at)
925
- VALUES ($1, $2, $3, $4, $5)
926
- ON CONFLICT (org, login) DO UPDATE SET role = EXCLUDED.role`,
927
- [member.org, member.login.toLowerCase(), member.role, member.invitedBy ?? null, member.createdAt]
928
- );
929
- }
930
- async deleteMember(org, login) {
931
- await this.ensureReady();
932
- await this.db.query(`DELETE FROM frankie_org_members WHERE org = $1 AND login = $2`, [org, login.toLowerCase()]);
933
- }
934
- async membershipsForLogin(login) {
935
- await this.ensureReady();
936
- const { rows } = await this.db.query(`SELECT * FROM frankie_org_members WHERE login = $1`, [login.toLowerCase()]);
937
- return rows.map((row) => this.memberRow(row));
938
- }
939
- parse(row) {
940
- const record = JSON.parse(String(row.record));
941
- this.snapshots.set(record, String(row.record));
942
- record.config = parseFrankieConfig2(record.config);
943
- return record;
944
- }
945
- async byPublishableKey(key) {
946
- await this.ensureReady();
947
- const { rows } = await this.db.query(`SELECT record FROM frankie_tenants WHERE publishable_key = $1`, [key]);
948
- if (rows.length === 0 || !rows[0]) return null;
949
- const record = this.parse(rows[0]);
950
- const providerApiKey = this.env[record.config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];
951
- if (!providerApiKey) return null;
952
- return {
953
- config: record.config,
954
- secrets: {
955
- ...record.jwtSecret ? { jwtSecret: record.jwtSecret } : {},
956
- sessionSecret: record.sessionSecret,
957
- providerApiKey
958
- }
959
- };
960
- }
961
- async list(ownerId) {
962
- await this.ensureReady();
963
- const { rows } = await this.db.query(
964
- `SELECT record FROM frankie_tenants
965
- WHERE $1::text IS NULL OR owner_id = $1
966
- ORDER BY created_at ASC`,
967
- [ownerId ?? null]
968
- );
969
- return rows.map((row) => this.parse(row));
970
- }
971
- async get(id) {
972
- await this.ensureReady();
973
- const { rows } = await this.db.query(`SELECT record FROM frankie_tenants WHERE id = $1`, [id]);
974
- return rows.length && rows[0] ? this.parse(rows[0]) : null;
975
- }
976
- async put(record) {
977
- await this.ensureReady();
978
- const key = record.config.auth.publishableKey;
979
- const clash = await this.db.query(`SELECT id FROM frankie_tenants WHERE publishable_key = $1 AND id <> $2`, [
980
- key,
981
- record.id
982
- ]);
983
- if (clash.rows.length > 0) {
984
- throw new Error(`Publishable key already belongs to tenant "${String(clash.rows[0]?.id)}".`);
985
- }
986
- await this.db.query(
987
- `INSERT INTO frankie_tenants (id, owner_id, publishable_key, record, created_at, updated_at)
988
- VALUES ($1, $2, $3, $4, $5, $6)
989
- ON CONFLICT (id) DO UPDATE SET
990
- owner_id = EXCLUDED.owner_id,
991
- publishable_key = EXCLUDED.publishable_key,
992
- record = EXCLUDED.record,
993
- updated_at = EXCLUDED.updated_at`,
994
- [record.id, record.ownerId ?? null, key, JSON.stringify(record), record.createdAt, record.updatedAt]
995
- );
996
- }
997
- async delete(id) {
998
- await this.ensureReady();
999
- await this.db.query(`DELETE FROM frankie_tenants WHERE id = $1`, [id]);
1000
- }
1001
- async compareAndSwap(previous, next) {
1002
- await this.ensureReady();
1003
- const snapshot = this.snapshots.get(previous);
1004
- if (!snapshot || next.id !== previous.id || next.config.auth.publishableKey !== previous.config.auth.publishableKey) return false;
1005
- 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]);
1006
- return rows.length === 1;
1007
- }
1008
- };
1009
-
1010
- // src/sessions.ts
1011
- import { randomUUID } from "crypto";
1012
- var MemorySessionStore = class {
1013
- constructor(ttlMs = 6 * 60 * 60 * 1e3) {
1014
- this.ttlMs = ttlMs;
1015
- }
1016
- ttlMs;
1017
- sessions = /* @__PURE__ */ new Map();
1018
- async create(session) {
1019
- this.evictExpired();
1020
- this.sessions.set(session.id, session);
1021
- }
1022
- async get(id) {
1023
- const session = this.sessions.get(id);
1024
- if (!session) return void 0;
1025
- if (Date.now() - session.lastActiveAt > this.ttlMs) {
1026
- this.sessions.delete(id);
1027
- return void 0;
1028
- }
1029
- return session;
1030
- }
1031
- async listByUser(tenantId, userId) {
1032
- this.evictExpired();
1033
- return [...this.sessions.values()].filter((session) => session.tenantId === tenantId && session.user.id === userId).sort((a, b) => b.lastActiveAt - a.lastActiveAt);
1034
- }
1035
- async listByTenant(tenantId) {
1036
- this.evictExpired();
1037
- return [...this.sessions.values()].filter((session) => session.tenantId === tenantId).sort((a, b) => b.lastActiveAt - a.lastActiveAt);
1038
- }
1039
- async update(id, patch) {
1040
- const session = this.sessions.get(id);
1041
- if (!session) return;
1042
- this.sessions.set(id, { ...session, ...patch, lastActiveAt: Date.now() });
1043
- }
1044
- async delete(id) {
1045
- this.sessions.delete(id);
1046
- }
1047
- evictExpired() {
1048
- const cutoff = Date.now() - this.ttlMs;
1049
- for (const [id, session] of this.sessions) if (session.lastActiveAt < cutoff) this.sessions.delete(id);
1050
- }
1051
- };
1052
- function newSessionId() {
1053
- return randomUUID();
1054
- }
1055
-
1056
- // src/postgres-sessions.ts
1057
- var PostgresSessionStore = class {
1058
- db;
1059
- ttlMs;
1060
- ready;
1061
- constructor(options) {
1062
- this.db = options.db;
1063
- this.ttlMs = options.ttlMs ?? 90 * 24 * 60 * 60 * 1e3;
1064
- }
1065
- ensureReady() {
1066
- return this.ready ??= this.init();
1067
- }
1068
- async init() {
1069
- await this.db.query(
1070
- `CREATE TABLE IF NOT EXISTS frankie_sessions (
1071
- id text PRIMARY KEY,
1072
- data text NOT NULL,
1073
- last_active bigint NOT NULL
1074
- )`
1075
- );
1076
- }
1077
- async create(session) {
1078
- await this.ensureReady();
1079
- await this.db.query(
1080
- `INSERT INTO frankie_sessions (id, data, last_active) VALUES ($1, $2, $3)
1081
- ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, last_active = EXCLUDED.last_active`,
1082
- [session.id, JSON.stringify(session), session.lastActiveAt]
1083
- );
1084
- }
1085
- async get(id) {
1086
- await this.ensureReady();
1087
- const { rows } = await this.db.query(`SELECT data, last_active FROM frankie_sessions WHERE id = $1`, [id]);
1088
- if (rows.length === 0 || !rows[0]) return void 0;
1089
- if (Date.now() - Number(rows[0].last_active) > this.ttlMs) {
1090
- await this.delete(id);
1091
- return void 0;
1092
- }
1093
- return JSON.parse(String(rows[0].data));
1094
- }
1095
- async listByUser(tenantId, userId) {
1096
- await this.ensureReady();
1097
- const { rows } = await this.db.query(`SELECT data, last_active FROM frankie_sessions ORDER BY last_active DESC`);
1098
- const sessions = [];
1099
- for (const row of rows) {
1100
- if (Date.now() - Number(row.last_active) > this.ttlMs) continue;
1101
- const session = JSON.parse(String(row.data));
1102
- if (session.tenantId === tenantId && session.user.id === userId) sessions.push(session);
1103
- }
1104
- return sessions;
1105
- }
1106
- async listByTenant(tenantId) {
1107
- await this.ensureReady();
1108
- const { rows } = await this.db.query(`SELECT data, last_active FROM frankie_sessions ORDER BY last_active DESC`);
1109
- const sessions = [];
1110
- for (const row of rows) {
1111
- if (Date.now() - Number(row.last_active) > this.ttlMs) continue;
1112
- const session = JSON.parse(String(row.data));
1113
- if (session.tenantId === tenantId) sessions.push(session);
1114
- }
1115
- return sessions;
1116
- }
1117
- async update(id, patch) {
1118
- const existing = await this.get(id);
1119
- if (!existing) return;
1120
- const next = { ...existing, ...patch, lastActiveAt: Date.now() };
1121
- await this.db.query(`UPDATE frankie_sessions SET data = $2, last_active = $3 WHERE id = $1`, [
1122
- id,
1123
- JSON.stringify(next),
1124
- next.lastActiveAt
1125
- ]);
1126
- }
1127
- async delete(id) {
1128
- await this.ensureReady();
1129
- await this.db.query(`DELETE FROM frankie_sessions WHERE id = $1`, [id]);
1130
- }
1131
- };
1132
-
1133
- // src/control/resolve-store.ts
1134
- async function resolveStores(env = process.env) {
1135
- const url = env["DATABASE_URL"];
1136
- if (url) {
1137
- const { Pool } = await import("pg");
1138
- const pool = new Pool({
1139
- connectionString: url,
1140
- max: 4,
1141
- ...env["DATABASE_SSL"] === "disable" ? {} : { ssl: { rejectUnauthorized: false } }
1142
- });
1143
- const tenants = new PostgresTenantStore({ db: pool, env });
1144
- const sessions = new PostgresSessionStore({ db: pool });
1145
- return { tenants, sessions, describe: "postgres" };
1146
- }
1147
- const dir = env["FRANKIE_TENANTS_DIR"] ?? "./.frankie/tenants";
1148
- return { tenants: new FileTenantStore({ dir, env }), sessions: new MemorySessionStore(), describe: `files:${dir}` };
1149
- }
1150
- async function resolveTenantStore(env = process.env) {
1151
- const url = env["DATABASE_URL"];
1152
- if (url) {
1153
- const { Pool } = await import("pg");
1154
- const pool = new Pool({
1155
- connectionString: url,
1156
- // Managed Postgres (Neon, Supabase, Cloud SQL over the proxy) expects TLS;
1157
- // opt out only for a local database via DATABASE_SSL=disable.
1158
- ...env["DATABASE_SSL"] === "disable" ? {} : { ssl: { rejectUnauthorized: false } }
1159
- });
1160
- const store = new PostgresTenantStore({ db: pool, env });
1161
- return { store, describe: "postgres" };
1162
- }
1163
- const dir = env["FRANKIE_TENANTS_DIR"] ?? "./.frankie/tenants";
1164
- return { store: new FileTenantStore({ dir, env }), describe: `files:${dir}` };
1165
- }
1166
-
1167
- export {
1168
- connectorFetch,
1169
- MemoryRateLimiter,
1170
- ModelResolutionError,
1171
- resolveModel,
1172
- resolveModelChoice,
1173
- modelHasApi,
1174
- AuthError,
1175
- TokenService,
1176
- isOriginAllowed,
1177
- safeEqual,
1178
- anonymousUser,
1179
- buildForwardedHeaders,
1180
- MCP_TOOL_PREFIX,
1181
- connectMcpTools,
1182
- API_TOOL_PREFIX,
1183
- OpenApiError,
1184
- inspectApiCapabilities,
1185
- buildApiTools,
1186
- loadSpec,
1187
- matchPath,
1188
- toPublicTenant,
1189
- FileTenantStore,
1190
- PostgresTenantStore,
1191
- MemorySessionStore,
1192
- newSessionId,
1193
- PostgresSessionStore,
1194
- resolveStores,
1195
- resolveTenantStore
1196
- };
1197
- //# sourceMappingURL=chunk-R5XGMQR4.js.map