99 * - Contract-compliant API endpoint: POST /api/text-to-speech
1010 * - Accepts text in body and model as query parameter
1111 * - Returns binary audio data (application/octet-stream)
12- * - Proxies to Vite dev server in development
13- * - Serves static frontend in production
12+ * - CORS enabled for frontend communication
13+ * - JWT session auth with page nonce (production only)
14+ * - Pure API server (frontend served separately)
1415 */
1516
1617require ( "dotenv" ) . config ( ) ;
1718
1819const { createClient } = require ( "@deepgram/sdk" ) ;
1920const cors = require ( "cors" ) ;
21+ const crypto = require ( "crypto" ) ;
2022const express = require ( "express" ) ;
23+ const fs = require ( "fs" ) ;
24+ const jwt = require ( "jsonwebtoken" ) ;
2125const path = require ( "path" ) ;
2226
2327// ============================================================================
@@ -39,6 +43,105 @@ const CONFIG = {
3943 host : process . env . HOST || "0.0.0.0" ,
4044} ;
4145
46+ // ============================================================================
47+ // SESSION AUTH - JWT tokens with page nonce for production security
48+ // ============================================================================
49+
50+ /**
51+ * Session secret for signing JWTs. When set (production/Fly.io), nonce
52+ * validation is enforced. When unset (local dev), tokens are issued freely.
53+ */
54+ const SESSION_SECRET =
55+ process . env . SESSION_SECRET || crypto . randomBytes ( 32 ) . toString ( "hex" ) ;
56+ const REQUIRE_NONCE = ! ! process . env . SESSION_SECRET ;
57+
58+ /** In-memory nonce store: nonce → expiry timestamp */
59+ const sessionNonces = new Map ( ) ;
60+
61+ /** Nonce expiry time (5 minutes) */
62+ const NONCE_TTL_MS = 5 * 60 * 1000 ;
63+
64+ /** JWT expiry time (1 hour) */
65+ const JWT_EXPIRY = "1h" ;
66+
67+ /**
68+ * Generates a single-use nonce and stores it with an expiry
69+ * @returns {string } The generated nonce
70+ */
71+ function generateNonce ( ) {
72+ const nonce = crypto . randomBytes ( 16 ) . toString ( "hex" ) ;
73+ sessionNonces . set ( nonce , Date . now ( ) + NONCE_TTL_MS ) ;
74+ return nonce ;
75+ }
76+
77+ /**
78+ * Validates and consumes a nonce (single-use)
79+ * @param {string } nonce - The nonce to validate
80+ * @returns {boolean } True if the nonce was valid and consumed
81+ */
82+ function consumeNonce ( nonce ) {
83+ const expiry = sessionNonces . get ( nonce ) ;
84+ if ( ! expiry ) return false ;
85+ sessionNonces . delete ( nonce ) ;
86+ return Date . now ( ) < expiry ;
87+ }
88+
89+ /** Periodically clean up expired nonces (every 60 seconds) */
90+ setInterval ( ( ) => {
91+ const now = Date . now ( ) ;
92+ for ( const [ nonce , expiry ] of sessionNonces ) {
93+ if ( now >= expiry ) sessionNonces . delete ( nonce ) ;
94+ }
95+ } , 60_000 ) ;
96+
97+ /**
98+ * Reads frontend/dist/index.html and injects a session nonce meta tag.
99+ * Returns null in dev mode (no built frontend).
100+ */
101+ let indexHtmlTemplate = null ;
102+ try {
103+ indexHtmlTemplate = fs . readFileSync (
104+ path . join ( __dirname , "frontend" , "dist" , "index.html" ) ,
105+ "utf-8"
106+ ) ;
107+ } catch {
108+ // No built frontend (dev mode) — index.html served by Vite
109+ }
110+
111+ /**
112+ * Express middleware that validates JWT from Authorization header.
113+ * Returns 401 with JSON error if token is missing or invalid.
114+ */
115+ function requireSession ( req , res , next ) {
116+ const authHeader = req . headers . authorization ;
117+ if ( ! authHeader || ! authHeader . startsWith ( "Bearer " ) ) {
118+ return res . status ( 401 ) . json ( {
119+ error : {
120+ type : "AuthenticationError" ,
121+ code : "MISSING_TOKEN" ,
122+ message : "Authorization header with Bearer token is required" ,
123+ } ,
124+ } ) ;
125+ }
126+
127+ try {
128+ const token = authHeader . slice ( 7 ) ;
129+ jwt . verify ( token , SESSION_SECRET ) ;
130+ next ( ) ;
131+ } catch ( err ) {
132+ return res . status ( 401 ) . json ( {
133+ error : {
134+ type : "AuthenticationError" ,
135+ code : "INVALID_TOKEN" ,
136+ message :
137+ err . name === "TokenExpiredError"
138+ ? "Session expired, please refresh the page"
139+ : "Invalid session token" ,
140+ } ,
141+ } ) ;
142+ }
143+ }
144+
42145// ============================================================================
43146// API KEY LOADING - Load Deepgram API key from .env or config.json
44147// ============================================================================
@@ -197,6 +300,50 @@ function formatErrorResponse(error, statusCode = 500, errorCode = null) {
197300 } ;
198301}
199302
303+ // ============================================================================
304+ // SESSION ROUTES - Auth endpoints (unprotected)
305+ // ============================================================================
306+
307+ /**
308+ * GET / — Serve index.html with injected session nonce (production only).
309+ * In dev mode, Vite serves the frontend directly.
310+ */
311+ app . get ( "/" , ( req , res ) => {
312+ if ( ! indexHtmlTemplate ) {
313+ return res . status ( 404 ) . send ( "Frontend not built. Run make build first." ) ;
314+ }
315+ const nonce = generateNonce ( ) ;
316+ const html = indexHtmlTemplate . replace (
317+ "</head>" ,
318+ `<meta name="session-nonce" content="${ nonce } ">\n</head>`
319+ ) ;
320+ res . type ( "html" ) . send ( html ) ;
321+ } ) ;
322+
323+ /**
324+ * GET /api/session — Issues a JWT. In production (SESSION_SECRET set),
325+ * requires a valid single-use nonce via X-Session-Nonce header.
326+ */
327+ app . get ( "/api/session" , ( req , res ) => {
328+ if ( REQUIRE_NONCE ) {
329+ const nonce = req . headers [ "x-session-nonce" ] ;
330+ if ( ! nonce || ! consumeNonce ( nonce ) ) {
331+ return res . status ( 403 ) . json ( {
332+ error : {
333+ type : "AuthenticationError" ,
334+ code : "INVALID_NONCE" ,
335+ message : "Valid session nonce required. Please refresh the page." ,
336+ } ,
337+ } ) ;
338+ }
339+ }
340+
341+ const token = jwt . sign ( { iat : Math . floor ( Date . now ( ) / 1000 ) } , SESSION_SECRET , {
342+ expiresIn : JWT_EXPIRY ,
343+ } ) ;
344+ res . json ( { token } ) ;
345+ } ) ;
346+
200347// ============================================================================
201348// API ROUTES - Define your API endpoints here
202349// ============================================================================
@@ -214,8 +361,10 @@ function formatErrorResponse(error, statusCode = 500, errorCode = null) {
214361 * - Error (4XX): JSON error response matching contract format
215362 *
216363 * This endpoint implements the TTS contract specification.
364+ *
365+ * Protected by JWT session auth (requireSession middleware).
217366 */
218- app . post ( "/api/text-to-speech" , async ( req , res ) => {
367+ app . post ( "/api/text-to-speech" , requireSession , async ( req , res ) => {
219368 try {
220369 // Get model from query parameter (contract specifies query param, not body)
221370 const model = req . query . model || DEFAULT_MODEL ;
@@ -289,7 +438,6 @@ app.post("/api/text-to-speech", async (req, res) => {
289438 */
290439app . get ( "/api/metadata" , ( req , res ) => {
291440 try {
292- const fs = require ( "fs" ) ;
293441 const toml = require ( "toml" ) ;
294442 const tomlPath = path . join ( __dirname , "deepgram.toml" ) ;
295443 const tomlContent = fs . readFileSync ( tomlPath , "utf-8" ) ;
@@ -329,9 +477,9 @@ app.get("/api/metadata", (req, res) => {
329477
330478app . listen ( CONFIG . port , CONFIG . host , ( ) => {
331479 console . log ( "\n" + "=" . repeat ( 70 ) ) ;
332- console . log ( `🚀 Backend API Server running at http://localhost:${ CONFIG . port } ` ) ;
333- console . log ( "" ) ;
334- console . log ( `📡 POST /api/text-to-speech` ) ;
480+ console . log ( `🚀 Backend API running at http://localhost:${ CONFIG . port } ` ) ;
481+ console . log ( `📡 GET /api/session ${ REQUIRE_NONCE ? " (nonce required)" : "" } ` ) ;
482+ console . log ( `📡 POST /api/text-to-speech (auth required) ` ) ;
335483 console . log ( `📡 GET /api/metadata` ) ;
336484 console . log ( "=" . repeat ( 70 ) + "\n" ) ;
337485} ) ;
0 commit comments