@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.
@@ -1,110 +1,87 @@
1
- // src/auth.ts
2
- import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose";
3
- var AuthError = class extends Error {
4
- constructor(message, code = "unauthorized") {
5
- super(message);
6
- this.code = code;
7
- }
8
- code;
9
- };
10
- var TokenService = class {
11
- constructor(auth, secrets) {
12
- this.auth = auth;
13
- this.sessionKey = new TextEncoder().encode(secrets.sessionSecret);
14
- this.userKey = secrets.jwtSecret ? new TextEncoder().encode(secrets.jwtSecret) : void 0;
15
- if (!this.userKey && !auth.jwksUrl && !auth.allowAnonymous) {
16
- throw new Error("Frankie auth needs either a JWT secret, a JWKS URL, or allowAnonymous.");
17
- }
18
- }
19
- auth;
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
- const subject = typeof payload.sub === "string" ? payload.sub : void 0;
45
- if (!subject) throw new AuthError("The user token has no `sub` claim.");
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
- async verifySessionToken(token) {
66
- try {
67
- const { payload } = await jwtVerify(token, this.sessionKey, {
68
- algorithms: ["HS256"],
69
- issuer: "frankie",
70
- audience: "frankie-widget"
71
- });
72
- const sessionId = stringClaim(payload, "sid");
73
- const userId = stringClaim(payload, "uid");
74
- const tenantId = stringClaim(payload, "tid");
75
- if (!sessionId || !userId || !tenantId) throw new AuthError("Malformed session token.");
76
- return { sessionId, userId, tenantId };
77
- } catch (error) {
78
- if (error instanceof AuthError) throw error;
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
- function stringClaim(payload, key) {
84
- const value = payload[key];
85
- return typeof value === "string" ? value : void 0;
86
- }
87
- function isOriginAllowed(origin, allowed) {
88
- if (allowed.length === 0) return origin === void 0;
89
- if (!origin) return false;
90
- return allowed.some((entry) => normaliseOrigin(entry) === normaliseOrigin(origin));
91
- }
92
- function normaliseOrigin(value) {
93
- try {
94
- const url = new URL(value);
95
- return url.origin.toLowerCase();
96
- } catch {
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 safeEqual(a, b) {
101
- if (a.length !== b.length) return false;
102
- let diff = 0;
103
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
104
- return diff === 0;
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 anonymousUser(seed) {
107
- return { id: `anon_${seed}`, anonymous: true, claims: {} };
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/mcp.ts
129
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
130
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
131
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
132
- import { Type } from "@earendil-works/pi-ai";
133
- import { describeTool, evaluateToolPolicy } from "@frankieai/core";
134
- var MCP_TOOL_PREFIX = "mcp__";
135
- async function connectMcpTools(options) {
136
- const clients = [];
137
- const tools = [];
138
- const failed = [];
139
- const results = await Promise.all(
140
- options.servers.filter((server) => server.enabled).map(async (server) => {
141
- try {
142
- return { server, client: await connectOne(server, options) };
143
- } catch (error) {
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 normaliseSchema(schema) {
216
- if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
217
- return { type: "object", properties: {} };
218
- }
219
- const copy = { ...schema };
220
- if (copy["type"] !== "object") copy["type"] = "object";
221
- if (typeof copy["properties"] !== "object" || copy["properties"] === null) copy["properties"] = {};
222
- return copy;
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 toAgentContent(content) {
225
- if (!Array.isArray(content)) return [{ type: "text", text: "" }];
226
- const out = [];
227
- for (const part of content) {
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
- function textOf(content) {
243
- return content.map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n").trim();
244
- }
245
- async function withTimeout(promise, ms, message) {
246
- let timer;
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
- return await Promise.race([
249
- promise,
250
- new Promise((_resolve, reject) => {
251
- timer = setTimeout(() => reject(new Error(message)), ms);
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
- if (timer) clearTimeout(timer);
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 { readFile } from "fs/promises";
261
- import { Type as Type2 } from "@earendil-works/pi-ai";
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 && !evaluateToolPolicy2(toolName, options.policy).allowed) continue;
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 ?? fetch;
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 (!evaluateToolPolicy2(name, options.policy).allowed) continue;
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: describeTool2(name, {}),
272
+ label: describeTool(name, {}),
332
273
  description: describeOperation(operation, method, path),
333
- parameters: Type2.Unsafe(toolSchema(params, body)),
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 ?? fetch)(spec, {
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 collectParameters(doc, item, operation) {
390
- const raw = [...asArray(item["parameters"]), ...asArray(operation["parameters"])];
391
- const params = [];
392
- for (const entry of raw) {
393
- const resolved = resolveRef(doc, entry);
394
- if (!isRecord(resolved)) continue;
395
- const name = resolved["name"];
396
- const location = resolved["in"];
397
- if (typeof name !== "string") continue;
398
- if (location !== "path" && location !== "query") continue;
399
- const schema = resolveRef(doc, resolved["schema"]);
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
- function toolSchema(params, body) {
421
- const properties = {};
422
- const required = [];
423
- for (const param of params) {
424
- properties[param.name] = param.description ? { ...param.schema, description: param.description } : param.schema;
425
- if (param.required) required.push(param.name);
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
- if (body) {
428
- properties["body"] = body;
429
- required.push("body");
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
- return {
432
- type: "object",
433
- properties,
434
- ...required.length ? { required } : {},
435
- additionalProperties: false
436
- };
437
- }
438
- function buildUrl(baseUrl, path, params, input) {
439
- let resolved = path;
440
- for (const param of params) {
441
- if (param.location !== "path") continue;
442
- const value = input[param.name];
443
- resolved = resolved.replace(`{${param.name}}`, encodeURIComponent(String(value ?? "")));
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
- const url = new URL(`${baseUrl}${resolved.startsWith("/") ? "" : "/"}${resolved}`);
446
- for (const param of params) {
447
- if (param.location !== "query") continue;
448
- const value = input[param.name];
449
- if (value === void 0 || value === null) continue;
450
- if (Array.isArray(value)) {
451
- for (const entry of value) url.searchParams.append(param.name, String(entry));
452
- } else {
453
- url.searchParams.set(param.name, String(value));
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
- return url.toString();
658
+ };
659
+ function stringClaim(payload, key) {
660
+ const value = payload[key];
661
+ return typeof value === "string" ? value : void 0;
457
662
  }
458
- function describeOperation(operation, method, path) {
459
- const summary = typeof operation["summary"] === "string" ? operation["summary"] : "";
460
- const description = typeof operation["description"] === "string" ? operation["description"] : "";
461
- const text = [summary, description].filter(Boolean).join(" - ");
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 resolveRef(doc, node, depth = 0) {
465
- if (!isRecord(node) || depth > 10) return node;
466
- const ref = node["$ref"];
467
- if (typeof ref !== "string" || !ref.startsWith("#/")) return node;
468
- let current = doc;
469
- for (const segment of ref.slice(2).split("/")) {
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 asArray(value) {
493
- return Array.isArray(value) ? value : [];
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 isRecord(value) {
496
- return typeof value === "object" && value !== null && !Array.isArray(value);
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 randomUUID();
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
- AuthError,
963
- TokenService,
964
- isOriginAllowed,
965
- safeEqual,
966
- anonymousUser,
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-K75HPI2W.js.map
1266
+ //# sourceMappingURL=chunk-WFENEYOV.js.map