@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,318 @@
1
+ /**
2
+ * Gateway WebSocket Proxy - Forwards client connections to OpenClaw Gateway
3
+ *
4
+ * This module creates a WebSocket endpoint at /ws/gateway that proxies
5
+ * connections to the local OpenClaw Gateway, enabling browser clients to
6
+ * communicate directly with the Gateway.
7
+ */
8
+
9
+ import { WebSocketServer, WebSocket } from 'ws';
10
+ import { log } from './utils.js';
11
+ import { ALLOWED_ORIGINS } from './config.js';
12
+
13
+ // Default Gateway WebSocket URL
14
+ const GATEWAY_PROXY_TARGET = process.env.GATEWAY_PROXY_TARGET || 'ws://localhost:18789/ws';
15
+
16
+ // Connection limits
17
+ const MAX_PROXY_CONNECTIONS = 10;
18
+ const MAX_CONNECTIONS_PER_IP = 5;
19
+ const ipConnectionCounts = new Map(); // ip -> count
20
+
21
+ // Track connected proxy clients for sync broadcasts
22
+ const proxyClients = new Map(); // clientId -> { ws, gatewayWs }
23
+
24
+ /**
25
+ * Verify if the origin is allowed for proxy connections
26
+ * Checks localhost, 127.0.0.1, *.ts.net, and ALLOWED_ORIGINS
27
+ * @param {string|undefined} origin - Origin header value
28
+ * @returns {boolean} - True if origin is allowed
29
+ */
30
+ function verifyProxyOrigin(origin) {
31
+ // NOTE: Unlike /ws (which rejects null origins per L-02), the proxy ALLOWS null origins.
32
+ // This is intentional: non-browser WS clients (Node.js, curl, server-side tools) don't send
33
+ // Origin headers, and the Gateway itself validates the forwarded Bearer token.
34
+ // The /ws endpoint is browser-only, so rejecting null there prevents non-browser abuse.
35
+ // Here, the proxy serves both browser and non-browser clients.
36
+ if (!origin) return true;
37
+
38
+ try {
39
+ const url = new URL(origin);
40
+ const hostname = url.hostname;
41
+
42
+ // Allow localhost and loopback
43
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
44
+ return true;
45
+ }
46
+
47
+ // Allow Tailscale domains (*.ts.net)
48
+ if (hostname.endsWith('.ts.net')) {
49
+ return true;
50
+ }
51
+
52
+ // Check configured ALLOWED_ORIGINS
53
+ if (ALLOWED_ORIGINS && ALLOWED_ORIGINS.includes(origin)) {
54
+ return true;
55
+ }
56
+ } catch (e) {
57
+ // Invalid origin URL
58
+ return false;
59
+ }
60
+
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Sets up the Gateway WebSocket proxy on path /gateway
66
+ * Uses noServer mode to avoid conflicts with other WebSocket servers
67
+ * @param {http.Server} server - HTTP server instance
68
+ */
69
+ export function setupGatewayProxy(server) {
70
+ const wss = new WebSocketServer({ noServer: true });
71
+
72
+ // Handle upgrade requests for /gateway path
73
+ server.on('upgrade', (request, socket, head) => {
74
+ const { pathname } = new URL(request.url, `http://${request.headers.host}`);
75
+ log('debug', `[Gateway Proxy] Upgrade request for: ${pathname}`);
76
+
77
+ if (pathname === '/gateway') {
78
+ log('debug', '[Gateway Proxy] Handling /gateway upgrade');
79
+ wss.handleUpgrade(request, socket, head, (ws) => {
80
+ log('debug', '[Gateway Proxy] Upgrade complete, emitting connection');
81
+ wss.emit('connection', ws, request);
82
+ });
83
+ }
84
+ // Don't handle other paths - let other WebSocket servers handle them
85
+ });
86
+
87
+ wss.on('connection', (clientWs, req) => {
88
+ const clientIp = req.socket.remoteAddress;
89
+ const clientId = `proxy-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
90
+
91
+ // Origin validation
92
+ const origin = req.headers.origin;
93
+ if (!verifyProxyOrigin(origin)) {
94
+ log('warn', `[Gateway Proxy] Rejected connection from ${clientIp} — invalid origin: ${origin}`);
95
+ clientWs.close(1008, 'Origin not allowed');
96
+ return;
97
+ }
98
+
99
+ // Global connection limit
100
+ if (proxyClients.size >= MAX_PROXY_CONNECTIONS) {
101
+ log('warn', `[Gateway Proxy] Rejected connection from ${clientIp} — max connections reached (${MAX_PROXY_CONNECTIONS})`);
102
+ clientWs.close(1013, 'Too many connections');
103
+ return;
104
+ }
105
+
106
+ // Per-IP connection limit
107
+ const currentIpCount = ipConnectionCounts.get(clientIp) || 0;
108
+ if (currentIpCount >= MAX_CONNECTIONS_PER_IP) {
109
+ log('warn', `[Gateway Proxy] Rejected connection from ${clientIp} — per-IP limit reached (${MAX_CONNECTIONS_PER_IP})`);
110
+ clientWs.close(1013, 'Too many connections from this IP');
111
+ return;
112
+ }
113
+ ipConnectionCounts.set(clientIp, currentIpCount + 1);
114
+
115
+ log('info', `[Gateway Proxy] Client connected from ${clientIp} (${clientId})`);
116
+
117
+ // Get auth token from query param (browser WS can't set headers) or Authorization header
118
+ // NOTE (M-37 / C-04): Token in query string is INTENTIONAL for browser WebSocket compatibility.
119
+ // Browser WebSocket API cannot set custom headers, so we accept ?token= query param.
120
+ // The proxy correctly forwards this as Authorization: Bearer header to the Gateway.
121
+ // Risk is minimal on local/Tailscale networks (no proxy/CDN logging query strings).
122
+ // This is the same pattern used by OpenClaw's own browser clients.
123
+ const url = new URL(req.url, `http://${req.headers.host}`);
124
+ const queryToken = url.searchParams.get('token');
125
+ const authHeader = req.headers.authorization;
126
+ const token = queryToken || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : authHeader);
127
+
128
+ // Create connection options for the Gateway
129
+ const gatewayOptions = {};
130
+ if (token) {
131
+ // Forward token as proper Bearer header to Gateway (Gateway validates it)
132
+ gatewayOptions.headers = {
133
+ 'Authorization': `Bearer ${token}`
134
+ };
135
+ }
136
+
137
+ // Create outbound connection to Gateway
138
+ let gatewayWs;
139
+ try {
140
+ gatewayWs = new WebSocket(GATEWAY_PROXY_TARGET, gatewayOptions);
141
+ } catch (err) {
142
+ log('error', '[Gateway Proxy] Failed to create Gateway connection:', err.message);
143
+ clientWs.close(1011, 'Failed to connect to Gateway');
144
+ return;
145
+ }
146
+
147
+ let clientClosed = false;
148
+ let gatewayClosed = false;
149
+
150
+ // Forward messages from client to Gateway
151
+ clientWs.on('message', (data) => {
152
+ if (gatewayWs.readyState === WebSocket.OPEN) {
153
+ try {
154
+ gatewayWs.send(data);
155
+ } catch (err) {
156
+ log('error', '[Gateway Proxy] Error forwarding to Gateway:', err.message);
157
+ }
158
+ }
159
+ });
160
+
161
+ // Forward messages from Gateway to client
162
+ gatewayWs.on('message', (data) => {
163
+ if (clientWs.readyState === WebSocket.OPEN) {
164
+ try {
165
+ clientWs.send(data);
166
+ } catch (err) {
167
+ log('error', '[Gateway Proxy] Error forwarding to client:', err.message);
168
+ }
169
+ }
170
+ });
171
+
172
+ // Handle client close
173
+ clientWs.on('close', (code, reason) => {
174
+ clientClosed = true;
175
+ log('info', `[Gateway Proxy] Client disconnected (code: ${code})`);
176
+ proxyClients.delete(clientId);
177
+ // Decrement per-IP connection count
178
+ const count = ipConnectionCounts.get(clientIp) || 1;
179
+ if (count <= 1) {
180
+ ipConnectionCounts.delete(clientIp);
181
+ } else {
182
+ ipConnectionCounts.set(clientIp, count - 1);
183
+ }
184
+ if (!gatewayClosed && gatewayWs.readyState === WebSocket.OPEN) {
185
+ gatewayWs.close();
186
+ }
187
+ });
188
+
189
+ // Handle Gateway close
190
+ gatewayWs.on('close', (code, reason) => {
191
+ gatewayClosed = true;
192
+ log('info', `[Gateway Proxy] Gateway disconnected (code: ${code})`);
193
+ if (!clientClosed && clientWs.readyState === WebSocket.OPEN) {
194
+ clientWs.close();
195
+ }
196
+ });
197
+
198
+ // Handle client errors
199
+ clientWs.on('error', (err) => {
200
+ log('error', '[Gateway Proxy] Client error:', err.message);
201
+ if (!gatewayClosed && gatewayWs.readyState === WebSocket.OPEN) {
202
+ gatewayWs.close();
203
+ }
204
+ });
205
+
206
+ // Handle Gateway errors
207
+ gatewayWs.on('error', (err) => {
208
+ log('error', '[Gateway Proxy] Gateway error:', err.message);
209
+ // Send error to client if still connected
210
+ if (clientWs.readyState === WebSocket.OPEN) {
211
+ try {
212
+ clientWs.send(JSON.stringify({
213
+ type: 'error',
214
+ error: 'Gateway connection error: ' + err.message
215
+ }));
216
+ } catch (e) {
217
+ // Ignore send errors
218
+ }
219
+ clientWs.close(1011, 'Gateway connection error');
220
+ }
221
+ });
222
+
223
+ // Handle Gateway connection open
224
+ gatewayWs.on('open', () => {
225
+ log('debug', '[Gateway Proxy] Connected to Gateway');
226
+ // Track client for sync broadcasts
227
+ proxyClients.set(clientId, { ws: clientWs, gatewayWs, lastActivity: Date.now() });
228
+ });
229
+
230
+ // Keepalive ping/pong to prevent proxy timeouts (Tailscale, etc.)
231
+ const keepaliveInterval = setInterval(() => {
232
+ if (clientWs.readyState === WebSocket.OPEN) {
233
+ try {
234
+ clientWs.ping();
235
+ } catch (err) {
236
+ log('warn', '[Gateway Proxy] Ping failed:', err.message);
237
+ }
238
+ }
239
+ if (gatewayWs.readyState === WebSocket.OPEN) {
240
+ try {
241
+ gatewayWs.ping();
242
+ } catch (err) {
243
+ log('warn', '[Gateway Proxy] Gateway ping failed:', err.message);
244
+ }
245
+ }
246
+ }, 15000); // 15 seconds
247
+
248
+ // Clean up interval on close
249
+ const cleanupKeepalive = () => {
250
+ clearInterval(keepaliveInterval);
251
+ };
252
+ clientWs.on('close', cleanupKeepalive);
253
+ gatewayWs.on('close', cleanupKeepalive);
254
+ });
255
+
256
+ // Clean up on close (add to existing close handlers above)
257
+ wss.on('close', () => {
258
+ proxyClients.clear();
259
+ });
260
+
261
+ log('info', `[Gateway Proxy] WebSocket proxy ready at /gateway -> ${GATEWAY_PROXY_TARGET}`);
262
+ return wss;
263
+ }
264
+
265
+ /**
266
+ * Broadcast a message to all Gateway proxy clients
267
+ * Used for cross-device sync to reach mobile clients
268
+ * @param {Object|string} message - Message to send
269
+ * @returns {{ sent: number, failed: number }}
270
+ */
271
+ export function broadcastToProxyClients(message) {
272
+ const data = typeof message === 'string' ? message : JSON.stringify(message);
273
+ let sent = 0;
274
+ let failed = 0;
275
+
276
+ log('info', `[Gateway Proxy] Broadcasting to ${proxyClients.size} proxy clients`);
277
+
278
+ for (const [clientId, { ws }] of proxyClients) {
279
+ try {
280
+ if (ws.readyState === WebSocket.OPEN) {
281
+ ws.send(data);
282
+ sent++;
283
+ log('debug', `[Gateway Proxy] Sent to ${clientId}`);
284
+ } else {
285
+ log('warn', `[Gateway Proxy] Client ${clientId} not open (state: ${ws.readyState})`);
286
+ }
287
+ } catch (err) {
288
+ failed++;
289
+ log('warn', `[Gateway Proxy] Broadcast failed for ${clientId}: ${err.message}`);
290
+ }
291
+ }
292
+
293
+ log('info', `[Gateway Proxy] Broadcast complete: ${sent} sent, ${failed} failed`);
294
+
295
+ return { sent, failed };
296
+ }
297
+
298
+ /**
299
+ * Get count of connected proxy clients
300
+ */
301
+ export function getProxyClientCount() {
302
+ return proxyClients.size;
303
+ }
304
+
305
+ /**
306
+ * Get proxy client info for debugging
307
+ */
308
+ export function getProxyClientInfo() {
309
+ const clients = [];
310
+ for (const [clientId, { ws }] of proxyClients) {
311
+ clients.push({
312
+ id: clientId,
313
+ readyState: ws.readyState,
314
+ readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][ws.readyState] || 'UNKNOWN'
315
+ });
316
+ }
317
+ return { count: proxyClients.size, clients };
318
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Uplink Server Modules
3
+ *
4
+ * This directory contains extracted modules from the main server.js
5
+ * to improve maintainability and separation of concerns.
6
+ *
7
+ * Module Structure:
8
+ * - utils.js - Common utilities (logging, fetch, cleanup, SSRF prevention)
9
+ * - tts.js - ElevenLabs TTS (configurable voice)
10
+ * - share.js - Public share links for conversations
11
+ * - sync.js - Encrypted cross-device sync
12
+ *
13
+ * Future extractions (still in server.js):
14
+ * - chat.js - Chat endpoints (voice and text)
15
+ * - webhooks.js - External webhook integrations
16
+ * - websocket.js - WebSocket handling
17
+ */
18
+
19
+ export * from './utils.js';
20
+ export * from './tts.js';
21
+ export { setupShareRoutes } from './share.js';
22
+ export { setupSyncRoutes } from './sync.js';
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Structured logger for Uplink
3
+ *
4
+ * Usage:
5
+ * import { createLogger } from './logger.js';
6
+ * const log = createLogger('my-module');
7
+ *
8
+ * log.debug('verbose debugging info', { data });
9
+ * log.info('normal operational message');
10
+ * log.warn('something fishy', error);
11
+ * log.error('something broke', error);
12
+ *
13
+ * Environment:
14
+ * LOG_LEVEL=debug|info|warn|error|silent (default: info)
15
+ */
16
+
17
+ const LOG_LEVELS = {
18
+ debug: 0,
19
+ info: 1,
20
+ warn: 2,
21
+ error: 3,
22
+ silent: 4,
23
+ };
24
+
25
+ const COLORS = {
26
+ debug: '\x1b[36m', // Cyan
27
+ info: '\x1b[32m', // Green
28
+ warn: '\x1b[33m', // Yellow
29
+ error: '\x1b[31m', // Red
30
+ reset: '\x1b[0m',
31
+ gray: '\x1b[90m',
32
+ };
33
+
34
+ const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL?.toLowerCase()] ?? LOG_LEVELS.info;
35
+
36
+ function timestamp() {
37
+ return new Date().toISOString();
38
+ }
39
+
40
+ function formatArgs(args) {
41
+ return args.map(arg => {
42
+ if (arg instanceof Error) {
43
+ return arg.stack || arg.message;
44
+ }
45
+ if (typeof arg === 'object' && arg !== null) {
46
+ try {
47
+ return JSON.stringify(arg);
48
+ } catch {
49
+ return String(arg);
50
+ }
51
+ }
52
+ return String(arg);
53
+ }).join(' ');
54
+ }
55
+
56
+ function writeLog(level, module, ...args) {
57
+ const levelNum = LOG_LEVELS[level];
58
+ if (levelNum < currentLevel) return;
59
+
60
+ const ts = timestamp();
61
+ const color = COLORS[level];
62
+ const reset = COLORS.reset;
63
+ const gray = COLORS.gray;
64
+
65
+ const prefix = `${gray}${ts}${reset} ${color}[${level.toUpperCase()}]${reset} [${module}]`;
66
+ const message = formatArgs(args);
67
+
68
+ const consoleFn = level === 'error' ? console.error :
69
+ level === 'warn' ? console.warn :
70
+ console.log;
71
+
72
+ consoleFn(`${prefix} ${message}`);
73
+ }
74
+
75
+ /**
76
+ * Create a logger instance for a module
77
+ * @param {string} moduleName
78
+ */
79
+ export function createLogger(moduleName) {
80
+ return {
81
+ debug: (...args) => writeLog('debug', moduleName, ...args),
82
+ info: (...args) => writeLog('info', moduleName, ...args),
83
+ warn: (...args) => writeLog('warn', moduleName, ...args),
84
+ error: (...args) => writeLog('error', moduleName, ...args),
85
+ };
86
+ }
87
+
88
+ /** Global default logger */
89
+ export const logger = createLogger('app');
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Authentication Middleware - Optional Defense-in-Depth
3
+ *
4
+ * DESIGN NOTES (per OpenClaw docs):
5
+ * - OpenClaw is "loopback first" — auth is defense-in-depth, not mandatory for local deployments
6
+ * - Uplink is a STANDALONE app that connects to Gateway as an operator client
7
+ * - This middleware is OPT-IN via UPLINK_AUTH_ENABLED environment variable
8
+ * - When disabled (default), all routes work as today
9
+ * - When enabled, routes require valid Bearer token in Authorization header
10
+ *
11
+ * USAGE:
12
+ * import { requireAuth } from './middleware/auth.js';
13
+ * app.use('/api', requireAuth); // Protects all /api routes
14
+ *
15
+ * CONFIGURATION:
16
+ * UPLINK_AUTH_ENABLED=true Enable auth middleware
17
+ * UPLINK_AUTH_TOKEN=your-secret Required Bearer token (must be long/random)
18
+ *
19
+ * When auth is enabled:
20
+ * - HTTP requests must include: Authorization: Bearer <token>
21
+ * - WebSocket connections must include token in query param or first message
22
+ * - set_user command validates user matches authenticated identity
23
+ */
24
+
25
+ import crypto from 'crypto';
26
+ import { log } from '../utils.js';
27
+ import { createLogger } from '../logger.js';
28
+
29
+ const authLog = createLogger('auth');
30
+
31
+ // Auth configuration from environment
32
+ const AUTH_ENABLED = process.env.UPLINK_AUTH_ENABLED === 'true';
33
+ const AUTH_TOKEN = process.env.UPLINK_AUTH_TOKEN || '';
34
+
35
+ // Minimum token length for security (prevent weak tokens)
36
+ const MIN_TOKEN_LENGTH = 32;
37
+
38
+ // Validate configuration on startup
39
+ if (AUTH_ENABLED) {
40
+ if (!AUTH_TOKEN) {
41
+ authLog.error('⚠️ UPLINK_AUTH_ENABLED=true but UPLINK_AUTH_TOKEN is not set!');
42
+ authLog.error('⚠️ Authentication will fail for all requests.');
43
+ authLog.error('⚠️ Set UPLINK_AUTH_TOKEN to a long random string (min 32 chars).');
44
+ } else if (AUTH_TOKEN.length < MIN_TOKEN_LENGTH) {
45
+ authLog.warn(`⚠️ UPLINK_AUTH_TOKEN is only ${AUTH_TOKEN.length} chars (min recommended: ${MIN_TOKEN_LENGTH})`);
46
+ authLog.warn('⚠️ Consider using a longer, more secure token.');
47
+ } else {
48
+ authLog.info('✅ Uplink authentication enabled');
49
+ authLog.info(`Token length: ${AUTH_TOKEN.length} characters`);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Extract Bearer token from Authorization header
55
+ * @param {Request} req - Express request object
56
+ * @returns {string|null} - Extracted token or null if not found
57
+ */
58
+ function extractBearerToken(req) {
59
+ const authHeader = req.headers.authorization;
60
+ if (!authHeader) return null;
61
+
62
+ if (authHeader.startsWith('Bearer ')) {
63
+ return authHeader.slice(7); // Remove 'Bearer ' prefix
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Verify if provided token matches configured token
71
+ * Uses constant-time comparison to prevent timing attacks
72
+ * @param {string} providedToken - Token from request
73
+ * @returns {boolean} - True if token is valid
74
+ */
75
+ function verifyToken(providedToken) {
76
+ if (!providedToken || !AUTH_TOKEN) return false;
77
+ const providedBuf = Buffer.from(String(providedToken));
78
+ const expectedBuf = Buffer.from(AUTH_TOKEN);
79
+ if (providedBuf.length !== expectedBuf.length) return false;
80
+ return crypto.timingSafeEqual(providedBuf, expectedBuf);
81
+ }
82
+
83
+ /**
84
+ * Authentication middleware for HTTP routes
85
+ *
86
+ * When auth is disabled: passes through all requests
87
+ * When auth is enabled: requires valid Bearer token
88
+ *
89
+ * @param {Request} req - Express request
90
+ * @param {Response} res - Express response
91
+ * @param {Function} next - Next middleware
92
+ */
93
+ export function requireAuth(req, res, next) {
94
+ // Auth disabled - passthrough
95
+ if (!AUTH_ENABLED) {
96
+ return next();
97
+ }
98
+
99
+ // Extract and verify token
100
+ const token = extractBearerToken(req);
101
+
102
+ if (!token) {
103
+ log('warn', `[Auth] Unauthorized request to ${req.path} - no token provided`);
104
+ return res.status(401).json({
105
+ error: true,
106
+ message: 'Authentication required. Include Authorization: Bearer <token> header.',
107
+ code: 'UNAUTHORIZED'
108
+ });
109
+ }
110
+
111
+ if (!verifyToken(token)) {
112
+ log('warn', `[Auth] Invalid token for ${req.path}`);
113
+ return res.status(401).json({
114
+ error: true,
115
+ message: 'Invalid authentication token.',
116
+ code: 'INVALID_TOKEN'
117
+ });
118
+ }
119
+
120
+ // Token valid - proceed
121
+ next();
122
+ }
123
+
124
+ /**
125
+ * Verify WebSocket token (from query param or upgrade request)
126
+ *
127
+ * @param {string|null} token - Token from query param or Authorization header
128
+ * @returns {boolean} - True if auth is disabled OR token is valid
129
+ */
130
+ export function verifyWebSocketToken(token) {
131
+ // Auth disabled - allow all connections
132
+ if (!AUTH_ENABLED) {
133
+ return true;
134
+ }
135
+
136
+ // Auth enabled - require valid token
137
+ return verifyToken(token);
138
+ }
139
+
140
+ /**
141
+ * Validate set_user command when auth is enabled
142
+ * Prevents session impersonation by ensuring user can only set their own identity
143
+ *
144
+ * @param {string} requestedUser - User identity being set
145
+ * @param {string|null} authenticatedUser - User from auth token (if auth enabled)
146
+ * @returns {boolean} - True if allowed
147
+ */
148
+ export function validateSetUser(requestedUser, authenticatedUser = null) {
149
+ // Auth disabled - allow any user (backward compatible)
150
+ if (!AUTH_ENABLED) {
151
+ return true;
152
+ }
153
+
154
+ // Auth enabled - must match authenticated identity
155
+ // For now, we derive authenticatedUser from session context
156
+ // In future, could embed user identity in JWT token
157
+
158
+ // TODO: When JWT tokens are implemented, decode token to get user identity
159
+ // For now, we simply require that auth is present (token was validated)
160
+ // and trust the user identity from session context
161
+
162
+ // This is a placeholder - full implementation would:
163
+ // 1. Use JWT tokens with embedded user claims
164
+ // 2. Validate requestedUser matches token.sub or token.user
165
+
166
+ return true; // Allow for now, will enhance with JWT in future
167
+ }
168
+
169
+ /**
170
+ * Check if authentication is enabled
171
+ * Useful for conditional logic in other modules
172
+ */
173
+ export function isAuthEnabled() {
174
+ return AUTH_ENABLED;
175
+ }
176
+
177
+ /**
178
+ * Get redacted auth status for logging/debugging
179
+ */
180
+ export function getAuthStatus() {
181
+ return {
182
+ enabled: AUTH_ENABLED,
183
+ tokenConfigured: AUTH_TOKEN.length > 0,
184
+ tokenLength: AUTH_TOKEN.length,
185
+ minTokenLength: MIN_TOKEN_LENGTH,
186
+ secure: AUTH_ENABLED && AUTH_TOKEN.length >= MIN_TOKEN_LENGTH
187
+ };
188
+ }