@frankieai/server 0.2.1 → 0.2.3

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.
@@ -0,0 +1,75 @@
1
+ // src/config.ts
2
+ import { readFile } from "fs/promises";
3
+ import { resolve } from "path";
4
+ import { parseFrankieConfig } from "@frankieai/core";
5
+ var ConfigError = class extends Error {
6
+ };
7
+ async function loadConfigFromFile(path) {
8
+ const target = resolve(path ?? process.env["FRANKIE_CONFIG"] ?? "frankie.config.json");
9
+ let raw;
10
+ try {
11
+ raw = await readFile(target, "utf8");
12
+ } catch {
13
+ throw new ConfigError(
14
+ `Could not read Frankie config at ${target}. Set FRANKIE_CONFIG to point at your frankie.config.json.`
15
+ );
16
+ }
17
+ try {
18
+ return parseFrankieConfig(applyEnvOverrides(JSON.parse(raw)));
19
+ } catch (error) {
20
+ throw new ConfigError(`Invalid Frankie config at ${target}: ${error.message}`);
21
+ }
22
+ }
23
+ function applyEnvOverrides(raw, env = process.env) {
24
+ const out = { ...raw };
25
+ const model = { ...isRecord(out["model"]) ? out["model"] : {} };
26
+ if (env["FRANKIE_MODEL_PROVIDER"]) model["provider"] = env["FRANKIE_MODEL_PROVIDER"];
27
+ if (env["FRANKIE_MODEL"]) model["model"] = env["FRANKIE_MODEL"];
28
+ if (env["FRANKIE_MODEL_BASE_URL"]) model["baseUrl"] = env["FRANKIE_MODEL_BASE_URL"];
29
+ if (Object.keys(model).length > 0) out["model"] = model;
30
+ const auth = { ...isRecord(out["auth"]) ? out["auth"] : {} };
31
+ if (env["FRANKIE_ALLOWED_ORIGINS"]) {
32
+ auth["allowedOrigins"] = env["FRANKIE_ALLOWED_ORIGINS"].split(",").map((o) => o.trim()).filter(Boolean);
33
+ }
34
+ if (env["FRANKIE_PUBLISHABLE_KEY"]) auth["publishableKey"] = env["FRANKIE_PUBLISHABLE_KEY"];
35
+ if (Object.keys(auth).length > 0) out["auth"] = auth;
36
+ return out;
37
+ }
38
+ function isRecord(value) {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+ function resolveSecrets(config, env = process.env) {
42
+ const missing = [];
43
+ const jwtSecret = env[config.auth.jwtSecretEnv];
44
+ if (!config.auth.jwksUrl && !jwtSecret) missing.push(config.auth.jwtSecretEnv);
45
+ const sessionSecret = env[config.auth.sessionSecretEnv];
46
+ if (!sessionSecret) missing.push(config.auth.sessionSecretEnv);
47
+ const providerApiKey = env[config.model.apiKeyEnv];
48
+ if (!providerApiKey) missing.push(config.model.apiKeyEnv);
49
+ if (missing.length) {
50
+ throw new ConfigError(`Missing required environment variables: ${missing.join(", ")}`);
51
+ }
52
+ if (sessionSecret.length < 32) {
53
+ throw new ConfigError(`${config.auth.sessionSecretEnv} must be at least 32 characters.`);
54
+ }
55
+ return {
56
+ config,
57
+ secrets: {
58
+ jwtSecret,
59
+ sessionSecret,
60
+ providerApiKey
61
+ }
62
+ };
63
+ }
64
+ async function loadResolvedConfig(path) {
65
+ return resolveSecrets(await loadConfigFromFile(path));
66
+ }
67
+
68
+ export {
69
+ ConfigError,
70
+ loadConfigFromFile,
71
+ applyEnvOverrides,
72
+ resolveSecrets,
73
+ loadResolvedConfig
74
+ };
75
+ //# sourceMappingURL=chunk-NF6IJVES.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/config.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { parseFrankieConfig, type FrankieConfig } from \"@frankieai/core\";\n\n/**\n * Secrets never live in the config file — the file names the env var that\n * holds each one. `ResolvedConfig` is the config plus the resolved values.\n */\nexport interface ResolvedConfig {\n config: FrankieConfig;\n secrets: {\n /** HS256 secret the host signs end-user tokens with. Absent when using JWKS. */\n jwtSecret?: string;\n /** Signs Frankie's own session tokens. */\n sessionSecret: string;\n /** Key for the model provider named in `model.provider`. */\n providerApiKey: string;\n };\n}\n\nexport class ConfigError extends Error {}\n\nexport async function loadConfigFromFile(path?: string): Promise<FrankieConfig> {\n const target = resolve(path ?? process.env[\"FRANKIE_CONFIG\"] ?? \"frankie.config.json\");\n let raw: string;\n try {\n raw = await readFile(target, \"utf8\");\n } catch {\n throw new ConfigError(\n `Could not read Frankie config at ${target}. Set FRANKIE_CONFIG to point at your frankie.config.json.`,\n );\n }\n try {\n return parseFrankieConfig(applyEnvOverrides(JSON.parse(raw) as Record<string, unknown>));\n } catch (error) {\n throw new ConfigError(`Invalid Frankie config at ${target}: ${(error as Error).message}`);\n }\n}\n\n/**\n * A handful of fields have to move with the deployment rather than the config\n * file — the model and the origin allowlist are what an operator most often\n * wants to flip without a redeploy.\n */\nexport function applyEnvOverrides(\n raw: Record<string, unknown>,\n env: NodeJS.ProcessEnv = process.env,\n): Record<string, unknown> {\n const out = { ...raw };\n\n const model = { ...(isRecord(out[\"model\"]) ? out[\"model\"] : {}) };\n if (env[\"FRANKIE_MODEL_PROVIDER\"]) model[\"provider\"] = env[\"FRANKIE_MODEL_PROVIDER\"];\n if (env[\"FRANKIE_MODEL\"]) model[\"model\"] = env[\"FRANKIE_MODEL\"];\n if (env[\"FRANKIE_MODEL_BASE_URL\"]) model[\"baseUrl\"] = env[\"FRANKIE_MODEL_BASE_URL\"];\n if (Object.keys(model).length > 0) out[\"model\"] = model;\n\n const auth = { ...(isRecord(out[\"auth\"]) ? out[\"auth\"] : {}) };\n if (env[\"FRANKIE_ALLOWED_ORIGINS\"]) {\n auth[\"allowedOrigins\"] = env[\"FRANKIE_ALLOWED_ORIGINS\"].split(\",\").map((o) => o.trim()).filter(Boolean);\n }\n if (env[\"FRANKIE_PUBLISHABLE_KEY\"]) auth[\"publishableKey\"] = env[\"FRANKIE_PUBLISHABLE_KEY\"];\n if (Object.keys(auth).length > 0) out[\"auth\"] = auth;\n\n return out;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nexport function resolveSecrets(config: FrankieConfig, env: NodeJS.ProcessEnv = process.env): ResolvedConfig {\n const missing: string[] = [];\n\n const jwtSecret = env[config.auth.jwtSecretEnv];\n if (!config.auth.jwksUrl && !jwtSecret) missing.push(config.auth.jwtSecretEnv);\n\n const sessionSecret = env[config.auth.sessionSecretEnv];\n if (!sessionSecret) missing.push(config.auth.sessionSecretEnv);\n\n const providerApiKey = env[config.model.apiKeyEnv];\n if (!providerApiKey) missing.push(config.model.apiKeyEnv);\n\n if (missing.length) {\n throw new ConfigError(`Missing required environment variables: ${missing.join(\", \")}`);\n }\n\n if (sessionSecret!.length < 32) {\n throw new ConfigError(`${config.auth.sessionSecretEnv} must be at least 32 characters.`);\n }\n\n return {\n config,\n secrets: {\n jwtSecret,\n sessionSecret: sessionSecret!,\n providerApiKey: providerApiKey!,\n },\n };\n}\n\nexport async function loadResolvedConfig(path?: string): Promise<ResolvedConfig> {\n return resolveSecrets(await loadConfigFromFile(path));\n}\n"],"mappings":";AAAA,SAAS,gBAAgB;AACzB,SAAS,eAAe;AACxB,SAAS,0BAA8C;AAkBhD,IAAM,cAAN,cAA0B,MAAM;AAAC;AAExC,eAAsB,mBAAmB,MAAuC;AAC9E,QAAM,SAAS,QAAQ,QAAQ,QAAQ,IAAI,gBAAgB,KAAK,qBAAqB;AACrF,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,SAAS,QAAQ,MAAM;AAAA,EACrC,QAAQ;AACN,UAAM,IAAI;AAAA,MACR,oCAAoC,MAAM;AAAA,IAC5C;AAAA,EACF;AACA,MAAI;AACF,WAAO,mBAAmB,kBAAkB,KAAK,MAAM,GAAG,CAA4B,CAAC;AAAA,EACzF,SAAS,OAAO;AACd,UAAM,IAAI,YAAY,6BAA6B,MAAM,KAAM,MAAgB,OAAO,EAAE;AAAA,EAC1F;AACF;AAOO,SAAS,kBACd,KACA,MAAyB,QAAQ,KACR;AACzB,QAAM,MAAM,EAAE,GAAG,IAAI;AAErB,QAAM,QAAQ,EAAE,GAAI,SAAS,IAAI,OAAO,CAAC,IAAI,IAAI,OAAO,IAAI,CAAC,EAAG;AAChE,MAAI,IAAI,wBAAwB,EAAG,OAAM,UAAU,IAAI,IAAI,wBAAwB;AACnF,MAAI,IAAI,eAAe,EAAG,OAAM,OAAO,IAAI,IAAI,eAAe;AAC9D,MAAI,IAAI,wBAAwB,EAAG,OAAM,SAAS,IAAI,IAAI,wBAAwB;AAClF,MAAI,OAAO,KAAK,KAAK,EAAE,SAAS,EAAG,KAAI,OAAO,IAAI;AAElD,QAAM,OAAO,EAAE,GAAI,SAAS,IAAI,MAAM,CAAC,IAAI,IAAI,MAAM,IAAI,CAAC,EAAG;AAC7D,MAAI,IAAI,yBAAyB,GAAG;AAClC,SAAK,gBAAgB,IAAI,IAAI,yBAAyB,EAAE,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO;AAAA,EACxG;AACA,MAAI,IAAI,yBAAyB,EAAG,MAAK,gBAAgB,IAAI,IAAI,yBAAyB;AAC1F,MAAI,OAAO,KAAK,IAAI,EAAE,SAAS,EAAG,KAAI,MAAM,IAAI;AAEhD,SAAO;AACT;AAEA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEO,SAAS,eAAe,QAAuB,MAAyB,QAAQ,KAAqB;AAC1G,QAAM,UAAoB,CAAC;AAE3B,QAAM,YAAY,IAAI,OAAO,KAAK,YAAY;AAC9C,MAAI,CAAC,OAAO,KAAK,WAAW,CAAC,UAAW,SAAQ,KAAK,OAAO,KAAK,YAAY;AAE7E,QAAM,gBAAgB,IAAI,OAAO,KAAK,gBAAgB;AACtD,MAAI,CAAC,cAAe,SAAQ,KAAK,OAAO,KAAK,gBAAgB;AAE7D,QAAM,iBAAiB,IAAI,OAAO,MAAM,SAAS;AACjD,MAAI,CAAC,eAAgB,SAAQ,KAAK,OAAO,MAAM,SAAS;AAExD,MAAI,QAAQ,QAAQ;AAClB,UAAM,IAAI,YAAY,2CAA2C,QAAQ,KAAK,IAAI,CAAC,EAAE;AAAA,EACvF;AAEA,MAAI,cAAe,SAAS,IAAI;AAC9B,UAAM,IAAI,YAAY,GAAG,OAAO,KAAK,gBAAgB,kCAAkC;AAAA,EACzF;AAEA,SAAO;AAAA,IACL;AAAA,IACA,SAAS;AAAA,MACP;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;AAEA,eAAsB,mBAAmB,MAAwC;AAC/E,SAAO,eAAe,MAAM,mBAAmB,IAAI,CAAC;AACtD;","names":[]}
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  createControlApp
4
- } from "./chunk-UZSHFMJN.js";
4
+ } from "./chunk-37VMCQCI.js";
5
5
  import {
6
+ createFrankieApp,
6
7
  resolveStores
7
- } from "./chunk-WFENEYOV.js";
8
+ } from "./chunk-MKN5663J.js";
8
9
 
