fluxy-bot 0.8.9 → 0.9.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.
Files changed (106) hide show
  1. package/README.md +53 -84
  2. package/bin/cli.js +1717 -6
  3. package/components.json +18 -18
  4. package/package.json +98 -109
  5. package/postcss.config.js +3 -3
  6. package/scripts/install.ps1 +1 -1
  7. package/scripts/install.sh +1 -1
  8. package/scripts/postinstall.js +47 -58
  9. package/shared/ai.ts +118 -202
  10. package/shared/config.ts +37 -39
  11. package/shared/logger.ts +6 -14
  12. package/shared/paths.ts +10 -16
  13. package/shared/relay.ts +89 -98
  14. package/supervisor/backend.ts +77 -89
  15. package/supervisor/chat/ARCHITECTURE.md +13 -14
  16. package/supervisor/chat/OnboardWizard.tsx +2194 -3419
  17. package/supervisor/chat/fluxy-main.tsx +495 -633
  18. package/supervisor/chat/fluxy.html +24 -36
  19. package/supervisor/chat/onboard-main.tsx +8 -8
  20. package/supervisor/chat/onboard.html +10 -13
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
  22. package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
  23. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
  24. package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
  25. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
  26. package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
  27. package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
  28. package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
  29. package/supervisor/chat/src/hooks/useChat.ts +210 -259
  30. package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
  31. package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
  32. package/supervisor/chat/src/lib/auth.ts +20 -23
  33. package/supervisor/chat/src/lib/ws-client.ts +113 -125
  34. package/supervisor/chat/src/styles/globals.css +76 -85
  35. package/supervisor/file-saver.ts +39 -52
  36. package/supervisor/fluxy-agent.ts +213 -292
  37. package/supervisor/index.ts +895 -1263
  38. package/supervisor/scheduler.ts +237 -299
  39. package/supervisor/tunnel.ts +113 -167
  40. package/supervisor/vite-dev.ts +51 -61
  41. package/supervisor/widget.js +135 -147
  42. package/supervisor/worker.ts +45 -49
  43. package/tsconfig.json +18 -24
  44. package/vite.config.ts +48 -55
  45. package/vite.fluxy.config.ts +34 -34
  46. package/worker/claude-auth.ts +202 -265
  47. package/worker/codex-auth.ts +144 -181
  48. package/worker/db.ts +63 -191
  49. package/worker/index.ts +601 -842
  50. package/workspace/MYSELF.md +1 -1
  51. package/workspace/PULSE.json +6 -6
  52. package/workspace/backend/index.ts +16 -19
  53. package/workspace/client/index.html +47 -60
  54. package/workspace/client/public/manifest.json +21 -21
  55. package/workspace/client/public/sw.js +29 -31
  56. package/workspace/client/src/App.tsx +91 -136
  57. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
  58. package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
  59. package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
  60. package/workspace/client/src/components/Layout/Footer.tsx +12 -12
  61. package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
  62. package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
  63. package/workspace/client/src/components/ui/avatar.tsx +85 -85
  64. package/workspace/client/src/components/ui/badge.tsx +40 -41
  65. package/workspace/client/src/components/ui/button.tsx +56 -57
  66. package/workspace/client/src/components/ui/card.tsx +75 -75
  67. package/workspace/client/src/components/ui/dialog.tsx +114 -117
  68. package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
  69. package/workspace/client/src/components/ui/input.tsx +17 -17
  70. package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
  71. package/workspace/client/src/components/ui/select.tsx +145 -151
  72. package/workspace/client/src/components/ui/separator.tsx +21 -21
  73. package/workspace/client/src/components/ui/sheet.tsx +102 -102
  74. package/workspace/client/src/components/ui/skeleton.tsx +10 -10
  75. package/workspace/client/src/components/ui/switch.tsx +26 -26
  76. package/workspace/client/src/components/ui/tabs.tsx +69 -69
  77. package/workspace/client/src/components/ui/textarea.tsx +14 -14
  78. package/workspace/client/src/components/ui/tooltip.tsx +37 -37
  79. package/workspace/client/src/lib/utils.ts +1 -1
  80. package/workspace/client/src/main.tsx +3 -3
  81. package/workspace/client/src/styles/globals.css +76 -85
  82. package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
  83. package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
  84. package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
  85. package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
  86. package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
  87. package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
  88. package/bin/cli.backup.js +0 -2138
  89. package/cli/commands/daemon.ts +0 -42
  90. package/cli/commands/init.ts +0 -32
  91. package/cli/commands/start.ts +0 -113
  92. package/cli/commands/tunnel.temp.ts +0 -206
  93. package/cli/commands/tunnel.ts +0 -227
  94. package/cli/commands/update.ts +0 -163
  95. package/cli/core/base-adapter.ts +0 -156
  96. package/cli/core/cloudflared.ts +0 -113
  97. package/cli/core/config.ts +0 -73
  98. package/cli/core/os-detector.ts +0 -43
  99. package/cli/core/server.ts +0 -109
  100. package/cli/core/types.ts +0 -15
  101. package/cli/index.ts +0 -72
  102. package/cli/platforms/darwin.ts +0 -146
  103. package/cli/platforms/index.ts +0 -21
  104. package/cli/platforms/linux.ts +0 -156
  105. package/cli/platforms/win32.ts +0 -34
  106. package/cli/utils/ui.ts +0 -37
package/worker/index.ts CHANGED
@@ -5,131 +5,66 @@ import path from 'path';
5
5
  import { loadConfig, saveConfig } from '../shared/config.js';
6
6
  import { paths, WORKSPACE_DIR } from '../shared/paths.js';
7
7
  import { log } from '../shared/logger.js';
8
- import {
9
- initDb,
10
- closeDb,
11
- listConversations,
12
- createConversation,
13
- deleteConversation,
14
- getMessages,
15
- addMessage,
16
- getSetting,
17
- getAllSettings,
18
- setSetting,
19
- createSession,
20
- getSession,
21
- deleteExpiredSessions,
22
- getRecentMessages,
23
- getMessagesBefore,
24
- addPushSubscription,
25
- removePushSubscription,
26
- getAllPushSubscriptions,
27
- getPushSubscriptionByEndpoint,
28
- createTrustedDevice,
29
- getTrustedDevice,
30
- updateDeviceLastSeen,
31
- listTrustedDevices,
32
- deleteTrustedDevice,
33
- deleteExpiredDevices,
34
- deleteAllTrustedDevices
35
- } from './db.js';
8
+ import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices } from './db.js';
36
9
  import webpush from 'web-push';
37
10
  import { TOTP } from 'otpauth';
38
11
  import QRCode from 'qrcode';
