@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 CHANGED
@@ -1,64 +1,106 @@
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
21
41
 
22
- - Next.js 14+ (App Router)
23
- - React 18+
24
- - PostgreSQL database (Supabase recommended)
25
- - Tailwind CSS v4
42
+ Peer dependencies (you provide these in your app):
43
+
44
+ - **Next.js** 14, 15, or 16 (App Router)
45
+ - **React** 18 or 19
46
+ - **`ai`** v5 or v6 (Vercel AI SDK)
47
+ - **`drizzle-orm`** ^0.44 and **`postgres`** ^3.4 — only if you use the default
48
+ Drizzle store (skip if you bring your own `ChatStore`)
49
+ - A **PostgreSQL** database (Supabase recommended) — for the default store
50
+ - An AI provider package for your model, e.g. **`@ai-sdk/anthropic`**
51
+
52
+ Styling ships pre-scoped in `@mordn/chat-widget/styles.css` — you do **not**
53
+ need Tailwind in your app to use the widget.
26
54
 
27
55
  ## Setup
28
56
 
29
57
  ### 1. Environment Variables
30
58
 
31
- Copy `.env.example` to `.env.local` and fill in your credentials:
59
+ Copy `.env.example` to `.env.local` and fill in your credentials (see the file
60
+ for the full list — `DATABASE_URL`, and the Supabase keys if you keep uploads).
32
61
 
33
- ```env
34
- # Database (Required)
35
- DATABASE_URL="postgresql://postgres.xxx:[PASSWORD]@aws-0-region.pooler.supabase.com:6543/postgres"
62
+ ### 2. Implement the auth boundary
36
63
 
37
- # AI Provider (Required)
38
- AI_GATEWAY_API_KEY="your-ai-gateway-key"
39
- ```
64
+ Open `lib/chat-auth.ts` and replace the throwing stub with your real session
65
+ lookup:
66
+
67
+ ```ts
68
+ // Clerk example
69
+ import { auth } from '@clerk/nextjs/server';
40
70
 
41
- ### 2. Database Setup
71
+ export async function getChatUserId() {
72
+ const { userId } = await auth(); // from the verified session — never a header
73
+ return userId;
74
+ }
75
+ ```
42
76
 
43
- Push the schema to your database:
77
+ ### 3. Database Setup
44
78
 
45
79
  ```bash
46
- npx drizzle-kit push
80
+ npx drizzle-kit push # creates chat_conversations + chat_messages
47
81
  ```
48
82
 
49
- ### 3. Configure Your AI Model
83
+ ### 4. Configure your model and tools
50
84
 
51
- Open `app/api/chat/route.ts` and update the config:
85
+ Everything is configured in the single `route.ts` the wizard created — model,
86
+ system prompt, store, storage, and tools:
52
87
 
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
- };
88
+ ```ts
89
+ export const { GET, POST, DELETE } = createChatHandler({
90
+ getUserId: getChatUserId,
91
+ model: anthropic('claude-sonnet-4-5'),
92
+ store: createDrizzleChatStore(), // or bring your own ChatStore
93
+ storage: createSupabaseStorage(), // or bring your own StorageAdapter
94
+ // buildTools: async (ctx) => ({ tools: { /* ... */ }, cleanup: async () => {} }),
95
+ });
59
96
  ```
60
97
 
61
- ### 4. Add the Widget
98
+ **Bring your own database / storage:** pass a custom `store` / `storage` that
99
+ implement the `ChatStore` / `StorageAdapter` interfaces from
100
+ `@mordn/chat-widget/server`. The hosted defaults and your own implementations
101
+ are interchangeable — same handler, same security.
102
+
103
+ ## Mount the widget (client)
62
104
 
