55 * Forwards all messages (JSON and binary) bidirectionally between client and Deepgram.
66 *
77 * Routes:
8- * WS /api/voice-agent - WebSocket proxy to Deepgram Agent API
9- * GET /api/metadata - Project metadata from deepgram.toml
8+ * GET /api/session - Issue JWT session token
9+ * GET /api/metadata - Project metadata from deepgram.toml
10+ * WS /api/voice-agent - WebSocket proxy to Deepgram Agent API (auth required)
1011 */
1112
1213const { WebSocketServer, WebSocket } = require ( 'ws' ) ;
1314const express = require ( 'express' ) ;
1415const { createServer } = require ( 'http' ) ;
1516const cors = require ( 'cors' ) ;
17+ const crypto = require ( 'crypto' ) ;
18+ const jwt = require ( 'jsonwebtoken' ) ;
1619require ( 'dotenv' ) . config ( ) ;
1720const path = require ( 'path' ) ;
1821const fs = require ( 'fs' ) ;
1922const toml = require ( 'toml' ) ;
20- // Native __dirname support in CommonJS
23+
24+ // Validate required environment variables
25+ if ( ! process . env . DEEPGRAM_API_KEY ) {
26+ console . error ( 'ERROR: DEEPGRAM_API_KEY environment variable is required' ) ;
27+ console . error ( 'Please copy sample.env to .env and add your API key' ) ;
28+ process . exit ( 1 ) ;
29+ }
2130
2231// Configuration
2332const CONFIG = {
@@ -27,24 +36,130 @@ const CONFIG = {
2736 host : process . env . HOST || '0.0.0.0' ,
2837} ;
2938
30- // Validate required environment variables
31- if ( ! CONFIG . deepgramApiKey ) {
32- console . error ( 'Error: DEEPGRAM_API_KEY not found in environment variables' ) ;
33- process . exit ( 1 ) ;
39+ // ============================================================================
40+ // SESSION AUTH - JWT tokens with page nonce for production security
41+ // ============================================================================
42+
43+ const SESSION_SECRET =
44+ process . env . SESSION_SECRET || crypto . randomBytes ( 32 ) . toString ( 'hex' ) ;
45+ const REQUIRE_NONCE = ! ! process . env . SESSION_SECRET ;
46+
47+ const sessionNonces = new Map ( ) ;
48+ const NONCE_TTL_MS = 5 * 60 * 1000 ;
49+ const JWT_EXPIRY = '1h' ;
50+
51+ function generateNonce ( ) {
52+ const nonce = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
53+ sessionNonces . set ( nonce , Date . now ( ) + NONCE_TTL_MS ) ;
54+ return nonce ;
55+ }
56+
57+ function consumeNonce ( nonce ) {
58+ const expiry = sessionNonces . get ( nonce ) ;
59+ if ( ! expiry ) return false ;
60+ sessionNonces . delete ( nonce ) ;
61+ return Date . now ( ) < expiry ;
62+ }
63+
64+ setInterval ( ( ) => {
65+ const now = Date . now ( ) ;
66+ for ( const [ nonce , expiry ] of sessionNonces ) {
67+ if ( now >= expiry ) sessionNonces . delete ( nonce ) ;
68+ }
69+ } , 60_000 ) ;
70+
71+ let indexHtmlTemplate = null ;
72+ try {
73+ indexHtmlTemplate = fs . readFileSync (
74+ path . join ( __dirname , 'frontend' , 'dist' , 'index.html' ) ,
75+ 'utf-8'
76+ ) ;
77+ } catch {
78+ // No built frontend (dev mode)
79+ }
80+
81+ /**
82+ * Validates JWT from WebSocket subprotocol: access_token.<jwt>
83+ * Returns the token string if valid, null if invalid.
84+ */
85+ function validateWsToken ( protocols ) {
86+ if ( ! protocols ) return null ;
87+ const list = Array . isArray ( protocols ) ? protocols : protocols . split ( ',' ) . map ( s => s . trim ( ) ) ;
88+ const tokenProto = list . find ( p => p . startsWith ( 'access_token.' ) ) ;
89+ if ( ! tokenProto ) return null ;
90+ const token = tokenProto . slice ( 'access_token.' . length ) ;
91+ try {
92+ jwt . verify ( token , SESSION_SECRET ) ;
93+ return tokenProto ;
94+ } catch {
95+ return null ;
96+ }
3497}
3598
36- // Initialize Express
3799const app = express ( ) ;
38- app . use ( express . json ( ) ) ;
100+ const server = createServer ( app ) ;
101+ const wss = new WebSocketServer ( {
102+ noServer : true ,
103+ handleProtocols : ( protocols ) => {
104+ // Accept the access_token.* subprotocol so the client sees it echoed back
105+ for ( const proto of protocols ) {
106+ if ( proto . startsWith ( 'access_token.' ) ) return proto ;
107+ }
108+ return false ;
109+ } ,
110+ } ) ;
111+
112+ // Track all active WebSocket connections for graceful shutdown
113+ const activeConnections = new Set ( ) ;
39114
40- // Enable CORS (wildcard is safe -- same-origin via Vite proxy / Caddy in production)
115+ // Enable CORS
41116app . use ( cors ( ) ) ;
42117
43118// ============================================================================
44- // API ROUTES
119+ // SESSION ROUTES - Auth endpoints (unprotected)
45120// ============================================================================
46121
47- // Metadata endpoint - required for standardization compliance
122+ /**
123+ * GET / — Serve index.html with injected session nonce (production only)
124+ */
125+ app . get ( '/' , ( req , res ) => {
126+ if ( ! indexHtmlTemplate ) {
127+ return res . status ( 404 ) . send ( 'Frontend not built. Run make build first.' ) ;
128+ }
129+ const nonce = generateNonce ( ) ;
130+ const html = indexHtmlTemplate . replace (
131+ '</head>' ,
132+ `<meta name="session-nonce" content="${ nonce } ">\n</head>`
133+ ) ;
134+ res . type ( 'html' ) . send ( html ) ;
135+ } ) ;
136+
137+ /**
138+ * GET /api/session — Issues a JWT. In production, requires valid nonce.
139+ */
140+ app . get ( '/api/session' , ( req , res ) => {
141+ if ( REQUIRE_NONCE ) {
142+ const nonce = req . headers [ 'x-session-nonce' ] ;
143+ if ( ! nonce || ! consumeNonce ( nonce ) ) {
144+ return res . status ( 403 ) . json ( {
145+ error : {
146+ type : 'AuthenticationError' ,
147+ code : 'INVALID_NONCE' ,
148+ message : 'Valid session nonce required. Please refresh the page.' ,
149+ } ,
150+ } ) ;
151+ }
152+ }
153+
154+ const token = jwt . sign ( { iat : Math . floor ( Date . now ( ) / 1000 ) } , SESSION_SECRET , {
155+ expiresIn : JWT_EXPIRY ,
156+ } ) ;
157+ res . json ( { token } ) ;
158+ } ) ;
159+
160+ /**
161+ * Metadata endpoint - required for standardization compliance
162+ */
48163app . get ( '/api/metadata' , ( req , res ) => {
49164 try {
50165 const tomlPath = path . join ( __dirname , 'deepgram.toml' ) ;
@@ -68,40 +183,20 @@ app.get('/api/metadata', (req, res) => {
68183 }
69184} ) ;
70185
71- // Create HTTP server
72- const server = createServer ( app ) ;
73-
74- // Create WebSocket server for agent endpoint
75- const wss = new WebSocketServer ( {
76- server,
77- path : '/api/voice-agent'
78- } ) ;
79-
80- // Handle WebSocket connections - simple pass-through proxy
186+ /**
187+ * WebSocket proxy handler
188+ * Forwards all messages bidirectionally between client and Deepgram Agent API
189+ */
81190wss . on ( 'connection' , async ( clientWs , request ) => {
82191 console . log ( 'Client connected to /api/voice-agent' ) ;
192+ activeConnections . add ( clientWs ) ;
83193
84194 try {
85- // Extract API key from Sec-WebSocket-Protocol header or use server's key
86- const protocol = request . headers [ 'sec-websocket-protocol' ] ;
87- const apiKey = protocol || CONFIG . deepgramApiKey ;
88-
89- if ( ! apiKey ) {
90- clientWs . send ( JSON . stringify ( {
91- type : 'Error' ,
92- description : 'Missing API key' ,
93- code : 'MISSING_API_KEY'
94- } ) ) ;
95- clientWs . close ( ) ;
96- return ;
97- }
98-
99- // Create raw WebSocket connection to Deepgram Agent API
100- // Send API key via Authorization header
195+ // Always use server-side API key for Deepgram connection
101196 console . log ( 'Initiating Deepgram connection...' ) ;
102197 const deepgramWs = new WebSocket ( CONFIG . deepgramAgentUrl , {
103198 headers : {
104- 'Authorization' : `Token ${ apiKey } `
199+ 'Authorization' : `Token ${ CONFIG . deepgramApiKey } `
105200 }
106201 } ) ;
107202
@@ -152,6 +247,7 @@ wss.on('connection', async (clientWs, request) => {
152247 if ( deepgramWs . readyState === WebSocket . OPEN ) {
153248 deepgramWs . close ( ) ;
154249 }
250+ activeConnections . delete ( clientWs ) ;
155251 } ) ;
156252
157253 // Handle client errors
@@ -175,44 +271,95 @@ wss.on('connection', async (clientWs, request) => {
175271 }
176272} ) ;
177273
178- // Start the server
179- server . listen ( CONFIG . port , CONFIG . host , ( ) => {
180- console . log ( '' ) ;
181- console . log ( '======================================================================' ) ;
182- console . log ( `🚀 Backend API Server running at http://localhost:${ CONFIG . port } ` ) ;
183- console . log ( `📡 WS /api/voice-agent` ) ;
184- console . log ( `📡 GET /api/metadata` ) ;
185- console . log ( '======================================================================' ) ;
186- console . log ( '' ) ;
187- } ) ;
274+ /**
275+ * Handle WebSocket upgrade requests for /api/voice-agent.
276+ * Validates JWT from access_token.<jwt> subprotocol before upgrading.
277+ */
278+ server . on ( 'upgrade' , ( request , socket , head ) => {
279+ const pathname = new URL ( request . url , 'http://localhost' ) . pathname ;
188280
189- // Graceful shutdown
190- function shutdown ( ) {
191- console . log ( '\nShutting down server...' ) ;
281+ console . log ( `WebSocket upgrade request for: ${ pathname } ` ) ;
192282
193- wss . clients . forEach ( ( client ) => {
194- try {
195- client . close ( ) ;
196- } catch ( err ) {
197- console . error ( 'Error closing client:' , err ) ;
283+ if ( pathname === '/api/voice-agent' ) {
284+ // Validate JWT from subprotocol
285+ const protocols = request . headers [ 'sec-websocket-protocol' ] ;
286+ const validProto = validateWsToken ( protocols ) ;
287+ if ( ! validProto ) {
288+ console . log ( 'WebSocket auth failed: invalid or missing token' ) ;
289+ socket . write ( 'HTTP/1.1 401 Unauthorized\r\n\r\n' ) ;
290+ socket . destroy ( ) ;
291+ return ;
198292 }
199- } ) ;
200293
294+ console . log ( 'Backend handling /api/voice-agent WebSocket (authenticated)' ) ;
295+ wss . handleUpgrade ( request , socket , head , ( ws ) => {
296+ wss . emit ( 'connection' , ws , request ) ;
297+ } ) ;
298+ return ;
299+ }
300+
301+ // Unknown WebSocket path - reject
302+ console . log ( `Unknown WebSocket path: ${ pathname } ` ) ;
303+ socket . destroy ( ) ;
304+ } ) ;
305+
306+ /**
307+ * Graceful shutdown handler
308+ */
309+ function gracefulShutdown ( signal ) {
310+ console . log ( `\n${ signal } signal received: starting graceful shutdown...` ) ;
311+
312+ // Stop accepting new connections
201313 wss . close ( ( ) => {
202- console . log ( 'WebSocket server closed' ) ;
314+ console . log ( 'WebSocket server closed to new connections' ) ;
315+ } ) ;
316+
317+ // Close all active WebSocket connections
318+ console . log ( `Closing ${ activeConnections . size } active WebSocket connection(s)...` ) ;
319+ activeConnections . forEach ( ( ws ) => {
320+ try {
321+ ws . close ( 1001 , 'Server shutting down' ) ;
322+ } catch ( error ) {
323+ console . error ( 'Error closing WebSocket:' , error ) ;
324+ }
203325 } ) ;
204326
327+ // Close the HTTP server
205328 server . close ( ( ) => {
206329 console . log ( 'HTTP server closed' ) ;
330+ console . log ( 'Shutdown complete' ) ;
207331 process . exit ( 0 ) ;
208332 } ) ;
209333
210- // Force exit after 5 seconds
334+ // Force shutdown after 10 seconds if graceful shutdown fails
211335 setTimeout ( ( ) => {
212- console . error ( 'Force closing ' ) ;
336+ console . error ( 'Could not close connections in time, forcefully shutting down ' ) ;
213337 process . exit ( 1 ) ;
214- } , 5000 ) ;
338+ } , 10000 ) ;
215339}
216340
217- process . on ( 'SIGINT' , shutdown ) ;
218- process . on ( 'SIGTERM' , shutdown ) ;
341+ // Handle shutdown signals
342+ process . on ( 'SIGTERM' , ( ) => gracefulShutdown ( 'SIGTERM' ) ) ;
343+ process . on ( 'SIGINT' , ( ) => gracefulShutdown ( 'SIGINT' ) ) ;
344+
345+ // Handle uncaught errors
346+ process . on ( 'uncaughtException' , ( error ) => {
347+ console . error ( 'Uncaught Exception:' , error ) ;
348+ gracefulShutdown ( 'UNCAUGHT_EXCEPTION' ) ;
349+ } ) ;
350+
351+ process . on ( 'unhandledRejection' , ( reason , promise ) => {
352+ console . error ( 'Unhandled Rejection at:' , promise , 'reason:' , reason ) ;
353+ gracefulShutdown ( 'UNHANDLED_REJECTION' ) ;
354+ } ) ;
355+
356+ // Start server
357+ server . listen ( CONFIG . port , CONFIG . host , ( ) => {
358+ console . log ( "\n" + "=" . repeat ( 70 ) ) ;
359+ console . log ( `🚀 Backend API Server running at http://localhost:${ CONFIG . port } ` ) ;
360+ console . log ( "" ) ;
361+ console . log ( `📡 GET /api/session${ REQUIRE_NONCE ? ' (nonce required)' : '' } ` ) ;
362+ console . log ( `📡 WS /api/voice-agent (auth required)` ) ;
363+ console . log ( `📡 GET /api/metadata` ) ;
364+ console . log ( "=" . repeat ( 70 ) + "\n" ) ;
365+ } ) ;
0 commit comments