@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.
package/dist/cli/init.js CHANGED
@@ -51,9 +51,17 @@ function detectAppDir() {
51
51
  }
52
52
  return path.join(process.cwd(), "src", "app");
53
53
  }
54
+ function detectLibDir() {
55
+ if (fs.existsSync(path.join(process.cwd(), "src"))) {
56
+ return path.join(process.cwd(), "src", "lib");
57
+ }
58
+ return path.join(process.cwd(), "lib");
59
+ }
54
60
  async function writeFileWithConfirm(filePath, content) {
55
61
  if (fs.existsSync(filePath)) {
56
- const overwrite = await confirm(`File ${path.relative(process.cwd(), filePath)} already exists. Overwrite?`);
62
+ const overwrite = await confirm(
63
+ `File ${path.relative(process.cwd(), filePath)} already exists. Overwrite?`
64
+ );
57
65
  if (!overwrite) {
58
66
  console.log(` Skipped: ${path.relative(process.cwd(), filePath)}`);
59
67
  return false;
@@ -64,320 +72,90 @@ async function writeFileWithConfirm(filePath, content) {
64
72
  console.log(` Created: ${path.relative(process.cwd(), filePath)}`);
65
73
  return true;
66
74
  }
67
- var MAIN_ROUTE = `import { saveChat, updateConversationTitle, db, conversations, messages, eq } from '@mordn/chat-widget/api';
68
- import { convertToModelMessages, streamText, UIMessage } from 'ai';
69
-
70
- export const maxDuration = 30;
71
-
72
- // ============================================
73
- // DEVELOPER CONFIG - Set these for your app
74
- // ============================================
75
- const DEVELOPER_CONFIG = {
76
- model: 'openai/gpt-4o', // Your AI model (provider/model format)
77
- systemPrompt: 'You are a helpful assistant',
78
- temperature: 0.7,
79
- };
80
-
81
- export async function POST(req: Request) {
82
- try {
83
- const body = await req.json();
84
- const userId = req.headers.get('X-User-Id');
85
-
86
- if (!userId) {
87
- return new Response('userId is required in X-User-Id header', { status: 400 });
88
- }
89
-
90
- const chatMessages: UIMessage[] = body.messages || [];
91
- const id: string = body.id || 'temp-id';
92
-
93
- const { model, systemPrompt, temperature } = DEVELOPER_CONFIG;
94
-
95
- // Check if conversation exists, create if not
96
- const existingConv = await db
97
- .select({ id: conversations.id })
98
- .from(conversations)
99
- .where(eq(conversations.id, id))
100
- .limit(1);
101
-
102
- if (!existingConv.length) {
103
- await db.insert(conversations).values({
104
- id,
105
- userId,
106
- title: 'New Chat',
107
- metadata: {},
108
- });
109
- }
110
-
111
- // Save the new user message
112
- const userMessages = chatMessages.filter(msg => msg.role === 'user');
113
- if (userMessages.length > 0) {
114
- const newUserMessage = userMessages[userMessages.length - 1];
115
- const textPart = newUserMessage.parts?.find(p => p.type === 'text') as { text: string } | undefined;
116
- const fileParts = newUserMessage.parts?.filter(p => p.type === 'file') || [];
117
-
118
- const existingMsg = await db
119
- .select({ id: messages.id })
120
- .from(messages)
121
- .where(eq(messages.id, newUserMessage.id))
122
- .limit(1);
123
-
124
- if (!existingMsg.length) {
125
- await db.insert(messages).values({
126
- id: newUserMessage.id,
127
- conversationId: id,
128
- role: newUserMessage.role,
129
- content: textPart?.text || '',
130
- files: fileParts,
131
- model: model,
132
- metadata: { parts: newUserMessage.parts || [] },
133
- });
134
- }
135
-
136
- // Update conversation title if needed
137
- if (textPart?.text) {
138
- const conv = await db
139
- .select({ title: conversations.title })
140
- .from(conversations)
141
- .where(eq(conversations.id, id))
142
- .limit(1);
143
-
144
- if (conv[0]?.title === 'New Chat') {
145
- await updateConversationTitle(id, textPart.text.slice(0, 100));
146
- }
147
- }
148
- }
149
-
150
- // Transform messages for AI (handle images)
151
- const transformedMessages = chatMessages.map(msg => {
152
- if (msg.role === 'user' && msg.parts) {
153
- const textPart = msg.parts.find(p => p.type === 'text');
154
- const fileParts = msg.parts.filter(p => p.type === 'file');
155
-
156
- if (fileParts.length > 0) {
157
- const content: any[] = [];
158
- if (textPart && 'text' in textPart) {
159
- content.push({ type: 'text', text: textPart.text });
160
- }
161
- for (const file of fileParts) {
162
- if ('mediaType' in file && (file as any).mediaType?.startsWith('image/')) {
163
- content.push({ type: 'image', image: (file as any).url });
164
- }
165
- }
166
- return { ...msg, content };
167
- }
168
- }
169
- return msg;
170
- });
171
-
172
- const result = streamText({
173
- model: model,
174
- messages: convertToModelMessages(transformedMessages),
175
- system: systemPrompt,
176
- temperature: temperature,
177
- });
178
-
179
- return result.toUIMessageStreamResponse({
180
- sendSources: true,
181
- sendReasoning: true,
182
- onFinish: ({ messages: finalMessages }) => {
183
- if (finalMessages.length > 0) {
184
- saveChat({ chatId: id, messages: finalMessages, model, userId });
185
- }
186
- },
187
- });
188
- } catch (error) {
189
- console.error('Chat API error:', error);
190
- return new Response(JSON.stringify({ error: 'Internal server error' }), {
191
- status: 500,
192
- headers: { 'Content-Type': 'application/json' },
193
- });
194
- }
195
- }
196
- `;
197
- var HISTORY_ROUTE = `import { NextResponse } from 'next/server';
198
- import { getConversations } from '@mordn/chat-widget/api';
199
-
200
- export async function GET(request: Request) {
201
- try {
202
- const url = new URL(request.url);
203
- const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');
204
-
205
- if (!userId) {
206
- return NextResponse.json({ error: 'userId is required' }, { status: 400 });
207
- }
208
-
209
- const conversationsData = await getConversations(userId);
210
-
211
- const conversations = conversationsData.map(conv => ({
212
- id: conv.id,
213
- title: conv.title,
214
- created_at: conv.createdAt,
215
- updated_at: conv.updatedAt,
216
- metadata: conv.metadata,
217
- message_count: conv.messageCount,
218
- }));
219
-
220
- return NextResponse.json({ conversations });
221
- } catch (error) {
222
- console.error('Error in chat history API:', error);
223
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
224
- }
225
- }
226
- `;
227
- var CONVERSATION_ROUTE = `import { NextResponse } from 'next/server';
228
- import { db, conversations, messages, eq, and, asc } from '@mordn/chat-widget/api';
229
-
230
- export async function GET(
231
- request: Request,
232
- { params }: { params: Promise<{ conversationId: string }> }
233
- ) {
234
- try {
235
- const { conversationId } = await params;
236
- const url = new URL(request.url);
237
- const userId = url.searchParams.get('userId') || request.headers.get('X-User-Id');
238
-
239
- if (!userId) {
240
- return NextResponse.json({ error: 'userId is required' }, { status: 400 });
241
- }
242
-
243
- // Verify the conversation belongs to the user
244
- const conv = await db
245
- .select({
246
- id: conversations.id,
247
- title: conversations.title,
248
- metadata: conversations.metadata,
249
- })
250
- .from(conversations)
251
- .where(and(
252
- eq(conversations.id, conversationId),
253
- eq(conversations.userId, userId)
254
- ))
255
- .limit(1);
256
-
257
- if (!conv.length) {
258
- return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
259
- }
260
-
261
- const conversation = conv[0];
262
-
263
- const dbMessages = await db
264
- .select()
265
- .from(messages)
266
- .where(eq(messages.conversationId, conversationId))
267
- .orderBy(asc(messages.createdAt))
268
- .limit(1000);
269
-
270
- const transformedMessages = dbMessages.map(msg => {
271
- const metadata = msg.metadata as { parts?: any[] } | null;
272
-
273
- if (metadata?.parts && Array.isArray(metadata.parts)) {
274
- return {
275
- id: msg.id,
276
- role: msg.role,
277
- content: msg.content,
278
- created_at: msg.createdAt,
279
- parts: metadata.parts
280
- };
281
- }
282
-
283
- return {
284
- id: msg.id,
285
- role: msg.role,
286
- content: msg.content,
287
- created_at: msg.createdAt,
288
- parts: msg.content ? [{ type: 'text', text: msg.content }] : undefined
289
- };
290
- });
291
-
292
- return NextResponse.json({ conversation, messages: transformedMessages });
293
- } catch (error) {
294
- console.error('Error loading conversation:', error);
295
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
296
- }
297
- }
75
+ var CATCHALL_ROUTE = `import { createChatHandler } from '@mordn/chat-widget/server';
76
+ import { createDrizzleChatStore } from '@mordn/chat-widget/server/drizzle';
77
+ import { createSupabaseStorage } from '@mordn/chat-widget/server/supabase';
78
+ import { anthropic } from '@ai-sdk/anthropic';
79
+ import { getChatUserId } from '@/lib/chat-auth';
80
+
81
+ // Allow tool-using turns to stream beyond the default 30s.
82
+ export const maxDuration = 300;
83
+
84
+ export const { GET, POST, DELETE } = createChatHandler({
85
+ // REQUIRED: derive the user id from your SERVER session. See lib/chat-auth.ts.
86
+ getUserId: getChatUserId,
87
+
88
+ // Which model to stream from. Swap for your provider/model.
89
+ model: anthropic('claude-sonnet-4-5'),
90
+
91
+ // Persistence. The default Drizzle store uses DATABASE_URL. Replace with
92
+ // your own ChatStore to bring your own database.
93
+ store: createDrizzleChatStore(),
94
+
95
+ // Attachments. The default uses a PRIVATE Supabase bucket + signed URLs.
96
+ // Remove this line to disable uploads, or pass your own StorageAdapter.
97
+ storage: createSupabaseStorage(),
98
+
99
+ // A system prompt. Make it a function of ctx to personalise per user.
100
+ buildSystemPrompt: () => 'You are a helpful assistant.',
101
+
102
+ // Add your tools here. buildTools is async and receives the request context
103
+ // (userId, conversationId, request) so tools can be user-scoped. If a tool
104
+ // holds a per-request resource (e.g. an MCP client), return a \`cleanup\`
105
+ // and the handler will tear it down exactly once when the turn ends.
106
+ //
107
+ // buildTools: async (ctx) => ({ tools: { /* ... */ }, cleanup: async () => {} }),
108
+ });
298
109
  `;
299
- var UPLOAD_ROUTE = `import { createClient } from '@supabase/supabase-js';
300
- import { nanoid } from 'nanoid';
301
-
302
- export async function POST(req: Request) {
303
- try {
304
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
305
- const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
306
-
307
- // Check for required environment variables
308
- if (!supabaseUrl || !supabaseServiceKey) {
309
- console.error('Missing Supabase environment variables. Please set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY');
310
- return Response.json({
311
- error: 'File upload is not configured. Please set up Supabase Storage environment variables.'
312
- }, { status: 503 });
313
- }
314
-
315
- const formData = await req.formData();
316
- const file = formData.get('file') as File;
317
- const conversationId = formData.get('conversationId') as string;
318
- const userId = formData.get('userId') as string;
319
-
320
- if (!file) {
321
- return Response.json({ error: 'No file provided' }, { status: 400 });
322
- }
323
-
324
- if (!userId) {
325
- return Response.json({ error: 'userId is required' }, { status: 400 });
326
- }
327
-
328
- // Only images supported
329
- if (!file.type.startsWith('image/')) {
330
- return Response.json({ error: 'Only image files are supported' }, { status: 400 });
331
- }
332
-
333
- // 5MB limit
334
- if (file.size > 5 * 1024 * 1024) {
335
- return Response.json({ error: 'File size exceeds 5MB limit' }, { status: 400 });
336
- }
337
-
338
- const supabase = createClient(supabaseUrl, supabaseServiceKey);
339
-
340
- const timestamp = Date.now();
341
- const randomId = nanoid(8);
342
- const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
343
- const filePath = \`\${userId}/\${conversationId || 'default'}/\${timestamp}-\${randomId}-\${safeFilename}\`;
344
-
345
- const fileBuffer = await file.arrayBuffer();
346
-
347
- const { error: uploadError } = await supabase.storage
348
- .from('chat-attachments')
349
- .upload(filePath, fileBuffer, {
350
- contentType: file.type,
351
- upsert: false,
352
- });
353
-
354
- if (uploadError) {
355
- console.error('Upload error:', uploadError);
356
- return Response.json({ error: 'Failed to upload file' }, { status: 500 });
357
- }
358
-
359
- const { data: urlData } = supabase.storage
360
- .from('chat-attachments')
361
- .getPublicUrl(filePath);
362
-
363
- return Response.json({
364
- url: urlData.publicUrl,
365
- filename: file.name,
366
- mediaType: file.type,
367
- size: file.size,
368
- type: 'file',
369
- });
370
- } catch (error) {
371
- console.error('Upload API error:', error);
372
- return Response.json({ error: 'Internal server error' }, { status: 500 });
373
- }
110
+ var CHAT_AUTH_STUB = `/**
111
+ * Chat identity \u2014 the security boundary.
112
+ *
113
+ * Return the authenticated user's id derived from your SERVER session: a
114
+ * verified cookie / JWT, Clerk \`auth()\`, NextAuth \`getServerSession()\`,
115
+ * \`supabase.auth.getUser()\`, etc. Return \`null\` for an unauthenticated
116
+ * request (the handler responds 401).
117
+ *
118
+ * SECURITY \u2014 read this once:
119
+ * \u2022 NEVER read the id from the request body, query string, or a header the
120
+ * browser controls (e.g. X-User-Id). Those are forgeable; trusting them
121
+ * lets any user read/write another user's conversations (IDOR).
122
+ * \u2022 The widget DOES send an X-User-Id header \u2014 ignore it for authorization.
123
+ * It is not, and must never be treated as, proof of identity.
124
+ *
125
+ * This stub throws on purpose. Replace its body with your real session lookup
126
+ * before going to production.
127
+ */
128
+ export async function getChatUserId(request: Request): Promise<string | null> {
129
+ // \u2500\u2500 Example (Clerk) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
130
+ // import { auth } from '@clerk/nextjs/server';
131
+ // const { userId } = await auth();
132
+ // return userId;
133
+ //
134
+ // \u2500\u2500 Example (NextAuth) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
135
+ // import { getServerSession } from 'next-auth';
136
+ // const session = await getServerSession(authOptions);
137
+ // return session?.user?.id ?? null;
138
+ //
139
+ // \u2500\u2500 Example (Supabase Auth) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
140
+ // const supabase = createServerClient(/* ... */);
141
+ // const { data: { user } } = await supabase.auth.getUser();
142
+ // return user?.id ?? null;
143
+
144
+ void request;
145
+ throw new Error(
146
+ '[chat-widget] getChatUserId is not implemented. Derive the user id from ' +
147
+ 'your server session and return it (or null). See the examples in this ' +
148
+ 'file. Do NOT read the id from request headers/query/body.',
149
+ );
374
150
  }
375
151
  `;
376
152
  var DRIZZLE_CONFIG = `import 'dotenv/config';
377
153
  import { defineConfig } from 'drizzle-kit';
378
154
 
379
155
  export default defineConfig({
380
- schema: './node_modules/@mordn/chat-widget/dist/schema/index.js',
156
+ // The default store's schema lives in the package. drizzle-kit reads it from
157
+ // the built dist so it can generate/push migrations for the chat tables.
158
+ schema: './node_modules/@mordn/chat-widget/dist/server/drizzle/index.js',
381
159
  out: './drizzle',
382
160
  dialect: 'postgresql',
383
161
  dbCredentials: {
@@ -385,43 +163,35 @@ export default defineConfig({
385
163
  },
386
164
  });
387
165
  `;
388
- var ENV_EXAMPLE = `# Database (Required)
166
+ var ENV_EXAMPLE = `# Database (required for the default Drizzle store)
389
167
  DATABASE_URL="postgresql://postgres.xxx:[PASSWORD]@aws-0-region.pooler.supabase.com:6543/postgres"
390
168
 
391
- # AI Gateway (Required)
392
- AI_GATEWAY_API_KEY="your-ai-gateway-key"
393
-
394
- # Supabase Storage (Optional - for file uploads)
169
+ # Attachments (required only if you keep createSupabaseStorage)
170
+ # The bucket MUST be created as a PRIVATE bucket.
395
171
  NEXT_PUBLIC_SUPABASE_URL="https://xxx.supabase.co"
396
172
  SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
397
173
  `;
398
174
  async function init() {
399
175
  console.log("\n@mordn/chat-widget init\n");
400
- console.log("This will create the required API routes and configuration files.\n");
176
+ console.log(
177
+ "Scaffolds a secure-by-default chat backend: one catch-all route + an\nauth stub you implement. All chat logic lives in the package.\n"
178
+ );
401
179
  const appDir = detectAppDir();
402
- const apiChatDir = path.join(appDir, "api", "chat");
403
- console.log(`Detected app directory: ${path.relative(process.cwd(), appDir)}
180
+ const libDir = detectLibDir();
181
+ console.log(`Detected app directory: ${path.relative(process.cwd(), appDir)}`);
182
+ console.log(`Detected lib directory: ${path.relative(process.cwd(), libDir)}
404
183
  `);
405
184
  let filesCreated = 0;
406
- console.log("Creating API routes...");
407
- if (await writeFileWithConfirm(path.join(apiChatDir, "route.ts"), MAIN_ROUTE)) {
185
+ console.log("Creating files...");
186
+ if (await writeFileWithConfirm(
187
+ path.join(appDir, "api", "chat", "[[...chat]]", "route.ts"),
188
+ CATCHALL_ROUTE
189
+ )) {
408
190
  filesCreated++;
409
191
  }
410
- if (await writeFileWithConfirm(path.join(apiChatDir, "history", "route.ts"), HISTORY_ROUTE)) {
192
+ if (await writeFileWithConfirm(path.join(libDir, "chat-auth.ts"), CHAT_AUTH_STUB)) {
411
193
  filesCreated++;
412
194
  }
413
- if (await writeFileWithConfirm(path.join(apiChatDir, "history", "[conversationId]", "route.ts"), CONVERSATION_ROUTE)) {
414
- filesCreated++;
415
- }
416
- const createUpload = await confirm("\nCreate file upload route? (requires Supabase Storage)");
417
- let uploadRouteCreated = false;
418
- if (createUpload) {
419
- if (await writeFileWithConfirm(path.join(apiChatDir, "upload", "route.ts"), UPLOAD_ROUTE)) {
420
- filesCreated++;
421
- uploadRouteCreated = true;
422
- }
423
- }
424
- console.log("\nCreating configuration files...");
425
195
  if (await writeFileWithConfirm(path.join(process.cwd(), "drizzle.config.ts"), DRIZZLE_CONFIG)) {
426
196
  filesCreated++;
427
197
  }
@@ -433,21 +203,16 @@ async function init() {
433
203
  `);
434
204
  console.log("Next steps:");
435
205
  console.log(" 1. Copy .env.example to .env.local and fill in your credentials");
436
- console.log(" 2. Run: npx drizzle-kit push");
437
- if (uploadRouteCreated) {
438
- console.log(' 3. Create a "chat-attachments" bucket in Supabase Storage');
439
- console.log(" 4. Add the ChatWidget with file uploads enabled:\n");
440
- console.log(" import { ChatWidget } from '@mordn/chat-widget';");
441
- console.log(" import '@mordn/chat-widget/styles.css';");
442
- console.log("");
443
- console.log(' <ChatWidget userId="user-123" features={{ fileUpload: true }} />\n');
444
- } else {
445
- console.log(" 3. Add the ChatWidget to your app:\n");
446
- console.log(" import { ChatWidget } from '@mordn/chat-widget';");
447
- console.log(" import '@mordn/chat-widget/styles.css';");
448
- console.log("");
449
- console.log(' <ChatWidget userId="user-123" />\n');
450
- }
206
+ console.log(" 2. Implement getChatUserId() in lib/chat-auth.ts");
207
+ console.log(" \u26A0 Until you do, every chat request will throw \u2014 by design.");
208
+ console.log(" 3. Run: npx drizzle-kit push (creates the chat tables)");
209
+ console.log(' 4. If using uploads: create a PRIVATE "chat-attachments" bucket in Supabase');
210
+ console.log(" 5. Mount the widget in your app:\n");
211
+ console.log(" import { ChatWidget } from '@mordn/chat-widget';");
212
+ console.log(" import '@mordn/chat-widget/styles.css';");
213
+ console.log(" <ChatWidget userId={/* your user id */} />\n");
214
+ console.log("Security: see SECURITY.md \u2014 userId is established on the server,");
215
+ console.log("never trusted from the client.\n");
451
216
  rl.close();
452
217
  }
453
218
  init().catch((error) => {