@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,72 @@
1
+ /**
2
+ * Faster-Whisper STT Provider
3
+ *
4
+ * Calls a local faster-whisper-server instance via its OpenAI-compatible API.
5
+ * User must run faster-whisper-server separately (e.g., via pip or Docker).
6
+ * No API key needed — it's a local service.
7
+ *
8
+ * Server: https://github.com/fedirz/faster-whisper-server
9
+ * Install: pip install faster-whisper-server
10
+ * Run: faster-whisper-server --host 0.0.0.0 --port 8000
11
+ */
12
+
13
+ import fs from 'fs/promises';
14
+ import path from 'path';
15
+
16
+ /**
17
+ * Transcribe audio using a local Faster-Whisper server
18
+ * @param {string} audioPath - Path to the audio file
19
+ * @param {Object} config - Runtime config object
20
+ * @returns {Promise<string>} Transcribed text
21
+ */
22
+ export async function transcribe(audioPath, config) {
23
+ const baseUrl = config.fasterWhisperUrl || process.env.FASTER_WHISPER_URL;
24
+ if (!baseUrl) {
25
+ throw new Error('Faster-Whisper server URL not configured');
26
+ }
27
+
28
+ // Normalize URL — remove trailing slash
29
+ const url = `${baseUrl.replace(/\/+$/, '')}/v1/audio/transcriptions`;
30
+
31
+ const audioBuffer = await fs.readFile(audioPath);
32
+ const ext = path.extname(audioPath).slice(1) || 'webm';
33
+ const blob = new Blob([audioBuffer], { type: `audio/${ext}` });
34
+
35
+ const formData = new FormData();
36
+ formData.append('file', blob, `audio.${ext}`);
37
+ formData.append('model', 'whisper-large-v3-turbo'); // faster-whisper-server ignores this if model is fixed
38
+ formData.append('language', 'en');
39
+
40
+ const controller = new AbortController();
41
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
42
+
43
+ try {
44
+ const response = await fetch(url, {
45
+ method: 'POST',
46
+ body: formData,
47
+ signal: controller.signal,
48
+ });
49
+
50
+ clearTimeout(timeoutId);
51
+
52
+ if (!response.ok) {
53
+ const error = await response.text();
54
+ throw new Error(`Faster-Whisper server error: ${response.status} - ${error}`);
55
+ }
56
+
57
+ const data = await response.json();
58
+ return data.text || '';
59
+ } catch (error) {
60
+ clearTimeout(timeoutId);
61
+
62
+ if (error.name === 'AbortError') {
63
+ throw new Error('Faster-Whisper request timed out (30s). Is the server running?');
64
+ }
65
+
66
+ if (error.code === 'ECONNREFUSED') {
67
+ throw new Error(`Faster-Whisper server not reachable at ${baseUrl}. Is it running?`);
68
+ }
69
+
70
+ throw error;
71
+ }
72
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Groq STT Provider
3
+ *
4
+ * Uses Groq's OpenAI-compatible Audio Transcriptions API.
5
+ * Requires a Groq API key. Free tier available.
6
+ */
7
+
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+
11
+ const GROQ_BASE_URL = 'https://api.groq.com/openai/v1/audio/transcriptions';
12
+
13
+ /**
14
+ * Transcribe audio using Groq API
15
+ * @param {string} audioPath - Path to the audio file
16
+ * @param {Object} config - Runtime config object
17
+ * @returns {Promise<string>} Transcribed text
18
+ */
19
+ export async function transcribe(audioPath, config) {
20
+ const apiKey = config.groqApiKey || process.env.GROQ_API_KEY;
21
+ if (!apiKey) {
22
+ throw new Error('Groq API key not configured');
23
+ }
24
+
25
+ const model = config.groqSttModel || 'whisper-large-v3-turbo';
26
+
27
+ const audioBuffer = await fs.readFile(audioPath);
28
+ const ext = path.extname(audioPath).slice(1) || 'webm';
29
+ const blob = new Blob([audioBuffer], { type: `audio/${ext}` });
30
+
31
+ const formData = new FormData();
32
+ formData.append('file', blob, `audio.${ext}`);
33
+ formData.append('model', model);
34
+ formData.append('language', 'en');
35
+
36
+ const response = await fetch(GROQ_BASE_URL, {
37
+ method: 'POST',
38
+ headers: {
39
+ 'Authorization': `Bearer ${apiKey}`,
40
+ },
41
+ body: formData,
42
+ });
43
+
44
+ if (!response.ok) {
45
+ const error = await response.text();
46
+ throw new Error(`Groq API error: ${response.status} - ${error}`);
47
+ }
48
+
49
+ const data = await response.json();
50
+ return data.text || '';
51
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * STT Module - Speech-to-Text provider abstraction
3
+ *
4
+ * Reads runtime config to determine which STT provider to use,
5
+ * then delegates to the appropriate provider module.
6
+ *
7
+ * Providers:
8
+ * - openai: OpenAI Whisper API (requires OpenAI API key)
9
+ * - groq: Groq API (requires Groq API key, free tier available)
10
+ * - faster-whisper: Local faster-whisper-server (requires running server)
11
+ * - none: STT disabled
12
+ */
13
+
14
+ import { loadConfig } from '../runtime-config.js';
15
+ import { transcribe as openaiTranscribe } from './openai.js';
16
+ import { transcribe as groqTranscribe } from './groq.js';
17
+ import { transcribe as fasterWhisperTranscribe } from './faster-whisper.js';
18
+ import { createLogger } from '../logger.js';
19
+
20
+ const log = createLogger('STT');
21
+
22
+ /**
23
+ * Resolve the effective STT provider.
24
+ * If sttProvider is 'none' but openaiApiKey exists, auto-detect to 'openai' (backward compat).
25
+ * @param {Object} config - Runtime config
26
+ * @returns {string} Effective provider name
27
+ */
28
+ function resolveProvider(config) {
29
+ const provider = config.sttProvider;
30
+
31
+ // Explicit provider set — use it
32
+ if (provider && provider !== 'none') {
33
+ return provider;
34
+ }
35
+
36
+ // Auto-detect: if openaiApiKey is available and no provider explicitly set, use OpenAI
37
+ if (config.openaiApiKey || process.env.OPENAI_API_KEY) {
38
+ return 'openai';
39
+ }
40
+
41
+ return 'none';
42
+ }
43
+
44
+ /**
45
+ * Transcribe audio using the configured STT provider
46
+ * @param {string} audioPath - Path to the audio file
47
+ * @returns {Promise<string>} Transcribed text, or empty string on error
48
+ */
49
+ export async function transcribe(audioPath) {
50
+ const config = await loadConfig();
51
+ const provider = resolveProvider(config);
52
+
53
+ if (provider === 'none') {
54
+ log.warn('No STT provider configured. Set one in Settings → Voice & STT.');
55
+ return '';
56
+ }
57
+
58
+ try {
59
+ let text = '';
60
+
61
+ switch (provider) {
62
+ case 'openai':
63
+ text = await openaiTranscribe(audioPath, config);
64
+ break;
65
+ case 'groq':
66
+ text = await groqTranscribe(audioPath, config);
67
+ break;
68
+ case 'faster-whisper':
69
+ text = await fasterWhisperTranscribe(audioPath, config);
70
+ break;
71
+ default:
72
+ log.error(`Unknown provider: ${provider}`);
73
+ return '';
74
+ }
75
+
76
+ log.debug(`[${provider}] Transcribed: "${text}"`);
77
+ return text;
78
+ } catch (error) {
79
+ log.error(`[${provider}] Transcription error:`, error.message);
80
+ return '';
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Detect if a local Faster-Whisper server is running at localhost:8000
86
+ * @returns {Promise<{fasterWhisperAvailable: boolean}>}
87
+ */
88
+ export async function detectLocalSTT() {
89
+ try {
90
+ const controller = new AbortController();
91
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
92
+ const resp = await fetch('http://localhost:8000/health', {
93
+ signal: controller.signal,
94
+ }).catch(() => null);
95
+ clearTimeout(timeoutId);
96
+
97
+ if (resp) {
98
+ return { fasterWhisperAvailable: true };
99
+ }
100
+
101
+ // Some servers don't have /health, try root
102
+ const controller2 = new AbortController();
103
+ const timeoutId2 = setTimeout(() => controller2.abort(), 2000);
104
+ const resp2 = await fetch('http://localhost:8000', {
105
+ signal: controller2.signal,
106
+ }).catch(() => null);
107
+ clearTimeout(timeoutId2);
108
+
109
+ return { fasterWhisperAvailable: !!resp2 };
110
+ } catch {
111
+ return { fasterWhisperAvailable: false };
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Test the current STT configuration
117
+ * Verifies the provider is reachable and properly configured.
118
+ * @param {string} [audioPath] - Optional audio file to test with
119
+ * @returns {Promise<{success: boolean, provider: string, transcription?: string, error?: string}>}
120
+ */
121
+ export async function testSTT(audioPath) {
122
+ const config = await loadConfig();
123
+ const provider = resolveProvider(config);
124
+
125
+ if (provider === 'none') {
126
+ return { success: false, provider: 'none', error: 'No STT provider configured' };
127
+ }
128
+
129
+ // If no audio file provided, just validate config
130
+ if (!audioPath) {
131
+ try {
132
+ switch (provider) {
133
+ case 'openai': {
134
+ const key = config.openaiApiKey || process.env.OPENAI_API_KEY;
135
+ if (!key) return { success: false, provider, error: 'OpenAI API key not set' };
136
+ return { success: true, provider, message: 'OpenAI API key is configured' };
137
+ }
138
+ case 'groq': {
139
+ const key = config.groqApiKey || process.env.GROQ_API_KEY;
140
+ if (!key) return { success: false, provider, error: 'Groq API key not set' };
141
+ return { success: true, provider, message: 'Groq API key is configured' };
142
+ }
143
+ case 'faster-whisper': {
144
+ const url = config.fasterWhisperUrl || process.env.FASTER_WHISPER_URL;
145
+ if (!url) return { success: false, provider, error: 'Faster-Whisper server URL not set' };
146
+ // Try to reach the server
147
+ try {
148
+ const controller = new AbortController();
149
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
150
+ const resp = await fetch(`${url.replace(/\/+$/, '')}/health`, {
151
+ signal: controller.signal,
152
+ }).catch(() => null);
153
+ clearTimeout(timeoutId);
154
+ // Some servers don't have /health, so any response is fine
155
+ if (resp) {
156
+ return { success: true, provider, message: `Faster-Whisper server reachable at ${url}` };
157
+ }
158
+ // Try just connecting
159
+ const controller2 = new AbortController();
160
+ const timeoutId2 = setTimeout(() => controller2.abort(), 5000);
161
+ const resp2 = await fetch(url.replace(/\/+$/, ''), {
162
+ signal: controller2.signal,
163
+ }).catch(() => null);
164
+ clearTimeout(timeoutId2);
165
+ if (resp2) {
166
+ return { success: true, provider, message: `Faster-Whisper server reachable at ${url}` };
167
+ }
168
+ return { success: false, provider, error: `Cannot reach Faster-Whisper server at ${url}` };
169
+ } catch {
170
+ return { success: false, provider, error: `Cannot reach Faster-Whisper server at ${url}` };
171
+ }
172
+ }
173
+ default:
174
+ return { success: false, provider, error: `Unknown provider: ${provider}` };
175
+ }
176
+ } catch (error) {
177
+ return { success: false, provider, error: error.message };
178
+ }
179
+ }
180
+
181
+ // Test with actual audio file
182
+ try {
183
+ const text = await transcribe(audioPath);
184
+ return {
185
+ success: true,
186
+ provider,
187
+ transcription: text,
188
+ };
189
+ } catch (error) {
190
+ return {
191
+ success: false,
192
+ provider,
193
+ error: error.message,
194
+ };
195
+ }
196
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * OpenAI Whisper STT Provider
3
+ *
4
+ * Uses the OpenAI Audio Transcriptions API.
5
+ * Requires an OpenAI API key.
6
+ */
7
+
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+
11
+ /**
12
+ * Transcribe audio using OpenAI Whisper API
13
+ * @param {string} audioPath - Path to the audio file
14
+ * @param {Object} config - Runtime config object
15
+ * @returns {Promise<string>} Transcribed text
16
+ */
17
+ export async function transcribe(audioPath, config) {
18
+ const apiKey = config.openaiApiKey || process.env.OPENAI_API_KEY;
19
+ if (!apiKey) {
20
+ throw new Error('OpenAI API key not configured');
21
+ }
22
+
23
+ const model = config.openaiSttModel || 'whisper-1';
24
+
25
+ const audioBuffer = await fs.readFile(audioPath);
26
+ const ext = path.extname(audioPath).slice(1) || 'webm';
27
+ const blob = new Blob([audioBuffer], { type: `audio/${ext}` });
28
+
29
+ const formData = new FormData();
30
+ formData.append('file', blob, `audio.${ext}`);
31
+ formData.append('model', model);
32
+ formData.append('language', 'en');
33
+
34
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
35
+ method: 'POST',
36
+ headers: {
37
+ 'Authorization': `Bearer ${apiKey}`,
38
+ },
39
+ body: formData,
40
+ });
41
+
42
+ if (!response.ok) {
43
+ const error = await response.text();
44
+ throw new Error(`OpenAI Whisper API error: ${response.status} - ${error}`);
45
+ }
46
+
47
+ const data = await response.json();
48
+ return data.text || '';
49
+ }
package/server/sync.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Sync Module - Encrypted cross-device sync
3
+ */
4
+
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ import lockfile from 'proper-lockfile';
8
+ import { log } from './utils.js';
9
+ import { sanitizeSyncId } from '../utils/id-sanitize.js';
10
+ import { sendSuccess, sendError, sendNotFoundError } from '../utils/response.js';
11
+ import { verifyBearerToken } from './middleware.js';
12
+
13
+ // Maximum sync data size: 10MB (encrypted data can be large)
14
+ const MAX_SYNC_SIZE_BYTES = 10 * 1024 * 1024;
15
+ // Sync data TTL: 30 days of inactivity
16
+ const SYNC_TTL_MS = 30 * 24 * 60 * 60 * 1000;
17
+ // Minimum encryption key length (base64-encoded 256-bit key)
18
+ const MIN_ENCRYPTION_KEY_LENGTH = 32;
19
+
20
+ /**
21
+ * Validate encrypted data structure
22
+ * Checks that the data appears to be properly encrypted (not plaintext)
23
+ * @param {any} encryptedData - The encrypted data payload
24
+ * @returns {{ valid: boolean, error?: string }}
25
+ */
26
+ function validateEncryptedData(encryptedData) {
27
+ if (!encryptedData) {
28
+ return { valid: false, error: 'encryptedData required' };
29
+ }
30
+
31
+ // If it's a string, check it looks like encrypted data (base64 or similar)
32
+ if (typeof encryptedData === 'string') {
33
+ // Encrypted data should be reasonably long (at least 32 chars for IV + some ciphertext)
34
+ if (encryptedData.length < MIN_ENCRYPTION_KEY_LENGTH) {
35
+ return { valid: false, error: 'encryptedData appears too short to be properly encrypted' };
36
+ }
37
+ return { valid: true };
38
+ }
39
+
40
+ // If it's an object, check for expected encryption fields
41
+ if (typeof encryptedData === 'object') {
42
+ // Common encrypted data structure: { iv, ciphertext } or { encrypted, nonce }
43
+ const hasIv = encryptedData.iv && typeof encryptedData.iv === 'string';
44
+ const hasCiphertext = encryptedData.ciphertext && typeof encryptedData.ciphertext === 'string';
45
+ const hasEncrypted = encryptedData.encrypted && typeof encryptedData.encrypted === 'string';
46
+ const hasNonce = encryptedData.nonce && typeof encryptedData.nonce === 'string';
47
+
48
+ if ((hasIv && hasCiphertext) || (hasEncrypted && hasNonce) || hasEncrypted) {
49
+ return { valid: true };
50
+ }
51
+
52
+ // Also accept raw encrypted string in object
53
+ if (encryptedData.data && typeof encryptedData.data === 'string') {
54
+ return { valid: true };
55
+ }
56
+
57
+ return { valid: false, error: 'encryptedData missing required encryption fields (iv/ciphertext or encrypted/nonce)' };
58
+ }
59
+
60
+ return { valid: false, error: 'encryptedData must be a string or object' };
61
+ }
62
+
63
+ /**
64
+ * Cleanup old sync files that haven't been accessed in SYNC_TTL_MS
65
+ * @param {string} syncDir - Directory containing sync files
66
+ */
67
+ async function cleanupOldSyncFiles(syncDir) {
68
+ try {
69
+ const files = await fs.readdir(syncDir);
70
+ const now = Date.now();
71
+ let cleaned = 0;
72
+
73
+ for (const file of files) {
74
+ if (!file.endsWith('.json')) continue;
75
+
76
+ const filePath = path.join(syncDir, file);
77
+ try {
78
+ const stat = await fs.stat(filePath);
79
+ const age = now - stat.mtimeMs;
80
+
81
+ if (age > SYNC_TTL_MS) {
82
+ await fs.unlink(filePath);
83
+ cleaned++;
84
+ log('debug', `[Sync] Cleaned up stale sync file: ${file}`);
85
+ }
86
+ } catch (statError) {
87
+ // Skip files we can't stat
88
+ }
89
+ }
90
+
91
+ if (cleaned > 0) {
92
+ log('info', `[Sync] Cleaned up ${cleaned} stale sync files`);
93
+ }
94
+ } catch (cleanupError) {
95
+ log('error', '[Sync] Cleanup error:', cleanupError.message);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Middleware to validate Content-Length before receiving body
101
+ * @param {number} maxSize - Maximum allowed body size in bytes
102
+ */
103
+ function validateContentLength(maxSize) {
104
+ return (req, res, next) => {
105
+ const contentLength = parseInt(req.get('Content-Length') || '0', 10);
106
+
107
+ if (contentLength > maxSize) {
108
+ log('warn', `[Sync] Rejected request: Content-Length ${contentLength} exceeds max ${maxSize}`);
109
+ return sendError(res, `Request too large (max ${maxSize / 1024 / 1024}MB)`, 413);
110
+ }
111
+
112
+ next();
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Setup sync routes
118
+ * @param {Express} app - Express app instance
119
+ * @param {string} syncDir - Directory to store sync data
120
+ */
121
+ export function setupSyncRoutes(app, syncDir) {
122
+ // Ensure sync directory exists
123
+ fs.mkdir(syncDir, { recursive: true }).catch(() => {});
124
+
125
+ // Run cleanup on startup and every 24 hours
126
+ cleanupOldSyncFiles(syncDir);
127
+ setInterval(() => cleanupOldSyncFiles(syncDir), 24 * 60 * 60 * 1000);
128
+
129
+ // Push encrypted history to server
130
+ app.post('/api/sync/push', verifyBearerToken, validateContentLength(MAX_SYNC_SIZE_BYTES), async (req, res) => {
131
+ try {
132
+ const { syncId, encryptedData, timestamp } = req.body;
133
+
134
+ // Validate syncId using centralized utility
135
+ const syncIdResult = sanitizeSyncId(syncId);
136
+ if (!syncIdResult.valid) {
137
+ return sendError(res, syncIdResult.error, 400);
138
+ }
139
+ const safeSyncId = syncIdResult.sanitized;
140
+
141
+ // Validate encrypted data structure
142
+ const encryptionResult = validateEncryptedData(encryptedData);
143
+ if (!encryptionResult.valid) {
144
+ return sendError(res, encryptionResult.error, 400);
145
+ }
146
+
147
+ // Double-check size limit (defense in depth - body parser may have already limited)
148
+ const dataSize = typeof encryptedData === 'string'
149
+ ? encryptedData.length
150
+ : JSON.stringify(encryptedData).length;
151
+ if (dataSize > MAX_SYNC_SIZE_BYTES) {
152
+ log('warn', `[Sync] Rejected push: size ${(dataSize / 1024 / 1024).toFixed(2)}MB exceeds limit`);
153
+ return sendError(res, `Sync data too large (max ${MAX_SYNC_SIZE_BYTES / 1024 / 1024}MB)`, 413);
154
+ }
155
+
156
+ const syncFile = path.join(syncDir, `${safeSyncId}.json`);
157
+
158
+ // Use atomic write with file locking to prevent concurrent write corruption
159
+ let release;
160
+ try {
161
+ // Create file if it doesn't exist (needed for lockfile)
162
+ try {
163
+ await fs.access(syncFile);
164
+ } catch {
165
+ await fs.writeFile(syncFile, JSON.stringify({ encryptedData: null, timestamp: 0, updatedAt: Date.now() }));
166
+ }
167
+
168
+ release = await lockfile.lock(syncFile, {
169
+ retries: { retries: 5, minTimeout: 50, maxTimeout: 500 }
170
+ });
171
+
172
+ await fs.writeFile(syncFile, JSON.stringify({
173
+ encryptedData,
174
+ timestamp: timestamp || Date.now(),
175
+ updatedAt: Date.now()
176
+ }, null, 2));
177
+
178
+ log('debug', `[Sync] Pushed data for syncId: ${safeSyncId.substring(0, 8)}...`);
179
+ return sendSuccess(res, { timestamp: Date.now() });
180
+ } finally {
181
+ if (release) {
182
+ await release();
183
+ }
184
+ }
185
+ } catch (pushError) {
186
+ log('error', '[Sync] Push error:', pushError.message);
187
+ return sendError(res, 'Sync push failed', 500);
188
+ }
189
+ });
190
+
191
+ // Pull encrypted history from server
192
+ app.get('/api/sync/pull', verifyBearerToken, async (req, res) => {
193
+ try {
194
+ const { syncId } = req.query;
195
+
196
+ // Validate syncId using centralized utility
197
+ const syncIdResult = sanitizeSyncId(syncId);
198
+ if (!syncIdResult.valid) {
199
+ return sendError(res, syncIdResult.error, 400);
200
+ }
201
+ const safeSyncId = syncIdResult.sanitized;
202
+
203
+ const syncFile = path.join(syncDir, `${safeSyncId}.json`);
204
+
205
+ try {
206
+ const fileContent = await fs.readFile(syncFile, 'utf8');
207
+ const syncData = JSON.parse(fileContent);
208
+ return sendSuccess(res, syncData);
209
+ } catch (readError) {
210
+ return sendNotFoundError(res, 'No sync data found');
211
+ }
212
+ } catch (pullError) {
213
+ log('error', '[Sync] Pull error:', pullError.message);
214
+ return sendError(res, 'Sync pull failed', 500);
215
+ }
216
+ });
217
+
218
+ // Check if sync data exists
219
+ app.get('/api/sync/check', verifyBearerToken, async (req, res) => {
220
+ try {
221
+ const { syncId } = req.query;
222
+
223
+ // Validate syncId using centralized utility
224
+ const syncIdResult = sanitizeSyncId(syncId);
225
+ if (!syncIdResult.valid) {
226
+ return sendError(res, syncIdResult.error, 400);
227
+ }
228
+ const safeSyncId = syncIdResult.sanitized;
229
+
230
+ const syncFile = path.join(syncDir, `${safeSyncId}.json`);
231
+
232
+ try {
233
+ const stat = await fs.stat(syncFile);
234
+ return sendSuccess(res, { exists: true, updatedAt: stat.mtimeMs });
235
+ } catch (statError) {
236
+ return sendSuccess(res, { exists: false });
237
+ }
238
+ } catch (checkError) {
239
+ return sendError(res, 'Sync check failed', 500);
240
+ }
241
+ });
242
+ }
243
+
244
+ export default { setupSyncRoutes };