@mordn/chat-widget 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ import { UIMessage } from 'ai';
2
+
3
+ /**
4
+ * Server-core domain types.
5
+ *
6
+ * This is the shared vocabulary for the server side of the widget — the
7
+ * persistence interfaces (`ChatStore`, `StorageAdapter`), the request
8
+ * router, and the lifecycle hooks all speak in these types.
9
+ *
10
+ * Design notes
11
+ * ------------
12
+ * 1. These types are deliberately decoupled from any specific database or
13
+ * ORM. `ChatStore` is an interface; the Drizzle/Postgres implementation
14
+ * that ships as the default is just *one* implementation of it. A hosted
15
+ * backend, a Prisma store, or an in-memory test double are equally valid.
16
+ *
17
+ * 2. Messages are stored as AI SDK `UIMessage`s — specifically their `parts`
18
+ * array — not as a flattened `content` string. The `parts` array is the
19
+ * canonical representation the AI SDK round-trips (text, reasoning, tool
20
+ * calls, sources, files). Storing anything less loses information on
21
+ * rehydration. We keep a denormalised `text` alongside it purely for
22
+ * cheap previews / titles / search — never as the source of truth.
23
+ *
24
+ * 3. Identity (`userId`) never appears as a *parameter* on read/write
25
+ * methods. A `ChatStore` is constructed already bound to one verified
26
+ * user (see `ChatStoreFactory`). This makes cross-user access
27
+ * unrepresentable at the type level — you cannot ask a store for another
28
+ * user's data because no method accepts a foreign id. That is the
29
+ * security property, encoded in the shape of the API rather than left to
30
+ * each caller's discipline.
31
+ */
32
+
33
+ /**
34
+ * A single attachment as persisted on a message part.
35
+ *
36
+ * The `url` is a *freshly signed, short-lived* URL when this object is
37
+ * handed to the client — never a permanent public link. `storagePath` is
38
+ * the durable pointer the `StorageAdapter` uses to re-sign on demand when an
39
+ * old conversation is reloaded. Only `storagePath` is guaranteed stable
40
+ * across reads; treat `url` as ephemeral.
41
+ */
42
+ interface StoredAttachment {
43
+ /** Durable, opaque pointer into the storage backend. Stable across reads. */
44
+ storagePath: string;
45
+ /** Freshly-signed, expiring URL for the client to fetch. Ephemeral. */
46
+ url: string;
47
+ /** Original filename as uploaded (for display + download). */
48
+ filename: string;
49
+ /** MIME type (e.g. `image/png`, `application/pdf`). */
50
+ mediaType: string;
51
+ /** Size in bytes. */
52
+ size: number;
53
+ }
54
+ /**
55
+ * A conversation row, as the store returns it. Summary-level — does not
56
+ * include messages. Use `listConversations` for the sidebar/history list and
57
+ * `getConversation` + `listMessages` to open one.
58
+ */
59
+ interface StoredConversation {
60
+ id: string;
61
+ /**
62
+ * Human-readable title. Defaults to "New Chat" until the first user
63
+ * message lands, at which point the router auto-titles from it. Consumers
64
+ * can override via `renameConversation`.
65
+ */
66
+ title: string;
67
+ /** Free-form metadata bag the host app may stamp (never read by the core). */
68
+ metadata: Record<string, unknown> | null;
69
+ createdAt: Date;
70
+ updatedAt: Date;
71
+ /**
72
+ * Number of messages in the conversation. Populated by `listConversations`
73
+ * for the history list; may be omitted (`undefined`) by single-row reads
74
+ * where the count isn't needed.
75
+ */
76
+ messageCount?: number;
77
+ }
78
+ /**
79
+ * A message row, as the store returns it.
80
+ *
81
+ * `parts` is the canonical AI SDK representation and the source of truth for
82
+ * rendering. `text` is a denormalised convenience for previews/search and
83
+ * may be empty for messages whose content is entirely non-text (e.g. a
84
+ * tool-only assistant turn).
85
+ */
86
+ interface StoredMessage {
87
+ id: string;
88
+ role: 'user' | 'assistant' | 'system';
89
+ /** Canonical AI SDK parts. Source of truth for rendering + model replay. */
90
+ parts: UIMessage['parts'];
91
+ /** Denormalised plain text for previews/titles/search. Not authoritative. */
92
+ text: string;
93
+ /** Which model produced this message, when known (assistant turns). */
94
+ model?: string;
95
+ createdAt: Date;
96
+ }
97
+ /**
98
+ * Pagination request for message history. The store returns the most-recent
99
+ * `limit` messages so a freshly-opened conversation shows the latest turns;
100
+ * `before` lets the client page backwards into older history.
101
+ */
102
+ interface ListMessagesOptions {
103
+ /** Max messages to return. The store clamps this to a sane ceiling. */
104
+ limit?: number;
105
+ /**
106
+ * Return only messages created strictly before this instant. Used for
107
+ * "load older messages" infinite-scroll. Omit for the most-recent page.
108
+ */
109
+ before?: Date;
110
+ }
111
+ /**
112
+ * What `saveTurn` persists at the end of a streamed response: the final,
113
+ * complete set of UI messages for the turn (user message + assistant
114
+ * message, including any tool/reasoning/source parts the SDK emitted).
115
+ *
116
+ * The store is responsible for idempotency — re-saving messages whose ids
117
+ * already exist must be a no-op, never a duplicate. (Replays, retries, and
118
+ * the AI SDK's own resumability all deliver already-seen ids.)
119
+ */
120
+ interface SaveTurnInput {
121
+ conversationId: string;
122
+ messages: UIMessage[];
123
+ /** Model that produced the assistant message(s) in this turn. */
124
+ model?: string;
125
+ }
126
+
127
+ /**
128
+ * ChatStore — the persistence contract for chat conversations and messages.
129
+ *
130
+ * This is one of the two pluggable backends of the widget (the other is
131
+ * `StorageAdapter` for attachments). The package ships a Drizzle/Postgres
132
+ * implementation as the default; a hosted backend or a BYO store (Prisma,
133
+ * raw SQL, DynamoDB, a test double) is simply another implementation of this
134
+ * same interface.
135
+ *
136
+ * ──────────────────────────────────────────────────────────────────────────
137
+ * The security model is in the shape of this API, not in its callers.
138
+ * ──────────────────────────────────────────────────────────────────────────
139
+ *
140
+ * A `ChatStore` is *bound to one verified user* at construction time (see
141
+ * `ChatStoreFactory`). None of its methods accept a `userId`. This is
142
+ * deliberate and it is the core defence against the IDOR class of bug:
143
+ *
144
+ * - You cannot ask the store for "conversation X belonging to user Y",
145
+ * because there is no parameter through which a foreign `userId` could
146
+ * enter. The only user the store will ever read or write is the one it
147
+ * was constructed with.
148
+ *
149
+ * - Every method is therefore *implicitly scoped*. `listConversations()`
150
+ * returns only the bound user's conversations. `getConversation(id)`
151
+ * returns `null` — not someone else's row — when `id` exists but belongs
152
+ * to a different user. `saveTurn(...)` refuses (throws
153
+ * `ConversationOwnershipError`) if `conversationId` exists under another
154
+ * user.
155
+ *
156
+ * The route layer's job shrinks to: authenticate the request, derive the
157
+ * real `userId` from the *server* session, construct a store bound to it,
158
+ * and call methods. There is no per-route ownership check to forget, because
159
+ * the store cannot be made to cross users.
160
+ *
161
+ * Implementations MUST uphold the contract documented on each method. The
162
+ * Drizzle default does; if you write your own, these invariants are the
163
+ * security boundary — treat them as load-bearing, not advisory.
164
+ */
165
+
166
+ /**
167
+ * Thrown by mutating methods when the target conversation exists but is owned
168
+ * by a different user than the one this store is bound to. Callers should map
169
+ * this to an HTTP 403. (Read methods don't throw — they return `null`/`[]` —
170
+ * so that probing for existence can't distinguish "not found" from
171
+ * "forbidden", which would itself leak information.)
172
+ */
173
+ declare class ConversationOwnershipError extends Error {
174
+ readonly conversationId: string;
175
+ constructor(conversationId: string);
176
+ }
177
+ interface ChatStore {
178
+ /**
179
+ * The user this store instance is bound to. Read-only; set at construction.
180
+ * Exposed so the router can stamp it onto storage paths, logs, etc. — never
181
+ * as something a caller can change.
182
+ */
183
+ readonly userId: string;
184
+ /**
185
+ * List the bound user's conversations, most-recently-updated first.
186
+ * Returns `messageCount` on each row for the history list. Returns `[]`
187
+ * (never throws) when the user has none.
188
+ */
189
+ listConversations(): Promise<StoredConversation[]>;
190
+ /**
191
+ * Fetch a single conversation by id, scoped to the bound user.
192
+ *
193
+ * Returns `null` when the conversation does not exist OR exists but belongs
194
+ * to another user — the two cases are intentionally indistinguishable to
195
+ * the caller (and thus to an attacker). Never returns another user's row.
196
+ */
197
+ getConversation(id: string): Promise<StoredConversation | null>;
198
+ /**
199
+ * Ensure a conversation row exists for `id`, owned by the bound user.
200
+ *
201
+ * - If no row exists for `id`: creates it, owned by the bound user, and
202
+ * returns it.
203
+ * - If a row exists and is owned by the bound user: returns it unchanged
204
+ * (idempotent — safe to call at the top of every request).
205
+ * - If a row exists but is owned by a *different* user: throws
206
+ * `ConversationOwnershipError` and writes nothing.
207
+ *
208
+ * This is the single chokepoint that makes "write into someone else's
209
+ * conversation" impossible: the router calls it before persisting any
210
+ * message, so a forged conversation id is rejected before any data lands.
211
+ */
212
+ ensureConversation(id: string, init?: {
213
+ title?: string;
214
+ }): Promise<StoredConversation>;
215
+ /**
216
+ * Rename a conversation owned by the bound user. No-op (does not throw) if
217
+ * the conversation doesn't exist or isn't owned by the user — renaming is
218
+ * not security-sensitive and silent failure is friendlier here.
219
+ */
220
+ renameConversation(id: string, title: string): Promise<void>;
221
+ /**
222
+ * Delete a conversation (and cascade its messages + attachment rows) owned
223
+ * by the bound user. No-op if it doesn't exist or isn't owned by the user.
224
+ * Returns `true` if a row was actually deleted, `false` otherwise — lets
225
+ * the route return 404 vs 200 honestly without a separate existence check.
226
+ *
227
+ * Note: this deletes message *rows*. Purging the underlying attachment
228
+ * blobs from storage is the router's job (it has the `StorageAdapter`),
229
+ * driven off the attachments this method returns having referenced.
230
+ */
231
+ deleteConversation(id: string): Promise<boolean>;
232
+ /**
233
+ * Load messages for a conversation, scoped to the bound user, newest-first
234
+ * internally but returned in chronological order (oldest → newest) ready to
235
+ * render. Returns `[]` if the conversation doesn't exist or isn't owned by
236
+ * the user — same non-distinguishing contract as `getConversation`.
237
+ *
238
+ * Honours `ListMessagesOptions` for pagination. Implementations MUST clamp
239
+ * `limit` to a ceiling (default ceiling: 100) so a hostile client can't
240
+ * request an unbounded page.
241
+ */
242
+ listMessages(conversationId: string, opts?: ListMessagesOptions): Promise<StoredMessage[]>;
243
+ /**
244
+ * Persist the final messages of a completed turn.
245
+ *
246
+ * Contract:
247
+ * - MUST verify the conversation is owned by the bound user first; throws
248
+ * `ConversationOwnershipError` otherwise (defence in depth — the router
249
+ * already called `ensureConversation`, but `saveTurn` must not trust
250
+ * that).
251
+ * - MUST be idempotent on message id: a message whose id already exists is
252
+ * skipped, not duplicated. (The AI SDK delivers stable ids; replays and
253
+ * retries re-deliver them.)
254
+ * - MUST persist each message's full `parts` array as the source of truth,
255
+ * plus a denormalised text projection for previews.
256
+ * - MUST bump the conversation's `updatedAt`.
257
+ *
258
+ * Errors other than ownership (e.g. a transient DB failure) propagate so
259
+ * the router can log them loudly — a silently-dropped assistant turn is
260
+ * exactly the bug we're trying to design out.
261
+ */
262
+ saveTurn(input: SaveTurnInput): Promise<void>;
263
+ }
264
+ /**
265
+ * Constructs a `ChatStore` bound to a specific, already-verified user.
266
+ *
267
+ * The router calls this *after* it has authenticated the request and derived
268
+ * `userId` from the server session — never from anything client-supplied.
269
+ * Passing a client-controlled value here would reintroduce the very IDOR the
270
+ * bound-store design exists to prevent, so implementations should treat
271
+ * `userId` as a trusted server secret, not as request input.
272
+ *
273
+ * Construction is intended to be cheap (the underlying DB pool/connection is
274
+ * shared across instances) so a fresh store per request is the norm.
275
+ */
276
+ type ChatStoreFactory = (userId: string) => ChatStore;
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 };