@frankieai/server 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -123,6 +123,8 @@ function toPublicTenant(record) {
123
123
  connection: config.apis.length > 0 ? "openapi" : config.mcpServers.length > 0 ? "mcp" : "knowledge",
124
124
  mcpServers: config.mcpServers.map((s) => s.name),
125
125
  apis: config.apis.map((a) => a.name),
126
+ theme: config.theme,
127
+ features: config.features,
126
128
  createdAt: record.createdAt,
127
129
  updatedAt: record.updatedAt
128
130
  };
@@ -433,4 +435,4 @@ export {
433
435
  resolveStores,
434
436
  resolveTenantStore
435
437
  };
436
- //# sourceMappingURL=chunk-ACXNDAOM.js.map
438
+ //# sourceMappingURL=chunk-F227CSY3.js.map
@@ -0,0 +1 @@
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 /** The customer's brand theme for the widget, so the dashboard can edit it. */\n theme: TenantRecord[\"config\"][\"theme\"];\n /** Opt-in chat capabilities (attachments, model picker) for the dashboard to edit. */\n features: TenantRecord[\"config\"][\"features\"];\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 theme: config.theme,\n features: config.features,\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,7 +1,7 @@
1
1
  import {
2
2
  safeEqual,
3
3
  toPublicTenant
4
- } from "./chunk-ACXNDAOM.js";
4
+ } from "./chunk-F227CSY3.js";
5
5
 
6
6
  // src/control/app.ts
7
7
  import { randomBytes } from "crypto";
@@ -102,6 +102,13 @@ function createControlApp(options) {
102
102
  } catch (error) {
103
103
  return c.json({ error: `Invalid connection: ${error.message}` }, 400);
104
104
  }
105
+ const allow = new Set(config.agent.tools.allow);
106
+ for (const api of config.apis) allow.add(`api__${api.name}`);
107
+ for (const server of config.mcpServers) allow.add(`mcp__${server.name}`);
108
+ config = parseFrankieConfig({
109
+ ...config,
110
+ agent: { ...config.agent, tools: { ...config.agent.tools, allow: [...allow] } }
111
+ });
105
112
  const updated = { ...record, config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
106
113
  await store.put(updated);
107
114
  log({ level: "info", event: "tenant_connection_updated", tenant: record.id });
@@ -124,6 +131,45 @@ function createControlApp(options) {
124
131
  await store.put(updated);
125
132
  return c.json({ tenant: toPublicTenant(updated) });
126
133
  });
