improvement(chat): deployed chat no longer uses subdomains, uses sim.ai/chat/[identifier] (#1474)

* improvement(chat): deployed chat no longer uses subdomains, uses sim.ai/chat/[identifier]

* ui fix

* added back validate route, remove backwards compatibility for subdomain redirects

* cleanup

* cleanup

* add chat page to conditional theme layout, and remove custom chat css

* consolidate theme providers

* ack pr commnets

---------

Co-authored-by: waleed <waleed>
This commit is contained in:
Waleed
2025-09-27 17:32:10 -07:00
committed by GitHub
parent 39356f3d74
commit dd1985c99c
36 changed files with 7302 additions and 934 deletions

View File

@@ -113,13 +113,13 @@ const otpVerifySchema = z.object({
// Send OTP endpoint
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ subdomain: string }> }
{ params }: { params: Promise<{ identifier: string }> }
) {
const { subdomain } = await params
const { identifier } = await params
const requestId = generateRequestId()
try {
logger.debug(`[${requestId}] Processing OTP request for subdomain: ${subdomain}`)
logger.debug(`[${requestId}] Processing OTP request for identifier: ${identifier}`)
// Parse request body
let body
@@ -136,11 +136,11 @@ export async function POST(
title: chat.title,
})
.from(chat)
.where(eq(chat.subdomain, subdomain))
.where(eq(chat.identifier, identifier))
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
}
@@ -227,13 +227,13 @@ export async function POST(
// Verify OTP endpoint
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ subdomain: string }> }
{ params }: { params: Promise<{ identifier: string }> }
) {
const { subdomain } = await params
const { identifier } = await params
const requestId = generateRequestId()
try {
logger.debug(`[${requestId}] Verifying OTP for subdomain: ${subdomain}`)
logger.debug(`[${requestId}] Verifying OTP for identifier: ${identifier}`)
// Parse request body
let body
@@ -248,11 +248,11 @@ export async function PUT(
authType: chat.authType,
})
.from(chat)
.where(eq(chat.subdomain, subdomain))
.where(eq(chat.identifier, identifier))
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
}

View File

