@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
package/server.js ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Uplink Server - Main entry point
3
+ *
4
+ * Modular architecture:
5
+ * - server/config.js - Environment variables and constants
6
+ * - server/middleware.js - Security headers, CSRF, rate limiting
7
+ * - server/utils.js - Logging, fetch helpers, request tracking
8
+ * - server/tts.js - ElevenLabs text-to-speech
9
+ * - server/chat.js - AI chat functions (transcribe, chat, parallel TTS)
10
+ * - server/routes.js - HTTP API endpoints
11
+ * - server/websocket/ - WebSocket real-time communication (split into submodules)
12
+ * - server/share.js - Public share links
13
+ * - server/sync.js - Encrypted cross-device sync
14
+ */
15
+
16
+ import express from 'express';
17
+ import http from 'http';
18
+ import path from 'path';
19
+ import fs from 'fs/promises';
20
+ import { randomUUID } from 'crypto';
21
+
22
+ // Server modules
23
+ import {
24
+ PORT, ROOT_DIR, TTS_VOICE_NAME as TTS_VOICE, WAKE_WORD,
25
+ AUDIO_DIR, UPLOADS_DIR, SHARES_DIR, SYNC_DIR,
26
+ AUDIO_MAX_AGE_MS, UPLOADS_MAX_AGE_MS,
27
+ MAX_CONCURRENT_REQUESTS, REQUEST_TIMEOUT
28
+ } from './server/config.js';
29
+ import {
30
+ securityHeaders, corsMiddleware, csrfProtection,
31
+ apiLimiter, jsonUtf8, noCache
32
+ } from './server/middleware.js';
33
+ import { requireAuth, getAuthStatus, isAuthEnabled } from './server/middleware/auth.js';
34
+ import { log, cleanupOldFiles } from './server/utils.js';
35
+ import { setupRoutes, saveMessageToSync } from './server/routes.js';
36
+ import { setupWebSocket, wsClients, broadcastToAll } from './server/websocket/index.js';
37
+ import { setupShareRoutes } from './server/share.js';
38
+ import { setupSyncRoutes } from './server/sync.js';
39
+ import { generateTTS } from './server/chat.js';
40
+ import { setupGatewayProxy } from './server/gateway-proxy.js';
41
+ import { setupRealtimeRelay } from './server/realtime/index.js';
42
+ import { setupAgentVoiceBridge } from './server/realtime/bridge.js';
43
+ import { setupTailscaleHTTPS } from './server/tailscale-https.js';
44
+ import { startUpdateChecker } from './server/update-checker.js';
45
+
46
+ // ===========================================
47
+ // CRASH DIAGNOSTICS
48
+ // ===========================================
49
+ process.on('uncaughtException', (err) => {
50
+ console.error('[CRASH] Uncaught exception:', err.message);
51
+ console.error(err.stack);
52
+ // H-15: Exit after logging — process is in undefined state after uncaught exception
53
+ process.exit(1);
54
+ });
55
+
56
+ process.on('unhandledRejection', (reason, promise) => {
57
+ console.error('[CRASH] Unhandled rejection at:', promise);
58
+ console.error('[CRASH] Reason:', reason);
59
+ process.exit(1);
60
+ });
61
+
62
+ process.on('exit', (code) => {
63
+ console.error(`[EXIT] Process exiting with code: ${code}`);
64
+ });
65
+
66
+ // ===========================================
67
+ // REQUEST TRACKING
68
+ // ===========================================
69
+ const activeRequests = new Map();
70
+
71
+ function canAcceptRequest() {
72
+ const now = Date.now();
73
+ for (const [id, req] of activeRequests) {
74
+ if (now - req.startedAt > REQUEST_TIMEOUT) {
75
+ log('warn', `Request ${id} timed out, removing from active`);
76
+ activeRequests.delete(id);
77
+ }
78
+ }
79
+ return activeRequests.size < MAX_CONCURRENT_REQUESTS;
80
+ }
81
+
82
+ function startRequest(type = 'unknown') {
83
+ const requestId = randomUUID();
84
+ activeRequests.set(requestId, { startedAt: Date.now(), type });
85
+ return requestId;
86
+ }
87
+
88
+ function endRequest(requestId) {
89
+ activeRequests.delete(requestId);
90
+ }
91
+
92
+ function isProcessing() {
93
+ return activeRequests.size > 0;
94
+ }
95
+
96
+ // Request helpers bundle for modules
97
+ const requestHelpers = {
98
+ canAcceptRequest,
99
+ startRequest,
100
+ endRequest,
101
+ isProcessing,
102
+ activeRequests,
103
+ MAX_CONCURRENT_REQUESTS,
104
+ textToSpeech: generateTTS
105
+ };
106
+
107
+ // ===========================================
108
+ // EXPRESS APP SETUP
109
+ // ===========================================
110
+ const app = express();
111
+ // Only trust proxy headers when explicitly configured (behind a reverse proxy)
112
+ // Without this, clients can spoof IPs via X-Forwarded-For to bypass rate limits
113
+ if (process.env.TRUST_PROXY) {
114
+ app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : process.env.TRUST_PROXY);
115
+ }
116
+ app.disable('x-powered-by');
117
+
118
+ // CSP nonce middleware - generates unique nonce per request
119
+ function cspNonceMiddleware(req, res, next) {
120
+ res.locals.nonce = randomUUID().replace(/-/g, '');
121
+ next();
122
+ }
123
+
124
+ // Middleware
125
+ // Cache-clear endpoint (bypasses SW since it's under /api/)
126
+ // SW version check — returns current CACHE_NAME so SW can self-update
127
+ app.get('/api/sw-version', (req, res) => {
128
+ res.json({ version: 'uplink-v62' });
129
+ });
130
+
131
+ app.get('/api/clear-cache', (req, res) => {
132
+ res.send(`<!DOCTYPE html><html><head><title>Clear Cache</title></head>
133
+ <body style="background:#111;color:#0f0;font-family:monospace;padding:2rem;">
134
+ <h2>Clearing cache...</h2><pre id="log"></pre>
135
+ <script>
136
+ const log=document.getElementById('log');
137
+ function p(m){log.textContent+=m+'\\n';}
138
+ async function go(){
139
+ const r=await navigator.serviceWorker.getRegistrations();
140
+ p('Found '+r.length+' SW(s)');
141
+ for(const s of r){await s.unregister();p('Unregistered: '+s.scope);}
142
+ const k=await caches.keys();
143
+ p('Found '+k.length+' cache(s)');
144
+ for(const c of k){await caches.delete(c);p('Deleted: '+c);}
145
+ p('\\nDone! Redirecting in 2s...');
146
+ setTimeout(()=>window.location.href='/',2000);
147
+ }
148
+ go().catch(e=>p('Error: '+e.message));
149
+ </script></body></html>`);
150
+ });
151
+
152
+ app.use(cspNonceMiddleware); // Generate CSP nonce for every request
153
+ app.use(securityHeaders);
154
+ app.use(corsMiddleware);
155
+
156
+ // Static asset caching with long-lived immutable cache headers
157
+ // Cache JS and CSS for 1 year (they have cache-bust query strings in production)
158
+ app.use('/js', express.static(path.join(ROOT_DIR, 'public', 'js'), {
159
+ maxAge: '1y',
160
+ immutable: true,
161
+ setHeaders: (res) => {
162
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
163
+ }
164
+ }));
165
+
166
+ app.use('/css', express.static(path.join(ROOT_DIR, 'public', 'css'), {
167
+ maxAge: '1y',
168
+ immutable: true,
169
+ setHeaders: (res) => {
170
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
171
+ }
172
+ }));
173
+
174
+ // Agent avatars - no cache (user-uploaded, can change anytime)
175
+ app.use('/img/agents', express.static(path.join(ROOT_DIR, 'public', 'img', 'agents'), {
176
+ maxAge: 0,
177
+ setHeaders: (res) => {
178
+ res.setHeader('Cache-Control', 'no-cache, must-revalidate');
179
+ }
180
+ }));
181
+
182
+ // Images and other static assets - cache for 1 day (may change more frequently)
183
+ app.use(express.static(path.join(ROOT_DIR, 'public'), {
184
+ index: false,
185
+ maxAge: '1d',
186
+ setHeaders: (res, filePath) => {
187
+ if (filePath.endsWith('.png') || filePath.endsWith('.jpg') || filePath.endsWith('.svg') || filePath.endsWith('.webp')) {
188
+ res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day
189
+ }
190
+ }
191
+ }));
192
+
193
+ // Apply no-cache to all other routes (HTML, API endpoints)
194
+ app.use(noCache);
195
+
196
+ // Serve uploaded images for chat history persistence
197
+ app.use('/uploads', express.static(path.join(ROOT_DIR, 'uploads')));
198
+
199
+ // Cache-bust version (change this to force client updates)
200
+ const CACHE_BUST_VERSION = Date.now();
201
+ console.log(`[Cache] Version: ${CACHE_BUST_VERSION}`);
202
+
203
+ // Serve index.html with CSP nonce and cache-bust injected
204
+ app.get('/', async (req, res) => {
205
+ try {
206
+ const indexPath = path.join(ROOT_DIR, 'public', 'index.html');
207
+ let html = await fs.readFile(indexPath, 'utf-8');
208
+
209
+ // Inject nonce into all script tags (handles both <script> and <script src=...>)
210
+ const nonce = res.locals.nonce;
211
+ html = html.replace(/<script(\s|>)/g, `<script nonce="${nonce}"$1`);
212
+
213
+ // Add cache-bust query string to JS and CSS files
214
+ html = html.replace(/\.js"/g, `.js?v=${CACHE_BUST_VERSION}"`);
215
+ html = html.replace(/\.css"/g, `.css?v=${CACHE_BUST_VERSION}"`);
216
+
217
+ // Prevent caching of index.html
218
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
219
+ res.setHeader('Pragma', 'no-cache');
220
+ res.setHeader('Expires', '0');
221
+ res.setHeader('Surrogate-Control', 'no-store'); // Cloudflare
222
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
223
+ res.send(html);
224
+ } catch (error) {
225
+ console.error('[ERROR] Failed to serve index.html:', error);
226
+ res.status(500).send('Internal Server Error');
227
+ }
228
+ });
229
+
230
+ app.use(express.json());
231
+ app.use(jsonUtf8);
232
+ app.use(csrfProtection);
233
+ app.use('/api/', apiLimiter);
234
+
235
+ // Optional authentication middleware (defense-in-depth)
236
+ // When UPLINK_AUTH_ENABLED=true, all /api routes require Bearer token
237
+ // When disabled (default), routes work as normal for local/Tailscale deployments
238
+ app.use('/api/', requireAuth);
239
+
240
+ // ===========================================
241
+ // CLEANUP TASKS
242
+ // ===========================================
243
+ async function runCleanup() {
244
+ await fs.mkdir(AUDIO_DIR, { recursive: true }).catch(err => log('warn', 'Failed to create audio dir:', err.message));
245
+ await fs.mkdir(UPLOADS_DIR, { recursive: true }).catch(err => log('warn', 'Failed to create uploads dir:', err.message));
246
+ await cleanupOldFiles(AUDIO_DIR, AUDIO_MAX_AGE_MS, ['.mp3']);
247
+ await cleanupOldFiles(UPLOADS_DIR, UPLOADS_MAX_AGE_MS);
248
+ }
249
+
250
+ // Run cleanup every 15 minutes
251
+ setInterval(runCleanup, 15 * 60 * 1000);
252
+ setTimeout(runCleanup, 10000);
253
+
254
+ // ===========================================
255
+ // ROUTES
256
+ // ===========================================
257
+
258
+ // Setup API routes
259
+ setupRoutes(app, requestHelpers);
260
+
261
+ // Setup share routes
262
+ setupShareRoutes(app, SHARES_DIR);
263
+
264
+ // Setup sync routes
265
+ setupSyncRoutes(app, SYNC_DIR);
266
+
267
+ // ===========================================
268
+ // GLOBAL ERROR HANDLER (H-16: must be after all routes)
269
+ // ===========================================
270
+ app.use((err, req, res, next) => {
271
+ console.error('[ERROR]', err.stack || err.message || err);
272
+ const statusCode = err.statusCode || err.status || 500;
273
+ const message = process.env.NODE_ENV === 'production'
274
+ ? 'Internal server error'
275
+ : (err.message || 'Internal server error');
276
+ res.status(statusCode).json({ error: true, message });
277
+ });
278
+
279
+ // ===========================================
280
+ // HTTP SERVER + WEBSOCKET
281
+ // ===========================================
282
+ const server = http.createServer(app);
283
+
284
+ // Setup Gateway WebSocket Proxy FIRST (uses noServer mode, handles /gateway)
285
+ setupGatewayProxy(server);
286
+
287
+ // Setup Realtime Voice relay (uses noServer mode, handles /api/realtime)
288
+ setupRealtimeRelay(server);
289
+
290
+ // Setup Agent Voice Bridge (handles /api/realtime?mode=agent)
291
+ setupAgentVoiceBridge(server);
292
+
293
+ // Setup main WebSocket (uses server mode with path: '/ws')
294
+ setupWebSocket(server, requestHelpers, saveMessageToSync);
295
+
296
+ // ===========================================
297
+ // START SERVER
298
+ // ===========================================
299
+ // Determine bind host: env var > config.json networkAccess > auth-based default
300
+ let configNetworkAccess = false;
301
+ try {
302
+ const { readFileSync } = await import('fs');
303
+ const configRaw = readFileSync(path.join(import.meta.dirname, 'config.json'), 'utf8');
304
+ configNetworkAccess = JSON.parse(configRaw).networkAccess === true;
305
+ } catch { /* config not found or invalid */ }
306
+ const BIND_HOST = process.env.UPLINK_HOST || (configNetworkAccess ? '0.0.0.0' : (isAuthEnabled() ? '0.0.0.0' : '127.0.0.1'));
307
+
308
+ server.on('error', (err) => {
309
+ if (err.code === 'EADDRINUSE') {
310
+ console.error(`[FATAL] Port ${PORT} is already in use`);
311
+ process.exit(1);
312
+ }
313
+ });
314
+
315
+ server.listen(PORT, BIND_HOST, () => {
316
+ const authStatus = getAuthStatus();
317
+ const bindAddress = server.address();
318
+ const isPublicBind = bindAddress.address === '0.0.0.0' || bindAddress.address === '::';
319
+
320
+ console.log(`🛰️ Uplink server running at http://localhost:${PORT}`);
321
+ console.log(` WebSocket: ws://localhost:${PORT}/ws`);
322
+ console.log(` Gateway Proxy: ws://localhost:${PORT}/gateway`);
323
+ console.log(` Bind: ${BIND_HOST}`);
324
+ console.log(` Wake word: "${WAKE_WORD}" (hands-free mode)`);
325
+ console.log(` TTS: ${TTS_VOICE} (ElevenLabs - Scouse legend)`);
326
+ console.log(` Whisper: small model (better accuracy)`);
327
+ console.log(` Press Space to talk, Esc to cancel`);
328
+
329
+ // Auth status
330
+ if (authStatus.enabled) {
331
+ console.log(`\n🔒 Authentication: ENABLED (defense-in-depth)`);
332
+ console.log(` Token configured: ${authStatus.tokenConfigured}`);
333
+ console.log(` Token length: ${authStatus.tokenLength} chars (min: ${authStatus.minTokenLength})`);
334
+ } else {
335
+ console.log(`\n🔓 Authentication: DISABLED (local/Tailscale mode)`);
336
+ console.log(` Set UPLINK_AUTH_ENABLED=true to enable auth middleware`);
337
+ if (BIND_HOST === '127.0.0.1') {
338
+ console.log(` Bind: 127.0.0.1 (auth disabled — set UPLINK_HOST=0.0.0.0 or enable auth for remote access)`);
339
+ }
340
+ }
341
+
342
+ // Start update checker (broadcasts to all WebSocket clients)
343
+ startUpdateChecker(server, (msg) => broadcastToAll(msg));
344
+
345
+ // Public exposure warning (GROUP C from audit findings)
346
+ if (isPublicBind && !authStatus.enabled) {
347
+ console.log(`\n⚠️ WARNING: Uplink is bound to all interfaces (${bindAddress.address}) without authentication!`);
348
+ console.log(`⚠️ This is NOT RECOMMENDED for public deployments.`);
349
+ console.log(`⚠️ For local/Tailscale use only, or enable authentication:`);
350
+ console.log(`⚠️ UPLINK_AUTH_ENABLED=true`);
351
+ console.log(`⚠️ UPLINK_AUTH_TOKEN=<your-long-random-token>`);
352
+ }
353
+ });
354
+
355
+ // ===========================================
356
+ // TAILSCALE HTTPS (auto-detect, zero config)
357
+ // ===========================================
358
+ (async () => {
359
+ try {
360
+ const ts = await setupTailscaleHTTPS(app, {
361
+ onServer: (httpsServer, domain) => {
362
+ // Wire up the same WebSocket handlers on the HTTPS server
363
+ setupGatewayProxy(httpsServer);
364
+ setupRealtimeRelay(httpsServer);
365
+ setupAgentVoiceBridge(httpsServer);
366
+ setupWebSocket(httpsServer, requestHelpers, saveMessageToSync);
367
+ }
368
+ });
369
+ if (ts) {
370
+ console.log(`\n🔐 Tailscale HTTPS: ${ts.url}`);
371
+ console.log(` Secure WebSocket: wss://${ts.domain}:${new URL(ts.url).port}/ws`);
372
+ console.log(` Gateway Proxy: wss://${ts.domain}:${new URL(ts.url).port}/gateway`);
373
+ console.log(` 📱 Mobile mic/camera: ✅ enabled`);
374
+ }
375
+ } catch (err) {
376
+ console.warn('[Tailscale] HTTPS setup failed:', err.message);
377
+ }
378
+ })();
379
+
380
+ // ===========================================
381
+ // GRACEFUL SHUTDOWN (H-14)
382
+ // ===========================================
383
+ let isShuttingDown = false;
384
+
385
+ function gracefulShutdown(signal) {
386
+ if (isShuttingDown) return;
387
+ isShuttingDown = true;
388
+ console.log(`\n[SHUTDOWN] ${signal} received, closing server gracefully...`);
389
+
390
+ // Stop accepting new connections
391
+ server.close(() => {
392
+ console.log('[SHUTDOWN] HTTP server closed');
393
+ process.exit(0);
394
+ });
395
+
396
+ // Force exit after 10 seconds if graceful shutdown hangs
397
+ setTimeout(() => {
398
+ console.error('[SHUTDOWN] Forced exit after timeout');
399
+ process.exit(1);
400
+ }, 10000).unref();
401
+ }
402
+
403
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
404
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Tool Detection Utility (Server-side)
3
+ * Detects tool usage in AI response text
4
+ *
5
+ * Previously duplicated in:
6
+ * - server/routes.js
7
+ * - server/websocket.js
8
+ */
9
+
10
+ // Tool keyword mappings
11
+ const TOOL_KEYWORDS = {
12
+ 'Read': 'read',
13
+ 'Write': 'write',
14
+ 'Edit': 'edit',
15
+ 'exec': 'exec',
16
+ 'browser': 'browser',
17
+ 'web_search': 'search',
18
+ 'web_fetch': 'fetch',
19
+ 'image': 'image',
20
+ 'tts': 'tts',
21
+ 'nodes': 'nodes',
22
+ 'message': 'message',
23
+ 'canvas': 'canvas'
24
+ };
25
+
26
+ /**
27
+ * Detect tool usage in AI response text
28
+ * Looks for <invoke name="ToolName"> patterns in the response
29
+ *
30
+ * @param {string} text - The AI response text to analyze
31
+ * @returns {string[]} - Array of detected tool names (lowercase)
32
+ */
33
+ export function detectToolUsage(text) {
34
+ if (typeof text !== 'string' || !text) {
35
+ return [];
36
+ }
37
+
38
+ const tools = [];
39
+
40
+ for (const [keyword, toolName] of Object.entries(TOOL_KEYWORDS)) {
41
+ const regex = new RegExp(`<invoke name="${keyword}"`, 'gi');
42
+ if (regex.test(text)) {
43
+ if (!tools.includes(toolName)) {
44
+ tools.push(toolName);
45
+ }
46
+ }
47
+ }
48
+
49
+ return tools;
50
+ }
51
+
52
+ /**
53
+ * Check if a specific tool was used
54
+ *
55
+ * @param {string} text - The AI response text
56
+ * @param {string} tool - The tool name to check for
57
+ * @returns {boolean} - True if the tool was detected
58
+ */
59
+ export function wasToolUsed(text, tool) {
60
+ const detected = detectToolUsage(text);
61
+ return detected.includes(tool.toLowerCase());
62
+ }
63
+
64
+ /**
65
+ * Get all available tool names
66
+ *
67
+ * @returns {string[]} - Array of all known tool names
68
+ */
69
+ export function getAvailableTools() {
70
+ return Object.values(TOOL_KEYWORDS);
71
+ }
72
+
73
+ /**
74
+ * Get the keyword for a tool name
75
+ *
76
+ * @param {string} toolName - The tool name (lowercase)
77
+ * @returns {string|null} - The keyword or null if not found
78
+ */
79
+ export function getToolKeyword(toolName) {
80
+ for (const [keyword, name] of Object.entries(TOOL_KEYWORDS)) {
81
+ if (name === toolName.toLowerCase()) {
82
+ return keyword;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ export default {
89
+ detectToolUsage,
90
+ wasToolUsed,
91
+ getAvailableTools,
92
+ getToolKeyword
93
+ };
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Standardized Error Response Utility
3
+ *
4
+ * All API errors should return:
5
+ * { error: true, message: "...", code: "ERROR_CODE" }
6
+ */
7
+
8
+ // Error codes enum
9
+ export const ErrorCodes = {
10
+ // Client errors (4xx)
11
+ BAD_REQUEST: 'BAD_REQUEST',
12
+ INVALID_INPUT: 'INVALID_INPUT',
13
+ MISSING_FIELD: 'MISSING_FIELD',
14
+ INVALID_FORMAT: 'INVALID_FORMAT',
15
+ INVALID_FILE_TYPE: 'INVALID_FILE_TYPE',
16
+ UNAUTHORIZED: 'UNAUTHORIZED',
17
+ FORBIDDEN: 'FORBIDDEN',
18
+ NOT_FOUND: 'NOT_FOUND',
19
+ RATE_LIMITED: 'RATE_LIMITED',
20
+ UPLOAD_FAILED: 'UPLOAD_FAILED',
21
+ VALIDATION_ERROR: 'VALIDATION_ERROR',
22
+
23
+ // Server errors (5xx)
24
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
25
+ GATEWAY_ERROR: 'GATEWAY_ERROR',
26
+ TTS_ERROR: 'TTS_ERROR',
27
+ TRANSCRIPTION_ERROR: 'TRANSCRIPTION_ERROR',
28
+ CONFIG_ERROR: 'CONFIG_ERROR',
29
+ TIMEOUT: 'TIMEOUT',
30
+ };
31
+
32
+ /**
33
+ * Send a standardized error response
34
+ * @param {Response} res - Express response object
35
+ * @param {number} status - HTTP status code
36
+ * @param {string} message - Human-readable error message
37
+ * @param {string} code - Error code from ErrorCodes
38
+ */
39
+ export function sendError(res, status, message, code = ErrorCodes.INTERNAL_ERROR) {
40
+ return res.status(status).json({
41
+ error: true,
42
+ message,
43
+ code,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Send a 400 Bad Request error
49
+ */
50
+ export function badRequest(res, message, code = ErrorCodes.BAD_REQUEST) {
51
+ return sendError(res, 400, message, code);
52
+ }
53
+
54
+ /**
55
+ * Send a 401 Unauthorized error
56
+ */
57
+ export function unauthorized(res, message = 'Unauthorized') {
58
+ return sendError(res, 401, message, ErrorCodes.UNAUTHORIZED);
59
+ }
60
+
61
+ /**
62
+ * Send a 403 Forbidden error
63
+ */
64
+ export function forbidden(res, message = 'Forbidden') {
65
+ return sendError(res, 403, message, ErrorCodes.FORBIDDEN);
66
+ }
67
+
68
+ /**
69
+ * Send a 404 Not Found error
70
+ */
71
+ export function notFound(res, message = 'Not found') {
72
+ return sendError(res, 404, message, ErrorCodes.NOT_FOUND);
73
+ }
74
+
75
+ /**
76
+ * Send a 429 Rate Limited error
77
+ */
78
+ export function rateLimited(res, message = 'Too many requests. Please wait a moment.') {
79
+ return sendError(res, 429, message, ErrorCodes.RATE_LIMITED);
80
+ }
81
+
82
+ /**
83
+ * Send a 500 Internal Server Error
84
+ * Automatically sanitizes error messages in production
85
+ */
86
+ export function internalError(res, message = 'Internal server error', code = ErrorCodes.INTERNAL_ERROR) {
87
+ // Sanitize error messages in production to avoid leaking implementation details
88
+ const sanitizedMessage = (process.env.NODE_ENV === 'production' && typeof message === 'string')
89
+ ? 'Internal server error'
90
+ : message;
91
+ return sendError(res, 500, sanitizedMessage, code);
92
+ }
93
+
94
+ /**
95
+ * Custom error class with status code and error code
96
+ */
97
+ export class AppError extends Error {
98
+ constructor(message, statusCode = 500, code = ErrorCodes.INTERNAL_ERROR) {
99
+ super(message);
100
+ this.name = 'AppError';
101
+ this.statusCode = statusCode;
102
+ this.code = code;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Sanitize error messages for client response
108
+ * Removes stack traces and sensitive info
109
+ * @param {Error} error - The error to sanitize
110
+ * @returns {string} - Safe error message
111
+ */
112
+ export function sanitizeErrorMessage(error) {
113
+ if (error instanceof AppError) {
114
+ return error.message;
115
+ }
116
+ // For unknown errors, return generic message in production
117
+ if (process.env.NODE_ENV === 'production') {
118
+ return 'An unexpected error occurred';
119
+ }
120
+ return error.message || 'An unexpected error occurred';
121
+ }
122
+
123
+ /**
124
+ * Create a standardized error response object
125
+ * @param {string} message - Error message
126
+ * @param {string} code - Error code
127
+ * @returns {Object} - Error response object
128
+ */
129
+ export function createErrorResponse(message, code = ErrorCodes.INTERNAL_ERROR) {
130
+ return {
131
+ error: true,
132
+ message,
133
+ code,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Alias for internalError (used by some modules)
139
+ */
140
+ export const serverError = internalError;
141
+
142
+ /**
143
+ * Namespace export for modules that import { errors }
144
+ */
145
+ export const errors = {
146
+ ErrorCodes,
147
+ sendError,
148
+ badRequest,
149
+ unauthorized,
150
+ forbidden,
151
+ notFound,
152
+ rateLimited,
153
+ internalError,
154
+ serverError,
155
+ AppError,
156
+ sanitizeErrorMessage,
157
+ createErrorResponse,
158
+ };