@mordn/chat-widget 0.7.1 → 0.8.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.
- package/README.md +137 -452
- package/SECURITY.md +101 -0
- package/dist/chat-store-DERCPwhl.d.mts +278 -0
- package/dist/chat-store-DERCPwhl.d.ts +278 -0
- package/dist/cli/init.js +111 -346
- package/dist/server/drizzle/index.d.mts +340 -0
- package/dist/server/drizzle/index.d.ts +340 -0
- package/dist/server/drizzle/index.js +238 -0
- package/dist/server/drizzle/index.js.map +1 -0
- package/dist/server/drizzle/index.mjs +207 -0
- package/dist/server/drizzle/index.mjs.map +1 -0
- package/dist/server/index.d.mts +217 -0
- package/dist/server/index.d.ts +217 -0
- package/dist/server/index.js +370 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/index.mjs +349 -0
- package/dist/server/index.mjs.map +1 -0
- package/dist/server/supabase/index.d.mts +50 -0
- package/dist/server/supabase/index.d.ts +50 -0
- package/dist/server/supabase/index.js +111 -0
- package/dist/server/supabase/index.js.map +1 -0
- package/dist/server/supabase/index.mjs +86 -0
- package/dist/server/supabase/index.mjs.map +1 -0
- package/dist/storage-adapter-DD8uqiAP.d.mts +126 -0
- package/dist/storage-adapter-DD8uqiAP.d.ts +126 -0
- package/dist/styles.css +1 -1
- package/package.json +20 -4
package/SECURITY.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Security model
|
|
2
|
+
|
|
3
|
+
This document explains how `@mordn/chat-widget` keeps one user's
|
|
4
|
+
conversations and attachments private from another's, and the one thing **you**
|
|
5
|
+
must do to uphold it.
|
|
6
|
+
|
|
7
|
+
## TL;DR
|
|
8
|
+
|
|
9
|
+
- **You establish identity on the server.** You implement `getChatUserId(req)`
|
|
10
|
+
to return the user id from your *verified* server session.
|
|
11
|
+
- **The widget's `X-User-Id` header is not an auth boundary.** The client sends
|
|
12
|
+
it; the server ignores it for authorization. Never trust it.
|
|
13
|
+
- **The package enforces the rest.** Conversation ownership, per-user data
|
|
14
|
+
scoping, private attachments, and signed URLs are handled inside the package
|
|
15
|
+
and are not your responsibility to wire up correctly.
|
|
16
|
+
|
|
17
|
+
## The threat we designed against: IDOR
|
|
18
|
+
|
|
19
|
+
IDOR — *Insecure Direct Object Reference* — is when a server uses a
|
|
20
|
+
client-supplied identifier to fetch data **without checking the requester owns
|
|
21
|
+
it**. For a chat product the identifier is the user id (and the conversation
|
|
22
|
+
id). If a route does this:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// ❌ NEVER do this
|
|
26
|
+
const userId = req.headers.get('X-User-Id'); // browser controls this
|
|
27
|
+
return getConversations(userId); // returns anyone's chats
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
…then any user can read or write any other user's conversations by changing a
|
|
31
|
+
header. This is the single most important class of bug for a multi-user chat
|
|
32
|
+
app, and it is easy to introduce by accident.
|
|
33
|
+
|
|
34
|
+
## How the package prevents it
|
|
35
|
+
|
|
36
|
+
### 1. Identity comes from your server session — `getChatUserId`
|
|
37
|
+
|
|
38
|
+
`createChatHandler` calls **your** `getChatUserId(request)` and uses whatever it
|
|
39
|
+
returns as the only identity for the request. Implement it against your auth
|
|
40
|
+
system's *server-verified* session:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// ✅ Clerk
|
|
44
|
+
import { auth } from '@clerk/nextjs/server';
|
|
45
|
+
export async function getChatUserId() {
|
|
46
|
+
const { userId } = await auth();
|
|
47
|
+
return userId; // from a verified session cookie/JWT
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The `request` is passed in so you can read **verified** cookies — not so you can
|
|
52
|
+
read a client-asserted id. Returning a value derived from `req.headers`,
|
|
53
|
+
`req.url` query params, or the JSON body re-introduces the IDOR. The scaffolded
|
|
54
|
+
stub **throws until you implement it**, so a fresh install is never silently
|
|
55
|
+
insecure.
|
|
56
|
+
|
|
57
|
+
### 2. The data layer is bound to one user — IDOR is unrepresentable
|
|
58
|
+
|
|
59
|
+
Internally, the store and storage adapters are **constructed bound to the
|
|
60
|
+
verified `userId`**. None of their methods accept a user id. There is no
|
|
61
|
+
parameter through which a foreign id could enter, so "fetch user B's data while
|
|
62
|
+
acting as user A" cannot be expressed in the code at all — it's a type-level
|
|
63
|
+
guarantee, not a convention you have to remember.
|
|
64
|
+
|
|
65
|
+
- `getConversation(id)` returns `null` when the conversation exists but belongs
|
|
66
|
+
to another user — indistinguishable from "not found", so existence can't be
|
|
67
|
+
probed.
|
|
68
|
+
- Creating/writing a conversation that belongs to another user is rejected
|
|
69
|
+
(HTTP 403) before anything is persisted.
|
|
70
|
+
- Listing and message reads are implicitly scoped to the bound user.
|
|
71
|
+
|
|
72
|
+
### 3. Attachments are private by default
|
|
73
|
+
|
|
74
|
+
The default storage adapter:
|
|
75
|
+
|
|
76
|
+
- writes to a **private** bucket (never a public URL),
|
|
77
|
+
- returns **short-lived signed URLs**, re-signed on demand when old
|
|
78
|
+
conversations are reloaded,
|
|
79
|
+
- stores files under **user-namespaced, unguessable paths** derived from the
|
|
80
|
+
bound user id, and refuses to sign or delete anything outside that namespace.
|
|
81
|
+
|
|
82
|
+
The bucket you create **must be private**. The adapter never relies on public
|
|
83
|
+
read; if you make the bucket public you reopen a hole the package otherwise
|
|
84
|
+
closes.
|
|
85
|
+
|
|
86
|
+
## Your responsibilities checklist
|
|
87
|
+
|
|
88
|
+
- [ ] Implement `getChatUserId` to return the id from your **server** session.
|
|
89
|
+
- [ ] Never read identity from `X-User-Id`, query params, or the request body.
|
|
90
|
+
- [ ] Create the attachments bucket as **private** (if you keep uploads).
|
|
91
|
+
- [ ] Keep `SUPABASE_SERVICE_ROLE_KEY` server-side only (never `NEXT_PUBLIC_`).
|
|
92
|
+
|
|
93
|
+
If you bring your own `ChatStore` or `StorageAdapter`, uphold the invariants
|
|
94
|
+
documented on those interfaces — they are the security boundary for the custom
|
|
95
|
+
path.
|
|
96
|
+
|
|
97
|
+
## Reporting a vulnerability
|
|
98
|
+
|
|
99
|
+
Please open a private report at
|
|
100
|
+
<https://github.com/arnavv-guptaa/chat-widget/security/advisories> rather than a
|
|
101
|
+
public issue.
|
|
@@ -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 };
|
|
@@ -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 };
|