@mordn/chat-widget 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -275,4 +275,4 @@ interface ChatStore {
275
275
  */
276
276
  type ChatStoreFactory = (userId: string) => ChatStore;
277
277
 
278
- export { type ChatStoreFactory as C, type ListMessagesOptions as L, type StoredAttachment as S, type StoredConversation as a, type StoredMessage as b, type SaveTurnInput as c, type ChatStore as d, ConversationOwnershipError as e };
278
+ export { type ChatStore as C, type ListMessagesOptions as L, type StoredAttachment as S, type ChatStoreFactory as a, type StoredConversation as b, type StoredMessage as c, type SaveTurnInput as d, ConversationOwnershipError as e };
@@ -275,4 +275,4 @@ interface ChatStore {
275
275
  */
276
276
  type ChatStoreFactory = (userId: string) => ChatStore;
277
277
 
278
- export { type ChatStoreFactory as C, type ListMessagesOptions as L, type StoredAttachment as S, type StoredConversation as a, type StoredMessage as b, type SaveTurnInput as c, type ChatStore as d, ConversationOwnershipError as e };
278
+ export { type ChatStore as C, type ListMessagesOptions as L, type StoredAttachment as S, type ChatStoreFactory as a, type StoredConversation as b, type StoredMessage as c, type SaveTurnInput as d, ConversationOwnershipError as e };
@@ -1,4 +1,4 @@
1
- import { d as ChatStore } from '../../chat-store-DERCPwhl.mjs';
1
+ import { C as ChatStore } from '../../chat-store-DdykLpDo.mjs';
2
2
  import { drizzle } from 'drizzle-orm/postgres-js';
3
3
  import * as ai from 'ai';
4
4
  import * as drizzle_orm_pg_core from 'drizzle-orm/pg-core';
@@ -1,4 +1,4 @@
1
- import { d as ChatStore } from '../../chat-store-DERCPwhl.js';
1
+ import { C as ChatStore } from '../../chat-store-DdykLpDo.js';
2
2
  import { drizzle } from 'drizzle-orm/postgres-js';
3
3
  import * as ai from 'ai';
4
4
  import * as drizzle_orm_pg_core from 'drizzle-orm/pg-core';
@@ -0,0 +1,38 @@
1
+ import { C as ChatStore } from '../../chat-store-DdykLpDo.mjs';
2
+ import { S as StorageAdapter } from '../../storage-adapter-DD8uqiAP.mjs';
3
+ import 'ai';
4
+
5
+ /**
6
+ * Hosted ChatStore + StorageAdapter — thin HTTP clients over the @mordn/chat-api
7
+ * service. Same interfaces as the Drizzle/Supabase defaults, so switching a
8
+ * consumer from BYO to hosted is a one-line change:
9
+ *
10
+ * store: createHostedChatStore({ apiKey: process.env.MORDN_CHAT_KEY })
11
+ * storage: createHostedStorage({ apiKey: process.env.MORDN_CHAT_KEY })
12
+ *
13
+ * Identity: the `apiKey` authenticates the TENANT (the customer/app). The
14
+ * per-request `userId` the handler binds is sent as `X-Chat-User` — the end
15
+ * user, derived from the consumer's verified session (same trust model as
16
+ * getUserId). The hosted service enforces both axes; this client never decides
17
+ * authorization, it only carries identity.
18
+ */
19
+
20
+ interface HostedOptions {
21
+ /** Tenant API key (mck_live_… / mck_test_…). Required. Never sent to the client. */
22
+ apiKey: string;
23
+ /** API base URL. Defaults to the hosted service; override for self-host/local. */
24
+ baseUrl?: string;
25
+ /** Optional fetch override (testing). */
26
+ fetch?: typeof fetch;
27
+ }
28
+ /**
29
+ * Create a `ChatStoreFactory` backed by the hosted @mordn/chat-api service.
30
+ * Pass to `createChatHandler({ store: createHostedChatStore({ apiKey }) })`.
31
+ */
32
+ declare function createHostedChatStore(options: HostedOptions): (userId: string) => ChatStore;
33
+ /**
34
+ * Create a `StorageAdapterFactory` backed by the hosted service.
35
+ */
36
+ declare function createHostedStorage(options: HostedOptions): (userId: string) => StorageAdapter;
37
+
38
+ export { type HostedOptions, createHostedChatStore, createHostedStorage };
@@ -0,0 +1,38 @@
1
+ import { C as ChatStore } from '../../chat-store-DdykLpDo.js';
2
+ import { S as StorageAdapter } from '../../storage-adapter-DD8uqiAP.js';
3
+ import 'ai';
4
+
5
+ /**
6
+ * Hosted ChatStore + StorageAdapter — thin HTTP clients over the @mordn/chat-api
7
+ * service. Same interfaces as the Drizzle/Supabase defaults, so switching a
8
+ * consumer from BYO to hosted is a one-line change:
9
+ *
10
+ * store: createHostedChatStore({ apiKey: process.env.MORDN_CHAT_KEY })
11
+ * storage: createHostedStorage({ apiKey: process.env.MORDN_CHAT_KEY })
12
+ *
13
+ * Identity: the `apiKey` authenticates the TENANT (the customer/app). The
14
+ * per-request `userId` the handler binds is sent as `X-Chat-User` — the end
15
+ * user, derived from the consumer's verified session (same trust model as
16
+ * getUserId). The hosted service enforces both axes; this client never decides
17
+ * authorization, it only carries identity.
18
+ */
19
+
20
+ interface HostedOptions {
21
+ /** Tenant API key (mck_live_… / mck_test_…). Required. Never sent to the client. */
22
+ apiKey: string;
23
+ /** API base URL. Defaults to the hosted service; override for self-host/local. */
24
+ baseUrl?: string;
25
+ /** Optional fetch override (testing). */
26
+ fetch?: typeof fetch;
27
+ }
28
+ /**
29
+ * Create a `ChatStoreFactory` backed by the hosted @mordn/chat-api service.
30
+ * Pass to `createChatHandler({ store: createHostedChatStore({ apiKey }) })`.
31
+ */
32
+ declare function createHostedChatStore(options: HostedOptions): (userId: string) => ChatStore;
33
+ /**
34
+ * Create a `StorageAdapterFactory` backed by the hosted service.
35
+ */
36
+ declare function createHostedStorage(options: HostedOptions): (userId: string) => StorageAdapter;
37
+
38
+ export { type HostedOptions, createHostedChatStore, createHostedStorage };
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/server/stores/hosted/index.ts
21
+ var hosted_exports = {};
22
+ __export(hosted_exports, {
23
+ createHostedChatStore: () => createHostedChatStore,
24
+ createHostedStorage: () => createHostedStorage
25
+ });
26
+ module.exports = __toCommonJS(hosted_exports);
27
+ var import_server_only2 = require("server-only");
28
+
29
+ // src/server/stores/hosted/store.ts
30
+ var import_server_only = require("server-only");
31
+
32
+ // src/server/chat-store.ts
33
+ var ConversationOwnershipError = class extends Error {
34
+ constructor(conversationId) {
35
+ super(`Conversation ${conversationId} is not owned by the current user`);
36
+ this.conversationId = conversationId;
37
+ this.name = "ConversationOwnershipError";
38
+ }
39
+ };
40
+
41
+ // src/server/stores/hosted/store.ts
42
+ var DEFAULT_BASE_URL = "https://api.mordn.dev";
43
+ function normaliseConversation(raw) {
44
+ return {
45
+ id: raw.id,
46
+ title: raw.title,
47
+ metadata: raw.metadata ?? null,
48
+ createdAt: new Date(raw.created_at ?? raw.createdAt),
49
+ updatedAt: new Date(raw.updated_at ?? raw.updatedAt),
50
+ messageCount: raw.message_count ?? raw.messageCount
51
+ };
52
+ }
53
+ function normaliseMessage(raw) {
54
+ return {
55
+ id: raw.id,
56
+ role: raw.role,
57
+ parts: raw.parts ?? [],
58
+ text: raw.content ?? raw.text ?? "",
59
+ model: raw.model ?? void 0,
60
+ createdAt: new Date(raw.created_at ?? raw.createdAt)
61
+ };
62
+ }
63
+ var HostedChatStore = class {
64
+ constructor(userId, apiKey, baseUrl, fetchImpl) {
65
+ this.userId = userId;
66
+ this.apiKey = apiKey;
67
+ this.base = baseUrl.replace(/\/$/, "");
68
+ this.doFetch = fetchImpl;
69
+ }
70
+ headers(json = false) {
71
+ const h = {
72
+ Authorization: `Bearer ${this.apiKey}`,
73
+ "X-Chat-User": this.userId
74
+ };
75
+ if (json) h["Content-Type"] = "application/json";
76
+ return h;
77
+ }
78
+ async req(path, init) {
79
+ return this.doFetch(`${this.base}/v1${path}`, init);
80
+ }
81
+ async listConversations() {
82
+ const res = await this.req("/conversations", { headers: this.headers() });
83
+ if (!res.ok) return [];
84
+ const data = await res.json();
85
+ return (data.conversations ?? []).map(normaliseConversation);
86
+ }
87
+ async getConversation(id) {
88
+ const res = await this.req(`/conversations/${encodeURIComponent(id)}`, { headers: this.headers() });
89
+ if (res.status === 404) return null;
90
+ if (!res.ok) return null;
91
+ const data = await res.json();
92
+ return data.conversation ? normaliseConversation(data.conversation) : null;
93
+ }
94
+ async ensureConversation(id) {
95
+ const res = await this.req(`/conversations/${encodeURIComponent(id)}/turns`, {
96
+ method: "POST",
97
+ headers: this.headers(true),
98
+ body: JSON.stringify({ messages: [] })
99
+ });
100
+ if (res.status === 403) throw new ConversationOwnershipError(id);
101
+ const conv = await this.getConversation(id);
102
+ if (conv) return conv;
103
+ const now = /* @__PURE__ */ new Date();
104
+ return { id, title: "New Chat", metadata: {}, createdAt: now, updatedAt: now };
105
+ }
106
+ async renameConversation(id, title) {
107
+ await this.req(`/conversations/${encodeURIComponent(id)}`, {
108
+ method: "PATCH",
109
+ headers: this.headers(true),
110
+ body: JSON.stringify({ title })
111
+ });
112
+ }
113
+ async deleteConversation(id) {
114
+ const res = await this.req(`/conversations/${encodeURIComponent(id)}`, {
115
+ method: "DELETE",
116
+ headers: this.headers()
117
+ });
118
+ return res.status === 204;
119
+ }
120
+ async listMessages(conversationId, _opts) {
121
+ const res = await this.req(`/conversations/${encodeURIComponent(conversationId)}`, { headers: this.headers() });
122
+ if (!res.ok) return [];
123
+ const data = await res.json();
124
+ return (data.messages ?? []).map(normaliseMessage);
125
+ }
126
+ async saveTurn(input) {
127
+ const res = await this.req(`/conversations/${encodeURIComponent(input.conversationId)}/turns`, {
128
+ method: "POST",
129
+ headers: this.headers(true),
130
+ body: JSON.stringify({ messages: input.messages, model: input.model })
131
+ });
132
+ if (res.status === 403) throw new ConversationOwnershipError(input.conversationId);
133
+ if (!res.ok) {
134
+ throw new Error(`[chat-widget] hosted saveTurn failed: ${res.status} ${await res.text().catch(() => "")}`);
135
+ }
136
+ }
137
+ };
138
+ var HostedStorageAdapter = class {
139
+ constructor(userId, apiKey, baseUrl, fetchImpl) {
140
+ this.userId = userId;
141
+ this.apiKey = apiKey;
142
+ this.base = baseUrl.replace(/\/$/, "");
143
+ this.doFetch = fetchImpl;
144
+ }
145
+ async upload(input) {
146
+ const form = new FormData();
147
+ const buf = input.data instanceof Uint8Array ? input.data.buffer.slice(input.data.byteOffset, input.data.byteOffset + input.data.byteLength) : input.data;
148
+ form.append("file", new Blob([buf], { type: input.mediaType }), input.filename);
149
+ if (input.conversationId) form.append("conversationId", input.conversationId);
150
+ const res = await this.doFetch(`${this.base}/v1/uploads`, {
151
+ method: "POST",
152
+ headers: { Authorization: `Bearer ${this.apiKey}`, "X-Chat-User": this.userId },
153
+ body: form
154
+ });
155
+ if (!res.ok) throw new Error(`[chat-widget] hosted upload failed: ${res.status}`);
156
+ const r = await res.json();
157
+ return r;
158
+ }
159
+ async resign(storagePath) {
160
+ const res = await this.doFetch(`${this.base}/v1/uploads/resign`, {
161
+ method: "POST",
162
+ headers: { Authorization: `Bearer ${this.apiKey}`, "X-Chat-User": this.userId, "Content-Type": "application/json" },
163
+ body: JSON.stringify({ storagePath })
164
+ });
165
+ if (!res.ok) return null;
166
+ const r = await res.json();
167
+ return r.url ?? null;
168
+ }
169
+ async remove(storagePath) {
170
+ await this.doFetch(`${this.base}/v1/uploads`, {
171
+ method: "DELETE",
172
+ headers: { Authorization: `Bearer ${this.apiKey}`, "X-Chat-User": this.userId, "Content-Type": "application/json" },
173
+ body: JSON.stringify({ storagePath })
174
+ }).catch(() => {
175
+ });
176
+ }
177
+ };
178
+ function createHostedChatStore(options) {
179
+ if (!options.apiKey) throw new Error("[chat-widget] createHostedChatStore requires an apiKey");
180
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
181
+ const fetchImpl = options.fetch ?? fetch;
182
+ return (userId) => new HostedChatStore(userId, options.apiKey, baseUrl, fetchImpl);
183
+ }
184
+ function createHostedStorage(options) {
185
+ if (!options.apiKey) throw new Error("[chat-widget] createHostedStorage requires an apiKey");
186
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
187
+ const fetchImpl = options.fetch ?? fetch;
188
+ return (userId) => new HostedStorageAdapter(userId, options.apiKey, baseUrl, fetchImpl);
189
+ }
190
+ // Annotate the CommonJS export names for ESM import in node:
191
+ 0 && (module.exports = {
192
+ createHostedChatStore,
193
+ createHostedStorage
194
+ });
195
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/server/stores/hosted/index.ts","../../../src/server/stores/hosted/store.ts","../../../src/server/chat-store.ts"],"sourcesContent":["/**\n * Hosted store/storage clients — public entry.\n *\n * import { createHostedChatStore, createHostedStorage } from '@mordn/chat-widget/server/hosted';\n * createChatHandler({\n * getUserId,\n * model,\n * store: createHostedChatStore({ apiKey: process.env.MORDN_CHAT_KEY }),\n * storage: createHostedStorage({ apiKey: process.env.MORDN_CHAT_KEY }),\n * });\n *\n * No DATABASE_URL, no bucket, no migrations — the hosted @mordn/chat-api service\n * owns all of that. The only secret you hold is the tenant API key.\n */\nimport 'server-only';\n\nexport { createHostedChatStore, createHostedStorage, type HostedOptions } from './store';\n","/**\n * Hosted ChatStore + StorageAdapter — thin HTTP clients over the @mordn/chat-api\n * service. Same interfaces as the Drizzle/Supabase defaults, so switching a\n * consumer from BYO to hosted is a one-line change:\n *\n * store: createHostedChatStore({ apiKey: process.env.MORDN_CHAT_KEY })\n * storage: createHostedStorage({ apiKey: process.env.MORDN_CHAT_KEY })\n *\n * Identity: the `apiKey` authenticates the TENANT (the customer/app). The\n * per-request `userId` the handler binds is sent as `X-Chat-User` — the end\n * user, derived from the consumer's verified session (same trust model as\n * getUserId). The hosted service enforces both axes; this client never decides\n * authorization, it only carries identity.\n */\n\nimport 'server-only';\nimport {\n ConversationOwnershipError,\n type ChatStore,\n} from '../../chat-store';\nimport type { StorageAdapter, UploadInput, UploadResult } from '../../storage-adapter';\nimport type {\n ListMessagesOptions,\n SaveTurnInput,\n StoredConversation,\n StoredMessage,\n} from '../../types';\n\nconst DEFAULT_BASE_URL = 'https://api.mordn.dev';\n\nexport interface HostedOptions {\n /** Tenant API key (mck_live_… / mck_test_…). Required. Never sent to the client. */\n apiKey: string;\n /** API base URL. Defaults to the hosted service; override for self-host/local. */\n baseUrl?: string;\n /** Optional fetch override (testing). */\n fetch?: typeof fetch;\n}\n\nfunction normaliseConversation(raw: any): StoredConversation {\n return {\n id: raw.id,\n title: raw.title,\n metadata: raw.metadata ?? null,\n createdAt: new Date(raw.created_at ?? raw.createdAt),\n updatedAt: new Date(raw.updated_at ?? raw.updatedAt),\n messageCount: raw.message_count ?? raw.messageCount,\n };\n}\n\nfunction normaliseMessage(raw: any): StoredMessage {\n return {\n id: raw.id,\n role: raw.role,\n parts: raw.parts ?? [],\n text: raw.content ?? raw.text ?? '',\n model: raw.model ?? undefined,\n createdAt: new Date(raw.created_at ?? raw.createdAt),\n };\n}\n\nclass HostedChatStore implements ChatStore {\n private readonly base: string;\n private readonly doFetch: typeof fetch;\n\n constructor(\n public readonly userId: string,\n private readonly apiKey: string,\n baseUrl: string,\n fetchImpl: typeof fetch,\n ) {\n this.base = baseUrl.replace(/\\/$/, '');\n this.doFetch = fetchImpl;\n }\n\n private headers(json = false): Record<string, string> {\n const h: Record<string, string> = {\n Authorization: `Bearer ${this.apiKey}`,\n 'X-Chat-User': this.userId,\n };\n if (json) h['Content-Type'] = 'application/json';\n return h;\n }\n\n private async req(path: string, init?: RequestInit): Promise<Response> {\n return this.doFetch(`${this.base}/v1${path}`, init);\n }\n\n async listConversations(): Promise<StoredConversation[]> {\n const res = await this.req('/conversations', { headers: this.headers() });\n if (!res.ok) return [];\n const data = (await res.json()) as { conversations?: any[] };\n return (data.conversations ?? []).map(normaliseConversation);\n }\n\n async getConversation(id: string): Promise<StoredConversation | null> {\n const res = await this.req(`/conversations/${encodeURIComponent(id)}`, { headers: this.headers() });\n if (res.status === 404) return null;\n if (!res.ok) return null;\n const data = (await res.json()) as { conversation?: any };\n return data.conversation ? normaliseConversation(data.conversation) : null;\n }\n\n async ensureConversation(id: string): Promise<StoredConversation> {\n // The hosted API creates-or-rejects inside POST /turns (it calls\n // ensureConversation server-side). With no messages this is a cheap upsert;\n // a 403 means the conversation belongs to another user → ownership error.\n const res = await this.req(`/conversations/${encodeURIComponent(id)}/turns`, {\n method: 'POST',\n headers: this.headers(true),\n body: JSON.stringify({ messages: [] }),\n });\n if (res.status === 403) throw new ConversationOwnershipError(id);\n // Read it back so the contract (returns the row) holds.\n const conv = await this.getConversation(id);\n if (conv) return conv;\n // Brand-new conversation with no messages yet: synthesise the row shape.\n const now = new Date();\n return { id, title: 'New Chat', metadata: {}, createdAt: now, updatedAt: now };\n }\n\n async renameConversation(id: string, title: string): Promise<void> {\n await this.req(`/conversations/${encodeURIComponent(id)}`, {\n method: 'PATCH',\n headers: this.headers(true),\n body: JSON.stringify({ title }),\n });\n }\n\n async deleteConversation(id: string): Promise<boolean> {\n const res = await this.req(`/conversations/${encodeURIComponent(id)}`, {\n method: 'DELETE',\n headers: this.headers(),\n });\n return res.status === 204;\n }\n\n async listMessages(conversationId: string, _opts?: ListMessagesOptions): Promise<StoredMessage[]> {\n const res = await this.req(`/conversations/${encodeURIComponent(conversationId)}`, { headers: this.headers() });\n if (!res.ok) return [];\n const data = (await res.json()) as { messages?: any[] };\n return (data.messages ?? []).map(normaliseMessage);\n }\n\n async saveTurn(input: SaveTurnInput): Promise<void> {\n const res = await this.req(`/conversations/${encodeURIComponent(input.conversationId)}/turns`, {\n method: 'POST',\n headers: this.headers(true),\n body: JSON.stringify({ messages: input.messages, model: input.model }),\n });\n if (res.status === 403) throw new ConversationOwnershipError(input.conversationId);\n if (!res.ok) {\n throw new Error(`[chat-widget] hosted saveTurn failed: ${res.status} ${await res.text().catch(() => '')}`);\n }\n }\n}\n\nclass HostedStorageAdapter implements StorageAdapter {\n private readonly base: string;\n private readonly doFetch: typeof fetch;\n\n constructor(\n public readonly userId: string,\n private readonly apiKey: string,\n baseUrl: string,\n fetchImpl: typeof fetch,\n ) {\n this.base = baseUrl.replace(/\\/$/, '');\n this.doFetch = fetchImpl;\n }\n\n async upload(input: UploadInput): Promise<UploadResult> {\n const form = new FormData();\n // Normalise to an ArrayBuffer for Blob (TS's BlobPart doesn't accept a\n // Uint8Array<ArrayBufferLike> directly under all lib configs).\n const buf: ArrayBuffer =\n input.data instanceof Uint8Array\n ? (input.data.buffer.slice(input.data.byteOffset, input.data.byteOffset + input.data.byteLength) as ArrayBuffer)\n : input.data;\n form.append('file', new Blob([buf], { type: input.mediaType }), input.filename);\n if (input.conversationId) form.append('conversationId', input.conversationId);\n const res = await this.doFetch(`${this.base}/v1/uploads`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${this.apiKey}`, 'X-Chat-User': this.userId },\n body: form,\n });\n if (!res.ok) throw new Error(`[chat-widget] hosted upload failed: ${res.status}`);\n const r = (await res.json()) as UploadResult;\n return r;\n }\n\n async resign(storagePath: string): Promise<string | null> {\n // The hosted history read re-signs server-side, so a separate resign call\n // is rarely needed; expose it for parity. Returns null on any failure.\n const res = await this.doFetch(`${this.base}/v1/uploads/resign`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${this.apiKey}`, 'X-Chat-User': this.userId, 'Content-Type': 'application/json' },\n body: JSON.stringify({ storagePath }),\n });\n if (!res.ok) return null;\n const r = (await res.json()) as { url?: string };\n return r.url ?? null;\n }\n\n async remove(storagePath: string): Promise<void> {\n await this.doFetch(`${this.base}/v1/uploads`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${this.apiKey}`, 'X-Chat-User': this.userId, 'Content-Type': 'application/json' },\n body: JSON.stringify({ storagePath }),\n }).catch(() => {});\n }\n}\n\n/**\n * Create a `ChatStoreFactory` backed by the hosted @mordn/chat-api service.\n * Pass to `createChatHandler({ store: createHostedChatStore({ apiKey }) })`.\n */\nexport function createHostedChatStore(options: HostedOptions) {\n if (!options.apiKey) throw new Error('[chat-widget] createHostedChatStore requires an apiKey');\n const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;\n const fetchImpl = options.fetch ?? fetch;\n return (userId: string): ChatStore => new HostedChatStore(userId, options.apiKey, baseUrl, fetchImpl);\n}\n\n/**\n * Create a `StorageAdapterFactory` backed by the hosted service.\n */\nexport function createHostedStorage(options: HostedOptions) {\n if (!options.apiKey) throw new Error('[chat-widget] createHostedStorage requires an apiKey');\n const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;\n const fetchImpl = options.fetch ?? fetch;\n return (userId: string): StorageAdapter => new HostedStorageAdapter(userId, options.apiKey, baseUrl, fetchImpl);\n}\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"],"mappings":"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","names":["import_server_only"]}
@@ -0,0 +1,169 @@
1
+ // src/server/stores/hosted/index.ts
2
+ import "server-only";
3
+
4
+ // src/server/stores/hosted/store.ts
5
+ import "server-only";
6
+
7
+ // src/server/chat-store.ts
8
+ var ConversationOwnershipError = class extends Error {
9
+ constructor(conversationId) {
10
+ super(`Conversation ${conversationId} is not owned by the current user`);
11
+ this.conversationId = conversationId;
12
+ this.name = "ConversationOwnershipError";
13
+ }
14
+ };
15
+
16
+ // src/server/stores/hosted/store.ts
17
+ var DEFAULT_BASE_URL = "https://api.mordn.dev";
18
+ function normaliseConversation(raw) {
19
+ return {
20
+ id: raw.id,
21
+ title: raw.title,
22
+ metadata: raw.metadata ?? null,
23
+ createdAt: new Date(raw.created_at ?? raw.createdAt),
24
+ updatedAt: new Date(raw.updated_at ?? raw.updatedAt),
25
+ messageCount: raw.message_count ?? raw.messageCount
26
+ };
27
+ }
28
+ function normaliseMessage(raw) {
29
+ return {
30
+ id: raw.id,
31
+ role: raw.role,
32
+ parts: raw.parts ?? [],
33
+ text: raw.content ?? raw.text ?? "",
34
+ model: raw.model ?? void 0,
35
+ createdAt: new Date(raw.created_at ?? raw.createdAt)
36
+ };
37
+ }
38
+ var HostedChatStore = class {
39
+ constructor(userId, apiKey, baseUrl, fetchImpl) {
40
+ this.userId = userId;
41
+ this.apiKey = apiKey;
42
+ this.base = baseUrl.replace(/\/$/, "");
43
+ this.doFetch = fetchImpl;
44
+ }
45
+ headers(json = false) {
46
+ const h = {
47
+ Authorization: `Bearer ${this.apiKey}`,
48
+ "X-Chat-User": this.userId
49
+ };
50
+ if (json) h["Content-Type"] = "application/json";
51
+ return h;
52
+ }
53
+ async req(path, init) {
54
+ return this.doFetch(`${this.base}/v1${path}`, init);
55
+ }
56
+ async listConversations() {
57
+ const res = await this.req("/conversations", { headers: this.headers() });
58
+ if (!res.ok) return [];
59
+ const data = await res.json();
60
+ return (data.conversations ?? []).map(normaliseConversation);
61
+ }
62
+ async getConversation(id) {
63
+ const res = await this.req(`/conversations/${encodeURIComponent(id)}`, { headers: this.headers() });
64
+ if (res.status === 404) return null;
65
+ if (!res.ok) return null;
66
+ const data = await res.json();
67
+ return data.conversation ? normaliseConversation(data.conversation) : null;
68
+ }
69
+ async ensureConversation(id) {
70
+ const res = await this.req(`/conversations/${encodeURIComponent(id)}/turns`, {
71
+ method: "POST",
72
+ headers: this.headers(true),
73
+ body: JSON.stringify({ messages: [] })
74
+ });
75
+ if (res.status === 403) throw new ConversationOwnershipError(id);
76
+ const conv = await this.getConversation(id);
77
+ if (conv) return conv;
78
+ const now = /* @__PURE__ */ new Date();
79
+ return { id, title: "New Chat", metadata: {}, createdAt: now, updatedAt: now };
80
+ }
81
+ async renameConversation(id, title) {
82
+ await this.req(`/conversations/${encodeURIComponent(id)}`, {
83
+ method: "PATCH",
84
+ headers: this.headers(true),
85
+ body: JSON.stringify({ title })
86
+ });
87
+ }
88
+ async deleteConversation(id) {
89
+ const res = await this.req(`/conversations/${encodeURIComponent(id)}`, {
90
+ method: "DELETE",
91
+ headers: this.headers()
92
+ });
93
+ return res.status === 204;
94
+ }
95
+ async listMessages(conversationId, _opts) {
96
+ const res = await this.req(`/conversations/${encodeURIComponent(conversationId)}`, { headers: this.headers() });
97
+ if (!res.ok) return [];
98
+ const data = await res.json();
99
+ return (data.messages ?? []).map(normaliseMessage);
100
+ }
101
+ async saveTurn(input) {
102
+ const res = await this.req(`/conversations/${encodeURIComponent(input.conversationId)}/turns`, {
103
+ method: "POST",
104
+ headers: this.headers(true),
105
+ body: JSON.stringify({ messages: input.messages, model: input.model })
106
+ });
107
+ if (res.status === 403) throw new ConversationOwnershipError(input.conversationId);
108
+ if (!res.ok) {
109
+ throw new Error(`[chat-widget] hosted saveTurn failed: ${res.status} ${await res.text().catch(() => "")}`);
110
+ }
111
+ }
112
+ };
113
+ var HostedStorageAdapter = class {
114
+ constructor(userId, apiKey, baseUrl, fetchImpl) {
115
+ this.userId = userId;
116
+ this.apiKey = apiKey;
117
+ this.base = baseUrl.replace(/\/$/, "");
118
+ this.doFetch = fetchImpl;
119
+ }
120
+ async upload(input) {
121
+ const form = new FormData();
122
+ const buf = input.data instanceof Uint8Array ? input.data.buffer.slice(input.data.byteOffset, input.data.byteOffset + input.data.byteLength) : input.data;
123
+ form.append("file", new Blob([buf], { type: input.mediaType }), input.filename);
124
+ if (input.conversationId) form.append("conversationId", input.conversationId);
125
+ const res = await this.doFetch(`${this.base}/v1/uploads`, {
126
+ method: "POST",
127
+ headers: { Authorization: `Bearer ${this.apiKey}`, "X-Chat-User": this.userId },
128
+ body: form
129
+ });
130
+ if (!res.ok) throw new Error(`[chat-widget] hosted upload failed: ${res.status}`);
131
+ const r = await res.json();
132
+ return r;
133
+ }
134
+ async resign(storagePath) {
135
+ const res = await this.doFetch(`${this.base}/v1/uploads/resign`, {
136
+ method: "POST",
137
+ headers: { Authorization: `Bearer ${this.apiKey}`, "X-Chat-User": this.userId, "Content-Type": "application/json" },
138
+ body: JSON.stringify({ storagePath })
139
+ });
140
+ if (!res.ok) return null;
141
+ const r = await res.json();
142
+ return r.url ?? null;
143
+ }
144
+ async remove(storagePath) {
145
+ await this.doFetch(`${this.base}/v1/uploads`, {
146
+ method: "DELETE",
147
+ headers: { Authorization: `Bearer ${this.apiKey}`, "X-Chat-User": this.userId, "Content-Type": "application/json" },
148
+ body: JSON.stringify({ storagePath })
149
+ }).catch(() => {
150
+ });
151
+ }
152
+ };
153
+ function createHostedChatStore(options) {
154
+ if (!options.apiKey) throw new Error("[chat-widget] createHostedChatStore requires an apiKey");
155
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
156
+ const fetchImpl = options.fetch ?? fetch;
157
+ return (userId) => new HostedChatStore(userId, options.apiKey, baseUrl, fetchImpl);
158
+ }
159
+ function createHostedStorage(options) {
160
+ if (!options.apiKey) throw new Error("[chat-widget] createHostedStorage requires an apiKey");
161
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
162
+ const fetchImpl = options.fetch ?? fetch;
163
+ return (userId) => new HostedStorageAdapter(userId, options.apiKey, baseUrl, fetchImpl);
164
+ }
165
+ export {
166
+ createHostedChatStore,
167
+ createHostedStorage
168
+ };
169
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/server/stores/hosted/index.ts","../../../src/server/stores/hosted/store.ts","../../../src/server/chat-store.ts"],"sourcesContent":["/**\n * Hosted store/storage clients — public entry.\n *\n * import { createHostedChatStore, createHostedStorage } from '@mordn/chat-widget/server/hosted';\n * createChatHandler({\n * getUserId,\n * model,\n * store: createHostedChatStore({ apiKey: process.env.MORDN_CHAT_KEY }),\n * storage: createHostedStorage({ apiKey: process.env.MORDN_CHAT_KEY }),\n * });\n *\n * No DATABASE_URL, no bucket, no migrations — the hosted @mordn/chat-api service\n * owns all of that. The only secret you hold is the tenant API key.\n */\nimport 'server-only';\n\nexport { createHostedChatStore, createHostedStorage, type HostedOptions } from './store';\n","/**\n * Hosted ChatStore + StorageAdapter — thin HTTP clients over the @mordn/chat-api\n * service. Same interfaces as the Drizzle/Supabase defaults, so switching a\n * consumer from BYO to hosted is a one-line change:\n *\n * store: createHostedChatStore({ apiKey: process.env.MORDN_CHAT_KEY })\n * storage: createHostedStorage({ apiKey: process.env.MORDN_CHAT_KEY })\n *\n * Identity: the `apiKey` authenticates the TENANT (the customer/app). The\n * per-request `userId` the handler binds is sent as `X-Chat-User` — the end\n * user, derived from the consumer's verified session (same trust model as\n * getUserId). The hosted service enforces both axes; this client never decides\n * authorization, it only carries identity.\n */\n\nimport 'server-only';\nimport {\n ConversationOwnershipError,\n type ChatStore,\n} from '../../chat-store';\nimport type { StorageAdapter, UploadInput, UploadResult } from '../../storage-adapter';\nimport type {\n ListMessagesOptions,\n SaveTurnInput,\n StoredConversation,\n StoredMessage,\n} from '../../types';\n\nconst DEFAULT_BASE_URL = 'https://api.mordn.dev';\n\nexport interface HostedOptions {\n /** Tenant API key (mck_live_… / mck_test_…). Required. Never sent to the client. */\n apiKey: string;\n /** API base URL. Defaults to the hosted service; override for self-host/local. */\n baseUrl?: string;\n /** Optional fetch override (testing). */\n fetch?: typeof fetch;\n}\n\nfunction normaliseConversation(raw: any): StoredConversation {\n return {\n id: raw.id,\n title: raw.title,\n metadata: raw.metadata ?? null,\n createdAt: new Date(raw.created_at ?? raw.createdAt),\n updatedAt: new Date(raw.updated_at ?? raw.updatedAt),\n messageCount: raw.message_count ?? raw.messageCount,\n };\n}\n\nfunction normaliseMessage(raw: any): StoredMessage {\n return {\n id: raw.id,\n role: raw.role,\n parts: raw.parts ?? [],\n text: raw.content ?? raw.text ?? '',\n model: raw.model ?? undefined,\n createdAt: new Date(raw.created_at ?? raw.createdAt),\n };\n}\n\nclass HostedChatStore implements ChatStore {\n private readonly base: string;\n private readonly doFetch: typeof fetch;\n\n constructor(\n public readonly userId: string,\n private readonly apiKey: string,\n baseUrl: string,\n fetchImpl: typeof fetch,\n ) {\n this.base = baseUrl.replace(/\\/$/, '');\n this.doFetch = fetchImpl;\n }\n\n private headers(json = false): Record<string, string> {\n const h: Record<string, string> = {\n Authorization: `Bearer ${this.apiKey}`,\n 'X-Chat-User': this.userId,\n };\n if (json) h['Content-Type'] = 'application/json';\n return h;\n }\n\n private async req(path: string, init?: RequestInit): Promise<Response> {\n return this.doFetch(`${this.base}/v1${path}`, init);\n }\n\n async listConversations(): Promise<StoredConversation[]> {\n const res = await this.req('/conversations', { headers: this.headers() });\n if (!res.ok) return [];\n const data = (await res.json()) as { conversations?: any[] };\n return (data.conversations ?? []).map(normaliseConversation);\n }\n\n async getConversation(id: string): Promise<StoredConversation | null> {\n const res = await this.req(`/conversations/${encodeURIComponent(id)}`, { headers: this.headers() });\n if (res.status === 404) return null;\n if (!res.ok) return null;\n const data = (await res.json()) as { conversation?: any };\n return data.conversation ? normaliseConversation(data.conversation) : null;\n }\n\n async ensureConversation(id: string): Promise<StoredConversation> {\n // The hosted API creates-or-rejects inside POST /turns (it calls\n // ensureConversation server-side). With no messages this is a cheap upsert;\n // a 403 means the conversation belongs to another user → ownership error.\n const res = await this.req(`/conversations/${encodeURIComponent(id)}/turns`, {\n method: 'POST',\n headers: this.headers(true),\n body: JSON.stringify({ messages: [] }),\n });\n if (res.status === 403) throw new ConversationOwnershipError(id);\n // Read it back so the contract (returns the row) holds.\n const conv = await this.getConversation(id);\n if (conv) return conv;\n // Brand-new conversation with no messages yet: synthesise the row shape.\n const now = new Date();\n return { id, title: 'New Chat', metadata: {}, createdAt: now, updatedAt: now };\n }\n\n async renameConversation(id: string, title: string): Promise<void> {\n await this.req(`/conversations/${encodeURIComponent(id)}`, {\n method: 'PATCH',\n headers: this.headers(true),\n body: JSON.stringify({ title }),\n });\n }\n\n async deleteConversation(id: string): Promise<boolean> {\n const res = await this.req(`/conversations/${encodeURIComponent(id)}`, {\n method: 'DELETE',\n headers: this.headers(),\n });\n return res.status === 204;\n }\n\n async listMessages(conversationId: string, _opts?: ListMessagesOptions): Promise<StoredMessage[]> {\n const res = await this.req(`/conversations/${encodeURIComponent(conversationId)}`, { headers: this.headers() });\n if (!res.ok) return [];\n const data = (await res.json()) as { messages?: any[] };\n return (data.messages ?? []).map(normaliseMessage);\n }\n\n async saveTurn(input: SaveTurnInput): Promise<void> {\n const res = await this.req(`/conversations/${encodeURIComponent(input.conversationId)}/turns`, {\n method: 'POST',\n headers: this.headers(true),\n body: JSON.stringify({ messages: input.messages, model: input.model }),\n });\n if (res.status === 403) throw new ConversationOwnershipError(input.conversationId);\n if (!res.ok) {\n throw new Error(`[chat-widget] hosted saveTurn failed: ${res.status} ${await res.text().catch(() => '')}`);\n }\n }\n}\n\nclass HostedStorageAdapter implements StorageAdapter {\n private readonly base: string;\n private readonly doFetch: typeof fetch;\n\n constructor(\n public readonly userId: string,\n private readonly apiKey: string,\n baseUrl: string,\n fetchImpl: typeof fetch,\n ) {\n this.base = baseUrl.replace(/\\/$/, '');\n this.doFetch = fetchImpl;\n }\n\n async upload(input: UploadInput): Promise<UploadResult> {\n const form = new FormData();\n // Normalise to an ArrayBuffer for Blob (TS's BlobPart doesn't accept a\n // Uint8Array<ArrayBufferLike> directly under all lib configs).\n const buf: ArrayBuffer =\n input.data instanceof Uint8Array\n ? (input.data.buffer.slice(input.data.byteOffset, input.data.byteOffset + input.data.byteLength) as ArrayBuffer)\n : input.data;\n form.append('file', new Blob([buf], { type: input.mediaType }), input.filename);\n if (input.conversationId) form.append('conversationId', input.conversationId);\n const res = await this.doFetch(`${this.base}/v1/uploads`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${this.apiKey}`, 'X-Chat-User': this.userId },\n body: form,\n });\n if (!res.ok) throw new Error(`[chat-widget] hosted upload failed: ${res.status}`);\n const r = (await res.json()) as UploadResult;\n return r;\n }\n\n async resign(storagePath: string): Promise<string | null> {\n // The hosted history read re-signs server-side, so a separate resign call\n // is rarely needed; expose it for parity. Returns null on any failure.\n const res = await this.doFetch(`${this.base}/v1/uploads/resign`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${this.apiKey}`, 'X-Chat-User': this.userId, 'Content-Type': 'application/json' },\n body: JSON.stringify({ storagePath }),\n });\n if (!res.ok) return null;\n const r = (await res.json()) as { url?: string };\n return r.url ?? null;\n }\n\n async remove(storagePath: string): Promise<void> {\n await this.doFetch(`${this.base}/v1/uploads`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${this.apiKey}`, 'X-Chat-User': this.userId, 'Content-Type': 'application/json' },\n body: JSON.stringify({ storagePath }),\n }).catch(() => {});\n }\n}\n\n/**\n * Create a `ChatStoreFactory` backed by the hosted @mordn/chat-api service.\n * Pass to `createChatHandler({ store: createHostedChatStore({ apiKey }) })`.\n */\nexport function createHostedChatStore(options: HostedOptions) {\n if (!options.apiKey) throw new Error('[chat-widget] createHostedChatStore requires an apiKey');\n const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;\n const fetchImpl = options.fetch ?? fetch;\n return (userId: string): ChatStore => new HostedChatStore(userId, options.apiKey, baseUrl, fetchImpl);\n}\n\n/**\n * Create a `StorageAdapterFactory` backed by the hosted service.\n */\nexport function createHostedStorage(options: HostedOptions) {\n if (!options.apiKey) throw new Error('[chat-widget] createHostedStorage requires an apiKey');\n const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;\n const fetchImpl = options.fetch ?? fetch;\n return (userId: string): StorageAdapter => new HostedStorageAdapter(userId, options.apiKey, baseUrl, fetchImpl);\n}\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"],"mappings":";AAcA,OAAO;;;ACCP,OAAO;;;ACsCA,IAAM,6BAAN,cAAyC,MAAM;AAAA,EACpD,YAA4B,gBAAwB;AAClD,UAAM,gBAAgB,cAAc,mCAAmC;AAD7C;AAE1B,SAAK,OAAO;AAAA,EACd;AACF;;;AD9BA,IAAM,mBAAmB;AAWzB,SAAS,sBAAsB,KAA8B;AAC3D,SAAO;AAAA,IACL,IAAI,IAAI;AAAA,IACR,OAAO,IAAI;AAAA,IACX,UAAU,IAAI,YAAY;AAAA,IAC1B,WAAW,IAAI,KAAK,IAAI,cAAc,IAAI,SAAS;AAAA,IACnD,WAAW,IAAI,KAAK,IAAI,cAAc,IAAI,SAAS;AAAA,IACnD,cAAc,IAAI,iBAAiB,IAAI;AAAA,EACzC;AACF;AAEA,SAAS,iBAAiB,KAAyB;AACjD,SAAO;AAAA,IACL,IAAI,IAAI;AAAA,IACR,MAAM,IAAI;AAAA,IACV,OAAO,IAAI,SAAS,CAAC;AAAA,IACrB,MAAM,IAAI,WAAW,IAAI,QAAQ;AAAA,IACjC,OAAO,IAAI,SAAS;AAAA,IACpB,WAAW,IAAI,KAAK,IAAI,cAAc,IAAI,SAAS;AAAA,EACrD;AACF;AAEA,IAAM,kBAAN,MAA2C;AAAA,EAIzC,YACkB,QACC,QACjB,SACA,WACA;AAJgB;AACC;AAIjB,SAAK,OAAO,QAAQ,QAAQ,OAAO,EAAE;AACrC,SAAK,UAAU;AAAA,EACjB;AAAA,EAEQ,QAAQ,OAAO,OAA+B;AACpD,UAAM,IAA4B;AAAA,MAChC,eAAe,UAAU,KAAK,MAAM;AAAA,MACpC,eAAe,KAAK;AAAA,IACtB;AACA,QAAI,KAAM,GAAE,cAAc,IAAI;AAC9B,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,IAAI,MAAc,MAAuC;AACrE,WAAO,KAAK,QAAQ,GAAG,KAAK,IAAI,MAAM,IAAI,IAAI,IAAI;AAAA,EACpD;AAAA,EAEA,MAAM,oBAAmD;AACvD,UAAM,MAAM,MAAM,KAAK,IAAI,kBAAkB,EAAE,SAAS,KAAK,QAAQ,EAAE,CAAC;AACxE,QAAI,CAAC,IAAI,GAAI,QAAO,CAAC;AACrB,UAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,YAAQ,KAAK,iBAAiB,CAAC,GAAG,IAAI,qBAAqB;AAAA,EAC7D;AAAA,EAEA,MAAM,gBAAgB,IAAgD;AACpE,UAAM,MAAM,MAAM,KAAK,IAAI,kBAAkB,mBAAmB,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,QAAQ,EAAE,CAAC;AAClG,QAAI,IAAI,WAAW,IAAK,QAAO;AAC/B,QAAI,CAAC,IAAI,GAAI,QAAO;AACpB,UAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,WAAO,KAAK,eAAe,sBAAsB,KAAK,YAAY,IAAI;AAAA,EACxE;AAAA,EAEA,MAAM,mBAAmB,IAAyC;AAIhE,UAAM,MAAM,MAAM,KAAK,IAAI,kBAAkB,mBAAmB,EAAE,CAAC,UAAU;AAAA,MAC3E,QAAQ;AAAA,MACR,SAAS,KAAK,QAAQ,IAAI;AAAA,MAC1B,MAAM,KAAK,UAAU,EAAE,UAAU,CAAC,EAAE,CAAC;AAAA,IACvC,CAAC;AACD,QAAI,IAAI,WAAW,IAAK,OAAM,IAAI,2BAA2B,EAAE;AAE/D,UAAM,OAAO,MAAM,KAAK,gBAAgB,EAAE;AAC1C,QAAI,KAAM,QAAO;AAEjB,UAAM,MAAM,oBAAI,KAAK;AACrB,WAAO,EAAE,IAAI,OAAO,YAAY,UAAU,CAAC,GAAG,WAAW,KAAK,WAAW,IAAI;AAAA,EAC/E;AAAA,EAEA,MAAM,mBAAmB,IAAY,OAA8B;AACjE,UAAM,KAAK,IAAI,kBAAkB,mBAAmB,EAAE,CAAC,IAAI;AAAA,MACzD,QAAQ;AAAA,MACR,SAAS,KAAK,QAAQ,IAAI;AAAA,MAC1B,MAAM,KAAK,UAAU,EAAE,MAAM,CAAC;AAAA,IAChC,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,mBAAmB,IAA8B;AACrD,UAAM,MAAM,MAAM,KAAK,IAAI,kBAAkB,mBAAmB,EAAE,CAAC,IAAI;AAAA,MACrE,QAAQ;AAAA,MACR,SAAS,KAAK,QAAQ;AAAA,IACxB,CAAC;AACD,WAAO,IAAI,WAAW;AAAA,EACxB;AAAA,EAEA,MAAM,aAAa,gBAAwB,OAAuD;AAChG,UAAM,MAAM,MAAM,KAAK,IAAI,kBAAkB,mBAAmB,cAAc,CAAC,IAAI,EAAE,SAAS,KAAK,QAAQ,EAAE,CAAC;AAC9G,QAAI,CAAC,IAAI,GAAI,QAAO,CAAC;AACrB,UAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,YAAQ,KAAK,YAAY,CAAC,GAAG,IAAI,gBAAgB;AAAA,EACnD;AAAA,EAEA,MAAM,SAAS,OAAqC;AAClD,UAAM,MAAM,MAAM,KAAK,IAAI,kBAAkB,mBAAmB,MAAM,cAAc,CAAC,UAAU;AAAA,MAC7F,QAAQ;AAAA,MACR,SAAS,KAAK,QAAQ,IAAI;AAAA,MAC1B,MAAM,KAAK,UAAU,EAAE,UAAU,MAAM,UAAU,OAAO,MAAM,MAAM,CAAC;AAAA,IACvE,CAAC;AACD,QAAI,IAAI,WAAW,IAAK,OAAM,IAAI,2BAA2B,MAAM,cAAc;AACjF,QAAI,CAAC,IAAI,IAAI;AACX,YAAM,IAAI,MAAM,yCAAyC,IAAI,MAAM,IAAI,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,EAAE,CAAC,EAAE;AAAA,IAC3G;AAAA,EACF;AACF;AAEA,IAAM,uBAAN,MAAqD;AAAA,EAInD,YACkB,QACC,QACjB,SACA,WACA;AAJgB;AACC;AAIjB,SAAK,OAAO,QAAQ,QAAQ,OAAO,EAAE;AACrC,SAAK,UAAU;AAAA,EACjB;AAAA,EAEA,MAAM,OAAO,OAA2C;AACtD,UAAM,OAAO,IAAI,SAAS;AAG1B,UAAM,MACJ,MAAM,gBAAgB,aACjB,MAAM,KAAK,OAAO,MAAM,MAAM,KAAK,YAAY,MAAM,KAAK,aAAa,MAAM,KAAK,UAAU,IAC7F,MAAM;AACZ,SAAK,OAAO,QAAQ,IAAI,KAAK,CAAC,GAAG,GAAG,EAAE,MAAM,MAAM,UAAU,CAAC,GAAG,MAAM,QAAQ;AAC9E,QAAI,MAAM,eAAgB,MAAK,OAAO,kBAAkB,MAAM,cAAc;AAC5E,UAAM,MAAM,MAAM,KAAK,QAAQ,GAAG,KAAK,IAAI,eAAe;AAAA,MACxD,QAAQ;AAAA,MACR,SAAS,EAAE,eAAe,UAAU,KAAK,MAAM,IAAI,eAAe,KAAK,OAAO;AAAA,MAC9E,MAAM;AAAA,IACR,CAAC;AACD,QAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,uCAAuC,IAAI,MAAM,EAAE;AAChF,UAAM,IAAK,MAAM,IAAI,KAAK;AAC1B,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,OAAO,aAA6C;AAGxD,UAAM,MAAM,MAAM,KAAK,QAAQ,GAAG,KAAK,IAAI,sBAAsB;AAAA,MAC/D,QAAQ;AAAA,MACR,SAAS,EAAE,eAAe,UAAU,KAAK,MAAM,IAAI,eAAe,KAAK,QAAQ,gBAAgB,mBAAmB;AAAA,MAClH,MAAM,KAAK,UAAU,EAAE,YAAY,CAAC;AAAA,IACtC,CAAC;AACD,QAAI,CAAC,IAAI,GAAI,QAAO;AACpB,UAAM,IAAK,MAAM,IAAI,KAAK;AAC1B,WAAO,EAAE,OAAO;AAAA,EAClB;AAAA,EAEA,MAAM,OAAO,aAAoC;AAC/C,UAAM,KAAK,QAAQ,GAAG,KAAK,IAAI,eAAe;AAAA,MAC5C,QAAQ;AAAA,MACR,SAAS,EAAE,eAAe,UAAU,KAAK,MAAM,IAAI,eAAe,KAAK,QAAQ,gBAAgB,mBAAmB;AAAA,MAClH,MAAM,KAAK,UAAU,EAAE,YAAY,CAAC;AAAA,IACtC,CAAC,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EACnB;AACF;AAMO,SAAS,sBAAsB,SAAwB;AAC5D,MAAI,CAAC,QAAQ,OAAQ,OAAM,IAAI,MAAM,wDAAwD;AAC7F,QAAM,UAAU,QAAQ,WAAW;AACnC,QAAM,YAAY,QAAQ,SAAS;AACnC,SAAO,CAAC,WAA8B,IAAI,gBAAgB,QAAQ,QAAQ,QAAQ,SAAS,SAAS;AACtG;AAKO,SAAS,oBAAoB,SAAwB;AAC1D,MAAI,CAAC,QAAQ,OAAQ,OAAM,IAAI,MAAM,sDAAsD;AAC3F,QAAM,UAAU,QAAQ,WAAW;AACnC,QAAM,YAAY,QAAQ,SAAS;AACnC,SAAO,CAAC,WAAmC,IAAI,qBAAqB,QAAQ,QAAQ,QAAQ,SAAS,SAAS;AAChH;","names":[]}
@@ -1,5 +1,5 @@
1
- import { C as ChatStoreFactory } from '../chat-store-DERCPwhl.mjs';
2
- export { d as ChatStore, e as ConversationOwnershipError, L as ListMessagesOptions, c as SaveTurnInput, S as StoredAttachment, a as StoredConversation, b as StoredMessage } from '../chat-store-DERCPwhl.mjs';
1
+ import { a as ChatStoreFactory } from '../chat-store-DdykLpDo.mjs';
2
+ export { C as ChatStore, e as ConversationOwnershipError, L as ListMessagesOptions, d as SaveTurnInput, S as StoredAttachment, b as StoredConversation, c as StoredMessage } from '../chat-store-DdykLpDo.mjs';
3
3
  import { a as StorageAdapterFactory } from '../storage-adapter-DD8uqiAP.mjs';
4
4
  export { S as StorageAdapter, U as UploadInput, b as UploadResult } from '../storage-adapter-DD8uqiAP.mjs';
5
5
  import { LanguageModel, ToolSet, ModelMessage, UIMessage, StopCondition } from 'ai';
@@ -1,5 +1,5 @@
1
- import { C as ChatStoreFactory } from '../chat-store-DERCPwhl.js';
2
- export { d as ChatStore, e as ConversationOwnershipError, L as ListMessagesOptions, c as SaveTurnInput, S as StoredAttachment, a as StoredConversation, b as StoredMessage } from '../chat-store-DERCPwhl.js';
1
+ import { a as ChatStoreFactory } from '../chat-store-DdykLpDo.js';
2
+ export { C as ChatStore, e as ConversationOwnershipError, L as ListMessagesOptions, d as SaveTurnInput, S as StoredAttachment, b as StoredConversation, c as StoredMessage } from '../chat-store-DdykLpDo.js';
3
3
  import { a as StorageAdapterFactory } from '../storage-adapter-DD8uqiAP.js';
4
4
  export { S as StorageAdapter, U as UploadInput, b as UploadResult } from '../storage-adapter-DD8uqiAP.js';
5
5
  import { LanguageModel, ToolSet, ModelMessage, UIMessage, StopCondition } from 'ai';
@@ -135,6 +135,7 @@ function createChatHandler(options) {
135
135
  const built = buildTools ? await buildTools(ctx) : { tools: {} };
136
136
  const tools = built.tools ?? {};
137
137
  const model = await resolveModel(ctx);
138
+ const modelLabel = typeof model === "string" ? model : model.modelId;
138
139
  const system = buildSystemPrompt ? await buildSystemPrompt(ctx) : DEFAULT_SYSTEM_PROMPT;
139
140
  let cleanedUp = false;
140
141
  const runCleanup = async (reason) => {
@@ -174,7 +175,7 @@ function createChatHandler(options) {
174
175
  onFinish: async ({ messages: finalMessages, isAborted }) => {
175
176
  if (!isAborted && finalMessages.length > 0) {
176
177
  try {
177
- await store.saveTurn({ conversationId, messages: finalMessages });
178
+ await store.saveTurn({ conversationId, messages: finalMessages, model: modelLabel });
178
179
  } catch (err) {
179
180
  console.error(
180
181
  JSON.stringify({