@frankieai/server 0.1.0 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,436 +0,0 @@
1
- // src/auth.ts
2
- import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose";
3
- var AuthError = class extends Error {
4
- constructor(message, code = "unauthorized") {
5
- super(message);
6
- this.code = code;
7
- }
8
- code;
9
- };
10
- var TokenService = class {
11
- constructor(auth, secrets) {
12
- this.auth = auth;
13
- this.sessionKey = new TextEncoder().encode(secrets.sessionSecret);
14
- this.userKey = secrets.jwtSecret ? new TextEncoder().encode(secrets.jwtSecret) : void 0;
15
- if (!this.userKey && !auth.jwksUrl && !auth.allowAnonymous) {
16
- throw new Error("Frankie auth needs either a JWT secret, a JWKS URL, or allowAnonymous.");
17
- }
18
- }
19
- auth;
20
- sessionKey;
21
- userKey;
22
- jwks;
23
- /** Verify a host-signed user token. Throws `AuthError` on any failure. */
24
- async verifyUserToken(token) {
25
- const options = {
26
- ...this.auth.jwtIssuer ? { issuer: this.auth.jwtIssuer } : {},
27
- ...this.auth.jwtAudience ? { audience: this.auth.jwtAudience } : {},
28
- clockTolerance: 30
29
- };
30
- let payload;
31
- try {
32
- if (this.auth.jwksUrl) {
33
- this.jwks ??= createRemoteJWKSet(new URL(this.auth.jwksUrl));
34
- ({ payload } = await jwtVerify(token, this.jwks, options));
35
- } else if (this.userKey) {
36
- ({ payload } = await jwtVerify(token, this.userKey, { ...options, algorithms: ["HS256"] }));
37
- } else {
38
- throw new AuthError("This tenant is not configured to accept user tokens.");
39
- }
40
- } catch (error) {
41
- if (error instanceof AuthError) throw error;
42
- throw new AuthError(`Could not verify the user token: ${error.message}`);
43
- }
44
- const subject = typeof payload.sub === "string" ? payload.sub : void 0;
45
- if (!subject) throw new AuthError("The user token has no `sub` claim.");
46
- return {
47
- id: subject,
48
- displayName: stringClaim(payload, "name") ?? stringClaim(payload, "preferred_username"),
49
- email: stringClaim(payload, "email"),
50
- anonymous: false,
51
- claims: payload
52
- };
53
- }
54
- /** Mint the bearer token the widget uses for the rest of the conversation. */
55
- async issueSessionToken(input) {
56
- const expiresAt = new Date(Date.now() + this.auth.sessionTtlSeconds * 1e3);
57
- const token = await new SignJWT({
58
- sid: input.sessionId,
59
- uid: input.user.id,
60
- anon: input.user.anonymous,
61
- tid: input.tenantId
62
- }).setProtectedHeader({ alg: "HS256", typ: "JWT" }).setIssuedAt().setIssuer("frankie").setAudience("frankie-widget").setExpirationTime(Math.floor(expiresAt.getTime() / 1e3)).sign(this.sessionKey);
63
- return { token, expiresAt };
64
- }
65
- async verifySessionToken(token) {
66
- try {
67
- const { payload } = await jwtVerify(token, this.sessionKey, {
68
- algorithms: ["HS256"],
69
- issuer: "frankie",
70
- audience: "frankie-widget"
71
- });
72
- const sessionId = stringClaim(payload, "sid");
73
- const userId = stringClaim(payload, "uid");
74
- const tenantId = stringClaim(payload, "tid");
75
- if (!sessionId || !userId || !tenantId) throw new AuthError("Malformed session token.");
76
- return { sessionId, userId, tenantId };
77
- } catch (error) {
78
- if (error instanceof AuthError) throw error;
79
- throw new AuthError("Your chat session has expired. Reload the page to start a new one.");
80
- }
81
- }
82
- };
83
- function stringClaim(payload, key) {
84
- const value = payload[key];
85
- return typeof value === "string" ? value : void 0;
86
- }
87
- function isOriginAllowed(origin, allowed) {
88
- if (allowed.length === 0) return origin === void 0;
89
- if (!origin) return false;
90
- return allowed.some((entry) => normaliseOrigin(entry) === normaliseOrigin(origin));
91
- }
92
- function normaliseOrigin(value) {
93
- try {
94
- const url = new URL(value);
95
- return url.origin.toLowerCase();
96
- } catch {
97
- return value.trim().replace(/\/+$/, "").toLowerCase();
98
- }
99
- }
100
- function safeEqual(a, b) {
101
- if (a.length !== b.length) return false;
102
- let diff = 0;
103
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
104
- return diff === 0;
105
- }
106
- function anonymousUser(seed) {
107
- return { id: `anon_${seed}`, anonymous: true, claims: {} };
108
- }
109
-
110
- // src/control/store.ts
111
- import { mkdir, readFile, readdir, writeFile } from "fs/promises";
112
- import { join, resolve } from "path";
113
- import { parseFrankieConfig } from "@frankieai/core";
114
- function toPublicTenant(record) {
115
- const { config } = record;
116
- return {
117
- id: record.id,
118
- ...record.ownerId ? { ownerId: record.ownerId } : {},
119
- name: config.branding.name,
120
- publishableKey: config.auth.publishableKey,
121
- allowedOrigins: config.auth.allowedOrigins,
122
- allowAnonymous: config.auth.allowAnonymous,
123
- connection: config.apis.length > 0 ? "openapi" : config.mcpServers.length > 0 ? "mcp" : "knowledge",
124
- mcpServers: config.mcpServers.map((s) => s.name),
125
- apis: config.apis.map((a) => a.name),
126
- createdAt: record.createdAt,
127
- updatedAt: record.updatedAt
128
- };
129
- }
130
- var FileTenantStore = class {
131
- dir;
132
- providerKeyEnv;
133
- env;
134
- index = /* @__PURE__ */ new Map();
135
- // by publishable key
136
- byId = /* @__PURE__ */ new Map();
137
- loaded = false;
138
- constructor(options) {
139
- this.dir = resolve(options.dir);
140
- this.providerKeyEnv = options.providerKeyEnv ?? "FIREWORKS_API_KEY";
141
- this.env = options.env ?? process.env;
142
- }
143
- async load() {
144
- if (this.loaded) return;
145
- await mkdir(this.dir, { recursive: true });
146
- const files = await readdir(this.dir).catch(() => []);
147
- for (const file of files) {
148
- if (!file.endsWith(".json")) continue;
149
- try {
150
- const record = JSON.parse(await readFile(join(this.dir, file), "utf8"));
151
- record.config = parseFrankieConfig(record.config);
152
- this.remember(record);
153
- } catch {
154
- }
155
- }
156
- this.loaded = true;
157
- }
158
- remember(record) {
159
- this.byId.set(record.id, record);
160
- this.index.set(record.config.auth.publishableKey, record);
161
- }
162
- async byPublishableKey(key) {
163
- await this.load();
164
- const record = this.index.get(key);
165
- if (!record) return null;
166
- const providerApiKey = this.env[record.config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];
167
- if (!providerApiKey) return null;
168
- return {
169
- config: record.config,
170
- secrets: {
171
- ...record.jwtSecret ? { jwtSecret: record.jwtSecret } : {},
172
- sessionSecret: record.sessionSecret,
173
- providerApiKey
174
- }
175
- };
176
- }
177
- /** All tenants, or — when an owner is given — only that owner's. */
178
- async list(ownerId) {
179
- await this.load();
180
- return [...this.byId.values()].filter((record) => ownerId === void 0 || record.ownerId === ownerId).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
181
- }
182
- async get(id) {
183
- await this.load();
184
- return this.byId.get(id) ?? null;
185
- }
186
- async put(record) {
187
- await this.load();
188
- const existing = this.index.get(record.config.auth.publishableKey);
189
- if (existing && existing.id !== record.id) {
190
- throw new Error(`Publishable key already belongs to tenant "${existing.id}".`);
191
- }
192
- this.remember(record);
193
- await mkdir(this.dir, { recursive: true });
194
- await writeFile(join(this.dir, `${record.id}.json`), JSON.stringify(record, null, 2), "utf8");
195
- }
196
- };
197
-
198
- // src/control/postgres-store.ts
199
- import { parseFrankieConfig as parseFrankieConfig2 } from "@frankieai/core";
200
- var PostgresTenantStore = class {
201
- db;
202
- providerKeyEnv;
203
- env;
204
- ready;
205
- constructor(options) {
206
- this.db = options.db;
207
- this.providerKeyEnv = options.providerKeyEnv ?? "FIREWORKS_API_KEY";
208
- this.env = options.env ?? process.env;
209
- }
210
- /**
211
- * Create the table on first use, once. Lazy so the server can start listening
212
- * before the database is reachable — a failed connection then surfaces on a
213
- * request (logged, a 502) instead of crashing boot on Cloud Run.
214
- */
215
- ensureReady() {
216
- return this.ready ??= this.init();
217
- }
218
- /** Create the table if it isn't there yet. Safe to call repeatedly. */
219
- async init() {
220
- await this.db.query(
221
- `CREATE TABLE IF NOT EXISTS frankie_tenants (
222
- id text PRIMARY KEY,
223
- owner_id text,
224
- publishable_key text NOT NULL UNIQUE,
225
- record text NOT NULL,
226
- created_at text NOT NULL,
227
- updated_at text NOT NULL
228
- )`
229
- );
230
- await this.db.query(`CREATE INDEX IF NOT EXISTS frankie_tenants_owner ON frankie_tenants (owner_id)`);
231
- }
232
- parse(row) {
233
- const record = JSON.parse(String(row.record));
234
- record.config = parseFrankieConfig2(record.config);
235
- return record;
236
- }
237
- async byPublishableKey(key) {
238
- await this.ensureReady();
239
- const { rows } = await this.db.query(`SELECT record FROM frankie_tenants WHERE publishable_key = $1`, [key]);
240
- if (rows.length === 0 || !rows[0]) return null;
241
- const record = this.parse(rows[0]);
242
- const providerApiKey = this.env[record.config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];
243
- if (!providerApiKey) return null;
244
- return {
245
- config: record.config,
246
- secrets: {
247
- ...record.jwtSecret ? { jwtSecret: record.jwtSecret } : {},
248
- sessionSecret: record.sessionSecret,
249
- providerApiKey
250
- }
251
- };
252
- }
253
- async list(ownerId) {
254
- await this.ensureReady();
255
- const { rows } = await this.db.query(
256
- `SELECT record FROM frankie_tenants
257
- WHERE $1::text IS NULL OR owner_id = $1
258
- ORDER BY created_at ASC`,
259
- [ownerId ?? null]
260
- );
261
- return rows.map((row) => this.parse(row));
262
- }
263
- async get(id) {
264
- await this.ensureReady();
265
- const { rows } = await this.db.query(`SELECT record FROM frankie_tenants WHERE id = $1`, [id]);
266
- return rows.length && rows[0] ? this.parse(rows[0]) : null;
267
- }
268
- async put(record) {
269
- await this.ensureReady();
270
- const key = record.config.auth.publishableKey;
271
- const clash = await this.db.query(`SELECT id FROM frankie_tenants WHERE publishable_key = $1 AND id <> $2`, [
272
- key,
273
- record.id
274
- ]);
275
- if (clash.rows.length > 0) {
276
- throw new Error(`Publishable key already belongs to tenant "${String(clash.rows[0]?.id)}".`);
277
- }
278
- await this.db.query(
279
- `INSERT INTO frankie_tenants (id, owner_id, publishable_key, record, created_at, updated_at)
280
- VALUES ($1, $2, $3, $4, $5, $6)
281
- ON CONFLICT (id) DO UPDATE SET
282
- owner_id = EXCLUDED.owner_id,
283
- publishable_key = EXCLUDED.publishable_key,
284
- record = EXCLUDED.record,
285
- updated_at = EXCLUDED.updated_at`,
286
- [record.id, record.ownerId ?? null, key, JSON.stringify(record), record.createdAt, record.updatedAt]
287
- );
288
- }
289
- };
290
-
291
- // src/sessions.ts
292
- import { randomUUID } from "crypto";
293
- var MemorySessionStore = class {
294
- constructor(ttlMs = 6 * 60 * 60 * 1e3) {
295
- this.ttlMs = ttlMs;
296
- }
297
- ttlMs;
298
- sessions = /* @__PURE__ */ new Map();
299
- async create(session) {
300
- this.evictExpired();
301
- this.sessions.set(session.id, session);
302
- }
303
- async get(id) {
304
- const session = this.sessions.get(id);
305
- if (!session) return void 0;
306
- if (Date.now() - session.lastActiveAt > this.ttlMs) {
307
- this.sessions.delete(id);
308
- return void 0;
309
- }
310
- return session;
311
- }
312
- async update(id, patch) {
313
- const session = this.sessions.get(id);
314
- if (!session) return;
315
- this.sessions.set(id, { ...session, ...patch, lastActiveAt: Date.now() });
316
- }
317
- async delete(id) {
318
- this.sessions.delete(id);
319
- }
320
- evictExpired() {
321
- const cutoff = Date.now() - this.ttlMs;
322
- for (const [id, session] of this.sessions) {
323
- if (session.lastActiveAt < cutoff) this.sessions.delete(id);
324
- }
325
- }
326
- };
327
- function newSessionId() {
328
- return randomUUID();
329
- }
330
-
331
- // src/postgres-sessions.ts
332
- var PostgresSessionStore = class {
333
- db;
334
- ttlMs;
335
- ready;
336
- constructor(options) {
337
- this.db = options.db;
338
- this.ttlMs = options.ttlMs ?? 6 * 60 * 60 * 1e3;
339
- }
340
- /** Create the table on first use, once — lazy, so boot never blocks on the DB. */
341
- ensureReady() {
342
- return this.ready ??= this.init();
343
- }
344
- async init() {
345
- await this.db.query(
346
- `CREATE TABLE IF NOT EXISTS frankie_sessions (
347
- id text PRIMARY KEY,
348
- data text NOT NULL,
349
- last_active bigint NOT NULL
350
- )`
351
- );
352
- }
353
- async create(session) {
354
- await this.ensureReady();
355
- await this.db.query(
356
- `INSERT INTO frankie_sessions (id, data, last_active) VALUES ($1, $2, $3)
357
- ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, last_active = EXCLUDED.last_active`,
358
- [session.id, JSON.stringify(session), session.lastActiveAt]
359
- );
360
- }
361
- async get(id) {
362
- await this.ensureReady();
363
- const { rows } = await this.db.query(`SELECT data, last_active FROM frankie_sessions WHERE id = $1`, [id]);
364
- if (rows.length === 0 || !rows[0]) return void 0;
365
- if (Date.now() - Number(rows[0].last_active) > this.ttlMs) {
366
- await this.delete(id);
367
- return void 0;
368
- }
369
- return JSON.parse(String(rows[0].data));
370
- }
371
- async update(id, patch) {
372
- const existing = await this.get(id);
373
- if (!existing) return;
374
- const next = { ...existing, ...patch, lastActiveAt: Date.now() };
375
- await this.db.query(`UPDATE frankie_sessions SET data = $2, last_active = $3 WHERE id = $1`, [
376
- id,
377
- JSON.stringify(next),
378
- next.lastActiveAt
379
- ]);
380
- }
381
- async delete(id) {
382
- await this.ensureReady();
383
- await this.db.query(`DELETE FROM frankie_sessions WHERE id = $1`, [id]);
384
- }
385
- };
386
-
387
- // src/control/resolve-store.ts
388
- async function resolveStores(env = process.env) {
389
- const url = env["DATABASE_URL"];
390
- if (url) {
391
- const { Pool } = await import("pg");
392
- const pool = new Pool({
393
- connectionString: url,
394
- max: 4,
395
- ...env["DATABASE_SSL"] === "disable" ? {} : { ssl: { rejectUnauthorized: false } }
396
- });
397
- const tenants = new PostgresTenantStore({ db: pool, env });
398
- const sessions = new PostgresSessionStore({ db: pool });
399
- return { tenants, sessions, describe: "postgres" };
400
- }
401
- const dir = env["FRANKIE_TENANTS_DIR"] ?? "./.frankie/tenants";
402
- return { tenants: new FileTenantStore({ dir, env }), sessions: new MemorySessionStore(), describe: `files:${dir}` };
403
- }
404
- async function resolveTenantStore(env = process.env) {
405
- const url = env["DATABASE_URL"];
406
- if (url) {
407
- const { Pool } = await import("pg");
408
- const pool = new Pool({
409
- connectionString: url,
410
- // Managed Postgres (Neon, Supabase, Cloud SQL over the proxy) expects TLS;
411
- // opt out only for a local database via DATABASE_SSL=disable.
412
- ...env["DATABASE_SSL"] === "disable" ? {} : { ssl: { rejectUnauthorized: false } }
413
- });
414
- const store = new PostgresTenantStore({ db: pool, env });
415
- return { store, describe: "postgres" };
416
- }
417
- const dir = env["FRANKIE_TENANTS_DIR"] ?? "./.frankie/tenants";
418
- return { store: new FileTenantStore({ dir, env }), describe: `files:${dir}` };
419
- }
420
-
421
- export {
422
- AuthError,
423
- TokenService,
424
- isOriginAllowed,
425
- safeEqual,
426
- anonymousUser,
427
- toPublicTenant,
428
- FileTenantStore,
429
- PostgresTenantStore,
430
- MemorySessionStore,
431
- newSessionId,
432
- PostgresSessionStore,
433
- resolveStores,
434
- resolveTenantStore
435
- };
436
- //# sourceMappingURL=chunk-ACXNDAOM.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/auth.ts","../src/control/store.ts","../src/control/postgres-store.ts","../src/sessions.ts","../src/postgres-sessions.ts","../src/control/resolve-store.ts"],"sourcesContent":["import { createRemoteJWKSet, jwtVerify, SignJWT, type JWTPayload } from \"jose\";\nimport type { AuthConfig } from \"@frankieai/core\";\n\nexport interface VerifiedUser {\n id: string;\n displayName?: string;\n email?: string;\n anonymous: boolean;\n /** Every claim from the host's token, for MCP forwarding and prompt context. */\n claims: JWTPayload;\n}\n\nexport class AuthError extends Error {\n constructor(\n message: string,\n readonly code: \"unauthorized\" | \"forbidden_origin\" = \"unauthorized\",\n ) {\n super(message);\n }\n}\n\ntype JwksResolver = ReturnType<typeof createRemoteJWKSet>;\n\n/**\n * Verifies the JWT the customer's backend mints for a signed-in end user, and\n * issues/verifies Frankie's own short-lived session tokens.\n */\nexport class TokenService {\n private readonly sessionKey: Uint8Array;\n private readonly userKey?: Uint8Array;\n private jwks?: JwksResolver;\n\n constructor(\n private readonly auth: AuthConfig,\n secrets: { sessionSecret: string; jwtSecret?: string },\n ) {\n this.sessionKey = new TextEncoder().encode(secrets.sessionSecret);\n this.userKey = secrets.jwtSecret ? new TextEncoder().encode(secrets.jwtSecret) : undefined;\n if (!this.userKey && !auth.jwksUrl && !auth.allowAnonymous) {\n throw new Error(\"Frankie auth needs either a JWT secret, a JWKS URL, or allowAnonymous.\");\n }\n }\n\n /** Verify a host-signed user token. Throws `AuthError` on any failure. */\n async verifyUserToken(token: string): Promise<VerifiedUser> {\n const options = {\n ...(this.auth.jwtIssuer ? { issuer: this.auth.jwtIssuer } : {}),\n ...(this.auth.jwtAudience ? { audience: this.auth.jwtAudience } : {}),\n clockTolerance: 30,\n };\n\n let payload: JWTPayload;\n try {\n if (this.auth.jwksUrl) {\n this.jwks ??= createRemoteJWKSet(new URL(this.auth.jwksUrl));\n ({ payload } = await jwtVerify(token, this.jwks, options));\n } else if (this.userKey) {\n ({ payload } = await jwtVerify(token, this.userKey, { ...options, algorithms: [\"HS256\"] }));\n } else {\n throw new AuthError(\"This tenant is not configured to accept user tokens.\");\n }\n } catch (error) {\n if (error instanceof AuthError) throw error;\n throw new AuthError(`Could not verify the user token: ${(error as Error).message}`);\n }\n\n const subject = typeof payload.sub === \"string\" ? payload.sub : undefined;\n if (!subject) throw new AuthError(\"The user token has no `sub` claim.\");\n\n return {\n id: subject,\n displayName: stringClaim(payload, \"name\") ?? stringClaim(payload, \"preferred_username\"),\n email: stringClaim(payload, \"email\"),\n anonymous: false,\n claims: payload,\n };\n }\n\n /** Mint the bearer token the widget uses for the rest of the conversation. */\n async issueSessionToken(input: { sessionId: string; user: VerifiedUser; tenantId: string }): Promise<{\n token: string;\n expiresAt: Date;\n }> {\n const expiresAt = new Date(Date.now() + this.auth.sessionTtlSeconds * 1000);\n const token = await new SignJWT({\n sid: input.sessionId,\n uid: input.user.id,\n anon: input.user.anonymous,\n tid: input.tenantId,\n })\n .setProtectedHeader({ alg: \"HS256\", typ: \"JWT\" })\n .setIssuedAt()\n .setIssuer(\"frankie\")\n .setAudience(\"frankie-widget\")\n .setExpirationTime(Math.floor(expiresAt.getTime() / 1000))\n .sign(this.sessionKey);\n\n return { token, expiresAt };\n }\n\n async verifySessionToken(token: string): Promise<{ sessionId: string; userId: string; tenantId: string }> {\n try {\n const { payload } = await jwtVerify(token, this.sessionKey, {\n algorithms: [\"HS256\"],\n issuer: \"frankie\",\n audience: \"frankie-widget\",\n });\n const sessionId = stringClaim(payload, \"sid\");\n const userId = stringClaim(payload, \"uid\");\n const tenantId = stringClaim(payload, \"tid\");\n if (!sessionId || !userId || !tenantId) throw new AuthError(\"Malformed session token.\");\n return { sessionId, userId, tenantId };\n } catch (error) {\n if (error instanceof AuthError) throw error;\n throw new AuthError(\"Your chat session has expired. Reload the page to start a new one.\");\n }\n }\n}\n\nfunction stringClaim(payload: JWTPayload, key: string): string | undefined {\n const value = payload[key];\n return typeof value === \"string\" ? value : undefined;\n}\n\n/**\n * Exact-origin allowlist. Wildcards are deliberately unsupported: the widget\n * runs on a page the customer controls, so the list is short and known.\n * An empty list means \"same-origin only\", which is what a server mounted\n * inside the host app wants.\n */\nexport function isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean {\n if (allowed.length === 0) return origin === undefined;\n if (!origin) return false;\n return allowed.some((entry) => normaliseOrigin(entry) === normaliseOrigin(origin));\n}\n\nfunction normaliseOrigin(value: string): string {\n try {\n const url = new URL(value);\n return url.origin.toLowerCase();\n } catch {\n return value.trim().replace(/\\/+$/, \"\").toLowerCase();\n }\n}\n\n/** Constant-time string comparison for the publishable key check. */\nexport function safeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n return diff === 0;\n}\n\n/** Stable pseudonymous id for an anonymous visitor. */\nexport function anonymousUser(seed: string): VerifiedUser {\n return { id: `anon_${seed}`, anonymous: true, claims: {} };\n}\n","import { mkdir, readFile, readdir, writeFile } from \"node:fs/promises\";\nimport { join, resolve } from \"node:path\";\nimport { parseFrankieConfig, type FrankieConfig } from \"@frankieai/core\";\nimport type { ResolvedConfig } from \"../config.js\";\nimport type { TenantStore } from \"../tenants.js\";\n\n/**\n * What the control plane stores per tenant. The model provider key is *not*\n * here — in the hosted shape that key is Frankie's own, one shared key read\n * from the environment at resolve time, so a tenant record never carries it.\n * The per-tenant secrets it does hold (the JWT verification secret the\n * customer's backend signs with, and the session-token secret) live here for a\n * self-hosted start; a hosted deployment swaps this file store for a vault.\n */\nexport interface TenantRecord {\n id: string;\n /** Who owns this tenant — the dashboard user's stable id (e.g. \"github:42\"). */\n ownerId?: string;\n config: FrankieConfig;\n jwtSecret?: string;\n sessionSecret: string;\n createdAt: string;\n updatedAt: string;\n}\n\n/** A tenant record with its secrets stripped — safe to return from the API. */\nexport interface PublicTenant {\n id: string;\n ownerId?: string;\n name: string;\n publishableKey: string;\n allowedOrigins: string[];\n allowAnonymous: boolean;\n connection: \"knowledge\" | \"mcp\" | \"openapi\";\n mcpServers: string[];\n apis: string[];\n createdAt: string;\n updatedAt: string;\n}\n\nexport function toPublicTenant(record: TenantRecord): PublicTenant {\n const { config } = record;\n return {\n id: record.id,\n ...(record.ownerId ? { ownerId: record.ownerId } : {}),\n name: config.branding.name,\n publishableKey: config.auth.publishableKey,\n allowedOrigins: config.auth.allowedOrigins,\n allowAnonymous: config.auth.allowAnonymous,\n connection: config.apis.length > 0 ? \"openapi\" : config.mcpServers.length > 0 ? \"mcp\" : \"knowledge\",\n mcpServers: config.mcpServers.map((s) => s.name),\n apis: config.apis.map((a) => a.name),\n createdAt: record.createdAt,\n updatedAt: record.updatedAt,\n };\n}\n\n/**\n * A `TenantStore` the control plane can also write to and list. The data plane\n * only needs `byPublishableKey`; the control plane needs the rest. Both the\n * file and Postgres stores implement this, so the admin API is written once\n * against the interface.\n */\nexport interface ControlStore extends TenantStore {\n list(ownerId?: string): Promise<TenantRecord[]>;\n get(id: string): Promise<TenantRecord | null>;\n put(record: TenantRecord): Promise<void>;\n}\n\n/**\n * The control plane's persistence, and at the same time a `TenantStore` the\n * data plane can serve from — so what the dashboard writes, the agent reads.\n *\n * `MemoryTenantStore` is fine for tests; this one survives a restart by writing\n * one JSON file per tenant under a directory. The provider key is injected from\n * the environment at read time, keeping it out of the tenant files entirely.\n */\nexport class FileTenantStore implements ControlStore {\n private readonly dir: string;\n private readonly providerKeyEnv: string;\n private readonly env: NodeJS.ProcessEnv;\n private index = new Map<string, TenantRecord>(); // by publishable key\n private byId = new Map<string, TenantRecord>();\n private loaded = false;\n\n constructor(options: { dir: string; providerKeyEnv?: string; env?: NodeJS.ProcessEnv }) {\n this.dir = resolve(options.dir);\n this.providerKeyEnv = options.providerKeyEnv ?? \"FIREWORKS_API_KEY\";\n this.env = options.env ?? process.env;\n }\n\n private async load(): Promise<void> {\n if (this.loaded) return;\n await mkdir(this.dir, { recursive: true });\n const files = await readdir(this.dir).catch(() => [] as string[]);\n for (const file of files) {\n if (!file.endsWith(\".json\")) continue;\n try {\n const record = JSON.parse(await readFile(join(this.dir, file), \"utf8\")) as TenantRecord;\n record.config = parseFrankieConfig(record.config);\n this.remember(record);\n } catch {\n // A malformed tenant file must not take the whole fleet down.\n }\n }\n this.loaded = true;\n }\n\n private remember(record: TenantRecord): void {\n this.byId.set(record.id, record);\n this.index.set(record.config.auth.publishableKey, record);\n }\n\n async byPublishableKey(key: string): Promise<ResolvedConfig | null> {\n await this.load();\n const record = this.index.get(key);\n if (!record) return null;\n const providerApiKey = this.env[record.config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];\n if (!providerApiKey) return null;\n return {\n config: record.config,\n secrets: {\n ...(record.jwtSecret ? { jwtSecret: record.jwtSecret } : {}),\n sessionSecret: record.sessionSecret,\n providerApiKey,\n },\n };\n }\n\n /** All tenants, or — when an owner is given — only that owner's. */\n async list(ownerId?: string): Promise<TenantRecord[]> {\n await this.load();\n return [...this.byId.values()]\n .filter((record) => ownerId === undefined || record.ownerId === ownerId)\n .sort((a, b) => a.createdAt.localeCompare(b.createdAt));\n }\n\n async get(id: string): Promise<TenantRecord | null> {\n await this.load();\n return this.byId.get(id) ?? null;\n }\n\n async put(record: TenantRecord): Promise<void> {\n await this.load();\n // A publishable key may have changed owners only if it's not another tenant's.\n const existing = this.index.get(record.config.auth.publishableKey);\n if (existing && existing.id !== record.id) {\n throw new Error(`Publishable key already belongs to tenant \"${existing.id}\".`);\n }\n this.remember(record);\n await mkdir(this.dir, { recursive: true });\n await writeFile(join(this.dir, `${record.id}.json`), JSON.stringify(record, null, 2), \"utf8\");\n }\n}\n","import { parseFrankieConfig } from \"@frankieai/core\";\nimport type { ResolvedConfig } from \"../config.js\";\nimport type { ControlStore, TenantRecord } from \"./store.js\";\n\n/**\n * The minimal surface of a Postgres client (node-postgres `Pool` or a test\n * double). Keeping the store to this interface means it never imports `pg`\n * directly, so it runs against an in-memory Postgres in tests and a real one in\n * production without changing a line.\n */\nexport interface Queryable {\n query(text: string, params?: unknown[]): Promise<{ rows: Array<Record<string, unknown>> }>;\n}\n\n/**\n * A `TenantStore` backed by Postgres — the production shape. The web app, the\n * control plane and the data plane run as separate services (Vercel + Cloud\n * Run) that share no filesystem, so tenants live in a database they all reach.\n *\n * One table, the full record as JSON with the two lookup keys pulled out as\n * columns. The model provider key is still injected from the environment at\n * read time and never written to the row.\n */\nexport class PostgresTenantStore implements ControlStore {\n private readonly db: Queryable;\n private readonly providerKeyEnv: string;\n private readonly env: NodeJS.ProcessEnv;\n private ready?: Promise<void>;\n\n constructor(options: { db: Queryable; providerKeyEnv?: string; env?: NodeJS.ProcessEnv }) {\n this.db = options.db;\n this.providerKeyEnv = options.providerKeyEnv ?? \"FIREWORKS_API_KEY\";\n this.env = options.env ?? process.env;\n }\n\n /**\n * Create the table on first use, once. Lazy so the server can start listening\n * before the database is reachable — a failed connection then surfaces on a\n * request (logged, a 502) instead of crashing boot on Cloud Run.\n */\n private ensureReady(): Promise<void> {\n return (this.ready ??= this.init());\n }\n\n /** Create the table if it isn't there yet. Safe to call repeatedly. */\n async init(): Promise<void> {\n await this.db.query(\n `CREATE TABLE IF NOT EXISTS frankie_tenants (\n id text PRIMARY KEY,\n owner_id text,\n publishable_key text NOT NULL UNIQUE,\n record text NOT NULL,\n created_at text NOT NULL,\n updated_at text NOT NULL\n )`,\n );\n await this.db.query(`CREATE INDEX IF NOT EXISTS frankie_tenants_owner ON frankie_tenants (owner_id)`);\n }\n\n private parse(row: Record<string, unknown>): TenantRecord {\n const record = JSON.parse(String(row.record)) as TenantRecord;\n record.config = parseFrankieConfig(record.config);\n return record;\n }\n\n async byPublishableKey(key: string): Promise<ResolvedConfig | null> {\n await this.ensureReady();\n const { rows } = await this.db.query(`SELECT record FROM frankie_tenants WHERE publishable_key = $1`, [key]);\n if (rows.length === 0 || !rows[0]) return null;\n const record = this.parse(rows[0]);\n const providerApiKey = this.env[record.config.model.apiKeyEnv] ?? this.env[this.providerKeyEnv];\n if (!providerApiKey) return null;\n return {\n config: record.config,\n secrets: {\n ...(record.jwtSecret ? { jwtSecret: record.jwtSecret } : {}),\n sessionSecret: record.sessionSecret,\n providerApiKey,\n },\n };\n }\n\n async list(ownerId?: string): Promise<TenantRecord[]> {\n await this.ensureReady();\n const { rows } = await this.db.query(\n `SELECT record FROM frankie_tenants\n WHERE $1::text IS NULL OR owner_id = $1\n ORDER BY created_at ASC`,\n [ownerId ?? null],\n );\n return rows.map((row) => this.parse(row));\n }\n\n async get(id: string): Promise<TenantRecord | null> {\n await this.ensureReady();\n const { rows } = await this.db.query(`SELECT record FROM frankie_tenants WHERE id = $1`, [id]);\n return rows.length && rows[0] ? this.parse(rows[0]) : null;\n }\n\n async put(record: TenantRecord): Promise<void> {\n await this.ensureReady();\n const key = record.config.auth.publishableKey;\n const clash = await this.db.query(`SELECT id FROM frankie_tenants WHERE publishable_key = $1 AND id <> $2`, [\n key,\n record.id,\n ]);\n if (clash.rows.length > 0) {\n throw new Error(`Publishable key already belongs to tenant \"${String(clash.rows[0]?.id)}\".`);\n }\n await this.db.query(\n `INSERT INTO frankie_tenants (id, owner_id, publishable_key, record, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (id) DO UPDATE SET\n owner_id = EXCLUDED.owner_id,\n publishable_key = EXCLUDED.publishable_key,\n record = EXCLUDED.record,\n updated_at = EXCLUDED.updated_at`,\n [record.id, record.ownerId ?? null, key, JSON.stringify(record), record.createdAt, record.updatedAt],\n );\n }\n}\n","import { randomUUID } from \"node:crypto\";\nimport type { AgentMessage } from \"@earendil-works/pi-agent-core\";\nimport type { VerifiedUser } from \"./auth.js\";\n\nexport interface ChatSession {\n id: string;\n tenantId: string;\n user: VerifiedUser;\n /**\n * The raw host-signed token, kept only so it can be forwarded to MCP servers\n * the tenant has explicitly opted into. Never written to disk or logs.\n */\n userToken?: string;\n /** Page context the host passed at session creation. */\n context: Record<string, string | number | boolean | null>;\n /**\n * The conversation transcript. Frankie owns it outright — the agent loop is\n * stateless between turns and is handed this array each time — which is what\n * makes a session portable to a shared store, and exportable.\n */\n messages: AgentMessage[];\n createdAt: number;\n lastActiveAt: number;\n messageCount: number;\n costUsd: number;\n /** Set while a turn is in flight, so a second concurrent turn is rejected. */\n busy: boolean;\n}\n\nexport interface SessionStore {\n create(session: ChatSession): Promise<void>;\n get(id: string): Promise<ChatSession | undefined>;\n update(id: string, patch: Partial<ChatSession>): Promise<void>;\n delete(id: string): Promise<void>;\n}\n\nexport class MemorySessionStore implements SessionStore {\n private readonly sessions = new Map<string, ChatSession>();\n\n constructor(private readonly ttlMs = 6 * 60 * 60 * 1000) {}\n\n async create(session: ChatSession): Promise<void> {\n this.evictExpired();\n this.sessions.set(session.id, session);\n }\n\n async get(id: string): Promise<ChatSession | undefined> {\n const session = this.sessions.get(id);\n if (!session) return undefined;\n if (Date.now() - session.lastActiveAt > this.ttlMs) {\n this.sessions.delete(id);\n return undefined;\n }\n return session;\n }\n\n async update(id: string, patch: Partial<ChatSession>): Promise<void> {\n const session = this.sessions.get(id);\n if (!session) return;\n this.sessions.set(id, { ...session, ...patch, lastActiveAt: Date.now() });\n }\n\n async delete(id: string): Promise<void> {\n this.sessions.delete(id);\n }\n\n private evictExpired(): void {\n const cutoff = Date.now() - this.ttlMs;\n for (const [id, session] of this.sessions) {\n if (session.lastActiveAt < cutoff) this.sessions.delete(id);\n }\n }\n}\n\nexport function newSessionId(): string {\n return randomUUID();\n}\n","import type { ChatSession, SessionStore } from \"./sessions.js\";\nimport type { Queryable } from \"./control/postgres-store.js\";\n\n/**\n * A `SessionStore` backed by Postgres, so a user's chat history survives a\n * restart and is shared across instances — the hosted default. The whole\n * `ChatSession` (transcript included) is stored as one JSON row keyed by id.\n *\n * A customer who would rather keep transcripts in their own store implements\n * `SessionStore` against it and passes it to `createFrankieApp` — this class is\n * just the batteries-included option.\n */\nexport class PostgresSessionStore implements SessionStore {\n private readonly db: Queryable;\n private readonly ttlMs: number;\n private ready?: Promise<void>;\n\n constructor(options: { db: Queryable; ttlMs?: number }) {\n this.db = options.db;\n this.ttlMs = options.ttlMs ?? 6 * 60 * 60 * 1000;\n }\n\n /** Create the table on first use, once — lazy, so boot never blocks on the DB. */\n private ensureReady(): Promise<void> {\n return (this.ready ??= this.init());\n }\n\n async init(): Promise<void> {\n await this.db.query(\n `CREATE TABLE IF NOT EXISTS frankie_sessions (\n id text PRIMARY KEY,\n data text NOT NULL,\n last_active bigint NOT NULL\n )`,\n );\n }\n\n async create(session: ChatSession): Promise<void> {\n await this.ensureReady();\n await this.db.query(\n `INSERT INTO frankie_sessions (id, data, last_active) VALUES ($1, $2, $3)\n ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, last_active = EXCLUDED.last_active`,\n [session.id, JSON.stringify(session), session.lastActiveAt],\n );\n }\n\n async get(id: string): Promise<ChatSession | undefined> {\n await this.ensureReady();\n const { rows } = await this.db.query(`SELECT data, last_active FROM frankie_sessions WHERE id = $1`, [id]);\n if (rows.length === 0 || !rows[0]) return undefined;\n if (Date.now() - Number(rows[0].last_active) > this.ttlMs) {\n await this.delete(id);\n return undefined;\n }\n return JSON.parse(String(rows[0].data)) as ChatSession;\n }\n\n async update(id: string, patch: Partial<ChatSession>): Promise<void> {\n const existing = await this.get(id);\n if (!existing) return;\n const next: ChatSession = { ...existing, ...patch, lastActiveAt: Date.now() };\n await this.db.query(`UPDATE frankie_sessions SET data = $2, last_active = $3 WHERE id = $1`, [\n id,\n JSON.stringify(next),\n next.lastActiveAt,\n ]);\n }\n\n async delete(id: string): Promise<void> {\n await this.ensureReady();\n await this.db.query(`DELETE FROM frankie_sessions WHERE id = $1`, [id]);\n }\n}\n","import { FileTenantStore, type ControlStore } from \"./store.js\";\nimport { PostgresTenantStore } from \"./postgres-store.js\";\nimport { MemorySessionStore, type SessionStore } from \"../sessions.js\";\nimport { PostgresSessionStore } from \"../postgres-sessions.js\";\n\nexport interface ResolvedStore {\n store: ControlStore;\n describe: string;\n}\n\nexport interface ResolvedStores {\n tenants: ControlStore;\n sessions: SessionStore;\n describe: string;\n}\n\n/**\n * Both stores the data plane needs, sharing one Postgres pool when\n * DATABASE_URL is set (so tenant config and chat transcripts live in the same\n * database), or a file tenant store + in-memory sessions otherwise.\n */\nexport async function resolveStores(env: NodeJS.ProcessEnv = process.env): Promise<ResolvedStores> {\n const url = env[\"DATABASE_URL\"];\n if (url) {\n const { Pool } = await import(\"pg\");\n const pool = new Pool({\n connectionString: url,\n max: 4,\n ...(env[\"DATABASE_SSL\"] === \"disable\" ? {} : { ssl: { rejectUnauthorized: false } }),\n });\n // Stores connect lazily on first use, so the server can start listening\n // before the database is reachable (Cloud Run kills a slow-to-listen boot).\n const tenants = new PostgresTenantStore({ db: pool, env });\n const sessions = new PostgresSessionStore({ db: pool });\n return { tenants, sessions, describe: \"postgres\" };\n }\n\n const dir = env[\"FRANKIE_TENANTS_DIR\"] ?? \"./.frankie/tenants\";\n return { tenants: new FileTenantStore({ dir, env }), sessions: new MemorySessionStore(), describe: `files:${dir}` };\n}\n\n/**\n * Pick the tenant store from the environment, so the same binary runs in every\n * shape:\n *\n * - DATABASE_URL set → Postgres, the production shape. The web app (Vercel),\n * the control plane and the data plane (Cloud Run) share no filesystem, so\n * tenants live in a database all three reach.\n * - otherwise → a file under FRANKIE_TENANTS_DIR, for a single host or local\n * development, where one persistent disk is shared.\n */\nexport async function resolveTenantStore(env: NodeJS.ProcessEnv = process.env): Promise<ResolvedStore> {\n const url = env[\"DATABASE_URL\"];\n if (url) {\n // Imported lazily so a file-store deployment never has to install `pg`.\n const { Pool } = await import(\"pg\");\n const pool = new Pool({\n connectionString: url,\n // Managed Postgres (Neon, Supabase, Cloud SQL over the proxy) expects TLS;\n // opt out only for a local database via DATABASE_SSL=disable.\n ...(env[\"DATABASE_SSL\"] === \"disable\" ? {} : { ssl: { rejectUnauthorized: false } }),\n });\n const store = new PostgresTenantStore({ db: pool, env }); // lazy init on first use\n return { store, describe: \"postgres\" };\n }\n\n const dir = env[\"FRANKIE_TENANTS_DIR\"] ?? \"./.frankie/tenants\";\n return { store: new FileTenantStore({ dir, env }), describe: `files:${dir}` };\n}\n"],"mappings":"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","names":["parseFrankieConfig"]}
@@ -1,148 +0,0 @@
1
- import {
2
- safeEqual,
3
- toPublicTenant
4
- } from "./chunk-ACXNDAOM.js";
5
-
6
- // src/control/app.ts
7
- import { randomBytes } from "crypto";
8
- import { Hono } from "hono";
9
- import { parseFrankieConfig } from "@frankieai/core";
10
- function createControlApp(options) {
11
- const { store } = options;
12
- const adminToken = options.adminToken ?? process.env["FRANKIE_ADMIN_TOKEN"];
13
- const log = options.logger ?? ((event) => console.log(JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...event })));
14
- const app = new Hono();
15
- app.use("/admin/*", async (c, next) => {
16
- if (!adminToken) {
17
- return c.json({ error: "Control plane is locked: set FRANKIE_ADMIN_TOKEN." }, 503);
18
- }
19
- const bearer = c.req.header("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
20
- if (!safeEqual(bearer, adminToken)) {
21
- return c.json({ error: "Unauthorized." }, 401);
22
- }
23
- await next();
24
- return;
25
- });
26
- const ownerOf = (c) => c.req.header("x-frankie-owner") || void 0;
27
- app.post("/admin/tenants", async (c) => {
28
- const body = await readJson(c) ?? {};
29
- if (!body.name?.trim()) return c.json({ error: "`name` is required." }, 400);
30
- const ownerId = ownerOf(c);
31
- let id = slug(body.name);
32
- if (await store.get(id)) id = `${id}-${randomBytes(3).toString("hex")}`;
33
- const publishableKey = `pk_live_${randomBytes(12).toString("hex")}`;
34
- const jwtSecret = randomBytes(36).toString("base64");
35
- const sessionSecret = randomBytes(36).toString("base64");
36
- let config;
37
- try {
38
- config = parseFrankieConfig({
39
- tenantId: id,
40
- branding: { name: body.name },
41
- agent: {
42
- ...body.goal ? { systemPrompt: body.goal } : {},
43
- ...body.greeting ? { greeting: body.greeting } : {}
44
- },
45
- auth: {
46
- publishableKey,
47
- allowedOrigins: body.allowedOrigins ?? [],
48
- ...body.allowAnonymous ? { allowAnonymous: true } : {}
49
- }
50
- });
51
- } catch (error) {
52
- return c.json({ error: `Invalid tenant: ${error.message}` }, 400);
53
- }
54
- const now = (/* @__PURE__ */ new Date()).toISOString();
55
- const record = {
56
- id,
57
- ...ownerId ? { ownerId } : {},
58
- config,
59
- jwtSecret,
60
- sessionSecret,
61
- createdAt: now,
62
- updatedAt: now
63
- };
64
- await store.put(record);
65
- log({ level: "info", event: "tenant_created", tenant: id, ...ownerId ? { owner: ownerId } : {} });
66
- return c.json(
67
- {
68
- tenant: toPublicTenant(record),
69
- secrets: { publishableKey, jwtSecret },
70
- next: "Set FRANKIE_JWT_SECRET to the jwtSecret on your backend, then run `frankie init`."
71
- },
72
- 201
73
- );
74
- });
75
- const own = async (c) => {
76
- const id = c.req.param("id");
77
- const record = id ? await store.get(id) : null;
78
- if (!record) return null;
79
- const owner = ownerOf(c);
80
- if (owner && record.ownerId && record.ownerId !== owner) return null;
81
- return record;
82
- };
83
- app.get("/admin/tenants", async (c) => {
84
- return c.json({ tenants: (await store.list(ownerOf(c))).map(toPublicTenant) });
85
- });
86
- app.get("/admin/tenants/:id", async (c) => {
87
- const record = await own(c);
88
- if (!record) return c.json({ error: "No such tenant." }, 404);
89
- return c.json({ tenant: toPublicTenant(record) });
90
- });
91
- app.put("/admin/tenants/:id/connection", async (c) => {
92
- const record = await own(c);
93
- if (!record) return c.json({ error: "No such tenant." }, 404);
94
- const body = await readJson(c) ?? {};
95
- let config;
96
- try {
97
- config = parseFrankieConfig({
98
- ...record.config,
99
- mcpServers: body.mcpServers ?? [],
100
- apis: body.apis ?? []
101
- });
102
- } catch (error) {
103
- return c.json({ error: `Invalid connection: ${error.message}` }, 400);
104
- }
105
- const updated = { ...record, config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
106
- await store.put(updated);
107
- log({ level: "info", event: "tenant_connection_updated", tenant: record.id });
108
- return c.json({ tenant: toPublicTenant(updated) });
109
- });
110
- app.put("/admin/tenants/:id/origins", async (c) => {
111
- const record = await own(c);
112
- if (!record) return c.json({ error: "No such tenant." }, 404);
113
- const body = await readJson(c) ?? {};
114
- let config;
115
- try {
116
- config = parseFrankieConfig({
117
- ...record.config,
118
- auth: { ...record.config.auth, allowedOrigins: body.allowedOrigins ?? [] }
119
- });
120
- } catch (error) {
121
- return c.json({ error: `Invalid origins: ${error.message}` }, 400);
122
- }
123
- const updated = { ...record, config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
124
- await store.put(updated);
125
- return c.json({ tenant: toPublicTenant(updated) });
126
- });
127
- app.onError((error, c) => {
128
- const message = error instanceof Error ? error.message : "Internal error.";
129
- log({ level: "error", event: "control_error", message });
130
- return c.json({ error: `Control plane error: ${message}` }, 500);
131
- });
132
- return app;
133
- }
134
- async function readJson(c) {
135
- try {
136
- return await c.req.json();
137
- } catch {
138
- return null;
139
- }
140
- }
141
- function slug(value) {
142
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "tenant";
143
- }
144
-
145
- export {
146
- createControlApp
147
- };
148
- //# sourceMappingURL=chunk-ZY4JZVCI.js.map