@mooncompany/uplink-chat 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Config Routes - Configuration and gateway validation
3
+ */
4
+
5
+ import { badRequest, internalError, ErrorCodes } from '../../utils/errors.js';
6
+ import { isEdgeTTSAvailable, isPiperConfigured, isLocalTTSConfigured, listEdgeTTSVoices } from '../tts.js';
7
+ import { GATEWAY_VALIDATION_TIMEOUT_MS } from '../config.js';
8
+
9
+ /**
10
+ * Setup config routes
11
+ * @param {Express} app - Express app instance
12
+ * @param {Object} context - Request context
13
+ */
14
+ export function setupConfigRoutes(app, context) {
15
+ const {
16
+ getClientConfig,
17
+ saveConfig,
18
+ loadConfig,
19
+ needsOnboarding,
20
+ strictLimiter,
21
+ isPrivateIP,
22
+ log,
23
+ } = context;
24
+
25
+ // ===========================================
26
+ // Gateway Validation
27
+ // ===========================================
28
+
29
+ app.post('/api/gateway/validate', strictLimiter, async (req, res) => {
30
+ let { url } = req.body;
31
+
32
+ if (!url) {
33
+ return badRequest(res, 'No URL provided', ErrorCodes.MISSING_FIELD);
34
+ }
35
+
36
+ url = url.replace(/^(wss?|https?)\/*:+\/*/, '$1://');
37
+ url = url.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://');
38
+
39
+ if (!/^https?:\/\//i.test(url)) {
40
+ url = 'http://' + url;
41
+ }
42
+
43
+ let parsedUrl;
44
+ try {
45
+ parsedUrl = new URL(url);
46
+ } catch (e) {
47
+ return badRequest(res, 'Invalid URL format', ErrorCodes.INVALID_FORMAT);
48
+ }
49
+
50
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
51
+ return badRequest(res, 'Only HTTP/HTTPS URLs are allowed', ErrorCodes.INVALID_FORMAT);
52
+ }
53
+
54
+ const hostname = parsedUrl.hostname.replace(/^\[|\]$/g, ''); // Strip IPv6 brackets
55
+
56
+ // Reject link-local addresses (169.254.x.x, fe80::) - never valid gateway targets
57
+ const isLinkLocal = /^169\.254\./.test(hostname) || /^fe80:/i.test(hostname);
58
+ if (isLinkLocal) {
59
+ return badRequest(res, 'Link-local addresses are not allowed as gateway URLs.', ErrorCodes.FORBIDDEN);
60
+ }
61
+
62
+ if (isPrivateIP(hostname)) {
63
+ // Localhost is always allowed — Uplink is local-first, gateway is typically on localhost
64
+ const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
65
+ if (!isLocalhost) {
66
+ // Block other private IPs (10.x, 192.168.x, etc.) unless explicitly allowed
67
+ const allowPrivate = process.env.ALLOW_PRIVATE_GATEWAY === 'true';
68
+ if (!allowPrivate) {
69
+ return badRequest(res, 'Private/internal URLs are not allowed. Set ALLOW_PRIVATE_GATEWAY=true to allow LAN addresses.', ErrorCodes.FORBIDDEN);
70
+ }
71
+ }
72
+ }
73
+
74
+ try {
75
+ const controller = new AbortController();
76
+ const timeout = setTimeout(() => controller.abort(), GATEWAY_VALIDATION_TIMEOUT_MS);
77
+
78
+ // Try multiple endpoints - OpenClaw serves HTML on root, so just check reachability
79
+ const baseUrl = url.replace(/\/$/, '');
80
+ let response;
81
+ let lastError;
82
+
83
+ for (const path of ['', '/health', '/v1/models']) {
84
+ try {
85
+ response = await fetch(`${baseUrl}${path}`, {
86
+ signal: controller.signal,
87
+ method: 'GET'
88
+ });
89
+ if (response.ok) break;
90
+ } catch (e) {
91
+ lastError = e;
92
+ }
93
+ }
94
+
95
+ clearTimeout(timeout);
96
+
97
+ if (response?.ok) {
98
+ res.json({ valid: true, status: response.status });
99
+ } else {
100
+ res.json({ valid: false, error: lastError?.message || `Gateway returned ${response?.status || 'no response'}` });
101
+ }
102
+ } catch (error) {
103
+ if (error.code === 'ECONNREFUSED' || error.cause?.code === 'ECONNREFUSED') {
104
+ return res.json({ valid: false, error: 'Gateway not responding. Is it running? Try: openclaw gateway start' });
105
+ } else if (error.name === 'AbortError') {
106
+ return res.json({ valid: false, error: 'Connection timed out. Check firewall settings or try a different URL.' });
107
+ }
108
+ return res.json({
109
+ valid: false,
110
+ error: process.env.NODE_ENV === 'production' ? 'Could not connect to gateway.' : error.message
111
+ });
112
+ }
113
+ });
114
+
115
+ // ===========================================
116
+ // Configuration API
117
+ // ===========================================
118
+
119
+ /**
120
+ * Get client-safe configuration
121
+ */
122
+ app.get('/api/config', async (req, res) => {
123
+ try {
124
+ const clientConfig = await getClientConfig();
125
+ res.json(clientConfig);
126
+ } catch (error) {
127
+ log('error', '[Config] Get error:', error.message);
128
+ internalError(res, 'Failed to load configuration', ErrorCodes.CONFIG_ERROR);
129
+ }
130
+ });
131
+
132
+ /**
133
+ * Check if onboarding is needed
134
+ */
135
+ app.get('/api/config/needs-onboarding', async (req, res) => {
136
+ try {
137
+ const needs = await needsOnboarding();
138
+ res.json({ needsOnboarding: needs });
139
+ } catch (error) {
140
+ log('error', '[Config] Onboarding check error:', error.message);
141
+ internalError(res, 'Failed to check onboarding status', ErrorCodes.CONFIG_ERROR);
142
+ }
143
+ });
144
+
145
+ /**
146
+ * Update configuration (used by onboarding wizard)
147
+ */
148
+ app.post('/api/config', strictLimiter, async (req, res) => {
149
+ try {
150
+ // Only allow known config keys to prevent config pollution
151
+ const ALLOWED_CONFIG_KEYS = new Set([
152
+ 'userName', 'assistantName', 'gatewayUrl', 'gatewayToken',
153
+ 'sessionUser', 'ttsProvider', 'elevenLabsApiKey', 'elevenLabsVoiceId',
154
+ 'ttsVoiceName', 'edgeTtsVoice', 'localTtsUrl', 'encryptHistory',
155
+ 'wakeWord', 'onboardingComplete', 'openaiApiKey', 'openaiTtsVoice',
156
+ 'openaiTtsModel', 'sttProvider', 'groqApiKey', 'groqSttModel',
157
+ 'fasterWhisperUrl', 'openaiSttModel', 'port', 'allowedOrigins',
158
+ 'watchdogEnabled', 'networkAccess',
159
+ 'realtimeVoiceEnabled', 'realtimeVoice', 'realtimeModel',
160
+ 'voiceMode', 'voiceModel', 'agentVoiceTtsEngine', 'agentVoiceTtsVoice',
161
+ ]);
162
+
163
+ const updates = {};
164
+ for (const [key, value] of Object.entries(req.body)) {
165
+ if (ALLOWED_CONFIG_KEYS.has(key)) {
166
+ updates[key] = value;
167
+ }
168
+ }
169
+
170
+ if (Object.keys(updates).length === 0) {
171
+ return badRequest(res, 'No valid config keys provided', ErrorCodes.VALIDATION_ERROR);
172
+ }
173
+
174
+ // Validate required fields for onboarding
175
+ if (updates.onboardingComplete) {
176
+ const currentConfig = await loadConfig();
177
+ if (!updates.gatewayToken && !process.env.GATEWAY_TOKEN && !currentConfig.gatewayToken) {
178
+ return badRequest(res, 'Gateway token is required', ErrorCodes.MISSING_FIELD);
179
+ }
180
+ }
181
+
182
+ const newConfig = await saveConfig(updates);
183
+ const clientConfig = await getClientConfig();
184
+
185
+ log('info', '[Config] Configuration updated');
186
+ res.json({ ok: true, config: clientConfig });
187
+ } catch (error) {
188
+ log('error', '[Config] Update error:', error.message);
189
+ internalError(res, 'Failed to save configuration', ErrorCodes.CONFIG_ERROR);
190
+ }
191
+ });
192
+
193
+ // ===========================================
194
+ // Server Settings
195
+ // ===========================================
196
+
197
+ /**
198
+ * Get server status (watchdog, network binding)
199
+ */
200
+ app.get('/api/config/server', async (req, res) => {
201
+ try {
202
+ const config = await loadConfig();
203
+ const isNetworkAccess = process.env.UPLINK_HOST === '0.0.0.0' || config.networkAccess === true;
204
+
205
+ // Check if watchdog is running
206
+ let watchdogRunning = false;
207
+ try {
208
+ const { readFileSync } = await import('fs');
209
+ const { join } = await import('path');
210
+ const stateFile = join(process.cwd(), '.uplink-watchdog.json');
211
+ const state = JSON.parse(readFileSync(stateFile, 'utf8'));
212
+ watchdogRunning = state.status === 'running' && state.watchdogPid != null;
213
+ } catch {
214
+ // No watchdog state file
215
+ }
216
+
217
+ res.json({
218
+ watchdogEnabled: config.watchdogEnabled !== false, // default true
219
+ watchdogRunning,
220
+ networkAccess: isNetworkAccess,
221
+ bindAddress: process.env.UPLINK_HOST || (isNetworkAccess ? '0.0.0.0' : '127.0.0.1'),
222
+ });
223
+ } catch (error) {
224
+ log('error', '[Config] Server status error:', error.message);
225
+ internalError(res, 'Failed to get server status', ErrorCodes.CONFIG_ERROR);
226
+ }
227
+ });
228
+
229
+ /**
230
+ * Update server settings (requires restart to take effect)
231
+ */
232
+ app.post('/api/config/server', strictLimiter, async (req, res) => {
233
+ try {
234
+ const updates = {};
235
+
236
+ if (typeof req.body.watchdogEnabled === 'boolean') {
237
+ updates.watchdogEnabled = req.body.watchdogEnabled;
238
+ }
239
+ if (typeof req.body.networkAccess === 'boolean') {
240
+ updates.networkAccess = req.body.networkAccess;
241
+ }
242
+
243
+ if (Object.keys(updates).length === 0) {
244
+ return badRequest(res, 'No valid settings provided', ErrorCodes.VALIDATION_ERROR);
245
+ }
246
+
247
+ await saveConfig(updates);
248
+ log('info', `[Config] Server settings updated: ${JSON.stringify(updates)}`);
249
+ res.json({ ok: true, restartRequired: true });
250
+ } catch (error) {
251
+ log('error', '[Config] Server settings error:', error.message);
252
+ internalError(res, 'Failed to save server settings', ErrorCodes.CONFIG_ERROR);
253
+ }
254
+ });
255
+
256
+ /**
257
+ * Restart the server process
258
+ */
259
+ app.post('/api/config/server/restart', strictLimiter, async (req, res) => {
260
+ try {
261
+ log('info', '[Config] Server restart requested via settings UI');
262
+ res.json({ ok: true, message: 'Restarting...' });
263
+
264
+ // Give time for response to send, then exit
265
+ // Watchdog (if running) will auto-restart the process
266
+ setTimeout(() => {
267
+ process.exit(0);
268
+ }, 500);
269
+ } catch (error) {
270
+ log('error', '[Config] Restart error:', error.message);
271
+ internalError(res, 'Failed to restart server', ErrorCodes.CONFIG_ERROR);
272
+ }
273
+ });
274
+
275
+ // ===========================================
276
+ // API Keys Management
277
+ // ===========================================
278
+
279
+ /**
280
+ * Save ElevenLabs API key (validates first)
281
+ */
282
+ app.post('/api/config/elevenlabs-key', strictLimiter, async (req, res) => {
283
+ const { apiKey } = req.body;
284
+
285
+ if (!apiKey) {
286
+ return badRequest(res, 'API key is required', ErrorCodes.MISSING_FIELD);
287
+ }
288
+
289
+ // Validate the key by fetching user info
290
+ try {
291
+ const response = await fetch('https://api.elevenlabs.io/v1/user', {
292
+ headers: { 'xi-api-key': apiKey }
293
+ });
294
+
295
+ if (!response.ok) {
296
+ return res.json({ valid: false, error: 'Invalid API key' });
297
+ }
298
+
299
+ const userData = await response.json();
300
+
301
+ // Key is valid - save it
302
+ await saveConfig({ elevenLabsApiKey: apiKey, ttsProvider: 'elevenlabs' });
303
+
304
+ log('info', '[Config] ElevenLabs API key saved');
305
+ res.json({
306
+ valid: true,
307
+ subscription: userData.subscription?.tier || 'unknown'
308
+ });
309
+ } catch (error) {
310
+ log('error', '[Config] ElevenLabs validation error:', error.message);
311
+ res.json({ valid: false, error: 'Failed to validate key' });
312
+ }
313
+ });
314
+
315
+ /**
316
+ * Remove ElevenLabs API key
317
+ */
318
+ app.delete('/api/config/elevenlabs-key', strictLimiter, async (req, res) => {
319
+ try {
320
+ await saveConfig({ elevenLabsApiKey: '', ttsProvider: 'none' });
321
+ log('info', '[Config] ElevenLabs API key removed');
322
+ res.json({ ok: true });
323
+ } catch (error) {
324
+ log('error', '[Config] Failed to remove key:', error.message);
325
+ internalError(res, 'Failed to remove key', ErrorCodes.CONFIG_ERROR);
326
+ }
327
+ });
328
+
329
+ /**
330
+ * Get available ElevenLabs voices
331
+ */
332
+ app.get('/api/config/elevenlabs-voices', async (req, res) => {
333
+ try {
334
+ const config = await loadConfig();
335
+
336
+ if (!config.elevenLabsApiKey) {
337
+ return res.json({ voices: [], error: 'No API key configured' });
338
+ }
339
+
340
+ const response = await fetch('https://api.elevenlabs.io/v1/voices', {
341
+ headers: { 'xi-api-key': config.elevenLabsApiKey }
342
+ });
343
+
344
+ if (!response.ok) {
345
+ return res.json({ voices: [], error: 'Failed to fetch voices' });
346
+ }
347
+
348
+ const data = await response.json();
349
+ const voices = data.voices?.map(v => ({
350
+ id: v.voice_id,
351
+ name: v.name,
352
+ category: v.category,
353
+ previewUrl: v.preview_url
354
+ })) || [];
355
+
356
+ res.json({ voices });
357
+ } catch (error) {
358
+ log('error', '[Config] Failed to fetch voices:', error.message);
359
+ const errorMsg = process.env.NODE_ENV === 'production'
360
+ ? 'Failed to fetch voices'
361
+ : error.message;
362
+ res.json({ voices: [], error: errorMsg });
363
+ }
364
+ });
365
+
366
+ /**
367
+ * Set ElevenLabs voice
368
+ */
369
+ app.post('/api/config/elevenlabs-voice', strictLimiter, async (req, res) => {
370
+ const { voiceId, voiceName } = req.body;
371
+
372
+ if (!voiceId) {
373
+ return badRequest(res, 'Voice ID is required', ErrorCodes.MISSING_FIELD);
374
+ }
375
+
376
+ try {
377
+ await saveConfig({
378
+ elevenLabsVoiceId: voiceId,
379
+ ttsVoiceName: voiceName || 'Assistant'
380
+ });
381
+
382
+ log('info', `[Config] ElevenLabs voice set to ${voiceName || voiceId}`);
383
+ res.json({ ok: true });
384
+ } catch (error) {
385
+ log('error', '[Config] Failed to set voice:', error.message);
386
+ internalError(res, 'Failed to save voice', ErrorCodes.CONFIG_ERROR);
387
+ }
388
+ });
389
+
390
+ // ===========================================
391
+ // OpenAI API Key
392
+ // ===========================================
393
+
394
+ /**
395
+ * Save OpenAI API key (validates first)
396
+ */
397
+ app.post('/api/config/openai-key', strictLimiter, async (req, res) => {
398
+ const { apiKey } = req.body;
399
+
400
+ if (!apiKey) {
401
+ return badRequest(res, 'API key is required', ErrorCodes.MISSING_FIELD);
402
+ }
403
+
404
+ // Validate the key by hitting the models endpoint
405
+ try {
406
+ const response = await fetch('https://api.openai.com/v1/models', {
407
+ headers: { 'Authorization': `Bearer ${apiKey}` }
408
+ });
409
+
410
+ if (!response.ok) {
411
+ return res.json({ valid: false, error: 'Invalid API key' });
412
+ }
413
+
414
+ // Key is valid — save it
415
+ await saveConfig({ openaiApiKey: apiKey });
416
+
417
+ log('info', '[Config] OpenAI API key saved');
418
+ res.json({ valid: true });
419
+ } catch (error) {
420
+ log('error', '[Config] OpenAI key validation error:', error.message);
421
+ res.json({ valid: false, error: 'Failed to validate key' });
422
+ }
423
+ });
424
+
425
+ /**
426
+ * Remove OpenAI API key
427
+ */
428
+ app.delete('/api/config/openai-key', strictLimiter, async (req, res) => {
429
+ try {
430
+ await saveConfig({ openaiApiKey: '' });
431
+ log('info', '[Config] OpenAI API key removed');
432
+ res.json({ ok: true });
433
+ } catch (error) {
434
+ log('error', '[Config] Failed to remove OpenAI key:', error.message);
435
+ internalError(res, 'Failed to remove key', ErrorCodes.CONFIG_ERROR);
436
+ }
437
+ });
438
+
439
+ // ===========================================
440
+ // TTS Provider Status & Configuration
441
+ // ===========================================
442
+
443
+ /**
444
+ * Get status of all TTS providers
445
+ */
446
+ app.get('/api/config/tts-status', async (req, res) => {
447
+ try {
448
+ const config = await loadConfig();
449
+
450
+ res.json({
451
+ elevenlabs: {
452
+ available: true,
453
+ configured: !!config.elevenLabsApiKey,
454
+ needsKey: true,
455
+ label: 'ElevenLabs',
456
+ description: 'High-quality cloud TTS (requires API key)',
457
+ },
458
+ openai: {
459
+ available: true,
460
+ configured: !!(config.openaiApiKey || process.env.OPENAI_API_KEY),
461
+ needsKey: true,
462
+ hasKey: !!(config.openaiApiKey || process.env.OPENAI_API_KEY),
463
+ voice: config.openaiTtsVoice || 'nova',
464
+ model: config.openaiTtsModel || 'tts-1',
465
+ label: 'OpenAI TTS',
466
+ description: 'Cloud TTS (uses your OpenAI API key)',
467
+ },
468
+ edge: {
469
+ available: isEdgeTTSAvailable(),
470
+ configured: isEdgeTTSAvailable(),
471
+ installed: isEdgeTTSAvailable(),
472
+ voice: config.edgeTtsVoice || 'en-US-AriaNeural',
473
+ label: 'Edge TTS',
474
+ description: isEdgeTTSAvailable()
475
+ ? 'Free Microsoft TTS (no API key needed)'
476
+ : 'Not installed — run: npm install node-edge-tts',
477
+ },
478
+ piper: {
479
+ available: isPiperConfigured(),
480
+ configured: isPiperConfigured(),
481
+ label: 'Piper',
482
+ description: isPiperConfigured()
483
+ ? 'Fast local TTS (configured via environment)'
484
+ : 'Not configured — set PIPER_MODEL in .env',
485
+ },
486
+ local: {
487
+ available: isLocalTTSConfigured() || !!config.localTtsUrl,
488
+ configured: isLocalTTSConfigured() || !!config.localTtsUrl,
489
+ url: config.localTtsUrl || process.env.LOCAL_TTS_URL || '',
490
+ label: 'XTTS (Local GPU)',
491
+ description: (isLocalTTSConfigured() || config.localTtsUrl)
492
+ ? 'GPU-accelerated local TTS'
493
+ : 'Not configured — enter server URL below',
494
+ },
495
+ });
496
+ } catch (error) {
497
+ log('error', '[Config] TTS status error:', error.message);
498
+ internalError(res, 'Failed to get TTS status', ErrorCodes.CONFIG_ERROR);
499
+ }
500
+ });
501
+
502
+ /**
503
+ * Get available Edge TTS voices
504
+ */
505
+ app.get('/api/config/edge-voices', async (req, res) => {
506
+ try {
507
+ if (!isEdgeTTSAvailable()) {
508
+ return res.json({ voices: [], error: 'Edge TTS not installed' });
509
+ }
510
+
511
+ const voices = await listEdgeTTSVoices();
512
+
513
+ // Filter to English by default, but include all if requested
514
+ const lang = req.query.lang || 'en';
515
+ const filtered = lang === 'all'
516
+ ? voices
517
+ : voices.filter(v => v.locale?.startsWith(lang));
518
+
519
+ res.json({ voices: filtered });
520
+ } catch (error) {
521
+ log('error', '[Config] Edge TTS voices error:', error.message);
522
+ const errorMsg = process.env.NODE_ENV === 'production'
523
+ ? 'Failed to fetch Edge TTS voices'
524
+ : error.message;
525
+ res.json({ voices: [], error: errorMsg });
526
+ }
527
+ });
528
+
529
+ /**
530
+ * Save OpenAI TTS settings (voice + model)
531
+ */
532
+ app.post('/api/config/openai-tts', strictLimiter, async (req, res) => {
533
+ const { voice, model } = req.body;
534
+
535
+ const allowedVoices = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
536
+ const allowedModels = ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts'];
537
+
538
+ if (voice && !allowedVoices.includes(voice)) {
539
+ return badRequest(res, `Invalid voice. Options: ${allowedVoices.join(', ')}`, ErrorCodes.INVALID_FORMAT);
540
+ }
541
+ if (model && !allowedModels.includes(model)) {
542
+ return badRequest(res, `Invalid model. Options: ${allowedModels.join(', ')}`, ErrorCodes.INVALID_FORMAT);
543
+ }
544
+
545
+ try {
546
+ const updates = {};
547
+ if (voice) updates.openaiTtsVoice = voice;
548
+ if (model) updates.openaiTtsModel = model;
549
+
550
+ await saveConfig(updates);
551
+ log('info', `[Config] OpenAI TTS updated: voice=${voice || '(unchanged)'}, model=${model || '(unchanged)'}`);
552
+ res.json({ ok: true });
553
+ } catch (error) {
554
+ log('error', '[Config] OpenAI TTS save error:', error.message);
555
+ internalError(res, 'Failed to save OpenAI TTS settings', ErrorCodes.CONFIG_ERROR);
556
+ }
557
+ });
558
+
559
+ /**
560
+ * Save Edge TTS voice selection
561
+ */
562
+ app.post('/api/config/edge-voice', strictLimiter, async (req, res) => {
563
+ const { voice } = req.body;
564
+
565
+ if (!voice || typeof voice !== 'string') {
566
+ return badRequest(res, 'Voice name is required', ErrorCodes.MISSING_FIELD);
567
+ }
568
+
569
+ try {
570
+ await saveConfig({ edgeTtsVoice: voice });
571
+ log('info', `[Config] Edge TTS voice set to ${voice}`);
572
+ res.json({ ok: true });
573
+ } catch (error) {
574
+ log('error', '[Config] Edge TTS voice save error:', error.message);
575
+ internalError(res, 'Failed to save Edge TTS voice', ErrorCodes.CONFIG_ERROR);
576
+ }
577
+ });
578
+
579
+ /**
580
+ * Save XTTS server URL
581
+ */
582
+ app.post('/api/config/local-tts', strictLimiter, async (req, res) => {
583
+ const { url } = req.body;
584
+
585
+ if (url !== undefined && url !== '') {
586
+ // Validate URL format
587
+ try {
588
+ new URL(url);
589
+ } catch {
590
+ return badRequest(res, 'Invalid URL format', ErrorCodes.INVALID_FORMAT);
591
+ }
592
+ }
593
+
594
+ try {
595
+ await saveConfig({ localTtsUrl: url || '' });
596
+ log('info', `[Config] XTTS URL set to ${url || '(cleared)'}`);
597
+ res.json({ ok: true });
598
+ } catch (error) {
599
+ log('error', '[Config] XTTS URL save error:', error.message);
600
+ internalError(res, 'Failed to save XTTS URL', ErrorCodes.CONFIG_ERROR);
601
+ }
602
+ });
603
+ }