@@ -1,12 +1,12 @@
/**
* Tests for chat subdomain API route
* Tests for chat identifier API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
describe('Chat Subdomain API Route', () => {
describe('Chat Identifier API Route', () => {
const createMockStream = () => {
return new ReadableStream({
start(controller) {
@@ -134,11 +134,11 @@ describe('Chat Subdomain API Route', () => {
})
describe('GET endpoint', () => {
it('should return chat info for a valid subdomain', async () => {
it('should return chat info for a valid identifier', async () => {
const req = createMockRequest('GET')
const params = Promise.resolve({ subdomain: 'test-chat' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { GET } = await import('@/app/api/chat/[subdomain]/route')
const { GET } = await import('@/app/api/chat/[identifier]/route')
const response = await GET(req, { params })
@@ -152,7 +152,7 @@ describe('Chat Subdomain API Route', () => {
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')
})
it('should return 404 for non-existent subdomain', async () => {
it('should return 404 for non-existent identifier', async () => {
vi.doMock('@sim/db', () => {
const mockLimit = vi.fn().mockReturnValue([])
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit })
@@ -167,9 +167,9 @@ describe('Chat Subdomain API Route', () => {
})
const req = createMockRequest('GET')
const params = Promise.resolve({ subdomain: 'nonexistent' })
const params = Promise.resolve({ identifier: 'nonexistent' })
const { GET } = await import('@/app/api/chat/[subdomain]/route')
const { GET } = await import('@/app/api/chat/[identifier]/route')
const response = await GET(req, { params })
@@ -201,9 +201,9 @@ describe('Chat Subdomain API Route', () => {
})
const req = createMockRequest('GET')
const params = Promise.resolve({ subdomain: 'inactive-chat' })
const params = Promise.resolve({ identifier: 'inactive-chat' })
const { GET } = await import('@/app/api/chat/[subdomain]/route')
const { GET } = await import('@/app/api/chat/[identifier]/route')
const response = await GET(req, { params })
@@ -222,9 +222,9 @@ describe('Chat Subdomain API Route', () => {
}))
const req = createMockRequest('GET')
const params = Promise.resolve({ subdomain: 'password-protected-chat' })
const params = Promise.resolve({ identifier: 'password-protected-chat' })
const { GET } = await import('@/app/api/chat/[subdomain]/route')
const { GET } = await import('@/app/api/chat/[identifier]/route')
const response = await GET(req, { params })
@@ -243,9 +243,9 @@ describe('Chat Subdomain API Route', () => {
describe('POST endpoint', () => {
it('should handle authentication requests without input', async () => {
const req = createMockRequest('POST', { password: 'test-password' })
const params = Promise.resolve({ subdomain: 'password-protected-chat' })
const params = Promise.resolve({ identifier: 'password-protected-chat' })
const { POST } = await import('@/app/api/chat/[subdomain]/route')
const { POST } = await import('@/app/api/chat/[identifier]/route')
const response = await POST(req, { params })
@@ -259,9 +259,9 @@ describe('Chat Subdomain API Route', () => {
it('should return 400 for requests without input', async () => {
const req = createMockRequest('POST', {})
const params = Promise.resolve({ subdomain: 'test-chat' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[subdomain]/route')
const { POST } = await import('@/app/api/chat/[identifier]/route')
const response = await POST(req, { params })
@@ -280,9 +280,9 @@ describe('Chat Subdomain API Route', () => {
}))
const req = createMockRequest('POST', { input: 'Hello' })
const params = Promise.resolve({ subdomain: 'protected-chat' })
const params = Promise.resolve({ identifier: 'protected-chat' })
const { POST } = await import('@/app/api/chat/[subdomain]/route')
const { POST } = await import('@/app/api/chat/[identifier]/route')
const response = await POST(req, { params })
@@ -343,9 +343,9 @@ describe('Chat Subdomain API Route', () => {
})
const req = createMockRequest('POST', { input: 'Hello' })
const params = Promise.resolve({ subdomain: 'test-chat' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[subdomain]/route')
const { POST } = await import('@/app/api/chat/[identifier]/route')
const response = await POST(req, { params })
@@ -358,9 +358,9 @@ describe('Chat Subdomain API Route', () => {
it('should return streaming response for valid chat messages', async () => {
const req = createMockRequest('POST', { input: 'Hello world', conversationId: 'conv-123' })
const params = Promise.resolve({ subdomain: 'test-chat' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[subdomain]/route')
const { POST } = await import('@/app/api/chat/[identifier]/route')
const response = await POST(req, { params })
@@ -375,9 +375,9 @@ describe('Chat Subdomain API Route', () => {
it('should handle streaming response body correctly', async () => {
const req = createMockRequest('POST', { input: 'Hello world' })
const params = Promise.resolve({ subdomain: 'test-chat' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[subdomain]/route')
const { POST } = await import('@/app/api/chat/[identifier]/route')
const response = await POST(req, { params })
@@ -405,9 +405,9 @@ describe('Chat Subdomain API Route', () => {
})
const req = createMockRequest('POST', { input: 'Trigger error' })
const params = Promise.resolve({ subdomain: 'test-chat' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[subdomain]/route')
const { POST } = await import('@/app/api/chat/[identifier]/route')
const response = await POST(req, { params })
@@ -430,9 +430,9 @@ describe('Chat Subdomain API Route', () => {
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
} as any
const params = Promise.resolve({ subdomain: 'test-chat' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[subdomain]/route')
const { POST } = await import('@/app/api/chat/[identifier]/route')
const response = await POST(req, { params })
@@ -448,9 +448,9 @@ describe('Chat Subdomain API Route', () => {
input: 'Hello world',
conversationId: 'test-conversation-123',
})
const params = Promise.resolve({ subdomain: 'test-chat' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[subdomain]/route')
const { POST } = await import('@/app/api/chat/[identifier]/route')
await POST(req, { params })
@@ -463,9 +463,9 @@ describe('Chat Subdomain API Route', () => {
it('should handle missing conversationId gracefully', async () => {
const req = createMockRequest('POST', { input: 'Hello world' })
const params = Promise.resolve({ subdomain: 'test-chat' })
const params = Promise.resolve({ identifier: 'test-chat' })
const { POST } = await import('@/app/api/chat/[subdomain]/route')
const { POST } = await import('@/app/api/chat/[identifier]/route')
await POST(req, { params })

View File

@@ -13,18 +13,18 @@ import {
} from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatSubdomainAPI')
const logger = createLogger('ChatIdentifierAPI')
// This endpoint handles chat interactions via the subdomain
// This endpoint handles chat interactions via the identifier
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ subdomain: string }> }
{ params }: { params: Promise<{ identifier: string }> }
) {
const { subdomain } = await params
const { identifier } = await params
const requestId = generateRequestId()
try {
logger.debug(`[${requestId}] Processing chat request for subdomain: ${subdomain}`)
logger.debug(`[${requestId}] Processing chat request for identifier: ${identifier}`)
// Parse the request body once
let parsedBody
@@ -34,7 +34,7 @@ export async function POST(
return addCorsHeaders(createErrorResponse('Invalid request body', 400), request)
}
// Find the chat deployment for this subdomain
// Find the chat deployment for this identifier
const deploymentResult = await db
.select({
id: chat.id,
@@ -47,11 +47,11 @@ export async function POST(
outputConfigs: chat.outputConfigs,
})
.from(chat)
.where(eq(chat.subdomain, subdomain))
.where(eq(chat.identifier, identifier))
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
}
@@ -59,7 +59,7 @@ export async function POST(
// Check if the chat is active
if (!deployment.isActive) {
logger.warn(`[${requestId}] Chat is not active: ${subdomain}`)
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request)
}
@@ -139,15 +139,15 @@ export async function POST(
// This endpoint returns information about the chat
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ subdomain: string }> }
{ params }: { params: Promise<{ identifier: string }> }
) {
const { subdomain } = await params
const { identifier } = await params
const requestId = generateRequestId()
try {
logger.debug(`[${requestId}] Fetching chat info for subdomain: ${subdomain}`)
logger.debug(`[${requestId}] Fetching chat info for identifier: ${identifier}`)
// Find the chat deployment for this subdomain
// Find the chat deployment for this identifier
const deploymentResult = await db
.select({
id: chat.id,
@@ -162,11 +162,11 @@ export async function GET(
outputConfigs: chat.outputConfigs,
})
.from(chat)
.where(eq(chat.subdomain, subdomain))
.where(eq(chat.identifier, identifier))
.limit(1)
if (deploymentResult.length === 0) {
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
}
@@ -174,7 +174,7 @@ export async function GET(
// Check if the chat is active
if (!deployment.isActive) {
logger.warn(`[${requestId}] Chat is not active: ${subdomain}`)
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request)
}
@@ -205,7 +205,7 @@ export async function GET(
const authResult = await validateChatAuth(requestId, deployment, request)
if (!authResult.authorized) {
logger.info(
`[${requestId}] Authentication required for chat: ${subdomain}, type: ${deployment.authType}`
`[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}`
)
return addCorsHeaders(
createErrorResponse(authResult.error || 'Authentication required', 401),

View File

@@ -39,7 +39,7 @@ describe('Chat Edit API Route', () => {
}))
vi.doMock('@sim/db/schema', () => ({
chat: { id: 'id', subdomain: 'subdomain', userId: 'userId' },
chat: { id: 'id', identifier: 'identifier', userId: 'userId' },
}))
vi.doMock('@/lib/logs/console/logger', () => ({
@@ -93,8 +93,8 @@ describe('Chat Edit API Route', () => {
getSession: vi.fn().mockResolvedValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
const { GET } = await import('@/app/api/chat/edit/[id]/route')
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
const { GET } = await import('@/app/api/chat/manage/[id]/route')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
@@ -110,8 +110,8 @@ describe('Chat Edit API Route', () => {
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
const { GET } = await import('@/app/api/chat/edit/[id]/route')
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
const { GET } = await import('@/app/api/chat/manage/[id]/route')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(404)
@@ -128,7 +128,7 @@ describe('Chat Edit API Route', () => {
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
description: 'A test chat',
password: 'encrypted-password',
@@ -137,18 +137,18 @@ describe('Chat Edit API Route', () => {
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
const { GET } = await import('@/app/api/chat/edit/[id]/route')
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
const { GET } = await import('@/app/api/chat/manage/[id]/route')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
id: 'chat-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
description: 'A test chat',
customizations: { primaryColor: '#000000' },
chatUrl: 'http://test-chat.localhost:3000',
chatUrl: 'http://localhost:3000/chat/test-chat',
hasPassword: true,
})
})
@@ -160,11 +160,11 @@ describe('Chat Edit API Route', () => {
getSession: vi.fn().mockResolvedValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat' }),
})
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
@@ -180,11 +180,11 @@ describe('Chat Edit API Route', () => {
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat' }),
})
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(404)
@@ -201,30 +201,30 @@ describe('Chat Edit API Route', () => {
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat', description: 'Updated description' }),
})
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockUpdate).toHaveBeenCalled()
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
id: 'chat-123',
chatUrl: 'http://test-chat.localhost:3000',
chatUrl: 'http://localhost:3000/chat/test-chat',
message: 'Chat deployment updated successfully',
})
})
it('should handle subdomain conflicts', async () => {
it('should handle identifier conflicts', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
@@ -233,23 +233,23 @@ describe('Chat Edit API Route', () => {
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
// Mock subdomain conflict
mockLimit.mockResolvedValueOnce([{ id: 'other-chat-id', subdomain: 'new-subdomain' }])
// Mock identifier conflict
mockLimit.mockResolvedValueOnce([{ id: 'other-chat-id', identifier: 'new-identifier' }])
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ subdomain: 'new-subdomain' }),
body: JSON.stringify({ identifier: 'new-identifier' }),
})
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Identifier already in use', 400)
})
it('should validate password requirement for password auth', async () => {
@@ -261,7 +261,7 @@ describe('Chat Edit API Route', () => {
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
password: null,
@@ -269,11 +269,11 @@ describe('Chat Edit API Route', () => {
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ authType: 'password' }), // No password provided
})
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(400)
@@ -292,7 +292,7 @@ describe('Chat Edit API Route', () => {
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
}
@@ -300,11 +300,11 @@ describe('Chat Edit API Route', () => {
// User doesn't own chat but has workspace admin access
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Admin Updated Chat' }),
})
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
@@ -318,10 +318,10 @@ describe('Chat Edit API Route', () => {
getSession: vi.fn().mockResolvedValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const { DELETE } = await import('@/app/api/chat/edit/[id]/route')
const { DELETE } = await import('@/app/api/chat/manage/[id]/route')
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
@@ -337,10 +337,10 @@ describe('Chat Edit API Route', () => {
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const { DELETE } = await import('@/app/api/chat/edit/[id]/route')
const { DELETE } = await import('@/app/api/chat/manage/[id]/route')
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(404)
@@ -358,10 +358,10 @@ describe('Chat Edit API Route', () => {
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
mockWhere.mockResolvedValue(undefined)
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const { DELETE } = await import('@/app/api/chat/edit/[id]/route')
const { DELETE } = await import('@/app/api/chat/manage/[id]/route')
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
@@ -382,10 +382,10 @@ describe('Chat Edit API Route', () => {
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
mockWhere.mockResolvedValue(undefined)
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
})
const { DELETE } = await import('@/app/api/chat/edit/[id]/route')
const { DELETE } = await import('@/app/api/chat/manage/[id]/route')
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)

View File

@@ -15,13 +15,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('ChatDetailAPI')
// Schema for updating an existing chat
const chatUpdateSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required').optional(),
subdomain: z
identifier: z
.string()
.min(1, 'Subdomain is required')
.regex(/^[a-z0-9-]+$/, 'Subdomain can only contain lowercase letters, numbers, and hyphens')
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens')
.optional(),
title: z.string().min(1, 'Title is required').optional(),
description: z.string().optional(),
@@ -59,19 +58,17 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
return createErrorResponse('Unauthorized', 401)
}
// Check if user has access to view this chat
const { hasAccess, chat: chatRecord } = await checkChatAccess(chatId, session.user.id)
if (!hasAccess || !chatRecord) {
return createErrorResponse('Chat not found or access denied', 404)
}
// Create a new result object without the password
const { password, ...safeData } = chatRecord
const baseDomain = getEmailDomain()
const protocol = isDev ? 'http' : 'https'
const chatUrl = `${protocol}://${chatRecord.subdomain}.${baseDomain}`
const chatUrl = `${protocol}://${baseDomain}/chat/${chatRecord.identifier}`
const result = {
...safeData,
@@ -105,19 +102,17 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
try {
const validatedData = chatUpdateSchema.parse(body)
// Check if user has access to edit this chat
const { hasAccess, chat: existingChatRecord } = await checkChatAccess(chatId, session.user.id)
if (!hasAccess || !existingChatRecord) {
return createErrorResponse('Chat not found or access denied', 404)
}
const existingChat = [existingChatRecord] // Keep array format for compatibility
const existingChat = [existingChatRecord]
// Extract validated data
const {
workflowId,
subdomain,
identifier,
title,
description,
customizations,
@@ -127,77 +122,62 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
outputConfigs,
} = validatedData
// Check if subdomain is changing and if it's available
if (subdomain && subdomain !== existingChat[0].subdomain) {
const existingSubdomain = await db
if (identifier && identifier !== existingChat[0].identifier) {
const existingIdentifier = await db
.select()
.from(chat)
.where(eq(chat.subdomain, subdomain))
.where(eq(chat.identifier, identifier))
.limit(1)
if (existingSubdomain.length > 0 && existingSubdomain[0].id !== chatId) {
return createErrorResponse('Subdomain already in use', 400)
if (existingIdentifier.length > 0 && existingIdentifier[0].id !== chatId) {
return createErrorResponse('Identifier already in use', 400)
}
}
// Handle password update
let encryptedPassword
// Only encrypt and update password if one is provided
if (password) {
const { encrypted } = await encryptSecret(password)
encryptedPassword = encrypted
logger.info('Password provided, will be updated')
} else if (authType === 'password' && !password) {
// If switching to password auth but no password provided,
// check if there's an existing password
if (existingChat[0].authType !== 'password' || !existingChat[0].password) {
// If there's no existing password to reuse, return an error
return createErrorResponse('Password is required when using password protection', 400)
}
logger.info('Keeping existing password')
}
// Prepare update data
const updateData: any = {
updatedAt: new Date(),
}
// Only include fields that are provided
if (workflowId) updateData.workflowId = workflowId
if (subdomain) updateData.subdomain = subdomain
if (identifier) updateData.identifier = identifier
if (title) updateData.title = title
if (description !== undefined) updateData.description = description
if (customizations) updateData.customizations = customizations
// Handle auth type update
if (authType) {
updateData.authType = authType
// Reset auth-specific fields when changing auth types
if (authType === 'public') {
updateData.password = null
updateData.allowedEmails = []
} else if (authType === 'password') {
updateData.allowedEmails = []
// Password handled separately
} else if (authType === 'email') {
updateData.password = null
// Emails handled separately
}
}
// Always update password if provided (not just when changing auth type)
if (encryptedPassword) {
updateData.password = encryptedPassword
}
// Always update allowed emails if provided
if (allowedEmails) {
updateData.allowedEmails = allowedEmails
}
// Handle output fields
if (outputConfigs) {
updateData.outputConfigs = outputConfigs
}
@@ -210,14 +190,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
outputConfigsCount: updateData.outputConfigs ? updateData.outputConfigs.length : undefined,
})
// Update the chat deployment
await db.update(chat).set(updateData).where(eq(chat.id, chatId))
const updatedSubdomain = subdomain || existingChat[0].subdomain
const updatedIdentifier = identifier || existingChat[0].identifier
const baseDomain = getEmailDomain()
const protocol = isDev ? 'http' : 'https'
const chatUrl = `${protocol}://${updatedSubdomain}.${baseDomain}`
const chatUrl = `${protocol}://${baseDomain}/chat/${updatedIdentifier}`
logger.info(`Chat "${chatId}" updated successfully`)
@@ -256,14 +235,12 @@ export async function DELETE(
return createErrorResponse('Unauthorized', 401)
}
// Check if user has access to delete this chat
const { hasAccess } = await checkChatAccess(chatId, session.user.id)
if (!hasAccess) {
return createErrorResponse('Chat not found or access denied', 404)
}
// Delete the chat deployment
await db.delete(chat).where(eq(chat.id, chatId))
logger.info(`Chat "${chatId}" deleted successfully`)

View File

@@ -37,7 +37,7 @@ describe('Chat API Route', () => {
}))
vi.doMock('@sim/db/schema', () => ({
chat: { userId: 'userId', subdomain: 'subdomain' },
chat: { userId: 'userId', identifier: 'identifier' },
workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' },
}))
@@ -169,7 +169,7 @@ describe('Chat API Route', () => {
expect(response.status).toBe(400)
})
it('should reject if subdomain already exists', async () => {
it('should reject if identifier already exists', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
@@ -178,7 +178,7 @@ describe('Chat API Route', () => {
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
@@ -186,7 +186,7 @@ describe('Chat API Route', () => {
},
}
mockLimit.mockResolvedValueOnce([{ id: 'existing-chat' }]) // Subdomain exists
mockLimit.mockResolvedValueOnce([{ id: 'existing-chat' }]) // Identifier exists
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
@@ -196,7 +196,7 @@ describe('Chat API Route', () => {
const response = await POST(req)
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Identifier already in use', 400)
})
it('should reject if workflow not found', async () => {
@@ -208,7 +208,7 @@ describe('Chat API Route', () => {
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
@@ -216,7 +216,7 @@ describe('Chat API Route', () => {
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat', {
@@ -254,7 +254,7 @@ describe('Chat API Route', () => {
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
@@ -262,7 +262,7 @@ describe('Chat API Route', () => {
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
@@ -299,7 +299,7 @@ describe('Chat API Route', () => {
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
@@ -307,7 +307,7 @@ describe('Chat API Route', () => {
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
@@ -334,7 +334,7 @@ describe('Chat API Route', () => {
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
@@ -342,7 +342,7 @@ describe('Chat API Route', () => {
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: false,
})
@@ -371,7 +371,7 @@ describe('Chat API Route', () => {
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
@@ -379,7 +379,7 @@ describe('Chat API Route', () => {
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockRejectedValue(new Error('Permission check failed'))
const req = new NextRequest('http://localhost:3000/api/chat', {
@@ -402,7 +402,7 @@ describe('Chat API Route', () => {
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
@@ -410,7 +410,7 @@ describe('Chat API Route', () => {
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([]) // Identifier is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },

View File

@@ -16,10 +16,10 @@ const logger = createLogger('ChatAPI')
const chatSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
subdomain: z
identifier: z
.string()
.min(1, 'Subdomain is required')
.regex(/^[a-z0-9-]+$/, 'Subdomain can only contain lowercase letters, numbers, and hyphens'),
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
customizations: z.object({
@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
// Extract validated data
const {
workflowId,
subdomain,
identifier,
title,
description = '',
customizations,
@@ -98,15 +98,15 @@ export async function POST(request: NextRequest) {
)
}
// Check if subdomain is available
const existingSubdomain = await db
// Check if identifier is available
const existingIdentifier = await db
.select()
.from(chat)
.where(eq(chat.subdomain, subdomain))
.where(eq(chat.identifier, identifier))
.limit(1)
if (existingSubdomain.length > 0) {
return createErrorResponse('Subdomain already in use', 400)
if (existingIdentifier.length > 0) {
return createErrorResponse('Identifier already in use', 400)
}
// Check if user has permission to create chat for this workflow
@@ -137,7 +137,7 @@ export async function POST(request: NextRequest) {
// Log the values we're inserting
logger.info('Creating chat deployment with values:', {
workflowId,
subdomain,
identifier,
title,
authType,
hasPassword: !!encryptedPassword,
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
id,
workflowId,
userId: session.user.id,
subdomain,
identifier,
title,
description: description || '',
customizations: mergedCustomizations,
@@ -170,7 +170,7 @@ export async function POST(request: NextRequest) {
})
// Return successful response with chat URL
// Generate chat URL based on the configured base URL
// Generate chat URL using path-based routing instead of subdomains
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
let chatUrl: string
@@ -180,7 +180,7 @@ export async function POST(request: NextRequest) {
if (host.startsWith('www.')) {
host = host.substring(4)
}
chatUrl = `${url.protocol}//${subdomain}.${host}`
chatUrl = `${url.protocol}//${host}/chat/${identifier}`
} catch (error) {
logger.warn('Failed to parse baseUrl, falling back to defaults:', {
baseUrl,
@@ -188,9 +188,9 @@ export async function POST(request: NextRequest) {
})
// Fallback based on environment
if (isDev) {
chatUrl = `http://${subdomain}.localhost:3000`
chatUrl = `http://localhost:3000/chat/${identifier}`
} else {
chatUrl = `https://${subdomain}.sim.ai`
chatUrl = `https://sim.ai/chat/${identifier}`
}
}

View File

@@ -1,242 +0,0 @@
import { NextRequest } from 'next/server'
/**
* Tests for subdomain validation API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('Subdomain Validation API Route', () => {
// Mock database responses
const mockSelect = vi.fn()
const mockFrom = vi.fn()
const mockWhere = vi.fn()
const mockLimit = vi.fn()
// Mock success and error responses
const mockCreateSuccessResponse = vi.fn()
const mockCreateErrorResponse = vi.fn()
const mockNextResponseJson = vi.fn()
beforeEach(() => {
vi.resetModules()
// Set up database query chain
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
// Mock the database
vi.doMock('@sim/db', () => ({
db: {
select: mockSelect,
},
}))
// Mock the schema
vi.doMock('@sim/db/schema', () => ({
chat: {
subdomain: 'subdomain',
},
}))
// Mock the logger
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}))
// Mock the response utilities
vi.doMock('@/app/api/workflows/utils', () => ({
createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
createErrorResponse: mockCreateErrorResponse.mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
}),
}))
// Mock the NextResponse json method
mockNextResponseJson.mockImplementation((data, options) => {
return new Response(JSON.stringify(data), {
status: options?.status || 200,
headers: { 'Content-Type': 'application/json' },
})
})
vi.doMock('next/server', () => ({
NextRequest: vi.fn(),
NextResponse: {
json: mockNextResponseJson,
},
}))
})
afterEach(() => {
vi.clearAllMocks()
})
it('should return 401 when user is not authenticated', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate?subdomain=test')
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
const response = await GET(req)
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should return 400 when subdomain parameter is missing', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate')
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
const response = await GET(req)
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Missing subdomain parameter', 400)
})
it('should return 400 when subdomain format is invalid', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const req = new NextRequest(
'http://localhost:3000/api/chat/subdomains/validate?subdomain=Invalid_Subdomain!'
)
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('available', false)
expect(data).toHaveProperty('error', 'Invalid subdomain format')
expect(mockNextResponseJson).toHaveBeenCalledWith(
{ available: false, error: 'Invalid subdomain format' },
{ status: 400 }
)
})
it('should return available=true when subdomain is valid and not in use', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockLimit.mockResolvedValue([])
const req = new NextRequest(
'http://localhost:3000/api/chat/subdomains/validate?subdomain=available-subdomain'
)
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
const response = await GET(req)
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
available: true,
subdomain: 'available-subdomain',
})
})
it('should return available=false when subdomain is reserved', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const req = new NextRequest(
'http://localhost:3000/api/chat/subdomains/validate?subdomain=telemetry'
)
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
const response = await GET(req)
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('available', false)
expect(data).toHaveProperty('error', 'This subdomain is reserved')
expect(mockNextResponseJson).toHaveBeenCalledWith(
{ available: false, error: 'This subdomain is reserved' },
{ status: 400 }
)
})
it('should return available=false when subdomain is already in use', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockLimit.mockResolvedValue([{ id: 'existing-chat-id' }])
const req = new NextRequest(
'http://localhost:3000/api/chat/subdomains/validate?subdomain=used-subdomain'
)
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
const response = await GET(req)
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
available: false,
subdomain: 'used-subdomain',
})
})
it('should return 500 when database query fails', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockLimit.mockRejectedValue(new Error('Database error'))
const req = new NextRequest(
'http://localhost:3000/api/chat/subdomains/validate?subdomain=error-subdomain'
)
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
const response = await GET(req)
expect(response.status).toBe(500)
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Failed to check subdomain availability',
500
)
})
})

View File

@@ -1,74 +0,0 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('SubdomainValidateAPI')
export async function GET(request: Request) {
const session = await getSession()
if (!session || !session.user) {
return createErrorResponse('Unauthorized', 401)
}
try {
const { searchParams } = new URL(request.url)
const subdomain = searchParams.get('subdomain')
if (!subdomain) {
return createErrorResponse('Missing subdomain parameter', 400)
}
if (!/^[a-z0-9-]+$/.test(subdomain)) {
return NextResponse.json(
{
available: false,
error: 'Invalid subdomain format',
},
{ status: 400 }
)
}
const reservedSubdomains = [
'telemetry',
'docs',
'api',
'admin',
'www',
'app',
'auth',
'blog',
'help',
'support',
'admin',
'qa',
'agent',
]
if (reservedSubdomains.includes(subdomain)) {
return NextResponse.json(
{
available: false,
error: 'This subdomain is reserved',
},
{ status: 400 }
)
}
const existingDeployment = await db
.select()
.from(chat)
.where(eq(chat.subdomain, subdomain))
.limit(1)
return createSuccessResponse({
available: existingDeployment.length === 0,
subdomain,
})
} catch (error) {
logger.error('Error checking subdomain availability:', error)
return createErrorResponse('Failed to check subdomain availability', 500)
}
}

View File

@@ -43,8 +43,6 @@ vi.mock('@/lib/utils', () => ({
describe('Chat API Utils', () => {
beforeEach(() => {
vi.resetModules()
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
@@ -61,6 +59,11 @@ describe('Chat API Utils', () => {
NODE_ENV: 'development',
},
})
vi.doMock('@/lib/environment', () => ({
isDev: true,
isHosted: false,
}))
})
afterEach(() => {
@@ -71,30 +74,30 @@ describe('Chat API Utils', () => {
it('should encrypt and validate auth tokens', async () => {
const { encryptAuthToken, validateAuthToken } = await import('@/app/api/chat/utils')
const subdomainId = 'test-subdomain-id'
const chatId = 'test-chat-id'
const type = 'password'
const token = encryptAuthToken(subdomainId, type)
const token = encryptAuthToken(chatId, type)
expect(typeof token).toBe('string')
expect(token.length).toBeGreaterThan(0)
const isValid = validateAuthToken(token, subdomainId)
const isValid = validateAuthToken(token, chatId)
expect(isValid).toBe(true)
const isInvalidSubdomain = validateAuthToken(token, 'wrong-subdomain-id')
expect(isInvalidSubdomain).toBe(false)
const isInvalidChat = validateAuthToken(token, 'wrong-chat-id')
expect(isInvalidChat).toBe(false)
})
it('should reject expired tokens', async () => {
const { validateAuthToken } = await import('@/app/api/chat/utils')
const subdomainId = 'test-subdomain-id'
const chatId = 'test-chat-id'
// Create an expired token by directly constructing it with an old timestamp
const expiredToken = Buffer.from(
`${subdomainId}:password:${Date.now() - 25 * 60 * 60 * 1000}`
`${chatId}:password:${Date.now() - 25 * 60 * 60 * 1000}`
).toString('base64')
const isValid = validateAuthToken(expiredToken, subdomainId)
const isValid = validateAuthToken(expiredToken, chatId)
expect(isValid).toBe(false)
})
})
@@ -110,13 +113,13 @@ describe('Chat API Utils', () => {
},
} as unknown as NextResponse
const subdomainId = 'test-subdomain-id'
const chatId = 'test-chat-id'
const type = 'password'
setChatAuthCookie(mockResponse, subdomainId, type)
setChatAuthCookie(mockResponse, chatId, type)
expect(mockSet).toHaveBeenCalledWith({
name: `chat_auth_${subdomainId}`,
name: `chat_auth_${chatId}`,
value: expect.any(String),
httpOnly: true,
secure: false, // Development mode
@@ -134,7 +137,7 @@ describe('Chat API Utils', () => {
const mockRequest = {
headers: {
get: vi.fn().mockReturnValue('http://test.localhost:3000'),
get: vi.fn().mockReturnValue('http://localhost:3000'),
},
} as any
@@ -148,7 +151,7 @@ describe('Chat API Utils', () => {
expect(mockResponse.headers.set).toHaveBeenCalledWith(
'Access-Control-Allow-Origin',
'http://test.localhost:3000'
'http://localhost:3000'
)
expect(mockResponse.headers.set).toHaveBeenCalledWith(
'Access-Control-Allow-Credentials',
@@ -169,7 +172,7 @@ describe('Chat API Utils', () => {
const mockRequest = {
headers: {
get: vi.fn().mockReturnValue('http://test.localhost:3000'),
get: vi.fn().mockReturnValue('http://localhost:3000'),
},
} as any

View File

@@ -11,7 +11,6 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { hasAdminPermission } from '@/lib/permissions/utils'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import { getEmailDomain } from '@/lib/urls/utils'
import { decryptSecret, generateRequestId } from '@/lib/utils'
import { TriggerUtils } from '@/lib/workflows/triggers'
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
@@ -102,17 +101,17 @@ export async function checkChatAccess(
return { hasAccess: false }
}
export const encryptAuthToken = (subdomainId: string, type: string): string => {
return Buffer.from(`${subdomainId}:${type}:${Date.now()}`).toString('base64')
export const encryptAuthToken = (chatId: string, type: string): string => {
return Buffer.from(`${chatId}:${type}:${Date.now()}`).toString('base64')
}
export const validateAuthToken = (token: string, subdomainId: string): boolean => {
export const validateAuthToken = (token: string, chatId: string): boolean => {
try {
const decoded = Buffer.from(token, 'base64').toString()
const [storedId, _type, timestamp] = decoded.split(':')
// Check if token is for this subdomain
if (storedId !== subdomainId) {
// Check if token is for this chat
if (storedId !== chatId) {
return false
}
@@ -132,22 +131,16 @@ export const validateAuthToken = (token: string, subdomainId: string): boolean =
}
// Set cookie helper function
export const setChatAuthCookie = (
response: NextResponse,
subdomainId: string,
type: string
): void => {
const token = encryptAuthToken(subdomainId, type)
export const setChatAuthCookie = (response: NextResponse, chatId: string, type: string): void => {
const token = encryptAuthToken(chatId, type)
// Set cookie with HttpOnly and secure flags
response.cookies.set({
name: `chat_auth_${subdomainId}`,
name: `chat_auth_${chatId}`,
value: token,
httpOnly: true,
secure: !isDev,
sameSite: 'lax',
path: '/',
// Using subdomain for the domain in production
domain: isDev ? undefined : `.${getEmailDomain()}`,
maxAge: 60 * 60 * 24, // 24 hours
})
}

View File

@@ -0,0 +1,49 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('ChatValidateAPI')
/**
* GET endpoint to validate chat identifier availability
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const identifier = searchParams.get('identifier')
if (!identifier) {
return createErrorResponse('Identifier parameter is required', 400)
}
if (!/^[a-z0-9-]+$/.test(identifier)) {
return createSuccessResponse({
available: false,
error: 'Identifier can only contain lowercase letters, numbers, and hyphens',
})
}
const existingChat = await db
.select({ id: chat.id })
.from(chat)
.where(eq(chat.identifier, identifier))
.limit(1)
const isAvailable = existingChat.length === 0
logger.debug(
`Identifier "${identifier}" availability check: ${isAvailable ? 'available' : 'taken'}`
)
return createSuccessResponse({
available: isAvailable,
error: isAvailable ? null : 'This identifier is already in use',
})
} catch (error: any) {
logger.error('Error validating chat identifier:', error)
return createErrorResponse(error.message || 'Failed to validate identifier', 500)
}
}

View File

@@ -21,7 +21,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const deploymentResults = await db
.select({
id: chat.id,
subdomain: chat.subdomain,
identifier: chat.identifier,
isActive: chat.isActive,
})
.from(chat)
@@ -33,7 +33,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
deploymentResults.length > 0
? {
id: deploymentResults[0].id,
subdomain: deploymentResults[0].subdomain,
identifier: deploymentResults[0].identifier,
}
: null

View File

@@ -92,7 +92,7 @@ function throttle<T extends (...args: any[]) => any>(func: T, delay: number): T
}) as T
}
export default function ChatClient({ subdomain }: { subdomain: string }) {
export default function ChatClient({ identifier }: { identifier: string }) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [isLoading, setIsLoading] = useState(false)
@@ -190,7 +190,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
const fetchChatConfig = async () => {
try {
const response = await fetch(`/api/chat/${subdomain}`, {
const response = await fetch(`/api/chat/${identifier}`, {
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest',
@@ -251,7 +251,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
.catch((err) => {
logger.error('Failed to fetch GitHub stars:', err)
})
}, [subdomain])
}, [identifier])
const refreshChat = () => {
fetchChatConfig()
@@ -309,7 +309,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
logger.info('API payload:', payload)
const response = await fetch(`/api/chat/${subdomain}`, {
const response = await fetch(`/api/chat/${identifier}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -433,7 +433,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
if (authRequired === 'password') {
return (
<PasswordAuth
subdomain={subdomain}
identifier={identifier}
onAuthSuccess={handleAuthSuccess}
title={title}
primaryColor={primaryColor}
@@ -443,7 +443,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
if (authRequired === 'email') {
return (
<EmailAuth
subdomain={subdomain}
identifier={identifier}
onAuthSuccess={handleAuthSuccess}
title={title}
primaryColor={primaryColor}

View File

@@ -0,0 +1,6 @@
import ChatClient from '@/app/chat/[identifier]/chat'
export default async function ChatPage({ params }: { params: Promise<{ identifier: string }> }) {
const { identifier } = await params
return <ChatClient identifier={identifier} />
}

View File

@@ -1,106 +0,0 @@
/**
* Chat Subdomain Light Mode Overrides
*
* This file overrides dark mode utility classes to force light mode appearance
* in the chat subdomain. It uses CSS variables defined in globals.css.
*
* The layout.tsx already applies the 'light' class which sets all the light
* theme CSS variables from globals.css, so we don't need to redefine them here.
*/
/* Background Color Overrides */
.chat-light-wrapper :is(.dark\:bg-black) {
background-color: hsl(var(--secondary));
}
.chat-light-wrapper :is(.dark\:bg-gray-900) {
background-color: hsl(var(--background));
}
.chat-light-wrapper :is(.dark\:bg-gray-800) {
background-color: hsl(var(--secondary));
}
.chat-light-wrapper :is(.dark\:bg-gray-700) {
background-color: hsl(var(--accent));
}
.chat-light-wrapper :is(.dark\:bg-gray-600) {
background-color: hsl(var(--muted));
}
.chat-light-wrapper :is(.dark\:bg-gray-300) {
background-color: hsl(var(--primary));
}
/* Text Color Overrides */
.chat-light-wrapper :is(.dark\:text-gray-100) {
color: hsl(var(--primary));
}
.chat-light-wrapper :is(.dark\:text-gray-200) {
color: hsl(var(--foreground));
}
.chat-light-wrapper :is(.dark\:text-gray-300) {
color: hsl(var(--muted-foreground));
}
.chat-light-wrapper :is(.dark\:text-gray-400) {
color: hsl(var(--muted-foreground));
}
.chat-light-wrapper :is(.dark\:text-neutral-600) {
color: hsl(var(--muted-foreground));
}
.chat-light-wrapper :is(.dark\:text-blue-400) {
color: var(--brand-accent-hex);
}
/* Border Color Overrides */
.chat-light-wrapper :is(.dark\:border-gray-700) {
border-color: hsl(var(--border));
}
.chat-light-wrapper :is(.dark\:border-gray-800) {
border-color: hsl(var(--border));
}
.chat-light-wrapper :is(.dark\:border-gray-600) {
border-color: hsl(var(--border));
}
.chat-light-wrapper :is(.dark\:divide-gray-700) > * + * {
border-color: hsl(var(--border));
}
/* Hover State Overrides */
.chat-light-wrapper :is(.dark\:hover\:bg-gray-800\/60:hover) {
background-color: hsl(var(--card-hover));
}
/* Code Block Overrides */
.chat-light-wrapper pre:is(.dark\:bg-black) {
background-color: hsl(var(--workflow-dots));
}
.chat-light-wrapper code:is(.dark\:bg-gray-700) {
background-color: hsl(var(--accent));
}
.chat-light-wrapper code:is(.dark\:text-gray-200) {
color: hsl(var(--foreground));
}
/* Special Components */
/* Tooltip overrides - keep tooltips dark with light text for consistency */
.chat-light-wrapper [data-radix-tooltip-content] {
background-color: hsl(0 0% 3.9%) !important;
color: hsl(0 0% 98%) !important;
}
/* Force light color scheme */
.chat-light-wrapper {
color-scheme: light !important;
}

