@frankieai/server 0.1.0

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 @@
1
+ {"version":3,"sources":["../src/control/app.ts"],"sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport { Hono } from \"hono\";\nimport type { Context } from \"hono\";\nimport { parseFrankieConfig } from \"@frankieai/core\";\nimport { safeEqual } from \"../auth.js\";\nimport { toPublicTenant, type ControlStore, type TenantRecord } from \"./store.js\";\n\nexport interface ControlOptions {\n store: ControlStore;\n /** Bearer token that gates the admin API. A stand-in for real user auth. */\n adminToken?: string;\n logger?: (event: Record<string, unknown>) => void;\n}\n\n/**\n * The control plane: create a tenant, hand back its keys, and manage how its\n * product connects — bring an MCP, point at an OpenAPI spec, or stay\n * knowledge-only. The dashboard and the setup CLI are thin clients over this.\n *\n * Admin auth here is a single shared token — deliberately the simplest thing\n * that is not \"nothing\", and the seam where real per-user accounts go.\n */\nexport function createControlApp(options: ControlOptions): Hono {\n const { store } = options;\n const adminToken = options.adminToken ?? process.env[\"FRANKIE_ADMIN_TOKEN\"];\n const log = options.logger ?? ((event) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...event })));\n\n const app = new Hono();\n\n app.use(\"/admin/*\", async (c, next) => {\n if (!adminToken) {\n return c.json({ error: \"Control plane is locked: set FRANKIE_ADMIN_TOKEN.\" }, 503);\n }\n const bearer = c.req.header(\"authorization\")?.replace(/^Bearer\\s+/i, \"\") ?? \"\";\n if (!safeEqual(bearer, adminToken)) {\n return c.json({ error: \"Unauthorized.\" }, 401);\n }\n await next();\n return;\n });\n\n /* ------------------------------ create ------------------------------ */\n\n /** The dashboard identifies the acting user with this header (e.g. \"github:42\"). */\n const ownerOf = (c: Context): string | undefined => c.req.header(\"x-frankie-owner\") || undefined;\n\n app.post(\"/admin/tenants\", async (c) => {\n const body = (await readJson<CreateTenantBody>(c)) ?? {};\n if (!body.name?.trim()) return c.json({ error: \"`name` is required.\" }, 400);\n const ownerId = ownerOf(c);\n\n // Ids are globally unique across owners, so two customers can both have an\n // \"Acme\" without colliding: take the clean slug, or suffix it if taken.\n let id = slug(body.name);\n if (await store.get(id)) id = `${id}-${randomBytes(3).toString(\"hex\")}`;\n\n const publishableKey = `pk_live_${randomBytes(12).toString(\"hex\")}`;\n const jwtSecret = randomBytes(36).toString(\"base64\");\n const sessionSecret = randomBytes(36).toString(\"base64\");\n\n let config;\n try {\n config = parseFrankieConfig({\n tenantId: id,\n branding: { name: body.name },\n agent: {\n ...(body.goal ? { systemPrompt: body.goal } : {}),\n ...(body.greeting ? { greeting: body.greeting } : {}),\n },\n auth: {\n publishableKey,\n allowedOrigins: body.allowedOrigins ?? [],\n ...(body.allowAnonymous ? { allowAnonymous: true } : {}),\n },\n });\n } catch (error) {\n return c.json({ error: `Invalid tenant: ${(error as Error).message}` }, 400);\n }\n\n const now = new Date().toISOString();\n const record: TenantRecord = {\n id,\n ...(ownerId ? { ownerId } : {}),\n config,\n jwtSecret,\n sessionSecret,\n createdAt: now,\n updatedAt: now,\n };\n await store.put(record);\n log({ level: \"info\", event: \"tenant_created\", tenant: id, ...(ownerId ? { owner: ownerId } : {}) });\n\n // The JWT secret is shown exactly once: the customer's backend signs user\n // tokens with it, and Frankie never needs to reveal it again.\n return c.json(\n {\n tenant: toPublicTenant(record),\n secrets: { publishableKey, jwtSecret },\n next: \"Set FRANKIE_JWT_SECRET to the jwtSecret on your backend, then run `frankie init`.\",\n },\n 201,\n );\n });\n\n /* ------------------------------- read ------------------------------- */\n\n // A tenant not owned by the caller is reported as absent, never as forbidden,\n // so one owner can't probe another's tenant ids.\n const own = async (c: Context): Promise<TenantRecord | null> => {\n const id = c.req.param(\"id\");\n const record = id ? await store.get(id) : null;\n if (!record) return null;\n const owner = ownerOf(c);\n if (owner && record.ownerId && record.ownerId !== owner) return null;\n return record;\n };\n\n app.get(\"/admin/tenants\", async (c) => {\n return c.json({ tenants: (await store.list(ownerOf(c))).map(toPublicTenant) });\n });\n\n app.get(\"/admin/tenants/:id\", async (c) => {\n const record = await own(c);\n if (!record) return c.json({ error: \"No such tenant.\" }, 404);\n return c.json({ tenant: toPublicTenant(record) });\n });\n\n /* ---------------------------- connection ---------------------------- */\n\n /** Set how the tenant's product connects: MCP servers, an OpenAPI source, or neither. */\n app.put(\"/admin/tenants/:id/connection\", async (c) => {\n const record = await own(c);\n if (!record) return c.json({ error: \"No such tenant.\" }, 404);\n\n const body = (await readJson<ConnectionBody>(c)) ?? {};\n let config;\n try {\n config = parseFrankieConfig({\n ...record.config,\n mcpServers: body.mcpServers ?? [],\n apis: body.apis ?? [],\n });\n } catch (error) {\n return c.json({ error: `Invalid connection: ${(error as Error).message}` }, 400);\n }\n\n const updated: TenantRecord = { ...record, config, updatedAt: new Date().toISOString() };\n await store.put(updated);\n log({ level: \"info\", event: \"tenant_connection_updated\", tenant: record.id });\n return c.json({ tenant: toPublicTenant(updated) });\n });\n\n /** Set the sites allowed to open a chat for this tenant. */\n app.put(\"/admin/tenants/:id/origins\", async (c) => {\n const record = await own(c);\n if (!record) return c.json({ error: \"No such tenant.\" }, 404);\n\n const body = (await readJson<{ allowedOrigins?: string[] }>(c)) ?? {};\n let config;\n try {\n config = parseFrankieConfig({\n ...record.config,\n auth: { ...record.config.auth, allowedOrigins: body.allowedOrigins ?? [] },\n });\n } catch (error) {\n return c.json({ error: `Invalid origins: ${(error as Error).message}` }, 400);\n }\n\n const updated: TenantRecord = { ...record, config, updatedAt: new Date().toISOString() };\n await store.put(updated);\n return c.json({ tenant: toPublicTenant(updated) });\n });\n\n // Surface the real cause of an unhandled failure (usually a database\n // connection error). The admin API is token-gated, so returning the message\n // is safe and makes the dashboard show what actually went wrong.\n app.onError((error, c) => {\n const message = error instanceof Error ? error.message : \"Internal error.\";\n log({ level: \"error\", event: \"control_error\", message });\n return c.json({ error: `Control plane error: ${message}` }, 500);\n });\n\n return app;\n}\n\n/* -------------------------------- types --------------------------------- */\n\ninterface CreateTenantBody {\n name?: string;\n allowedOrigins?: string[];\n goal?: string;\n greeting?: string;\n /** Let visitors chat without a host-signed token — for public onboarding pages. */\n allowAnonymous?: boolean;\n}\n\ninterface ConnectionBody {\n mcpServers?: unknown[];\n apis?: unknown[];\n}\n\n/* -------------------------------- helpers -------------------------------- */\n\nasync function readJson<T>(c: Context): Promise<T | null> {\n try {\n return (await c.req.json()) as T;\n } catch {\n return null;\n }\n}\n\nexport function slug(value: string): string {\n return (\n value\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\")\n .slice(0, 40) || \"tenant\"\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ createControlApp
4
+ } from "./chunk-ZY4JZVCI.js";
5
+ import {
6
+ resolveTenantStore
7
+ } from "./chunk-ACXNDAOM.js";
8
+
9
+ // src/control-server.ts
10
+ import { serve } from "@hono/node-server";
11
+ async function main() {
12
+ const port = Number(process.env["PORT"] ?? process.env["CONTROL_PORT"] ?? 8081);
13
+ const host = process.env["HOST"] ?? "0.0.0.0";
14
+ if (!process.env["FRANKIE_ADMIN_TOKEN"]) {
15
+ console.error("[frankie] Set FRANKIE_ADMIN_TOKEN to run the control plane.");
16
+ process.exit(1);
17
+ }
18
+ const { store, describe } = await resolveTenantStore();
19
+ const app = createControlApp({ store });
20
+ serve({ fetch: app.fetch, port, hostname: host }, () => {
21
+ console.log(
22
+ JSON.stringify({
23
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
24
+ level: "info",
25
+ event: "control_listening",
26
+ store: describe,
27
+ url: `http://${host}:${port}`
28
+ })
29
+ );
30
+ });
31
+ }
32
+ void main();
33
+ //# sourceMappingURL=control-server.js.map
@@ -0,0 +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 { resolveTenantStore } 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 { store, describe } = await resolveTenantStore();\n const app = createControlApp({ store });\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,OAAO,SAAS,IAAI,MAAM,mBAAmB;AACrD,QAAM,MAAM,iBAAiB,EAAE,MAAM,CAAC;AAEtC,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":[]}
@@ -0,0 +1,591 @@
1
+ import { Hono } from 'hono';
2
+ import { FrankieConfig, AuthConfig, FrankieServerEvent, McpServerConfig, ToolPolicy, ApiSourceConfig } from '@frankieai/core';
3
+ import { AgentMessage, AgentTool } from '@earendil-works/pi-agent-core';
4
+ import { JWTPayload } from 'jose';
5
+ import { Models, Model, Api } from '@earendil-works/pi-ai';
6
+
7
+ /**
8
+ * Secrets never live in the config file — the file names the env var that
9
+ * holds each one. `ResolvedConfig` is the config plus the resolved values.
10
+ */
11
+ interface ResolvedConfig {
12
+ config: FrankieConfig;
13
+ secrets: {
14
+ /** HS256 secret the host signs end-user tokens with. Absent when using JWKS. */
15
+ jwtSecret?: string;
16
+ /** Signs Frankie's own session tokens. */
17
+ sessionSecret: string;
18
+ /** Key for the model provider named in `model.provider`. */
19
+ providerApiKey: string;
20
+ };
21
+ }
22
+ declare class ConfigError extends Error {
23
+ }
24
+ declare function loadConfigFromFile(path?: string): Promise<FrankieConfig>;
25
+ /**
26
+ * A handful of fields have to move with the deployment rather than the config
27
+ * file — the model and the origin allowlist are what an operator most often
28
+ * wants to flip without a redeploy.
29
+ */
30
+ declare function applyEnvOverrides(raw: Record<string, unknown>, env?: NodeJS.ProcessEnv): Record<string, unknown>;
31
+ declare function resolveSecrets(config: FrankieConfig, env?: NodeJS.ProcessEnv): ResolvedConfig;
32
+ declare function loadResolvedConfig(path?: string): Promise<ResolvedConfig>;
33
+
34
+ /**
35
+ * Sliding-window counter. In-memory by default; swap in Redis for multi-instance
36
+ * deployments by implementing `RateLimiter`.
37
+ */
38
+ interface RateLimiter {
39
+ /** Returns false when the caller is over budget. */
40
+ take(key: string, limit: number, windowSeconds: number): Promise<boolean>;
41
+ }
42
+ declare class MemoryRateLimiter implements RateLimiter {
43
+ private readonly hits;
44
+ private lastSweep;
45
+ take(key: string, limit: number, windowSeconds: number): Promise<boolean>;
46
+ /** Drop empty buckets occasionally so a long-lived process does not grow. */
47
+ private sweep;
48
+ }
49
+
50
+ interface VerifiedUser {
51
+ id: string;
52
+ displayName?: string;
53
+ email?: string;
54
+ anonymous: boolean;
55
+ /** Every claim from the host's token, for MCP forwarding and prompt context. */
56
+ claims: JWTPayload;
57
+ }
58
+ declare class AuthError extends Error {
59
+ readonly code: "unauthorized" | "forbidden_origin";
60
+ constructor(message: string, code?: "unauthorized" | "forbidden_origin");
61
+ }
62
+ /**
63
+ * Verifies the JWT the customer's backend mints for a signed-in end user, and
64
+ * issues/verifies Frankie's own short-lived session tokens.
65
+ */
66
+ declare class TokenService {
67
+ private readonly auth;
68
+ private readonly sessionKey;
69
+ private readonly userKey?;
70
+ private jwks?;
71
+ constructor(auth: AuthConfig, secrets: {
72
+ sessionSecret: string;
73
+ jwtSecret?: string;
74
+ });
75
+ /** Verify a host-signed user token. Throws `AuthError` on any failure. */
76
+ verifyUserToken(token: string): Promise<VerifiedUser>;
77
+ /** Mint the bearer token the widget uses for the rest of the conversation. */
78
+ issueSessionToken(input: {
79
+ sessionId: string;
80
+ user: VerifiedUser;
81
+ tenantId: string;
82
+ }): Promise<{
83
+ token: string;
84
+ expiresAt: Date;
85
+ }>;
86
+ verifySessionToken(token: string): Promise<{
87
+ sessionId: string;
88
+ userId: string;
89
+ tenantId: string;
90
+ }>;
91
+ }
92
+ /**
93
+ * Exact-origin allowlist. Wildcards are deliberately unsupported: the widget
94
+ * runs on a page the customer controls, so the list is short and known.
95
+ * An empty list means "same-origin only", which is what a server mounted
96
+ * inside the host app wants.
97
+ */
98
+ declare function isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean;
99
+ /** Constant-time string comparison for the publishable key check. */
100
+ declare function safeEqual(a: string, b: string): boolean;
101
+ /** Stable pseudonymous id for an anonymous visitor. */
102
+ declare function anonymousUser(seed: string): VerifiedUser;
103
+
104
+ interface ChatSession {
105
+ id: string;
106
+ tenantId: string;
107
+ user: VerifiedUser;
108
+ /**
109
+ * The raw host-signed token, kept only so it can be forwarded to MCP servers
110
+ * the tenant has explicitly opted into. Never written to disk or logs.
111
+ */
112
+ userToken?: string;
113
+ /** Page context the host passed at session creation. */
114
+ context: Record<string, string | number | boolean | null>;
115
+ /**
116
+ * The conversation transcript. Frankie owns it outright — the agent loop is
117
+ * stateless between turns and is handed this array each time — which is what
118
+ * makes a session portable to a shared store, and exportable.
119
+ */
120
+ messages: AgentMessage[];
121
+ createdAt: number;
122
+ lastActiveAt: number;
123
+ messageCount: number;
124
+ costUsd: number;
125
+ /** Set while a turn is in flight, so a second concurrent turn is rejected. */
126
+ busy: boolean;
127
+ }
128
+ interface SessionStore {
129
+ create(session: ChatSession): Promise<void>;
130
+ get(id: string): Promise<ChatSession | undefined>;
131
+ update(id: string, patch: Partial<ChatSession>): Promise<void>;
132
+ delete(id: string): Promise<void>;
133
+ }
134
+ declare class MemorySessionStore implements SessionStore {
135
+ private readonly ttlMs;
136
+ private readonly sessions;
137
+ constructor(ttlMs?: number);
138
+ create(session: ChatSession): Promise<void>;
139
+ get(id: string): Promise<ChatSession | undefined>;
140
+ update(id: string, patch: Partial<ChatSession>): Promise<void>;
141
+ delete(id: string): Promise<void>;
142
+ private evictExpired;
143
+ }
144
+ declare function newSessionId(): string;
145
+
146
+ declare class ModelResolutionError extends Error {
147
+ }
148
+ interface ResolvedModel {
149
+ models: Models;
150
+ model: Model<Api>;
151
+ }
152
+ /**
153
+ * Resolve the tenant's configured model against the provider catalogue.
154
+ *
155
+ * The catalogue already knows which wire API each model speaks — Kimi K2.6 and
156
+ * K2.7 on Fireworks answer the Anthropic Messages API, K3 the OpenAI
157
+ * completions API — so nothing here has to care, and swapping providers stays a
158
+ * config change.
159
+ */
160
+ declare function resolveModel(config: FrankieConfig): ResolvedModel;
161
+ /** Narrow a resolved model to a specific wire API, for callers that need it. */
162
+ declare function modelHasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi>;
163
+
164
+ /**
165
+ * Everything a request handler needs to serve one tenant, built once and
166
+ * cached. The single-tenant server and the multi-tenant SaaS both run the same
167
+ * handlers; the only difference is where the `TenantRuntime` comes from.
168
+ */
169
+ interface TenantRuntime {
170
+ config: FrankieConfig;
171
+ secrets: ResolvedConfig["secrets"];
172
+ tokens: TokenService;
173
+ models: ResolvedModel["models"];
174
+ model: ResolvedModel["model"];
175
+ }
176
+ /**
177
+ * Where per-tenant config comes from. One process serves many tenants by
178
+ * looking each one up by its publishable key at request time.
179
+ *
180
+ * The in-memory and single-tenant stores below are enough to run and test the
181
+ * data plane. A hosted deployment implements this against its own control
182
+ * plane — a database row per tenant, secrets from a vault — and nothing in the
183
+ * request path changes.
184
+ */
185
+ interface TenantStore {
186
+ /** The resolved config for a publishable key, or null if no tenant owns it. */
187
+ byPublishableKey(key: string): Promise<ResolvedConfig | null>;
188
+ }
189
+ /**
190
+ * Turns a `TenantStore` into ready-to-serve `TenantRuntime`s, caching each
191
+ * tenant's `TokenService` and resolved model so the model catalogue is walked
192
+ * once per tenant rather than once per request.
193
+ */
194
+ declare class TenantRegistry {
195
+ private readonly store;
196
+ private readonly cache;
197
+ constructor(store: TenantStore);
198
+ /**
199
+ * A registry over exactly one tenant. Builds the runtime eagerly so an
200
+ * invalid provider, model or secret fails at startup — the same contract the
201
+ * single-tenant server has always had — rather than on the first message.
202
+ */
203
+ static single(resolved: ResolvedConfig): TenantRegistry;
204
+ private build;
205
+ /**
206
+ * Resolve a publishable key to a tenant runtime, or null if the key is
207
+ * unknown. Building can throw (an invalid model in a tenant's config); the
208
+ * caller turns that into a 502 for that tenant alone, never a crash for the
209
+ * whole fleet.
210
+ */
211
+ resolve(publishableKey: string): Promise<TenantRuntime | null>;
212
+ /** Drop a cached runtime so the next request rebuilds it from the store. */
213
+ evict(publishableKey: string): void;
214
+ }
215
+ /** A store over a single resolved config — the self-hosted, one-tenant case. */
216
+ declare class SingleTenantStore implements TenantStore {
217
+ private readonly resolved;
218
+ constructor(resolved: ResolvedConfig);
219
+ byPublishableKey(key: string): Promise<ResolvedConfig | null>;
220
+ }
221
+ /**
222
+ * An in-memory store indexed by publishable key. Good for tests and small
223
+ * fixed fleets; a real SaaS swaps this for its database without touching the
224
+ * request path.
225
+ */
226
+ declare class MemoryTenantStore implements TenantStore {
227
+ private readonly byKey;
228
+ constructor(tenants?: ReadonlyArray<ResolvedConfig>);
229
+ /** Register (or replace) a tenant. Its publishable key is its lookup key. */
230
+ add(resolved: ResolvedConfig): void;
231
+ byPublishableKey(key: string): Promise<ResolvedConfig | null>;
232
+ }
233
+
234
+ interface CreateAppOptions {
235
+ /** A single tenant, resolved at startup — the self-hosted case. */
236
+ resolved?: ResolvedConfig;
237
+ /** Many tenants, each resolved by publishable key at request time — the SaaS case. */
238
+ tenants?: TenantStore | TenantRegistry;
239
+ sessions?: SessionStore;
240
+ rateLimiter?: RateLimiter;
241
+ logger?: (event: Record<string, unknown>) => void;
242
+ }
243
+ type AppEnv = {
244
+ Variables: {
245
+ tenant: TenantRuntime;
246
+ };
247
+ };
248
+ declare function createFrankieApp(options: CreateAppOptions): Hono<AppEnv>;
249
+
250
+ interface McpHostOptions {
251
+ tenants: TenantStore | TenantRegistry;
252
+ logger?: (event: Record<string, unknown>) => void;
253
+ /** Injected in tests so generated API tools hit a stub rather than the network. */
254
+ fetchImpl?: typeof fetch;
255
+ }
256
+ declare function createMcpHostApp(options: McpHostOptions): Hono;
257
+
258
+ /**
259
+ * What the control plane stores per tenant. The model provider key is *not*
260
+ * here — in the hosted shape that key is Frankie's own, one shared key read
261
+ * from the environment at resolve time, so a tenant record never carries it.
262
+ * The per-tenant secrets it does hold (the JWT verification secret the
263
+ * customer's backend signs with, and the session-token secret) live here for a
264
+ * self-hosted start; a hosted deployment swaps this file store for a vault.
265
+ */
266
+ interface TenantRecord {
267
+ id: string;
268
+ /** Who owns this tenant — the dashboard user's stable id (e.g. "github:42"). */
269
+ ownerId?: string;
270
+ config: FrankieConfig;
271
+ jwtSecret?: string;
272
+ sessionSecret: string;
273
+ createdAt: string;
274
+ updatedAt: string;
275
+ }
276
+ /** A tenant record with its secrets stripped — safe to return from the API. */
277
+ interface PublicTenant {
278
+ id: string;
279
+ ownerId?: string;
280
+ name: string;
281
+ publishableKey: string;
282
+ allowedOrigins: string[];
283
+ allowAnonymous: boolean;
284
+ connection: "knowledge" | "mcp" | "openapi";
285
+ mcpServers: string[];
286
+ apis: string[];
287
+ createdAt: string;
288
+ updatedAt: string;
289
+ }
290
+ declare function toPublicTenant(record: TenantRecord): PublicTenant;
291
+ /**
292
+ * A `TenantStore` the control plane can also write to and list. The data plane
293
+ * only needs `byPublishableKey`; the control plane needs the rest. Both the
294
+ * file and Postgres stores implement this, so the admin API is written once
295
+ * against the interface.
296
+ */
297
+ interface ControlStore extends TenantStore {
298
+ list(ownerId?: string): Promise<TenantRecord[]>;
299
+ get(id: string): Promise<TenantRecord | null>;
300
+ put(record: TenantRecord): Promise<void>;
301
+ }
302
+ /**
303
+ * The control plane's persistence, and at the same time a `TenantStore` the
304
+ * data plane can serve from — so what the dashboard writes, the agent reads.
305
+ *
306
+ * `MemoryTenantStore` is fine for tests; this one survives a restart by writing
307
+ * one JSON file per tenant under a directory. The provider key is injected from
308
+ * the environment at read time, keeping it out of the tenant files entirely.
309
+ */
310
+ declare class FileTenantStore implements ControlStore {
311
+ private readonly dir;
312
+ private readonly providerKeyEnv;
313
+ private readonly env;
314
+ private index;
315
+ private byId;
316
+ private loaded;
317
+ constructor(options: {
318
+ dir: string;
319
+ providerKeyEnv?: string;
320
+ env?: NodeJS.ProcessEnv;
321
+ });
322
+ private load;
323
+ private remember;
324
+ byPublishableKey(key: string): Promise<ResolvedConfig | null>;
325
+ /** All tenants, or — when an owner is given — only that owner's. */
326
+ list(ownerId?: string): Promise<TenantRecord[]>;
327
+ get(id: string): Promise<TenantRecord | null>;
328
+ put(record: TenantRecord): Promise<void>;
329
+ }
330
+
331
+ /**
332
+ * The minimal surface of a Postgres client (node-postgres `Pool` or a test
333
+ * double). Keeping the store to this interface means it never imports `pg`
334
+ * directly, so it runs against an in-memory Postgres in tests and a real one in
335
+ * production without changing a line.
336
+ */
337
+ interface Queryable {
338
+ query(text: string, params?: unknown[]): Promise<{
339
+ rows: Array<Record<string, unknown>>;
340
+ }>;
341
+ }
342
+ /**
343
+ * A `TenantStore` backed by Postgres — the production shape. The web app, the
344
+ * control plane and the data plane run as separate services (Vercel + Cloud
345
+ * Run) that share no filesystem, so tenants live in a database they all reach.
346
+ *
347
+ * One table, the full record as JSON with the two lookup keys pulled out as
348
+ * columns. The model provider key is still injected from the environment at
349
+ * read time and never written to the row.
350
+ */
351
+ declare class PostgresTenantStore implements ControlStore {
352
+ private readonly db;
353
+ private readonly providerKeyEnv;
354
+ private readonly env;
355
+ private ready?;
356
+ constructor(options: {
357
+ db: Queryable;
358
+ providerKeyEnv?: string;
359
+ env?: NodeJS.ProcessEnv;
360
+ });
361
+ /**
362
+ * Create the table on first use, once. Lazy so the server can start listening
363
+ * before the database is reachable — a failed connection then surfaces on a
364
+ * request (logged, a 502) instead of crashing boot on Cloud Run.
365
+ */
366
+ private ensureReady;
367
+ /** Create the table if it isn't there yet. Safe to call repeatedly. */
368
+ init(): Promise<void>;
369
+ private parse;
370
+ byPublishableKey(key: string): Promise<ResolvedConfig | null>;
371
+ list(ownerId?: string): Promise<TenantRecord[]>;
372
+ get(id: string): Promise<TenantRecord | null>;
373
+ put(record: TenantRecord): Promise<void>;
374
+ }
375
+
376
+ interface ResolvedStore {
377
+ store: ControlStore;
378
+ describe: string;
379
+ }
380
+ interface ResolvedStores {
381
+ tenants: ControlStore;
382
+ sessions: SessionStore;
383
+ describe: string;
384
+ }
385
+ /**
386
+ * Both stores the data plane needs, sharing one Postgres pool when
387
+ * DATABASE_URL is set (so tenant config and chat transcripts live in the same
388
+ * database), or a file tenant store + in-memory sessions otherwise.
389
+ */
390
+ declare function resolveStores(env?: NodeJS.ProcessEnv): Promise<ResolvedStores>;
391
+ /**
392
+ * Pick the tenant store from the environment, so the same binary runs in every
393
+ * shape:
394
+ *
395
+ * - DATABASE_URL set → Postgres, the production shape. The web app (Vercel),
396
+ * the control plane and the data plane (Cloud Run) share no filesystem, so
397
+ * tenants live in a database all three reach.
398
+ * - otherwise → a file under FRANKIE_TENANTS_DIR, for a single host or local
399
+ * development, where one persistent disk is shared.
400
+ */
401
+ declare function resolveTenantStore(env?: NodeJS.ProcessEnv): Promise<ResolvedStore>;
402
+
403
+ /**
404
+ * A `SessionStore` backed by Postgres, so a user's chat history survives a
405
+ * restart and is shared across instances — the hosted default. The whole
406
+ * `ChatSession` (transcript included) is stored as one JSON row keyed by id.
407
+ *
408
+ * A customer who would rather keep transcripts in their own store implements
409
+ * `SessionStore` against it and passes it to `createFrankieApp` — this class is
410
+ * just the batteries-included option.
411
+ */
412
+ declare class PostgresSessionStore implements SessionStore {
413
+ private readonly db;
414
+ private readonly ttlMs;
415
+ private ready?;
416
+ constructor(options: {
417
+ db: Queryable;
418
+ ttlMs?: number;
419
+ });
420
+ /** Create the table on first use, once — lazy, so boot never blocks on the DB. */
421
+ private ensureReady;
422
+ init(): Promise<void>;
423
+ create(session: ChatSession): Promise<void>;
424
+ get(id: string): Promise<ChatSession | undefined>;
425
+ update(id: string, patch: Partial<ChatSession>): Promise<void>;
426
+ delete(id: string): Promise<void>;
427
+ }
428
+
429
+ interface ControlOptions {
430
+ store: ControlStore;
431
+ /** Bearer token that gates the admin API. A stand-in for real user auth. */
432
+ adminToken?: string;
433
+ logger?: (event: Record<string, unknown>) => void;
434
+ }
435
+ /**
436
+ * The control plane: create a tenant, hand back its keys, and manage how its
437
+ * product connects — bring an MCP, point at an OpenAPI spec, or stay
438
+ * knowledge-only. The dashboard and the setup CLI are thin clients over this.
439
+ *
440
+ * Admin auth here is a single shared token — deliberately the simplest thing
441
+ * that is not "nothing", and the seam where real per-user accounts go.
442
+ */
443
+ declare function createControlApp(options: ControlOptions): Hono;
444
+
445
+ interface RunTurnParams {
446
+ config: FrankieConfig;
447
+ session: ChatSession;
448
+ message: string;
449
+ /** Provider API key, resolved from the env var the config names. */
450
+ apiKey: string;
451
+ /** Model and provider registry, resolved once at startup. */
452
+ model: Model<Api>;
453
+ models: Models;
454
+ signal: AbortSignal;
455
+ /** Called with the full transcript once the turn settles, to persist it. */
456
+ onTranscript?: (messages: AgentMessage[]) => void;
457
+ onWarning?: (message: string, detail: Record<string, unknown>) => void;
458
+ }
459
+ /**
460
+ * Run one user turn and stream it back as widget-facing events.
461
+ *
462
+ * The agent loop runs in this process: there is no subprocess and no
463
+ * translation shim, so the only thing between the browser and the provider is
464
+ * the mapping below.
465
+ */
466
+ declare function runTurn(params: RunTurnParams): AsyncGenerator<FrankieServerEvent>;
467
+
468
+ /** Tools are namespaced so one tenant's policy can name a server or a tool. */
469
+ declare const MCP_TOOL_PREFIX = "mcp__";
470
+ interface McpConnection {
471
+ tools: AgentTool[];
472
+ /** Servers that could not be reached, by name. */
473
+ failed: Array<{
474
+ name: string;
475
+ error: string;
476
+ }>;
477
+ close(): Promise<void>;
478
+ }
479
+ interface ConnectMcpOptions {
480
+ servers: readonly McpServerConfig[];
481
+ session: ChatSession;
482
+ policy: ToolPolicy;
483
+ connectTimeoutMs?: number;
484
+ callTimeoutMs?: number;
485
+ onWarning?: (message: string, detail: Record<string, unknown>) => void;
486
+ }
487
+ /**
488
+ * Connect to the tenant's MCP servers and expose their tools to the agent.
489
+ *
490
+ * Connections are per turn rather than pooled. That costs one handshake per
491
+ * message, and buys three things worth more for an onboarding agent: a server
492
+ * restarting mid-conversation heals on the next turn, tool lists stay current,
493
+ * and no connection outlives the request that authorised it.
494
+ */
495
+ declare function connectMcpTools(options: ConnectMcpOptions): Promise<McpConnection>;
496
+
497
+ /** Generated API tools share the namespace shape of MCP tools. */
498
+ declare const API_TOOL_PREFIX = "api__";
499
+ declare class OpenApiError extends Error {
500
+ }
501
+ interface BuildApiToolsOptions {
502
+ sources: readonly ApiSourceConfig[];
503
+ session: ChatSession;
504
+ policy: ToolPolicy;
505
+ fetchImpl?: typeof fetch;
506
+ onWarning?: (message: string, detail: Record<string, unknown>) => void;
507
+ }
508
+ interface ApiToolSet {
509
+ tools: AgentTool[];
510
+ failed: Array<{
511
+ name: string;
512
+ error: string;
513
+ }>;
514
+ }
515
+ /**
516
+ * Turn a tenant's OpenAPI document into agent tools.
517
+ *
518
+ * This is the third onboarding path: a customer who has no MCP server and does
519
+ * not want to write one points Frankie at their spec instead. The tools it
520
+ * produces are indistinguishable to the agent from MCP ones, and go through the
521
+ * same deny-by-default policy and the same identity forwarding.
522
+ */
523
+ declare function buildApiTools(options: BuildApiToolsOptions): Promise<ApiToolSet>;
524
+ interface OpenApiDoc {
525
+ servers?: Array<{
526
+ url?: string;
527
+ }>;
528
+ paths?: Record<string, unknown>;
529
+ components?: Record<string, unknown>;
530
+ }
531
+ declare function loadSpec(spec: string, fetchImpl?: typeof fetch): Promise<OpenApiDoc>;
532
+ /**
533
+ * `/projects/*` matches one path segment; `/projects/**` matches any depth and
534
+ * the base path itself, since a tenant writing that plainly means "this part of
535
+ * the API" rather than "everything under it but not it".
536
+ */
537
+ declare function matchPath(glob: string, path: string): boolean;
538
+
539
+ interface CollectToolsOptions {
540
+ config: FrankieConfig;
541
+ session: ChatSession;
542
+ fetchImpl?: typeof fetch;
543
+ onWarning?: (message: string, detail: Record<string, unknown>) => void;
544
+ }
545
+ interface CollectedTools {
546
+ tools: AgentTool[];
547
+ /** Sources that could not be reached or parsed, by name. */
548
+ failed: Array<{
549
+ name: string;
550
+ error: string;
551
+ }>;
552
+ close(): Promise<void>;
553
+ }
554
+ /**
555
+ * Gather every tool the agent may use this turn, from both sources.
556
+ *
557
+ * A tenant can bring an MCP server, point at an OpenAPI document, or do both —
558
+ * the agent cannot tell the difference, and neither can the tool policy, which
559
+ * sees `mcp__<server>__<tool>` and `api__<name>__<operation>` in the same
560
+ * namespace.
561
+ *
562
+ * A source that fails is logged and skipped rather than failing the turn: one
563
+ * unreachable backend should not take the assistant down with it.
564
+ */
565
+ declare function collectTools(options: CollectToolsOptions): Promise<CollectedTools>;
566
+
567
+ /** The forwarding fields MCP servers and API sources have in common. */
568
+ interface ForwardingConfig {
569
+ headers: Record<string, string>;
570
+ forwardUserTokenAs?: string | undefined;
571
+ forwardUserClaims: readonly string[];
572
+ }
573
+ /**
574
+ * Headers sent to a tenant's own backend, whether that is an MCP server or an
575
+ * HTTP API.
576
+ *
577
+ * `forwardUserTokenAs` is how the tenant's tools act *as* the signed-in user:
578
+ * their API authorises the call exactly as it would any other request from that
579
+ * person. It is opt-in per source precisely because it hands that source the
580
+ * user's identity token.
581
+ */
582
+ declare function buildForwardedHeaders(source: ForwardingConfig, session: ChatSession): Record<string, string>;
583
+
584
+ /**
585
+ * The runtime's own operating instructions, followed by whatever the tenant
586
+ * configured. Host-supplied page context is fenced off as data: it comes from
587
+ * a web page and must never be able to redirect the agent.
588
+ */
589
+ declare function buildSystemPrompt(config: FrankieConfig, session: ChatSession): string;
590
+
591
+ 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 };