@frankieai/server 0.2.0 → 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.
@@ -84,115 +84,6 @@ function modelHasApi(model, api) {
84
84
  return hasApi(model, api);
85
85
  }
86
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
87
  // src/agent/headers.ts
197
88
  function buildForwardedHeaders(source, session) {
198
89
  const headers = { ...source.headers };
@@ -211,8 +102,8 @@ function buildForwardedHeaders(source, session) {
211
102
  return headers;
212
103
  }
213
104
 
214
- // src/agent/mcp.ts
215
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
105
+ // src/agent/openapi.ts
106
+ import { readFile } from "fs/promises";
216
107
 
217
108
  // src/connector-fetch.ts
218
109
  import { lookup } from "dns/promises";
@@ -307,145 +198,9 @@ async function readLimited(stream, max) {
307
198
  var trustedLocalConnectors = () => process.env["FRANKIE_TRUST_LOCAL_CONNECTORS"] === "true";
308
199
  var connectorFetch = (input, init) => trustedLocalConnectors() ? fetch(input, init) : publicConnectorFetch(input, init);
309
200
 
310
- // src/agent/mcp.ts
311
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
312
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
201
+ // src/agent/openapi.ts
313
202
  import { Type } from "@earendil-works/pi-ai";
314
203
  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
204
  import { parse as parseYaml } from "yaml";
450
205
  var API_TOOL_PREFIX = "api__";
451
206
  var OpenApiError = class extends Error {
@@ -465,7 +220,7 @@ async function inspectApiCapabilities(source, options = {}) {
465
220
  const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `${method}_${slug(path)}`;
466
221
  if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
467
222
  const toolName = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
468
- if (options.policy && !evaluateToolPolicy2(toolName, options.policy).allowed) continue;
223
+ if (options.policy && !evaluateToolPolicy(toolName, options.policy).allowed) continue;
469
224
  const summary = typeof operation["summary"] === "string" ? operation["summary"] : void 0;
470
225
  capabilities.push({ operationId, method: httpMethod, path, ...summary ? { summary } : {} });
471
226
  }
@@ -509,14 +264,14 @@ async function buildOne(source, options) {
509
264
  if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
510
265
  if (source.selectedTools && !source.selectedTools.includes(operationId)) continue;
511
266
  const name = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
512
- if (!evaluateToolPolicy2(name, options.policy).allowed) continue;
267
+ if (!evaluateToolPolicy(name, options.policy).allowed) continue;
513
268
  const params = collectParameters(doc, item, operation);
514
269
  const body = collectRequestBody(doc, operation);
515
270
  tools.push({
516
271
  name,
517
- label: describeTool2(name, {}),
272
+ label: describeTool(name, {}),
518
273
  description: describeOperation(operation, method, path),
519
- parameters: Type2.Unsafe(toolSchema(params, body)),
274
+ parameters: Type.Unsafe(toolSchema(params, body)),
520
275
  async execute(_id, args, signal) {
521
276
  const input = isRecord(args) ? args : {};
522
277
  const url = buildUrl(baseUrl, path, params, input);
@@ -616,75 +371,321 @@ function toolSchema(params, body) {
616
371
  required.push("body");
617
372
  }
618
373
  return {
619
- type: "object",
620
- properties,
621
- ...required.length ? { required } : {},
622
- additionalProperties: false
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
+ }
623
531
  };
624
532
  }
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 ?? "")));
533
+ function normaliseSchema(schema) {
534
+ if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
535
+ return { type: "object", properties: {} };
631
536
  }
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));
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 });
641
556
  }
642
557
  }