63
105
  ```tsx
64
106
  'use client';
@@ -66,470 +108,113 @@ const DEVELOPER_CONFIG = {
66
108
  import { ChatWidget } from '@mordn/chat-widget';
67
109
  import '@mordn/chat-widget/styles.css';
68
110
 
69
- export default function Page() {
111
+ export default function Assistant({ userId }: { userId: string }) {
70
112
  return (
71
113
  <ChatWidget
72
- // Required
73
- userId="user-123"
74
-
75
- // Theme: 'light' | 'dark'
76
- theme={{ mode: 'light' }}
77
-
78
- // Feature toggles
79
- features={{
80
- fileUpload: false, // Requires Supabase Storage setup
81
- }}
82
-
83
- // Display options
84
- display={{
85
- defaultOpen: false, // Start with chat open
86
- size: 'default', // 'compact' | 'default' | 'large' | 'full'
87
- resizable: true, // Allow resizing
88
- showToggleButton: true, // Show FAB toggle button
89
- }}
90
-
91
- // Starter prompts shown on empty chat
114
+ userId={userId} // your app's user id (for the client)
115
+ theme={{ mode: 'light' }} // 'light' | 'dark'
116
+ features={{ fileUpload: true }} // needs `storage` configured on the handler
117
+ display={{ layout: 'popup', size: 'default', resizable: true }}
92
118
  starterPrompts={[
93
- { title: "What can you help me with?" },
94
- { title: "How do I get started?" },
119
+ { title: 'What can you help me with?' },
120
+ { title: 'How do I get started?' },
95
121
  ]}
96
122
  />
97
123
  );
98
124
  }
99
125
  ```
100
126
 
127
+ > The widget sends `userId` as an `X-User-Id` header for convenience, but the
128
+ > **server ignores it for authorization** — your `getChatUserId` is the only
129
+ > source of identity. See the security note above.
130
+
101
131
  ---
102
132
 
103
- ## File Uploads (Optional)
133
+ ## Bring your own database / storage
104
134
 
105
- To enable image attachments, you need Supabase Storage and to enable the feature:
135
+ The default `createDrizzleChatStore()` and `createSupabaseStorage()` are just
136
+ implementations of two interfaces. To use your own database, ORM, or object
137
+ store, implement the interface and pass it instead — same handler, same
138
+ security guarantees:
106
139
 
107
- ```tsx
108
- <ChatWidget
109
- userId="user-123"
110
- features={{ fileUpload: true }}
111
- />
140
+ ```ts
141
+ import type { ChatStore, StorageAdapter } from '@mordn/chat-widget/server';
142
+
143
+ const myStore = (userId: string): ChatStore => ({ /* ... */ });
144
+ const myStorage = (userId: string): StorageAdapter => ({ /* ... */ });
145
+
146
+ createChatHandler({ getUserId, model, store: myStore, storage: myStorage });
112
147
  ```
113
148
 
114
- ### 1. Create Storage Bucket
149
+ Both factories are constructed per request with the **server-verified** user id,
150
+ so a store/adapter instance can only ever touch that user's data — cross-user
151
+ access (IDOR) is unrepresentable. See `SECURITY.md` for the full model.
115
152
 
116
- 1. Go to your [Supabase Dashboard](https://supabase.com/dashboard)
117
- 2. Navigate to **Storage** → **New Bucket**
118
- 3. Create a bucket named `chat-attachments`
119
- 4. Set it to **Public** (or configure RLS policies for private access)
153
+ ### File uploads & the storage bucket
120
154
 
121
- ### 2. Add Environment Variables
155
+ `createSupabaseStorage()` expects a **private** `chat-attachments` bucket and
156
+ the service-role key:
122
157
 
123
158
  ```env
124
159
  NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
