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

This commit is contained in:
waleed
2025-09-27 11:37:28 -07:00
parent bcc75376e5
commit c68c052601
30 changed files with 7280 additions and 715 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

@@ -136,9 +136,9 @@ describe('Chat Subdomain API Route', () => {
describe('GET endpoint', () => {
it('should return chat info for a valid subdomain', 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 })
@@ -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', () => ({
@@ -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',
@@ -144,11 +144,11 @@ describe('Chat Edit API Route', () => {
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,
})
})
@@ -201,7 +201,7 @@ describe('Chat Edit API Route', () => {
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
identifier: 'test-chat',
title: 'Test Chat',
authType: 'public',
}
@@ -219,12 +219,12 @@ describe('Chat Edit API Route', () => {
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', {
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 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,
@@ -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',
}

View File

@@ -18,10 +18,10 @@ 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(),
@@ -71,7 +71,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
const baseDomain = getEmailDomain()
const protocol = isDev ? 'http' : 'https'
const chatUrl = `${protocol}://${chatRecord.subdomain}.${baseDomain}`
const chatUrl = `${protocol}://${baseDomain}/chat/${chatRecord.identifier}`
const result = {
...safeData,
@@ -117,7 +117,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
// Extract validated data
const {
workflowId,
subdomain,
identifier,
title,
description,
customizations,
@@ -127,16 +127,16 @@ 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
// Check if identifier is changing and if it's available
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)
}
}
@@ -165,7 +165,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
// 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
@@ -213,11 +213,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
// 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`)

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

@@ -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,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

@@ -17,7 +17,8 @@ export function ConditionalThemeProvider({ children, ...props }: ThemeProviderPr
pathname.startsWith('/privacy') ||
pathname.startsWith('/invite') ||
pathname.startsWith('/verify') ||
pathname.startsWith('/changelog')
pathname.startsWith('/changelog') ||
pathname.startsWith('/chat')
? 'light'
: undefined

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' ||
@@ -132,7 +132,7 @@ export function ChatDeploy({
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)
}
@@ -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 subdomain from path-based URL format (e.g., sim.ai/chat/subdomain)
const pathParts = url.pathname.split('/')
const subdomainPart = pathParts[2] || '' // /chat/subdomain
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}
</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: {
@@ -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

@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
export function useIdentifierValidation(
identifier: string,
originalIdentifier?: string,
isEditingExisting?: boolean
) {
const [error, setError] = useState<string | null>(null)
const [isValid, setIsValid] = useState(false)
useEffect(() => {
// Reset states immediately when identifier changes
setError(null)
setIsValid(false)
// Skip validation if empty
if (!identifier.trim()) {
return
}
// Skip validation if same as original (existing deployment)
if (originalIdentifier && identifier === originalIdentifier) {
setIsValid(true)
return
}
// 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 && !originalIdentifier) {
setIsValid(true)
return
}
// Validate format - only client-side validation needed now
if (!/^[a-z0-9-]+$/.test(identifier)) {
setError('Identifier can only contain lowercase letters, numbers, and hyphens')
return
}
// If format is valid, mark as valid
setIsValid(true)
}, [identifier, originalIdentifier, isEditingExisting])
// No longer need isChecking since we're not doing async validation
return { isChecking: false, error, isValid }
}

View File

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

View File

@@ -13,143 +13,178 @@ 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
]
] as const
const BASE_DOMAIN = getBaseDomain()
export async function middleware(request: NextRequest) {
// Check for active session
const sessionCookie = getSessionCookie(request)
const hasActiveSession = !!sessionCookie
const OPERATIONAL_SUBDOMAINS = new Set([
'telemetry',
'docs',
'api',
'admin',
'www',
'app',
'auth',
'blog',
'help',
'support',
'qa',
'agent',
'staging',
])
interface SubdomainAnalysis {
isCustomDomain: boolean
subdomain: string | null
}
/**
* Analyzes the hostname to determine if it's a custom subdomain and extracts the subdomain part
*/
function analyzeSubdomain(hostname: string): SubdomainAnalysis {
// Standard check for non-base domains
if (hostname === BASE_DOMAIN || hostname.startsWith('www.')) {
return { isCustomDomain: false, subdomain: null }
}
// 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: ["sim", "ai"] -> "sim.ai"
: BASE_DOMAIN
// Check if hostname is under the same root domain
if (!hostname.includes(rootDomain)) {
return { isCustomDomain: false, subdomain: null }
}
// 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
const isCustomDomain = hostParts.length > basePartCount || hostname !== BASE_DOMAIN
return {
isCustomDomain,
subdomain: isCustomDomain ? hostname.split('.')[0] : null,
}
}
/**
* Handles chat subdomain redirects for backward compatibility
*/
function handleChatSubdomainRedirect(request: NextRequest, subdomain: string): 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))
// Skip redirect for API endpoints
if (url.pathname.startsWith('/api/chat/') || url.pathname.startsWith('/api/proxy/')) {
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))
}
// Build redirect URL
const baseDomain = isDev ? `localhost:${url.port || '3000'}` : getBaseDomain()
const protocol = isDev ? 'http' : 'https'
const redirectUrl = `${protocol}://${baseDomain}/chat/${subdomain}${url.pathname}${url.search}`
// For root path, redirect authenticated users to workspace
if (hasActiveSession && url.pathname === '/') {
return NextResponse.redirect(new URL('/workspace', request.url))
}
logger.info(`Redirecting subdomain request from ${request.headers.get('host')} to ${redirectUrl}`)
return NextResponse.redirect(redirectUrl, 301) // Permanent redirect
}
/**
* Handles authentication-based redirects for root paths
*/
function handleRootPathRedirects(
request: NextRequest,
hasActiveSession: boolean
): NextResponse | null {
const url = request.nextUrl
if (url.pathname !== '/' && url.pathname !== '/homepage') {
return null
}
// 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 +193,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,6 +209,59 @@ export async function middleware(request: NextRequest) {
})
}
return null
}
export async function middleware(request: NextRequest) {
const url = request.nextUrl
const hostname = request.headers.get('host') || ''
const sessionCookie = getSessionCookie(request)
const hasActiveSession = !!sessionCookie
const { isCustomDomain, subdomain } = analyzeSubdomain(hostname)
// Handle chat subdomains - redirect to path-based URLs for backward compatibility
if (subdomain && isCustomDomain && !OPERATIONAL_SUBDOMAINS.has(subdomain)) {
const redirect = handleChatSubdomainRedirect(request, subdomain)
if (redirect) return redirect
}
// Handle root path redirects based on session status and hosting type
if (!isCustomDomain) {
const redirect = handleRootPathRedirects(request, hasActiveSession)
if (redirect) return redirect
}
// Handle login/signup pages - redirect authenticated users to workspace
if (url.pathname === '/login' || url.pathname === '/signup') {
if (hasActiveSession) {
return NextResponse.redirect(new URL('/workspace', request.url))
}
return NextResponse.next()
}
// Handle protected routes that require authentication
if (url.pathname.startsWith('/workspace')) {
if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url))
}
// Email verification is enforced by Better Auth (server-side). No cookie gating here.
return NextResponse.next()
}
// Handle invitation links
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
if (invitationRedirect) return invitationRedirect
// Handle workspace invitation API endpoints
const workspaceInvitationRedirect = handleWorkspaceInvitationAPI(request, hasActiveSession)
if (workspaceInvitationRedirect) return workspaceInvitationRedirect
// Handle security filtering for suspicious requests
const securityBlock = handleSecurityFiltering(request)
if (securityBlock) return securityBlock
const response = NextResponse.next()
response.headers.set('Vary', 'User-Agent')

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),
}
}
)