9
10
  // src/control-server.ts
10
11
  import { serve } from "@hono/node-server";
12
+ import { Hono } from "hono";
11
13
  async function main() {
12
14
  const port = Number(process.env["PORT"] ?? process.env["CONTROL_PORT"] ?? 8081);
13
15
  const host = process.env["HOST"] ?? "0.0.0.0";
@@ -16,7 +18,9 @@ async function main() {
16
18
  process.exit(1);
17
19
  }
18
20
  const { tenants: store, sessions, describe } = await resolveStores();
19
- const app = createControlApp({ store, sessions });
21
+ const app = new Hono();
22
+ app.route("/", createControlApp({ store, sessions }));
23
+ app.route("/", createFrankieApp({ tenants: store, sessions }));
20
24
  serve({ fetch: app.fetch, port, hostname: host }, () => {
21
25
  console.log(
22
26
  JSON.stringify({
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/control-server.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * The control plane, as its own process: the admin API the dashboard and the\n * setup CLI talk to. It writes tenants into FRANKIE_TENANTS_DIR — the same\n * directory the data-plane server reads — so what you create here is served\n * there with no further wiring.\n *\n * FRANKIE_ADMIN_TOKEN=… FRANKIE_TENANTS_DIR=./.frankie/tenants \\\n * pnpm --filter @frankieai/server start:control\n */\nimport { serve } from \"@hono/node-server\";\nimport { createControlApp } from \"./control/app.js\";\nimport { resolveStores } from \"./control/resolve-store.js\";\n\nasync function main(): Promise<void> {\n const port = Number(process.env[\"PORT\"] ?? process.env[\"CONTROL_PORT\"] ?? 8081);\n const host = process.env[\"HOST\"] ?? \"0.0.0.0\";\n\n if (!process.env[\"FRANKIE_ADMIN_TOKEN\"]) {\n console.error(\"[frankie] Set FRANKIE_ADMIN_TOKEN to run the control plane.\");\n process.exit(1);\n }\n\n const { tenants: store, sessions, describe } = await resolveStores();\n const app = createControlApp({ store, sessions });\n\n serve({ fetch: app.fetch, port, hostname: host }, () => {\n console.log(\n JSON.stringify({\n ts: new Date().toISOString(),\n level: \"info\",\n event: \"control_listening\",\n store: describe,\n url: `http://${host}:${port}`,\n }),\n );\n });\n}\n\nvoid main();\n"],"mappings":";;;;;;;;;AAUA,SAAS,aAAa;AAItB,eAAe,OAAsB;AACnC,QAAM,OAAO,OAAO,QAAQ,IAAI,MAAM,KAAK,QAAQ,IAAI,cAAc,KAAK,IAAI;AAC9E,QAAM,OAAO,QAAQ,IAAI,MAAM,KAAK;AAEpC,MAAI,CAAC,QAAQ,IAAI,qBAAqB,GAAG;AACvC,YAAQ,MAAM,6DAA6D;AAC3E,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,QAAM,EAAE,SAAS,OAAO,UAAU,SAAS,IAAI,MAAM,cAAc;AACnE,QAAM,MAAM,iBAAiB,EAAE,OAAO,SAAS,CAAC;AAEhD,QAAM,EAAE,OAAO,IAAI,OAAO,MAAM,UAAU,KAAK,GAAG,MAAM;AACtD,YAAQ;AAAA,MACN,KAAK,UAAU;AAAA,QACb,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,QAC3B,OAAO;AAAA,QACP,OAAO;AAAA,QACP,OAAO;AAAA,QACP,KAAK,UAAU,IAAI,IAAI,IAAI;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK;","names":[]}
1
+ {"version":3,"sources":["../src/control-server.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * The control plane, as its own process: the admin API the dashboard and the\n * setup CLI talk to. It writes tenants into FRANKIE_TENANTS_DIR — the same\n * directory the data-plane server reads — so what you create here is served\n * there with no further wiring.\n *\n * FRANKIE_ADMIN_TOKEN=… FRANKIE_TENANTS_DIR=./.frankie/tenants \\\n * pnpm --filter @frankieai/server start:control\n */\nimport { serve } from \"@hono/node-server\";\nimport { Hono } from \"hono\";\nimport { createFrankieApp } from \"./app.js\";\nimport { createControlApp } from \"./control/app.js\";\nimport { resolveStores } from \"./control/resolve-store.js\";\n\nasync function main(): Promise<void> {\n const port = Number(process.env[\"PORT\"] ?? process.env[\"CONTROL_PORT\"] ?? 8081);\n const host = process.env[\"HOST\"] ?? \"0.0.0.0\";\n\n if (!process.env[\"FRANKIE_ADMIN_TOKEN\"]) {\n console.error(\"[frankie] Set FRANKIE_ADMIN_TOKEN to run the control plane.\");\n process.exit(1);\n }\n\n const { tenants: store, sessions, describe } = await resolveStores();\n const app = new Hono();\n app.route(\"/\", createControlApp({ store, sessions }));\n app.route(\"/\", createFrankieApp({ tenants: store, sessions }));\n\n serve({ fetch: app.fetch, port, hostname: host }, () => {\n console.log(\n JSON.stringify({\n ts: new Date().toISOString(),\n level: \"info\",\n event: \"control_listening\",\n store: describe,\n url: `http://${host}:${port}`,\n }),\n );\n });\n}\n\nvoid main();\n"],"mappings":";;;;;;;;;;AAUA,SAAS,aAAa;AACtB,SAAS,YAAY;AAKrB,eAAe,OAAsB;AACnC,QAAM,OAAO,OAAO,QAAQ,IAAI,MAAM,KAAK,QAAQ,IAAI,cAAc,KAAK,IAAI;AAC9E,QAAM,OAAO,QAAQ,IAAI,MAAM,KAAK;AAEpC,MAAI,CAAC,QAAQ,IAAI,qBAAqB,GAAG;AACvC,YAAQ,MAAM,6DAA6D;AAC3E,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,QAAM,EAAE,SAAS,OAAO,UAAU,SAAS,IAAI,MAAM,cAAc;AACnE,QAAM,MAAM,IAAI,KAAK;AACrB,MAAI,MAAM,KAAK,iBAAiB,EAAE,OAAO,SAAS,CAAC,CAAC;AACpD,MAAI,MAAM,KAAK,iBAAiB,EAAE,SAAS,OAAO,SAAS,CAAC,CAAC;AAE7D,QAAM,EAAE,OAAO,IAAI,OAAO,MAAM,UAAU,KAAK,GAAG,MAAM;AACtD,YAAQ;AAAA,MACN,KAAK,UAAU;AAAA,QACb,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,QAC3B,OAAO;AAAA,QACP,OAAO;AAAA,QACP,OAAO;AAAA,QACP,KAAK,UAAU,IAAI,IAAI,IAAI;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK;","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
- import { FrankieConfig, AuthConfig, WorkspaceSkill, ChatAttachment, FrankieServerEvent, McpServerConfig, ToolPolicy, ApiSourceConfig } from '@frankieai/core';
2
+ import { FrankieConfig, AuthConfig, WorkspaceSkill, HostToolRequest, HostToolResult, ChatAttachment, FrankieServerEvent, McpServerConfig, ToolPolicy, ApiToolInventory, ApiSourceConfig } from '@frankieai/core';
3
+ export { matchPath } from '@frankieai/core';
3
4
  import { AgentMessage, AgentTool } from '@earendil-works/pi-agent-core';
4
5
  import { JWTPayload } from 'jose';
5
6
  import { Models, Model, Api } from '@earendil-works/pi-ai';
@@ -138,6 +139,12 @@ interface SessionStore {
138
139
  listByUser(tenantId: string, userId: string): Promise<ChatSession[]>;
139
140
  /** Workspace-owner history view. This is only exposed through the authenticated control plane. */
140
141
  listByTenant(tenantId: string): Promise<ChatSession[]>;
142
+ /**
143
+ * Non-expired conversation count per tenant id, in one pass. Used by the
144
+ * workspace directory to show a workspace as "Live" once it has real chats —
145
+ * cheaper than a listByTenant call per workspace.
146
+ */
147
+ countsByTenant(): Promise<Record<string, number>>;
141
148
  update(id: string, patch: Partial<ChatSession>): Promise<void>;
142
149
  delete(id: string): Promise<void>;
143
150
  }
@@ -149,6 +156,7 @@ declare class MemorySessionStore implements SessionStore {
149
156
  get(id: string): Promise<ChatSession | undefined>;
150
157
  listByUser(tenantId: string, userId: string): Promise<ChatSession[]>;
151
158
  listByTenant(tenantId: string): Promise<ChatSession[]>;
159
+ countsByTenant(): Promise<Record<string, number>>;
152
160
  update(id: string, patch: Partial<ChatSession>): Promise<void>;
153
161
  delete(id: string): Promise<void>;
154
162
  private evictExpired;
@@ -285,8 +293,35 @@ interface RepositoryInstallRecord {
285
293
  pullRequestUrl?: string;
286
294
  requiresJwtSecret: boolean;
287
295
  failureMessage?: string;
296
+ /**
297
+ * Last-known rich readiness state, persisted from live GitHub inspection so the
298
+ * dashboard renders the current step instantly on reload and can fall back to
299
+ * it when GitHub is briefly unreachable (a stale token no longer wipes progress).
300
+ */
301
+ step?: string;
302
+ pullRequestState?: "draft" | "open" | "closed" | "merged";
303
+ checkRollup?: "success" | "pending" | "failure" | "none";
304
+ checkedAt?: string;
288
305
  updatedAt: string;
289
306
  }
307
+ /**
308
+ * A Vercel integration installed for this workspace. The access token is a
309
+ * secret like `jwtSecret`: stored here, never returned by the public shape.
310
+ */
311
+ interface VercelLinkRecord {
312
+ /** Token minted by the integration's OAuth exchange. Integration tokens don't expire. */
313
+ accessToken: string;
314
+ /** Vercel's id for this installation (`configurationId`), for support and uninstall. */
315
+ installationId: string;
316
+ /** Set when the integration was installed on a team rather than a personal account. */
317
+ teamId?: string;
318
+ /** The Vercel project linked to this workspace's repository, once matched. */
319
+ projectId?: string;
320
+ projectName?: string;
321
+ connectedAt: string;
322
+ }
323
+ /** `VercelLinkRecord` with the token stripped — what the dashboard sees. */
324
+ type PublicVercelLink = Omit<VercelLinkRecord, "accessToken">;
290
325
  interface TenantRecord {
291
326
  apiSpecSync?: Record<string, {
292
327
  repository: string;
@@ -294,6 +329,11 @@ interface TenantRecord {
294
329
  commitSha: string;
295
330
  syncedAt: string;
296
331
  }>;
332
+ /** Last live connectivity/auth check per API source, so the dashboard doesn't need to re-run it on every load. */
333
+ apiConnectivityChecks?: Record<string, {
334
+ checkedAt: string;
335
+ ok: boolean;
336
+ }>;
297
337
  skillHistory?: FrankieConfig["skills"];
298
338
  id: string;
299
339
  /** Who owns this tenant — the dashboard user's stable id (e.g. "github:42"). */
@@ -305,12 +345,15 @@ interface TenantRecord {
305
345
  locked?: boolean;
306
346
  /** Durable repository-install state surfaced in the dashboard and setup chat. */
307
347
  repositoryInstall?: RepositoryInstallRecord;
348
+ /** Deployment provider connection, when the workspace has connected Vercel. */
349
+ vercel?: VercelLinkRecord;
308
350
  createdAt: string;
309
351
  updatedAt: string;
310
352
  }
311
353
  /** A tenant record with its secrets stripped — safe to return from the API. */
312
354
  interface PublicTenant {
313
355
  apiSpecSync?: TenantRecord["apiSpecSync"];
356
+ apiConnectivityChecks?: TenantRecord["apiConnectivityChecks"];
314
357
  skillHistory: FrankieConfig["skills"];
315
358
  skills: TenantRecord["config"]["skills"];
316
359
  id: string;
@@ -319,6 +362,8 @@ interface PublicTenant {
319
362
  publishableKey: string;
320
363
  allowedOrigins: string[];
321
364
  allowAnonymous: boolean;
365
+ /** How the hosted MCP treats a caller's bearer: verify it as a JWT, or forward it opaquely. */
366
+ userCredential: "jwt" | "opaque";
322
367
  connection: "knowledge" | "mcp" | "openapi";
323
368
  mcpServers: string[];
324
369
  apis: string[];
@@ -326,6 +371,8 @@ interface PublicTenant {
326
371
  connections: Array<{
327
372
  kind: "mcp" | "openapi";
328
373
  name: string;
374
+ /** Friendly label for the dashboard; `name` remains the stable identifier. */
375
+ displayName?: string;
329
376
  /** OpenAPI spec URL/path. */
330
377
  spec?: string;
331
378
  /** MCP server URL. */
@@ -337,6 +384,8 @@ interface PublicTenant {
337
384
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
338
385
  path: string;
339
386
  summary?: string;
387
+ /** True for a write operation whose only auth is a static/shared credential, not the calling user's own identity. */
388
+ authGap: boolean;
340
389
  }>;
341
390
  discoveredTools?: Array<{
342
391
  name: string;
@@ -344,7 +393,13 @@ interface PublicTenant {
344
393
  }>;
345
394
  selectedTools?: string[];
346
395
  exposeAsMcp?: boolean;
396
+ /** Where the source's operations run: "server" (Frankie's server) or "client" (the user's browser). */
397
+ execution?: "server" | "client";
347
398
  forwardsToken: boolean;
399
+ /** The header the user's credential is forwarded to your API as (empty when none). */
400
+ forwardTokenAs?: string;
401
+ /** Security scheme names with a credential configured (OpenAPI only). Never the values themselves. */
402
+ securedSchemes?: string[];
348
403
  /** Whether this source is currently enabled for chat. */
349
404
  enabled: boolean;
350
405
  /** Configuration health, not a simulated runtime probe. */
@@ -354,6 +409,8 @@ interface PublicTenant {
354
409
  locked: boolean;
355
410
  /** A submitted or in-progress SDK installation for this workspace. */
356
411
  repositoryInstall?: RepositoryInstallRecord;
412
+ /** The connected Vercel installation and matched project — never its token. */
413
+ vercel?: PublicVercelLink;
357
414
  /** The customer's brand theme for the widget, so the dashboard can edit it. */
358
415
  theme: TenantRecord["config"]["theme"];
359
416
  /** Opt-in chat capabilities (attachments, model picker) for the dashboard to edit. */
@@ -559,13 +616,28 @@ declare class PostgresSessionStore implements SessionStore {
559
616
  get(id: string): Promise<ChatSession | undefined>;
560
617
  listByUser(tenantId: string, userId: string): Promise<ChatSession[]>;
561
618
  listByTenant(tenantId: string): Promise<ChatSession[]>;
619
+ countsByTenant(): Promise<Record<string, number>>;
562
620
  update(id: string, patch: Partial<ChatSession>): Promise<void>;
563
621
  delete(id: string): Promise<void>;
564
622
  }
565
623
 
566
624
  interface SkillGenerationInput {
567
625
  goal: string;
626
+ /** Approved tool identifiers this skill may use. Validated against on return. */
568
627
  tools: string[];
628
+ /**
629
+ * What each tool in `tools` actually does (method/path/summary, or an MCP
630
+ * tool's own description) — see `toolDescriptions()` in @frankieai/core.
631
+ * Without this the model has nothing to write concrete steps from but a
632
+ * bare identifier like `api__product__listItems`. Optional so older
633
+ * callers (and tests) that only have bare ids still work; the resulting
634
+ * draft is just less specific.
635
+ */
636
+ toolDetails?: Array<{
637
+ id: string;
638
+ description: string;
639
+ }>;
640
+ /** Present when refining an existing skill rather than starting fresh. */
569
641
  instructions?: string;
570
642
  }
571
643
  declare function generateSkill(resolved: ResolvedConfig, input: SkillGenerationInput): Promise<WorkspaceSkill>;
@@ -579,6 +651,16 @@ interface ControlOptions {
579
651
  sessions?: SessionStore;
580
652
  /** Bearer token that gates the admin API. A stand-in for real user auth. */
581
653
  adminToken?: string;
654
+ /**
655
+ * Optional shared secret for signed owner assertions. When set (here or via
656
+ * FRANKIE_CONTROL_ASSERTION_SECRET), the acting owner must be proven by a
657
+ * short-lived JWT the dashboard signs with the same secret — a plaintext
658
+ * `x-frankie-owner` header alone is refused. This closes the gap where the
659
+ * admin token by itself could select an arbitrary owner. Off by default, so
660
+ * existing deployments and unscoped admin-token callers are unaffected until
661
+ * the secret is configured on both the dashboard and the control plane.
662
+ */
663
+ ownerAssertionSecret?: string;
582
664
  logger?: (event: Record<string, unknown>) => void;
583
665
  }
584
666
  /**
@@ -591,6 +673,22 @@ interface ControlOptions {
591
673
  */
592
674
  declare function createControlApp(options: ControlOptions): Hono;
593
675
 
676
+ /**
677
+ * The per-turn handle an API tool uses to run itself in the user's browser
678
+ * instead of on Frankie's server. `available` says whether a live widget
679
+ * channel is bound for this session — false over the hosted MCP endpoint or any
680
+ * headless caller, where a browser-scoped call is impossible.
681
+ */
682
+ interface ClientToolBridge {
683
+ readonly available: boolean;
684
+ /**
685
+ * Ask the browser to perform one HTTP call and resolve with its outcome.
686
+ * Rejects if no channel is bound, the turn is aborted, or the browser does
687
+ * not answer before `timeoutMs`.
688
+ */
689
+ execute(name: string, request: HostToolRequest, signal?: AbortSignal): Promise<HostToolResult>;
690
+ }
691
+
594
692
  interface RunTurnParams {
595
693
  config: FrankieConfig;
596
694
  session: ChatSession;
@@ -604,6 +702,8 @@ interface RunTurnParams {
604
702
  modelOverride?: Model<Api>;
605
703
  /** Files attached to this turn, already validated against the feature flags. */
606
704
  attachments?: ChatAttachment[];
705
+ /** Browser channel for API sources marked `execution: "client"`, when a widget is connected. */
706
+ clientBridge?: ClientToolBridge;
607
707
  signal: AbortSignal;
608
708
  /** Called with the full transcript once the turn settles, to persist it. */
609
709
  onTranscript?: (messages: AgentMessage[]) => void;
@@ -656,6 +756,13 @@ interface BuildApiToolsOptions {
656
756
  session: ChatSession;
657
757
  policy: ToolPolicy;
658
758
  fetchImpl?: typeof fetch;
759
+ /**
760
+ * Bridge to the user's browser for sources marked `execution: "client"`. When
761
+ * absent (headless callers, the hosted MCP endpoint), a client-mode operation
762
+ * has no way to run and reports that clearly instead of falling back to a
763
+ * server-side call the user's session would not authorize.
764
+ */
765
+ clientBridge?: ClientToolBridge;
659
766
  onWarning?: (message: string, detail: Record<string, unknown>) => void;
660
767
  }
661
768
  interface ApiToolSet {
@@ -664,6 +771,8 @@ interface ApiToolSet {
664
771
  name: string;
665
772
  error: string;
666
773
  }>;
774
+ /** One entry per source that built successfully, for chat-facing visibility. */
775
+ apis: ApiToolInventory[];
667
776
  }
668
777
  /**
669
778
  * Turn a tenant's OpenAPI document into agent tools.
@@ -680,14 +789,10 @@ interface OpenApiDoc {
680
789
  }>;
681
790
  paths?: Record<string, unknown>;
682
791
  components?: Record<string, unknown>;
792
+ /** Document-wide default security requirement; each operation may override it. */
793
+ security?: unknown[];
683
794
  }
684
795
  declare function loadSpec(spec: string, fetchImpl?: typeof fetch): Promise<OpenApiDoc>;
685
- /**
686
- * `/projects/*` matches one path segment; `/projects/**` matches any depth and
687
- * the base path itself, since a tenant writing that plainly means "this part of
688
- * the API" rather than "everything under it but not it".
689
- */
690
- declare function matchPath(glob: string, path: string): boolean;
691
796
 
692
797
  interface CollectToolsOptions {
693
798
  config: FrankieConfig;
@@ -695,6 +800,8 @@ interface CollectToolsOptions {
695
800
  fetchImpl?: typeof fetch;
696
801
  /** Base URL of the account API for the built-in workspaceAdmin tools. */
697
802
  accountApiUrl?: string;
803
+ /** Browser channel for API sources marked `execution: "client"`. */
804
+ clientBridge?: ClientToolBridge;
698
805
  onWarning?: (message: string, detail: Record<string, unknown>) => void;
699
806
  }
700
807
  interface CollectedTools {
@@ -704,6 +811,8 @@ interface CollectedTools {
704
811
  name: string;
705
812
  error: string;
706
813
  }>;
814
+ /** OpenAPI sources that built successfully this turn, for chat-facing visibility. */
815
+ apis: ApiToolInventory[];
707
816
  close(): Promise<void>;
708
817
  }
709
818
  /**
@@ -743,4 +852,4 @@ declare function buildForwardedHeaders(source: ForwardingConfig, session: ChatSe
743
852
  */
744
853
  declare function buildSystemPrompt(config: FrankieConfig, session: ChatSession, availableTools?: string[]): string;
745
854
 
746
- export { API_TOOL_PREFIX, type ApiToolSet, AuthError, type BuildApiToolsOptions, type ChatSession, type CollectToolsOptions, type CollectedTools, ConfigError, type ConnectMcpOptions, type ControlOptions, type ControlStore, type CreateAppOptions, FileTenantStore, type ForwardingConfig, MCP_TOOL_PREFIX, type McpConnection, type McpHostOptions, MemoryRateLimiter, MemorySessionStore, MemoryTenantStore, ModelResolutionError, OpenApiError, PostgresSessionStore, PostgresTenantStore, type PublicTenant, type Queryable, type RateLimiter, type ResolvedConfig, type ResolvedModel, type ResolvedStore, type ResolvedStores, type RunTurnParams, type SessionStore, SingleTenantStore, type TenantRecord, TenantRegistry, type TenantRuntime, type TenantStore, TokenService, type VerifiedUser, anonymousUser, applyEnvOverrides, buildApiTools, buildForwardedHeaders, buildSystemPrompt, collectTools, connectMcpTools, createControlApp, createFrankieApp, createMcpHostApp, isOriginAllowed, loadConfigFromFile, loadResolvedConfig, loadSpec, matchPath, modelHasApi, newSessionId, resolveModel, resolveSecrets, resolveStores, resolveTenantStore, runTurn, safeEqual, toPublicTenant };
855
+ export { API_TOOL_PREFIX, type ApiToolSet, AuthError, type BuildApiToolsOptions, type ChatSession, type CollectToolsOptions, type CollectedTools, ConfigError, type ConnectMcpOptions, type ControlOptions, type ControlStore, type CreateAppOptions, FileTenantStore, type ForwardingConfig, MCP_TOOL_PREFIX, type McpConnection, type McpHostOptions, MemoryRateLimiter, MemorySessionStore, MemoryTenantStore, ModelResolutionError, OpenApiError, PostgresSessionStore, PostgresTenantStore, type PublicTenant, type Queryable, type RateLimiter, type ResolvedConfig, type ResolvedModel, type ResolvedStore, type ResolvedStores, type RunTurnParams, type SessionStore, SingleTenantStore, type TenantRecord, TenantRegistry, type TenantRuntime, type TenantStore, TokenService, type VerifiedUser, anonymousUser, applyEnvOverrides, buildApiTools, buildForwardedHeaders, buildSystemPrompt, collectTools, connectMcpTools, createControlApp, createFrankieApp, createMcpHostApp, isOriginAllowed, loadConfigFromFile, loadResolvedConfig, loadSpec, modelHasApi, newSessionId, resolveModel, resolveSecrets, resolveStores, resolveTenantStore, runTurn, safeEqual, toPublicTenant };
package/dist/index.js CHANGED
@@ -1,21 +1,13 @@
1
1
  import {
2
2
  createControlApp
3
- } from "./chunk-UZSHFMJN.js";
3
+ } from "./chunk-37VMCQCI.js";
4
4
  import {
5
5
  ConfigError,
6
- MemoryTenantStore,
7
- SingleTenantStore,
8
- TenantRegistry,
9
6
  applyEnvOverrides,
10
- buildSystemPrompt,
11
- collectTools,
12
- createFrankieApp,
13
- createMcpHostApp,
14
7
  loadConfigFromFile,
15
8
  loadResolvedConfig,
16
- resolveSecrets,
17
- runTurn
18
- } from "./chunk-XCIM765D.js";
9
+ resolveSecrets
10
+ } from "./chunk-NF6IJVES.js";
19
11
  import {
20
12
  API_TOOL_PREFIX,
21
13
  AuthError,
@@ -23,15 +15,22 @@ import {
23
15
  MCP_TOOL_PREFIX,
24
16
  MemoryRateLimiter,
25
17
  MemorySessionStore,
18
+ MemoryTenantStore,
26
19
  ModelResolutionError,
27
20
  OpenApiError,
28
21
  PostgresSessionStore,
29
22
  PostgresTenantStore,
23
+ SingleTenantStore,
24
+ TenantRegistry,
30
25
  TokenService,
31
26
  anonymousUser,
32
27
  buildApiTools,
33
28
  buildForwardedHeaders,
29
+ buildSystemPrompt,
30
+ collectTools,
34
31
  connectMcpTools,
32
+ createFrankieApp,
33
+ createMcpHostApp,
35
34
  isOriginAllowed,
36
35
  loadSpec,
37
36
  matchPath,
@@ -40,9 +39,10 @@ import {
40
39
  resolveModel,
41
40
  resolveStores,
42
41
  resolveTenantStore,
42
+ runTurn,
43
43
  safeEqual,
44
44
  toPublicTenant
45
- } from "./chunk-WFENEYOV.js";
45
+ } from "./chunk-MKN5663J.js";
46
46
  export {
47
47
  API_TOOL_PREFIX,
48
48
  AuthError,
package/dist/server.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ConfigError,
4
- createFrankieApp,
5
4
  loadResolvedConfig
6
- } from "./chunk-XCIM765D.js";
5
+ } from "./chunk-NF6IJVES.js";
7
6
  import {
8
7
  ModelResolutionError,
8
+ createFrankieApp,
9
9
  resolveStores
10
- } from "./chunk-WFENEYOV.js";
10
+ } from "./chunk-MKN5663J.js";
11
11
 
12
12
  // src/server.ts
13
13
  import { serve } from "@hono/node-server";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frankieai/server",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Frankie cloud agent server — auth, sessions, MCP wiring and streaming for the embedded onboarding agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "yaml": "^2.9.0",
29
29
  "zod": "^4.0.0",
30
30
  "pg": "^8.13.0",
31
- "@frankieai/core": "0.2.1"
31
+ "@frankieai/core": "0.2.3"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "^22.10.0",
@@ -39,6 +39,9 @@
39
39
  "pg-mem": "^3.0.5"
40
40
  },
41
41
  "module": "./dist/index.js",
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
42
45
  "scripts": {
43
46
  "build": "tsup",
44
47
  "dev": "tsup --watch",