134
+ app.put("/admin/tenants/:id/theme", async (c) => {
135
+ const record = await own(c);
136
+ if (!record) return c.json({ error: "No such tenant." }, 404);
137
+ const body = await readJson(c) ?? {};
138
+ let config;
139
+ try {
140
+ config = parseFrankieConfig({ ...record.config, theme: body.theme ?? {} });
141
+ } catch (error) {
142
+ return c.json({ error: `Invalid theme: ${error.message}` }, 400);
143
+ }
144
+ const updated = { ...record, config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
145
+ await store.put(updated);
146
+ log({ level: "info", event: "tenant_theme_updated", tenant: record.id });
147
+ return c.json({ tenant: toPublicTenant(updated) });
148
+ });
149
+ app.put("/admin/tenants/:id/features", async (c) => {
150
+ const record = await own(c);
151
+ if (!record) return c.json({ error: "No such tenant." }, 404);
152
+ const body = await readJson(c) ?? {};
153
+ let config;
154
+ try {
155
+ config = parseFrankieConfig({ ...record.config, features: body.features ?? {} });
156
+ } catch (error) {
157
+ return c.json({ error: `Invalid features: ${error.message}` }, 400);
158
+ }
159
+ const updated = { ...record, config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
160
+ await store.put(updated);
161
+ log({ level: "info", event: "tenant_features_updated", tenant: record.id });
162
+ return c.json({ tenant: toPublicTenant(updated) });
163
+ });
164
+ app.post("/admin/tenants/:id/rotate-jwt-secret", async (c) => {
165
+ const record = await own(c);
166
+ if (!record) return c.json({ error: "No such tenant." }, 404);
167
+ const jwtSecret = randomBytes(36).toString("base64");
168
+ const updated = { ...record, jwtSecret, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
169
+ await store.put(updated);
170
+ log({ level: "info", event: "tenant_secret_rotated", tenant: record.id });
171
+ return c.json({ tenant: toPublicTenant(updated), secrets: { jwtSecret } });
172
+ });
127
173
  app.onError((error, c) => {
128
174
  const message = error instanceof Error ? error.message : "Internal error.";
129
175
  log({ level: "error", event: "control_error", message });
@@ -145,4 +191,4 @@ function slug(value) {
145
191
  export {
146
192
  createControlApp
147
193
  };
148
- //# sourceMappingURL=chunk-ZY4JZVCI.js.map
194
+ //# sourceMappingURL=chunk-S7OCXWEP.js.map
@@ -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 // Connecting a source means \"let the agent use it\": add each source's prefix\n // to the allow list. Tool policy stays deny-by-default, but the tools you\n // just connected are no longer denied by it — otherwise they generate and\n // are immediately filtered out, and the connection does nothing. Methods\n // still default to GET-only, so this is read access, not a blank cheque.\n // Tools are named `api__<name>__<op>` / `mcp__<name>__<tool>`; naming the\n // `api__<name>` / `mcp__<name>` prefix allows every tool from that source.\n const allow = new Set(config.agent.tools.allow);\n for (const api of config.apis) allow.add(`api__${api.name}`);\n for (const server of config.mcpServers) allow.add(`mcp__${server.name}`);\n config = parseFrankieConfig({\n ...config,\n agent: { ...config.agent, tools: { ...config.agent.tools, allow: [...allow] } },\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 /** Set the customer's brand theme for the widget (colors, radius, light/dark). */\n app.put(\"/admin/tenants/:id/theme\", 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<{ theme?: unknown }>(c)) ?? {};\n let config;\n try {\n config = parseFrankieConfig({ ...record.config, theme: body.theme ?? {} });\n } catch (error) {\n return c.json({ error: `Invalid theme: ${(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_theme_updated\", tenant: record.id });\n return c.json({ tenant: toPublicTenant(updated) });\n });\n\n /** Set the opt-in chat capabilities (attachments, model picker) for the widget. */\n app.put(\"/admin/tenants/:id/features\", 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<{ features?: unknown }>(c)) ?? {};\n let config;\n try {\n config = parseFrankieConfig({ ...record.config, features: body.features ?? {} });\n } catch (error) {\n return c.json({ error: `Invalid features: ${(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_features_updated\", tenant: record.id });\n return c.json({ tenant: toPublicTenant(updated) });\n });\n\n /**\n * Rotate the tenant's JWT secret and return the new value once, exactly as at\n * creation. The secret is shown once and never stored retrievably, so this is\n * the way back when it is lost — no need to recreate the workspace. Rotating\n * invalidates any user token signed with the old secret, so the customer must\n * update `FRANKIE_JWT_SECRET` on their backend to the new value.\n */\n app.post(\"/admin/tenants/:id/rotate-jwt-secret\", async (c) => {\n const record = await own(c);\n if (!record) return c.json({ error: \"No such tenant.\" }, 404);\n\n const jwtSecret = randomBytes(36).toString(\"base64\");\n const updated: TenantRecord = { ...record, jwtSecret, updatedAt: new Date().toISOString() };\n await store.put(updated);\n log({ level: \"info\", event: \"tenant_secret_rotated\", tenant: record.id });\n\n return c.json({ tenant: toPublicTenant(updated), secrets: { jwtSecret } });\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":[]}
@@ -6,37 +6,7 @@ import {
6
6
  isOriginAllowed,
7
7
  newSessionId,
8
8
  safeEqual
9
- } from "./chunk-ACXNDAOM.js";
10
-
11
- // src/rate-limit.ts
12
- var MemoryRateLimiter = class {
13
- hits = /* @__PURE__ */ new Map();
14
- lastSweep = Date.now();
15
- async take(key, limit, windowSeconds) {
16
- const now = Date.now();
17
- const cutoff = now - windowSeconds * 1e3;
18
- this.sweep(now);
19
- const timestamps = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
20
- if (timestamps.length >= limit) {
21
- this.hits.set(key, timestamps);
22
- return false;
23
- }
24
- timestamps.push(now);
25
- this.hits.set(key, timestamps);
26
- return true;
27
- }
28
- /** Drop empty buckets occasionally so a long-lived process does not grow. */
29
- sweep(now) {
30
- if (now - this.lastSweep < 6e4) return;
31
- this.lastSweep = now;
32
- const horizon = now - 60 * 60 * 1e3;
33
- for (const [key, timestamps] of this.hits) {
34
- const live = timestamps.filter((t) => t > horizon);
35
- if (live.length === 0) this.hits.delete(key);
36
- else this.hits.set(key, live);
37
- }
38
- }
39
- };
9
+ } from "./chunk-F227CSY3.js";
40
10
 
41
11
  // src/agent/models.ts
42
12
  import { builtinModels } from "@earendil-works/pi-ai/providers/all";
@@ -65,10 +35,53 @@ function resolveModel(config) {
65
35
  };
66
36
  return { models, model };
67
37
  }
38
+ function resolveModelChoice(catalogue, config, requestedId) {
39
+ const choice = config.features.modelPicker.models.find((entry) => entry.id === requestedId);
40
+ const isDefault = requestedId === config.model.model;
41
+ if (!choice && !isDefault) return null;
42
+ const provider = choice?.provider ?? config.model.provider;
43
+ const found = catalogue.getModel(provider, requestedId);
44
+ if (!found) return null;
45
+ return {
46
+ ...found,
47
+ ...config.model.baseUrl ? { baseUrl: config.model.baseUrl } : {},
48
+ maxTokens: Math.min(config.model.maxTokens, found.maxTokens)
49
+ };
50
+ }
68
51
  function modelHasApi(model, api) {
69
52
  return hasApi(model, api);
70
53
  }
71
54
 
55
+ // src/rate-limit.ts
56
+ var MemoryRateLimiter = class {
57
+ hits = /* @__PURE__ */ new Map();
58
+ lastSweep = Date.now();
59
+ async take(key, limit, windowSeconds) {
60
+ const now = Date.now();
61
+ const cutoff = now - windowSeconds * 1e3;
62
+ this.sweep(now);
63
+ const timestamps = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
64
+ if (timestamps.length >= limit) {
65
+ this.hits.set(key, timestamps);
66
+ return false;
67
+ }
68
+ timestamps.push(now);
69
+ this.hits.set(key, timestamps);
70
+ return true;
71
+ }
72
+ /** Drop empty buckets occasionally so a long-lived process does not grow. */
73
+ sweep(now) {
74
+ if (now - this.lastSweep < 6e4) return;
75
+ this.lastSweep = now;
76
+ const horizon = now - 60 * 60 * 1e3;
77
+ for (const [key, timestamps] of this.hits) {
78
+ const live = timestamps.filter((t) => t > horizon);
79
+ if (live.length === 0) this.hits.delete(key);
80
+ else this.hits.set(key, live);
81
+ }
82
+ }
83
+ };
84
+
72
85
  // src/tenants.ts
73
86
  var TenantRegistry = class _TenantRegistry {
74
87
  constructor(store) {
@@ -785,10 +798,14 @@ async function* runTurn(params) {
785
798
  let turnCount = 0;
786
799
  let lastAssistant;
787
800
  const usage = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
801
+ const { images, documentText } = splitAttachments(params.attachments ?? []);
802
+ const promptText = documentText ? `${params.message}
803
+
804
+ ${documentText}` : params.message;
788
805
  const agent = new Agent({
789
806
  initialState: {
790
807
  systemPrompt: buildSystemPrompt(config, session),
791
- model: params.model,
808
+ model: params.modelOverride ?? params.model,
792
809
  thinkingLevel: config.model.thinkingLevel,
793
810
  tools: toolset.tools,
794
811
  messages: session.messages
@@ -829,7 +846,7 @@ async function* runTurn(params) {
829
846
  });
830
847
  const onAbort = () => agent.abort();
831
848
  params.signal.addEventListener("abort", onAbort, { once: true });
832
- const run = agent.prompt(params.message).then(() => void 0).catch((error) => {
849
+ const run = agent.prompt(promptText, images.length ? images : void 0).then(() => void 0).catch((error) => {
833
850
  queue.push({
834
851
  type: "error",
835
852
  code: "upstream_error",
@@ -914,6 +931,30 @@ function toUserFacingError(error) {
914
931
  if (/api.?key|unauthor|\b401\b|\b403\b/i.test(raw)) return "The assistant is misconfigured. We've been notified.";
915
932
  return "Something went wrong on our side. Try again in a moment.";
916
933
  }
934
+ function splitAttachments(attachments) {
935
+ const images = [];
936
+ const docs = [];
937
+ for (const file of attachments) {
938
+ if (file.mimeType.startsWith("image/")) {
939
+ images.push({ type: "image", data: file.data, mimeType: file.mimeType });
940
+ } else if (file.mimeType.startsWith("text/") || file.mimeType === "application/json") {
941
+ docs.push(`[Attached file: ${file.name}]
942
+ ${decodeBase64Text(file.data)}`);
943
+ } else {
944
+ docs.push(`[Attached file: ${file.name} (${file.mimeType}) \u2014 attached, but its contents can't be read here.]`);
945
+ }
946
+ }
947
+ return { images, documentText: docs.join("\n\n") };
948
+ }
949
+ function decodeBase64Text(data) {
950
+ try {
951
+ const text = Buffer.from(data, "base64").toString("utf8");
952
+ return text.length > 2e4 ? `${text.slice(0, 2e4)}
953
+ \u2026[truncated]` : text;
954
+ } catch {
955
+ return "[could not decode file]";
956
+ }
957
+ }
917
958
  var EventQueue = class {
918
959
  items = [];
919
960
  resolve;
@@ -1092,7 +1133,19 @@ function createFrankieApp(options) {
1092
1133
  suggestedPrompts: config.agent.suggestedPrompts,
1093
1134
  theme: config.theme,
1094
1135
  allowAnonymous: config.auth.allowAnonymous,
1095
- showToolActivity: config.agent.showToolActivity
1136
+ showToolActivity: config.agent.showToolActivity,
1137
+ features: {
1138
+ attachments: {
1139
+ enabled: config.features.attachments.enabled,
1140
+ accept: config.features.attachments.accept,
1141
+ maxMb: config.features.attachments.maxMb,
1142
+ maxCount: config.features.attachments.maxCount
1143
+ },
1144
+ modelPicker: {
1145
+ enabled: config.features.modelPicker.enabled,
1146
+ models: config.features.modelPicker.models.map((entry) => ({ id: entry.id, label: entry.label }))
1147
+ }
1148
+ }
1096
1149
  };
1097
1150
  return c.json(publicConfig);
1098
1151
  });
@@ -1163,6 +1216,26 @@ function createFrankieApp(options) {
1163
1216
  const body = await readJson(c);
1164
1217
  if (!body?.message?.trim()) return jsonError(c, 400, "internal", "`message` is required.");
1165
1218
  if (body.message.length > 8e3) return jsonError(c, 400, "internal", "That message is too long.");
1219
+ const attachments = Array.isArray(body.attachments) ? body.attachments : [];
1220
+ if (attachments.length) {
1221
+ const limits = config.features.attachments;
1222
+ if (!limits.enabled) return jsonError(c, 400, "internal", "Attachments aren't enabled for this assistant.");
1223
+ if (attachments.length > limits.maxCount) {
1224
+ return jsonError(c, 400, "internal", `Attach at most ${limits.maxCount} file(s).`);
1225
+ }
1226
+ const maxBytes = limits.maxMb * 1024 * 1024;
1227
+ for (const file of attachments) {
1228
+ if (typeof file?.data !== "string" || typeof file?.mimeType !== "string" || !file.mimeType) {
1229
+ return jsonError(c, 400, "internal", "An attachment was malformed.");
1230
+ }
1231
+ if (!mimeAccepted(file.mimeType, limits.accept)) {
1232
+ return jsonError(c, 400, "internal", `Files of type ${file.mimeType} aren't allowed here.`);
1233
+ }
1234
+ if (base64Bytes(file.data) > maxBytes) {
1235
+ return jsonError(c, 400, "internal", `Each file must be under ${limits.maxMb} MB.`);
1236
+ }
1237
+ }
1238
+ }
1166
1239
  const session = await sessions.get(claims.sessionId);
1167
1240
  if (!session) {
1168
1241
  return jsonError(c, 404, "session_not_found", "Your chat session expired. Reload the page to start again.");
@@ -1187,6 +1260,7 @@ function createFrankieApp(options) {
1187
1260
  const abort = new AbortController();
1188
1261
  const timeout = setTimeout(() => abort.abort(), config.limits.turnTimeoutMs);
1189
1262
  c.req.raw.signal?.addEventListener("abort", () => abort.abort());
1263
+ const modelOverride = body.model && body.model !== config.model.model && config.features.modelPicker.enabled ? resolveModelChoice(models, config, body.model) ?? void 0 : void 0;
1190
1264
  const current = await sessions.get(session.id) ?? session;
1191
1265
  const events = runTurn({
1192
1266
  config,
@@ -1195,6 +1269,8 @@ function createFrankieApp(options) {
1195
1269
  apiKey: secrets.providerApiKey,
1196
1270
  model,
1197
1271
  models,
1272
+ ...modelOverride ? { modelOverride } : {},
1273
+ ...attachments.length ? { attachments } : {},
1198
1274
  signal: abort.signal,
1199
1275
  onTranscript: (messages) => {
1200
1276
  void sessions.update(session.id, { messages });
@@ -1255,6 +1331,15 @@ function createFrankieApp(options) {
1255
1331
  });
1256
1332
  return app;
1257
1333
  }
1334
+ function mimeAccepted(mimeType, accept) {
1335
+ return accept.some(
1336
+ (pattern) => pattern.endsWith("/*") ? mimeType.startsWith(pattern.slice(0, -1)) : pattern === mimeType
1337
+ );
1338
+ }
1339
+ function base64Bytes(data) {
1340
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
1341
+ return Math.floor(data.length * 3 / 4) - padding;
1342
+ }
1258
1343
  async function readJson(c) {
1259
1344
  try {
1260
1345
  return await c.req.json();
@@ -1361,10 +1446,10 @@ async function loadResolvedConfig(path) {
1361
1446
  }
1362
1447
 
1363
1448
  export {
1364
- MemoryRateLimiter,
1365
1449
  ModelResolutionError,
1366
1450
  resolveModel,
1367
1451
  modelHasApi,
1452
+ MemoryRateLimiter,
1368
1453
  TenantRegistry,
1369
1454
  SingleTenantStore,
1370
1455
  MemoryTenantStore,
@@ -1387,4 +1472,4 @@ export {
1387
1472
  resolveSecrets,
1388
1473
  loadResolvedConfig
1389
1474
  };
1390
- //# sourceMappingURL=chunk-2YYKRPT7.js.map
1475
+ //# sourceMappingURL=chunk-UUOTMDWW.js.map