View File

@@ -1,19 +0,0 @@
'use client'
import { ThemeProvider } from 'next-themes'
import './chat.css'
export default function ChatLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute='class'
forcedTheme='light'
enableSystem={false}
disableTransitionOnChange
>
<div className='light chat-light-wrapper' style={{ colorScheme: 'light' }}>
{children}
</div>
</ThemeProvider>
)
}

View File

@@ -1,6 +0,0 @@
import ChatClient from '@/app/chat/[subdomain]/chat'
export default async function ChatPage({ params }: { params: Promise<{ subdomain: string }> }) {
const { subdomain } = await params
return <ChatClient subdomain={subdomain} />
}

View File

@@ -16,7 +16,7 @@ import { soehne } from '@/app/fonts/soehne/soehne'
const logger = createLogger('EmailAuth')
interface EmailAuthProps {
subdomain: string
identifier: string
onAuthSuccess: () => void
title?: string
primaryColor?: string
@@ -39,7 +39,7 @@ const validateEmailField = (emailValue: string): string[] => {
}
export default function EmailAuth({
subdomain,
identifier,
onAuthSuccess,
title = 'chat',
primaryColor = 'var(--brand-primary-hover-hex)',
@@ -133,7 +133,7 @@ export default function EmailAuth({
setIsSendingOtp(true)
try {
const response = await fetch(`/api/chat/${subdomain}/otp`, {
const response = await fetch(`/api/chat/${identifier}/otp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -170,7 +170,7 @@ export default function EmailAuth({
setIsVerifyingOtp(true)
try {
const response = await fetch(`/api/chat/${subdomain}/otp`, {
const response = await fetch(`/api/chat/${identifier}/otp`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -201,7 +201,7 @@ export default function EmailAuth({
setCountdown(30)
try {
const response = await fetch(`/api/chat/${subdomain}/otp`, {
const response = await fetch(`/api/chat/${identifier}/otp`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -14,14 +14,14 @@ import { soehne } from '@/app/fonts/soehne/soehne'
const logger = createLogger('PasswordAuth')
interface PasswordAuthProps {
subdomain: string
identifier: string
onAuthSuccess: () => void
title?: string
primaryColor?: string
}
export default function PasswordAuth({
subdomain,
identifier,
onAuthSuccess,
title = 'chat',
primaryColor = 'var(--brand-primary-hover-hex)',
@@ -94,7 +94,7 @@ export default function PasswordAuth({
try {
const payload = { password }
const response = await fetch(`/api/chat/${subdomain}`, {
const response = await fetch(`/api/chat/${identifier}`, {
method: 'POST',
credentials: 'same-origin',
headers: {

View File

@@ -1,37 +0,0 @@
'use client'
import { usePathname } from 'next/navigation'
import type { ThemeProviderProps } from 'next-themes'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ConditionalThemeProvider({ children, ...props }: ThemeProviderProps) {
const pathname = usePathname()
// Force light mode for certain pages
const forcedTheme =
pathname === '/' ||
pathname === '/homepage' ||
pathname.startsWith('/login') ||
pathname.startsWith('/signup') ||
pathname.startsWith('/terms') ||
pathname.startsWith('/privacy') ||
pathname.startsWith('/invite') ||
pathname.startsWith('/verify') ||
pathname.startsWith('/changelog')
? 'light'
: undefined
return (
<NextThemesProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
storageKey='sim-theme'
forcedTheme={forcedTheme}
{...props}
>
{children}
</NextThemesProvider>
)
}

View File

@@ -9,7 +9,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import '@/app/globals.css'
import { SessionProvider } from '@/lib/session/session-context'
import { ConditionalThemeProvider } from '@/app/conditional-theme-provider'
import { ThemeProvider } from '@/app/theme-provider'
import { ZoomPrevention } from '@/app/zoom-prevention'
const logger = createLogger('RootLayout')
@@ -91,7 +91,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<PublicEnvScript />
</head>
<body suppressHydrationWarning>
<ConditionalThemeProvider>
<ThemeProvider>
<SessionProvider>
<BrandedLayout>
<ZoomPrevention />
@@ -103,7 +103,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
)}
</BrandedLayout>
</SessionProvider>
</ConditionalThemeProvider>
</ThemeProvider>
</body>
</html>
)

View File

@@ -1,9 +1,27 @@
'use client'
import { usePathname } from 'next/navigation'
import type { ThemeProviderProps } from 'next-themes'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const pathname = usePathname()
// Force light mode for certain pages
const forcedTheme =
pathname === '/' ||
pathname === '/homepage' ||
pathname.startsWith('/login') ||
pathname.startsWith('/signup') ||
pathname.startsWith('/terms') ||
pathname.startsWith('/privacy') ||
pathname.startsWith('/invite') ||
pathname.startsWith('/verify') ||
pathname.startsWith('/changelog') ||
pathname.startsWith('/chat')
? 'light'
: undefined
return (
<NextThemesProvider
attribute='class'
@@ -11,6 +29,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
enableSystem
disableTransitionOnChange
storageKey='sim-theme'
forcedTheme={forcedTheme}
{...props}
>
{children}

View File

@@ -24,7 +24,7 @@ import {
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
import { AuthSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector'
import { SubdomainInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/subdomain-input'
import { IdentifierInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/identifier-input'
import { SuccessView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view'
import { useChatDeployment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment'
import { useChatForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'
@@ -50,7 +50,7 @@ interface ChatDeployProps {
interface ExistingChat {
id: string
subdomain: string
identifier: string
title: string
description: string
authType: 'public' | 'password' | 'email'
@@ -96,9 +96,9 @@ export function ChatDeploy({
const { formData, errors, updateField, setError, validateForm, setFormData } = useChatForm()
const { deployedUrl, deployChat } = useChatDeployment()
const formRef = useRef<HTMLFormElement>(null)
const [isSubdomainValid, setIsSubdomainValid] = useState(false)
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const isFormValid =
isSubdomainValid &&
isIdentifierValid &&
Boolean(formData.title.trim()) &&
formData.selectedOutputBlocks.length > 0 &&
(formData.authType !== 'password' ||
@@ -125,14 +125,14 @@ export function ChatDeploy({
const data = await response.json()
if (data.isDeployed && data.deployment) {
const detailResponse = await fetch(`/api/chat/edit/${data.deployment.id}`)
const detailResponse = await fetch(`/api/chat/manage/${data.deployment.id}`)
if (detailResponse.ok) {
const chatDetail = await detailResponse.json()
setExistingChat(chatDetail)
setFormData({
subdomain: chatDetail.subdomain || '',
identifier: chatDetail.identifier || '',
title: chatDetail.title || '',
description: chatDetail.description || '',
authType: chatDetail.authType || 'public',
@@ -185,8 +185,8 @@ export function ChatDeploy({
return
}
if (!isSubdomainValid && formData.subdomain !== existingChat?.subdomain) {
setError('subdomain', 'Please wait for subdomain validation to complete')
if (!isIdentifierValid && formData.identifier !== existingChat?.identifier) {
setError('identifier', 'Please wait for identifier validation to complete')
setChatSubmitting(false)
return
}
@@ -201,8 +201,8 @@ export function ChatDeploy({
// This ensures existingChat is available when switching back to edit mode
await fetchExistingChat()
} catch (error: any) {
if (error.message?.includes('subdomain')) {
setError('subdomain', error.message)
if (error.message?.includes('identifier')) {
setError('identifier', error.message)
} else {
setError('general', error.message)
}
@@ -217,7 +217,7 @@ export function ChatDeploy({
try {
setIsDeleting(true)
const response = await fetch(`/api/chat/edit/${existingChat.id}`, {
const response = await fetch(`/api/chat/manage/${existingChat.id}`, {
method: 'DELETE',
})
@@ -267,7 +267,7 @@ export function ChatDeploy({
<AlertDialogDescription>
This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>
{existingChat?.subdomain}.{getEmailDomain()}
{getEmailDomain()}/chat/{existingChat?.identifier}
</span>
.
<span className='mt-2 block'>
@@ -316,12 +316,12 @@ export function ChatDeploy({
)}
<div className='space-y-4'>
<SubdomainInput
value={formData.subdomain}
onChange={(value) => updateField('subdomain', value)}
originalSubdomain={existingChat?.subdomain || undefined}
<IdentifierInput
value={formData.identifier}
onChange={(value) => updateField('identifier', value)}
originalIdentifier={existingChat?.identifier || undefined}
disabled={chatSubmitting}
onValidationChange={setIsSubdomainValid}
onValidationChange={setIsIdentifierValid}
isEditingExisting={!!existingChat}
/>
<div className='space-y-2'>
@@ -445,7 +445,7 @@ export function ChatDeploy({
<AlertDialogDescription>
This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>
{existingChat?.subdomain}.{getEmailDomain()}
{getEmailDomain()}/chat/{existingChat?.identifier}
</span>
.
<span className='mt-2 block'>

View File

@@ -2,33 +2,33 @@ import { useEffect } from 'react'
import { Input, Label } from '@/components/ui'
import { getEmailDomain } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
import { useSubdomainValidation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-subdomain-validation'
import { useIdentifierValidation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-identifier-validation'
interface SubdomainInputProps {
interface IdentifierInputProps {
value: string
onChange: (value: string) => void
originalSubdomain?: string
originalIdentifier?: string
disabled?: boolean
onValidationChange?: (isValid: boolean) => void
isEditingExisting?: boolean
}
const getDomainSuffix = (() => {
const suffix = `.${getEmailDomain()}`
return () => suffix
const getDomainPrefix = (() => {
const prefix = `${getEmailDomain()}/chat/`
return () => prefix
})()
export function SubdomainInput({
export function IdentifierInput({
value,
onChange,
originalSubdomain,
originalIdentifier,
disabled = false,
onValidationChange,
isEditingExisting = false,
}: SubdomainInputProps) {
const { isChecking, error, isValid } = useSubdomainValidation(
}: IdentifierInputProps) {
const { isChecking, error, isValid } = useIdentifierValidation(
value,
originalSubdomain,
originalIdentifier,
isEditingExisting
)
@@ -44,20 +44,23 @@ export function SubdomainInput({
return (
<div className='space-y-2'>
<Label htmlFor='subdomain' className='font-medium text-sm'>
Subdomain
<Label htmlFor='identifier' className='font-medium text-sm'>
Identifier
</Label>
<div className='relative flex items-center rounded-md ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'>
<div className='flex h-10 items-center whitespace-nowrap rounded-l-md border border-r-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
{getDomainPrefix()}
</div>
<div className='relative flex-1'>
<Input
id='subdomain'
id='identifier'
placeholder='company-name'
value={value}
onChange={(e) => handleChange(e.target.value)}
required
disabled={disabled}
className={cn(
'rounded-r-none border-r-0 focus-visible:ring-0 focus-visible:ring-offset-0',
'rounded-l-none border-l-0 focus-visible:ring-0 focus-visible:ring-offset-0',
isChecking && 'pr-8',
error && 'border-destructive focus-visible:border-destructive'
)}
@@ -68,9 +71,6 @@ export function SubdomainInput({
</div>
)}
</div>
<div className='flex h-10 items-center whitespace-nowrap rounded-r-md border border-l-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
{getDomainSuffix()}
</div>
</div>
{error && <p className='mt-1 text-destructive text-sm'>{error}</p>}
</div>

View File

@@ -3,7 +3,7 @@ import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils'
interface ExistingChat {
id: string
subdomain: string
identifier: string
title: string
description: string
authType: 'public' | 'password' | 'email'
@@ -27,21 +27,20 @@ export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: S
const hostname = url.hostname
const isDevelopmentUrl = hostname.includes('localhost')
let domainSuffix
// Extract identifier from path-based URL format (e.g., sim.ai/chat/identifier)
const pathParts = url.pathname.split('/')
const identifierPart = pathParts[2] || '' // /chat/identifier
let domainPrefix
if (isDevelopmentUrl) {
const baseDomain = getBaseDomain()
const baseHost = baseDomain.split(':')[0]
const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
domainSuffix = `.${baseHost}:${port}`
domainPrefix = `${baseHost}:${port}/chat/`
} else {
domainSuffix = `.${getEmailDomain()}`
domainPrefix = `${getEmailDomain()}/chat/`
}
const baseDomainForSplit = getEmailDomain()
const subdomainPart = isDevelopmentUrl
? hostname.split('.')[0]
: hostname.split(`.${baseDomainForSplit}`)[0]
return (
<div className='space-y-4'>
<div className='space-y-2'>
@@ -49,17 +48,17 @@ export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: S
Chat {existingChat ? 'Update' : 'Deployment'} Successful
</Label>
<div className='relative flex items-center rounded-md ring-offset-background'>
<div className='flex h-10 items-center whitespace-nowrap rounded-l-md border border-r-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
{domainPrefix}
</div>
<a
href={deployedUrl}
target='_blank'
rel='noopener noreferrer'
className='flex h-10 flex-1 items-center break-all rounded-l-md border border-r-0 p-2 font-medium text-foreground text-sm'
className='flex h-10 flex-1 items-center break-all rounded-r-md border border-l-0 p-2 font-medium text-foreground text-sm'
>
{subdomainPart}
{identifierPart}
</a>
<div className='flex h-10 items-center whitespace-nowrap rounded-r-md border border-l-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
{domainSuffix}
</div>
</div>
<p className='text-muted-foreground text-xs'>
Your chat is now live at{' '}

View File

@@ -14,10 +14,10 @@ export interface ChatDeploymentState {
const chatSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
subdomain: z
identifier: z
.string()
.min(1, 'Subdomain is required')
.regex(/^[a-z0-9-]+$/, 'Subdomain can only contain lowercase letters, numbers, and hyphens'),
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
customizations: z.object({
@@ -75,7 +75,7 @@ export function useChatDeployment() {
// Create request payload
const payload = {
workflowId,
subdomain: formData.subdomain.trim(),
identifier: formData.identifier.trim(),
title: formData.title.trim(),
description: formData.description.trim(),
customizations: {
@@ -95,7 +95,7 @@ export function useChatDeployment() {
chatSchema.parse(payload)
// Determine endpoint and method
const endpoint = existingChatId ? `/api/chat/edit/${existingChatId}` : '/api/chat'
const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat'
const method = existingChatId ? 'PATCH' : 'POST'
const response = await fetch(endpoint, {
@@ -107,9 +107,9 @@ export function useChatDeployment() {
const result = await response.json()
if (!response.ok) {
// Handle subdomain conflict specifically
if (result.error === 'Subdomain already in use') {
throw new Error('This subdomain is already in use')
// Handle identifier conflict specifically
if (result.error === 'Identifier already in use') {
throw new Error('This identifier is already in use')
}
throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
}

View File

@@ -3,7 +3,7 @@ import { useCallback, useState } from 'react'
export type AuthType = 'public' | 'password' | 'email'
export interface ChatFormData {
subdomain: string
identifier: string
title: string
description: string
authType: AuthType
@@ -14,7 +14,7 @@ export interface ChatFormData {
}
export interface ChatFormErrors {
subdomain?: string
identifier?: string
title?: string
password?: string
emails?: string
@@ -23,7 +23,7 @@ export interface ChatFormErrors {
}
const initialFormData: ChatFormData = {
subdomain: '',
identifier: '',
title: '',
description: '',
authType: 'public',
@@ -67,10 +67,10 @@ export function useChatForm(initialData?: Partial<ChatFormData>) {
const validateForm = useCallback((): boolean => {
const newErrors: ChatFormErrors = {}
if (!formData.subdomain.trim()) {
newErrors.subdomain = 'Subdomain is required'
} else if (!/^[a-z0-9-]+$/.test(formData.subdomain)) {
newErrors.subdomain = 'Subdomain can only contain lowercase letters, numbers, and hyphens'
if (!formData.identifier.trim()) {
newErrors.identifier = 'Identifier is required'
} else if (!/^[a-z0-9-]+$/.test(formData.identifier)) {
newErrors.identifier = 'Identifier can only contain lowercase letters, numbers, and hyphens'
}
if (!formData.title.trim()) {

View File

@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from 'react'
export function useSubdomainValidation(
subdomain: string,
originalSubdomain?: string,
export function useIdentifierValidation(
identifier: string,
originalIdentifier?: string,
isEditingExisting?: boolean
) {
const [isChecking, setIsChecking] = useState(false)
@@ -12,71 +12,72 @@ export function useSubdomainValidation(
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Reset states immediately when subdomain changes
// Reset states immediately when identifier changes
setError(null)
setIsValid(false)
setIsChecking(false)
// Skip validation if empty
if (!subdomain.trim()) {
if (!identifier.trim()) {
return
}
// Skip validation if same as original (existing deployment)
if (originalSubdomain && subdomain === originalSubdomain) {
if (originalIdentifier && identifier === originalIdentifier) {
setIsValid(true)
return
}
// If we're editing an existing deployment but originalSubdomain isn't available yet,
// If we're editing an existing deployment but originalIdentifier isn't available yet,
// assume it's valid and wait for the data to load
if (isEditingExisting && !originalSubdomain) {
if (isEditingExisting && !originalIdentifier) {
setIsValid(true)
return
}
// Validate format first
if (!/^[a-z0-9-]+$/.test(subdomain)) {
setError('Subdomain can only contain lowercase letters, numbers, and hyphens')
// Validate format first - client-side validation
if (!/^[a-z0-9-]+$/.test(identifier)) {
setError('Identifier can only contain lowercase letters, numbers, and hyphens')
return
}
// Debounce API call
// Check availability with server
setIsChecking(true)
timeoutRef.current = setTimeout(async () => {
try {
const response = await fetch(
`/api/chat/subdomains/validate?subdomain=${encodeURIComponent(subdomain)}`
`/api/chat/validate?identifier=${encodeURIComponent(identifier)}`
)
const data = await response.json()
if (!response.ok || !data.available) {
setError(data.error || 'This subdomain is already in use')
if (!response.ok) {
setError('Error checking identifier availability')
setIsValid(false)
} else if (!data.available) {
setError(data.error || 'This identifier is already in use')
setIsValid(false)
} else {
setError(null)
setIsValid(true)
}
} catch (error) {
setError('Error checking subdomain availability')
setError('Error checking identifier availability')
setIsValid(false)
} finally {
setIsChecking(false)
}
}, 500)
// Cleanup function
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [subdomain, originalSubdomain, isEditingExisting])
}, [identifier, originalIdentifier, isEditingExisting])
return { isChecking, error, isValid }
}

View File

@@ -16,7 +16,10 @@ const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root
{...props}
>
<ProgressPrimitive.Indicator
className={cn('h-full w-full flex-1 bg-primary transition-all', indicatorClassName)}
className={cn(
'h-full w-full flex-1 bg-primary transition-all dark:bg-white',
indicatorClassName
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View File

@@ -1,9 +1,8 @@
import { getSessionCookie } from 'better-auth/cookies'
import { type NextRequest, NextResponse } from 'next/server'
import { isDev, isHosted } from './lib/environment'
import { isHosted } from './lib/environment'
import { createLogger } from './lib/logs/console/logger'
import { generateRuntimeCSP } from './lib/security/csp'
import { getBaseDomain } from './lib/urls/utils'
const logger = createLogger('Middleware')
@@ -13,143 +12,98 @@ const SUSPICIOUS_UA_PATTERNS = [
/<\s*script/i, // Potential XSS payloads
/^\(\)\s*{/, // Command execution attempt
/\b(sqlmap|nikto|gobuster|dirb|nmap)\b/i, // Known scanning tools
]
const BASE_DOMAIN = getBaseDomain()
export async function middleware(request: NextRequest) {
// Check for active session
const sessionCookie = getSessionCookie(request)
const hasActiveSession = !!sessionCookie
] as const
/**
* Handles authentication-based redirects for root paths
*/
function handleRootPathRedirects(
request: NextRequest,
hasActiveSession: boolean
): NextResponse | null {
const url = request.nextUrl
const hostname = request.headers.get('host') || ''
// Extract subdomain - handle nested subdomains for any domain
const isCustomDomain = (() => {
// Standard check for non-base domains
if (hostname === BASE_DOMAIN || hostname.startsWith('www.')) {
return false
}
// Extract root domain from BASE_DOMAIN (e.g., "sim.ai" from "staging.sim.ai")
const baseParts = BASE_DOMAIN.split('.')
const rootDomain = isDev
? 'localhost'
: baseParts.length >= 2
? baseParts
.slice(-2)
.join('.') // Last 2 parts: ["simstudio", "ai"] -> "sim.ai"
: BASE_DOMAIN
// Check if hostname is under the same root domain
if (!hostname.includes(rootDomain)) {
return false
}
// For nested subdomain environments: handle cases like myapp.staging.example.com
const hostParts = hostname.split('.')
const basePartCount = BASE_DOMAIN.split('.').length
// If hostname has more parts than base domain, it's a nested subdomain
if (hostParts.length > basePartCount) {
return true
}
// For single-level subdomains: regular subdomain logic
return hostname !== BASE_DOMAIN
})()
const subdomain = isCustomDomain ? hostname.split('.')[0] : null
// Handle chat subdomains
if (subdomain && isCustomDomain) {
if (url.pathname.startsWith('/api/chat/') || url.pathname.startsWith('/api/proxy/')) {
return NextResponse.next()
}
// Rewrite to the chat page but preserve the URL in browser
return NextResponse.rewrite(new URL(`/chat/${subdomain}${url.pathname}`, request.url))
if (url.pathname !== '/' && url.pathname !== '/homepage') {
return null
}
// Handle root path redirects based on session status and hosting type
// Only apply redirects to the main domain, not subdomains
if (!isCustomDomain && (url.pathname === '/' || url.pathname === '/homepage')) {
if (!isHosted) {
// Self-hosted: Always redirect based on session
if (hasActiveSession) {
return NextResponse.redirect(new URL('/workspace', request.url))
}
return NextResponse.redirect(new URL('/login', request.url))
}
// Hosted: Allow access to /homepage route even for authenticated users
if (url.pathname === '/homepage') {
return NextResponse.rewrite(new URL('/', request.url))
}
// For root path, redirect authenticated users to workspace
if (hasActiveSession && url.pathname === '/') {
return NextResponse.redirect(new URL('/workspace', request.url))
}
}
// Handle login page - redirect authenticated users to workspace
if (url.pathname === '/login' || url.pathname === '/signup') {
if (!isHosted) {
// Self-hosted: Always redirect based on session
if (hasActiveSession) {
return NextResponse.redirect(new URL('/workspace', request.url))
}
return NextResponse.next()
return NextResponse.redirect(new URL('/login', request.url))
}
// Handle protected routes that require authentication
if (url.pathname.startsWith('/workspace')) {
if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url))
// Hosted: Allow access to /homepage route even for authenticated users
if (url.pathname === '/homepage') {
return NextResponse.rewrite(new URL('/', request.url))
}
// For root path, redirect authenticated users to workspace
if (hasActiveSession && url.pathname === '/') {
return NextResponse.redirect(new URL('/workspace', request.url))
}
return null
}
/**
* Handles invitation link redirects for unauthenticated users
*/
function handleInvitationRedirects(
request: NextRequest,
hasActiveSession: boolean
): NextResponse | null {
if (!request.nextUrl.pathname.startsWith('/invite/')) {
return null
}
if (
!hasActiveSession &&
!request.nextUrl.pathname.endsWith('/login') &&
!request.nextUrl.pathname.endsWith('/signup') &&
!request.nextUrl.search.includes('callbackUrl')
) {
const token = request.nextUrl.searchParams.get('token')
const inviteId = request.nextUrl.pathname.split('/').pop()
const callbackParam = encodeURIComponent(`/invite/${inviteId}${token ? `?token=${token}` : ''}`)
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url)
)
}
return NextResponse.next()
}
/**
* Handles workspace invitation API endpoint access
*/
function handleWorkspaceInvitationAPI(
request: NextRequest,
hasActiveSession: boolean
): NextResponse | null {
if (!request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) {
return null
}
if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) {
const token = request.nextUrl.searchParams.get('token')
if (token) {
return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, request.url))
}
// Email verification is enforced by Better Auth (server-side). No cookie gating here.
return NextResponse.next()
}
// Allow access to invitation links
if (request.nextUrl.pathname.startsWith('/invite/')) {
if (
!hasActiveSession &&
!request.nextUrl.pathname.endsWith('/login') &&
!request.nextUrl.pathname.endsWith('/signup') &&
!request.nextUrl.search.includes('callbackUrl')
) {
const token = request.nextUrl.searchParams.get('token')
const inviteId = request.nextUrl.pathname.split('/').pop()
const callbackParam = encodeURIComponent(
`/invite/${inviteId}${token ? `?token=${token}` : ''}`
)
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url)
)
}
return NextResponse.next()
}
// Allow access to workspace invitation API endpoint
if (request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) {
if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) {
const token = request.nextUrl.searchParams.get('token')
if (token) {
return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, request.url))
}
}
return NextResponse.next()
}
return NextResponse.next()
}
/**
* Handles security filtering for suspicious user agents
*/
function handleSecurityFiltering(request: NextRequest): NextResponse | null {
const userAgent = request.headers.get('user-agent') || ''
// Check if this is a webhook endpoint that should be exempt from User-Agent validation
const isWebhookEndpoint = url.pathname.startsWith('/api/webhooks/trigger/')
const isWebhookEndpoint = request.nextUrl.pathname.startsWith('/api/webhooks/trigger/')
const isSuspicious = SUSPICIOUS_UA_PATTERNS.some((pattern) => pattern.test(userAgent))
// Block suspicious requests, but exempt webhook endpoints from User-Agent validation only
// Block suspicious requests, but exempt webhook endpoints from User-Agent validation
if (isSuspicious && !isWebhookEndpoint) {
logger.warn('Blocked suspicious request', {
userAgent,
@@ -158,6 +112,7 @@ export async function middleware(request: NextRequest) {
method: request.method,
pattern: SUSPICIOUS_UA_PATTERNS.find((pattern) => pattern.test(userAgent))?.toString(),
})
return new NextResponse(null, {
status: 403,
statusText: 'Forbidden',
@@ -173,10 +128,44 @@ export async function middleware(request: NextRequest) {
})
}
return null
}
export async function middleware(request: NextRequest) {
const url = request.nextUrl
const sessionCookie = getSessionCookie(request)
const hasActiveSession = !!sessionCookie
const redirect = handleRootPathRedirects(request, hasActiveSession)
if (redirect) return redirect
if (url.pathname === '/login' || url.pathname === '/signup') {
if (hasActiveSession) {
return NextResponse.redirect(new URL('/workspace', request.url))
}
return NextResponse.next()
}
if (url.pathname.startsWith('/workspace')) {
if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
if (invitationRedirect) return invitationRedirect
const workspaceInvitationRedirect = handleWorkspaceInvitationAPI(request, hasActiveSession)
if (workspaceInvitationRedirect) return workspaceInvitationRedirect
const securityBlock = handleSecurityFiltering(request)
if (securityBlock) return securityBlock
const response = NextResponse.next()
response.headers.set('Vary', 'User-Agent')
// Generate runtime CSP for main application routes that need dynamic environment variables
if (
url.pathname.startsWith('/workspace') ||
url.pathname.startsWith('/chat') ||
@@ -188,7 +177,6 @@ export async function middleware(request: NextRequest) {
return response
}
// Update matcher to include invitation routes and root path
export const config = {
matcher: [
'/', // Root path for self-hosted redirect logic

View File

@@ -194,16 +194,9 @@ export const useGeneralStore = create<GeneralStore>()(
// If parsing fails, continue to load from DB
}
}
// Skip loading if on a subdomain or chat path
if (
typeof window !== 'undefined' &&
(window.location.pathname.startsWith('/chat/') ||
(window.location.hostname !== 'sim.ai' &&
window.location.hostname !== 'localhost' &&
window.location.hostname !== '127.0.0.1' &&
!window.location.hostname.startsWith('www.')))
) {
logger.debug('Skipping settings load - on chat or subdomain page')
// Skip loading if on a chat path
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/chat/')) {
logger.debug('Skipping settings load - on chat page')
return
}
@@ -258,15 +251,8 @@ export const useGeneralStore = create<GeneralStore>()(
},
updateSetting: async (key, value) => {
if (
typeof window !== 'undefined' &&
(window.location.pathname.startsWith('/chat/') ||
(window.location.hostname !== 'sim.ai' &&
window.location.hostname !== 'localhost' &&
window.location.hostname !== '127.0.0.1' &&
!window.location.hostname.startsWith('www.')))
) {
logger.debug(`Skipping setting update for ${key} on chat or subdomain page`)
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/chat/')) {
logger.debug(`Skipping setting update for ${key} on chat page`)
return
}

View File

@@ -0,0 +1,3 @@
ALTER TABLE "chat" RENAME COLUMN "subdomain" TO "identifier";--> statement-breakpoint
DROP INDEX "subdomain_idx";--> statement-breakpoint
CREATE UNIQUE INDEX "identifier_idx" ON "chat" USING btree ("identifier");

File diff suppressed because it is too large Load Diff

View File

@@ -652,6 +652,13 @@
"when": 1758751182653,
"tag": "0093_medical_sentinel",
"breakpoints": true
},
{
"idx": 94,
"version": "7",
"when": 1758998206326,
"tag": "0094_perpetual_the_watchers",
"breakpoints": true
}
]
}

View File

@@ -633,7 +633,7 @@ export const chat = pgTable(
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
subdomain: text('subdomain').notNull(),
identifier: text('identifier').notNull(),
title: text('title').notNull(),
description: text('description'),
isActive: boolean('is_active').notNull().default(true),
@@ -652,8 +652,8 @@ export const chat = pgTable(
},
(table) => {
return {
// Ensure subdomains are unique
subdomainIdx: uniqueIndex('subdomain_idx').on(table.subdomain),
// Ensure identifiers are unique
identifierIdx: uniqueIndex('identifier_idx').on(table.identifier),
}
}
)