@mordn/chat-widget 0.8.1 → 0.10.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.
@@ -51,17 +51,23 @@ var DEFAULT_ALLOWED_MEDIA_TYPES = [
51
51
  ];
52
52
  var DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant.";
53
53
  var KNOWN_SEGMENTS = /* @__PURE__ */ new Set(["upload", "history"]);
54
- function json(body, status = 200) {
54
+ function json(body, status = 200, extraHeaders) {
55
55
  return new Response(JSON.stringify(body), {
56
56
  status,
57
- headers: { "Content-Type": "application/json" }
57
+ headers: { "Content-Type": "application/json", ...extraHeaders }
58
58
  });
59
59
  }
60
+ function jsonNoStore(body, status = 200) {
61
+ return json(body, status, { "Cache-Control": "no-store, private" });
62
+ }
60
63
  function subSegments(url) {
61
64
  const parts = url.pathname.split("/").filter(Boolean);
62
- const chatIdx = parts.lastIndexOf("chat");
63
- if (chatIdx === -1) return [];
64
- return parts.slice(chatIdx + 1);
65
+ for (let i = parts.length - 1; i >= 0; i--) {
66
+ if (KNOWN_SEGMENTS.has(parts[i])) {
67
+ return parts.slice(i);
68
+ }
69
+ }
70
+ return [];
65
71
  }
66
72
  function createChatHandler(options) {
67
73
  const {
@@ -71,6 +77,7 @@ function createChatHandler(options) {
71
77
  store: storeFactory,
72
78
  storage: storageFactory,
73
79
  buildSystemPrompt,
80
+ getHostedConfig,
74
81
  transformMessages,
75
82
  onChatFinish,
76
83
  onError,
@@ -89,11 +96,12 @@ function createChatHandler(options) {
89
96
  if (storageFactory) return storageFactory(userId);
90
97
  return null;
91
98
  }
92
- async function resolveModel(ctx) {
99
+ async function resolveModel(ctx, hostedModel) {
93
100
  if (typeof modelOption === "function") return modelOption(ctx);
94
101
  if (modelOption) return modelOption;
102
+ if (hostedModel) return hostedModel;
95
103
  throw new Error(
96
- "[chat-widget] No `model` provided. Pass a `model` (a LanguageModel or a function returning one)."
104
+ "[chat-widget] No `model` provided. Pass a `model` (a LanguageModel or a function returning one), or configure one via hosted config."
97
105
  );
98
106
  }
99
107
  async function authenticate(request, conversationId) {
@@ -134,8 +142,16 @@ function createChatHandler(options) {
134
142
  if (transformMessages) modelMessages = await transformMessages(modelMessages, ctx);
135
143
  const built = buildTools ? await buildTools(ctx) : { tools: {} };
136
144
  const tools = built.tools ?? {};
137
- const model = await resolveModel(ctx);
138
- const system = buildSystemPrompt ? await buildSystemPrompt(ctx) : DEFAULT_SYSTEM_PROMPT;
145
+ const hosted = getHostedConfig ? await (async () => {
146
+ try {
147
+ return await getHostedConfig(ctx);
148
+ } catch {
149
+ return null;
150
+ }
151
+ })() : null;
152
+ const model = await resolveModel(ctx, hosted?.model);
153
+ const modelLabel = typeof model === "string" ? model : model.modelId;
154
+ const system = buildSystemPrompt ? await buildSystemPrompt(ctx) : hosted?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
139
155
  let cleanedUp = false;
140
156
  const runCleanup = async (reason) => {
141
157
  if (cleanedUp || !built.cleanup) return;
@@ -174,7 +190,7 @@ function createChatHandler(options) {
174
190
  onFinish: async ({ messages: finalMessages, isAborted }) => {
175
191
  if (!isAborted && finalMessages.length > 0) {
176
192
  try {
177
- await store.saveTurn({ conversationId, messages: finalMessages });
193
+ await store.saveTurn({ conversationId, messages: finalMessages, model: modelLabel });
178
194
  } catch (err) {
179
195
  console.error(
180
196
  JSON.stringify({
@@ -212,7 +228,7 @@ function createChatHandler(options) {
212
228
  if (!ctx) return new Response("Unauthorized", { status: 401 });
213
229
  const store = resolveStore(ctx.userId);
214
230
  const conversations = await store.listConversations();
215
- return json({
231
+ return jsonNoStore({
216
232
  conversations: conversations.map((c) => ({
217
233
  id: c.id,
218
234
  title: c.title,
@@ -236,7 +252,7 @@ function createChatHandler(options) {
236
252
  if (!conversation) return json({ error: "Conversation not found" }, 404);
237
253
  const messages = await store.listMessages(conversationId, { limit: 100 });
238
254
  const rehydrated = storage ? await Promise.all(messages.map((m) => resignMessageAttachments(m, storage))) : messages;
239
- return json({
255
+ return jsonNoStore({
240
256
  conversation: {
241
257
  id: conversation.id,
242
258
  title: conversation.title,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/index.ts","../../src/server/chat-store.ts","../../src/server/handler.ts"],"sourcesContent":["/**\n * Server-core public surface.\n *\n * The pluggable contracts that define the widget's backend. Both the hosted\n * default and any BYO implementation satisfy these. The request router (added\n * next) depends only on these interfaces, never on a concrete DB/storage.\n *\n * Guarded by `server-only`: importing this into a client bundle is a build\n * error, since these types reference server-side concerns and the\n * implementations hold secrets (DB URLs, service keys).\n */\nimport 'server-only';\n\nexport type {\n StoredAttachment,\n StoredConversation,\n StoredMessage,\n ListMessagesOptions,\n SaveTurnInput,\n} from './types';\n\nexport type { ChatStore, ChatStoreFactory } from './chat-store';\nexport { ConversationOwnershipError } from './chat-store';\n\nexport type {\n StorageAdapter,\n StorageAdapterFactory,\n UploadInput,\n UploadResult,\n} from './storage-adapter';\n\nexport { createChatHandler } from './handler';\nexport type {\n CreateChatHandlerOptions,\n ChatRequestContext,\n BuiltTools,\n UploadPolicy,\n} from './handler-types';\n","/**\n * ChatStore — the persistence contract for chat conversations and messages.\n *\n * This is one of the two pluggable backends of the widget (the other is\n * `StorageAdapter` for attachments). The package ships a Drizzle/Postgres\n * implementation as the default; a hosted backend or a BYO store (Prisma,\n * raw SQL, DynamoDB, a test double) is simply another implementation of this\n * same interface.\n *\n * ──────────────────────────────────────────────────────────────────────────\n * The security model is in the shape of this API, not in its callers.\n * ──────────────────────────────────────────────────────────────────────────\n *\n * A `ChatStore` is *bound to one verified user* at construction time (see\n * `ChatStoreFactory`). None of its methods accept a `userId`. This is\n * deliberate and it is the core defence against the IDOR class of bug:\n *\n * - You cannot ask the store for \"conversation X belonging to user Y\",\n * because there is no parameter through which a foreign `userId` could\n * enter. The only user the store will ever read or write is the one it\n * was constructed with.\n *\n * - Every method is therefore *implicitly scoped*. `listConversations()`\n * returns only the bound user's conversations. `getConversation(id)`\n * returns `null` — not someone else's row — when `id` exists but belongs\n * to a different user. `saveTurn(...)` refuses (throws\n * `ConversationOwnershipError`) if `conversationId` exists under another\n * user.\n *\n * The route layer's job shrinks to: authenticate the request, derive the\n * real `userId` from the *server* session, construct a store bound to it,\n * and call methods. There is no per-route ownership check to forget, because\n * the store cannot be made to cross users.\n *\n * Implementations MUST uphold the contract documented on each method. The\n * Drizzle default does; if you write your own, these invariants are the\n * security boundary — treat them as load-bearing, not advisory.\n */\n\nimport type {\n ListMessagesOptions,\n SaveTurnInput,\n StoredConversation,\n StoredMessage,\n} from './types';\n\n/**\n * Thrown by mutating methods when the target conversation exists but is owned\n * by a different user than the one this store is bound to. Callers should map\n * this to an HTTP 403. (Read methods don't throw — they return `null`/`[]` —\n * so that probing for existence can't distinguish \"not found\" from\n * \"forbidden\", which would itself leak information.)\n */\nexport class ConversationOwnershipError extends Error {\n constructor(public readonly conversationId: string) {\n super(`Conversation ${conversationId} is not owned by the current user`);\n this.name = 'ConversationOwnershipError';\n }\n}\n\nexport interface ChatStore {\n /**\n * The user this store instance is bound to. Read-only; set at construction.\n * Exposed so the router can stamp it onto storage paths, logs, etc. — never\n * as something a caller can change.\n */\n readonly userId: string;\n\n // ── Conversations ──────────────────────────────────────────────────────\n\n /**\n * List the bound user's conversations, most-recently-updated first.\n * Returns `messageCount` on each row for the history list. Returns `[]`\n * (never throws) when the user has none.\n */\n listConversations(): Promise<StoredConversation[]>;\n\n /**\n * Fetch a single conversation by id, scoped to the bound user.\n *\n * Returns `null` when the conversation does not exist OR exists but belongs\n * to another user — the two cases are intentionally indistinguishable to\n * the caller (and thus to an attacker). Never returns another user's row.\n */\n getConversation(id: string): Promise<StoredConversation | null>;\n\n /**\n * Ensure a conversation row exists for `id`, owned by the bound user.\n *\n * - If no row exists for `id`: creates it, owned by the bound user, and\n * returns it.\n * - If a row exists and is owned by the bound user: returns it unchanged\n * (idempotent — safe to call at the top of every request).\n * - If a row exists but is owned by a *different* user: throws\n * `ConversationOwnershipError` and writes nothing.\n *\n * This is the single chokepoint that makes \"write into someone else's\n * conversation\" impossible: the router calls it before persisting any\n * message, so a forged conversation id is rejected before any data lands.\n */\n ensureConversation(id: string, init?: { title?: string }): Promise<StoredConversation>;\n\n /**\n * Rename a conversation owned by the bound user. No-op (does not throw) if\n * the conversation doesn't exist or isn't owned by the user — renaming is\n * not security-sensitive and silent failure is friendlier here.\n */\n renameConversation(id: string, title: string): Promise<void>;\n\n /**\n * Delete a conversation (and cascade its messages + attachment rows) owned\n * by the bound user. No-op if it doesn't exist or isn't owned by the user.\n * Returns `true` if a row was actually deleted, `false` otherwise — lets\n * the route return 404 vs 200 honestly without a separate existence check.\n *\n * Note: this deletes message *rows*. Purging the underlying attachment\n * blobs from storage is the router's job (it has the `StorageAdapter`),\n * driven off the attachments this method returns having referenced.\n */\n deleteConversation(id: string): Promise<boolean>;\n\n // ── Messages ───────────────────────────────────────────────────────────\n\n /**\n * Load messages for a conversation, scoped to the bound user, newest-first\n * internally but returned in chronological order (oldest → newest) ready to\n * render. Returns `[]` if the conversation doesn't exist or isn't owned by\n * the user — same non-distinguishing contract as `getConversation`.\n *\n * Honours `ListMessagesOptions` for pagination. Implementations MUST clamp\n * `limit` to a ceiling (default ceiling: 100) so a hostile client can't\n * request an unbounded page.\n */\n listMessages(conversationId: string, opts?: ListMessagesOptions): Promise<StoredMessage[]>;\n\n /**\n * Persist the final messages of a completed turn.\n *\n * Contract:\n * - MUST verify the conversation is owned by the bound user first; throws\n * `ConversationOwnershipError` otherwise (defence in depth — the router\n * already called `ensureConversation`, but `saveTurn` must not trust\n * that).\n * - MUST be idempotent on message id: a message whose id already exists is\n * skipped, not duplicated. (The AI SDK delivers stable ids; replays and\n * retries re-deliver them.)\n * - MUST persist each message's full `parts` array as the source of truth,\n * plus a denormalised text projection for previews.\n * - MUST bump the conversation's `updatedAt`.\n *\n * Errors other than ownership (e.g. a transient DB failure) propagate so\n * the router can log them loudly — a silently-dropped assistant turn is\n * exactly the bug we're trying to design out.\n */\n saveTurn(input: SaveTurnInput): Promise<void>;\n}\n\n/**\n * Constructs a `ChatStore` bound to a specific, already-verified user.\n *\n * The router calls this *after* it has authenticated the request and derived\n * `userId` from the server session — never from anything client-supplied.\n * Passing a client-controlled value here would reintroduce the very IDOR the\n * bound-store design exists to prevent, so implementations should treat\n * `userId` as a trusted server secret, not as request input.\n *\n * Construction is intended to be cheap (the underlying DB pool/connection is\n * shared across instances) so a fresh store per request is the norm.\n */\nexport type ChatStoreFactory = (userId: string) => ChatStore;\n","/**\n * createChatHandler — the request router and the \"OWN loop\".\n *\n * This is the heart of the redesign. It owns every piece of shared,\n * dangerous-to-get-wrong plumbing so a host app never writes it:\n *\n * • authentication gate (401 when getUserId returns null)\n * • conversation ownership (create-or-reject; never write a foreign row)\n * • idempotent user-message persistence\n * • sliding-window context pruning + defensive per-message capping\n * • per-request tool resources with guaranteed single teardown\n * • streaming the model response\n * • save-on-finish persistence of the assistant turn\n * • history list + history-by-id with attachment re-signing\n * • uploads to private storage with server-side policy enforcement\n *\n * It exposes only the seams in `CreateChatHandlerOptions`. Nothing security-\n * or correctness-critical is configurable, by design.\n *\n * Mounting: the returned `{ GET, POST }` is designed to sit on a single\n * catch-all route, `app/api/chat/[[...chat]]/route.ts`, so one file mounts the\n * whole backend. The handler dispatches on the trailing path segments:\n *\n * POST /api/chat → chat (stream)\n * POST /api/chat/upload → attachment upload\n * GET /api/chat/history → conversation list\n * GET /api/chat/history/:id → one conversation + messages\n * DELETE /api/chat/history/:id → delete a conversation\n */\n\nimport 'server-only';\nimport {\n convertToModelMessages,\n generateId,\n stepCountIs,\n streamText,\n type LanguageModel,\n type ModelMessage,\n type ToolSet,\n type UIMessage,\n} from 'ai';\n\nimport { ConversationOwnershipError, type ChatStore } from './chat-store';\nimport type { StorageAdapter } from './storage-adapter';\nimport type {\n ChatRequestContext,\n CreateChatHandlerOptions,\n UploadPolicy,\n} from './handler-types';\n\n// ── Defaults ────────────────────────────────────────────────────────────────\n\nconst DEFAULT_MAX_HISTORY_MESSAGES = 30;\nconst DEFAULT_MAX_MESSAGE_CHARS = 4000;\nconst DEFAULT_STEP_BUDGET = 10;\nconst DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;\nconst DEFAULT_ALLOWED_MEDIA_TYPES = [\n 'image/png',\n 'image/jpeg',\n 'image/webp',\n 'image/gif',\n 'application/pdf',\n];\nconst DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant.';\n\n// Internal: the base path the handler is mounted under, used to compute the\n// sub-route from the request URL. Derived from the request, not hardcoded, so\n// the handler works whether mounted at /api/chat or somewhere else.\nconst KNOWN_SEGMENTS = new Set(['upload', 'history']);\n\n// ── Small helpers ─────────────────────────────────────────────────────────\n\nfunction json(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'Content-Type': 'application/json' },\n });\n}\n\n/**\n * Split the request path into the segments *after* the handler's mount point.\n * We locate the mount point by finding the last \"chat\" segment that's followed\n * only by known sub-routes (or nothing). This keeps the handler agnostic to\n * the exact mount path.\n */\nfunction subSegments(url: URL): string[] {\n const parts = url.pathname.split('/').filter(Boolean);\n // Find the final \"chat\" segment — everything after it is our sub-route.\n const chatIdx = parts.lastIndexOf('chat');\n if (chatIdx === -1) return [];\n return parts.slice(chatIdx + 1);\n}\n\n// ── The handler ─────────────────────────────────────────────────────────────\n\nexport function createChatHandler(options: CreateChatHandlerOptions) {\n const {\n getUserId,\n model: modelOption,\n buildTools,\n store: storeFactory,\n storage: storageFactory,\n buildSystemPrompt,\n transformMessages,\n onChatFinish,\n onError,\n stopWhen,\n upload,\n maxHistoryMessages = DEFAULT_MAX_HISTORY_MESSAGES,\n maxMessageChars = DEFAULT_MAX_MESSAGE_CHARS,\n } = options;\n\n // The hosted default store/storage are resolved lazily so a BYO consumer who\n // passes their own never triggers our default's env-var requirements.\n function resolveStore(userId: string): ChatStore {\n if (storeFactory) return storeFactory(userId);\n // The hosted/default Drizzle store is wired in a later step. Until then,\n // a BYO `store` is required. Failing loudly here is correct: a silent\n // no-op store would drop every message.\n throw new Error(\n '[chat-widget] No `store` provided and the hosted default store is not ' +\n 'configured. Pass a `store` factory (see createDrizzleChatStore).',\n );\n }\n\n function resolveStorage(userId: string): StorageAdapter | null {\n if (storageFactory) return storageFactory(userId);\n return null; // uploads disabled when no storage configured\n }\n\n async function resolveModel(ctx: ChatRequestContext): Promise<LanguageModel> {\n if (typeof modelOption === 'function') return modelOption(ctx);\n if (modelOption) return modelOption;\n throw new Error(\n '[chat-widget] No `model` provided. Pass a `model` (a LanguageModel or a ' +\n 'function returning one).',\n );\n }\n\n // Authenticate and build the per-request context. Returns null when the\n // request is unauthenticated — callers turn that into a 401.\n async function authenticate(request: Request, conversationId: string): Promise<ChatRequestContext | null> {\n const userId = await getUserId(request);\n if (!userId) return null;\n return { userId, conversationId, request };\n }\n\n // ── POST /chat ─────────────────────────────────────────────────────────\n async function handleChat(request: Request): Promise<Response> {\n let body: { messages?: UIMessage[]; id?: string };\n try {\n body = await request.json();\n } catch {\n return json({ error: 'Invalid JSON body' }, 400);\n }\n const conversationId = typeof body.id === 'string' && body.id ? body.id : undefined;\n if (!conversationId) return json({ error: 'Missing conversation id' }, 400);\n\n const ctx = await authenticate(request, conversationId);\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n\n // Sanitise the incoming array: drop anything that isn't a well-formed\n // message (null/undefined, missing role, missing parts). A malformed entry\n // must never crash the turn — skip it rather than throw downstream.\n const incoming = (Array.isArray(body.messages) ? body.messages : []).filter(\n (m): m is UIMessage =>\n !!m && typeof m === 'object' && typeof m.role === 'string' && Array.isArray(m.parts),\n );\n const store = resolveStore(ctx.userId);\n\n // Ownership chokepoint: create the conversation for this user, or reject\n // (403) if the id belongs to someone else. Nothing is persisted on reject.\n try {\n await store.ensureConversation(conversationId);\n } catch (err) {\n if (err instanceof ConversationOwnershipError) {\n return new Response('Forbidden', { status: 403 });\n }\n throw err;\n }\n\n // Persist the latest user message idempotently (the store dedupes on id).\n const lastUser = [...incoming].reverse().find((m) => m.role === 'user');\n if (lastUser) {\n await store.saveTurn({ conversationId, messages: [lastUser] });\n }\n\n // Sliding-window prune + defensive char-cap, then the host's transform.\n const windowed = incoming.slice(-maxHistoryMessages);\n const capped = maxMessageChars > 0 ? capMessages(windowed, maxMessageChars) : windowed;\n let modelMessages: ModelMessage[] = await convertToModelMessages(capped);\n if (transformMessages) modelMessages = await transformMessages(modelMessages, ctx);\n\n // Build tools (with their per-request resource) and resolve the model.\n const built = buildTools ? await buildTools(ctx) : { tools: {} as ToolSet };\n const tools = built.tools ?? ({} as ToolSet);\n const model = await resolveModel(ctx);\n const system = buildSystemPrompt ? await buildSystemPrompt(ctx) : DEFAULT_SYSTEM_PROMPT;\n\n // Single, guarded teardown of the tools' per-request resource. Fires\n // exactly once across all completion paths (finish / error / abort).\n let cleanedUp = false;\n const runCleanup = async (reason: string) => {\n if (cleanedUp || !built.cleanup) return;\n cleanedUp = true;\n try {\n await built.cleanup();\n } catch (err) {\n console.error(`[chat-widget] tool cleanup failed (${reason}):`, err);\n }\n };\n request.signal.addEventListener('abort', () => void runCleanup('client-abort'));\n\n // streamText's own onFinish is the only place usage + providerMetadata are\n // available (the UI-stream onFinish below exposes neither). Capture them\n // here so the host's onChatFinish hook gets real numbers, not undefined.\n let finalUsage: unknown;\n let finalProviderMetadata: unknown;\n\n const result = streamText({\n model,\n system,\n messages: modelMessages,\n tools,\n stopWhen: stopWhen ?? stepCountIs(DEFAULT_STEP_BUDGET),\n onFinish: ({ usage, providerMetadata }) => {\n finalUsage = usage;\n finalProviderMetadata = providerMetadata;\n },\n });\n\n return result.toUIMessageStreamResponse({\n sendSources: true,\n sendReasoning: true,\n // REQUIRED for correct persistence. Without `generateMessageId` the\n // assistant message comes back with an empty id, so every assistant turn\n // in a conversation collides on the same '' primary key and only the\n // first one survives `saveTurn`'s idempotent insert. `originalMessages`\n // lets the SDK reuse existing ids (preventing duplicates) and return the\n // full original+response set in onFinish.\n originalMessages: incoming,\n generateMessageId: generateId,\n onFinish: async ({ messages: finalMessages, isAborted }) => {\n // Don't persist a turn the client aborted mid-stream — the assistant\n // message is partial and the user didn't receive it. The idempotent\n // user-message save already happened before streaming.\n if (!isAborted && finalMessages.length > 0) {\n // Persist the assistant turn. Errors here are logged loudly — a\n // silently-dropped turn is the exact failure we designed against —\n // but never thrown, because the user already has their answer.\n try {\n await store.saveTurn({ conversationId, messages: finalMessages });\n } catch (err) {\n console.error(\n JSON.stringify({\n event: 'chat.save_failed',\n userId: ctx.userId,\n conversationId,\n error: err instanceof Error ? err.message : String(err),\n }),\n );\n }\n }\n if (onChatFinish) {\n try {\n await onChatFinish({\n ctx,\n messages: finalMessages,\n usage: finalUsage,\n providerMetadata: finalProviderMetadata,\n });\n } catch (err) {\n console.error('[chat-widget] onChatFinish hook threw:', err);\n }\n }\n await runCleanup('on-finish');\n },\n onError: (err) => {\n const message = onError ? onError(err) : defaultErrorMessage(err);\n void runCleanup('on-error');\n return message;\n },\n });\n }\n\n // ── GET /history ─────────────────────────────────────────────────────────\n async function handleHistoryList(request: Request): Promise<Response> {\n const ctx = await authenticate(request, '');\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const store = resolveStore(ctx.userId);\n const conversations = await store.listConversations();\n return json({\n conversations: conversations.map((c) => ({\n id: c.id,\n title: c.title,\n created_at: c.createdAt,\n updated_at: c.updatedAt,\n metadata: c.metadata,\n message_count: c.messageCount,\n })),\n });\n }\n\n // ── GET /history/:id and DELETE /history/:id ─────────────────────────────\n async function handleConversation(\n request: Request,\n conversationId: string,\n method: 'GET' | 'DELETE',\n ): Promise<Response> {\n const ctx = await authenticate(request, conversationId);\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const store = resolveStore(ctx.userId);\n const storage = resolveStorage(ctx.userId);\n\n if (method === 'DELETE') {\n const deleted = await store.deleteConversation(conversationId);\n return new Response(null, { status: deleted ? 204 : 404 });\n }\n\n const conversation = await store.getConversation(conversationId);\n if (!conversation) return json({ error: 'Conversation not found' }, 404);\n\n const messages = await store.listMessages(conversationId, { limit: 100 });\n // Re-sign attachment URLs so reopened conversations show live thumbnails.\n const rehydrated = storage\n ? await Promise.all(messages.map((m) => resignMessageAttachments(m, storage)))\n : messages;\n\n return json({\n conversation: {\n id: conversation.id,\n title: conversation.title,\n metadata: conversation.metadata,\n },\n messages: rehydrated.map((m) => ({\n id: m.id,\n role: m.role,\n content: m.text,\n created_at: m.createdAt,\n parts: m.parts,\n })),\n });\n }\n\n // ── POST /upload ───────────────────────────────────────────────────────────\n async function handleUpload(request: Request): Promise<Response> {\n const ctx = await authenticate(request, '');\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const storage = resolveStorage(ctx.userId);\n if (!storage) return json({ error: 'File upload is not configured' }, 503);\n\n let form: FormData;\n try {\n form = await request.formData();\n } catch {\n return json({ error: 'Invalid multipart body' }, 400);\n }\n const file = form.get('file');\n const conversationId =\n typeof form.get('conversationId') === 'string'\n ? (form.get('conversationId') as string)\n : undefined;\n\n if (!(file instanceof File)) return json({ error: 'No file provided' }, 400);\n\n const policy = resolveUploadPolicy(upload);\n if (file.size === 0) return json({ error: 'Empty file' }, 400);\n if (file.size > policy.maxBytes) {\n return json({ error: `File too large (max ${policy.maxBytes / 1024 / 1024} MB)` }, 413);\n }\n const mediaType = file.type || 'application/octet-stream';\n if (!policy.allowedMediaTypes.includes(mediaType)) {\n return json({ error: `Unsupported file type: ${mediaType}` }, 415);\n }\n\n const data = await file.arrayBuffer();\n const uploaded = await storage.upload({\n data,\n filename: file.name,\n mediaType,\n size: file.size,\n conversationId,\n });\n return json({\n url: uploaded.url,\n storagePath: uploaded.storagePath,\n filename: uploaded.filename,\n mediaType: uploaded.mediaType,\n size: uploaded.size,\n type: 'file',\n });\n }\n\n // ── Dispatch ───────────────────────────────────────────────────────────────\n async function dispatch(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const segments = subSegments(url);\n const method = request.method.toUpperCase();\n\n try {\n // /chat (no extra segments)\n if (segments.length === 0) {\n if (method === 'POST') return await handleChat(request);\n return methodNotAllowed();\n }\n const [head, ...rest] = segments;\n if (!KNOWN_SEGMENTS.has(head)) return json({ error: 'Not found' }, 404);\n\n if (head === 'upload') {\n if (method === 'POST') return await handleUpload(request);\n return methodNotAllowed();\n }\n if (head === 'history') {\n if (rest.length === 0) {\n if (method === 'GET') return await handleHistoryList(request);\n return methodNotAllowed();\n }\n const conversationId = rest[0];\n if (method === 'GET') return await handleConversation(request, conversationId, 'GET');\n if (method === 'DELETE') return await handleConversation(request, conversationId, 'DELETE');\n return methodNotAllowed();\n }\n return json({ error: 'Not found' }, 404);\n } catch (err) {\n console.error('[chat-widget] handler error:', err);\n return json({ error: 'Internal server error' }, 500);\n }\n }\n\n // Next.js App Router expects named method exports. We point them all at the\n // same dispatcher so one catch-all route file mounts everything.\n return {\n GET: dispatch,\n POST: dispatch,\n DELETE: dispatch,\n };\n}\n\n// ── Module-private utilities ────────────────────────────────────────────────\n\nfunction methodNotAllowed(): Response {\n return json({ error: 'Method not allowed' }, 405);\n}\n\nfunction defaultErrorMessage(err: unknown): string {\n console.error('[chat-widget] stream error:', err);\n return 'An error occurred while generating the response.';\n}\n\nfunction resolveUploadPolicy(upload?: UploadPolicy): {\n maxBytes: number;\n allowedMediaTypes: string[];\n} {\n return {\n maxBytes: upload?.maxBytes ?? DEFAULT_MAX_UPLOAD_BYTES,\n allowedMediaTypes: upload?.allowedMediaTypes ?? DEFAULT_ALLOWED_MEDIA_TYPES,\n };\n}\n\n/** Cap overlong text parts so one pasted blob can't dominate the window. */\nfunction capMessages(messages: UIMessage[], maxChars: number): UIMessage[] {\n return messages.map((msg) => {\n if (!msg || !Array.isArray(msg.parts)) return msg;\n const parts = msg.parts.map((p) =>\n p.type === 'text' && typeof (p as { text?: string }).text === 'string' && (p as { text: string }).text.length > maxChars\n ? { ...p, text: (p as { text: string }).text.slice(0, maxChars) }\n : p,\n );\n return { ...msg, parts };\n });\n}\n\n/**\n * Re-sign every file part on a stored message so a reopened conversation gets\n * live URLs. A failed re-sign leaves the original (stale) url in place rather\n * than dropping the whole message — one missing blob never breaks a load.\n */\nasync function resignMessageAttachments<T extends { parts: UIMessage['parts'] }>(\n message: T,\n storage: StorageAdapter,\n): Promise<T> {\n if (!message.parts?.length) return message;\n const parts = await Promise.all(\n message.parts.map(async (part) => {\n const p = part as { type?: string; storagePath?: string; url?: string };\n if (p.type !== 'file' || typeof p.storagePath !== 'string') return part;\n const fresh = await storage.resign(p.storagePath);\n return fresh ? { ...part, url: fresh } : part;\n }),\n );\n return { ...message, parts };\n}\n"],"mappings":"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","names":["import_server_only"]}
1
+ {"version":3,"sources":["../../src/server/index.ts","../../src/server/chat-store.ts","../../src/server/handler.ts"],"sourcesContent":["/**\n * Server-core public surface.\n *\n * The pluggable contracts that define the widget's backend. Both the hosted\n * default and any BYO implementation satisfy these. The request router (added\n * next) depends only on these interfaces, never on a concrete DB/storage.\n *\n * Guarded by `server-only`: importing this into a client bundle is a build\n * error, since these types reference server-side concerns and the\n * implementations hold secrets (DB URLs, service keys).\n */\nimport 'server-only';\n\nexport type {\n StoredAttachment,\n StoredConversation,\n StoredMessage,\n ListMessagesOptions,\n SaveTurnInput,\n} from './types';\n\nexport type { ChatStore, ChatStoreFactory } from './chat-store';\nexport { ConversationOwnershipError } from './chat-store';\n\nexport type {\n StorageAdapter,\n StorageAdapterFactory,\n UploadInput,\n UploadResult,\n} from './storage-adapter';\n\nexport { createChatHandler } from './handler';\nexport type {\n CreateChatHandlerOptions,\n ChatRequestContext,\n HostedAgentConfig,\n BuiltTools,\n UploadPolicy,\n} from './handler-types';\n","/**\n * ChatStore — the persistence contract for chat conversations and messages.\n *\n * This is one of the two pluggable backends of the widget (the other is\n * `StorageAdapter` for attachments). The package ships a Drizzle/Postgres\n * implementation as the default; a hosted backend or a BYO store (Prisma,\n * raw SQL, DynamoDB, a test double) is simply another implementation of this\n * same interface.\n *\n * ──────────────────────────────────────────────────────────────────────────\n * The security model is in the shape of this API, not in its callers.\n * ──────────────────────────────────────────────────────────────────────────\n *\n * A `ChatStore` is *bound to one verified user* at construction time (see\n * `ChatStoreFactory`). None of its methods accept a `userId`. This is\n * deliberate and it is the core defence against the IDOR class of bug:\n *\n * - You cannot ask the store for \"conversation X belonging to user Y\",\n * because there is no parameter through which a foreign `userId` could\n * enter. The only user the store will ever read or write is the one it\n * was constructed with.\n *\n * - Every method is therefore *implicitly scoped*. `listConversations()`\n * returns only the bound user's conversations. `getConversation(id)`\n * returns `null` — not someone else's row — when `id` exists but belongs\n * to a different user. `saveTurn(...)` refuses (throws\n * `ConversationOwnershipError`) if `conversationId` exists under another\n * user.\n *\n * The route layer's job shrinks to: authenticate the request, derive the\n * real `userId` from the *server* session, construct a store bound to it,\n * and call methods. There is no per-route ownership check to forget, because\n * the store cannot be made to cross users.\n *\n * Implementations MUST uphold the contract documented on each method. The\n * Drizzle default does; if you write your own, these invariants are the\n * security boundary — treat them as load-bearing, not advisory.\n */\n\nimport type {\n ListMessagesOptions,\n SaveTurnInput,\n StoredConversation,\n StoredMessage,\n} from './types';\n\n/**\n * Thrown by mutating methods when the target conversation exists but is owned\n * by a different user than the one this store is bound to. Callers should map\n * this to an HTTP 403. (Read methods don't throw — they return `null`/`[]` —\n * so that probing for existence can't distinguish \"not found\" from\n * \"forbidden\", which would itself leak information.)\n */\nexport class ConversationOwnershipError extends Error {\n constructor(public readonly conversationId: string) {\n super(`Conversation ${conversationId} is not owned by the current user`);\n this.name = 'ConversationOwnershipError';\n }\n}\n\nexport interface ChatStore {\n /**\n * The user this store instance is bound to. Read-only; set at construction.\n * Exposed so the router can stamp it onto storage paths, logs, etc. — never\n * as something a caller can change.\n */\n readonly userId: string;\n\n // ── Conversations ──────────────────────────────────────────────────────\n\n /**\n * List the bound user's conversations, most-recently-updated first.\n * Returns `messageCount` on each row for the history list. Returns `[]`\n * (never throws) when the user has none.\n */\n listConversations(): Promise<StoredConversation[]>;\n\n /**\n * Fetch a single conversation by id, scoped to the bound user.\n *\n * Returns `null` when the conversation does not exist OR exists but belongs\n * to another user — the two cases are intentionally indistinguishable to\n * the caller (and thus to an attacker). Never returns another user's row.\n */\n getConversation(id: string): Promise<StoredConversation | null>;\n\n /**\n * Ensure a conversation row exists for `id`, owned by the bound user.\n *\n * - If no row exists for `id`: creates it, owned by the bound user, and\n * returns it.\n * - If a row exists and is owned by the bound user: returns it unchanged\n * (idempotent — safe to call at the top of every request).\n * - If a row exists but is owned by a *different* user: throws\n * `ConversationOwnershipError` and writes nothing.\n *\n * This is the single chokepoint that makes \"write into someone else's\n * conversation\" impossible: the router calls it before persisting any\n * message, so a forged conversation id is rejected before any data lands.\n */\n ensureConversation(id: string, init?: { title?: string }): Promise<StoredConversation>;\n\n /**\n * Rename a conversation owned by the bound user. No-op (does not throw) if\n * the conversation doesn't exist or isn't owned by the user — renaming is\n * not security-sensitive and silent failure is friendlier here.\n */\n renameConversation(id: string, title: string): Promise<void>;\n\n /**\n * Delete a conversation (and cascade its messages + attachment rows) owned\n * by the bound user. No-op if it doesn't exist or isn't owned by the user.\n * Returns `true` if a row was actually deleted, `false` otherwise — lets\n * the route return 404 vs 200 honestly without a separate existence check.\n *\n * Note: this deletes message *rows*. Purging the underlying attachment\n * blobs from storage is the router's job (it has the `StorageAdapter`),\n * driven off the attachments this method returns having referenced.\n */\n deleteConversation(id: string): Promise<boolean>;\n\n // ── Messages ───────────────────────────────────────────────────────────\n\n /**\n * Load messages for a conversation, scoped to the bound user, newest-first\n * internally but returned in chronological order (oldest → newest) ready to\n * render. Returns `[]` if the conversation doesn't exist or isn't owned by\n * the user — same non-distinguishing contract as `getConversation`.\n *\n * Honours `ListMessagesOptions` for pagination. Implementations MUST clamp\n * `limit` to a ceiling (default ceiling: 100) so a hostile client can't\n * request an unbounded page.\n */\n listMessages(conversationId: string, opts?: ListMessagesOptions): Promise<StoredMessage[]>;\n\n /**\n * Persist the final messages of a completed turn.\n *\n * Contract:\n * - MUST verify the conversation is owned by the bound user first; throws\n * `ConversationOwnershipError` otherwise (defence in depth — the router\n * already called `ensureConversation`, but `saveTurn` must not trust\n * that).\n * - MUST be idempotent on message id: a message whose id already exists is\n * skipped, not duplicated. (The AI SDK delivers stable ids; replays and\n * retries re-deliver them.)\n * - MUST persist each message's full `parts` array as the source of truth,\n * plus a denormalised text projection for previews.\n * - MUST bump the conversation's `updatedAt`.\n *\n * Errors other than ownership (e.g. a transient DB failure) propagate so\n * the router can log them loudly — a silently-dropped assistant turn is\n * exactly the bug we're trying to design out.\n */\n saveTurn(input: SaveTurnInput): Promise<void>;\n}\n\n/**\n * Constructs a `ChatStore` bound to a specific, already-verified user.\n *\n * The router calls this *after* it has authenticated the request and derived\n * `userId` from the server session — never from anything client-supplied.\n * Passing a client-controlled value here would reintroduce the very IDOR the\n * bound-store design exists to prevent, so implementations should treat\n * `userId` as a trusted server secret, not as request input.\n *\n * Construction is intended to be cheap (the underlying DB pool/connection is\n * shared across instances) so a fresh store per request is the norm.\n */\nexport type ChatStoreFactory = (userId: string) => ChatStore;\n","/**\n * createChatHandler — the request router and the \"OWN loop\".\n *\n * This is the heart of the redesign. It owns every piece of shared,\n * dangerous-to-get-wrong plumbing so a host app never writes it:\n *\n * • authentication gate (401 when getUserId returns null)\n * • conversation ownership (create-or-reject; never write a foreign row)\n * • idempotent user-message persistence\n * • sliding-window context pruning + defensive per-message capping\n * • per-request tool resources with guaranteed single teardown\n * • streaming the model response\n * • save-on-finish persistence of the assistant turn\n * • history list + history-by-id with attachment re-signing\n * • uploads to private storage with server-side policy enforcement\n *\n * It exposes only the seams in `CreateChatHandlerOptions`. Nothing security-\n * or correctness-critical is configurable, by design.\n *\n * Mounting: the returned `{ GET, POST }` is designed to sit on a single\n * catch-all route, `app/api/chat/[[...chat]]/route.ts`, so one file mounts the\n * whole backend. The handler dispatches on the trailing path segments:\n *\n * POST /api/chat → chat (stream)\n * POST /api/chat/upload → attachment upload\n * GET /api/chat/history → conversation list\n * GET /api/chat/history/:id → one conversation + messages\n * DELETE /api/chat/history/:id → delete a conversation\n */\n\nimport 'server-only';\nimport {\n convertToModelMessages,\n generateId,\n stepCountIs,\n streamText,\n type LanguageModel,\n type ModelMessage,\n type ToolSet,\n type UIMessage,\n} from 'ai';\n\nimport { ConversationOwnershipError, type ChatStore } from './chat-store';\nimport type { StorageAdapter } from './storage-adapter';\nimport type {\n ChatRequestContext,\n CreateChatHandlerOptions,\n UploadPolicy,\n} from './handler-types';\n\n// ── Defaults ────────────────────────────────────────────────────────────────\n\nconst DEFAULT_MAX_HISTORY_MESSAGES = 30;\nconst DEFAULT_MAX_MESSAGE_CHARS = 4000;\nconst DEFAULT_STEP_BUDGET = 10;\nconst DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;\nconst DEFAULT_ALLOWED_MEDIA_TYPES = [\n 'image/png',\n 'image/jpeg',\n 'image/webp',\n 'image/gif',\n 'application/pdf',\n];\nconst DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant.';\n\n// Internal: the base path the handler is mounted under, used to compute the\n// sub-route from the request URL. Derived from the request, not hardcoded, so\n// the handler works whether mounted at /api/chat or somewhere else.\nconst KNOWN_SEGMENTS = new Set(['upload', 'history']);\n\n// ── Small helpers ─────────────────────────────────────────────────────────\n\nfunction json(body: unknown, status = 200, extraHeaders?: Record<string, string>): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'Content-Type': 'application/json', ...extraHeaders },\n });\n}\n\n// For responses that carry user chat data (conversation lists, messages):\n// never let a browser/proxy/disk cache retain them, and mark them private so\n// a shared cache can't serve one user's history to another.\nfunction jsonNoStore(body: unknown, status = 200): Response {\n return json(body, status, { 'Cache-Control': 'no-store, private' });\n}\n\n/**\n * Split the request path into the segments *after* the handler's mount point —\n * the trailing sub-route the handler dispatches on (`[]`, `['upload']`,\n * `['history']`, `['history', ':id']`).\n *\n * The handler is mount-agnostic: it can sit at `/api/chat`, `/api/preview-chat/:agentId`,\n * or anywhere. We detect the sub-route by the trailing KNOWN_SEGMENT\n * (`upload`/`history`) rather than a hardcoded mount marker:\n * • `…/history` → ['history']\n * • `…/history/:id` → ['history', ':id']\n * • `…/upload` → ['upload']\n * • anything else → [] (the root chat turn — POST, or empty GET)\n */\nfunction subSegments(url: URL): string[] {\n const parts = url.pathname.split('/').filter(Boolean);\n // Scan from the end for the last known sub-route head. Everything from there\n // on is our sub-route; everything before it is the (arbitrary) mount path.\n for (let i = parts.length - 1; i >= 0; i--) {\n if (KNOWN_SEGMENTS.has(parts[i])) {\n return parts.slice(i);\n }\n }\n return [];\n}\n\n// ── The handler ─────────────────────────────────────────────────────────────\n\nexport function createChatHandler(options: CreateChatHandlerOptions) {\n const {\n getUserId,\n model: modelOption,\n buildTools,\n store: storeFactory,\n storage: storageFactory,\n buildSystemPrompt,\n getHostedConfig,\n transformMessages,\n onChatFinish,\n onError,\n stopWhen,\n upload,\n maxHistoryMessages = DEFAULT_MAX_HISTORY_MESSAGES,\n maxMessageChars = DEFAULT_MAX_MESSAGE_CHARS,\n } = options;\n\n // The hosted default store/storage are resolved lazily so a BYO consumer who\n // passes their own never triggers our default's env-var requirements.\n function resolveStore(userId: string): ChatStore {\n if (storeFactory) return storeFactory(userId);\n // The hosted/default Drizzle store is wired in a later step. Until then,\n // a BYO `store` is required. Failing loudly here is correct: a silent\n // no-op store would drop every message.\n throw new Error(\n '[chat-widget] No `store` provided and the hosted default store is not ' +\n 'configured. Pass a `store` factory (see createDrizzleChatStore).',\n );\n }\n\n function resolveStorage(userId: string): StorageAdapter | null {\n if (storageFactory) return storageFactory(userId);\n return null; // uploads disabled when no storage configured\n }\n\n // Precedence: code option > hosted config > throw. A hosted model is a\n // gateway string, which `streamText` accepts directly.\n async function resolveModel(\n ctx: ChatRequestContext,\n hostedModel?: string | null,\n ): Promise<LanguageModel> {\n if (typeof modelOption === 'function') return modelOption(ctx);\n if (modelOption) return modelOption;\n if (hostedModel) return hostedModel;\n throw new Error(\n '[chat-widget] No `model` provided. Pass a `model` (a LanguageModel or a ' +\n 'function returning one), or configure one via hosted config.',\n );\n }\n\n // Authenticate and build the per-request context. Returns null when the\n // request is unauthenticated — callers turn that into a 401.\n async function authenticate(request: Request, conversationId: string): Promise<ChatRequestContext | null> {\n const userId = await getUserId(request);\n if (!userId) return null;\n return { userId, conversationId, request };\n }\n\n // ── POST /chat ─────────────────────────────────────────────────────────\n async function handleChat(request: Request): Promise<Response> {\n let body: { messages?: UIMessage[]; id?: string };\n try {\n body = await request.json();\n } catch {\n return json({ error: 'Invalid JSON body' }, 400);\n }\n const conversationId = typeof body.id === 'string' && body.id ? body.id : undefined;\n if (!conversationId) return json({ error: 'Missing conversation id' }, 400);\n\n const ctx = await authenticate(request, conversationId);\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n\n // Sanitise the incoming array: drop anything that isn't a well-formed\n // message (null/undefined, missing role, missing parts). A malformed entry\n // must never crash the turn — skip it rather than throw downstream.\n const incoming = (Array.isArray(body.messages) ? body.messages : []).filter(\n (m): m is UIMessage =>\n !!m && typeof m === 'object' && typeof m.role === 'string' && Array.isArray(m.parts),\n );\n const store = resolveStore(ctx.userId);\n\n // Ownership chokepoint: create the conversation for this user, or reject\n // (403) if the id belongs to someone else. Nothing is persisted on reject.\n try {\n await store.ensureConversation(conversationId);\n } catch (err) {\n if (err instanceof ConversationOwnershipError) {\n return new Response('Forbidden', { status: 403 });\n }\n throw err;\n }\n\n // Persist the latest user message idempotently (the store dedupes on id).\n const lastUser = [...incoming].reverse().find((m) => m.role === 'user');\n if (lastUser) {\n await store.saveTurn({ conversationId, messages: [lastUser] });\n }\n\n // Sliding-window prune + defensive char-cap, then the host's transform.\n const windowed = incoming.slice(-maxHistoryMessages);\n const capped = maxMessageChars > 0 ? capMessages(windowed, maxMessageChars) : windowed;\n let modelMessages: ModelMessage[] = await convertToModelMessages(capped);\n if (transformMessages) modelMessages = await transformMessages(modelMessages, ctx);\n\n // Build tools (with their per-request resource).\n const built = buildTools ? await buildTools(ctx) : { tools: {} as ToolSet };\n const tools = built.tools ?? ({} as ToolSet);\n\n // Fetch hosted config once (best-effort — a failure must never break the\n // turn). The inner try/catch swallows BOTH a synchronous throw and an async\n // rejection, honouring the \"throwing falls through to code/defaults\"\n // contract for arbitrary consumers. Used only to fill model / system that\n // code didn't provide.\n const hosted = getHostedConfig\n ? await (async () => {\n try {\n return await getHostedConfig(ctx);\n } catch {\n return null;\n }\n })()\n : null;\n\n // Model: code option > hosted > throw.\n const model = await resolveModel(ctx, hosted?.model);\n // String label of the model for persistence (the `model` column). A\n // LanguageModel is either a gateway string (\"anthropic/claude-…\") or a\n // provider object exposing `.modelId`.\n const modelLabel =\n typeof model === 'string' ? model : (model as { modelId?: string }).modelId;\n\n // System prompt: code (buildSystemPrompt) > hosted > package default.\n const system = buildSystemPrompt\n ? await buildSystemPrompt(ctx)\n : hosted?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;\n\n // Single, guarded teardown of the tools' per-request resource. Fires\n // exactly once across all completion paths (finish / error / abort).\n let cleanedUp = false;\n const runCleanup = async (reason: string) => {\n if (cleanedUp || !built.cleanup) return;\n cleanedUp = true;\n try {\n await built.cleanup();\n } catch (err) {\n console.error(`[chat-widget] tool cleanup failed (${reason}):`, err);\n }\n };\n request.signal.addEventListener('abort', () => void runCleanup('client-abort'));\n\n // streamText's own onFinish is the only place usage + providerMetadata are\n // available (the UI-stream onFinish below exposes neither). Capture them\n // here so the host's onChatFinish hook gets real numbers, not undefined.\n let finalUsage: unknown;\n let finalProviderMetadata: unknown;\n\n const result = streamText({\n model,\n system,\n messages: modelMessages,\n tools,\n stopWhen: stopWhen ?? stepCountIs(DEFAULT_STEP_BUDGET),\n onFinish: ({ usage, providerMetadata }) => {\n finalUsage = usage;\n finalProviderMetadata = providerMetadata;\n },\n });\n\n return result.toUIMessageStreamResponse({\n sendSources: true,\n sendReasoning: true,\n // REQUIRED for correct persistence. Without `generateMessageId` the\n // assistant message comes back with an empty id, so every assistant turn\n // in a conversation collides on the same '' primary key and only the\n // first one survives `saveTurn`'s idempotent insert. `originalMessages`\n // lets the SDK reuse existing ids (preventing duplicates) and return the\n // full original+response set in onFinish.\n originalMessages: incoming,\n generateMessageId: generateId,\n onFinish: async ({ messages: finalMessages, isAborted }) => {\n // Don't persist a turn the client aborted mid-stream — the assistant\n // message is partial and the user didn't receive it. The idempotent\n // user-message save already happened before streaming.\n if (!isAborted && finalMessages.length > 0) {\n // Persist the assistant turn. Errors here are logged loudly — a\n // silently-dropped turn is the exact failure we designed against —\n // but never thrown, because the user already has their answer.\n try {\n await store.saveTurn({ conversationId, messages: finalMessages, model: modelLabel });\n } catch (err) {\n console.error(\n JSON.stringify({\n event: 'chat.save_failed',\n userId: ctx.userId,\n conversationId,\n error: err instanceof Error ? err.message : String(err),\n }),\n );\n }\n }\n if (onChatFinish) {\n try {\n await onChatFinish({\n ctx,\n messages: finalMessages,\n usage: finalUsage,\n providerMetadata: finalProviderMetadata,\n });\n } catch (err) {\n console.error('[chat-widget] onChatFinish hook threw:', err);\n }\n }\n await runCleanup('on-finish');\n },\n onError: (err) => {\n const message = onError ? onError(err) : defaultErrorMessage(err);\n void runCleanup('on-error');\n return message;\n },\n });\n }\n\n // ── GET /history ─────────────────────────────────────────────────────────\n async function handleHistoryList(request: Request): Promise<Response> {\n const ctx = await authenticate(request, '');\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const store = resolveStore(ctx.userId);\n const conversations = await store.listConversations();\n return jsonNoStore({\n conversations: conversations.map((c) => ({\n id: c.id,\n title: c.title,\n created_at: c.createdAt,\n updated_at: c.updatedAt,\n metadata: c.metadata,\n message_count: c.messageCount,\n })),\n });\n }\n\n // ── GET /history/:id and DELETE /history/:id ─────────────────────────────\n async function handleConversation(\n request: Request,\n conversationId: string,\n method: 'GET' | 'DELETE',\n ): Promise<Response> {\n const ctx = await authenticate(request, conversationId);\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const store = resolveStore(ctx.userId);\n const storage = resolveStorage(ctx.userId);\n\n if (method === 'DELETE') {\n const deleted = await store.deleteConversation(conversationId);\n return new Response(null, { status: deleted ? 204 : 404 });\n }\n\n const conversation = await store.getConversation(conversationId);\n if (!conversation) return json({ error: 'Conversation not found' }, 404);\n\n const messages = await store.listMessages(conversationId, { limit: 100 });\n // Re-sign attachment URLs so reopened conversations show live thumbnails.\n const rehydrated = storage\n ? await Promise.all(messages.map((m) => resignMessageAttachments(m, storage)))\n : messages;\n\n return jsonNoStore({\n conversation: {\n id: conversation.id,\n title: conversation.title,\n metadata: conversation.metadata,\n },\n messages: rehydrated.map((m) => ({\n id: m.id,\n role: m.role,\n content: m.text,\n created_at: m.createdAt,\n parts: m.parts,\n })),\n });\n }\n\n // ── POST /upload ───────────────────────────────────────────────────────────\n async function handleUpload(request: Request): Promise<Response> {\n const ctx = await authenticate(request, '');\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const storage = resolveStorage(ctx.userId);\n if (!storage) return json({ error: 'File upload is not configured' }, 503);\n\n let form: FormData;\n try {\n form = await request.formData();\n } catch {\n return json({ error: 'Invalid multipart body' }, 400);\n }\n const file = form.get('file');\n const conversationId =\n typeof form.get('conversationId') === 'string'\n ? (form.get('conversationId') as string)\n : undefined;\n\n if (!(file instanceof File)) return json({ error: 'No file provided' }, 400);\n\n const policy = resolveUploadPolicy(upload);\n if (file.size === 0) return json({ error: 'Empty file' }, 400);\n if (file.size > policy.maxBytes) {\n return json({ error: `File too large (max ${policy.maxBytes / 1024 / 1024} MB)` }, 413);\n }\n const mediaType = file.type || 'application/octet-stream';\n if (!policy.allowedMediaTypes.includes(mediaType)) {\n return json({ error: `Unsupported file type: ${mediaType}` }, 415);\n }\n\n const data = await file.arrayBuffer();\n const uploaded = await storage.upload({\n data,\n filename: file.name,\n mediaType,\n size: file.size,\n conversationId,\n });\n return json({\n url: uploaded.url,\n storagePath: uploaded.storagePath,\n filename: uploaded.filename,\n mediaType: uploaded.mediaType,\n size: uploaded.size,\n type: 'file',\n });\n }\n\n // ── Dispatch ───────────────────────────────────────────────────────────────\n async function dispatch(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const segments = subSegments(url);\n const method = request.method.toUpperCase();\n\n try {\n // /chat (no extra segments)\n if (segments.length === 0) {\n if (method === 'POST') return await handleChat(request);\n return methodNotAllowed();\n }\n const [head, ...rest] = segments;\n if (!KNOWN_SEGMENTS.has(head)) return json({ error: 'Not found' }, 404);\n\n if (head === 'upload') {\n if (method === 'POST') return await handleUpload(request);\n return methodNotAllowed();\n }\n if (head === 'history') {\n if (rest.length === 0) {\n if (method === 'GET') return await handleHistoryList(request);\n return methodNotAllowed();\n }\n const conversationId = rest[0];\n if (method === 'GET') return await handleConversation(request, conversationId, 'GET');\n if (method === 'DELETE') return await handleConversation(request, conversationId, 'DELETE');\n return methodNotAllowed();\n }\n return json({ error: 'Not found' }, 404);\n } catch (err) {\n console.error('[chat-widget] handler error:', err);\n return json({ error: 'Internal server error' }, 500);\n }\n }\n\n // Next.js App Router expects named method exports. We point them all at the\n // same dispatcher so one catch-all route file mounts everything.\n return {\n GET: dispatch,\n POST: dispatch,\n DELETE: dispatch,\n };\n}\n\n// ── Module-private utilities ────────────────────────────────────────────────\n\nfunction methodNotAllowed(): Response {\n return json({ error: 'Method not allowed' }, 405);\n}\n\nfunction defaultErrorMessage(err: unknown): string {\n console.error('[chat-widget] stream error:', err);\n return 'An error occurred while generating the response.';\n}\n\nfunction resolveUploadPolicy(upload?: UploadPolicy): {\n maxBytes: number;\n allowedMediaTypes: string[];\n} {\n return {\n maxBytes: upload?.maxBytes ?? DEFAULT_MAX_UPLOAD_BYTES,\n allowedMediaTypes: upload?.allowedMediaTypes ?? DEFAULT_ALLOWED_MEDIA_TYPES,\n };\n}\n\n/** Cap overlong text parts so one pasted blob can't dominate the window. */\nfunction capMessages(messages: UIMessage[], maxChars: number): UIMessage[] {\n return messages.map((msg) => {\n if (!msg || !Array.isArray(msg.parts)) return msg;\n const parts = msg.parts.map((p) =>\n p.type === 'text' && typeof (p as { text?: string }).text === 'string' && (p as { text: string }).text.length > maxChars\n ? { ...p, text: (p as { text: string }).text.slice(0, maxChars) }\n : p,\n );\n return { ...msg, parts };\n });\n}\n\n/**\n * Re-sign every file part on a stored message so a reopened conversation gets\n * live URLs. A failed re-sign leaves the original (stale) url in place rather\n * than dropping the whole message — one missing blob never breaks a load.\n */\nasync function resignMessageAttachments<T extends { parts: UIMessage['parts'] }>(\n message: T,\n storage: StorageAdapter,\n): Promise<T> {\n if (!message.parts?.length) return message;\n const parts = await Promise.all(\n message.parts.map(async (part) => {\n const p = part as { type?: string; storagePath?: string; url?: string };\n if (p.type !== 'file' || typeof p.storagePath !== 'string') return part;\n const fresh = await storage.resign(p.storagePath);\n return fresh ? { ...part, url: fresh } : part;\n }),\n );\n return { ...message, parts };\n}\n"],"mappings":"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","names":["import_server_only"]}
@@ -31,17 +31,23 @@ var DEFAULT_ALLOWED_MEDIA_TYPES = [
31
31
  ];
32
32
  var DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant.";
33
33
  var KNOWN_SEGMENTS = /* @__PURE__ */ new Set(["upload", "history"]);
34
- function json(body, status = 200) {
34
+ function json(body, status = 200, extraHeaders) {
35
35
  return new Response(JSON.stringify(body), {
36
36
  status,
37
- headers: { "Content-Type": "application/json" }
37
+ headers: { "Content-Type": "application/json", ...extraHeaders }
38
38
  });
39
39
  }
40
+ function jsonNoStore(body, status = 200) {
41
+ return json(body, status, { "Cache-Control": "no-store, private" });
42
+ }
40
43
  function subSegments(url) {
41
44
  const parts = url.pathname.split("/").filter(Boolean);
42
- const chatIdx = parts.lastIndexOf("chat");
43
- if (chatIdx === -1) return [];
44
- return parts.slice(chatIdx + 1);
45
+ for (let i = parts.length - 1; i >= 0; i--) {
46
+ if (KNOWN_SEGMENTS.has(parts[i])) {
47
+ return parts.slice(i);
48
+ }
49
+ }
50
+ return [];
45
51
  }
46
52
  function createChatHandler(options) {
47
53
  const {
@@ -51,6 +57,7 @@ function createChatHandler(options) {
51
57
  store: storeFactory,
52
58
  storage: storageFactory,
53
59
  buildSystemPrompt,
60
+ getHostedConfig,
54
61
  transformMessages,
55
62
  onChatFinish,
56
63
  onError,
@@ -69,11 +76,12 @@ function createChatHandler(options) {
69
76
  if (storageFactory) return storageFactory(userId);
70
77
  return null;
71
78
  }
72
- async function resolveModel(ctx) {
79
+ async function resolveModel(ctx, hostedModel) {
73
80
  if (typeof modelOption === "function") return modelOption(ctx);
74
81
  if (modelOption) return modelOption;
82
+ if (hostedModel) return hostedModel;
75
83
  throw new Error(
76
- "[chat-widget] No `model` provided. Pass a `model` (a LanguageModel or a function returning one)."
84
+ "[chat-widget] No `model` provided. Pass a `model` (a LanguageModel or a function returning one), or configure one via hosted config."
77
85
  );
78
86
  }
79
87
  async function authenticate(request, conversationId) {
@@ -114,8 +122,16 @@ function createChatHandler(options) {
114
122
  if (transformMessages) modelMessages = await transformMessages(modelMessages, ctx);
115
123
  const built = buildTools ? await buildTools(ctx) : { tools: {} };
116
124
  const tools = built.tools ?? {};
117
- const model = await resolveModel(ctx);
118
- const system = buildSystemPrompt ? await buildSystemPrompt(ctx) : DEFAULT_SYSTEM_PROMPT;
125
+ const hosted = getHostedConfig ? await (async () => {
126
+ try {
127
+ return await getHostedConfig(ctx);
128
+ } catch {
129
+ return null;
130
+ }
131
+ })() : null;
132
+ const model = await resolveModel(ctx, hosted?.model);
133
+ const modelLabel = typeof model === "string" ? model : model.modelId;
134
+ const system = buildSystemPrompt ? await buildSystemPrompt(ctx) : hosted?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
119
135
  let cleanedUp = false;
120
136
  const runCleanup = async (reason) => {
121
137
  if (cleanedUp || !built.cleanup) return;
@@ -154,7 +170,7 @@ function createChatHandler(options) {
154
170
  onFinish: async ({ messages: finalMessages, isAborted }) => {
155
171
  if (!isAborted && finalMessages.length > 0) {
156
172
  try {
157
- await store.saveTurn({ conversationId, messages: finalMessages });
173
+ await store.saveTurn({ conversationId, messages: finalMessages, model: modelLabel });
158
174
  } catch (err) {
159
175
  console.error(
160
176
  JSON.stringify({
@@ -192,7 +208,7 @@ function createChatHandler(options) {
192
208
  if (!ctx) return new Response("Unauthorized", { status: 401 });
193
209
  const store = resolveStore(ctx.userId);
194
210
  const conversations = await store.listConversations();
195
- return json({
211
+ return jsonNoStore({
196
212
  conversations: conversations.map((c) => ({
197
213
  id: c.id,
198
214
  title: c.title,
@@ -216,7 +232,7 @@ function createChatHandler(options) {
216
232
  if (!conversation) return json({ error: "Conversation not found" }, 404);
217
233
  const messages = await store.listMessages(conversationId, { limit: 100 });
218
234
  const rehydrated = storage ? await Promise.all(messages.map((m) => resignMessageAttachments(m, storage))) : messages;
219
- return json({
235
+ return jsonNoStore({
220
236
  conversation: {
221
237
  id: conversation.id,
222
238
  title: conversation.title,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/index.ts","../../src/server/chat-store.ts","../../src/server/handler.ts"],"sourcesContent":["/**\n * Server-core public surface.\n *\n * The pluggable contracts that define the widget's backend. Both the hosted\n * default and any BYO implementation satisfy these. The request router (added\n * next) depends only on these interfaces, never on a concrete DB/storage.\n *\n * Guarded by `server-only`: importing this into a client bundle is a build\n * error, since these types reference server-side concerns and the\n * implementations hold secrets (DB URLs, service keys).\n */\nimport 'server-only';\n\nexport type {\n StoredAttachment,\n StoredConversation,\n StoredMessage,\n ListMessagesOptions,\n SaveTurnInput,\n} from './types';\n\nexport type { ChatStore, ChatStoreFactory } from './chat-store';\nexport { ConversationOwnershipError } from './chat-store';\n\nexport type {\n StorageAdapter,\n StorageAdapterFactory,\n UploadInput,\n UploadResult,\n} from './storage-adapter';\n\nexport { createChatHandler } from './handler';\nexport type {\n CreateChatHandlerOptions,\n ChatRequestContext,\n BuiltTools,\n UploadPolicy,\n} from './handler-types';\n","/**\n * ChatStore — the persistence contract for chat conversations and messages.\n *\n * This is one of the two pluggable backends of the widget (the other is\n * `StorageAdapter` for attachments). The package ships a Drizzle/Postgres\n * implementation as the default; a hosted backend or a BYO store (Prisma,\n * raw SQL, DynamoDB, a test double) is simply another implementation of this\n * same interface.\n *\n * ──────────────────────────────────────────────────────────────────────────\n * The security model is in the shape of this API, not in its callers.\n * ──────────────────────────────────────────────────────────────────────────\n *\n * A `ChatStore` is *bound to one verified user* at construction time (see\n * `ChatStoreFactory`). None of its methods accept a `userId`. This is\n * deliberate and it is the core defence against the IDOR class of bug:\n *\n * - You cannot ask the store for \"conversation X belonging to user Y\",\n * because there is no parameter through which a foreign `userId` could\n * enter. The only user the store will ever read or write is the one it\n * was constructed with.\n *\n * - Every method is therefore *implicitly scoped*. `listConversations()`\n * returns only the bound user's conversations. `getConversation(id)`\n * returns `null` — not someone else's row — when `id` exists but belongs\n * to a different user. `saveTurn(...)` refuses (throws\n * `ConversationOwnershipError`) if `conversationId` exists under another\n * user.\n *\n * The route layer's job shrinks to: authenticate the request, derive the\n * real `userId` from the *server* session, construct a store bound to it,\n * and call methods. There is no per-route ownership check to forget, because\n * the store cannot be made to cross users.\n *\n * Implementations MUST uphold the contract documented on each method. The\n * Drizzle default does; if you write your own, these invariants are the\n * security boundary — treat them as load-bearing, not advisory.\n */\n\nimport type {\n ListMessagesOptions,\n SaveTurnInput,\n StoredConversation,\n StoredMessage,\n} from './types';\n\n/**\n * Thrown by mutating methods when the target conversation exists but is owned\n * by a different user than the one this store is bound to. Callers should map\n * this to an HTTP 403. (Read methods don't throw — they return `null`/`[]` —\n * so that probing for existence can't distinguish \"not found\" from\n * \"forbidden\", which would itself leak information.)\n */\nexport class ConversationOwnershipError extends Error {\n constructor(public readonly conversationId: string) {\n super(`Conversation ${conversationId} is not owned by the current user`);\n this.name = 'ConversationOwnershipError';\n }\n}\n\nexport interface ChatStore {\n /**\n * The user this store instance is bound to. Read-only; set at construction.\n * Exposed so the router can stamp it onto storage paths, logs, etc. — never\n * as something a caller can change.\n */\n readonly userId: string;\n\n // ── Conversations ──────────────────────────────────────────────────────\n\n /**\n * List the bound user's conversations, most-recently-updated first.\n * Returns `messageCount` on each row for the history list. Returns `[]`\n * (never throws) when the user has none.\n */\n listConversations(): Promise<StoredConversation[]>;\n\n /**\n * Fetch a single conversation by id, scoped to the bound user.\n *\n * Returns `null` when the conversation does not exist OR exists but belongs\n * to another user — the two cases are intentionally indistinguishable to\n * the caller (and thus to an attacker). Never returns another user's row.\n */\n getConversation(id: string): Promise<StoredConversation | null>;\n\n /**\n * Ensure a conversation row exists for `id`, owned by the bound user.\n *\n * - If no row exists for `id`: creates it, owned by the bound user, and\n * returns it.\n * - If a row exists and is owned by the bound user: returns it unchanged\n * (idempotent — safe to call at the top of every request).\n * - If a row exists but is owned by a *different* user: throws\n * `ConversationOwnershipError` and writes nothing.\n *\n * This is the single chokepoint that makes \"write into someone else's\n * conversation\" impossible: the router calls it before persisting any\n * message, so a forged conversation id is rejected before any data lands.\n */\n ensureConversation(id: string, init?: { title?: string }): Promise<StoredConversation>;\n\n /**\n * Rename a conversation owned by the bound user. No-op (does not throw) if\n * the conversation doesn't exist or isn't owned by the user — renaming is\n * not security-sensitive and silent failure is friendlier here.\n */\n renameConversation(id: string, title: string): Promise<void>;\n\n /**\n * Delete a conversation (and cascade its messages + attachment rows) owned\n * by the bound user. No-op if it doesn't exist or isn't owned by the user.\n * Returns `true` if a row was actually deleted, `false` otherwise — lets\n * the route return 404 vs 200 honestly without a separate existence check.\n *\n * Note: this deletes message *rows*. Purging the underlying attachment\n * blobs from storage is the router's job (it has the `StorageAdapter`),\n * driven off the attachments this method returns having referenced.\n */\n deleteConversation(id: string): Promise<boolean>;\n\n // ── Messages ───────────────────────────────────────────────────────────\n\n /**\n * Load messages for a conversation, scoped to the bound user, newest-first\n * internally but returned in chronological order (oldest → newest) ready to\n * render. Returns `[]` if the conversation doesn't exist or isn't owned by\n * the user — same non-distinguishing contract as `getConversation`.\n *\n * Honours `ListMessagesOptions` for pagination. Implementations MUST clamp\n * `limit` to a ceiling (default ceiling: 100) so a hostile client can't\n * request an unbounded page.\n */\n listMessages(conversationId: string, opts?: ListMessagesOptions): Promise<StoredMessage[]>;\n\n /**\n * Persist the final messages of a completed turn.\n *\n * Contract:\n * - MUST verify the conversation is owned by the bound user first; throws\n * `ConversationOwnershipError` otherwise (defence in depth — the router\n * already called `ensureConversation`, but `saveTurn` must not trust\n * that).\n * - MUST be idempotent on message id: a message whose id already exists is\n * skipped, not duplicated. (The AI SDK delivers stable ids; replays and\n * retries re-deliver them.)\n * - MUST persist each message's full `parts` array as the source of truth,\n * plus a denormalised text projection for previews.\n * - MUST bump the conversation's `updatedAt`.\n *\n * Errors other than ownership (e.g. a transient DB failure) propagate so\n * the router can log them loudly — a silently-dropped assistant turn is\n * exactly the bug we're trying to design out.\n */\n saveTurn(input: SaveTurnInput): Promise<void>;\n}\n\n/**\n * Constructs a `ChatStore` bound to a specific, already-verified user.\n *\n * The router calls this *after* it has authenticated the request and derived\n * `userId` from the server session — never from anything client-supplied.\n * Passing a client-controlled value here would reintroduce the very IDOR the\n * bound-store design exists to prevent, so implementations should treat\n * `userId` as a trusted server secret, not as request input.\n *\n * Construction is intended to be cheap (the underlying DB pool/connection is\n * shared across instances) so a fresh store per request is the norm.\n */\nexport type ChatStoreFactory = (userId: string) => ChatStore;\n","/**\n * createChatHandler — the request router and the \"OWN loop\".\n *\n * This is the heart of the redesign. It owns every piece of shared,\n * dangerous-to-get-wrong plumbing so a host app never writes it:\n *\n * • authentication gate (401 when getUserId returns null)\n * • conversation ownership (create-or-reject; never write a foreign row)\n * • idempotent user-message persistence\n * • sliding-window context pruning + defensive per-message capping\n * • per-request tool resources with guaranteed single teardown\n * • streaming the model response\n * • save-on-finish persistence of the assistant turn\n * • history list + history-by-id with attachment re-signing\n * • uploads to private storage with server-side policy enforcement\n *\n * It exposes only the seams in `CreateChatHandlerOptions`. Nothing security-\n * or correctness-critical is configurable, by design.\n *\n * Mounting: the returned `{ GET, POST }` is designed to sit on a single\n * catch-all route, `app/api/chat/[[...chat]]/route.ts`, so one file mounts the\n * whole backend. The handler dispatches on the trailing path segments:\n *\n * POST /api/chat → chat (stream)\n * POST /api/chat/upload → attachment upload\n * GET /api/chat/history → conversation list\n * GET /api/chat/history/:id → one conversation + messages\n * DELETE /api/chat/history/:id → delete a conversation\n */\n\nimport 'server-only';\nimport {\n convertToModelMessages,\n generateId,\n stepCountIs,\n streamText,\n type LanguageModel,\n type ModelMessage,\n type ToolSet,\n type UIMessage,\n} from 'ai';\n\nimport { ConversationOwnershipError, type ChatStore } from './chat-store';\nimport type { StorageAdapter } from './storage-adapter';\nimport type {\n ChatRequestContext,\n CreateChatHandlerOptions,\n UploadPolicy,\n} from './handler-types';\n\n// ── Defaults ────────────────────────────────────────────────────────────────\n\nconst DEFAULT_MAX_HISTORY_MESSAGES = 30;\nconst DEFAULT_MAX_MESSAGE_CHARS = 4000;\nconst DEFAULT_STEP_BUDGET = 10;\nconst DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;\nconst DEFAULT_ALLOWED_MEDIA_TYPES = [\n 'image/png',\n 'image/jpeg',\n 'image/webp',\n 'image/gif',\n 'application/pdf',\n];\nconst DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant.';\n\n// Internal: the base path the handler is mounted under, used to compute the\n// sub-route from the request URL. Derived from the request, not hardcoded, so\n// the handler works whether mounted at /api/chat or somewhere else.\nconst KNOWN_SEGMENTS = new Set(['upload', 'history']);\n\n// ── Small helpers ─────────────────────────────────────────────────────────\n\nfunction json(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'Content-Type': 'application/json' },\n });\n}\n\n/**\n * Split the request path into the segments *after* the handler's mount point.\n * We locate the mount point by finding the last \"chat\" segment that's followed\n * only by known sub-routes (or nothing). This keeps the handler agnostic to\n * the exact mount path.\n */\nfunction subSegments(url: URL): string[] {\n const parts = url.pathname.split('/').filter(Boolean);\n // Find the final \"chat\" segment — everything after it is our sub-route.\n const chatIdx = parts.lastIndexOf('chat');\n if (chatIdx === -1) return [];\n return parts.slice(chatIdx + 1);\n}\n\n// ── The handler ─────────────────────────────────────────────────────────────\n\nexport function createChatHandler(options: CreateChatHandlerOptions) {\n const {\n getUserId,\n model: modelOption,\n buildTools,\n store: storeFactory,\n storage: storageFactory,\n buildSystemPrompt,\n transformMessages,\n onChatFinish,\n onError,\n stopWhen,\n upload,\n maxHistoryMessages = DEFAULT_MAX_HISTORY_MESSAGES,\n maxMessageChars = DEFAULT_MAX_MESSAGE_CHARS,\n } = options;\n\n // The hosted default store/storage are resolved lazily so a BYO consumer who\n // passes their own never triggers our default's env-var requirements.\n function resolveStore(userId: string): ChatStore {\n if (storeFactory) return storeFactory(userId);\n // The hosted/default Drizzle store is wired in a later step. Until then,\n // a BYO `store` is required. Failing loudly here is correct: a silent\n // no-op store would drop every message.\n throw new Error(\n '[chat-widget] No `store` provided and the hosted default store is not ' +\n 'configured. Pass a `store` factory (see createDrizzleChatStore).',\n );\n }\n\n function resolveStorage(userId: string): StorageAdapter | null {\n if (storageFactory) return storageFactory(userId);\n return null; // uploads disabled when no storage configured\n }\n\n async function resolveModel(ctx: ChatRequestContext): Promise<LanguageModel> {\n if (typeof modelOption === 'function') return modelOption(ctx);\n if (modelOption) return modelOption;\n throw new Error(\n '[chat-widget] No `model` provided. Pass a `model` (a LanguageModel or a ' +\n 'function returning one).',\n );\n }\n\n // Authenticate and build the per-request context. Returns null when the\n // request is unauthenticated — callers turn that into a 401.\n async function authenticate(request: Request, conversationId: string): Promise<ChatRequestContext | null> {\n const userId = await getUserId(request);\n if (!userId) return null;\n return { userId, conversationId, request };\n }\n\n // ── POST /chat ─────────────────────────────────────────────────────────\n async function handleChat(request: Request): Promise<Response> {\n let body: { messages?: UIMessage[]; id?: string };\n try {\n body = await request.json();\n } catch {\n return json({ error: 'Invalid JSON body' }, 400);\n }\n const conversationId = typeof body.id === 'string' && body.id ? body.id : undefined;\n if (!conversationId) return json({ error: 'Missing conversation id' }, 400);\n\n const ctx = await authenticate(request, conversationId);\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n\n // Sanitise the incoming array: drop anything that isn't a well-formed\n // message (null/undefined, missing role, missing parts). A malformed entry\n // must never crash the turn — skip it rather than throw downstream.\n const incoming = (Array.isArray(body.messages) ? body.messages : []).filter(\n (m): m is UIMessage =>\n !!m && typeof m === 'object' && typeof m.role === 'string' && Array.isArray(m.parts),\n );\n const store = resolveStore(ctx.userId);\n\n // Ownership chokepoint: create the conversation for this user, or reject\n // (403) if the id belongs to someone else. Nothing is persisted on reject.\n try {\n await store.ensureConversation(conversationId);\n } catch (err) {\n if (err instanceof ConversationOwnershipError) {\n return new Response('Forbidden', { status: 403 });\n }\n throw err;\n }\n\n // Persist the latest user message idempotently (the store dedupes on id).\n const lastUser = [...incoming].reverse().find((m) => m.role === 'user');\n if (lastUser) {\n await store.saveTurn({ conversationId, messages: [lastUser] });\n }\n\n // Sliding-window prune + defensive char-cap, then the host's transform.\n const windowed = incoming.slice(-maxHistoryMessages);\n const capped = maxMessageChars > 0 ? capMessages(windowed, maxMessageChars) : windowed;\n let modelMessages: ModelMessage[] = await convertToModelMessages(capped);\n if (transformMessages) modelMessages = await transformMessages(modelMessages, ctx);\n\n // Build tools (with their per-request resource) and resolve the model.\n const built = buildTools ? await buildTools(ctx) : { tools: {} as ToolSet };\n const tools = built.tools ?? ({} as ToolSet);\n const model = await resolveModel(ctx);\n const system = buildSystemPrompt ? await buildSystemPrompt(ctx) : DEFAULT_SYSTEM_PROMPT;\n\n // Single, guarded teardown of the tools' per-request resource. Fires\n // exactly once across all completion paths (finish / error / abort).\n let cleanedUp = false;\n const runCleanup = async (reason: string) => {\n if (cleanedUp || !built.cleanup) return;\n cleanedUp = true;\n try {\n await built.cleanup();\n } catch (err) {\n console.error(`[chat-widget] tool cleanup failed (${reason}):`, err);\n }\n };\n request.signal.addEventListener('abort', () => void runCleanup('client-abort'));\n\n // streamText's own onFinish is the only place usage + providerMetadata are\n // available (the UI-stream onFinish below exposes neither). Capture them\n // here so the host's onChatFinish hook gets real numbers, not undefined.\n let finalUsage: unknown;\n let finalProviderMetadata: unknown;\n\n const result = streamText({\n model,\n system,\n messages: modelMessages,\n tools,\n stopWhen: stopWhen ?? stepCountIs(DEFAULT_STEP_BUDGET),\n onFinish: ({ usage, providerMetadata }) => {\n finalUsage = usage;\n finalProviderMetadata = providerMetadata;\n },\n });\n\n return result.toUIMessageStreamResponse({\n sendSources: true,\n sendReasoning: true,\n // REQUIRED for correct persistence. Without `generateMessageId` the\n // assistant message comes back with an empty id, so every assistant turn\n // in a conversation collides on the same '' primary key and only the\n // first one survives `saveTurn`'s idempotent insert. `originalMessages`\n // lets the SDK reuse existing ids (preventing duplicates) and return the\n // full original+response set in onFinish.\n originalMessages: incoming,\n generateMessageId: generateId,\n onFinish: async ({ messages: finalMessages, isAborted }) => {\n // Don't persist a turn the client aborted mid-stream — the assistant\n // message is partial and the user didn't receive it. The idempotent\n // user-message save already happened before streaming.\n if (!isAborted && finalMessages.length > 0) {\n // Persist the assistant turn. Errors here are logged loudly — a\n // silently-dropped turn is the exact failure we designed against —\n // but never thrown, because the user already has their answer.\n try {\n await store.saveTurn({ conversationId, messages: finalMessages });\n } catch (err) {\n console.error(\n JSON.stringify({\n event: 'chat.save_failed',\n userId: ctx.userId,\n conversationId,\n error: err instanceof Error ? err.message : String(err),\n }),\n );\n }\n }\n if (onChatFinish) {\n try {\n await onChatFinish({\n ctx,\n messages: finalMessages,\n usage: finalUsage,\n providerMetadata: finalProviderMetadata,\n });\n } catch (err) {\n console.error('[chat-widget] onChatFinish hook threw:', err);\n }\n }\n await runCleanup('on-finish');\n },\n onError: (err) => {\n const message = onError ? onError(err) : defaultErrorMessage(err);\n void runCleanup('on-error');\n return message;\n },\n });\n }\n\n // ── GET /history ─────────────────────────────────────────────────────────\n async function handleHistoryList(request: Request): Promise<Response> {\n const ctx = await authenticate(request, '');\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const store = resolveStore(ctx.userId);\n const conversations = await store.listConversations();\n return json({\n conversations: conversations.map((c) => ({\n id: c.id,\n title: c.title,\n created_at: c.createdAt,\n updated_at: c.updatedAt,\n metadata: c.metadata,\n message_count: c.messageCount,\n })),\n });\n }\n\n // ── GET /history/:id and DELETE /history/:id ─────────────────────────────\n async function handleConversation(\n request: Request,\n conversationId: string,\n method: 'GET' | 'DELETE',\n ): Promise<Response> {\n const ctx = await authenticate(request, conversationId);\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const store = resolveStore(ctx.userId);\n const storage = resolveStorage(ctx.userId);\n\n if (method === 'DELETE') {\n const deleted = await store.deleteConversation(conversationId);\n return new Response(null, { status: deleted ? 204 : 404 });\n }\n\n const conversation = await store.getConversation(conversationId);\n if (!conversation) return json({ error: 'Conversation not found' }, 404);\n\n const messages = await store.listMessages(conversationId, { limit: 100 });\n // Re-sign attachment URLs so reopened conversations show live thumbnails.\n const rehydrated = storage\n ? await Promise.all(messages.map((m) => resignMessageAttachments(m, storage)))\n : messages;\n\n return json({\n conversation: {\n id: conversation.id,\n title: conversation.title,\n metadata: conversation.metadata,\n },\n messages: rehydrated.map((m) => ({\n id: m.id,\n role: m.role,\n content: m.text,\n created_at: m.createdAt,\n parts: m.parts,\n })),\n });\n }\n\n // ── POST /upload ───────────────────────────────────────────────────────────\n async function handleUpload(request: Request): Promise<Response> {\n const ctx = await authenticate(request, '');\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const storage = resolveStorage(ctx.userId);\n if (!storage) return json({ error: 'File upload is not configured' }, 503);\n\n let form: FormData;\n try {\n form = await request.formData();\n } catch {\n return json({ error: 'Invalid multipart body' }, 400);\n }\n const file = form.get('file');\n const conversationId =\n typeof form.get('conversationId') === 'string'\n ? (form.get('conversationId') as string)\n : undefined;\n\n if (!(file instanceof File)) return json({ error: 'No file provided' }, 400);\n\n const policy = resolveUploadPolicy(upload);\n if (file.size === 0) return json({ error: 'Empty file' }, 400);\n if (file.size > policy.maxBytes) {\n return json({ error: `File too large (max ${policy.maxBytes / 1024 / 1024} MB)` }, 413);\n }\n const mediaType = file.type || 'application/octet-stream';\n if (!policy.allowedMediaTypes.includes(mediaType)) {\n return json({ error: `Unsupported file type: ${mediaType}` }, 415);\n }\n\n const data = await file.arrayBuffer();\n const uploaded = await storage.upload({\n data,\n filename: file.name,\n mediaType,\n size: file.size,\n conversationId,\n });\n return json({\n url: uploaded.url,\n storagePath: uploaded.storagePath,\n filename: uploaded.filename,\n mediaType: uploaded.mediaType,\n size: uploaded.size,\n type: 'file',\n });\n }\n\n // ── Dispatch ───────────────────────────────────────────────────────────────\n async function dispatch(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const segments = subSegments(url);\n const method = request.method.toUpperCase();\n\n try {\n // /chat (no extra segments)\n if (segments.length === 0) {\n if (method === 'POST') return await handleChat(request);\n return methodNotAllowed();\n }\n const [head, ...rest] = segments;\n if (!KNOWN_SEGMENTS.has(head)) return json({ error: 'Not found' }, 404);\n\n if (head === 'upload') {\n if (method === 'POST') return await handleUpload(request);\n return methodNotAllowed();\n }\n if (head === 'history') {\n if (rest.length === 0) {\n if (method === 'GET') return await handleHistoryList(request);\n return methodNotAllowed();\n }\n const conversationId = rest[0];\n if (method === 'GET') return await handleConversation(request, conversationId, 'GET');\n if (method === 'DELETE') return await handleConversation(request, conversationId, 'DELETE');\n return methodNotAllowed();\n }\n return json({ error: 'Not found' }, 404);\n } catch (err) {\n console.error('[chat-widget] handler error:', err);\n return json({ error: 'Internal server error' }, 500);\n }\n }\n\n // Next.js App Router expects named method exports. We point them all at the\n // same dispatcher so one catch-all route file mounts everything.\n return {\n GET: dispatch,\n POST: dispatch,\n DELETE: dispatch,\n };\n}\n\n// ── Module-private utilities ────────────────────────────────────────────────\n\nfunction methodNotAllowed(): Response {\n return json({ error: 'Method not allowed' }, 405);\n}\n\nfunction defaultErrorMessage(err: unknown): string {\n console.error('[chat-widget] stream error:', err);\n return 'An error occurred while generating the response.';\n}\n\nfunction resolveUploadPolicy(upload?: UploadPolicy): {\n maxBytes: number;\n allowedMediaTypes: string[];\n} {\n return {\n maxBytes: upload?.maxBytes ?? DEFAULT_MAX_UPLOAD_BYTES,\n allowedMediaTypes: upload?.allowedMediaTypes ?? DEFAULT_ALLOWED_MEDIA_TYPES,\n };\n}\n\n/** Cap overlong text parts so one pasted blob can't dominate the window. */\nfunction capMessages(messages: UIMessage[], maxChars: number): UIMessage[] {\n return messages.map((msg) => {\n if (!msg || !Array.isArray(msg.parts)) return msg;\n const parts = msg.parts.map((p) =>\n p.type === 'text' && typeof (p as { text?: string }).text === 'string' && (p as { text: string }).text.length > maxChars\n ? { ...p, text: (p as { text: string }).text.slice(0, maxChars) }\n : p,\n );\n return { ...msg, parts };\n });\n}\n\n/**\n * Re-sign every file part on a stored message so a reopened conversation gets\n * live URLs. A failed re-sign leaves the original (stale) url in place rather\n * than dropping the whole message — one missing blob never breaks a load.\n */\nasync function resignMessageAttachments<T extends { parts: UIMessage['parts'] }>(\n message: T,\n storage: StorageAdapter,\n): Promise<T> {\n if (!message.parts?.length) return message;\n const parts = await Promise.all(\n message.parts.map(async (part) => {\n const p = part as { type?: string; storagePath?: string; url?: string };\n if (p.type !== 'file' || typeof p.storagePath !== 'string') return part;\n const fresh = await storage.resign(p.storagePath);\n return fresh ? { ...part, url: fresh } : part;\n }),\n );\n return { ...message, parts };\n}\n"],"mappings":";AAWA,OAAO;;;AC0CA,IAAM,6BAAN,cAAyC,MAAM;AAAA,EACpD,YAA4B,gBAAwB;AAClD,UAAM,gBAAgB,cAAc,mCAAmC;AAD7C;AAE1B,SAAK,OAAO;AAAA,EACd;AACF;;;AC5BA,OAAO;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAKK;AAYP,IAAM,+BAA+B;AACrC,IAAM,4BAA4B;AAClC,IAAM,sBAAsB;AAC5B,IAAM,2BAA2B,IAAI,OAAO;AAC5C,IAAM,8BAA8B;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AACA,IAAM,wBAAwB;AAK9B,IAAM,iBAAiB,oBAAI,IAAI,CAAC,UAAU,SAAS,CAAC;AAIpD,SAAS,KAAK,MAAe,SAAS,KAAe;AACnD,SAAO,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;AAAA,IACxC;AAAA,IACA,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,EAChD,CAAC;AACH;AAQA,SAAS,YAAY,KAAoB;AACvC,QAAM,QAAQ,IAAI,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO;AAEpD,QAAM,UAAU,MAAM,YAAY,MAAM;AACxC,MAAI,YAAY,GAAI,QAAO,CAAC;AAC5B,SAAO,MAAM,MAAM,UAAU,CAAC;AAChC;AAIO,SAAS,kBAAkB,SAAmC;AACnE,QAAM;AAAA,IACJ;AAAA,IACA,OAAO;AAAA,IACP;AAAA,IACA,OAAO;AAAA,IACP,SAAS;AAAA,IACT;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,qBAAqB;AAAA,IACrB,kBAAkB;AAAA,EACpB,IAAI;AAIJ,WAAS,aAAa,QAA2B;AAC/C,QAAI,aAAc,QAAO,aAAa,MAAM;AAI5C,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AAEA,WAAS,eAAe,QAAuC;AAC7D,QAAI,eAAgB,QAAO,eAAe,MAAM;AAChD,WAAO;AAAA,EACT;AAEA,iBAAe,aAAa,KAAiD;AAC3E,QAAI,OAAO,gBAAgB,WAAY,QAAO,YAAY,GAAG;AAC7D,QAAI,YAAa,QAAO;AACxB,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AAIA,iBAAe,aAAa,SAAkB,gBAA4D;AACxG,UAAM,SAAS,MAAM,UAAU,OAAO;AACtC,QAAI,CAAC,OAAQ,QAAO;AACpB,WAAO,EAAE,QAAQ,gBAAgB,QAAQ;AAAA,EAC3C;AAGA,iBAAe,WAAW,SAAqC;AAC7D,QAAI;AACJ,QAAI;AACF,aAAO,MAAM,QAAQ,KAAK;AAAA,IAC5B,QAAQ;AACN,aAAO,KAAK,EAAE,OAAO,oBAAoB,GAAG,GAAG;AAAA,IACjD;AACA,UAAM,iBAAiB,OAAO,KAAK,OAAO,YAAY,KAAK,KAAK,KAAK,KAAK;AAC1E,QAAI,CAAC,eAAgB,QAAO,KAAK,EAAE,OAAO,0BAA0B,GAAG,GAAG;AAE1E,UAAM,MAAM,MAAM,aAAa,SAAS,cAAc;AACtD,QAAI,CAAC,IAAK,QAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;AAK7D,UAAM,YAAY,MAAM,QAAQ,KAAK,QAAQ,IAAI,KAAK,WAAW,CAAC,GAAG;AAAA,MACnE,CAAC,MACC,CAAC,CAAC,KAAK,OAAO,MAAM,YAAY,OAAO,EAAE,SAAS,YAAY,MAAM,QAAQ,EAAE,KAAK;AAAA,IACvF;AACA,UAAM,QAAQ,aAAa,IAAI,MAAM;AAIrC,QAAI;AACF,YAAM,MAAM,mBAAmB,cAAc;AAAA,IAC/C,SAAS,KAAK;AACZ,UAAI,eAAe,4BAA4B;AAC7C,eAAO,IAAI,SAAS,aAAa,EAAE,QAAQ,IAAI,CAAC;AAAA,MAClD;AACA,YAAM;AAAA,IACR;AAGA,UAAM,WAAW,CAAC,GAAG,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,MAAM,EAAE,SAAS,MAAM;AACtE,QAAI,UAAU;AACZ,YAAM,MAAM,SAAS,EAAE,gBAAgB,UAAU,CAAC,QAAQ,EAAE,CAAC;AAAA,IAC/D;AAGA,UAAM,WAAW,SAAS,MAAM,CAAC,kBAAkB;AACnD,UAAM,SAAS,kBAAkB,IAAI,YAAY,UAAU,eAAe,IAAI;AAC9E,QAAI,gBAAgC,MAAM,uBAAuB,MAAM;AACvE,QAAI,kBAAmB,iBAAgB,MAAM,kBAAkB,eAAe,GAAG;AAGjF,UAAM,QAAQ,aAAa,MAAM,WAAW,GAAG,IAAI,EAAE,OAAO,CAAC,EAAa;AAC1E,UAAM,QAAQ,MAAM,SAAU,CAAC;AAC/B,UAAM,QAAQ,MAAM,aAAa,GAAG;AACpC,UAAM,SAAS,oBAAoB,MAAM,kBAAkB,GAAG,IAAI;AAIlE,QAAI,YAAY;AAChB,UAAM,aAAa,OAAO,WAAmB;AAC3C,UAAI,aAAa,CAAC,MAAM,QAAS;AACjC,kBAAY;AACZ,UAAI;AACF,cAAM,MAAM,QAAQ;AAAA,MACtB,SAAS,KAAK;AACZ,gBAAQ,MAAM,sCAAsC,MAAM,MAAM,GAAG;AAAA,MACrE;AAAA,IACF;AACA,YAAQ,OAAO,iBAAiB,SAAS,MAAM,KAAK,WAAW,cAAc,CAAC;AAK9E,QAAI;AACJ,QAAI;AAEJ,UAAM,SAAS,WAAW;AAAA,MACxB;AAAA,MACA;AAAA,MACA,UAAU;AAAA,MACV;AAAA,MACA,UAAU,YAAY,YAAY,mBAAmB;AAAA,MACrD,UAAU,CAAC,EAAE,OAAO,iBAAiB,MAAM;AACzC,qBAAa;AACb,gCAAwB;AAAA,MAC1B;AAAA,IACF,CAAC;AAED,WAAO,OAAO,0BAA0B;AAAA,MACtC,aAAa;AAAA,MACb,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAOf,kBAAkB;AAAA,MAClB,mBAAmB;AAAA,MACnB,UAAU,OAAO,EAAE,UAAU,eAAe,UAAU,MAAM;AAI1D,YAAI,CAAC,aAAa,cAAc,SAAS,GAAG;AAI1C,cAAI;AACF,kBAAM,MAAM,SAAS,EAAE,gBAAgB,UAAU,cAAc,CAAC;AAAA,UAClE,SAAS,KAAK;AACZ,oBAAQ;AAAA,cACN,KAAK,UAAU;AAAA,gBACb,OAAO;AAAA,gBACP,QAAQ,IAAI;AAAA,gBACZ;AAAA,gBACA,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,cACxD,CAAC;AAAA,YACH;AAAA,UACF;AAAA,QACF;AACA,YAAI,cAAc;AAChB,cAAI;AACF,kBAAM,aAAa;AAAA,cACjB;AAAA,cACA,UAAU;AAAA,cACV,OAAO;AAAA,cACP,kBAAkB;AAAA,YACpB,CAAC;AAAA,UACH,SAAS,KAAK;AACZ,oBAAQ,MAAM,0CAA0C,GAAG;AAAA,UAC7D;AAAA,QACF;AACA,cAAM,WAAW,WAAW;AAAA,MAC9B;AAAA,MACA,SAAS,CAAC,QAAQ;AAChB,cAAM,UAAU,UAAU,QAAQ,GAAG,IAAI,oBAAoB,GAAG;AAChE,aAAK,WAAW,UAAU;AAC1B,eAAO;AAAA,MACT;AAAA,IACF,CAAC;AAAA,EACH;AAGA,iBAAe,kBAAkB,SAAqC;AACpE,UAAM,MAAM,MAAM,aAAa,SAAS,EAAE;AAC1C,QAAI,CAAC,IAAK,QAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;AAC7D,UAAM,QAAQ,aAAa,IAAI,MAAM;AACrC,UAAM,gBAAgB,MAAM,MAAM,kBAAkB;AACpD,WAAO,KAAK;AAAA,MACV,eAAe,cAAc,IAAI,CAAC,OAAO;AAAA,QACvC,IAAI,EAAE;AAAA,QACN,OAAO,EAAE;AAAA,QACT,YAAY,EAAE;AAAA,QACd,YAAY,EAAE;AAAA,QACd,UAAU,EAAE;AAAA,QACZ,eAAe,EAAE;AAAA,MACnB,EAAE;AAAA,IACJ,CAAC;AAAA,EACH;AAGA,iBAAe,mBACb,SACA,gBACA,QACmB;AACnB,UAAM,MAAM,MAAM,aAAa,SAAS,cAAc;AACtD,QAAI,CAAC,IAAK,QAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;AAC7D,UAAM,QAAQ,aAAa,IAAI,MAAM;AACrC,UAAM,UAAU,eAAe,IAAI,MAAM;AAEzC,QAAI,WAAW,UAAU;AACvB,YAAM,UAAU,MAAM,MAAM,mBAAmB,cAAc;AAC7D,aAAO,IAAI,SAAS,MAAM,EAAE,QAAQ,UAAU,MAAM,IAAI,CAAC;AAAA,IAC3D;AAEA,UAAM,eAAe,MAAM,MAAM,gBAAgB,cAAc;AAC/D,QAAI,CAAC,aAAc,QAAO,KAAK,EAAE,OAAO,yBAAyB,GAAG,GAAG;AAEvE,UAAM,WAAW,MAAM,MAAM,aAAa,gBAAgB,EAAE,OAAO,IAAI,CAAC;AAExE,UAAM,aAAa,UACf,MAAM,QAAQ,IAAI,SAAS,IAAI,CAAC,MAAM,yBAAyB,GAAG,OAAO,CAAC,CAAC,IAC3E;AAEJ,WAAO,KAAK;AAAA,MACV,cAAc;AAAA,QACZ,IAAI,aAAa;AAAA,QACjB,OAAO,aAAa;AAAA,QACpB,UAAU,aAAa;AAAA,MACzB;AAAA,MACA,UAAU,WAAW,IAAI,CAAC,OAAO;AAAA,QAC/B,IAAI,EAAE;AAAA,QACN,MAAM,EAAE;AAAA,QACR,SAAS,EAAE;AAAA,QACX,YAAY,EAAE;AAAA,QACd,OAAO,EAAE;AAAA,MACX,EAAE;AAAA,IACJ,CAAC;AAAA,EACH;AAGA,iBAAe,aAAa,SAAqC;AAC/D,UAAM,MAAM,MAAM,aAAa,SAAS,EAAE;AAC1C,QAAI,CAAC,IAAK,QAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;AAC7D,UAAM,UAAU,eAAe,IAAI,MAAM;AACzC,QAAI,CAAC,QAAS,QAAO,KAAK,EAAE,OAAO,gCAAgC,GAAG,GAAG;AAEzE,QAAI;AACJ,QAAI;AACF,aAAO,MAAM,QAAQ,SAAS;AAAA,IAChC,QAAQ;AACN,aAAO,KAAK,EAAE,OAAO,yBAAyB,GAAG,GAAG;AAAA,IACtD;AACA,UAAM,OAAO,KAAK,IAAI,MAAM;AAC5B,UAAM,iBACJ,OAAO,KAAK,IAAI,gBAAgB,MAAM,WACjC,KAAK,IAAI,gBAAgB,IAC1B;AAEN,QAAI,EAAE,gBAAgB,MAAO,QAAO,KAAK,EAAE,OAAO,mBAAmB,GAAG,GAAG;AAE3E,UAAM,SAAS,oBAAoB,MAAM;AACzC,QAAI,KAAK,SAAS,EAAG,QAAO,KAAK,EAAE,OAAO,aAAa,GAAG,GAAG;AAC7D,QAAI,KAAK,OAAO,OAAO,UAAU;AAC/B,aAAO,KAAK,EAAE,OAAO,uBAAuB,OAAO,WAAW,OAAO,IAAI,OAAO,GAAG,GAAG;AAAA,IACxF;AACA,UAAM,YAAY,KAAK,QAAQ;AAC/B,QAAI,CAAC,OAAO,kBAAkB,SAAS,SAAS,GAAG;AACjD,aAAO,KAAK,EAAE,OAAO,0BAA0B,SAAS,GAAG,GAAG,GAAG;AAAA,IACnE;AAEA,UAAM,OAAO,MAAM,KAAK,YAAY;AACpC,UAAM,WAAW,MAAM,QAAQ,OAAO;AAAA,MACpC;AAAA,MACA,UAAU,KAAK;AAAA,MACf;AAAA,MACA,MAAM,KAAK;AAAA,MACX;AAAA,IACF,CAAC;AACD,WAAO,KAAK;AAAA,MACV,KAAK,SAAS;AAAA,MACd,aAAa,SAAS;AAAA,MACtB,UAAU,SAAS;AAAA,MACnB,WAAW,SAAS;AAAA,MACpB,MAAM,SAAS;AAAA,MACf,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAGA,iBAAe,SAAS,SAAqC;AAC3D,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,WAAW,YAAY,GAAG;AAChC,UAAM,SAAS,QAAQ,OAAO,YAAY;AAE1C,QAAI;AAEF,UAAI,SAAS,WAAW,GAAG;AACzB,YAAI,WAAW,OAAQ,QAAO,MAAM,WAAW,OAAO;AACtD,eAAO,iBAAiB;AAAA,MAC1B;AACA,YAAM,CAAC,MAAM,GAAG,IAAI,IAAI;AACxB,UAAI,CAAC,eAAe,IAAI,IAAI,EAAG,QAAO,KAAK,EAAE,OAAO,YAAY,GAAG,GAAG;AAEtE,UAAI,SAAS,UAAU;AACrB,YAAI,WAAW,OAAQ,QAAO,MAAM,aAAa,OAAO;AACxD,eAAO,iBAAiB;AAAA,MAC1B;AACA,UAAI,SAAS,WAAW;AACtB,YAAI,KAAK,WAAW,GAAG;AACrB,cAAI,WAAW,MAAO,QAAO,MAAM,kBAAkB,OAAO;AAC5D,iBAAO,iBAAiB;AAAA,QAC1B;AACA,cAAM,iBAAiB,KAAK,CAAC;AAC7B,YAAI,WAAW,MAAO,QAAO,MAAM,mBAAmB,SAAS,gBAAgB,KAAK;AACpF,YAAI,WAAW,SAAU,QAAO,MAAM,mBAAmB,SAAS,gBAAgB,QAAQ;AAC1F,eAAO,iBAAiB;AAAA,MAC1B;AACA,aAAO,KAAK,EAAE,OAAO,YAAY,GAAG,GAAG;AAAA,IACzC,SAAS,KAAK;AACZ,cAAQ,MAAM,gCAAgC,GAAG;AACjD,aAAO,KAAK,EAAE,OAAO,wBAAwB,GAAG,GAAG;AAAA,IACrD;AAAA,EACF;AAIA,SAAO;AAAA,IACL,KAAK;AAAA,IACL,MAAM;AAAA,IACN,QAAQ;AAAA,EACV;AACF;AAIA,SAAS,mBAA6B;AACpC,SAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;AAClD;AAEA,SAAS,oBAAoB,KAAsB;AACjD,UAAQ,MAAM,+BAA+B,GAAG;AAChD,SAAO;AACT;AAEA,SAAS,oBAAoB,QAG3B;AACA,SAAO;AAAA,IACL,UAAU,QAAQ,YAAY;AAAA,IAC9B,mBAAmB,QAAQ,qBAAqB;AAAA,EAClD;AACF;AAGA,SAAS,YAAY,UAAuB,UAA+B;AACzE,SAAO,SAAS,IAAI,CAAC,QAAQ;AAC3B,QAAI,CAAC,OAAO,CAAC,MAAM,QAAQ,IAAI,KAAK,EAAG,QAAO;AAC9C,UAAM,QAAQ,IAAI,MAAM;AAAA,MAAI,CAAC,MAC3B,EAAE,SAAS,UAAU,OAAQ,EAAwB,SAAS,YAAa,EAAuB,KAAK,SAAS,WAC5G,EAAE,GAAG,GAAG,MAAO,EAAuB,KAAK,MAAM,GAAG,QAAQ,EAAE,IAC9D;AAAA,IACN;AACA,WAAO,EAAE,GAAG,KAAK,MAAM;AAAA,EACzB,CAAC;AACH;AAOA,eAAe,yBACb,SACA,SACY;AACZ,MAAI,CAAC,QAAQ,OAAO,OAAQ,QAAO;AACnC,QAAM,QAAQ,MAAM,QAAQ;AAAA,IAC1B,QAAQ,MAAM,IAAI,OAAO,SAAS;AAChC,YAAM,IAAI;AACV,UAAI,EAAE,SAAS,UAAU,OAAO,EAAE,gBAAgB,SAAU,QAAO;AACnE,YAAM,QAAQ,MAAM,QAAQ,OAAO,EAAE,WAAW;AAChD,aAAO,QAAQ,EAAE,GAAG,MAAM,KAAK,MAAM,IAAI;AAAA,IAC3C,CAAC;AAAA,EACH;AACA,SAAO,EAAE,GAAG,SAAS,MAAM;AAC7B;","names":[]}
1
+ {"version":3,"sources":["../../src/server/index.ts","../../src/server/chat-store.ts","../../src/server/handler.ts"],"sourcesContent":["/**\n * Server-core public surface.\n *\n * The pluggable contracts that define the widget's backend. Both the hosted\n * default and any BYO implementation satisfy these. The request router (added\n * next) depends only on these interfaces, never on a concrete DB/storage.\n *\n * Guarded by `server-only`: importing this into a client bundle is a build\n * error, since these types reference server-side concerns and the\n * implementations hold secrets (DB URLs, service keys).\n */\nimport 'server-only';\n\nexport type {\n StoredAttachment,\n StoredConversation,\n StoredMessage,\n ListMessagesOptions,\n SaveTurnInput,\n} from './types';\n\nexport type { ChatStore, ChatStoreFactory } from './chat-store';\nexport { ConversationOwnershipError } from './chat-store';\n\nexport type {\n StorageAdapter,\n StorageAdapterFactory,\n UploadInput,\n UploadResult,\n} from './storage-adapter';\n\nexport { createChatHandler } from './handler';\nexport type {\n CreateChatHandlerOptions,\n ChatRequestContext,\n HostedAgentConfig,\n BuiltTools,\n UploadPolicy,\n} from './handler-types';\n","/**\n * ChatStore — the persistence contract for chat conversations and messages.\n *\n * This is one of the two pluggable backends of the widget (the other is\n * `StorageAdapter` for attachments). The package ships a Drizzle/Postgres\n * implementation as the default; a hosted backend or a BYO store (Prisma,\n * raw SQL, DynamoDB, a test double) is simply another implementation of this\n * same interface.\n *\n * ──────────────────────────────────────────────────────────────────────────\n * The security model is in the shape of this API, not in its callers.\n * ──────────────────────────────────────────────────────────────────────────\n *\n * A `ChatStore` is *bound to one verified user* at construction time (see\n * `ChatStoreFactory`). None of its methods accept a `userId`. This is\n * deliberate and it is the core defence against the IDOR class of bug:\n *\n * - You cannot ask the store for \"conversation X belonging to user Y\",\n * because there is no parameter through which a foreign `userId` could\n * enter. The only user the store will ever read or write is the one it\n * was constructed with.\n *\n * - Every method is therefore *implicitly scoped*. `listConversations()`\n * returns only the bound user's conversations. `getConversation(id)`\n * returns `null` — not someone else's row — when `id` exists but belongs\n * to a different user. `saveTurn(...)` refuses (throws\n * `ConversationOwnershipError`) if `conversationId` exists under another\n * user.\n *\n * The route layer's job shrinks to: authenticate the request, derive the\n * real `userId` from the *server* session, construct a store bound to it,\n * and call methods. There is no per-route ownership check to forget, because\n * the store cannot be made to cross users.\n *\n * Implementations MUST uphold the contract documented on each method. The\n * Drizzle default does; if you write your own, these invariants are the\n * security boundary — treat them as load-bearing, not advisory.\n */\n\nimport type {\n ListMessagesOptions,\n SaveTurnInput,\n StoredConversation,\n StoredMessage,\n} from './types';\n\n/**\n * Thrown by mutating methods when the target conversation exists but is owned\n * by a different user than the one this store is bound to. Callers should map\n * this to an HTTP 403. (Read methods don't throw — they return `null`/`[]` —\n * so that probing for existence can't distinguish \"not found\" from\n * \"forbidden\", which would itself leak information.)\n */\nexport class ConversationOwnershipError extends Error {\n constructor(public readonly conversationId: string) {\n super(`Conversation ${conversationId} is not owned by the current user`);\n this.name = 'ConversationOwnershipError';\n }\n}\n\nexport interface ChatStore {\n /**\n * The user this store instance is bound to. Read-only; set at construction.\n * Exposed so the router can stamp it onto storage paths, logs, etc. — never\n * as something a caller can change.\n */\n readonly userId: string;\n\n // ── Conversations ──────────────────────────────────────────────────────\n\n /**\n * List the bound user's conversations, most-recently-updated first.\n * Returns `messageCount` on each row for the history list. Returns `[]`\n * (never throws) when the user has none.\n */\n listConversations(): Promise<StoredConversation[]>;\n\n /**\n * Fetch a single conversation by id, scoped to the bound user.\n *\n * Returns `null` when the conversation does not exist OR exists but belongs\n * to another user — the two cases are intentionally indistinguishable to\n * the caller (and thus to an attacker). Never returns another user's row.\n */\n getConversation(id: string): Promise<StoredConversation | null>;\n\n /**\n * Ensure a conversation row exists for `id`, owned by the bound user.\n *\n * - If no row exists for `id`: creates it, owned by the bound user, and\n * returns it.\n * - If a row exists and is owned by the bound user: returns it unchanged\n * (idempotent — safe to call at the top of every request).\n * - If a row exists but is owned by a *different* user: throws\n * `ConversationOwnershipError` and writes nothing.\n *\n * This is the single chokepoint that makes \"write into someone else's\n * conversation\" impossible: the router calls it before persisting any\n * message, so a forged conversation id is rejected before any data lands.\n */\n ensureConversation(id: string, init?: { title?: string }): Promise<StoredConversation>;\n\n /**\n * Rename a conversation owned by the bound user. No-op (does not throw) if\n * the conversation doesn't exist or isn't owned by the user — renaming is\n * not security-sensitive and silent failure is friendlier here.\n */\n renameConversation(id: string, title: string): Promise<void>;\n\n /**\n * Delete a conversation (and cascade its messages + attachment rows) owned\n * by the bound user. No-op if it doesn't exist or isn't owned by the user.\n * Returns `true` if a row was actually deleted, `false` otherwise — lets\n * the route return 404 vs 200 honestly without a separate existence check.\n *\n * Note: this deletes message *rows*. Purging the underlying attachment\n * blobs from storage is the router's job (it has the `StorageAdapter`),\n * driven off the attachments this method returns having referenced.\n */\n deleteConversation(id: string): Promise<boolean>;\n\n // ── Messages ───────────────────────────────────────────────────────────\n\n /**\n * Load messages for a conversation, scoped to the bound user, newest-first\n * internally but returned in chronological order (oldest → newest) ready to\n * render. Returns `[]` if the conversation doesn't exist or isn't owned by\n * the user — same non-distinguishing contract as `getConversation`.\n *\n * Honours `ListMessagesOptions` for pagination. Implementations MUST clamp\n * `limit` to a ceiling (default ceiling: 100) so a hostile client can't\n * request an unbounded page.\n */\n listMessages(conversationId: string, opts?: ListMessagesOptions): Promise<StoredMessage[]>;\n\n /**\n * Persist the final messages of a completed turn.\n *\n * Contract:\n * - MUST verify the conversation is owned by the bound user first; throws\n * `ConversationOwnershipError` otherwise (defence in depth — the router\n * already called `ensureConversation`, but `saveTurn` must not trust\n * that).\n * - MUST be idempotent on message id: a message whose id already exists is\n * skipped, not duplicated. (The AI SDK delivers stable ids; replays and\n * retries re-deliver them.)\n * - MUST persist each message's full `parts` array as the source of truth,\n * plus a denormalised text projection for previews.\n * - MUST bump the conversation's `updatedAt`.\n *\n * Errors other than ownership (e.g. a transient DB failure) propagate so\n * the router can log them loudly — a silently-dropped assistant turn is\n * exactly the bug we're trying to design out.\n */\n saveTurn(input: SaveTurnInput): Promise<void>;\n}\n\n/**\n * Constructs a `ChatStore` bound to a specific, already-verified user.\n *\n * The router calls this *after* it has authenticated the request and derived\n * `userId` from the server session — never from anything client-supplied.\n * Passing a client-controlled value here would reintroduce the very IDOR the\n * bound-store design exists to prevent, so implementations should treat\n * `userId` as a trusted server secret, not as request input.\n *\n * Construction is intended to be cheap (the underlying DB pool/connection is\n * shared across instances) so a fresh store per request is the norm.\n */\nexport type ChatStoreFactory = (userId: string) => ChatStore;\n","/**\n * createChatHandler — the request router and the \"OWN loop\".\n *\n * This is the heart of the redesign. It owns every piece of shared,\n * dangerous-to-get-wrong plumbing so a host app never writes it:\n *\n * • authentication gate (401 when getUserId returns null)\n * • conversation ownership (create-or-reject; never write a foreign row)\n * • idempotent user-message persistence\n * • sliding-window context pruning + defensive per-message capping\n * • per-request tool resources with guaranteed single teardown\n * • streaming the model response\n * • save-on-finish persistence of the assistant turn\n * • history list + history-by-id with attachment re-signing\n * • uploads to private storage with server-side policy enforcement\n *\n * It exposes only the seams in `CreateChatHandlerOptions`. Nothing security-\n * or correctness-critical is configurable, by design.\n *\n * Mounting: the returned `{ GET, POST }` is designed to sit on a single\n * catch-all route, `app/api/chat/[[...chat]]/route.ts`, so one file mounts the\n * whole backend. The handler dispatches on the trailing path segments:\n *\n * POST /api/chat → chat (stream)\n * POST /api/chat/upload → attachment upload\n * GET /api/chat/history → conversation list\n * GET /api/chat/history/:id → one conversation + messages\n * DELETE /api/chat/history/:id → delete a conversation\n */\n\nimport 'server-only';\nimport {\n convertToModelMessages,\n generateId,\n stepCountIs,\n streamText,\n type LanguageModel,\n type ModelMessage,\n type ToolSet,\n type UIMessage,\n} from 'ai';\n\nimport { ConversationOwnershipError, type ChatStore } from './chat-store';\nimport type { StorageAdapter } from './storage-adapter';\nimport type {\n ChatRequestContext,\n CreateChatHandlerOptions,\n UploadPolicy,\n} from './handler-types';\n\n// ── Defaults ────────────────────────────────────────────────────────────────\n\nconst DEFAULT_MAX_HISTORY_MESSAGES = 30;\nconst DEFAULT_MAX_MESSAGE_CHARS = 4000;\nconst DEFAULT_STEP_BUDGET = 10;\nconst DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;\nconst DEFAULT_ALLOWED_MEDIA_TYPES = [\n 'image/png',\n 'image/jpeg',\n 'image/webp',\n 'image/gif',\n 'application/pdf',\n];\nconst DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant.';\n\n// Internal: the base path the handler is mounted under, used to compute the\n// sub-route from the request URL. Derived from the request, not hardcoded, so\n// the handler works whether mounted at /api/chat or somewhere else.\nconst KNOWN_SEGMENTS = new Set(['upload', 'history']);\n\n// ── Small helpers ─────────────────────────────────────────────────────────\n\nfunction json(body: unknown, status = 200, extraHeaders?: Record<string, string>): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'Content-Type': 'application/json', ...extraHeaders },\n });\n}\n\n// For responses that carry user chat data (conversation lists, messages):\n// never let a browser/proxy/disk cache retain them, and mark them private so\n// a shared cache can't serve one user's history to another.\nfunction jsonNoStore(body: unknown, status = 200): Response {\n return json(body, status, { 'Cache-Control': 'no-store, private' });\n}\n\n/**\n * Split the request path into the segments *after* the handler's mount point —\n * the trailing sub-route the handler dispatches on (`[]`, `['upload']`,\n * `['history']`, `['history', ':id']`).\n *\n * The handler is mount-agnostic: it can sit at `/api/chat`, `/api/preview-chat/:agentId`,\n * or anywhere. We detect the sub-route by the trailing KNOWN_SEGMENT\n * (`upload`/`history`) rather than a hardcoded mount marker:\n * • `…/history` → ['history']\n * • `…/history/:id` → ['history', ':id']\n * • `…/upload` → ['upload']\n * • anything else → [] (the root chat turn — POST, or empty GET)\n */\nfunction subSegments(url: URL): string[] {\n const parts = url.pathname.split('/').filter(Boolean);\n // Scan from the end for the last known sub-route head. Everything from there\n // on is our sub-route; everything before it is the (arbitrary) mount path.\n for (let i = parts.length - 1; i >= 0; i--) {\n if (KNOWN_SEGMENTS.has(parts[i])) {\n return parts.slice(i);\n }\n }\n return [];\n}\n\n// ── The handler ─────────────────────────────────────────────────────────────\n\nexport function createChatHandler(options: CreateChatHandlerOptions) {\n const {\n getUserId,\n model: modelOption,\n buildTools,\n store: storeFactory,\n storage: storageFactory,\n buildSystemPrompt,\n getHostedConfig,\n transformMessages,\n onChatFinish,\n onError,\n stopWhen,\n upload,\n maxHistoryMessages = DEFAULT_MAX_HISTORY_MESSAGES,\n maxMessageChars = DEFAULT_MAX_MESSAGE_CHARS,\n } = options;\n\n // The hosted default store/storage are resolved lazily so a BYO consumer who\n // passes their own never triggers our default's env-var requirements.\n function resolveStore(userId: string): ChatStore {\n if (storeFactory) return storeFactory(userId);\n // The hosted/default Drizzle store is wired in a later step. Until then,\n // a BYO `store` is required. Failing loudly here is correct: a silent\n // no-op store would drop every message.\n throw new Error(\n '[chat-widget] No `store` provided and the hosted default store is not ' +\n 'configured. Pass a `store` factory (see createDrizzleChatStore).',\n );\n }\n\n function resolveStorage(userId: string): StorageAdapter | null {\n if (storageFactory) return storageFactory(userId);\n return null; // uploads disabled when no storage configured\n }\n\n // Precedence: code option > hosted config > throw. A hosted model is a\n // gateway string, which `streamText` accepts directly.\n async function resolveModel(\n ctx: ChatRequestContext,\n hostedModel?: string | null,\n ): Promise<LanguageModel> {\n if (typeof modelOption === 'function') return modelOption(ctx);\n if (modelOption) return modelOption;\n if (hostedModel) return hostedModel;\n throw new Error(\n '[chat-widget] No `model` provided. Pass a `model` (a LanguageModel or a ' +\n 'function returning one), or configure one via hosted config.',\n );\n }\n\n // Authenticate and build the per-request context. Returns null when the\n // request is unauthenticated — callers turn that into a 401.\n async function authenticate(request: Request, conversationId: string): Promise<ChatRequestContext | null> {\n const userId = await getUserId(request);\n if (!userId) return null;\n return { userId, conversationId, request };\n }\n\n // ── POST /chat ─────────────────────────────────────────────────────────\n async function handleChat(request: Request): Promise<Response> {\n let body: { messages?: UIMessage[]; id?: string };\n try {\n body = await request.json();\n } catch {\n return json({ error: 'Invalid JSON body' }, 400);\n }\n const conversationId = typeof body.id === 'string' && body.id ? body.id : undefined;\n if (!conversationId) return json({ error: 'Missing conversation id' }, 400);\n\n const ctx = await authenticate(request, conversationId);\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n\n // Sanitise the incoming array: drop anything that isn't a well-formed\n // message (null/undefined, missing role, missing parts). A malformed entry\n // must never crash the turn — skip it rather than throw downstream.\n const incoming = (Array.isArray(body.messages) ? body.messages : []).filter(\n (m): m is UIMessage =>\n !!m && typeof m === 'object' && typeof m.role === 'string' && Array.isArray(m.parts),\n );\n const store = resolveStore(ctx.userId);\n\n // Ownership chokepoint: create the conversation for this user, or reject\n // (403) if the id belongs to someone else. Nothing is persisted on reject.\n try {\n await store.ensureConversation(conversationId);\n } catch (err) {\n if (err instanceof ConversationOwnershipError) {\n return new Response('Forbidden', { status: 403 });\n }\n throw err;\n }\n\n // Persist the latest user message idempotently (the store dedupes on id).\n const lastUser = [...incoming].reverse().find((m) => m.role === 'user');\n if (lastUser) {\n await store.saveTurn({ conversationId, messages: [lastUser] });\n }\n\n // Sliding-window prune + defensive char-cap, then the host's transform.\n const windowed = incoming.slice(-maxHistoryMessages);\n const capped = maxMessageChars > 0 ? capMessages(windowed, maxMessageChars) : windowed;\n let modelMessages: ModelMessage[] = await convertToModelMessages(capped);\n if (transformMessages) modelMessages = await transformMessages(modelMessages, ctx);\n\n // Build tools (with their per-request resource).\n const built = buildTools ? await buildTools(ctx) : { tools: {} as ToolSet };\n const tools = built.tools ?? ({} as ToolSet);\n\n // Fetch hosted config once (best-effort — a failure must never break the\n // turn). The inner try/catch swallows BOTH a synchronous throw and an async\n // rejection, honouring the \"throwing falls through to code/defaults\"\n // contract for arbitrary consumers. Used only to fill model / system that\n // code didn't provide.\n const hosted = getHostedConfig\n ? await (async () => {\n try {\n return await getHostedConfig(ctx);\n } catch {\n return null;\n }\n })()\n : null;\n\n // Model: code option > hosted > throw.\n const model = await resolveModel(ctx, hosted?.model);\n // String label of the model for persistence (the `model` column). A\n // LanguageModel is either a gateway string (\"anthropic/claude-…\") or a\n // provider object exposing `.modelId`.\n const modelLabel =\n typeof model === 'string' ? model : (model as { modelId?: string }).modelId;\n\n // System prompt: code (buildSystemPrompt) > hosted > package default.\n const system = buildSystemPrompt\n ? await buildSystemPrompt(ctx)\n : hosted?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;\n\n // Single, guarded teardown of the tools' per-request resource. Fires\n // exactly once across all completion paths (finish / error / abort).\n let cleanedUp = false;\n const runCleanup = async (reason: string) => {\n if (cleanedUp || !built.cleanup) return;\n cleanedUp = true;\n try {\n await built.cleanup();\n } catch (err) {\n console.error(`[chat-widget] tool cleanup failed (${reason}):`, err);\n }\n };\n request.signal.addEventListener('abort', () => void runCleanup('client-abort'));\n\n // streamText's own onFinish is the only place usage + providerMetadata are\n // available (the UI-stream onFinish below exposes neither). Capture them\n // here so the host's onChatFinish hook gets real numbers, not undefined.\n let finalUsage: unknown;\n let finalProviderMetadata: unknown;\n\n const result = streamText({\n model,\n system,\n messages: modelMessages,\n tools,\n stopWhen: stopWhen ?? stepCountIs(DEFAULT_STEP_BUDGET),\n onFinish: ({ usage, providerMetadata }) => {\n finalUsage = usage;\n finalProviderMetadata = providerMetadata;\n },\n });\n\n return result.toUIMessageStreamResponse({\n sendSources: true,\n sendReasoning: true,\n // REQUIRED for correct persistence. Without `generateMessageId` the\n // assistant message comes back with an empty id, so every assistant turn\n // in a conversation collides on the same '' primary key and only the\n // first one survives `saveTurn`'s idempotent insert. `originalMessages`\n // lets the SDK reuse existing ids (preventing duplicates) and return the\n // full original+response set in onFinish.\n originalMessages: incoming,\n generateMessageId: generateId,\n onFinish: async ({ messages: finalMessages, isAborted }) => {\n // Don't persist a turn the client aborted mid-stream — the assistant\n // message is partial and the user didn't receive it. The idempotent\n // user-message save already happened before streaming.\n if (!isAborted && finalMessages.length > 0) {\n // Persist the assistant turn. Errors here are logged loudly — a\n // silently-dropped turn is the exact failure we designed against —\n // but never thrown, because the user already has their answer.\n try {\n await store.saveTurn({ conversationId, messages: finalMessages, model: modelLabel });\n } catch (err) {\n console.error(\n JSON.stringify({\n event: 'chat.save_failed',\n userId: ctx.userId,\n conversationId,\n error: err instanceof Error ? err.message : String(err),\n }),\n );\n }\n }\n if (onChatFinish) {\n try {\n await onChatFinish({\n ctx,\n messages: finalMessages,\n usage: finalUsage,\n providerMetadata: finalProviderMetadata,\n });\n } catch (err) {\n console.error('[chat-widget] onChatFinish hook threw:', err);\n }\n }\n await runCleanup('on-finish');\n },\n onError: (err) => {\n const message = onError ? onError(err) : defaultErrorMessage(err);\n void runCleanup('on-error');\n return message;\n },\n });\n }\n\n // ── GET /history ─────────────────────────────────────────────────────────\n async function handleHistoryList(request: Request): Promise<Response> {\n const ctx = await authenticate(request, '');\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const store = resolveStore(ctx.userId);\n const conversations = await store.listConversations();\n return jsonNoStore({\n conversations: conversations.map((c) => ({\n id: c.id,\n title: c.title,\n created_at: c.createdAt,\n updated_at: c.updatedAt,\n metadata: c.metadata,\n message_count: c.messageCount,\n })),\n });\n }\n\n // ── GET /history/:id and DELETE /history/:id ─────────────────────────────\n async function handleConversation(\n request: Request,\n conversationId: string,\n method: 'GET' | 'DELETE',\n ): Promise<Response> {\n const ctx = await authenticate(request, conversationId);\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const store = resolveStore(ctx.userId);\n const storage = resolveStorage(ctx.userId);\n\n if (method === 'DELETE') {\n const deleted = await store.deleteConversation(conversationId);\n return new Response(null, { status: deleted ? 204 : 404 });\n }\n\n const conversation = await store.getConversation(conversationId);\n if (!conversation) return json({ error: 'Conversation not found' }, 404);\n\n const messages = await store.listMessages(conversationId, { limit: 100 });\n // Re-sign attachment URLs so reopened conversations show live thumbnails.\n const rehydrated = storage\n ? await Promise.all(messages.map((m) => resignMessageAttachments(m, storage)))\n : messages;\n\n return jsonNoStore({\n conversation: {\n id: conversation.id,\n title: conversation.title,\n metadata: conversation.metadata,\n },\n messages: rehydrated.map((m) => ({\n id: m.id,\n role: m.role,\n content: m.text,\n created_at: m.createdAt,\n parts: m.parts,\n })),\n });\n }\n\n // ── POST /upload ───────────────────────────────────────────────────────────\n async function handleUpload(request: Request): Promise<Response> {\n const ctx = await authenticate(request, '');\n if (!ctx) return new Response('Unauthorized', { status: 401 });\n const storage = resolveStorage(ctx.userId);\n if (!storage) return json({ error: 'File upload is not configured' }, 503);\n\n let form: FormData;\n try {\n form = await request.formData();\n } catch {\n return json({ error: 'Invalid multipart body' }, 400);\n }\n const file = form.get('file');\n const conversationId =\n typeof form.get('conversationId') === 'string'\n ? (form.get('conversationId') as string)\n : undefined;\n\n if (!(file instanceof File)) return json({ error: 'No file provided' }, 400);\n\n const policy = resolveUploadPolicy(upload);\n if (file.size === 0) return json({ error: 'Empty file' }, 400);\n if (file.size > policy.maxBytes) {\n return json({ error: `File too large (max ${policy.maxBytes / 1024 / 1024} MB)` }, 413);\n }\n const mediaType = file.type || 'application/octet-stream';\n if (!policy.allowedMediaTypes.includes(mediaType)) {\n return json({ error: `Unsupported file type: ${mediaType}` }, 415);\n }\n\n const data = await file.arrayBuffer();\n const uploaded = await storage.upload({\n data,\n filename: file.name,\n mediaType,\n size: file.size,\n conversationId,\n });\n return json({\n url: uploaded.url,\n storagePath: uploaded.storagePath,\n filename: uploaded.filename,\n mediaType: uploaded.mediaType,\n size: uploaded.size,\n type: 'file',\n });\n }\n\n // ── Dispatch ───────────────────────────────────────────────────────────────\n async function dispatch(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const segments = subSegments(url);\n const method = request.method.toUpperCase();\n\n try {\n // /chat (no extra segments)\n if (segments.length === 0) {\n if (method === 'POST') return await handleChat(request);\n return methodNotAllowed();\n }\n const [head, ...rest] = segments;\n if (!KNOWN_SEGMENTS.has(head)) return json({ error: 'Not found' }, 404);\n\n if (head === 'upload') {\n if (method === 'POST') return await handleUpload(request);\n return methodNotAllowed();\n }\n if (head === 'history') {\n if (rest.length === 0) {\n if (method === 'GET') return await handleHistoryList(request);\n return methodNotAllowed();\n }\n const conversationId = rest[0];\n if (method === 'GET') return await handleConversation(request, conversationId, 'GET');\n if (method === 'DELETE') return await handleConversation(request, conversationId, 'DELETE');\n return methodNotAllowed();\n }\n return json({ error: 'Not found' }, 404);\n } catch (err) {\n console.error('[chat-widget] handler error:', err);\n return json({ error: 'Internal server error' }, 500);\n }\n }\n\n // Next.js App Router expects named method exports. We point them all at the\n // same dispatcher so one catch-all route file mounts everything.\n return {\n GET: dispatch,\n POST: dispatch,\n DELETE: dispatch,\n };\n}\n\n// ── Module-private utilities ────────────────────────────────────────────────\n\nfunction methodNotAllowed(): Response {\n return json({ error: 'Method not allowed' }, 405);\n}\n\nfunction defaultErrorMessage(err: unknown): string {\n console.error('[chat-widget] stream error:', err);\n return 'An error occurred while generating the response.';\n}\n\nfunction resolveUploadPolicy(upload?: UploadPolicy): {\n maxBytes: number;\n allowedMediaTypes: string[];\n} {\n return {\n maxBytes: upload?.maxBytes ?? DEFAULT_MAX_UPLOAD_BYTES,\n allowedMediaTypes: upload?.allowedMediaTypes ?? DEFAULT_ALLOWED_MEDIA_TYPES,\n };\n}\n\n/** Cap overlong text parts so one pasted blob can't dominate the window. */\nfunction capMessages(messages: UIMessage[], maxChars: number): UIMessage[] {\n return messages.map((msg) => {\n if (!msg || !Array.isArray(msg.parts)) return msg;\n const parts = msg.parts.map((p) =>\n p.type === 'text' && typeof (p as { text?: string }).text === 'string' && (p as { text: string }).text.length > maxChars\n ? { ...p, text: (p as { text: string }).text.slice(0, maxChars) }\n : p,\n );\n return { ...msg, parts };\n });\n}\n\n/**\n * Re-sign every file part on a stored message so a reopened conversation gets\n * live URLs. A failed re-sign leaves the original (stale) url in place rather\n * than dropping the whole message — one missing blob never breaks a load.\n */\nasync function resignMessageAttachments<T extends { parts: UIMessage['parts'] }>(\n message: T,\n storage: StorageAdapter,\n): Promise<T> {\n if (!message.parts?.length) return message;\n const parts = await Promise.all(\n message.parts.map(async (part) => {\n const p = part as { type?: string; storagePath?: string; url?: string };\n if (p.type !== 'file' || typeof p.storagePath !== 'string') return part;\n const fresh = await storage.resign(p.storagePath);\n return fresh ? { ...part, url: fresh } : part;\n }),\n );\n return { ...message, parts };\n}\n"],"mappings":"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","names":[]}