643
- return url.toString();
558
+ return out.length > 0 ? out : [{ type: "text", text: "" }];
644
559
  }
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}`;
560
+ function textOf(content) {
561
+ return content.map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n").trim();
650
562
  }
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, "~")];
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);
659
574
  }
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
575
  }
669
- function sanitize(value) {
670
- return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48);
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;
583
+ }
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
+ }
594
+ }
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
+ };
629
+ }
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.");
656
+ }
657
+ }
658
+ };
659
+ function stringClaim(payload, key) {
660
+ const value = payload[key];
661
+ return typeof value === "string" ? value : void 0;
671
662
  }
672
- function slug(path) {
673
- return path.replace(/[{}]/g, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_|_$/g, "");
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));
674
667
  }
675
- function truncate(value, max) {
676
- return value.length <= max ? value : `${value.slice(0, max)}
677
- [truncated]`;
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();
674
+ }
678
675
  }
679
- function asArray(value) {
680
- 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;
681
681
  }
682
- function isRecord(value) {
683
- return typeof value === "object" && value !== null && !Array.isArray(value);
682
+ function anonymousUser(seed) {
683
+ return { id: `anon_${seed}`, anonymous: true, claims: {} };
684
684
  }
685
685
 
686
686
  // src/control/store.ts
687
- 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";
688
689
  import { join, resolve } from "path";
689
690
  import { parseFrankieConfig } from "@frankieai/core";
690
691
  var MEMBERS_FILE = "__members.json";
@@ -692,6 +693,7 @@ function toPublicTenant(record) {
692
693
  const { config } = record;
693
694
  return {
694
695
  skillHistory: record.skillHistory ?? [],
696
+ ...record.apiSpecSync ? { apiSpecSync: record.apiSpecSync } : {},
695
697
  skills: config.skills,
696
698
  id: record.id,
697
699
  ...record.ownerId ? { ownerId: record.ownerId } : {},
@@ -736,6 +738,40 @@ function toPublicTenant(record) {
736
738
  };
737
739
  }
738
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
+ }
739
775
  dir;
740
776
  providerKeyEnv;
741
777
  env;
@@ -878,6 +914,9 @@ var PostgresTenantStore = class {
878
914
  }
879
915
  /** Create the table if it isn't there yet. Safe to call repeatedly. */
880
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
+ )`);
881
920
  await this.db.query(
882
921
  `CREATE TABLE IF NOT EXISTS frankie_tenants (
883
922
  id text PRIMARY KEY,
@@ -910,6 +949,36 @@ var PostgresTenantStore = class {
910
949
  createdAt: String(row.created_at)
911
950
  };
912
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
+ }
913
982
  async listMembers(org) {
914
983
  await this.ensureReady();
915
984
  const { rows } = await this.db.query(
@@ -1008,7 +1077,7 @@ var PostgresTenantStore = class {
1008
1077
  };
1009
1078
 
1010
1079
  // src/sessions.ts
1011
- import { randomUUID } from "crypto";
1080
+ import { randomUUID as randomUUID2 } from "crypto";
1012
1081
  var MemorySessionStore = class {
1013
1082
  constructor(ttlMs = 6 * 60 * 60 * 1e3) {
1014
1083
  this.ttlMs = ttlMs;
@@ -1050,7 +1119,7 @@ var MemorySessionStore = class {
1050
1119
  }
1051
1120
  };
1052
1121
  function newSessionId() {
1053
- return randomUUID();
1122
+ return randomUUID2();
1054
1123
  }
1055
1124
 
1056
1125
  // src/postgres-sessions.ts
@@ -1171,20 +1240,20 @@ export {
1171
1240
  resolveModel,
1172
1241
  resolveModelChoice,
1173
1242
  modelHasApi,
1174
- AuthError,
1175
- TokenService,
1176
- isOriginAllowed,
1177
- safeEqual,
1178
- anonymousUser,
1179
1243
  buildForwardedHeaders,
1180
- MCP_TOOL_PREFIX,
1181
- connectMcpTools,
1182
1244
  API_TOOL_PREFIX,
1183
1245
  OpenApiError,
1184
1246
  inspectApiCapabilities,
1185
1247
  buildApiTools,
1186
1248
  loadSpec,
1187
1249
  matchPath,
1250
+ MCP_TOOL_PREFIX,
1251
+ connectMcpTools,
1252
+ AuthError,
1253
+ TokenService,
1254
+ isOriginAllowed,
1255
+ safeEqual,
1256
+ anonymousUser,
1188
1257
  toPublicTenant,
1189
1258
  FileTenantStore,
1190
1259
  PostgresTenantStore,
@@ -1194,4 +1263,4 @@ export {
1194
1263
  resolveStores,
1195
1264
  resolveTenantStore
1196
1265
  };
1197
- //# sourceMappingURL=chunk-R5XGMQR4.js.map
1266
+ //# sourceMappingURL=chunk-WFENEYOV.js.map