mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
improvement(chat): deployed chat no longer uses subdomains, uses sim.ai/chat/[identifier]
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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),
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
6
apps/sim/app/chat/[identifier]/page.tsx
Normal file
6
apps/sim/app/chat/[identifier]/page.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
3
packages/db/migrations/0094_perpetual_the_watchers.sql
Normal file
3
packages/db/migrations/0094_perpetual_the_watchers.sql
Normal 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");
|
||||
6818
packages/db/migrations/meta/0094_snapshot.json
Normal file
6818
packages/db/migrations/meta/0094_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user