diff --git a/backend/src/voice/voice.controller.ts b/backend/src/voice/voice.controller.ts index 0d8d54d..1865d66 100644 --- a/backend/src/voice/voice.controller.ts +++ b/backend/src/voice/voice.controller.ts @@ -98,37 +98,75 @@ export class VoiceController { /** * TwiML for outbound calls from browser (Twilio Device) + * Twilio sends application/x-www-form-urlencoded data */ @Post('twiml/outbound') async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) { - const body = req.body as any; + // Parse body - Twilio sends URL-encoded form data + let body = req.body as any; + + // Handle case where body might be parsed as JSON key (URL-encoded string as key) + if (body && typeof body === 'object' && Object.keys(body).length === 1) { + const key = Object.keys(body)[0]; + if (key.startsWith('{') || key.includes('=')) { + try { + // Try parsing as JSON if it looks like JSON + if (key.startsWith('{')) { + body = JSON.parse(key); + } else { + // Parse as URL-encoded + const params = new URLSearchParams(key); + body = Object.fromEntries(params.entries()); + } + } catch (e) { + this.logger.warn(`Failed to re-parse body: ${e.message}`); + } + } + } + const to = body.To; - const from = body.From; + const from = body.From; // Format: "client:tenantId:userId" const callSid = body.CallSid; this.logger.log(`=== TwiML OUTBOUND REQUEST RECEIVED ===`); - this.logger.log(`CallSid: ${callSid}, Body From: ${from}, Body To: ${to}`); - this.logger.log(`Full body: ${JSON.stringify(body)}`); + this.logger.log(`CallSid: ${callSid}, From: ${from}, To: ${to}`); try { - // Extract tenant domain from Host header - const host = req.headers.host || ''; - const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co" - - this.logger.log(`Extracted tenant domain: ${tenantDomain}`); + // Extract tenant ID from the client identity + // Format: "client:tenantId:userId" + let tenantId: string | null = null; + if (from && from.startsWith('client:')) { + const parts = from.replace('client:', '').split(':'); + if (parts.length >= 2) { + tenantId = parts[0]; // First part is tenantId + this.logger.log(`Extracted tenantId from client identity: ${tenantId}`); + } + } + + if (!tenantId) { + this.logger.error(`Could not extract tenant from From: ${from}`); + throw new Error('Could not determine tenant from call'); + } // Look up tenant's Twilio phone number from config - let callerId = to; // Fallback (will cause error if not found) + let callerId: string | undefined; try { - // Get Twilio config to find the phone number - const { config } = await this.voiceService['getTwilioClient'](tenantDomain); + const { config } = await this.voiceService['getTwilioClient'](tenantId); callerId = config.phoneNumber; this.logger.log(`Retrieved Twilio phone number for tenant: ${callerId}`); } catch (error: any) { this.logger.error(`Failed to get Twilio config: ${error.message}`); + throw error; } - const dialNumber = to; + if (!callerId) { + throw new Error('No caller ID configured for tenant'); + } + + const dialNumber = to?.trim(); + if (!dialNumber) { + throw new Error('No destination number provided'); + } this.logger.log(`Using callerId: ${callerId}, dialNumber: ${dialNumber}`); @@ -145,10 +183,9 @@ export class VoiceController { } catch (error: any) { this.logger.error(`=== ERROR GENERATING TWIML ===`); this.logger.error(`Error: ${error.message}`); - this.logger.error(`Stack: ${error.stack}`); const errorTwiml = ` - An error occurred while processing your call. + An error occurred while processing your call. ${error.message} `; res.type('text/xml').send(errorTwiml); } @@ -156,13 +193,33 @@ export class VoiceController { /** * TwiML for inbound calls + * Twilio sends application/x-www-form-urlencoded data */ @Post('twiml/inbound') async inboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) { - const body = req.body as any; + // Parse body - Twilio sends URL-encoded form data + let body = req.body as any; + + // Handle case where body might be parsed incorrectly + if (body && typeof body === 'object' && Object.keys(body).length === 1) { + const key = Object.keys(body)[0]; + if (key.startsWith('{') || key.includes('=')) { + try { + if (key.startsWith('{')) { + body = JSON.parse(key); + } else { + const params = new URLSearchParams(key); + body = Object.fromEntries(params.entries()); + } + } catch (e) { + this.logger.warn(`Failed to re-parse body: ${e.message}`); + } + } + } + const callSid = body.CallSid; const fromNumber = body.From; - const toNumber = body.To; + const toNumber = body.To; // This is the Twilio phone number that was called this.logger.log(`\n\n╔════════════════════════════════════════╗`); this.logger.log(`║ === INBOUND CALL RECEIVED ===`); @@ -170,19 +227,28 @@ export class VoiceController { this.logger.log(`CallSid: ${callSid}`); this.logger.log(`From: ${fromNumber}`); this.logger.log(`To: ${toNumber}`); - this.logger.log(`Full body: ${JSON.stringify(body)}`); try { - // Extract tenant domain from Host header - const host = req.headers.host || ''; - const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co" + // Look up tenant by the Twilio phone number that was called + const tenantInfo = await this.voiceService.findTenantByPhoneNumber(toNumber); - this.logger.log(`Extracted tenant domain: ${tenantDomain}`); + if (!tenantInfo) { + this.logger.error(`No tenant found for phone number: ${toNumber}`); + const twiml = ` + + Sorry, this number is not configured. Please contact support. + +`; + return res.type('text/xml').send(twiml); + } + + const tenantId = tenantInfo.tenantId; + this.logger.log(`Found tenant: ${tenantId}`); // Get all connected users for this tenant - const connectedUsers = this.voiceGateway.getConnectedUsers(tenantDomain); + const connectedUsers = this.voiceGateway.getConnectedUsers(tenantId); - this.logger.log(`Connected users for tenant ${tenantDomain}: ${connectedUsers.length}`); + this.logger.log(`Connected users for tenant ${tenantId}: ${connectedUsers.length}`); if (connectedUsers.length > 0) { this.logger.log(`Connected user IDs: ${connectedUsers.join(', ')}`); } @@ -198,20 +264,22 @@ export class VoiceController { return res.type('text/xml').send(twiml); } - // Build TwiML to dial all connected clients with Media Streams for AI - const clientElements = connectedUsers.map(userId => ` ${userId}`).join('\n'); + // Build TwiML to dial all connected clients + // Client identity format is now: tenantId:userId + const clientElements = connectedUsers.map(userId => ` ${tenantId}:${userId}`).join('\n'); - // Use wss:// for secure WebSocket (Traefik handles HTTPS) + // Log the client identities being dialed + this.logger.log(`Client identities being dialed:`); + connectedUsers.forEach(userId => { + this.logger.log(` - ${tenantId}:${userId}`); + }); + + // Use wss:// for secure WebSocket + const host = req.headers.host || 'backend.routebox.co'; const streamUrl = `wss://${host}/api/voice/media-stream`; this.logger.log(`Stream URL: ${streamUrl}`); this.logger.log(`Dialing ${connectedUsers.length} client(s)...`); - this.logger.log(`Client IDs to dial: ${connectedUsers.join(', ')}`); - - // Verify we have client IDs in proper format - if (connectedUsers.length > 0) { - this.logger.log(`First Client ID format check: "${connectedUsers[0]}" (length: ${connectedUsers[0].length})`); - } // Notify connected users about incoming call via Socket.IO connectedUsers.forEach(userId => { @@ -219,7 +287,7 @@ export class VoiceController { callSid, fromNumber, toNumber, - tenantDomain, + tenantId, }); }); @@ -227,7 +295,7 @@ export class VoiceController { - + @@ -236,7 +304,7 @@ ${clientElements} `; - this.logger.log(`✓ Returning inbound TwiML with Media Streams - dialing ${connectedUsers.length} client(s)`); + this.logger.log(`✓ Returning inbound TwiML - dialing ${connectedUsers.length} client(s)`); this.logger.log(`Generated TwiML:\n${twiml}\n`); res.type('text/xml').send(twiml); } catch (error: any) { diff --git a/backend/src/voice/voice.gateway.ts b/backend/src/voice/voice.gateway.ts index 963e583..604f8e9 100644 --- a/backend/src/voice/voice.gateway.ts +++ b/backend/src/voice/voice.gateway.ts @@ -61,33 +61,41 @@ export class VoiceGateway const payload = await this.jwtService.verifyAsync(token); // Extract domain from origin header (e.g., http://tenant1.routebox.co:3001) - // The domains table stores just the subdomain part (e.g., "tenant1") const origin = client.handshake.headers.origin || client.handshake.headers.referer; - let domain = 'localhost'; + let subdomain = 'localhost'; if (origin) { try { const url = new URL(origin); - const hostname = url.hostname; // e.g., tenant1.routebox.co or localhost - - // Extract first part of subdomain as domain - // tenant1.routebox.co -> tenant1 - // localhost -> localhost - domain = hostname.split('.')[0]; + const hostname = url.hostname; + subdomain = hostname.split('.')[0]; } catch (error) { this.logger.warn(`Failed to parse origin: ${origin}`); } } - client.tenantId = domain; // Store the subdomain as tenantId + // Resolve the actual tenantId (UUID) from the subdomain + let tenantId: string | null = null; + try { + const tenant = await this.tenantDbService.getTenantByDomain(subdomain); + if (tenant) { + tenantId = tenant.id; + this.logger.log(`Resolved tenant ${subdomain} -> ${tenantId}`); + } + } catch (error) { + this.logger.warn(`Failed to resolve tenant for subdomain ${subdomain}: ${error.message}`); + } + + // Fall back to subdomain if tenant lookup fails + client.tenantId = tenantId || subdomain; client.userId = payload.sub; - client.tenantSlug = domain; // Same as subdomain + client.tenantSlug = subdomain; this.connectedUsers.set(client.userId, client); this.logger.log( - `✓ Client connected: ${client.id} (User: ${client.userId}, Domain: ${domain})`, + `✓ Client connected: ${client.id} (User: ${client.userId}, TenantId: ${client.tenantId}, Subdomain: ${subdomain})`, ); - this.logger.log(`Total connected users in ${domain}: ${this.getConnectedUsers(domain).length}`); + this.logger.log(`Total connected users in tenant ${client.tenantId}: ${this.getConnectedUsers(client.tenantId).length}`); // Send current call state if any active call const activeCallSid = this.activeCallsByUser.get(client.userId); @@ -303,13 +311,14 @@ export class VoiceGateway /** * Get connected users for a tenant + * @param tenantId - The tenant UUID to filter by */ - getConnectedUsers(tenantDomain?: string): string[] { + getConnectedUsers(tenantId?: string): string[] { const userIds: string[] = []; for (const [userId, socket] of this.connectedUsers.entries()) { - // If tenantDomain specified, filter by tenant - if (!tenantDomain || socket.tenantSlug === tenantDomain) { + // If tenantId specified, filter by tenant + if (!tenantId || socket.tenantId === tenantId) { userIds.push(userId); } } diff --git a/backend/src/voice/voice.service.ts b/backend/src/voice/voice.service.ts index 3cda137..a2eccf5 100644 --- a/backend/src/voice/voice.service.ts +++ b/backend/src/voice/voice.service.ts @@ -31,46 +31,46 @@ export class VoiceService { /** * Get Twilio client for a tenant */ - private async getTwilioClient(tenantIdOrDomain: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> { + private async getTwilioClient(tenantId: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> { // Check cache first - if (this.twilioClients.has(tenantIdOrDomain)) { + if (this.twilioClients.has(tenantId)) { const centralPrisma = getCentralPrisma(); - // Look up tenant by domain - const domainRecord = await centralPrisma.domain.findUnique({ - where: { domain: tenantIdOrDomain }, - include: { tenant: { select: { id: true, integrationsConfig: true } } }, + // Look up tenant by ID + const tenant = await centralPrisma.tenant.findUnique({ + where: { id: tenantId }, + select: { id: true, integrationsConfig: true }, }); - const config = this.getIntegrationConfig(domainRecord?.tenant?.integrationsConfig as any); + const config = this.getIntegrationConfig(tenant?.integrationsConfig as any); return { - client: this.twilioClients.get(tenantIdOrDomain), + client: this.twilioClients.get(tenantId), config: config.twilio, - tenantId: domainRecord.tenant.id + tenantId: tenant.id }; } // Fetch tenant integrations config const centralPrisma = getCentralPrisma(); - this.logger.log(`Looking up domain: ${tenantIdOrDomain}`); + this.logger.log(`Looking up tenant: ${tenantId}`); - const domainRecord = await centralPrisma.domain.findUnique({ - where: { domain: tenantIdOrDomain }, - include: { tenant: { select: { id: true, integrationsConfig: true } } }, + const tenant = await centralPrisma.tenant.findUnique({ + where: { id: tenantId }, + select: { id: true, integrationsConfig: true }, }); - this.logger.log(`Domain record found: ${!!domainRecord}, Tenant: ${!!domainRecord?.tenant}, Config: ${!!domainRecord?.tenant?.integrationsConfig}`); + this.logger.log(`Tenant found: ${!!tenant}, Config: ${!!tenant?.integrationsConfig}`); - if (!domainRecord?.tenant) { - throw new Error(`Domain ${tenantIdOrDomain} not found`); + if (!tenant) { + throw new Error(`Tenant ${tenantId} not found`); } - if (!domainRecord.tenant.integrationsConfig) { + if (!tenant.integrationsConfig) { throw new Error('Tenant integrations config not found. Please configure Twilio credentials in Settings > Integrations'); } - const config = this.getIntegrationConfig(domainRecord.tenant.integrationsConfig as any); + const config = this.getIntegrationConfig(tenant.integrationsConfig as any); this.logger.log(`Config decrypted: ${!!config.twilio}, AccountSid: ${config.twilio?.accountSid?.substring(0, 10)}..., AuthToken: ${config.twilio?.authToken?.substring(0, 10)}..., Phone: ${config.twilio?.phoneNumber}`); @@ -79,9 +79,9 @@ export class VoiceService { } const client = Twilio.default(config.twilio.accountSid, config.twilio.authToken); - this.twilioClients.set(tenantIdOrDomain, client); + this.twilioClients.set(tenantId, client); - return { client, config: config.twilio, tenantId: domainRecord.tenant.id }; + return { client, config: config.twilio, tenantId: tenant.id }; } /** @@ -105,22 +105,64 @@ export class VoiceService { return {}; } + /** + * Find tenant by their configured Twilio phone number + * Used for inbound call routing + */ + async findTenantByPhoneNumber(phoneNumber: string): Promise<{ tenantId: string; config: TwilioConfig } | null> { + const centralPrisma = getCentralPrisma(); + + // Normalize phone number (remove spaces, ensure + prefix for comparison) + const normalizedPhone = phoneNumber.replace(/\s+/g, '').replace(/^(\d)/, '+$1'); + + this.logger.log(`Looking up tenant by phone number: ${normalizedPhone}`); + + // Get all tenants with integrations config + const tenants = await centralPrisma.tenant.findMany({ + where: { + integrationsConfig: { not: null }, + }, + select: { id: true, integrationsConfig: true }, + }); + + for (const tenant of tenants) { + const config = this.getIntegrationConfig(tenant.integrationsConfig as any); + if (config.twilio?.phoneNumber) { + const tenantPhone = config.twilio.phoneNumber.replace(/\s+/g, '').replace(/^(\d)/, '+$1'); + if (tenantPhone === normalizedPhone) { + this.logger.log(`Found tenant ${tenant.id} for phone number ${normalizedPhone}`); + return { tenantId: tenant.id, config: config.twilio }; + } + } + } + + this.logger.warn(`No tenant found for phone number: ${normalizedPhone}`); + return null; + } + /** * Generate Twilio access token for browser Voice SDK */ - async generateAccessToken(tenantDomain: string, userId: string): Promise { - const { config, tenantId } = await this.getTwilioClient(tenantDomain); + async generateAccessToken(tenantId: string, userId: string): Promise { + const { config, tenantId: resolvedTenantId } = await this.getTwilioClient(tenantId); if (!config.accountSid || !config.apiKey || !config.apiSecret) { throw new Error('Twilio API credentials not configured. Please add API Key and Secret in Settings > Integrations'); } + // Include tenantId in the identity so we can extract it in TwiML webhooks + // Format: tenantId:userId + const identity = `${resolvedTenantId}:${userId}`; + + this.logger.log(`Generating access token with identity: ${identity}`); + this.logger.log(` Input tenantId: ${tenantId}, Resolved tenantId: ${resolvedTenantId}, userId: ${userId}`); + // Create an access token const token = new AccessToken( config.accountSid, config.apiKey, config.apiSecret, - { identity: userId, ttl: 3600 } // 1 hour expiry + { identity, ttl: 3600 } // 1 hour expiry ); // Create a Voice grant diff --git a/frontend/composables/useSoftphone.ts b/frontend/composables/useSoftphone.ts index bf82502..751921a 100644 --- a/frontend/composables/useSoftphone.ts +++ b/frontend/composables/useSoftphone.ts @@ -1,4 +1,4 @@ -import { ref, computed, onMounted, onUnmounted, shallowRef } from 'vue'; +import { ref, computed, onMounted, onUnmounted, shallowRef, watch } from 'vue'; import { io, Socket } from 'socket.io-client'; import { Device, Call as TwilioCall } from '@twilio/voice-sdk'; import { useAuth } from './useAuth'; @@ -44,17 +44,6 @@ const volume = ref(100); export function useSoftphone() { const auth = useAuth(); - // Get token and tenantId from localStorage - const getToken = () => { - if (typeof window === 'undefined') return null; - return localStorage.getItem('token'); - }; - - const getTenantId = () => { - if (typeof window === 'undefined') return null; - return localStorage.getItem('tenantId'); - }; - // Computed properties const isInCall = computed(() => currentCall.value !== null); const hasIncomingCall = computed(() => incomingCall.value !== null); @@ -93,6 +82,12 @@ export function useSoftphone() { * Initialize Twilio Device */ const initializeTwilioDevice = async () => { + // Prevent re-initialization if device already exists and is registered + if (twilioDevice.value) { + console.log('Twilio Device already exists, skipping initialization'); + return; + } + try { // First, explicitly request microphone permission const hasPermission = await requestMicrophonePermission(); @@ -107,12 +102,14 @@ export function useSoftphone() { // Log the token payload to see what identity is being used try { const tokenPayload = JSON.parse(atob(token.split('.')[1])); + console.log('📱 Twilio Device identity:', tokenPayload.grants?.identity || tokenPayload.sub); } catch (e) { console.log('Could not parse token payload'); } + console.log('📱 Creating new Twilio Device...'); twilioDevice.value = new Device(token, { - logLevel: 3, + logLevel: 1, // Reduce log level (1 = errors only, 3 = debug) codecPreferences: ['opus', 'pcmu'], enableImprovedSignalingErrorPrecision: true, edge: 'ashburn', @@ -120,10 +117,12 @@ export function useSoftphone() { // Device events twilioDevice.value.on('registered', () => { + console.log('✅ Twilio Device registered successfully'); toast.success('Softphone ready'); }); twilioDevice.value.on('unregistered', () => { + console.log('📱 Twilio Device unregistered'); }); twilioDevice.value.on('error', (error) => { @@ -132,6 +131,11 @@ export function useSoftphone() { }); twilioDevice.value.on('incoming', (call: TwilioCall) => { + console.log('📞 Twilio Device incoming call event!', { + callSid: call.parameters.CallSid, + from: call.parameters.From, + to: call.parameters.To, + }); twilioCall.value = call; // Update state @@ -210,35 +214,54 @@ export function useSoftphone() { /** * Initialize WebSocket connection */ - const connect = () => { - const token = getToken(); - - if (socket.value?.connected || !token) { + const connect = async () => { + // Guard against multiple connection attempts + if (socket.value) { + console.log('Softphone: Socket already exists, skipping connection'); return; } - // Use same pattern as useApi to preserve subdomain for multi-tenant - const getBackendUrl = () => { - if (typeof window !== 'undefined') { - const currentHost = window.location.hostname; - const protocol = window.location.protocol; - return `${protocol}//${currentHost}`; + // Check if user is authenticated + if (!auth.isAuthenticated.value) { + // Try to verify authentication first + const isValid = await auth.checkAuth(); + if (!isValid) { + console.log('Softphone: User not authenticated, skipping connection'); + return; } - return 'http://localhost:3000'; - }; + } - // Connect to /voice namespace with proper auth header - socket.value = io(`${getBackendUrl()}/voice`, { - auth: { - token: token, - }, - transports: ['websocket', 'polling'], - reconnection: true, - reconnectionDelay: 1000, - reconnectionDelayMax: 5000, - reconnectionAttempts: 5, - query: {}, // Explicitly set empty query to prevent token leaking - }); + try { + // Get WebSocket token from BFF (this retrieves the token from HTTP-only cookie server-side) + const wsAuth = await $fetch('/api/auth/ws-token'); + + if (!wsAuth.token) { + console.log('Softphone: No WebSocket token available'); + return; + } + + // Use same pattern as useApi to preserve subdomain for multi-tenant + const getBackendUrl = () => { + if (typeof window !== 'undefined') { + const currentHost = window.location.hostname; + const protocol = window.location.protocol; + return `${protocol}//${currentHost}`; + } + return 'http://localhost:3000'; + }; + + // Connect to /voice namespace with proper auth header + socket.value = io(`${getBackendUrl()}/voice`, { + auth: { + token: wsAuth.token, + }, + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + reconnectionAttempts: 5, + query: {}, // Explicitly set empty query to prevent token leaking + }); // Connection events socket.value.on('connect', () => { @@ -279,6 +302,10 @@ export function useSoftphone() { socket.value.on('ai:action', handleAiAction); isInitialized.value = true; + } catch (error: any) { + console.error('Softphone: Failed to connect:', error); + toast.error('Failed to initialize voice service'); + } }; /** @@ -569,14 +596,25 @@ export function useSoftphone() { } }; - // Auto-connect on mount if token is available - onMounted(() => { - if (getToken() && !isInitialized.value) { + // Only set up auto-connect and watchers once (not for every component that uses this composable) + // Use a module-level flag to track if watchers are already set up + if (process.client && !isInitialized.value && !socket.value) { + // Auto-connect if authenticated + if (auth.isAuthenticated.value) { connect(); } - }); - // Cleanup on unmount + // Watch for authentication changes to connect/disconnect + watch(() => auth.isAuthenticated.value, async (isAuth) => { + if (isAuth && !isInitialized.value && !socket.value) { + await connect(); + } else if (!isAuth && isInitialized.value) { + disconnect(); + } + }); + } + + // Cleanup on unmount - only stop ringtone, don't disconnect shared socket onUnmounted(() => { stopRingtone(); }); diff --git a/frontend/server/api/[...path].ts b/frontend/server/api/[...path].ts index 9312408..fc4a26e 100644 --- a/frontend/server/api/[...path].ts +++ b/frontend/server/api/[...path].ts @@ -22,6 +22,8 @@ export default defineEventHandler(async (event) => { const queryString = new URLSearchParams(query as Record).toString() const fullUrl = `${backendUrl}/api/${path}${queryString ? `?${queryString}` : ''}` + console.log(`[BFF Proxy] ${method} ${fullUrl} (subdomain: ${subdomain}, hasToken: ${!!token})`) + // Build headers to forward const headers: Record = { 'Content-Type': getHeader(event, 'content-type') || 'application/json', @@ -74,8 +76,14 @@ export default defineEventHandler(async (event) => { errorMessage = errorData.message || errorMessage } catch { // Response wasn't JSON + try { + const text = await response.text() + console.error(`[BFF Proxy] Backend error (non-JSON): ${text}`) + } catch {} } + console.error(`[BFF Proxy] Backend returned ${response.status}: ${errorMessage}`, errorData) + throw createError({ statusCode: response.status, statusMessage: errorMessage, diff --git a/frontend/server/api/auth/ws-token.get.ts b/frontend/server/api/auth/ws-token.get.ts new file mode 100644 index 0000000..ce026c2 --- /dev/null +++ b/frontend/server/api/auth/ws-token.get.ts @@ -0,0 +1,26 @@ +import { defineEventHandler, createError } from 'h3' +import { getSubdomainFromRequest } from '~/server/utils/tenant' +import { getSessionToken } from '~/server/utils/session' + +/** + * Get a short-lived token for WebSocket authentication + * This is needed because socket.io cannot use HTTP-only cookies directly + */ +export default defineEventHandler(async (event) => { + const subdomain = getSubdomainFromRequest(event) + const token = getSessionToken(event) + + if (!token) { + throw createError({ + statusCode: 401, + statusMessage: 'Not authenticated', + }) + } + + // Return the token for WebSocket use + // The token is already validated by being in the HTTP-only cookie + return { + token, + subdomain, + } +})