125
- SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
160
+ SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" # server-only, never NEXT_PUBLIC
126
161
  ```
127
162
 
128
- You can find these in Supabase Dashboard **Settings** **API**
129
-
130
- ### 3. Create Upload Route
131
-
132
- When running `npx @mordn/chat-widget`, select **Yes** when asked about the upload route.
163
+ Create the bucket as **Private** the adapter never relies on public read; it
164
+ mints short-lived signed URLs and re-signs them on history load. A public
165
+ bucket would defeat the security model. Omit the `storage` option entirely to
166
+ disable uploads.
133
167
 
134
168
  ---
135
169
 
136
- ## Props
137
-
138
- | Prop | Type | Default | Description |
139
- |------|------|---------|-------------|
140
- | `userId` | `string` | **required** | User identifier for storing conversations |
141
- | `conversationId` | `string` | - | Load a specific conversation |
142
- | `initialMessages` | `array` | - | Pre-fill the chat with messages |
143
- | `className` | `string` | - | Additional CSS classes |
144
- | `theme` | `ThemeConfig` | - | Theme configuration |
145
- | `features` | `FeatureConfig` | - | Feature toggles |
146
- | `display` | `DisplayConfig` | - | Display options |
147
-
148
- ### ThemeConfig
149
-
150
- ```typescript
151
- {
152
- mode?: 'light' | 'dark';
153
- }
154
- ```
155
-
156
- ### FeatureConfig
157
-
158
- ```typescript
159
- {
160
- fileUpload?: boolean; // Enable file attachments (default: false)
161
- webSearch?: boolean; // Enable web search toggle
162
- }
163
- ```
164
-
165
- ### DisplayConfig
166
-
167
- ```typescript
168
- {
169
- width?: string; // e.g., '400px' or '30vw'
170
- defaultOpen?: boolean; // Start with chat open (default: false)
171
- showToggleButton?: boolean; // Show FAB toggle button (default: true)
172
- toggleButtonPosition?: {
173
- bottom?: string;
174
- right?: string;
175
- };
176
- }
177
- ```
170
+ ## Handler options (`createChatHandler`)
171
+
172
+ | Option | Required | Description |
173
+ |--------|----------|-------------|
174
+ | `getUserId(req)` | **yes** | Return the user id from your verified server session, or `null` (→ 401). The security boundary. |
175
+ | `model` | yes | A `LanguageModel`, or `(ctx) => LanguageModel` for per-user selection. |
176
+ | `store` | no* | A `ChatStoreFactory`. Use `createDrizzleChatStore()` or your own. *Required until a hosted default ships.* |
177
+ | `storage` | no | A `StorageAdapterFactory` (e.g. `createSupabaseStorage()`). Omit to disable uploads. |
178
+ | `buildTools(ctx)` | no | `async (ctx) => ({ tools, cleanup? })`. `cleanup` is called exactly once per turn (finish/error/abort) — use it to close per-request resources like an MCP client. |
179
+ | `buildSystemPrompt(ctx)` | no | Returns the system prompt; receives the request context to personalise. |
180
+ | `transformMessages(msgs, ctx)` | no | Last-chance rewrite of model messages (e.g. image part handling). |
181
+ | `onChatFinish(info)` | no | Post-persist hook for telemetry/usage. |
182
+ | `onError(err)` | no | Map a stream error to the user-facing string. |
183
+ | `stopWhen` | no | AI SDK stop condition for tool-call loops (default: bounded step count). |
184
+ | `upload` | no | `{ allowedMediaTypes?, maxBytes? }` — server-side upload policy. |
185
+ | `maxHistoryMessages` | no | Sliding-window size sent to the model (default 30). |
186
+
187
+ The widget exposes only these seams. Ownership checks, idempotent persistence,
188
+ history pagination, attachment re-signing, and socket teardown are owned by the
189
+ handler and are not configurable — getting them wrong is a bug, not a setting.
178
190
 
179
191
  ---
180
192
 
181
193
  ## Exports
182
194
 
183
- ```typescript
184
- // Main component
195
+ ```ts
196
+ // Client component + styles
185
197
  import { ChatWidget } from '@mordn/chat-widget';
186
198
  import '@mordn/chat-widget/styles.css';
187
199
 
188
- // Database utilities (server-side only)
200
+ // Server handler + the pluggable contracts (server-only)
189
201
  import {
190
- db,
191
- conversations,
192
- messages,
193
- createChat,
194
- loadChat,
195
- saveChat,
196
- getConversations,
197
- deleteConversation,
198
- updateConversationTitle,
199
- eq, and, or, desc, asc, sql
200
- } from '@mordn/chat-widget/api';
201
- ```
202
-
203
- ---
204
-
205
- ## Generated Files Reference
206
-
207
- <details>
208
- <summary><strong>app/api/chat/route.ts</strong> - Main Chat Endpoint</summary>
209
-
210
- ```typescript
211
- import { saveChat, updateConversationTitle, db, conversations, messages, eq } from '@mordn/chat-widget/api';
212
- import { convertToModelMessages, streamText, UIMessage } from 'ai';
213
-
214
- export const maxDuration = 30;
215
-
216
- const DEVELOPER_CONFIG = {
217
- model: 'openai/gpt-4o',
218
- systemPrompt: 'You are a helpful assistant',
219
- temperature: 0.7,
220
- };
221
-
222
- export async function POST(req: Request) {
223
- try {
224
- const body = await req.json();
225
- const userId = req.headers.get('X-User-Id');
226
-
227
- if (!userId) {
228
- return new Response('userId is required in X-User-Id header', { status: 400 });
229
- }
230
-
231
- const chatMessages: UIMessage[] = body.messages || [];
232
- const id: string = body.id || 'temp-id';
233
- const { model, systemPrompt, temperature } = DEVELOPER_CONFIG;
234
-
235
- const existingConv = await db
236
- .select({ id: conversations.id })
237
- .from(conversations)
238
- .where(eq(conversations.id, id))
239
- .limit(1);
240
-
241
- if (!existingConv.length) {
242
- await db.insert(conversations).values({
243
- id,
244
- userId,
245
- title: 'New Chat',
246
- metadata: {},
247
- });
248
- }
249
-
250
- const userMessages = chatMessages.filter(msg => msg.role === 'user');
251
- if (userMessages.length > 0) {
252
- const newUserMessage = userMessages[userMessages.length - 1];
253
- const textPart = newUserMessage.parts?.find(p => p.type === 'text') as { text: string } | undefined;
254
- const fileParts = newUserMessage.parts?.filter(p => p.type === 'file') || [];
255
-
256
- const existingMsg = await db
257
- .select({ id: messages.id })
258
- .from(messages)
259
- .where(eq(messages.id, newUserMessage.id))
260
- .limit(1);
261
-
262
- if (!existingMsg.length) {
263
- await db.insert(messages).values({
264
- id: newUserMessage.id,
265
- conversationId: id,
266
- role: newUserMessage.role,
267
- content: textPart?.text || '',
268
- files: fileParts,
269
- model: model,
270
- metadata: { parts: newUserMessage.parts || [] },
271
- });
272
- }
273
-
274
- if (textPart?.text) {
275
- const conv = await db
276
- .select({ title: conversations.title })
277
- .from(conversations)
278
- .where(eq(conversations.id, id))
279
- .limit(1);
280
-
281
- if (conv[0]?.title === 'New Chat') {
282
- await updateConversationTitle(id, textPart.text.slice(0, 100));
283
- }
284
- }
285
- }
286
-
287
- const transformedMessages = chatMessages.map(msg => {
288
- if (msg.role === 'user' && msg.parts) {
289
- const textPart = msg.parts.find(p => p.type === 'text');
290
- const fileParts = msg.parts.filter(p => p.type === 'file');
291
-
292
- if (fileParts.length > 0) {
293
- const content: any[] = [];
294
- if (textPart && 'text' in textPart) {
295
- content.push({ type: 'text', text: textPart.text });
296
- }
297
- for (const file of fileParts) {
298
- if ('mediaType' in file && (file as any).mediaType?.startsWith('image/')) {
299
- content.push({ type: 'image', image: (file as any).url });
300
- }
301
- }
302
- return { ...msg, content };
303
- }
304
- }
305
- return msg;
306
- });
307
-
308
- const result = streamText({
309
- model: model,
310
- messages: convertToModelMessages(transformedMessages),
311
- system: systemPrompt,
312
- temperature: temperature,
313
- });
314
-
315
- return result.toUIMessageStreamResponse({
316
- sendSources: true,
317
- sendReasoning: true,
318
- onFinish: ({ messages: finalMessages }) => {
319
- if (finalMessages.length > 0) {
320
- saveChat({ chatId: id, messages: finalMessages, model, userId });
321
- }
322
- },
323
- });
324
- } catch (error) {
325
- console.error('Chat API error:', error);
326
- return new Response(JSON.stringify({ error: 'Internal server error' }), {
327
- status: 500,
328
- headers: { 'Content-Type': 'application/json' },
329
- });
330
- }
331
- }
332
- ```
333
-
334
- </details>
335
-
336
- <details>
337
- <summary><strong>app/api/chat/history/route.ts</strong> - List Conversations</summary>
338
-
339
- ```typescript
340
- import { NextResponse } from 'next/server';
341
- import { getConversations } from '@mordn/chat-widget/api';
202
+ createChatHandler,
203
+ type ChatStore, type ChatStoreFactory,
204
+ type StorageAdapter, type StorageAdapterFactory,
205
+ ConversationOwnershipError,
206
+ } from '@mordn/chat-widget/server';
342
207
 
343
- export async function GET(request: Request) {
344
- try {
345
- const url = new URL(request.url);
346
- const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');
208
+ // Default Postgres/Drizzle store (server-only)
209
+ import { createDrizzleChatStore, schema } from '@mordn/chat-widget/server/drizzle';
347
210
 
348
- if (!userId) {
349
- return NextResponse.json({ error: 'userId is required' }, { status: 400 });
350
- }
351
-
352
- const conversationsData = await getConversations(userId);
353
-
354
- const conversations = conversationsData.map(conv => ({
355
- id: conv.id,
356
- title: conv.title,
357
- created_at: conv.createdAt,
358
- updated_at: conv.updatedAt,
359
- metadata: conv.metadata,
360
- message_count: conv.messageCount,
361
- }));
362
-
363
- return NextResponse.json({ conversations });
364
- } catch (error) {
365
- console.error('Error in chat history API:', error);
366
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
367
- }
368
- }
369
- ```
370
-
371
- </details>
372
-
373
- <details>
374
- <summary><strong>app/api/chat/history/[conversationId]/route.ts</strong> - Get Conversation</summary>
375
-
376
- ```typescript
377
- import { NextResponse } from 'next/server';
378
- import { db, conversations, messages, eq, and, asc } from '@mordn/chat-widget/api';
379
-
380
- export async function GET(
381
- request: Request,
382
- { params }: { params: Promise<{ conversationId: string }> }
383
- ) {
384
- try {
385
- const { conversationId } = await params;
386
- const url = new URL(request.url);
387
- const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');
388
-
389
- if (!userId) {
390
- return NextResponse.json({ error: 'userId is required' }, { status: 400 });
391
- }
392
-
393
- const conv = await db
394
- .select({
395
- id: conversations.id,
396
- title: conversations.title,
397
- metadata: conversations.metadata,
398
- })
399
- .from(conversations)
400
- .where(and(
401
- eq(conversations.id, conversationId),
402
- eq(conversations.userId, userId)
403
- ))
404
- .limit(1);
405
-
406
- if (!conv.length) {
407
- return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
408
- }
409
-
410
- const conversation = conv[0];
411
-
412
- const dbMessages = await db
413
- .select()
414
- .from(messages)
415
- .where(eq(messages.conversationId, conversationId))
416
- .orderBy(asc(messages.createdAt))
417
- .limit(1000);
418
-
419
- const transformedMessages = dbMessages.map(msg => {
420
- const metadata = msg.metadata as { parts?: any[] } | null;
421
-
422
- if (metadata?.parts && Array.isArray(metadata.parts)) {
423
- return {
424
- id: msg.id,
425
- role: msg.role,
426
- content: msg.content,
427
- created_at: msg.createdAt,
428
- parts: metadata.parts
429
- };
430
- }
431
-
432
- return {
433
- id: msg.id,
434
- role: msg.role,
435
- content: msg.content,
436
- created_at: msg.createdAt,
437
- parts: msg.content ? [{ type: 'text', text: msg.content }] : undefined
438
- };
439
- });
440
-
441
- return NextResponse.json({ conversation, messages: transformedMessages });
442
- } catch (error) {
443
- console.error('Error loading conversation:', error);
444
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
445
- }
446
- }
447
- ```
448
-
449
- </details>
450
-
451
- <details>
452
- <summary><strong>app/api/chat/upload/route.ts</strong> - File Upload (Optional)</summary>
453
-
454
- ```typescript
455
- import { createClient } from '@supabase/supabase-js';
456
- import { nanoid } from 'nanoid';
457
-
458
- export async function POST(req: Request) {
459
- try {
460
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
461
- const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
462
-
463
- // Check for required environment variables
464
- if (!supabaseUrl || !supabaseServiceKey) {
465
- console.error('Missing Supabase environment variables');
466
- return Response.json({
467
- error: 'File upload is not configured. Please set up Supabase Storage environment variables.'
468
- }, { status: 503 });
469
- }
470
-
471
- const formData = await req.formData();
472
- const file = formData.get('file') as File;
473
- const conversationId = formData.get('conversationId') as string;
474
- const userId = formData.get('userId') as string;
475
-
476
- if (!file) {
477
- return Response.json({ error: 'No file provided' }, { status: 400 });
478
- }
479
-
480
- if (!userId) {
481
- return Response.json({ error: 'userId is required' }, { status: 400 });
482
- }
483
-
484
- if (!file.type.startsWith('image/')) {
485
- return Response.json({ error: 'Only image files are supported' }, { status: 400 });
486
- }
487
-
488
- if (file.size > 5 * 1024 * 1024) {
489
- return Response.json({ error: 'File size exceeds 5MB limit' }, { status: 400 });
490
- }
491
-
492
- const supabase = createClient(supabaseUrl, supabaseServiceKey);
493
-
494
- const timestamp = Date.now();
495
- const randomId = nanoid(8);
496
- const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
497
- const filePath = `${userId}/${conversationId || 'default'}/${timestamp}-${randomId}-${safeFilename}`;
498
-
499
- const fileBuffer = await file.arrayBuffer();
500
-
501
- const { error: uploadError } = await supabase.storage
502
- .from('chat-attachments')
503
- .upload(filePath, fileBuffer, {
504
- contentType: file.type,
505
- upsert: false,
506
- });
507
-
508
- if (uploadError) {
509
- return Response.json({ error: 'Failed to upload file' }, { status: 500 });
510
- }
511
-
512
- const { data: urlData } = supabase.storage
513
- .from('chat-attachments')
514
- .getPublicUrl(filePath);
515
-
516
- return Response.json({
517
- url: urlData.publicUrl,
518
- filename: file.name,
519
- mediaType: file.type,
520
- size: file.size,
521
- type: 'file',
522
- });
523
- } catch (error) {
524
- return Response.json({ error: 'Internal server error' }, { status: 500 });
525
- }
526
- }
211
+ // Default Supabase storage adapter (server-only)
212
+ import { createSupabaseStorage } from '@mordn/chat-widget/server/supabase';
527
213
  ```
528
214
 
529
- </details>
530
-
531
215
  ---
532
216
 
533
217
  ## License
534
218
 
535
219
  MIT
220
+