39
- import {
40
- startCodexOAuth,
41
- cancelCodexOAuth,
42
- getCodexAuthStatus,
43
- readCodexAccessToken
44
- } from './codex-auth.js';
45
- import {
46
- startClaudeOAuth,
47
- exchangeClaudeCode,
48
- getClaudeAuthStatus,
49
- readClaudeAccessToken
50
- } from './claude-auth.js';
51
- import {
52
- checkAvailability,
53
- registerHandle,
54
- claimReservedHandle,
55
- releaseHandle,
56
- updateTunnelUrl,
57
- startHeartbeat,
58
- stopHeartbeat
59
- } from '../shared/relay.js';
12
+ import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, readCodexAccessToken } from './codex-auth.js';
13
+ import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
14
+ import { checkAvailability, registerHandle, claimReservedHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
60
15
  import { ensureFileDirs } from '../supervisor/file-saver.js';
61
16
 
62
17
  // ── Password hashing (scrypt) ──
63
18
 
64
19
  function hashPassword(password: string): string {
65
- const salt = crypto.randomBytes(16).toString('hex');
66
- const hash = crypto.scryptSync(password, salt, 64).toString('hex');
67
- return `${salt}:${hash}`;
20
+ const salt = crypto.randomBytes(16).toString('hex');
21
+ const hash = crypto.scryptSync(password, salt, 64).toString('hex');
22
+ return `${salt}:${hash}`;
68
23
  }
69
24
 
70
25
  function verifyPassword(password: string, stored: string): boolean {
71
- const [salt, hash] = stored.split(':');
72
- const test = crypto.scryptSync(password, salt, 64).toString('hex');
73
- return hash === test;
26
+ const [salt, hash] = stored.split(':');
27
+ const test = crypto.scryptSync(password, salt, 64).toString('hex');
28
+ return hash === test;
74
29
  }
75
30
 
76
31
  // ── TOTP helpers ──
77
32
 
78
33
  function generateTOTPSecret(): string {
79
- return crypto
80
- .randomBytes(20)
81
- .toString('base64url')
82
- .replace(/[^A-Z2-7]/gi, '')
83
- .slice(0, 32)
84
- .toUpperCase();
34
+ return crypto.randomBytes(20).toString('base64url').replace(/[^A-Z2-7]/gi, '').slice(0, 32).toUpperCase();
85
35
  }
86
36
 
87
37
  function verifyTOTPCode(code: string, secret: string): boolean {
88
- const totp = new TOTP({
89
- issuer: 'Fluxy',
90
- algorithm: 'SHA1',
91
- digits: 6,
92
- period: 30,
93
- secret
94
- });
95
- const delta = totp.validate({ token: code, window: 1 });
96
- return delta !== null;
38
+ const totp = new TOTP({ issuer: 'Fluxy', algorithm: 'SHA1', digits: 6, period: 30, secret });
39
+ const delta = totp.validate({ token: code, window: 1 });
40
+ return delta !== null;
97
41
  }
98
42
 
99
43
  function generateRecoveryCodes(): string[] {
100
- const codes: string[] = [];
101
- for (let i = 0; i < 8; i++) {
102
- codes.push(crypto.randomBytes(4).toString('hex'));
103
- }
104
- return codes;
44
+ const codes: string[] = [];
45
+ for (let i = 0; i < 8; i++) {
46
+ codes.push(crypto.randomBytes(4).toString('hex'));
47
+ }
48
+ return codes;
105
49
  }
106
50
 
107
51
  function hashRecoveryCode(code: string): string {
108
- return crypto.createHash('sha256').update(code.toLowerCase()).digest('hex');
52
+ return crypto.createHash('sha256').update(code.toLowerCase()).digest('hex');
109
53
  }
110
54
 
111
- function verifyRecoveryCode(
112
- code: string,
113
- hashes: string[]
114
- ): { valid: boolean; remaining: string[] } {
115
- const h = hashRecoveryCode(code);
116
- const idx = hashes.indexOf(h);
117
- if (idx === -1) return { valid: false, remaining: hashes };
118
- const remaining = [...hashes];
119
- remaining.splice(idx, 1);
120
- return { valid: true, remaining };
55
+ function verifyRecoveryCode(code: string, hashes: string[]): { valid: boolean; remaining: string[] } {
56
+ const h = hashRecoveryCode(code);
57
+ const idx = hashes.indexOf(h);
58
+ if (idx === -1) return { valid: false, remaining: hashes };
59
+ const remaining = [...hashes];
60
+ remaining.splice(idx, 1);
61
+ return { valid: true, remaining };
121
62
  }
122
63
 
123
- function parseCookie(
124
- cookieHeader: string | undefined,
125
- name: string
126
- ): string | undefined {
127
- if (!cookieHeader) return undefined;
128
- const match = cookieHeader
129
- .split(';')
130
- .map(c => c.trim())
131
- .find(c => c.startsWith(`${name}=`));
132
- return match ? match.slice(name.length + 1) : undefined;
64
+ function parseCookie(cookieHeader: string | undefined, name: string): string | undefined {
65
+ if (!cookieHeader) return undefined;
66
+ const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${name}=`));
67
+ return match ? match.slice(name.length + 1) : undefined;
133
68
  }
134
69
 
135
70
  const port = parseInt(process.env.WORKER_PORT || '3001', 10);
@@ -144,23 +79,23 @@ ensureFileDirs();
144
79
  // ── VAPID key management (Web Push) ──
145
80
 
146
81
  function getOrCreateVapidKeys() {
147
- let publicKey = getSetting('vapid_public_key');
148
- let privateKey = getSetting('vapid_private_key');
149
- if (!publicKey || !privateKey) {
150
- const keys = webpush.generateVAPIDKeys();
151
- publicKey = keys.publicKey;
152
- privateKey = keys.privateKey;
153
- setSetting('vapid_public_key', publicKey);
154
- setSetting('vapid_private_key', privateKey);
155
- log.ok('Generated new VAPID keys');
156
- }
157
- return { publicKey, privateKey };
82
+ let publicKey = getSetting('vapid_public_key');
83
+ let privateKey = getSetting('vapid_private_key');
84
+ if (!publicKey || !privateKey) {
85
+ const keys = webpush.generateVAPIDKeys();
86
+ publicKey = keys.publicKey;
87
+ privateKey = keys.privateKey;
88
+ setSetting('vapid_public_key', publicKey);
89
+ setSetting('vapid_private_key', privateKey);
90
+ log.ok('Generated new VAPID keys');
91
+ }
92
+ return { publicKey, privateKey };
158
93
  }
159
94
 
160
95
  function initWebPush() {
161
- const { publicKey, privateKey } = getOrCreateVapidKeys();
162
- webpush.setVapidDetails('mailto:push@fluxy.bot', publicKey, privateKey);
163
- log.ok('Web Push initialized');
96
+ const { publicKey, privateKey } = getOrCreateVapidKeys();
97
+ webpush.setVapidDetails('mailto:push@fluxy.bot', publicKey, privateKey);
98
+ log.ok('Web Push initialized');
164
99
  }
165
100
 
166
101
  initWebPush();
@@ -171,914 +106,742 @@ app.use(express.json({ limit: '10mb' }));
171
106
 
172
107
  // Prevent browsers/CDN/relay from caching API responses (avoids stale 502s)
173
108
  app.use('/api', (_, res, next) => {
174
- res.set(
175
- 'Cache-Control',
176
- 'no-store, no-cache, must-revalidate, proxy-revalidate'
177
- );
178
- res.set('Pragma', 'no-cache');
179
- res.set('Expires', '0');
180
- res.set('Surrogate-Control', 'no-store');
181
- next();
109
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
110
+ res.set('Pragma', 'no-cache');
111
+ res.set('Expires', '0');
112
+ res.set('Surrogate-Control', 'no-store');
113
+ next();
182
114
  });
183
115
 
184
116
  app.get('/api/health', (_, res) => res.json({ status: 'ok' }));
185
117
  app.get('/api/conversations', (_, res) => res.json(listConversations()));
186
118
  app.get('/api/conversations/:id', (req, res) => {
187
- const msgs = getMessages(req.params.id);
188
- res.json({ id: req.params.id, messages: msgs });
119
+ const msgs = getMessages(req.params.id);
120
+ res.json({ id: req.params.id, messages: msgs });
189
121
  });
190
122
  app.post('/api/conversations', (req, res) => {
191
- const { title, model } = req.body || {};
192
- const conv = createConversation(title, model);
193
- res.json(conv);
123
+ const { title, model } = req.body || {};
124
+ const conv = createConversation(title, model);
125
+ res.json(conv);
194
126
  });
195
127
  app.post('/api/conversations/:id/messages', (req, res) => {
196
- const { role, content, meta } = req.body || {};
197
- if (!role || !content) {
198
- res.status(400).json({ error: 'Missing role or content' });
199
- return;
200
- }
201
- const msg = addMessage(req.params.id, role, content, meta);
202
- res.json(msg);
128
+ const { role, content, meta } = req.body || {};
129
+ if (!role || !content) { res.status(400).json({ error: 'Missing role or content' }); return; }
130
+ const msg = addMessage(req.params.id, role, content, meta);
131
+ res.json(msg);
203
132
  });
204
133
  app.get('/api/conversations/:id/messages/recent', (req, res) => {
205
- const limit = parseInt(req.query.limit as string) || 20;
206
- const msgs = getRecentMessages(req.params.id, Math.min(limit, 100));
207
- res.json(msgs);
134
+ const limit = parseInt(req.query.limit as string) || 20;
135
+ const msgs = getRecentMessages(req.params.id, Math.min(limit, 100));
136
+ res.json(msgs);
208
137
  });
209
138
  app.get('/api/conversations/:id/messages', (req, res) => {
210
- const before = req.query.before as string;
211
- const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
212
- if (before) {
213
- res.json(getMessagesBefore(req.params.id, before, limit));
214
- } else {
215
- res.json(getRecentMessages(req.params.id, limit));
216
- }
217
- });
218
- app.delete('/api/conversations/:id', (req, res) => {
219
- deleteConversation(req.params.id);
220
- res.json({ ok: true });
221
- });
139
+ const before = req.query.before as string;
140
+ const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
141
+ if (before) {
142
+ res.json(getMessagesBefore(req.params.id, before, limit));
143
+ } else {
144
+ res.json(getRecentMessages(req.params.id, limit));
145
+ }
146
+ });
147
+ app.delete('/api/conversations/:id', (req, res) => { deleteConversation(req.params.id); res.json({ ok: true }); });
222
148
  app.get('/api/settings', (_, res) => res.json(getAllSettings()));
223
149
  app.put('/api/settings/:key', (req, res) => {
224
- setSetting(req.params.key, req.body.value);
225
- res.json({ ok: true });
150
+ setSetting(req.params.key, req.body.value);
151
+ res.json({ ok: true });
226
152
  });
227
153
 
228
154
  // ── Current conversation (shared across devices) ──
229
155
 
230
156
  app.get('/api/context/current', (_, res) => {
231
- const convId = getSetting('current_conversation');
232
- res.json({ conversationId: convId || null });
157
+ const convId = getSetting('current_conversation');
158
+ res.json({ conversationId: convId || null });
233
159
  });
234
160
 
235
161
  app.post('/api/context/set', (req, res) => {
236
- const { conversationId } = req.body;
237
- if (conversationId) {
238
- setSetting('current_conversation', conversationId);
239
- }
240
- res.json({ ok: true });
162
+ const { conversationId } = req.body;
163
+ if (conversationId) {
164
+ setSetting('current_conversation', conversationId);
165
+ }
166
+ res.json({ ok: true });
241
167
  });
242
168
 
243
169
  app.post('/api/context/clear', (_, res) => {
244
- setSetting('current_conversation', '');
245
- res.json({ ok: true });
170
+ setSetting('current_conversation', '');
171
+ res.json({ ok: true });
246
172
  });
247
173
 
248
174
  // ── Codex OAuth routes ──
249
175
 
250
176
  app.post('/api/auth/codex/start', async (_req, res) => {
251
- const result = await startCodexOAuth();
252
- res.json(result);
177
+ const result = await startCodexOAuth();
178
+ res.json(result);
253
179
  });
254
180
 
255
181
  app.post('/api/auth/codex/cancel', (_req, res) => {
256
- cancelCodexOAuth();
257
- res.json({ ok: true });
182
+ cancelCodexOAuth();
183
+ res.json({ ok: true });
258
184
  });
259
185
 
260
186
  app.get('/api/auth/codex/status', (_req, res) => {
261
- res.json(getCodexAuthStatus());
187
+ res.json(getCodexAuthStatus());
262
188
  });
263
189
 
264
190
  // ── Claude OAuth routes ──
265
191
 
266
192
  app.post('/api/auth/claude/start', (_req, res) => {
267
- res.json(startClaudeOAuth());
193
+ res.json(startClaudeOAuth());
268
194
  });
269
195
 
270
196
  app.post('/api/auth/claude/exchange', async (req, res) => {
271
- const { code } = req.body;
272
- if (!code) {
273
- res.json({ success: false, error: 'No code provided' });
274
- return;
275
- }
276
- const result = await exchangeClaudeCode(code);
277
- res.json(result);
197
+ const { code } = req.body;
198
+ if (!code) { res.json({ success: false, error: 'No code provided' }); return; }
199
+ const result = await exchangeClaudeCode(code);
200
+ res.json(result);
278
201
  });
279
202
 
280
203
  app.get('/api/auth/claude/status', async (_req, res) => {
281
- res.json(await getClaudeAuthStatus());
204
+ res.json(await getClaudeAuthStatus());
282
205
  });
283
206
 
284
207
  // ── Handle registration ──
285
208
 
286
209
  app.get('/api/handle/check/:username', async (req, res) => {
287
- try {
288
- const result = await checkAvailability(req.params.username);
289
- res.json(result);
290
- } catch {
291
- res.json({
292
- available: false,
293
- valid: false,
294
- error: 'Could not reach relay server'
295
- });
296
- }
210
+ try {
211
+ const result = await checkAvailability(req.params.username);
212
+ res.json(result);
213
+ } catch {
214
+ res.json({ available: false, valid: false, error: 'Could not reach relay server' });
215
+ }
297
216
  });
298
217
 
299
218
  app.get('/api/handle/status', (_req, res) => {
300
- const cfg = loadConfig();
301
- res.json({
302
- registered: !!cfg.relay?.token,
303
- username: cfg.username || '',
304
- tier: cfg.relay?.tier || '',
305
- url: cfg.relay?.url || ''
306
- });
219
+ const cfg = loadConfig();
220
+ res.json({
221
+ registered: !!cfg.relay?.token,
222
+ username: cfg.username || '',
223
+ tier: cfg.relay?.tier || '',
224
+ url: cfg.relay?.url || '',
225
+ });
307
226
  });
308
227
 
309
228
  app.post('/api/handle/register', async (req, res) => {
310
- const { username, tier } = req.body;
311
- log.ok(`Handle register request: username=${username}, tier=${tier}`);
312
- if (!username || !tier) {
313
- res.status(400).json({ error: 'Missing username or tier' });
314
- return;
315
- }
316
-
317
- try {
318
- const result = await registerHandle(username, tier);
319
- log.ok(
320
- `Handle registered with relay: url=${result.relayUrl}, token=${result.token ? 'received' : 'MISSING'}`
321
- );
229
+ const { username, tier } = req.body;
230
+ log.ok(`Handle register request: username=${username}, tier=${tier}`);
231
+ if (!username || !tier) {
232
+ res.status(400).json({ error: 'Missing username or tier' });
233
+ return;
234
+ }
235
+
236
+ try {
237
+ const result = await registerHandle(username, tier);
238
+ log.ok(`Handle registered with relay: url=${result.relayUrl}, token=${result.token ? 'received' : 'MISSING'}`);
239
+
240
+ // Save to config
241
+ const cfg = loadConfig();
242
+ cfg.username = username;
243
+ cfg.relay = { token: result.token, tier, url: result.relayUrl };
244
+ saveConfig(cfg);
322
245
 
323
- // Save to config
324
- const cfg = loadConfig();
325
- cfg.username = username;
326
- cfg.relay = { token: result.token, tier, url: result.relayUrl };
327
- saveConfig(cfg);
328
-
329
- log.ok(`Handle registered: ${result.relayUrl}`);
330
-
331
- // If the tunnel is already running, push the URL to the relay immediately
332
- if (cfg.tunnelUrl) {
333
- try {
334
- await updateTunnelUrl(result.token, cfg.tunnelUrl);
335
- startHeartbeat(result.token, cfg.tunnelUrl);
336
- } catch (err: any) {
337
- log.warn(`Relay update: ${err.message}`);
338
- }
339
- }
246
+ log.ok(`Handle registered: ${result.relayUrl}`);
340
247
 
341
- res.json({ ok: true, url: result.relayUrl });
342
- } catch (err: any) {
343
- res.status(400).json({ error: err.message || 'Registration failed' });
248
+ // If the tunnel is already running, push the URL to the relay immediately
249
+ if (cfg.tunnelUrl) {
250
+ try {
251
+ await updateTunnelUrl(result.token, cfg.tunnelUrl);
252
+ startHeartbeat(result.token, cfg.tunnelUrl);
253
+ } catch (err: any) {
254
+ log.warn(`Relay update: ${err.message}`);
255
+ }
344
256
  }
257
+
258
+ res.json({ ok: true, url: result.relayUrl });
259
+ } catch (err: any) {
260
+ res.status(400).json({ error: err.message || 'Registration failed' });
261
+ }
345
262
  });
346
263
 
347
264
  app.post('/api/handle/change', async (req, res) => {
348
- const { username, tier } = req.body;
349
- log.ok(`Handle change request: new=${username}, tier=${tier}`);
350
- if (!username || !tier) {
351
- res.status(400).json({ error: 'Missing username or tier' });
352
- return;
265
+ const { username, tier } = req.body;
266
+ log.ok(`Handle change request: new=${username}, tier=${tier}`);
267
+ if (!username || !tier) {
268
+ res.status(400).json({ error: 'Missing username or tier' });
269
+ return;
270
+ }
271
+
272
+ try {
273
+ const cfg = loadConfig();
274
+ log.ok(`Handle change: old username=${cfg.username || '(none)'}, old token=${cfg.relay?.token ? 'yes' : 'NO — cannot release!'}`);
275
+
276
+ // Release old handle if one exists
277
+ if (cfg.relay?.token) {
278
+ stopHeartbeat();
279
+ try {
280
+ await releaseHandle(cfg.relay.token);
281
+ log.ok(`Released old handle: ${cfg.username}`);
282
+ } catch (err: any) {
283
+ log.warn(`Release old handle: ${err.message}`);
284
+ }
285
+ } else {
286
+ log.warn('Handle change: no existing token found — old handle will NOT be released (orphaned in MongoDB)');
353
287
  }
354
288
 
355
- try {
356
- const cfg = loadConfig();
357
- log.ok(
358
- `Handle change: old username=${cfg.username || '(none)'}, old token=${cfg.relay?.token ? 'yes' : 'NO — cannot release!'}`
359
- );
360
-
361
- // Release old handle if one exists
362
- if (cfg.relay?.token) {
363
- stopHeartbeat();
364
- try {
365
- await releaseHandle(cfg.relay.token);
366
- log.ok(`Released old handle: ${cfg.username}`);
367
- } catch (err: any) {
368
- log.warn(`Release old handle: ${err.message}`);
369
- }
370
- } else {
371
- log.warn(
372
- 'Handle change: no existing token found — old handle will NOT be released (orphaned in MongoDB)'
373
- );
374
- }
375
-
376
- // Register new handle
377
- const result = await registerHandle(username, tier);
289
+ // Register new handle
290
+ const result = await registerHandle(username, tier);
378
291
 
379
- cfg.username = username;
380
- cfg.relay = { token: result.token, tier, url: result.relayUrl };
381
- saveConfig(cfg);
292
+ cfg.username = username;
293
+ cfg.relay = { token: result.token, tier, url: result.relayUrl };
294
+ saveConfig(cfg);
382
295
 
383
- log.ok(`Handle changed to: ${result.relayUrl}`);
296
+ log.ok(`Handle changed to: ${result.relayUrl}`);
384
297
 
385
- // Push tunnel URL to new handle
386
- if (cfg.tunnelUrl) {
387
- try {
388
- await updateTunnelUrl(result.token, cfg.tunnelUrl);
389
- startHeartbeat(result.token, cfg.tunnelUrl);
390
- } catch (err: any) {
391
- log.warn(`Relay update: ${err.message}`);
392
- }
393
- }
394
-
395
- res.json({ ok: true, url: result.relayUrl });
396
- } catch (err: any) {
397
- res.status(400).json({ error: err.message || 'Handle change failed' });
298
+ // Push tunnel URL to new handle
299
+ if (cfg.tunnelUrl) {
300
+ try {
301
+ await updateTunnelUrl(result.token, cfg.tunnelUrl);
302
+ startHeartbeat(result.token, cfg.tunnelUrl);
303
+ } catch (err: any) {
304
+ log.warn(`Relay update: ${err.message}`);
305
+ }
398
306
  }
307
+
308
+ res.json({ ok: true, url: result.relayUrl });
309
+ } catch (err: any) {
310
+ res.status(400).json({ error: err.message || 'Handle change failed' });
311
+ }
399
312
  });
400
313
 
401
314
  app.post('/api/handle/claim-reserved', async (req, res) => {
402
- const { handle, hash } = req.body;
403
- if (!handle || !hash) {
404
- res.status(400).json({ error: 'Missing handle or activation code' });
405
- return;
406
- }
315
+ const { handle, hash } = req.body;
316
+ if (!handle || !hash) {
317
+ res.status(400).json({ error: 'Missing handle or activation code' });
318
+ return;
319
+ }
407
320
 
408
- try {
409
- const result = await claimReservedHandle(handle, hash);
321
+ try {
322
+ const result = await claimReservedHandle(handle, hash);
410
323
 
411
- // Save to config (same as normal registration)
412
- const cfg = loadConfig();
324
+ // Save to config (same as normal registration)
325
+ const cfg = loadConfig();
413
326
 
414
- // Release old handle if one exists
415
- if (cfg.relay?.token) {
416
- stopHeartbeat();
417
- try {
418
- await releaseHandle(cfg.relay.token);
419
- } catch (err: any) {
420
- log.warn(`Release old handle: ${err.message}`);
421
- }
422
- }
327
+ // Release old handle if one exists
328
+ if (cfg.relay?.token) {
329
+ stopHeartbeat();
330
+ try {
331
+ await releaseHandle(cfg.relay.token);
332
+ } catch (err: any) {
333
+ log.warn(`Release old handle: ${err.message}`);
334
+ }
335
+ }
423
336
 
424
- cfg.username = handle;
425
- cfg.relay = {
426
- token: result.token,
427
- tier: 'premium',
428
- url: result.relayUrl
429
- };
430
- saveConfig(cfg);
431
-
432
- log.ok(`Reserved handle claimed: ${result.relayUrl}`);
433
-
434
- // Push tunnel URL if running
435
- if (cfg.tunnelUrl) {
436
- try {
437
- await updateTunnelUrl(result.token, cfg.tunnelUrl);
438
- startHeartbeat(result.token, cfg.tunnelUrl);
439
- } catch (err: any) {
440
- log.warn(`Relay update: ${err.message}`);
441
- }
442
- }
337
+ cfg.username = handle;
338
+ cfg.relay = { token: result.token, tier: 'premium', url: result.relayUrl };
339
+ saveConfig(cfg);
443
340
 
444
- res.json({ ok: true, url: result.relayUrl });
445
- } catch (err: any) {
446
- res.status(400).json({ error: err.message || 'Claim failed' });
341
+ log.ok(`Reserved handle claimed: ${result.relayUrl}`);
342
+
343
+ // Push tunnel URL if running
344
+ if (cfg.tunnelUrl) {
345
+ try {
346
+ await updateTunnelUrl(result.token, cfg.tunnelUrl);
347
+ startHeartbeat(result.token, cfg.tunnelUrl);
348
+ } catch (err: any) {
349
+ log.warn(`Relay update: ${err.message}`);
350
+ }
447
351
  }
352
+
353
+ res.json({ ok: true, url: result.relayUrl });
354
+ } catch (err: any) {
355
+ res.status(400).json({ error: err.message || 'Claim failed' });
356
+ }
448
357
  });
449
358
 
450
359
  // ── Onboarding ──
451
360
 
452
361
  app.get('/api/onboard/status', (_, res) => {
453
- const settings = getAllSettings();
454
- const cfg = loadConfig();
455
- const hasToken = !!cfg.relay?.token;
456
- res.json({
457
- userName: settings.user_name || '',
458
- agentName: settings.agent_name || '',
459
- portalUser: settings.portal_user || '',
460
- portalConfigured: !!settings.portal_pass,
461
- whisperEnabled: settings.whisper_enabled === 'true',
462
- whisperKey: settings.whisper_key || '',
463
- provider: cfg.ai?.provider || '',
464
- model: cfg.ai?.model || '',
465
- handle: hasToken
466
- ? {
467
- username: cfg.username,
468
- tier: cfg.relay.tier,
469
- url: cfg.relay.url
470
- }
471
- : null,
472
- tunnelMode: cfg.tunnel?.mode || 'quick',
473
- tunnelDomain: cfg.tunnel?.domain || '',
474
- tunnelUrl: cfg.tunnelUrl || '',
475
- totpEnabled: settings.totp_enabled === 'true'
476
- });
362
+ const settings = getAllSettings();
363
+ const cfg = loadConfig();
364
+ const hasToken = !!cfg.relay?.token;
365
+ res.json({
366
+ userName: settings.user_name || '',
367
+ agentName: settings.agent_name || '',
368
+ portalUser: settings.portal_user || '',
369
+ portalConfigured: !!settings.portal_pass,
370
+ whisperEnabled: settings.whisper_enabled === 'true',
371
+ whisperKey: settings.whisper_key || '',
372
+ provider: cfg.ai?.provider || '',
373
+ model: cfg.ai?.model || '',
374
+ handle: hasToken ? {
375
+ username: cfg.username,
376
+ tier: cfg.relay.tier,
377
+ url: cfg.relay.url,
378
+ } : null,
379
+ tunnelMode: cfg.tunnel?.mode || 'quick',
380
+ tunnelDomain: cfg.tunnel?.domain || '',
381
+ tunnelUrl: cfg.tunnelUrl || '',
382
+ totpEnabled: settings.totp_enabled === 'true',
383
+ });
477
384
  });
478
385
 
479
386
  app.post('/api/portal/verify-password', (req, res) => {
480
- const { password } = req.body;
481
- const stored = getSetting('portal_pass');
482
- if (!stored) {
483
- res.json({ valid: false, error: 'No password set' });
484
- return;
485
- }
486
- res.json({ valid: verifyPassword(password, stored) });
387
+ const { password } = req.body;
388
+ const stored = getSetting('portal_pass');
389
+ if (!stored) { res.json({ valid: false, error: 'No password set' }); return; }
390
+ res.json({ valid: verifyPassword(password, stored) });
487
391
  });
488
392
 
489
393
  // Shared login logic (used by both POST and GET handlers)
490
- function handleLogin(
491
- username: string | undefined,
492
- password: string | undefined,
493
- req: any,
494
- res: any
495
- ) {
496
- if (!password) {
497
- res.status(400).json({ error: 'Password required' });
498
- return;
499
- }
500
- const storedPass = getSetting('portal_pass');
501
- if (!storedPass) {
502
- res.status(400).json({ error: 'No password set' });
503
- return;
504
- }
505
- if (!verifyPassword(password, storedPass)) {
506
- res.status(401).json({ error: 'Invalid password' });
507
- return;
508
- }
509
-
510
- // Check if TOTP is enabled
511
- const totpEnabled = getSetting('totp_enabled') === 'true';
512
- if (totpEnabled) {
513
- // Check for trusted device cookie
514
- const deviceToken = parseCookie(req.headers.cookie, 'fluxy_device');
515
- if (deviceToken) {
516
- const device = getTrustedDevice(deviceToken);
517
- if (device) {
518
- updateDeviceLastSeen(deviceToken);
519
- // Trusted device — skip TOTP
520
- deleteExpiredSessions();
521
- const token = crypto.randomBytes(64).toString('hex');
522
- const expiresAt = new Date(
523
- Date.now() + 7 * 24 * 60 * 60 * 1000
524
- ).toISOString();
525
- createSession(token, expiresAt);
526
- res.json({ token, expiresAt });
527
- return;
528
- }
529
- }
530
- // No valid trusted device — require TOTP
531
- const pendingToken = crypto.randomBytes(32).toString('hex');
532
- const pendingExpiry = new Date(
533
- Date.now() + 5 * 60 * 1000
534
- ).toISOString();
535
- setSetting(`totp_pending_login:${pendingToken}`, pendingExpiry);
536
- res.json({ requiresTOTP: true, pendingToken });
394
+ function handleLogin(username: string | undefined, password: string | undefined, req: any, res: any) {
395
+ if (!password) { res.status(400).json({ error: 'Password required' }); return; }
396
+ const storedPass = getSetting('portal_pass');
397
+ if (!storedPass) { res.status(400).json({ error: 'No password set' }); return; }
398
+ if (!verifyPassword(password, storedPass)) { res.status(401).json({ error: 'Invalid password' }); return; }
399
+
400
+ // Check if TOTP is enabled
401
+ const totpEnabled = getSetting('totp_enabled') === 'true';
402
+ if (totpEnabled) {
403
+ // Check for trusted device cookie
404
+ const deviceToken = parseCookie(req.headers.cookie, 'fluxy_device');
405
+ if (deviceToken) {
406
+ const device = getTrustedDevice(deviceToken);
407
+ if (device) {
408
+ updateDeviceLastSeen(deviceToken);
409
+ // Trusted device — skip TOTP
410
+ deleteExpiredSessions();
411
+ const token = crypto.randomBytes(64).toString('hex');
412
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
413
+ createSession(token, expiresAt);
414
+ res.json({ token, expiresAt });
537
415
  return;
538
- }
539
-
540
- deleteExpiredSessions();
541
- const token = crypto.randomBytes(64).toString('hex');
542
- const expiresAt = new Date(
543
- Date.now() + 7 * 24 * 60 * 60 * 1000
544
- ).toISOString();
545
- createSession(token, expiresAt);
546
- res.json({ token, expiresAt });
416
+ }
417
+ }
418
+ // No valid trusted device — require TOTP
419
+ const pendingToken = crypto.randomBytes(32).toString('hex');
420
+ const pendingExpiry = new Date(Date.now() + 5 * 60 * 1000).toISOString();
421
+ setSetting(`totp_pending_login:${pendingToken}`, pendingExpiry);
422
+ res.json({ requiresTOTP: true, pendingToken });
423
+ return;
424
+ }
425
+
426
+ deleteExpiredSessions();
427
+ const token = crypto.randomBytes(64).toString('hex');
428
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
429
+ createSession(token, expiresAt);
430
+ res.json({ token, expiresAt });
547
431
  }
548
432
 
549
433
  // POST: credentials in JSON body
550
434
  app.post('/api/portal/login', (req, res) => {
551
- const { username, password } = req.body;
552
- handleLogin(username, password, req, res);
435
+ const { username, password } = req.body;
436
+ handleLogin(username, password, req, res);
553
437
  });
554
438
 
555
439
  // GET: credentials via Authorization Basic header (relay proxies don't forward POST bodies)
556
440
  app.get('/api/portal/login', (req, res) => {
557
- const authHeader = req.headers['authorization'];
558
- if (!authHeader?.startsWith('Basic ')) {
559
- res.status(400).json({ error: 'Authorization header required' });
560
- return;
561
- }
562
- const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
563
- const sep = decoded.indexOf(':');
564
- if (sep < 0) {
565
- res.status(400).json({ error: 'Invalid credentials format' });
566
- return;
567
- }
568
- handleLogin(decoded.slice(0, sep), decoded.slice(sep + 1), req, res);
441
+ const authHeader = req.headers['authorization'];
442
+ if (!authHeader?.startsWith('Basic ')) { res.status(400).json({ error: 'Authorization header required' }); return; }
443
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
444
+ const sep = decoded.indexOf(':');
445
+ if (sep < 0) { res.status(400).json({ error: 'Invalid credentials format' }); return; }
446
+ handleLogin(decoded.slice(0, sep), decoded.slice(sep + 1), req, res);
569
447
  });
570
448
 
571
449
  // POST + GET for validate-token (same relay issue)
572
450
  function handleValidateToken(token: string | undefined, res: any) {
573
- if (!token) {
574
- res.json({ valid: false });
575
- return;
576
- }
577
- const session = getSession(token);
578
- res.json({ valid: !!session });
451
+ if (!token) { res.json({ valid: false }); return; }
452
+ const session = getSession(token);
453
+ res.json({ valid: !!session });
579
454
  }
580
455
 
581
456
  app.post('/api/portal/validate-token', (req, res) => {
582
- handleValidateToken(req.body.token, res);
457
+ handleValidateToken(req.body.token, res);
583
458
  });
584
459
 
585
460
  app.get('/api/portal/validate-token', (req, res) => {
586
- handleValidateToken(req.query.token as string, res);
461
+ handleValidateToken(req.query.token as string, res);
587
462
  });
588
463
 
589
464
  // ── TOTP 2FA endpoints ──
590
465
 
591
466
  app.get('/api/portal/totp/status', (_req, res) => {
592
- res.json({ enabled: getSetting('totp_enabled') === 'true' });
467
+ res.json({ enabled: getSetting('totp_enabled') === 'true' });
593
468
  });
594
469
 
595
470
  app.post('/api/portal/totp/setup', async (req, res) => {
596
- // Verify caller has auth: session token, correct password, or initial onboard (no password set yet)
597
- const authHeader = req.headers['authorization'];
598
- let authorized = false;
599
- if (authHeader?.startsWith('Bearer ')) {
600
- const session = getSession(authHeader.slice(7));
601
- if (session) authorized = true;
602
- }
603
- if (!authorized && req.body?.password) {
604
- const storedPass = getSetting('portal_pass');
605
- if (storedPass && verifyPassword(req.body.password, storedPass))
606
- authorized = true;
607
- }
608
- // During initial onboard, no password is stored yet — allow setup
609
- if (!authorized && !getSetting('portal_pass')) authorized = true;
610
- if (!authorized) {
611
- res.status(401).json({ error: 'Unauthorized' });
612
- return;
613
- }
471
+ // Verify caller has auth: session token, correct password, or initial onboard (no password set yet)
472
+ const authHeader = req.headers['authorization'];
473
+ let authorized = false;
474
+ if (authHeader?.startsWith('Bearer ')) {
475
+ const session = getSession(authHeader.slice(7));
476
+ if (session) authorized = true;
477
+ }
478
+ if (!authorized && req.body?.password) {
479
+ const storedPass = getSetting('portal_pass');
480
+ if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true;
481
+ }
482
+ // During initial onboard, no password is stored yet — allow setup
483
+ if (!authorized && !getSetting('portal_pass')) authorized = true;
484
+ if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; }
614
485
 
615
- const secret = generateTOTPSecret();
616
- setSetting('totp_pending_secret', secret);
617
-
618
- const botName = getSetting('agent_name') || 'Fluxy';
619
- const totp = new TOTP({
620
- issuer: 'Fluxy',
621
- label: botName,
622
- algorithm: 'SHA1',
623
- digits: 6,
624
- period: 30,
625
- secret
626
- });
627
- const otpauthUri = totp.toString();
628
-
629
- try {
630
- const qrDataUri = await QRCode.toDataURL(otpauthUri, {
631
- width: 256,
632
- margin: 2
633
- });
634
- res.json({ secret, qrDataUri, otpauthUri });
635
- } catch (err: any) {
636
- res.status(500).json({ error: 'Failed to generate QR code' });
637
- }
486
+ const secret = generateTOTPSecret();
487
+ setSetting('totp_pending_secret', secret);
488
+
489
+ const botName = getSetting('agent_name') || 'Fluxy';
490
+ const totp = new TOTP({ issuer: 'Fluxy', label: botName, algorithm: 'SHA1', digits: 6, period: 30, secret });
491
+ const otpauthUri = totp.toString();
492
+
493
+ try {
494
+ const qrDataUri = await QRCode.toDataURL(otpauthUri, { width: 256, margin: 2 });
495
+ res.json({ secret, qrDataUri, otpauthUri });
496
+ } catch (err: any) {
497
+ res.status(500).json({ error: 'Failed to generate QR code' });
498
+ }
638
499
  });
639
500
 
640
501
  app.post('/api/portal/totp/verify-setup', (req, res) => {
641
- const authHeader = req.headers['authorization'];
642
- let authorized = false;
643
- if (authHeader?.startsWith('Bearer ')) {
644
- const session = getSession(authHeader.slice(7));
645
- if (session) authorized = true;
646
- }
647
- if (!authorized && req.body?.password) {
648
- const storedPass = getSetting('portal_pass');
649
- if (storedPass && verifyPassword(req.body.password, storedPass))
650
- authorized = true;
651
- }
652
- // During initial onboard, no password is stored yet — allow verify
653
- if (!authorized && !getSetting('portal_pass')) authorized = true;
654
- if (!authorized) {
655
- res.status(401).json({ error: 'Unauthorized' });
656
- return;
657
- }
502
+ const authHeader = req.headers['authorization'];
503
+ let authorized = false;
504
+ if (authHeader?.startsWith('Bearer ')) {
505
+ const session = getSession(authHeader.slice(7));
506
+ if (session) authorized = true;
507
+ }
508
+ if (!authorized && req.body?.password) {
509
+ const storedPass = getSetting('portal_pass');
510
+ if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true;
511
+ }
512
+ // During initial onboard, no password is stored yet — allow verify
513
+ if (!authorized && !getSetting('portal_pass')) authorized = true;
514
+ if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; }
658
515
 
659
- const { code } = req.body;
660
- if (!code) {
661
- res.status(400).json({ error: 'Code required' });
662
- return;
663
- }
516
+ const { code } = req.body;
517
+ if (!code) { res.status(400).json({ error: 'Code required' }); return; }
664
518
 
665
- const pendingSecret = getSetting('totp_pending_secret');
666
- if (!pendingSecret) {
667
- res.status(400).json({ error: 'No TOTP setup in progress' });
668
- return;
669
- }
519
+ const pendingSecret = getSetting('totp_pending_secret');
520
+ if (!pendingSecret) { res.status(400).json({ error: 'No TOTP setup in progress' }); return; }
670
521
 
671
- if (!verifyTOTPCode(code, pendingSecret)) {
672
- res.status(400).json({
673
- error: 'Invalid code. Check your authenticator app and try again.'
674
- });
675
- return;
676
- }
522
+ if (!verifyTOTPCode(code, pendingSecret)) {
523
+ res.status(400).json({ error: 'Invalid code. Check your authenticator app and try again.' });
524
+ return;
525
+ }
677
526
 
678
- // Success: persist TOTP config
679
- setSetting('totp_secret', pendingSecret);
680
- setSetting('totp_enabled', 'true');
527
+ // Success: persist TOTP config
528
+ setSetting('totp_secret', pendingSecret);
529
+ setSetting('totp_enabled', 'true');
681
530
 
682
- // Generate recovery codes
683
- const codes = generateRecoveryCodes();
684
- const hashes = codes.map(hashRecoveryCode);
685
- setSetting('totp_recovery_codes', JSON.stringify(hashes));
531
+ // Generate recovery codes
532
+ const codes = generateRecoveryCodes();
533
+ const hashes = codes.map(hashRecoveryCode);
534
+ setSetting('totp_recovery_codes', JSON.stringify(hashes));
686
535
 
687
- // Clean up pending secret
688
- setSetting('totp_pending_secret', '');
536
+ // Clean up pending secret
537
+ setSetting('totp_pending_secret', '');
689
538
 
690
- res.json({ success: true, recoveryCodes: codes });
539
+ res.json({ success: true, recoveryCodes: codes });
691
540
  });
692
541
 
693
542
  app.post('/api/portal/totp/disable', (req, res) => {
694
- const { password, code } = req.body;
695
- if (!password || !code) {
696
- res.status(400).json({ error: 'Password and TOTP code required' });
697
- return;
698
- }
543
+ const { password, code } = req.body;
544
+ if (!password || !code) { res.status(400).json({ error: 'Password and TOTP code required' }); return; }
699
545
 
700
- const storedPass = getSetting('portal_pass');
701
- if (!storedPass || !verifyPassword(password, storedPass)) {
702
- res.status(401).json({ error: 'Invalid password' });
703
- return;
704
- }
546
+ const storedPass = getSetting('portal_pass');
547
+ if (!storedPass || !verifyPassword(password, storedPass)) {
548
+ res.status(401).json({ error: 'Invalid password' });
549
+ return;
550
+ }
705
551
 
706
- const secret = getSetting('totp_secret');
707
- if (!secret) {
708
- res.status(400).json({ error: '2FA is not enabled' });
709
- return;
710
- }
552
+ const secret = getSetting('totp_secret');
553
+ if (!secret) { res.status(400).json({ error: '2FA is not enabled' }); return; }
711
554
 
712
- if (!verifyTOTPCode(code, secret)) {
713
- res.status(400).json({ error: 'Invalid TOTP code' });
714
- return;
715
- }
555
+ if (!verifyTOTPCode(code, secret)) {
556
+ res.status(400).json({ error: 'Invalid TOTP code' });
557
+ return;
558
+ }
716
559
 
717
- // Clear all TOTP settings
718
- setSetting('totp_enabled', 'false');
719
- setSetting('totp_secret', '');
720
- setSetting('totp_recovery_codes', '');
721
- setSetting('totp_pending_secret', '');
722
- deleteAllTrustedDevices();
560
+ // Clear all TOTP settings
561
+ setSetting('totp_enabled', 'false');
562
+ setSetting('totp_secret', '');
563
+ setSetting('totp_recovery_codes', '');
564
+ setSetting('totp_pending_secret', '');
565
+ deleteAllTrustedDevices();
723
566
 
724
- res.json({ success: true });
567
+ res.json({ success: true });
725
568
  });
726
569
 
727
570
  app.get('/api/portal/login/totp', (req, res) => {
728
- const pending = req.query.pending as string;
729
- const code = req.query.code as string;
730
- const trust = req.query.trust as string;
731
-
732
- if (!pending || !code) {
733
- res.status(400).json({ error: 'Missing pending token or code' });
734
- return;
735
- }
736
-
737
- // Validate pending token
738
- const expiry = getSetting(`totp_pending_login:${pending}`);
739
- if (!expiry || new Date(expiry) < new Date()) {
740
- res.status(401).json({
741
- error: 'Login session expired. Please start over.'
742
- });
743
- return;
744
- }
745
-
746
- // Clean up pending token
747
- setSetting(`totp_pending_login:${pending}`, '');
748
-
749
- const secret = getSetting('totp_secret');
750
- if (!secret) {
751
- res.status(400).json({ error: '2FA is not configured' });
752
- return;
753
- }
754
-
755
- // Try TOTP code first
756
- let valid = verifyTOTPCode(code, secret);
757
-
758
- // If not valid as TOTP, try as recovery code
759
- if (!valid) {
760
- const hashesJson = getSetting('totp_recovery_codes');
761
- if (hashesJson) {
762
- try {
763
- const hashes = JSON.parse(hashesJson) as string[];
764
- const result = verifyRecoveryCode(code, hashes);
765
- if (result.valid) {
766
- valid = true;
767
- setSetting(
768
- 'totp_recovery_codes',
769
- JSON.stringify(result.remaining)
770
- );
771
- }
772
- } catch {}
571
+ const pending = req.query.pending as string;
572
+ const code = req.query.code as string;
573
+ const trust = req.query.trust as string;
574
+
575
+ if (!pending || !code) { res.status(400).json({ error: 'Missing pending token or code' }); return; }
576
+
577
+ // Validate pending token
578
+ const expiry = getSetting(`totp_pending_login:${pending}`);
579
+ if (!expiry || new Date(expiry) < new Date()) {
580
+ res.status(401).json({ error: 'Login session expired. Please start over.' });
581
+ return;
582
+ }
583
+
584
+ // Clean up pending token
585
+ setSetting(`totp_pending_login:${pending}`, '');
586
+
587
+ const secret = getSetting('totp_secret');
588
+ if (!secret) { res.status(400).json({ error: '2FA is not configured' }); return; }
589
+
590
+ // Try TOTP code first
591
+ let valid = verifyTOTPCode(code, secret);
592
+
593
+ // If not valid as TOTP, try as recovery code
594
+ if (!valid) {
595
+ const hashesJson = getSetting('totp_recovery_codes');
596
+ if (hashesJson) {
597
+ try {
598
+ const hashes = JSON.parse(hashesJson) as string[];
599
+ const result = verifyRecoveryCode(code, hashes);
600
+ if (result.valid) {
601
+ valid = true;
602
+ setSetting('totp_recovery_codes', JSON.stringify(result.remaining));
773
603
  }
604
+ } catch {}
774
605
  }
606
+ }
775
607
 
776
- if (!valid) {
777
- res.status(401).json({ error: 'Invalid code' });
778
- return;
779
- }
608
+ if (!valid) {
609
+ res.status(401).json({ error: 'Invalid code' });
610
+ return;
611
+ }
780
612
 
781
- // Create session
782
- deleteExpiredSessions();
783
- const token = crypto.randomBytes(64).toString('hex');
784
- const expiresAt = new Date(
785
- Date.now() + 7 * 24 * 60 * 60 * 1000
786
- ).toISOString();
787
- createSession(token, expiresAt);
788
-
789
- // Trust device if requested
790
- if (trust === '1') {
791
- deleteExpiredDevices();
792
- const deviceToken = crypto.randomBytes(32).toString('hex');
793
- const deviceExpiry = new Date(
794
- Date.now() + 90 * 24 * 60 * 60 * 1000
795
- ).toISOString();
796
- createTrustedDevice(deviceToken, 'Browser', deviceExpiry);
797
- res.setHeader(
798
- 'Set-Cookie',
799
- `fluxy_device=${deviceToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=7776000; Path=/`
800
- );
801
- }
613
+ // Create session
614
+ deleteExpiredSessions();
615
+ const token = crypto.randomBytes(64).toString('hex');
616
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
617
+ createSession(token, expiresAt);
618
+
619
+ // Trust device if requested
620
+ if (trust === '1') {
621
+ deleteExpiredDevices();
622
+ const deviceToken = crypto.randomBytes(32).toString('hex');
623
+ const deviceExpiry = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
624
+ createTrustedDevice(deviceToken, 'Browser', deviceExpiry);
625
+ res.setHeader('Set-Cookie', `fluxy_device=${deviceToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=7776000; Path=/`);
626
+ }
802
627
 
803
- res.json({ token, expiresAt });
628
+ res.json({ token, expiresAt });
804
629
  });
805
630
 
806
631
  app.get('/api/portal/devices', (_req, res) => {
807
- res.json(listTrustedDevices());
632
+ res.json(listTrustedDevices());
808
633
  });
809
634
 
810
635
  app.delete('/api/portal/devices/:id', (req, res) => {
811
- deleteTrustedDevice(req.params.id);
812
- res.json({ ok: true });
636
+ deleteTrustedDevice(req.params.id);
637
+ res.json({ ok: true });
813
638
  });
814
639
 
815
640
  app.post('/api/portal/devices/revoke', (req, res) => {
816
- const { id } = req.body;
817
- if (!id) {
818
- res.status(400).json({ error: 'Device ID required' });
819
- return;
820
- }
821
- deleteTrustedDevice(id);
822
- res.json({ ok: true });
641
+ const { id } = req.body;
642
+ if (!id) { res.status(400).json({ error: 'Device ID required' }); return; }
643
+ deleteTrustedDevice(id);
644
+ res.json({ ok: true });
823
645
  });
824
646
 
825
647
  app.post('/api/onboard', (req, res) => {
826
- const {
827
- userName,
828
- agentName,
829
- provider,
830
- model,
831
- apiKey,
832
- baseUrl,
833
- portalUser,
834
- portalPass,
835
- whisperEnabled,
836
- whisperKey
837
- } = req.body;
838
-
839
- // Read old names before overwriting (needed for FLUXY.md re-onboard)
840
- const oldBotName = getSetting('agent_name') || '$BOT';
841
- const oldHumanName = getSetting('user_name') || '$HUMAN';
842
-
843
- setSetting('user_name', userName || '');
844
- setSetting('agent_name', agentName || 'Fluxy');
845
- setSetting('onboard_complete', 'true');
846
-
847
- // Save portal credentials
848
- if (portalUser) {
849
- setSetting('portal_user', portalUser.trim().toLowerCase());
850
- }
851
- if (portalPass) {
852
- setSetting('portal_pass', hashPassword(portalPass));
853
- }
854
-
855
- // Save whisper config
856
- setSetting('whisper_enabled', whisperEnabled ? 'true' : 'false');
857
- if (whisperKey) {
858
- setSetting('whisper_key', whisperKey);
859
- }
860
-
861
- // Update bot and human names in FLUXY.md
862
- // On first onboard: replaces $BOT / $HUMAN placeholders
863
- // On re-onboard: replaces the previous names with the new ones
864
- const newBotName = agentName || 'Fluxy';
865
- const newHumanName = userName || 'Human';
866
- const fluxyMdPath = path.join(WORKSPACE_DIR, 'FLUXY.md');
867
- if (fs.existsSync(fluxyMdPath)) {
868
- let fluxyContent = fs.readFileSync(fluxyMdPath, 'utf-8');
869
- if (oldBotName !== newBotName) {
870
- fluxyContent = fluxyContent.replace(
871
- new RegExp(
872
- oldBotName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
873
- 'g'
874
- ),
875
- newBotName
876
- );
877
- }
878
- if (oldHumanName !== newHumanName) {
879
- fluxyContent = fluxyContent.replace(
880
- new RegExp(
881
- oldHumanName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
882
- 'g'
883
- ),
884
- newHumanName
885
- );
886
- }
887
- fs.writeFileSync(fluxyMdPath, fluxyContent, 'utf-8');
888
- log.ok(
889
- `FLUXY.md: updated names — bot="${newBotName}", human="${newHumanName}"`
890
- );
891
- }
892
-
893
- // Re-read config from disk to preserve relay/handle data written by registration
894
- const currentCfg = loadConfig();
895
- log.ok(
896
- `Onboard: preserving relay data — token=${currentCfg.relay?.token ? 'yes' : 'no'}, username=${currentCfg.username || '(none)'}`
897
- );
898
-
899
- currentCfg.ai.provider = provider || '';
900
- currentCfg.ai.model = model || '';
901
- currentCfg.ai.baseUrl = baseUrl || undefined;
902
-
903
- // Read OAuth token if no API key provided
904
- if (!apiKey && provider === 'openai') {
905
- currentCfg.ai.apiKey = readCodexAccessToken() || '';
906
- } else if (!apiKey && provider === 'anthropic') {
907
- currentCfg.ai.apiKey = readClaudeAccessToken() || '';
908
- } else {
909
- currentCfg.ai.apiKey = apiKey || '';
910
- }
911
-
912
- saveConfig(currentCfg);
913
-
914
- res.json({ ok: true });
648
+ const { userName, agentName, provider, model, apiKey, baseUrl, portalUser, portalPass, whisperEnabled, whisperKey } = req.body;
649
+
650
+ // Read old names before overwriting (needed for FLUXY.md re-onboard)
651
+ const oldBotName = getSetting('agent_name') || '$BOT';
652
+ const oldHumanName = getSetting('user_name') || '$HUMAN';
653
+
654
+ setSetting('user_name', userName || '');
655
+ setSetting('agent_name', agentName || 'Fluxy');
656
+ setSetting('onboard_complete', 'true');
657
+
658
+ // Save portal credentials
659
+ if (portalUser) {
660
+ setSetting('portal_user', portalUser.trim().toLowerCase());
661
+ }
662
+ if (portalPass) {
663
+ setSetting('portal_pass', hashPassword(portalPass));
664
+ }
665
+
666
+ // Save whisper config
667
+ setSetting('whisper_enabled', whisperEnabled ? 'true' : 'false');
668
+ if (whisperKey) {
669
+ setSetting('whisper_key', whisperKey);
670
+ }
671
+
672
+ // Update bot and human names in FLUXY.md
673
+ // On first onboard: replaces $BOT / $HUMAN placeholders
674
+ // On re-onboard: replaces the previous names with the new ones
675
+ const newBotName = agentName || 'Fluxy';
676
+ const newHumanName = userName || 'Human';
677
+ const fluxyMdPath = path.join(WORKSPACE_DIR, 'FLUXY.md');
678
+ if (fs.existsSync(fluxyMdPath)) {
679
+ let fluxyContent = fs.readFileSync(fluxyMdPath, 'utf-8');
680
+ if (oldBotName !== newBotName) {
681
+ fluxyContent = fluxyContent.replace(new RegExp(oldBotName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newBotName);
682
+ }
683
+ if (oldHumanName !== newHumanName) {
684
+ fluxyContent = fluxyContent.replace(new RegExp(oldHumanName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newHumanName);
685
+ }
686
+ fs.writeFileSync(fluxyMdPath, fluxyContent, 'utf-8');
687
+ log.ok(`FLUXY.md: updated names bot="${newBotName}", human="${newHumanName}"`);
688
+ }
689
+
690
+ // Re-read config from disk to preserve relay/handle data written by registration
691
+ const currentCfg = loadConfig();
692
+ log.ok(`Onboard: preserving relay data — token=${currentCfg.relay?.token ? 'yes' : 'no'}, username=${currentCfg.username || '(none)'}`);
693
+
694
+ currentCfg.ai.provider = provider || '';
695
+ currentCfg.ai.model = model || '';
696
+ currentCfg.ai.baseUrl = baseUrl || undefined;
697
+
698
+ // Read OAuth token if no API key provided
699
+ if (!apiKey && provider === 'openai') {
700
+ currentCfg.ai.apiKey = readCodexAccessToken() || '';
701
+ } else if (!apiKey && provider === 'anthropic') {
702
+ currentCfg.ai.apiKey = readClaudeAccessToken() || '';
703
+ } else {
704
+ currentCfg.ai.apiKey = apiKey || '';
705
+ }
706
+
707
+ saveConfig(currentCfg);
708
+
709
+ res.json({ ok: true });
915
710
  });
916
711
 
917
712
  // ── Push notifications ──
918
713
 
919
714
  app.get('/api/push/vapid-public-key', (_, res) => {
920
- const key = getSetting('vapid_public_key');
921
- if (!key) {
922
- res.status(500).json({ error: 'VAPID keys not initialized' });
923
- return;
924
- }
925
- res.json({ publicKey: key });
715
+ const key = getSetting('vapid_public_key');
716
+ if (!key) { res.status(500).json({ error: 'VAPID keys not initialized' }); return; }
717
+ res.json({ publicKey: key });
926
718
  });
927
719
 
928
720
  app.post('/api/push/subscribe', (req, res) => {
929
- const { endpoint, keys } = req.body || {};
930
- if (!endpoint || !keys?.p256dh || !keys?.auth) {
931
- res.status(400).json({ error: 'Invalid subscription' });
932
- return;
933
- }
934
- addPushSubscription(endpoint, keys.p256dh, keys.auth);
935
- res.json({ ok: true });
721
+ const { endpoint, keys } = req.body || {};
722
+ if (!endpoint || !keys?.p256dh || !keys?.auth) {
723
+ res.status(400).json({ error: 'Invalid subscription' });
724
+ return;
725
+ }
726
+ addPushSubscription(endpoint, keys.p256dh, keys.auth);
727
+ res.json({ ok: true });
936
728
  });
937
729
 
938
730
  app.delete('/api/push/unsubscribe', (req, res) => {
939
- const { endpoint } = req.body || {};
940
- if (!endpoint) {
941
- res.status(400).json({ error: 'Missing endpoint' });
942
- return;
943
- }
944
- removePushSubscription(endpoint);
945
- res.json({ ok: true });
731
+ const { endpoint } = req.body || {};
732
+ if (!endpoint) { res.status(400).json({ error: 'Missing endpoint' }); return; }
733
+ removePushSubscription(endpoint);
734
+ res.json({ ok: true });
946
735
  });
947
736
 
948
737
  app.post('/api/push/send', async (req, res) => {
949
- const { title, body, tag, url } = req.body || {};
950
- const subs = getAllPushSubscriptions();
951
- log.info(`[push] Sending to ${subs.length} subscription(s): "${title}"`);
952
- const payload = JSON.stringify({
953
- title: title || 'Fluxy',
954
- body: body || '',
955
- tag: tag || 'fluxy',
956
- url: url || '/'
957
- });
738
+ const { title, body, tag, url } = req.body || {};
739
+ const subs = getAllPushSubscriptions();
740
+ log.info(`[push] Sending to ${subs.length} subscription(s): "${title}"`);
741
+ const payload = JSON.stringify({ title: title || 'Fluxy', body: body || '', tag: tag || 'fluxy', url: url || '/' });
742
+
743
+ const results = await Promise.allSettled(
744
+ subs.map(async (sub) => {
745
+ try {
746
+ await webpush.sendNotification(
747
+ { endpoint: sub.endpoint, keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth } },
748
+ payload,
749
+ );
750
+ } catch (err: any) {
751
+ if (err.statusCode === 410 || err.statusCode === 404) {
752
+ removePushSubscription(sub.endpoint);
753
+ log.info(`[push] Removed expired subscription: ${sub.endpoint.slice(0, 60)}...`);
754
+ } else {
755
+ log.warn(`[push] Send failed: ${err.message}`);
756
+ }
757
+ throw err;
758
+ }
759
+ }),
760
+ );
958
761
 
959
- const results = await Promise.allSettled(
960
- subs.map(async sub => {
961
- try {
962
- await webpush.sendNotification(
963
- {
964
- endpoint: sub.endpoint,
965
- keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth }
966
- },
967
- payload
968
- );
969
- } catch (err: any) {
970
- if (err.statusCode === 410 || err.statusCode === 404) {
971
- removePushSubscription(sub.endpoint);
972
- log.info(
973
- `[push] Removed expired subscription: ${sub.endpoint.slice(0, 60)}...`
974
- );
975
- } else {
976
- log.warn(`[push] Send failed: ${err.message}`);
977
- }
978
- throw err;
979
- }
980
- })
981
- );
982
-
983
- const sent = results.filter(r => r.status === 'fulfilled').length;
984
- res.json({ sent, total: subs.length });
762
+ const sent = results.filter((r) => r.status === 'fulfilled').length;
763
+ res.json({ sent, total: subs.length });
985
764
  });
986
765
 
987
766
  app.get('/api/push/status', (req, res) => {
988
- const endpoint = req.query.endpoint as string;
989
- if (!endpoint) {
990
- res.json({ subscribed: false });
991
- return;
992
- }
993
- const sub = getPushSubscriptionByEndpoint(endpoint);
994
- res.json({ subscribed: !!sub });
767
+ const endpoint = req.query.endpoint as string;
768
+ if (!endpoint) { res.json({ subscribed: false }); return; }
769
+ const sub = getPushSubscriptionByEndpoint(endpoint);
770
+ res.json({ subscribed: !!sub });
995
771
  });
996
772
 
997
773
  // ── Whisper transcription ──
998
774
 
999
- app.post(
1000
- '/api/whisper/transcribe',
1001
- express.json({ limit: '10mb' }),
1002
- async (req, res) => {
1003
- const whisperEnabled = getSetting('whisper_enabled');
1004
- const whisperKey = getSetting('whisper_key');
1005
-
1006
- if (whisperEnabled !== 'true' || !whisperKey) {
1007
- res.status(400).json({
1008
- error: 'Whisper not enabled or API key missing'
1009
- });
1010
- return;
1011
- }
1012
-
1013
- const { audio } = req.body; // base64 string (no data URL prefix expected, but handle both)
1014
- if (!audio) {
1015
- res.status(400).json({ error: 'No audio data provided' });
1016
- return;
1017
- }
775
+ app.post('/api/whisper/transcribe', express.json({ limit: '10mb' }), async (req, res) => {
776
+ const whisperEnabled = getSetting('whisper_enabled');
777
+ const whisperKey = getSetting('whisper_key');
778
+
779
+ if (whisperEnabled !== 'true' || !whisperKey) {
780
+ res.status(400).json({ error: 'Whisper not enabled or API key missing' });
781
+ return;
782
+ }
783
+
784
+ const { audio } = req.body; // base64 string (no data URL prefix expected, but handle both)
785
+ if (!audio) {
786
+ res.status(400).json({ error: 'No audio data provided' });
787
+ return;
788
+ }
789
+
790
+ try {
791
+ // Strip data URL prefix if present
792
+ const raw = audio.includes(',') ? audio.split(',')[1] : audio;
793
+ const audioBuffer = Buffer.from(raw, 'base64');
794
+
795
+ // Build multipart/form-data manually
796
+ const boundary = '----WhisperBoundary' + Date.now();
797
+ const CRLF = '\r\n';
798
+
799
+ const parts: Buffer[] = [];
800
+
801
+ // file part
802
+ parts.push(Buffer.from(
803
+ `--${boundary}${CRLF}` +
804
+ `Content-Disposition: form-data; name="file"; filename="audio.webm"${CRLF}` +
805
+ `Content-Type: audio/webm${CRLF}${CRLF}`
806
+ ));
807
+ parts.push(audioBuffer);
808
+ parts.push(Buffer.from(CRLF));
809
+
810
+ // model part
811
+ parts.push(Buffer.from(
812
+ `--${boundary}${CRLF}` +
813
+ `Content-Disposition: form-data; name="model"${CRLF}${CRLF}` +
814
+ `whisper-1${CRLF}`
815
+ ));
816
+
817
+ // closing boundary
818
+ parts.push(Buffer.from(`--${boundary}--${CRLF}`));
819
+
820
+ const body = Buffer.concat(parts);
821
+
822
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
823
+ method: 'POST',
824
+ headers: {
825
+ 'Authorization': `Bearer ${whisperKey}`,
826
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
827
+ },
828
+ body,
829
+ });
1018
830
 
1019
- try {
1020
- // Strip data URL prefix if present
1021
- const raw = audio.includes(',') ? audio.split(',')[1] : audio;
1022
- const audioBuffer = Buffer.from(raw, 'base64');
1023
-
1024
- // Build multipart/form-data manually
1025
- const boundary = '----WhisperBoundary' + Date.now();
1026
- const CRLF = '\r\n';
1027
-
1028
- const parts: Buffer[] = [];
1029
-
1030
- // file part
1031
- parts.push(
1032
- Buffer.from(
1033
- `--${boundary}${CRLF}` +
1034
- `Content-Disposition: form-data; name="file"; filename="audio.webm"${CRLF}` +
1035
- `Content-Type: audio/webm${CRLF}${CRLF}`
1036
- )
1037
- );
1038
- parts.push(audioBuffer);
1039
- parts.push(Buffer.from(CRLF));
1040
-
1041
- // model part
1042
- parts.push(
1043
- Buffer.from(
1044
- `--${boundary}${CRLF}` +
1045
- `Content-Disposition: form-data; name="model"${CRLF}${CRLF}` +
1046
- `whisper-1${CRLF}`
1047
- )
1048
- );
1049
-
1050
- // closing boundary
1051
- parts.push(Buffer.from(`--${boundary}--${CRLF}`));
1052
-
1053
- const body = Buffer.concat(parts);
1054
-
1055
- const response = await fetch(
1056
- 'https://api.openai.com/v1/audio/transcriptions',
1057
- {
1058
- method: 'POST',
1059
- headers: {
1060
- Authorization: `Bearer ${whisperKey}`,
1061
- 'Content-Type': `multipart/form-data; boundary=${boundary}`
1062
- },
1063
- body
1064
- }
1065
- );
1066
-
1067
- if (!response.ok) {
1068
- const errText = await response.text();
1069
- log.warn(`Whisper API error: ${response.status} ${errText}`);
1070
- res.status(502).json({ error: 'Whisper API error' });
1071
- return;
1072
- }
1073
-
1074
- const result = (await response.json()) as { text: string };
1075
- res.json({ transcript: result.text });
1076
- } catch (err: any) {
1077
- log.warn(`Whisper transcription failed: ${err.message}`);
1078
- res.status(500).json({ error: 'Transcription failed' });
1079
- }
831
+ if (!response.ok) {
832
+ const errText = await response.text();
833
+ log.warn(`Whisper API error: ${response.status} ${errText}`);
834
+ res.status(502).json({ error: 'Whisper API error' });
835
+ return;
1080
836
  }
1081
- );
837
+
838
+ const result = await response.json() as { text: string };
839
+ res.json({ transcript: result.text });
840
+ } catch (err: any) {
841
+ log.warn(`Whisper transcription failed: ${err.message}`);
842
+ res.status(500).json({ error: 'Transcription failed' });
843
+ }
844
+ });
1082
845
 
1083
846
  // Serve stored files (audio, images, documents)
1084
847
  app.use('/api/files', express.static(paths.files));
@@ -1089,8 +852,4 @@ console.log('[worker] API-only mode — dashboard served by Vite dev server');
1089
852
  // HTTP server (no WebSocket — chat lives in supervisor)
1090
853
  const server = app.listen(port, () => log.ok(`Worker on port ${port}`));
1091
854
 
1092
- process.on('SIGTERM', () => {
1093
- closeDb();
1094
- server.close();
1095
- process.exit(0);
1096
- });
855
+ process.on('SIGTERM', () => { closeDb(); server.close(); process.exit(0); });