@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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- 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
|
+
};
|
package/utils/errors.js
ADDED
|
@@ -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
|
+
};
|