@mordn/chat-widget 0.7.0 → 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.
package/README.md CHANGED
@@ -1,20 +1,40 @@
1
1
  # @mordn/chat-widget
2
2
 
3
- A customizable AI chat widget for React/Next.js applications with built-in conversation persistence.
3
+ A customizable, **secure-by-default** AI chat widget for React/Next.js apps,
4
+ with conversation persistence and attachments handled for you.
5
+
6
+ The widget owns the hard, dangerous-to-get-wrong backend plumbing — conversation
7
+ ownership, idempotent persistence, history, private attachments, streaming —
8
+ behind one mounted handler. You supply the three things that are genuinely
9
+ yours: **who the user is** (auth), **which model**, and **which tools**.
10
+
11
+ > ## ⚠️ Security: you establish identity on the server
12
+ >
13
+ > The widget sends an `X-User-Id` header, but **it is not an authentication
14
+ > boundary** — the browser controls it. You must implement `getChatUserId(req)`
15
+ > to return the user id from your **verified server session** (Clerk, NextAuth,
16
+ > Supabase Auth, …). The scaffold's stub **throws until you do this**, so a
17
+ > fresh install is never silently insecure.
18
+ >
19
+ > Trusting a client-supplied id is the IDOR bug that lets one user read another
20
+ > user's chats. The package is designed so this is *unrepresentable* once you
21
+ > wire up `getChatUserId`. **Read [SECURITY.md](./SECURITY.md).**
4
22
 
5
23
  ## Quick Start
6
24
 
7
25
  ```bash
8
- # 1. Install the package
26
+ # 1. Install
9
27
  npm install @mordn/chat-widget drizzle-kit
10
28
 
11
29
  # 2. Run the setup wizard
12
30
  npx @mordn/chat-widget
13
31
  ```
14
32
 
15
- The setup wizard creates all required files:
16
- - API routes (`/api/chat/...`)
17
- - `drizzle.config.ts`
33
+ The wizard creates exactly four files:
34
+
35
+ - `app/api/chat/[[...chat]]/route.ts` — one catch-all that mounts the whole backend
36
+ - `lib/chat-auth.ts` — the `getChatUserId` stub **you implement** (the security boundary)
37
+ - `drizzle.config.ts` — points at the package's chat schema
18
38
  - `.env.example`
19
39
 
20
40
  ## Requirements
@@ -23,41 +43,56 @@ The setup wizard creates all required files:
23
43
  - React 18+
24
44
  - PostgreSQL database (Supabase recommended)
25
45
  - Tailwind CSS v4
46
+ - `ai` v5 or v6 (peer dependency)
26
47
 
27
48
  ## Setup
28
49
 
29
50
  ### 1. Environment Variables
30
51
 
31
- Copy `.env.example` to `.env.local` and fill in your credentials:
52
+ Copy `.env.example` to `.env.local` and fill in your credentials (see the file
53
+ for the full list — `DATABASE_URL`, and the Supabase keys if you keep uploads).
32
54
 
33
- ```env
34
- # Database (Required)
35
- DATABASE_URL="postgresql://postgres.xxx:[PASSWORD]@aws-0-region.pooler.supabase.com:6543/postgres"
55
+ ### 2. Implement the auth boundary
36
56
 
37
- # AI Provider (Required)
38
- AI_GATEWAY_API_KEY="your-ai-gateway-key"
39
- ```
57
+ Open `lib/chat-auth.ts` and replace the throwing stub with your real session
58
+ lookup:
40
59
 
41
- ### 2. Database Setup
60
+ ```ts
61
+ // Clerk example
62
+ import { auth } from '@clerk/nextjs/server';
63
+
64
+ export async function getChatUserId() {
65
+ const { userId } = await auth(); // from the verified session — never a header
66
+ return userId;
67
+ }
68
+ ```
42
69
 
43
- Push the schema to your database:
70
+ ### 3. Database Setup
44
71
 
45
72
  ```bash
46
- npx drizzle-kit push
73
+ npx drizzle-kit push # creates chat_conversations + chat_messages
47
74
  ```
48
75
 
49
- ### 3. Configure Your AI Model
76
+ ### 4. Configure your model and tools
50
77
 
51
- Open `app/api/chat/route.ts` and update the config:
78
+ Everything is configured in the single `route.ts` the wizard created — model,
79
+ system prompt, store, storage, and tools:
52
80
 
53
- ```typescript
54
- const DEVELOPER_CONFIG = {
55
- model: 'openai/gpt-4o', // Your AI model
56
- systemPrompt: 'You are a helpful assistant',
57
- temperature: 0.7,
58
- };
81
+ ```ts
82
+ export const { GET, POST, DELETE } = createChatHandler({
83
+ getUserId: getChatUserId,
84
+ model: anthropic('claude-sonnet-4-5'),
85
+ store: createDrizzleChatStore(), // or bring your own ChatStore
86
+ storage: createSupabaseStorage(), // or bring your own StorageAdapter
87
+ // buildTools: async (ctx) => ({ tools: { /* ... */ }, cleanup: async () => {} }),
88
+ });
59
89
  ```
60
90
 
91
+ **Bring your own database / storage:** pass a custom `store` / `storage` that
92
+ implement the `ChatStore` / `StorageAdapter` interfaces from
93
+ `@mordn/chat-widget/server`. The hosted defaults and your own implementations
94
+ are interchangeable — same handler, same security.
95
+
61
96
  ### 4. Add the Widget
62
97
 
63
98
  ```tsx
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 };