@auxiora/dashboard 1.0.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 (97) hide show
  1. package/LICENSE +191 -0
  2. package/dist/auth.d.ts +13 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +69 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/cloud-types.d.ts +71 -0
  7. package/dist/cloud-types.d.ts.map +1 -0
  8. package/dist/cloud-types.js +2 -0
  9. package/dist/cloud-types.js.map +1 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +4 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/router.d.ts +13 -0
  15. package/dist/router.d.ts.map +1 -0
  16. package/dist/router.js +2250 -0
  17. package/dist/router.js.map +1 -0
  18. package/dist/types.d.ts +314 -0
  19. package/dist/types.d.ts.map +1 -0
  20. package/dist/types.js +7 -0
  21. package/dist/types.js.map +1 -0
  22. package/dist-ui/assets/index-BfY0i5jw.css +1 -0
  23. package/dist-ui/assets/index-CXpk9mvw.js +60 -0
  24. package/dist-ui/icon.svg +59 -0
  25. package/dist-ui/index.html +20 -0
  26. package/package.json +32 -0
  27. package/src/auth.ts +83 -0
  28. package/src/cloud-types.ts +63 -0
  29. package/src/index.ts +5 -0
  30. package/src/router.ts +2494 -0
  31. package/src/types.ts +269 -0
  32. package/tests/auth.test.ts +51 -0
  33. package/tests/cloud-router.test.ts +249 -0
  34. package/tests/desktop-router.test.ts +151 -0
  35. package/tests/router.test.ts +388 -0
  36. package/tests/trust-router.test.ts +170 -0
  37. package/tsconfig.json +12 -0
  38. package/tsconfig.tsbuildinfo +1 -0
  39. package/ui/index.html +19 -0
  40. package/ui/node_modules/.bin/browserslist +17 -0
  41. package/ui/node_modules/.bin/tsc +17 -0
  42. package/ui/node_modules/.bin/tsserver +17 -0
  43. package/ui/node_modules/.bin/vite +17 -0
  44. package/ui/package.json +23 -0
  45. package/ui/public/icon.svg +59 -0
  46. package/ui/src/App.tsx +63 -0
  47. package/ui/src/api.ts +238 -0
  48. package/ui/src/components/ActivityFeed.tsx +123 -0
  49. package/ui/src/components/BehaviorHealth.tsx +105 -0
  50. package/ui/src/components/DataTable.tsx +39 -0
  51. package/ui/src/components/Layout.tsx +160 -0
  52. package/ui/src/components/PasswordStrength.tsx +31 -0
  53. package/ui/src/components/SetupProgress.tsx +26 -0
  54. package/ui/src/components/StatusBadge.tsx +12 -0
  55. package/ui/src/components/ThemeSelector.tsx +39 -0
  56. package/ui/src/contexts/ThemeContext.tsx +58 -0
  57. package/ui/src/hooks/useApi.ts +19 -0
  58. package/ui/src/hooks/usePolling.ts +8 -0
  59. package/ui/src/main.tsx +16 -0
  60. package/ui/src/pages/AuditLog.tsx +36 -0
  61. package/ui/src/pages/Behaviors.tsx +426 -0
  62. package/ui/src/pages/Chat.tsx +688 -0
  63. package/ui/src/pages/Login.tsx +64 -0
  64. package/ui/src/pages/Overview.tsx +56 -0
  65. package/ui/src/pages/Sessions.tsx +26 -0
  66. package/ui/src/pages/SettingsAmbient.tsx +185 -0
  67. package/ui/src/pages/SettingsConnections.tsx +201 -0
  68. package/ui/src/pages/SettingsNotifications.tsx +241 -0
  69. package/ui/src/pages/SetupAppearance.tsx +45 -0
  70. package/ui/src/pages/SetupChannels.tsx +143 -0
  71. package/ui/src/pages/SetupComplete.tsx +31 -0
  72. package/ui/src/pages/SetupConnections.tsx +80 -0
  73. package/ui/src/pages/SetupDashboardPassword.tsx +50 -0
  74. package/ui/src/pages/SetupIdentity.tsx +68 -0
  75. package/ui/src/pages/SetupPersonality.tsx +78 -0
  76. package/ui/src/pages/SetupProvider.tsx +65 -0
  77. package/ui/src/pages/SetupVault.tsx +50 -0
  78. package/ui/src/pages/SetupWelcome.tsx +19 -0
  79. package/ui/src/pages/UnlockVault.tsx +56 -0
  80. package/ui/src/pages/Webhooks.tsx +158 -0
  81. package/ui/src/pages/settings/Appearance.tsx +63 -0
  82. package/ui/src/pages/settings/Channels.tsx +138 -0
  83. package/ui/src/pages/settings/Identity.tsx +61 -0
  84. package/ui/src/pages/settings/Personality.tsx +54 -0
  85. package/ui/src/pages/settings/PersonalityEditor.tsx +577 -0
  86. package/ui/src/pages/settings/Provider.tsx +537 -0
  87. package/ui/src/pages/settings/Security.tsx +111 -0
  88. package/ui/src/styles/global.css +2308 -0
  89. package/ui/src/styles/themes/index.css +7 -0
  90. package/ui/src/styles/themes/monolith.css +125 -0
  91. package/ui/src/styles/themes/nebula.css +90 -0
  92. package/ui/src/styles/themes/neon.css +149 -0
  93. package/ui/src/styles/themes/polar.css +151 -0
  94. package/ui/src/styles/themes/signal.css +163 -0
  95. package/ui/src/styles/themes/terra.css +146 -0
  96. package/ui/tsconfig.json +14 -0
  97. package/ui/vite.config.ts +20 -0
package/src/router.ts ADDED
@@ -0,0 +1,2494 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { Router, type Request, type Response, type NextFunction } from 'express';
3
+ import { getLogger } from '@auxiora/logger';
4
+ import { audit } from '@auxiora/audit';
5
+ import { DashboardAuth } from './auth.js';
6
+ import type { DashboardConfig, DashboardDeps, SetupDeps } from './types.js';
7
+ import type { CloudDeps, CloudSignupRequest, CloudLoginRequest, CloudPlanChangeRequest, CloudPaymentMethodRequest } from './cloud-types.js';
8
+
9
+ const logger = getLogger('dashboard:router');
10
+
11
+ const COOKIE_NAME = 'auxiora_dash_session';
12
+
13
+ export interface DashboardRouterOptions {
14
+ deps: DashboardDeps;
15
+ config: DashboardConfig;
16
+ verifyPassword: (input: string) => boolean;
17
+ }
18
+
19
+ function parseCookies(header: string | undefined): Record<string, string> {
20
+ const cookies: Record<string, string> = {};
21
+ if (!header) return cookies;
22
+ for (const pair of header.split(';')) {
23
+ const [name, ...rest] = pair.trim().split('=');
24
+ if (name) cookies[name] = rest.join('=');
25
+ }
26
+ return cookies;
27
+ }
28
+
29
+ export function createDashboardRouter(options: DashboardRouterOptions): { router: Router; auth: DashboardAuth } {
30
+ const { deps, config, verifyPassword } = options;
31
+ const router = Router();
32
+ const auth = new DashboardAuth(config.sessionTtlMs);
33
+
34
+ // --- Auth middleware ---
35
+ function requireAuth(req: Request, res: Response, next: NextFunction): void {
36
+ // OAuth callbacks are public — Google redirects here without a session cookie
37
+ if (req.method === 'GET' && /\/connectors\/[^/]+\/callback/.test(req.path)) {
38
+ next();
39
+ return;
40
+ }
41
+
42
+ const cookies = parseCookies(req.headers.cookie);
43
+ const sessionId = cookies[COOKIE_NAME];
44
+
45
+ if (!sessionId || !auth.validateSession(sessionId)) {
46
+ res.status(401).json({ error: 'Unauthorized' });
47
+ return;
48
+ }
49
+
50
+ next();
51
+ }
52
+
53
+ function buildCookieHeader(value: string, req: Request, maxAge?: number): string {
54
+ let cookie = `${COOKIE_NAME}=${value}; HttpOnly; SameSite=Strict; Path=/`;
55
+ if (maxAge !== undefined) cookie += `; Max-Age=${maxAge}`;
56
+ if (req.secure) cookie += '; Secure';
57
+ return cookie;
58
+ }
59
+
60
+ // --- Auth routes (no auth required) ---
61
+ router.post('/auth/login', (req: Request, res: Response) => {
62
+ const ip = req.ip || req.socket.remoteAddress || 'unknown';
63
+
64
+ if (auth.isRateLimited(ip)) {
65
+ res.status(429).json({ error: 'Too many login attempts' });
66
+ return;
67
+ }
68
+
69
+ const { password } = req.body as { password?: string };
70
+ if (!password) {
71
+ res.status(400).json({ error: 'Password required' });
72
+ return;
73
+ }
74
+
75
+ if (!verifyPassword(password)) {
76
+ auth.recordAttempt(ip);
77
+ void audit('dashboard.login_failed', { ip });
78
+ res.status(401).json({ error: 'Invalid password' });
79
+ return;
80
+ }
81
+
82
+ const sessionId = auth.createSession(ip);
83
+ void audit('dashboard.login', { ip });
84
+
85
+ res.setHeader('Set-Cookie', buildCookieHeader(sessionId, req));
86
+ res.json({ success: true });
87
+ });
88
+
89
+ router.post('/auth/logout', (req: Request, res: Response) => {
90
+ const cookies = parseCookies(req.headers.cookie);
91
+ const sessionId = cookies[COOKIE_NAME];
92
+
93
+ if (sessionId) {
94
+ auth.destroySession(sessionId);
95
+ void audit('dashboard.logout', {});
96
+ }
97
+
98
+ res.setHeader('Set-Cookie', buildCookieHeader('', req, 0));
99
+ res.json({ success: true });
100
+ });
101
+
102
+ router.get('/auth/check', (req: Request, res: Response) => {
103
+ const cookies = parseCookies(req.headers.cookie);
104
+ const sessionId = cookies[COOKIE_NAME];
105
+ const authenticated = !!sessionId && auth.validateSession(sessionId);
106
+ res.json({ authenticated });
107
+ });
108
+
109
+ // --- Setup wizard routes (no auth required during first-run) ---
110
+ const setup = deps.setup;
111
+ let setupComplete = false;
112
+
113
+ async function checkNeedsSetup(): Promise<boolean> {
114
+ if (setupComplete) return false;
115
+ if (!setup) return false;
116
+ // Vault must exist for the app to function
117
+ if (setup.vaultExists && !(await setup.vaultExists())) return true;
118
+ const agentName = setup.getAgentName?.() ?? 'Auxiora';
119
+ const hasSoul = setup.hasSoulFile ? await setup.hasSoulFile() : false;
120
+ return agentName === 'Auxiora' && !hasSoul;
121
+ }
122
+
123
+ router.get('/setup/status', async (req: Request, res: Response) => {
124
+ const needsSetup = await checkNeedsSetup();
125
+ const completedSteps: string[] = [];
126
+ let vaultUnlocked = false;
127
+ let dashboardPasswordSet = false;
128
+ const agentName = setup?.getAgentName?.() ?? 'Auxiora';
129
+
130
+ if (agentName !== 'Auxiora') completedSteps.push('identity');
131
+ if (setup?.hasSoulFile && await setup.hasSoulFile()) {
132
+ completedSteps.push('personality');
133
+ }
134
+ try {
135
+ if (deps.vault.has('ANTHROPIC_API_KEY') || deps.vault.has('OPENAI_API_KEY')) {
136
+ completedSteps.push('provider');
137
+ }
138
+ vaultUnlocked = true;
139
+ dashboardPasswordSet = deps.vault.has('DASHBOARD_PASSWORD');
140
+ } catch {
141
+ // vault locked
142
+ }
143
+
144
+ res.json({ needsSetup, completedSteps, vaultUnlocked, dashboardPasswordSet, agentName });
145
+ });
146
+
147
+ router.get('/setup/templates', async (req: Request, res: Response) => {
148
+ if (!setup?.personality) {
149
+ res.json({ data: [] });
150
+ return;
151
+ }
152
+ const templates = await setup.personality.listTemplates();
153
+ res.json({ data: templates });
154
+ });
155
+
156
+ router.post('/setup/vault', async (req: Request, res: Response) => {
157
+ const { password } = req.body as { password?: string };
158
+ if (!password || typeof password !== 'string' || password.length < 8) {
159
+ res.status(400).json({ error: 'Password must be at least 8 characters' });
160
+ return;
161
+ }
162
+
163
+ try {
164
+ await deps.vault.unlock(password);
165
+ void audit('setup.vault', {});
166
+ // Initialize channels/providers that need vault access
167
+ if (deps.onVaultUnlocked) {
168
+ await deps.onVaultUnlocked();
169
+ }
170
+ res.json({ success: true });
171
+ } catch (error) {
172
+ const msg = error instanceof Error ? error.message : 'Failed to initialize vault';
173
+ res.status(500).json({ error: msg });
174
+ }
175
+ });
176
+
177
+ router.post('/setup/dashboard-password', async (req: Request, res: Response) => {
178
+ const { password } = req.body as { password?: string };
179
+ if (!password || typeof password !== 'string' || password.length < 8) {
180
+ res.status(400).json({ error: 'Password must be at least 8 characters' });
181
+ return;
182
+ }
183
+
184
+ try {
185
+ await deps.vault.add('DASHBOARD_PASSWORD', password);
186
+ void audit('setup.dashboard_password', {});
187
+ res.json({ success: true });
188
+ } catch (error) {
189
+ const msg = error instanceof Error ? error.message : 'Failed to store dashboard password';
190
+ res.status(500).json({ error: msg });
191
+ }
192
+ });
193
+
194
+ router.post('/setup/identity', async (req: Request, res: Response) => {
195
+ if (!setup?.saveConfig) {
196
+ res.status(503).json({ error: 'Setup not available' });
197
+ return;
198
+ }
199
+
200
+ const { name, pronouns, vibe } = req.body as { name?: string; pronouns?: string; vibe?: string };
201
+ if (!name || typeof name !== 'string') {
202
+ res.status(400).json({ error: 'Agent name is required' });
203
+ return;
204
+ }
205
+
206
+ await setup.saveConfig({
207
+ agent: {
208
+ name,
209
+ ...(pronouns ? { pronouns } : {}),
210
+ ...(vibe && typeof vibe === 'string' ? { vibe } : {}),
211
+ },
212
+ });
213
+
214
+ void audit('setup.identity', { name, pronouns, vibe });
215
+ res.json({ success: true, name });
216
+ });
217
+
218
+ router.post('/setup/personality', async (req: Request, res: Response) => {
219
+ if (!setup?.personality) {
220
+ res.status(503).json({ error: 'Personality not available' });
221
+ return;
222
+ }
223
+
224
+ const { template, custom } = req.body as { template?: string; custom?: Record<string, unknown> };
225
+
226
+ if (template) {
227
+ try {
228
+ await setup.personality.applyTemplate(template);
229
+ void audit('setup.personality', { template });
230
+ res.json({ success: true, template });
231
+ } catch (error) {
232
+ const msg = error instanceof Error ? error.message : 'Unknown error';
233
+ res.status(404).json({ error: msg });
234
+ }
235
+ } else if (custom) {
236
+ const content = await setup.personality.buildCustom(custom);
237
+ void audit('setup.personality', { custom: true });
238
+ res.json({ success: true, content });
239
+ } else {
240
+ res.status(400).json({ error: 'Provide either "template" or "custom" in body' });
241
+ }
242
+ });
243
+
244
+ router.post('/setup/provider', async (req: Request, res: Response) => {
245
+ const { provider, apiKey } = req.body as { provider?: string; apiKey?: string };
246
+ if (!provider) {
247
+ res.status(400).json({ error: 'Provider is required' });
248
+ return;
249
+ }
250
+
251
+ if (provider !== 'anthropic' && provider !== 'openai' && provider !== 'ollama') {
252
+ res.status(400).json({ error: 'Provider must be "anthropic", "openai", or "ollama"' });
253
+ return;
254
+ }
255
+
256
+ if (provider === 'ollama') {
257
+ const endpoint = (req.body as any).endpoint;
258
+ if (setup?.saveConfig) {
259
+ await setup.saveConfig({
260
+ provider: {
261
+ primary: 'ollama',
262
+ ollama: { baseUrl: endpoint || 'http://localhost:11434' },
263
+ },
264
+ });
265
+ }
266
+ void audit('setup.provider', { provider });
267
+ res.json({ success: true, provider });
268
+ return;
269
+ }
270
+
271
+ if (!apiKey) {
272
+ res.status(400).json({ error: 'API key is required for this provider' });
273
+ return;
274
+ }
275
+
276
+ const vaultKey = provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
277
+ try {
278
+ await deps.vault.add(vaultKey, apiKey);
279
+ } catch (error) {
280
+ const msg = error instanceof Error ? error.message : 'Failed to store API key';
281
+ res.status(400).json({ error: `Vault is not initialized. Complete the vault setup step first. (${msg})` });
282
+ return;
283
+ }
284
+
285
+ if (setup?.saveConfig) {
286
+ await setup.saveConfig({ provider: { primary: provider } });
287
+ }
288
+
289
+ void audit('setup.provider', { provider });
290
+ res.json({ success: true, provider });
291
+ });
292
+
293
+ router.post('/setup/channels', async (req: Request, res: Response) => {
294
+ if (!setup?.saveConfig) {
295
+ res.status(503).json({ error: 'Setup not available' });
296
+ return;
297
+ }
298
+
299
+ const CREDENTIAL_VAULT_KEYS: Record<string, Record<string, string>> = {
300
+ discord: { botToken: 'DISCORD_BOT_TOKEN' },
301
+ telegram: { botToken: 'TELEGRAM_BOT_TOKEN' },
302
+ slack: { botToken: 'SLACK_BOT_TOKEN', appToken: 'SLACK_APP_TOKEN' },
303
+ matrix: { accessToken: 'MATRIX_ACCESS_TOKEN' },
304
+ signal: {},
305
+ teams: { appPassword: 'TEAMS_APP_PASSWORD' },
306
+ whatsapp: { accessToken: 'WHATSAPP_ACCESS_TOKEN', verifyToken: 'WHATSAPP_VERIFY_TOKEN' },
307
+ twilio: { accountSid: 'TWILIO_ACCOUNT_SID', authToken: 'TWILIO_AUTH_TOKEN' },
308
+ email: { password: 'EMAIL_PASSWORD' },
309
+ };
310
+
311
+ const { channels } = req.body as { channels?: Array<string | { type: string; enabled: boolean; credentials?: Record<string, string> }> };
312
+ if (!Array.isArray(channels)) {
313
+ res.status(400).json({ error: 'Channels must be an array' });
314
+ return;
315
+ }
316
+
317
+ const channelConfig: Record<string, { enabled: boolean }> = {};
318
+ const channelNames: string[] = [];
319
+
320
+ for (const ch of channels) {
321
+ if (typeof ch === 'string') {
322
+ channelConfig[ch] = { enabled: true };
323
+ channelNames.push(ch);
324
+ } else if (typeof ch === 'object' && ch.type) {
325
+ channelConfig[ch.type] = { enabled: ch.enabled };
326
+ channelNames.push(ch.type);
327
+
328
+ if (ch.credentials) {
329
+ const keyMap = CREDENTIAL_VAULT_KEYS[ch.type];
330
+ if (keyMap) {
331
+ for (const [credKey, credValue] of Object.entries(ch.credentials)) {
332
+ const vaultKey = keyMap[credKey];
333
+ if (vaultKey && credValue && typeof credValue === 'string') {
334
+ try {
335
+ await deps.vault.add(vaultKey, credValue);
336
+ } catch (error) {
337
+ const msg = error instanceof Error ? error.message : 'Failed to store credential';
338
+ res.status(400).json({ error: `Vault is not initialized. Complete the vault setup step first. (${msg})` });
339
+ return;
340
+ }
341
+ }
342
+ }
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ await setup.saveConfig({ channels: channelConfig });
349
+ void audit('setup.channels', { channels: channelNames });
350
+ res.json({ success: true, channels: channelNames });
351
+ });
352
+
353
+ router.post('/setup/complete', async (req: Request, res: Response) => {
354
+ setupComplete = true;
355
+ void audit('setup.complete', {});
356
+
357
+ // Re-initialize providers now that vault has API keys
358
+ if (setup?.onSetupComplete) {
359
+ await setup.onSetupComplete();
360
+ }
361
+
362
+ const agentName = setup?.getAgentName?.() ?? 'Auxiora';
363
+ res.json({
364
+ success: true,
365
+ greeting: `${agentName} is ready! Setup complete.`,
366
+ });
367
+ });
368
+
369
+ // --- Setup guard middleware ---
370
+ router.use(async (req: Request, res: Response, next: NextFunction) => {
371
+ const needsSetup = await checkNeedsSetup();
372
+ if (needsSetup) {
373
+ res.status(403).json({ error: 'Setup required', needsSetup: true });
374
+ return;
375
+ }
376
+ next();
377
+ });
378
+
379
+ // --- Protected routes ---
380
+ router.use(requireAuth);
381
+
382
+ // Behaviors
383
+ router.get('/behaviors', async (req: Request, res: Response) => {
384
+ if (!deps.behaviors) {
385
+ res.json({ data: [] });
386
+ return;
387
+ }
388
+ const behaviors = await deps.behaviors.list();
389
+ res.json({ data: behaviors });
390
+ });
391
+
392
+ router.post('/behaviors', async (req: Request, res: Response) => {
393
+ if (!deps.behaviors) {
394
+ res.status(503).json({ error: 'Behaviors not available' });
395
+ return;
396
+ }
397
+ const { type, action, cron, timezone, intervalMinutes, condition, runAt } = req.body as {
398
+ type?: string;
399
+ action?: string;
400
+ cron?: string;
401
+ timezone?: string;
402
+ intervalMinutes?: number;
403
+ condition?: string;
404
+ runAt?: string;
405
+ };
406
+ if (!type || !action) {
407
+ res.status(400).json({ error: 'type and action are required' });
408
+ return;
409
+ }
410
+ if (!['scheduled', 'monitor', 'one-shot'].includes(type)) {
411
+ res.status(400).json({ error: 'type must be "scheduled", "monitor", or "one-shot"' });
412
+ return;
413
+ }
414
+
415
+ const input: Record<string, unknown> = {
416
+ type,
417
+ action,
418
+ channel: { type: 'webchat', id: 'dashboard', overridden: false },
419
+ createdBy: 'dashboard',
420
+ };
421
+
422
+ if (type === 'scheduled') {
423
+ if (!cron) {
424
+ res.status(400).json({ error: 'cron is required for scheduled behaviors' });
425
+ return;
426
+ }
427
+ input.schedule = { cron, timezone: timezone || 'UTC' };
428
+ } else if (type === 'monitor') {
429
+ if (!intervalMinutes || !condition) {
430
+ res.status(400).json({ error: 'intervalMinutes and condition are required for monitor behaviors' });
431
+ return;
432
+ }
433
+ input.polling = { intervalMs: intervalMinutes * 60_000, condition };
434
+ } else if (type === 'one-shot') {
435
+ if (!runAt) {
436
+ res.status(400).json({ error: 'runAt is required for one-shot behaviors' });
437
+ return;
438
+ }
439
+ const ts = new Date(runAt).getTime();
440
+ if (isNaN(ts) || ts <= Date.now()) {
441
+ res.status(400).json({ error: 'runAt must be a valid future timestamp' });
442
+ return;
443
+ }
444
+ input.delay = { runAt: ts };
445
+ }
446
+
447
+ try {
448
+ const behavior = await deps.behaviors.create(input);
449
+ void audit('behavior.created', { id: behavior.id, type });
450
+ res.status(201).json({ data: behavior });
451
+ } catch (error) {
452
+ const msg = error instanceof Error ? error.message : 'Failed to create behavior';
453
+ res.status(400).json({ error: msg });
454
+ }
455
+ });
456
+
457
+ router.patch('/behaviors/:id', async (req: Request, res: Response) => {
458
+ if (!deps.behaviors) {
459
+ res.status(503).json({ error: 'Behaviors not available' });
460
+ return;
461
+ }
462
+ const id = String(req.params.id);
463
+ const { action, status, cron, timezone, intervalMinutes, condition, runAt } = req.body as {
464
+ action?: string;
465
+ status?: string;
466
+ cron?: string;
467
+ timezone?: string;
468
+ intervalMinutes?: number;
469
+ condition?: string;
470
+ runAt?: string;
471
+ };
472
+
473
+ // Transform flat frontend fields into nested Behavior structure
474
+ const updates: Record<string, unknown> = {};
475
+ if (action !== undefined) updates.action = action;
476
+ if (status !== undefined) updates.status = status;
477
+ if (cron !== undefined) {
478
+ updates.schedule = { cron, timezone: timezone || 'UTC' };
479
+ } else if (timezone !== undefined) {
480
+ // Fetch current to preserve existing cron
481
+ const current = await deps.behaviors.get(id);
482
+ if (current?.schedule) {
483
+ updates.schedule = { ...current.schedule, timezone };
484
+ }
485
+ }
486
+ if (intervalMinutes !== undefined || condition !== undefined) {
487
+ const current = await deps.behaviors.get(id);
488
+ updates.polling = {
489
+ intervalMs: intervalMinutes !== undefined ? intervalMinutes * 60_000 : current?.polling?.intervalMs,
490
+ condition: condition !== undefined ? condition : current?.polling?.condition,
491
+ };
492
+ }
493
+ if (runAt !== undefined) {
494
+ const ts = new Date(runAt).getTime();
495
+ if (!isNaN(ts)) {
496
+ updates.delay = { fireAt: ts };
497
+ }
498
+ }
499
+
500
+ const result = await deps.behaviors.update(id, updates);
501
+ if (!result) {
502
+ res.status(404).json({ error: 'Behavior not found' });
503
+ return;
504
+ }
505
+ res.json({ data: result });
506
+ });
507
+
508
+ router.delete('/behaviors/:id', async (req: Request, res: Response) => {
509
+ if (!deps.behaviors) {
510
+ res.status(503).json({ error: 'Behaviors not available' });
511
+ return;
512
+ }
513
+ const removed = await deps.behaviors.remove(String(req.params.id));
514
+ if (!removed) {
515
+ res.status(404).json({ error: 'Behavior not found' });
516
+ return;
517
+ }
518
+ res.json({ data: { deleted: true } });
519
+ });
520
+
521
+ // Webhooks
522
+ router.get('/webhooks', async (req: Request, res: Response) => {
523
+ if (!deps.webhooks) {
524
+ res.json({ data: [] });
525
+ return;
526
+ }
527
+ const webhooks = await deps.webhooks.list();
528
+ // Redact secrets
529
+ const redacted = webhooks.map((w: any) => ({ ...w, secret: '***' }));
530
+ res.json({ data: redacted });
531
+ });
532
+
533
+ router.post('/webhooks', async (req: Request, res: Response) => {
534
+ if (!deps.webhooks) {
535
+ res.status(503).json({ error: 'Webhooks not available' });
536
+ return;
537
+ }
538
+ const { name, secret, behaviorId } = req.body as {
539
+ name?: string;
540
+ secret?: string;
541
+ behaviorId?: string;
542
+ };
543
+ if (!name || !secret) {
544
+ res.status(400).json({ error: 'name and secret are required' });
545
+ return;
546
+ }
547
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
548
+ res.status(400).json({ error: 'name must be URL-safe (letters, numbers, hyphens, underscores)' });
549
+ return;
550
+ }
551
+
552
+ try {
553
+ const webhook = await deps.webhooks.create({
554
+ name,
555
+ secret,
556
+ type: 'generic',
557
+ enabled: true,
558
+ ...(behaviorId ? { behaviorId } : {}),
559
+ });
560
+ void audit('webhook.created', { id: webhook.id, name });
561
+ res.status(201).json({ data: { ...webhook, secret: '***' } });
562
+ } catch (error) {
563
+ const msg = error instanceof Error ? error.message : 'Failed to create webhook';
564
+ res.status(400).json({ error: msg });
565
+ }
566
+ });
567
+
568
+ router.patch('/webhooks/:id', async (req: Request, res: Response) => {
569
+ if (!deps.webhooks?.update) {
570
+ res.status(503).json({ error: 'Webhooks not available' });
571
+ return;
572
+ }
573
+ const result = await deps.webhooks.update(String(req.params.id), req.body);
574
+ if (!result) {
575
+ res.status(404).json({ error: 'Webhook not found' });
576
+ return;
577
+ }
578
+ res.json({ data: { ...result, secret: '***' } });
579
+ });
580
+
581
+ router.delete('/webhooks/:id', async (req: Request, res: Response) => {
582
+ if (!deps.webhooks) {
583
+ res.status(503).json({ error: 'Webhooks not available' });
584
+ return;
585
+ }
586
+ const removed = await deps.webhooks.delete(String(req.params.id));
587
+ if (!removed) {
588
+ res.status(404).json({ error: 'Webhook not found' });
589
+ return;
590
+ }
591
+ res.json({ data: { deleted: true } });
592
+ });
593
+
594
+ // Sessions
595
+ router.get('/sessions', (req: Request, res: Response) => {
596
+ const connections = deps.getConnections();
597
+ res.json({ data: connections });
598
+ });
599
+
600
+ // Chat history for the webchat session
601
+ router.get('/session/messages', async (req: Request, res: Response) => {
602
+ if (!deps.sessions) {
603
+ res.json({ data: [] });
604
+ return;
605
+ }
606
+ const messages = await deps.sessions.getWebchatMessages();
607
+ res.json({ data: messages });
608
+ });
609
+
610
+ // Chat management
611
+ router.get('/chats', (req: Request, res: Response) => {
612
+ if (!deps.sessions?.listChats) {
613
+ res.json({ data: [], total: 0 });
614
+ return;
615
+ }
616
+ const archived = req.query.archived === 'true';
617
+ const limit = parseInt(req.query.limit as string) || 50;
618
+ const offset = parseInt(req.query.offset as string) || 0;
619
+ const chats = deps.sessions.listChats({ archived, limit, offset });
620
+ res.json({ data: chats, total: chats.length });
621
+ });
622
+
623
+ router.post('/chats', (req: Request, res: Response) => {
624
+ if (!deps.sessions?.createChat) {
625
+ res.status(503).json({ error: 'Sessions not available' });
626
+ return;
627
+ }
628
+ const { title } = req.body as { title?: string };
629
+ const chat = deps.sessions.createChat(title);
630
+ res.status(201).json({ data: chat });
631
+ });
632
+
633
+ router.get('/chats/:id/messages', (req: Request, res: Response) => {
634
+ if (!deps.sessions?.getChatMessages) {
635
+ res.json({ data: [] });
636
+ return;
637
+ }
638
+ const messages = deps.sessions.getChatMessages(String(req.params.id));
639
+ res.json({ data: messages });
640
+ });
641
+
642
+ router.patch('/chats/:id', (req: Request, res: Response) => {
643
+ if (!deps.sessions) {
644
+ res.status(503).json({ error: 'Sessions not available' });
645
+ return;
646
+ }
647
+ const chatId = String(req.params.id);
648
+ const { title, archived } = req.body as { title?: string; archived?: boolean };
649
+ if (title !== undefined && deps.sessions.renameChat) {
650
+ deps.sessions.renameChat(chatId, title);
651
+ }
652
+ if (archived === true && deps.sessions.archiveChat) {
653
+ deps.sessions.archiveChat(chatId);
654
+ }
655
+ res.json({ data: { ok: true } });
656
+ });
657
+
658
+ router.delete('/chats/:id', (req: Request, res: Response) => {
659
+ if (!deps.sessions?.deleteChat) {
660
+ res.status(503).json({ error: 'Sessions not available' });
661
+ return;
662
+ }
663
+ deps.sessions.deleteChat(String(req.params.id));
664
+ res.json({ data: { deleted: true } });
665
+ });
666
+
667
+ // Audit
668
+ router.get('/audit', async (req: Request, res: Response) => {
669
+ const limit = parseInt(req.query.limit as string) || 100;
670
+ const entries = await deps.getAuditEntries(limit);
671
+
672
+ // Filter by type if provided
673
+ const type = req.query.type as string | undefined;
674
+ const filtered = type
675
+ ? entries.filter((e: any) => e.event.startsWith(type))
676
+ : entries;
677
+
678
+ res.json({ data: filtered });
679
+ });
680
+
681
+ // Status
682
+ router.get('/status', async (req: Request, res: Response) => {
683
+ const connections = deps.getConnections();
684
+ const behaviors = deps.behaviors ? await deps.behaviors.list() : [];
685
+ const webhooks = deps.webhooks ? await deps.webhooks.list() : [];
686
+ const activeBehaviors = behaviors.filter((b: any) => b.status === 'active');
687
+
688
+ const activeModel = deps.getActiveModel?.() ?? { provider: 'unknown', model: 'unknown' };
689
+
690
+ res.json({
691
+ data: {
692
+ uptime: process.uptime(),
693
+ connections: connections.length,
694
+ activeBehaviors: activeBehaviors.length,
695
+ totalBehaviors: behaviors.length,
696
+ webhooks: webhooks.length,
697
+ activeModel,
698
+ },
699
+ });
700
+ });
701
+
702
+ // --- Settings routes (authenticated) ---
703
+
704
+ // Identity
705
+ router.get('/identity', (req: Request, res: Response) => {
706
+ const name = setup?.getAgentName?.() ?? 'Auxiora';
707
+ const pronouns = setup?.getAgentPronouns?.() ?? 'they/them';
708
+ res.json({ data: { name, pronouns } });
709
+ });
710
+
711
+ router.post('/identity', async (req: Request, res: Response) => {
712
+ if (!setup?.saveConfig) {
713
+ res.status(503).json({ error: 'Setup not available' });
714
+ return;
715
+ }
716
+ const { name, pronouns } = req.body as { name?: string; pronouns?: string };
717
+ if (!name || typeof name !== 'string') {
718
+ res.status(400).json({ error: 'Agent name is required' });
719
+ return;
720
+ }
721
+ await setup.saveConfig({
722
+ agent: { name, ...(pronouns ? { pronouns } : {}) },
723
+ });
724
+ void audit('settings.identity', { name, pronouns });
725
+ res.json({ success: true });
726
+ });
727
+
728
+ // Full personality state for the editor
729
+ router.get('/personality/full', async (req: Request, res: Response) => {
730
+ const agent = setup?.getAgentConfig?.() ?? {};
731
+
732
+ let soulContent: string | null = null;
733
+ if (setup?.getSoulContent) {
734
+ soulContent = await setup.getSoulContent();
735
+ }
736
+
737
+ let activeTemplate: string | null = null;
738
+ if (setup?.personality?.getActiveTemplate) {
739
+ const t = await setup.personality.getActiveTemplate();
740
+ activeTemplate = t?.id ?? null;
741
+ }
742
+
743
+ res.json({
744
+ data: {
745
+ name: (agent.name as string) ?? 'Auxiora',
746
+ pronouns: (agent.pronouns as string) ?? 'they/them',
747
+ avatar: (agent.avatar as string) ?? null,
748
+ vibe: (agent.vibe as string) ?? '',
749
+ tone: (agent.tone as Record<string, number>) ?? { warmth: 0.6, directness: 0.5, humor: 0.3, formality: 0.5 },
750
+ errorStyle: (agent.errorStyle as string) ?? 'professional',
751
+ expertise: (agent.expertise as string[]) ?? [],
752
+ catchphrases: (agent.catchphrases as Record<string, string>) ?? {},
753
+ boundaries: (agent.boundaries as Record<string, string[]>) ?? { neverJokeAbout: [], neverAdviseOn: [] },
754
+ customInstructions: (agent.customInstructions as string) ?? '',
755
+ soulContent,
756
+ activeTemplate,
757
+ },
758
+ });
759
+ });
760
+
761
+ router.put('/personality/full', async (req: Request, res: Response) => {
762
+ if (!setup?.saveConfig) {
763
+ res.status(503).json({ error: 'Setup not available' });
764
+ return;
765
+ }
766
+ const body = req.body as Record<string, unknown>;
767
+
768
+ const agentUpdate: Record<string, unknown> = {};
769
+ if (typeof body.name === 'string') agentUpdate.name = body.name;
770
+ if (typeof body.pronouns === 'string') agentUpdate.pronouns = body.pronouns;
771
+ if (typeof body.avatar === 'string' || body.avatar === null) agentUpdate.avatar = body.avatar ?? undefined;
772
+ if (typeof body.vibe === 'string') agentUpdate.vibe = body.vibe;
773
+ if (typeof body.customInstructions === 'string') agentUpdate.customInstructions = body.customInstructions;
774
+ if (typeof body.errorStyle === 'string') agentUpdate.errorStyle = body.errorStyle;
775
+ if (body.tone && typeof body.tone === 'object') agentUpdate.tone = body.tone;
776
+ if (Array.isArray(body.expertise)) agentUpdate.expertise = body.expertise;
777
+ if (body.catchphrases && typeof body.catchphrases === 'object') agentUpdate.catchphrases = body.catchphrases;
778
+ if (body.boundaries && typeof body.boundaries === 'object') agentUpdate.boundaries = body.boundaries;
779
+
780
+ try {
781
+ await setup.saveConfig({ agent: agentUpdate });
782
+
783
+ if (typeof body.soulContent === 'string' && setup.saveSoulContent) {
784
+ await setup.saveSoulContent(body.soulContent);
785
+ }
786
+
787
+ if (typeof body.template === 'string' && body.template && setup.personality) {
788
+ await setup.personality.applyTemplate(body.template);
789
+ }
790
+
791
+ void audit('settings.personality', { source: 'editor', fields: Object.keys(agentUpdate) });
792
+ res.json({ success: true });
793
+ } catch (error) {
794
+ const msg = error instanceof Error ? error.message : 'Unknown error';
795
+ res.status(500).json({ error: msg });
796
+ }
797
+ });
798
+
799
+ // Current personality (match SOUL.md against templates)
800
+ router.get('/personality', async (req: Request, res: Response) => {
801
+ if (!setup?.personality?.getActiveTemplate) {
802
+ res.json({ data: { template: null } });
803
+ return;
804
+ }
805
+ const template = await setup.personality.getActiveTemplate();
806
+ res.json({ data: { template } });
807
+ });
808
+
809
+ // Personality templates
810
+ router.get('/personality/templates', async (req: Request, res: Response) => {
811
+ if (!setup?.personality) {
812
+ res.json({ data: [] });
813
+ return;
814
+ }
815
+ const templates = await setup.personality.listTemplates();
816
+ res.json({ data: templates });
817
+ });
818
+
819
+ router.post('/personality', async (req: Request, res: Response) => {
820
+ if (!setup?.personality) {
821
+ res.status(503).json({ error: 'Personality not available' });
822
+ return;
823
+ }
824
+ const { template } = req.body as { template?: string };
825
+ if (!template) {
826
+ res.status(400).json({ error: 'Template is required' });
827
+ return;
828
+ }
829
+ try {
830
+ await setup.personality.applyTemplate(template);
831
+ void audit('settings.personality', { template });
832
+ res.json({ success: true });
833
+ } catch (error) {
834
+ const msg = error instanceof Error ? error.message : 'Unknown error';
835
+ res.status(404).json({ error: msg });
836
+ }
837
+ });
838
+
839
+ // Provider
840
+ router.post('/provider', async (req: Request, res: Response) => {
841
+ const { provider, apiKey, endpoint } = req.body as { provider?: string; apiKey?: string; endpoint?: string };
842
+ if (!provider) {
843
+ res.status(400).json({ error: 'Provider is required' });
844
+ return;
845
+ }
846
+ if (provider !== 'anthropic' && provider !== 'openai' && provider !== 'ollama') {
847
+ res.status(400).json({ error: 'Provider must be "anthropic", "openai", or "ollama"' });
848
+ return;
849
+ }
850
+ if (provider === 'ollama') {
851
+ if (setup?.saveConfig) {
852
+ await setup.saveConfig({
853
+ provider: { primary: 'ollama', ollama: { baseUrl: endpoint || 'http://localhost:11434' } },
854
+ });
855
+ }
856
+ void audit('settings.provider', { provider });
857
+ res.json({ success: true });
858
+ return;
859
+ }
860
+ if (!apiKey) {
861
+ res.status(400).json({ error: 'API key is required for this provider' });
862
+ return;
863
+ }
864
+ const vaultKey = provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
865
+ try {
866
+ await deps.vault.add(vaultKey, apiKey);
867
+ } catch (error) {
868
+ const msg = error instanceof Error ? error.message : 'Failed to store API key';
869
+ res.status(400).json({ error: msg });
870
+ return;
871
+ }
872
+ if (setup?.saveConfig) {
873
+ await setup.saveConfig({ provider: { primary: provider } });
874
+ }
875
+ if (setup?.onSetupComplete) {
876
+ await setup.onSetupComplete();
877
+ }
878
+ void audit('settings.provider', { provider });
879
+ res.json({ success: true });
880
+ });
881
+
882
+ // Provider: configure a specific provider's credentials (does NOT change primary/fallback)
883
+ const PROVIDER_VAULT_KEYS: Record<string, string> = {
884
+ anthropic: 'ANTHROPIC_API_KEY',
885
+ openai: 'OPENAI_API_KEY',
886
+ google: 'GOOGLE_API_KEY',
887
+ groq: 'GROQ_API_KEY',
888
+ deepseek: 'DEEPSEEK_API_KEY',
889
+ cohere: 'COHERE_API_KEY',
890
+ xai: 'XAI_API_KEY',
891
+ replicate: 'REPLICATE_API_TOKEN',
892
+ };
893
+
894
+ const VALID_PROVIDERS = ['anthropic', 'openai', 'google', 'ollama', 'groq', 'deepseek', 'cohere', 'xai', 'openaiCompatible', 'claudeOAuth'];
895
+
896
+ // --- Claude OAuth PKCE flow ---
897
+ const pkceStates = new Map<string, { verifier: string; state: string; createdAt: number }>();
898
+ const PKCE_TTL_MS = 10 * 60 * 1000; // 10 minutes
899
+
900
+ setInterval(() => {
901
+ const now = Date.now();
902
+ for (const [key, state] of pkceStates) {
903
+ if (now - state.createdAt > PKCE_TTL_MS) {
904
+ pkceStates.delete(key);
905
+ }
906
+ }
907
+ }, 60_000);
908
+
909
+ router.post('/provider/claude-oauth/start', async (req: Request, res: Response) => {
910
+ const providersModule = '@auxiora/providers';
911
+ const { generatePKCE, buildAuthorizationUrl } = await import(/* webpackIgnore: true */ providersModule);
912
+ const { verifier, challenge } = generatePKCE();
913
+
914
+ const cookies = parseCookies(req.headers.cookie);
915
+ const sessionId = cookies[COOKIE_NAME];
916
+ if (!sessionId) {
917
+ res.status(401).json({ error: 'No session' });
918
+ return;
919
+ }
920
+
921
+ const oauthState = crypto.randomBytes(32).toString('hex');
922
+ pkceStates.set(sessionId, { verifier, state: oauthState, createdAt: Date.now() });
923
+ const authUrl = buildAuthorizationUrl(challenge, oauthState);
924
+ void audit('settings.provider', { provider: 'claudeOAuth', action: 'oauth-start' });
925
+ res.json({ authUrl });
926
+ });
927
+
928
+ router.post('/provider/claude-oauth/callback', async (req: Request, res: Response) => {
929
+ let { code } = req.body as { code?: string };
930
+ if (!code || typeof code !== 'string') {
931
+ res.status(400).json({ error: 'Authorization code is required' });
932
+ return;
933
+ }
934
+ // Anthropic returns code as "code#state" — split to extract both parts
935
+ const codeParts = code.trim().split('#');
936
+ code = codeParts[0];
937
+ const codeState = codeParts[1] || '';
938
+
939
+ const cookies = parseCookies(req.headers.cookie);
940
+ const sessionId = cookies[COOKIE_NAME];
941
+ if (!sessionId) {
942
+ res.status(401).json({ error: 'No session' });
943
+ return;
944
+ }
945
+
946
+ const pkceState = pkceStates.get(sessionId);
947
+ if (!pkceState) {
948
+ res.status(400).json({ error: 'No pending OAuth flow. Click "Connect" to start again.' });
949
+ return;
950
+ }
951
+
952
+ if (Date.now() - pkceState.createdAt > PKCE_TTL_MS) {
953
+ pkceStates.delete(sessionId);
954
+ res.status(400).json({ error: 'OAuth flow expired. Click "Connect" to start again.' });
955
+ return;
956
+ }
957
+
958
+ pkceStates.delete(sessionId);
959
+
960
+ try {
961
+ const providersModule = '@auxiora/providers';
962
+ const { exchangeCodeForTokens, writeClaudeCliCredentials } = await import(/* webpackIgnore: true */ providersModule);
963
+ const tokens = await exchangeCodeForTokens(code, pkceState.verifier, codeState || pkceState.state);
964
+
965
+ // Store all token data in vault for refresh support
966
+ await deps.vault.add('ANTHROPIC_OAUTH_TOKEN', tokens.accessToken);
967
+ await deps.vault.add('CLAUDE_OAUTH_REFRESH_TOKEN', tokens.refreshToken);
968
+ await deps.vault.add('CLAUDE_OAUTH_EXPIRES_AT', String(tokens.expiresAt));
969
+
970
+ // Also write to CLI credentials file if it exists (non-critical)
971
+ writeClaudeCliCredentials(tokens);
972
+
973
+ if (setup?.onSetupComplete) {
974
+ await setup.onSetupComplete();
975
+ }
976
+
977
+ void audit('settings.provider', { provider: 'claudeOAuth', action: 'oauth-complete' });
978
+ res.json({ success: true });
979
+ } catch (error) {
980
+ const msg = error instanceof Error ? error.message : 'Token exchange failed';
981
+ logger.error(`Claude OAuth callback failed: ${msg}`);
982
+ res.status(400).json({ error: msg });
983
+ }
984
+ });
985
+
986
+ router.post('/provider/claude-oauth/disconnect', async (req: Request, res: Response) => {
987
+ try {
988
+ await deps.vault.add('ANTHROPIC_OAUTH_TOKEN', '');
989
+ await deps.vault.add('CLAUDE_OAUTH_REFRESH_TOKEN', '');
990
+ await deps.vault.add('CLAUDE_OAUTH_EXPIRES_AT', '');
991
+
992
+ if (setup?.onSetupComplete) {
993
+ await setup.onSetupComplete();
994
+ }
995
+
996
+ void audit('settings.provider', { provider: 'claudeOAuth', action: 'disconnect' });
997
+ res.json({ success: true });
998
+ } catch (error) {
999
+ const msg = error instanceof Error ? error.message : 'Disconnect failed';
1000
+ res.status(500).json({ error: msg });
1001
+ }
1002
+ });
1003
+
1004
+ router.get('/provider/claude-oauth/status', (_req: Request, res: Response) => {
1005
+ const hasToken = deps.vault.has('ANTHROPIC_OAUTH_TOKEN') &&
1006
+ deps.vault.get('ANTHROPIC_OAUTH_TOKEN') !== '';
1007
+ res.json({ connected: hasToken });
1008
+ });
1009
+
1010
+ router.post('/provider/configure', async (req: Request, res: Response) => {
1011
+ const { provider, apiKey, endpoint } = req.body as { provider?: string; apiKey?: string; endpoint?: string };
1012
+ if (!provider || !VALID_PROVIDERS.includes(provider)) {
1013
+ res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
1014
+ return;
1015
+ }
1016
+
1017
+ // For local providers (ollama), save config only
1018
+ if (provider === 'ollama') {
1019
+ if (setup?.saveConfig) {
1020
+ await setup.saveConfig({
1021
+ provider: { ollama: { baseUrl: endpoint || 'http://localhost:11434' } },
1022
+ });
1023
+ }
1024
+ void audit('settings.provider', { provider, action: 'configure' });
1025
+ res.json({ success: true, provider });
1026
+ return;
1027
+ }
1028
+
1029
+ if (provider === 'openaiCompatible') {
1030
+ if (!endpoint) {
1031
+ res.status(400).json({ error: 'Endpoint URL is required for OpenAI-compatible providers' });
1032
+ return;
1033
+ }
1034
+ if (apiKey) {
1035
+ try {
1036
+ await deps.vault.add('OPENAI_COMPATIBLE_API_KEY', apiKey);
1037
+ } catch (error) {
1038
+ const msg = error instanceof Error ? error.message : 'Failed to store API key';
1039
+ res.status(400).json({ error: msg });
1040
+ return;
1041
+ }
1042
+ }
1043
+ if (setup?.saveConfig) {
1044
+ await setup.saveConfig({
1045
+ provider: { openaiCompatible: { baseUrl: endpoint } },
1046
+ });
1047
+ }
1048
+ void audit('settings.provider', { provider, action: 'configure' });
1049
+ res.json({ success: true, provider });
1050
+ return;
1051
+ }
1052
+
1053
+ // Cloud providers: store API key in vault
1054
+ if (!apiKey) {
1055
+ res.status(400).json({ error: 'API key is required for this provider' });
1056
+ return;
1057
+ }
1058
+
1059
+ const vaultKey = PROVIDER_VAULT_KEYS[provider];
1060
+ if (!vaultKey) {
1061
+ res.status(400).json({ error: `Unknown vault key for provider: ${provider}` });
1062
+ return;
1063
+ }
1064
+
1065
+ try {
1066
+ await deps.vault.add(vaultKey, apiKey);
1067
+ } catch (error) {
1068
+ const msg = error instanceof Error ? error.message : 'Failed to store API key';
1069
+ res.status(400).json({ error: msg });
1070
+ return;
1071
+ }
1072
+
1073
+ // Re-initialize providers to pick up the new key
1074
+ if (setup?.onSetupComplete) {
1075
+ await setup.onSetupComplete();
1076
+ }
1077
+
1078
+ void audit('settings.provider', { provider, action: 'configure' });
1079
+ res.json({ success: true, provider });
1080
+ });
1081
+
1082
+ // Provider: update primary/fallback routing
1083
+ router.post('/provider/routing', async (req: Request, res: Response) => {
1084
+ const { primary, fallback } = req.body as { primary?: string; fallback?: string };
1085
+ if (!primary || !VALID_PROVIDERS.includes(primary)) {
1086
+ res.status(400).json({ error: 'Valid primary provider is required' });
1087
+ return;
1088
+ }
1089
+ if (fallback && !VALID_PROVIDERS.includes(fallback)) {
1090
+ res.status(400).json({ error: `Invalid fallback provider: ${fallback}` });
1091
+ return;
1092
+ }
1093
+
1094
+ if (setup?.saveConfig) {
1095
+ await setup.saveConfig({
1096
+ provider: { primary, ...(fallback ? { fallback } : {}) },
1097
+ });
1098
+ }
1099
+
1100
+ // Re-initialize to apply routing change
1101
+ if (setup?.onSetupComplete) {
1102
+ await setup.onSetupComplete();
1103
+ }
1104
+
1105
+ void audit('settings.provider', { primary, fallback, action: 'routing' });
1106
+ res.json({ success: true, primary, fallback });
1107
+ });
1108
+
1109
+ // Provider: set default model for a provider
1110
+ router.post('/provider/model', async (req: Request, res: Response) => {
1111
+ const { provider, model } = req.body as { provider?: string; model?: string };
1112
+ if (!provider || !VALID_PROVIDERS.includes(provider)) {
1113
+ res.status(400).json({ error: 'Valid provider is required' });
1114
+ return;
1115
+ }
1116
+ if (!model || typeof model !== 'string') {
1117
+ res.status(400).json({ error: 'Model name is required' });
1118
+ return;
1119
+ }
1120
+
1121
+ if (setup?.saveConfig) {
1122
+ await setup.saveConfig({
1123
+ provider: { [provider]: { model } },
1124
+ });
1125
+ }
1126
+
1127
+ void audit('settings.provider', { provider, model, action: 'model' });
1128
+ res.json({ success: true, provider, model });
1129
+ });
1130
+
1131
+ // Channels
1132
+ router.get('/channels', (req: Request, res: Response) => {
1133
+ const connections = deps.getConnections();
1134
+ const connectedTypes = [...new Set(connections.map(c => c.channelType))];
1135
+ const configured = deps.getConfiguredChannels?.() ?? [];
1136
+ res.json({ data: { connected: connectedTypes, configured } });
1137
+ });
1138
+
1139
+ router.post('/channels', async (req: Request, res: Response) => {
1140
+ if (!setup?.saveConfig) {
1141
+ res.status(503).json({ error: 'Setup not available' });
1142
+ return;
1143
+ }
1144
+
1145
+ const CREDENTIAL_VAULT_KEYS: Record<string, Record<string, string>> = {
1146
+ discord: { botToken: 'DISCORD_BOT_TOKEN' },
1147
+ telegram: { botToken: 'TELEGRAM_BOT_TOKEN' },
1148
+ slack: { botToken: 'SLACK_BOT_TOKEN', appToken: 'SLACK_APP_TOKEN' },
1149
+ matrix: { accessToken: 'MATRIX_ACCESS_TOKEN' },
1150
+ signal: {},
1151
+ teams: { appPassword: 'TEAMS_APP_PASSWORD' },
1152
+ whatsapp: { accessToken: 'WHATSAPP_ACCESS_TOKEN', verifyToken: 'WHATSAPP_VERIFY_TOKEN' },
1153
+ twilio: { accountSid: 'TWILIO_ACCOUNT_SID', authToken: 'TWILIO_AUTH_TOKEN' },
1154
+ email: { password: 'EMAIL_PASSWORD' },
1155
+ };
1156
+
1157
+ const { channels } = req.body as { channels?: Array<{ type: string; enabled: boolean; credentials?: Record<string, string> }> };
1158
+ if (!Array.isArray(channels)) {
1159
+ res.status(400).json({ error: 'Channels must be an array' });
1160
+ return;
1161
+ }
1162
+
1163
+ const channelConfig: Record<string, { enabled: boolean }> = {};
1164
+ for (const ch of channels) {
1165
+ channelConfig[ch.type] = { enabled: ch.enabled };
1166
+ if (ch.credentials) {
1167
+ const keyMap = CREDENTIAL_VAULT_KEYS[ch.type];
1168
+ if (keyMap) {
1169
+ for (const [credKey, credValue] of Object.entries(ch.credentials)) {
1170
+ const vk = keyMap[credKey];
1171
+ if (vk && credValue && typeof credValue === 'string') {
1172
+ try {
1173
+ await deps.vault.add(vk, credValue);
1174
+ } catch (error) {
1175
+ const msg = error instanceof Error ? error.message : 'Failed to store credential';
1176
+ res.status(400).json({ error: msg });
1177
+ return;
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ await setup.saveConfig({ channels: channelConfig });
1186
+ void audit('settings.channels', { channels: channels.map(c => c.type) });
1187
+ res.json({ success: true });
1188
+ });
1189
+
1190
+ // Security: Change dashboard password
1191
+ router.post('/security/dashboard-password', async (req: Request, res: Response) => {
1192
+ const { oldPassword, newPassword } = req.body as { oldPassword?: string; newPassword?: string };
1193
+ if (!oldPassword || !newPassword) {
1194
+ res.status(400).json({ error: 'Both oldPassword and newPassword are required' });
1195
+ return;
1196
+ }
1197
+ if (typeof newPassword !== 'string' || newPassword.length < 8) {
1198
+ res.status(400).json({ error: 'New password must be at least 8 characters' });
1199
+ return;
1200
+ }
1201
+ if (!verifyPassword(oldPassword)) {
1202
+ res.status(401).json({ error: 'Current password is incorrect' });
1203
+ return;
1204
+ }
1205
+ try {
1206
+ await deps.vault.add('DASHBOARD_PASSWORD', newPassword);
1207
+ void audit('settings.dashboard_password_changed', {});
1208
+ res.json({ success: true });
1209
+ } catch (error) {
1210
+ const msg = error instanceof Error ? error.message : 'Failed to update password';
1211
+ res.status(500).json({ error: msg });
1212
+ }
1213
+ });
1214
+
1215
+ // Security: Change vault password
1216
+ router.post('/security/vault-password', async (req: Request, res: Response) => {
1217
+ const { newPassword } = req.body as { newPassword?: string };
1218
+ if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
1219
+ res.status(400).json({ error: 'New password must be at least 8 characters' });
1220
+ return;
1221
+ }
1222
+ try {
1223
+ await deps.vault.changePassword(newPassword);
1224
+ void audit('settings.vault_password_changed', {});
1225
+ res.json({ success: true });
1226
+ } catch (error) {
1227
+ const msg = error instanceof Error ? error.message : 'Failed to change vault password';
1228
+ res.status(500).json({ error: msg });
1229
+ }
1230
+ });
1231
+
1232
+ // Plugins
1233
+ router.get('/plugins', (req: Request, res: Response) => {
1234
+ const plugins = deps.getPlugins ? deps.getPlugins() : [];
1235
+ res.json({ data: plugins });
1236
+ });
1237
+
1238
+ router.get('/plugins/:id', (req: Request, res: Response) => {
1239
+ const plugins = deps.getPlugins ? deps.getPlugins() : [];
1240
+ const plugin = plugins.find(p => p.name === String(req.params.id));
1241
+ if (!plugin) {
1242
+ res.status(404).json({ error: 'Plugin not found' });
1243
+ return;
1244
+ }
1245
+ res.json({ data: plugin });
1246
+ });
1247
+
1248
+ router.post('/plugins/:id/enable', async (req: Request, res: Response) => {
1249
+ if (!deps.pluginManager) {
1250
+ res.status(503).json({ error: 'Plugin manager not available' });
1251
+ return;
1252
+ }
1253
+ const success = await deps.pluginManager.enable(String(req.params.id));
1254
+ if (!success) {
1255
+ res.status(404).json({ error: 'Plugin not found' });
1256
+ return;
1257
+ }
1258
+ void audit('plugin.enabled', { name: String(req.params.id) });
1259
+ res.json({ data: { enabled: true } });
1260
+ });
1261
+
1262
+ router.post('/plugins/:id/disable', async (req: Request, res: Response) => {
1263
+ if (!deps.pluginManager) {
1264
+ res.status(503).json({ error: 'Plugin manager not available' });
1265
+ return;
1266
+ }
1267
+ const success = await deps.pluginManager.disable(String(req.params.id));
1268
+ if (!success) {
1269
+ res.status(404).json({ error: 'Plugin not found' });
1270
+ return;
1271
+ }
1272
+ void audit('plugin.disabled', { name: String(req.params.id) });
1273
+ res.json({ data: { disabled: true } });
1274
+ });
1275
+
1276
+ router.delete('/plugins/:id', async (req: Request, res: Response) => {
1277
+ if (!deps.pluginManager) {
1278
+ res.status(503).json({ error: 'Plugin manager not available' });
1279
+ return;
1280
+ }
1281
+ const success = await deps.pluginManager.remove(String(req.params.id));
1282
+ if (!success) {
1283
+ res.status(404).json({ error: 'Plugin not found' });
1284
+ return;
1285
+ }
1286
+ void audit('plugin.removed', { name: String(req.params.id) });
1287
+ res.json({ data: { deleted: true } });
1288
+ });
1289
+
1290
+ router.get('/plugins/:id/config', (req: Request, res: Response) => {
1291
+ if (!deps.pluginManager) {
1292
+ res.status(503).json({ error: 'Plugin manager not available' });
1293
+ return;
1294
+ }
1295
+ const config = deps.pluginManager.getConfig(String(req.params.id));
1296
+ if (config === null) {
1297
+ res.status(404).json({ error: 'Plugin not found' });
1298
+ return;
1299
+ }
1300
+ res.json({ data: config });
1301
+ });
1302
+
1303
+ router.post('/plugins/:id/config', async (req: Request, res: Response) => {
1304
+ if (!deps.pluginManager) {
1305
+ res.status(503).json({ error: 'Plugin manager not available' });
1306
+ return;
1307
+ }
1308
+ const body = req.body as Record<string, unknown>;
1309
+ const success = await deps.pluginManager.setConfig(String(req.params.id), body);
1310
+ if (!success) {
1311
+ res.status(404).json({ error: 'Plugin not found' });
1312
+ return;
1313
+ }
1314
+ void audit('plugin.config_updated', { name: String(req.params.id) });
1315
+ res.json({ data: { updated: true } });
1316
+ });
1317
+
1318
+ router.get('/plugins/:id/permissions', (req: Request, res: Response) => {
1319
+ if (!deps.pluginManager) {
1320
+ res.status(503).json({ error: 'Plugin manager not available' });
1321
+ return;
1322
+ }
1323
+ const permissions = deps.pluginManager.getPermissions(String(req.params.id));
1324
+ if (permissions === null) {
1325
+ res.status(404).json({ error: 'Plugin not found' });
1326
+ return;
1327
+ }
1328
+ res.json({ data: permissions });
1329
+ });
1330
+
1331
+ router.post('/plugins/:id/permissions', async (req: Request, res: Response) => {
1332
+ if (!deps.pluginManager) {
1333
+ res.status(503).json({ error: 'Plugin manager not available' });
1334
+ return;
1335
+ }
1336
+ const { permissions } = req.body as { permissions?: string[] };
1337
+ if (!Array.isArray(permissions)) {
1338
+ res.status(400).json({ error: 'Permissions must be an array' });
1339
+ return;
1340
+ }
1341
+ const success = await deps.pluginManager.setPermissions(String(req.params.id), permissions);
1342
+ if (!success) {
1343
+ res.status(404).json({ error: 'Plugin not found' });
1344
+ return;
1345
+ }
1346
+ void audit('plugin.permissions_updated', { name: String(req.params.id), permissions });
1347
+ res.json({ data: { updated: true } });
1348
+ });
1349
+
1350
+ // Marketplace
1351
+ router.get('/marketplace', async (req: Request, res: Response) => {
1352
+ if (!deps.marketplace) {
1353
+ res.json({ data: [] });
1354
+ return;
1355
+ }
1356
+ const query = (req.query.q as string) || '';
1357
+ const results = await deps.marketplace.search(query);
1358
+ res.json({ data: results });
1359
+ });
1360
+
1361
+ router.get('/marketplace/:id', async (req: Request, res: Response) => {
1362
+ if (!deps.marketplace) {
1363
+ res.status(503).json({ error: 'Marketplace not available' });
1364
+ return;
1365
+ }
1366
+ const plugin = await deps.marketplace.getPlugin(String(req.params.id));
1367
+ if (!plugin) {
1368
+ res.status(404).json({ error: 'Plugin not found in marketplace' });
1369
+ return;
1370
+ }
1371
+ res.json({ data: plugin });
1372
+ });
1373
+
1374
+ router.post('/marketplace/:id/install', async (req: Request, res: Response) => {
1375
+ if (!deps.marketplace) {
1376
+ res.status(503).json({ error: 'Marketplace not available' });
1377
+ return;
1378
+ }
1379
+ const result = await deps.marketplace.install(String(req.params.id));
1380
+ if (!result.success) {
1381
+ res.status(500).json({ error: result.error || 'Install failed' });
1382
+ return;
1383
+ }
1384
+ void audit('marketplace.install', { name: String(req.params.id) });
1385
+ res.json({ data: result });
1386
+ });
1387
+
1388
+ // Memories
1389
+ router.get('/memories', async (req: Request, res: Response) => {
1390
+ const memories = deps.getMemories ? await deps.getMemories() : [];
1391
+ res.json({ data: memories });
1392
+ });
1393
+
1394
+ // Living memory routes
1395
+ router.get('/memories/living', async (req: Request, res: Response) => {
1396
+ if (!deps.memory) {
1397
+ res.status(503).json({ error: 'Living memory not available' });
1398
+ return;
1399
+ }
1400
+ const state = await deps.memory.getLivingState();
1401
+ res.json({ data: state });
1402
+ });
1403
+
1404
+ router.get('/memories/stats', async (req: Request, res: Response) => {
1405
+ if (!deps.memory) {
1406
+ res.status(503).json({ error: 'Living memory not available' });
1407
+ return;
1408
+ }
1409
+ const stats = await deps.memory.getStats();
1410
+ res.json({ data: stats });
1411
+ });
1412
+
1413
+ router.get('/memories/adaptations', async (req: Request, res: Response) => {
1414
+ if (!deps.memory) {
1415
+ res.status(503).json({ error: 'Living memory not available' });
1416
+ return;
1417
+ }
1418
+ const adaptations = await deps.memory.getAdaptations();
1419
+ res.json({ data: adaptations });
1420
+ });
1421
+
1422
+ router.delete('/memories/:id', async (req: Request, res: Response) => {
1423
+ if (!deps.memory) {
1424
+ res.status(503).json({ error: 'Living memory not available' });
1425
+ return;
1426
+ }
1427
+ const deleted = await deps.memory.deleteMemory(String(req.params.id));
1428
+ if (!deleted) {
1429
+ res.status(404).json({ error: 'Memory not found' });
1430
+ return;
1431
+ }
1432
+ res.json({ data: { deleted: true } });
1433
+ });
1434
+
1435
+ router.post('/memories/export', async (req: Request, res: Response) => {
1436
+ if (!deps.memory) {
1437
+ res.status(503).json({ error: 'Living memory not available' });
1438
+ return;
1439
+ }
1440
+ const exported = await deps.memory.exportAll();
1441
+ res.json({ data: exported });
1442
+ });
1443
+
1444
+ router.post('/memories/import', async (req: Request, res: Response) => {
1445
+ if (!deps.memory) {
1446
+ res.status(503).json({ error: 'Living memory not available' });
1447
+ return;
1448
+ }
1449
+ const body = req.body as { memories?: any[] };
1450
+ if (!body.memories || !Array.isArray(body.memories)) {
1451
+ res.status(400).json({ error: 'Request body must contain a "memories" array' });
1452
+ return;
1453
+ }
1454
+ const result = await deps.memory.importAll({ memories: body.memories });
1455
+ res.json({ data: result });
1456
+ });
1457
+
1458
+ // Orchestration
1459
+ router.get('/orchestration/status', (req: Request, res: Response) => {
1460
+ if (!deps.orchestration) {
1461
+ res.json({ data: { enabled: false, maxConcurrentAgents: 0, allowedPatterns: [] } });
1462
+ return;
1463
+ }
1464
+ const config = deps.orchestration.getConfig();
1465
+ res.json({ data: config });
1466
+ });
1467
+
1468
+ router.get('/orchestration/history', (req: Request, res: Response) => {
1469
+ if (!deps.orchestration) {
1470
+ res.json({ data: [] });
1471
+ return;
1472
+ }
1473
+ const limit = parseInt(req.query.limit as string) || 20;
1474
+ const history = deps.orchestration.getHistory(limit);
1475
+ res.json({ data: history });
1476
+ });
1477
+
1478
+ // Models
1479
+ router.get('/models', (req: Request, res: Response) => {
1480
+ if (!deps.models) {
1481
+ res.json({ providers: [], routing: {}, cost: {} });
1482
+ return;
1483
+ }
1484
+ const providers = deps.models.listProviders();
1485
+ const routing = deps.models.getRoutingConfig();
1486
+ const cost = deps.models.getCostSummary();
1487
+ res.json({ providers, routing, cost });
1488
+ });
1489
+
1490
+ router.get('/models/routing', (req: Request, res: Response) => {
1491
+ if (!deps.models) {
1492
+ res.json({ enabled: false });
1493
+ return;
1494
+ }
1495
+ const routing = deps.models.getRoutingConfig();
1496
+ res.json(routing);
1497
+ });
1498
+
1499
+ router.get('/models/cost', (req: Request, res: Response) => {
1500
+ if (!deps.models) {
1501
+ res.json({ today: 0, thisMonth: 0, isOverBudget: false, warningThresholdReached: false });
1502
+ return;
1503
+ }
1504
+ const summary = deps.models.getCostSummary();
1505
+ res.json(summary);
1506
+ });
1507
+
1508
+ // --- [P6] Desktop routes ---
1509
+ router.get('/desktop/status', (req: Request, res: Response) => {
1510
+ if (!deps.desktop) {
1511
+ res.status(503).json({ error: 'Desktop not available' });
1512
+ return;
1513
+ }
1514
+ const status = deps.desktop.getStatus();
1515
+ res.json({ data: status });
1516
+ });
1517
+
1518
+ router.post('/desktop/config', async (req: Request, res: Response) => {
1519
+ if (!deps.desktop) {
1520
+ res.status(503).json({ error: 'Desktop not available' });
1521
+ return;
1522
+ }
1523
+ const updates = req.body as Record<string, unknown>;
1524
+ if (!updates || typeof updates !== 'object') {
1525
+ res.status(400).json({ error: 'Request body must be an object' });
1526
+ return;
1527
+ }
1528
+ const result = await deps.desktop.updateConfig(updates);
1529
+ void audit('desktop.config_updated', { keys: Object.keys(updates) });
1530
+ res.json({ data: result });
1531
+ });
1532
+
1533
+ router.post('/desktop/notification', async (req: Request, res: Response) => {
1534
+ if (!deps.desktop) {
1535
+ res.status(503).json({ error: 'Desktop not available' });
1536
+ return;
1537
+ }
1538
+ const { title, body: notifBody } = req.body as { title?: string; body?: string };
1539
+ if (!title || !notifBody) {
1540
+ res.status(400).json({ error: 'title and body are required' });
1541
+ return;
1542
+ }
1543
+ await deps.desktop.sendNotification({ title, body: notifBody });
1544
+ res.json({ data: { sent: true } });
1545
+ });
1546
+
1547
+ router.get('/desktop/updates', async (req: Request, res: Response) => {
1548
+ if (!deps.desktop) {
1549
+ res.status(503).json({ error: 'Desktop not available' });
1550
+ return;
1551
+ }
1552
+ const updateInfo = await deps.desktop.checkUpdates();
1553
+ res.json({ data: updateInfo });
1554
+ });
1555
+
1556
+ // --- Cloud routes (Phase 7) ---
1557
+ // Signup and login are NOT behind requireAuth — they are public cloud endpoints
1558
+
1559
+ router.post('/cloud/signup', async (req: Request, res: Response) => {
1560
+ if (!deps.cloud) {
1561
+ res.status(503).json({ error: 'Cloud not available' });
1562
+ return;
1563
+ }
1564
+ const { email, name, password, plan } = req.body as CloudSignupRequest;
1565
+ if (!email || !name || !password) {
1566
+ res.status(400).json({ error: 'email, name, and password are required' });
1567
+ return;
1568
+ }
1569
+ try {
1570
+ const result = await deps.cloud.signup(email, name, password, plan);
1571
+ void audit('cloud.signup', { email });
1572
+ res.status(201).json({ data: result });
1573
+ } catch (error) {
1574
+ const msg = error instanceof Error ? error.message : 'Signup failed';
1575
+ res.status(409).json({ error: msg });
1576
+ }
1577
+ });
1578
+
1579
+ router.post('/cloud/login', async (req: Request, res: Response) => {
1580
+ if (!deps.cloud) {
1581
+ res.status(503).json({ error: 'Cloud not available' });
1582
+ return;
1583
+ }
1584
+ const { email, password } = req.body as CloudLoginRequest;
1585
+ if (!email || !password) {
1586
+ res.status(400).json({ error: 'email and password are required' });
1587
+ return;
1588
+ }
1589
+ const result = await deps.cloud.login(email, password);
1590
+ if (!result) {
1591
+ res.status(401).json({ error: 'Invalid credentials' });
1592
+ return;
1593
+ }
1594
+ void audit('cloud.login', { email });
1595
+ res.json({ data: result });
1596
+ });
1597
+
1598
+ router.get('/cloud/tenant', async (req: Request, res: Response) => {
1599
+ if (!deps.cloud) {
1600
+ res.status(503).json({ error: 'Cloud not available' });
1601
+ return;
1602
+ }
1603
+ const tenantId = req.headers['x-tenant-id'] as string;
1604
+ if (!tenantId) {
1605
+ res.status(400).json({ error: 'x-tenant-id header required' });
1606
+ return;
1607
+ }
1608
+ const tenant = await deps.cloud.getTenant(tenantId);
1609
+ if (!tenant) {
1610
+ res.status(404).json({ error: 'Tenant not found' });
1611
+ return;
1612
+ }
1613
+ res.json({ data: tenant });
1614
+ });
1615
+
1616
+ router.post('/cloud/tenant/plan', async (req: Request, res: Response) => {
1617
+ if (!deps.cloud) {
1618
+ res.status(503).json({ error: 'Cloud not available' });
1619
+ return;
1620
+ }
1621
+ const tenantId = req.headers['x-tenant-id'] as string;
1622
+ if (!tenantId) {
1623
+ res.status(400).json({ error: 'x-tenant-id header required' });
1624
+ return;
1625
+ }
1626
+ const { plan } = req.body as CloudPlanChangeRequest;
1627
+ if (!plan) {
1628
+ res.status(400).json({ error: 'plan is required' });
1629
+ return;
1630
+ }
1631
+ const result = await deps.cloud.changePlan(tenantId, plan);
1632
+ void audit('cloud.plan_change', { tenantId, plan });
1633
+ res.json({ data: result });
1634
+ });
1635
+
1636
+ router.get('/cloud/tenant/usage', async (req: Request, res: Response) => {
1637
+ if (!deps.cloud) {
1638
+ res.status(503).json({ error: 'Cloud not available' });
1639
+ return;
1640
+ }
1641
+ const tenantId = req.headers['x-tenant-id'] as string;
1642
+ if (!tenantId) {
1643
+ res.status(400).json({ error: 'x-tenant-id header required' });
1644
+ return;
1645
+ }
1646
+ const usage = await deps.cloud.getUsage(tenantId);
1647
+ res.json({ data: usage });
1648
+ });
1649
+
1650
+ router.get('/cloud/tenant/billing', async (req: Request, res: Response) => {
1651
+ if (!deps.cloud) {
1652
+ res.status(503).json({ error: 'Cloud not available' });
1653
+ return;
1654
+ }
1655
+ const tenantId = req.headers['x-tenant-id'] as string;
1656
+ if (!tenantId) {
1657
+ res.status(400).json({ error: 'x-tenant-id header required' });
1658
+ return;
1659
+ }
1660
+ const billing = await deps.cloud.getBilling(tenantId);
1661
+ res.json({ data: billing });
1662
+ });
1663
+
1664
+ router.post('/cloud/tenant/billing/payment-method', async (req: Request, res: Response) => {
1665
+ if (!deps.cloud) {
1666
+ res.status(503).json({ error: 'Cloud not available' });
1667
+ return;
1668
+ }
1669
+ const tenantId = req.headers['x-tenant-id'] as string;
1670
+ if (!tenantId) {
1671
+ res.status(400).json({ error: 'x-tenant-id header required' });
1672
+ return;
1673
+ }
1674
+ const { token } = req.body as CloudPaymentMethodRequest;
1675
+ if (!token) {
1676
+ res.status(400).json({ error: 'token is required' });
1677
+ return;
1678
+ }
1679
+ const result = await deps.cloud.addPaymentMethod(tenantId, token);
1680
+ void audit('cloud.payment_method', { tenantId });
1681
+ res.json({ data: result });
1682
+ });
1683
+
1684
+ router.post('/cloud/tenant/export', async (req: Request, res: Response) => {
1685
+ if (!deps.cloud) {
1686
+ res.status(503).json({ error: 'Cloud not available' });
1687
+ return;
1688
+ }
1689
+ const tenantId = req.headers['x-tenant-id'] as string;
1690
+ if (!tenantId) {
1691
+ res.status(400).json({ error: 'x-tenant-id header required' });
1692
+ return;
1693
+ }
1694
+ const result = await deps.cloud.exportData(tenantId);
1695
+ void audit('cloud.export', { tenantId });
1696
+ res.json({ data: result });
1697
+ });
1698
+
1699
+ router.delete('/cloud/tenant', async (req: Request, res: Response) => {
1700
+ if (!deps.cloud) {
1701
+ res.status(503).json({ error: 'Cloud not available' });
1702
+ return;
1703
+ }
1704
+ const tenantId = req.headers['x-tenant-id'] as string;
1705
+ if (!tenantId) {
1706
+ res.status(400).json({ error: 'x-tenant-id header required' });
1707
+ return;
1708
+ }
1709
+ const result = await deps.cloud.deleteTenant(tenantId);
1710
+ void audit('cloud.delete_tenant', { tenantId });
1711
+ res.json({ data: result });
1712
+ });
1713
+
1714
+ // --- [P13] Connector routes ---
1715
+ router.get('/connectors', (req: Request, res: Response) => {
1716
+ if (!deps.connectors) {
1717
+ res.json({ data: [] });
1718
+ return;
1719
+ }
1720
+ const connectors = deps.connectors.list();
1721
+ res.json({ data: connectors });
1722
+ });
1723
+
1724
+ router.get('/connectors/:id', (req: Request, res: Response) => {
1725
+ if (!deps.connectors) {
1726
+ res.status(503).json({ error: 'Connectors not available' });
1727
+ return;
1728
+ }
1729
+ const connector = deps.connectors.get(String(req.params.id));
1730
+ if (!connector) {
1731
+ res.status(404).json({ error: 'Connector not found' });
1732
+ return;
1733
+ }
1734
+ res.json({ data: connector });
1735
+ });
1736
+
1737
+ router.post('/connectors/:id', async (req: Request, res: Response) => {
1738
+ if (!deps.connectors) {
1739
+ res.status(503).json({ error: 'Connectors not available' });
1740
+ return;
1741
+ }
1742
+ const connectorId = String(req.params.id);
1743
+ const { credentials, label } = req.body as { credentials?: Record<string, string>; label?: string };
1744
+ if (!credentials) {
1745
+ res.status(400).json({ error: 'credentials are required' });
1746
+ return;
1747
+ }
1748
+ const result = await deps.connectors.connect(connectorId, credentials, label);
1749
+ if (!result) {
1750
+ res.status(404).json({ error: 'Connector not found' });
1751
+ return;
1752
+ }
1753
+ void audit('connector.connected', { connectorId });
1754
+ res.json({ data: result });
1755
+ });
1756
+
1757
+ router.delete('/connectors/:id', async (req: Request, res: Response) => {
1758
+ if (!deps.connectors) {
1759
+ res.status(503).json({ error: 'Connectors not available' });
1760
+ return;
1761
+ }
1762
+ const removed = await deps.connectors.disconnect(String(req.params.id));
1763
+ if (!removed) {
1764
+ res.status(404).json({ error: 'Connector not found' });
1765
+ return;
1766
+ }
1767
+ void audit('connector.disconnected', { connectorId: String(req.params.id) });
1768
+ res.json({ data: { deleted: true } });
1769
+ });
1770
+
1771
+ router.get('/connectors/:id/actions', (req: Request, res: Response) => {
1772
+ if (!deps.connectors) {
1773
+ res.status(503).json({ error: 'Connectors not available' });
1774
+ return;
1775
+ }
1776
+ const actions = deps.connectors.getActions(String(req.params.id));
1777
+ res.json({ data: actions });
1778
+ });
1779
+
1780
+ router.post('/connectors/:id/actions/:actionId', async (req: Request, res: Response) => {
1781
+ if (!deps.connectors) {
1782
+ res.status(503).json({ error: 'Connectors not available' });
1783
+ return;
1784
+ }
1785
+ const connectorId = String(req.params.id);
1786
+ const actionId = String(req.params.actionId);
1787
+ const params = req.body as Record<string, unknown>;
1788
+ const result = await deps.connectors.executeAction(connectorId, actionId, params);
1789
+ if (!result.success) {
1790
+ res.status(400).json({ error: result.error });
1791
+ return;
1792
+ }
1793
+ res.json({ data: result });
1794
+ });
1795
+
1796
+ // --- OAuth2 connector routes ---
1797
+
1798
+ // In-memory CSRF state storage (keyed by state token, value is connectorId)
1799
+ const oauthStates = new Map<string, { connectorId: string; createdAt: number }>();
1800
+
1801
+ // Clean up expired states (older than 10 minutes)
1802
+ function cleanupOAuthStates(): void {
1803
+ const cutoff = Date.now() - 10 * 60_000;
1804
+ for (const [key, value] of oauthStates) {
1805
+ if (value.createdAt < cutoff) oauthStates.delete(key);
1806
+ }
1807
+ }
1808
+
1809
+ // Store OAuth client credentials in vault
1810
+ router.post('/connectors/:connectorId/credentials', async (req: Request, res: Response) => {
1811
+ const connectorId = String(req.params.connectorId);
1812
+ const { clientId, clientSecret } = req.body as { clientId?: string; clientSecret?: string };
1813
+
1814
+ if (!clientId || !clientSecret) {
1815
+ res.status(400).json({ error: 'clientId and clientSecret are required' });
1816
+ return;
1817
+ }
1818
+
1819
+ try {
1820
+ await deps.vault.add(
1821
+ `connectors.${connectorId}.credentials`,
1822
+ JSON.stringify({ clientId, clientSecret }),
1823
+ );
1824
+ void audit('connector.credentials_stored', { connectorId });
1825
+ res.json({ success: true });
1826
+ } catch (error) {
1827
+ const msg = error instanceof Error ? error.message : 'Failed to store credentials';
1828
+ res.status(500).json({ error: msg });
1829
+ }
1830
+ });
1831
+
1832
+ // Start OAuth2 consent flow — redirects user to provider
1833
+ router.get('/connectors/:connectorId/auth', async (req: Request, res: Response) => {
1834
+ const connectorId = String(req.params.connectorId);
1835
+
1836
+ // Load client credentials from vault
1837
+ const credsJson = deps.vault.get(`connectors.${connectorId}.credentials`);
1838
+ if (!credsJson) {
1839
+ res.status(400).json({ error: 'No client credentials configured. Store credentials first.' });
1840
+ return;
1841
+ }
1842
+
1843
+ let creds: { clientId: string; clientSecret: string };
1844
+ try {
1845
+ creds = JSON.parse(credsJson) as { clientId: string; clientSecret: string };
1846
+ } catch {
1847
+ res.status(500).json({ error: 'Invalid stored credentials' });
1848
+ return;
1849
+ }
1850
+
1851
+ // Look up connector to get scopes
1852
+ const connector = deps.connectors?.get(connectorId);
1853
+ const scopes = connector?.auth?.oauth2?.scopes ?? [];
1854
+
1855
+ // Generate CSRF state token
1856
+ cleanupOAuthStates();
1857
+ const state = crypto.randomUUID();
1858
+ oauthStates.set(state, { connectorId, createdAt: Date.now() });
1859
+
1860
+ // Build callback URL from request
1861
+ const protocol = req.secure ? 'https' : 'http';
1862
+ const host = req.headers.host ?? 'localhost';
1863
+ const callbackUrl = `${protocol}://${host}/api/v1/dashboard/connectors/${connectorId}/callback`;
1864
+
1865
+ // Build Google OAuth consent URL
1866
+ const params = new URLSearchParams({
1867
+ client_id: creds.clientId,
1868
+ redirect_uri: callbackUrl,
1869
+ response_type: 'code',
1870
+ scope: scopes.join(' '),
1871
+ access_type: 'offline',
1872
+ prompt: 'consent',
1873
+ state,
1874
+ });
1875
+
1876
+ const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
1877
+ void audit('connector.oauth_started', { connectorId });
1878
+ res.redirect(authUrl);
1879
+ });
1880
+
1881
+ // OAuth2 callback — exchange code for tokens
1882
+ router.get('/connectors/:connectorId/callback', async (req: Request, res: Response) => {
1883
+ const connectorId = String(req.params.connectorId);
1884
+ const { code, state, error: oauthError } = req.query as {
1885
+ code?: string;
1886
+ state?: string;
1887
+ error?: string;
1888
+ };
1889
+
1890
+ if (oauthError) {
1891
+ logger.warn('OAuth error from provider', { connectorId, error: new Error(String(oauthError)) });
1892
+ res.redirect(`/dashboard/settings/connections?error=${encodeURIComponent(String(oauthError))}`);
1893
+ return;
1894
+ }
1895
+
1896
+ if (!code || !state) {
1897
+ res.status(400).json({ error: 'Missing code or state parameter' });
1898
+ return;
1899
+ }
1900
+
1901
+ // Validate CSRF state
1902
+ const storedState = oauthStates.get(state);
1903
+ if (!storedState || storedState.connectorId !== connectorId) {
1904
+ res.status(403).json({ error: 'Invalid or expired state token' });
1905
+ return;
1906
+ }
1907
+ oauthStates.delete(state);
1908
+
1909
+ // Load client credentials
1910
+ const credsJson = deps.vault.get(`connectors.${connectorId}.credentials`);
1911
+ if (!credsJson) {
1912
+ res.status(500).json({ error: 'Client credentials not found' });
1913
+ return;
1914
+ }
1915
+
1916
+ let creds: { clientId: string; clientSecret: string };
1917
+ try {
1918
+ creds = JSON.parse(credsJson) as { clientId: string; clientSecret: string };
1919
+ } catch {
1920
+ res.status(500).json({ error: 'Invalid stored credentials' });
1921
+ return;
1922
+ }
1923
+
1924
+ const protocol = req.secure ? 'https' : 'http';
1925
+ const host = req.headers.host ?? 'localhost';
1926
+ const callbackUrl = `${protocol}://${host}/api/v1/dashboard/connectors/${connectorId}/callback`;
1927
+
1928
+ // Exchange authorization code for tokens
1929
+ try {
1930
+ const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
1931
+ method: 'POST',
1932
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1933
+ body: new URLSearchParams({
1934
+ code,
1935
+ client_id: creds.clientId,
1936
+ client_secret: creds.clientSecret,
1937
+ redirect_uri: callbackUrl,
1938
+ grant_type: 'authorization_code',
1939
+ }).toString(),
1940
+ });
1941
+
1942
+ if (!tokenResponse.ok) {
1943
+ const errBody = await tokenResponse.text();
1944
+ logger.error(`Token exchange failed: ${tokenResponse.status}`);
1945
+ res.redirect(`/dashboard/settings/connections?error=${encodeURIComponent('Token exchange failed')}`);
1946
+ return;
1947
+ }
1948
+
1949
+ const tokens = await tokenResponse.json() as {
1950
+ access_token: string;
1951
+ refresh_token?: string;
1952
+ expires_in?: number;
1953
+ token_type?: string;
1954
+ scope?: string;
1955
+ };
1956
+
1957
+ // Store tokens in vault
1958
+ const storedTokens = {
1959
+ accessToken: tokens.access_token,
1960
+ refreshToken: tokens.refresh_token,
1961
+ expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
1962
+ tokenType: tokens.token_type ?? 'Bearer',
1963
+ };
1964
+
1965
+ await deps.vault.add(
1966
+ `connectors.${connectorId}.tokens`,
1967
+ JSON.stringify(storedTokens),
1968
+ );
1969
+
1970
+ void audit('connector.oauth_completed', { connectorId });
1971
+ res.redirect(`/dashboard/settings/connections?connected=${connectorId}`);
1972
+ } catch (error) {
1973
+ const msg = error instanceof Error ? error.message : 'Token exchange failed';
1974
+ logger.error(`OAuth callback error: ${msg}`);
1975
+ res.redirect(`/dashboard/settings/connections?error=${encodeURIComponent(msg)}`);
1976
+ }
1977
+ });
1978
+
1979
+ // Connection status
1980
+ router.get('/connectors/:connectorId/status', (req: Request, res: Response) => {
1981
+ const connectorId = String(req.params.connectorId);
1982
+
1983
+ const hasCredentials = deps.vault.has(`connectors.${connectorId}.credentials`);
1984
+ const tokensJson = deps.vault.get(`connectors.${connectorId}.tokens`);
1985
+ let connected = false;
1986
+ let expiresAt: number | undefined;
1987
+
1988
+ if (tokensJson) {
1989
+ try {
1990
+ const tokens = JSON.parse(tokensJson) as { accessToken?: string; expiresAt?: number };
1991
+ connected = !!tokens.accessToken;
1992
+ expiresAt = tokens.expiresAt;
1993
+ } catch {
1994
+ // Invalid tokens
1995
+ }
1996
+ }
1997
+
1998
+ res.json({
1999
+ data: {
2000
+ connectorId,
2001
+ hasCredentials,
2002
+ connected,
2003
+ expiresAt,
2004
+ },
2005
+ });
2006
+ });
2007
+
2008
+ // Disconnect — revoke and delete tokens
2009
+ router.post('/connectors/:connectorId/disconnect', async (req: Request, res: Response) => {
2010
+ const connectorId = String(req.params.connectorId);
2011
+
2012
+ // Attempt to revoke the access token with Google
2013
+ const tokensJson = deps.vault.get(`connectors.${connectorId}.tokens`);
2014
+ if (tokensJson) {
2015
+ try {
2016
+ const tokens = JSON.parse(tokensJson) as { accessToken?: string };
2017
+ if (tokens.accessToken) {
2018
+ await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(tokens.accessToken)}`, {
2019
+ method: 'POST',
2020
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
2021
+ }).catch(() => {
2022
+ // Best-effort revocation
2023
+ });
2024
+ }
2025
+ } catch {
2026
+ // Ignore parse errors
2027
+ }
2028
+ }
2029
+
2030
+ // Clear tokens from vault (store empty string to overwrite)
2031
+ try {
2032
+ await deps.vault.add(`connectors.${connectorId}.tokens`, '');
2033
+ } catch {
2034
+ // Best-effort cleanup
2035
+ }
2036
+
2037
+ void audit('connector.oauth_disconnected', { connectorId });
2038
+ res.json({ success: true });
2039
+ });
2040
+
2041
+ // --- [P14] Team / Social routes ---
2042
+ router.get('/team', async (req: Request, res: Response) => {
2043
+ if (!deps.team) {
2044
+ res.json({ data: [] });
2045
+ return;
2046
+ }
2047
+ const users = await deps.team.listUsers();
2048
+ res.json({ data: users });
2049
+ });
2050
+
2051
+ router.post('/team', async (req: Request, res: Response) => {
2052
+ if (!deps.team) {
2053
+ res.status(503).json({ error: 'Team management not available' });
2054
+ return;
2055
+ }
2056
+ const { name, role, channels } = req.body as { name?: string; role?: string; channels?: any[] };
2057
+ if (!name) {
2058
+ res.status(400).json({ error: 'name is required' });
2059
+ return;
2060
+ }
2061
+ const user = await deps.team.createUser(name, role ?? 'member', channels);
2062
+ void audit('team.user_created', { name, role });
2063
+ res.status(201).json({ data: user });
2064
+ });
2065
+
2066
+ router.delete('/team/:id', async (req: Request, res: Response) => {
2067
+ if (!deps.team) {
2068
+ res.status(503).json({ error: 'Team management not available' });
2069
+ return;
2070
+ }
2071
+ const deleted = await deps.team.deleteUser(String(req.params.id));
2072
+ if (!deleted) {
2073
+ res.status(404).json({ error: 'User not found' });
2074
+ return;
2075
+ }
2076
+ void audit('team.user_deleted', { id: String(req.params.id) });
2077
+ res.json({ data: { deleted: true } });
2078
+ });
2079
+
2080
+ // --- [P14] Workflow routes ---
2081
+ router.get('/workflows', async (req: Request, res: Response) => {
2082
+ if (!deps.workflows) {
2083
+ res.json({ data: [] });
2084
+ return;
2085
+ }
2086
+ const all = req.query.all === 'true';
2087
+ const workflows = all ? await deps.workflows.listAll() : await deps.workflows.listActive();
2088
+ res.json({ data: workflows });
2089
+ });
2090
+
2091
+ router.post('/workflows', async (req: Request, res: Response) => {
2092
+ if (!deps.workflows) {
2093
+ res.status(503).json({ error: 'Workflows not available' });
2094
+ return;
2095
+ }
2096
+ const workflow = await deps.workflows.createWorkflow(req.body);
2097
+ void audit('workflow.created', { id: workflow.id });
2098
+ res.status(201).json({ data: workflow });
2099
+ });
2100
+
2101
+ router.get('/workflows/:id/status', async (req: Request, res: Response) => {
2102
+ if (!deps.workflows) {
2103
+ res.status(503).json({ error: 'Workflows not available' });
2104
+ return;
2105
+ }
2106
+ const status = await deps.workflows.getStatus(String(req.params.id));
2107
+ if (!status) {
2108
+ res.status(404).json({ error: 'Workflow not found' });
2109
+ return;
2110
+ }
2111
+ res.json({ data: status });
2112
+ });
2113
+
2114
+ router.post('/workflows/:id/cancel', async (req: Request, res: Response) => {
2115
+ if (!deps.workflows) {
2116
+ res.status(503).json({ error: 'Workflows not available' });
2117
+ return;
2118
+ }
2119
+ const cancelled = await deps.workflows.cancelWorkflow(String(req.params.id));
2120
+ if (!cancelled) {
2121
+ res.status(400).json({ error: 'Cannot cancel workflow' });
2122
+ return;
2123
+ }
2124
+ void audit('workflow.cancelled', { id: String(req.params.id) });
2125
+ res.json({ data: { cancelled: true } });
2126
+ });
2127
+
2128
+ router.get('/workflows/approvals', async (req: Request, res: Response) => {
2129
+ if (!deps.workflows) {
2130
+ res.json({ data: [] });
2131
+ return;
2132
+ }
2133
+ const userId = req.query.userId as string | undefined;
2134
+ const approvals = await deps.workflows.getPendingApprovals(userId);
2135
+ res.json({ data: approvals });
2136
+ });
2137
+
2138
+ router.post('/workflows/approvals/:id/approve', async (req: Request, res: Response) => {
2139
+ if (!deps.workflows) {
2140
+ res.status(503).json({ error: 'Workflows not available' });
2141
+ return;
2142
+ }
2143
+ const { userId, reason } = req.body as { userId?: string; reason?: string };
2144
+ const result = await deps.workflows.approve(String(req.params.id), userId ?? 'dashboard', reason);
2145
+ if (!result) {
2146
+ res.status(400).json({ error: 'Cannot approve' });
2147
+ return;
2148
+ }
2149
+ void audit('workflow.approved', { id: String(req.params.id) });
2150
+ res.json({ data: result });
2151
+ });
2152
+
2153
+ router.post('/workflows/approvals/:id/reject', async (req: Request, res: Response) => {
2154
+ if (!deps.workflows) {
2155
+ res.status(503).json({ error: 'Workflows not available' });
2156
+ return;
2157
+ }
2158
+ const { userId, reason } = req.body as { userId?: string; reason?: string };
2159
+ const result = await deps.workflows.reject(String(req.params.id), userId ?? 'dashboard', reason);
2160
+ if (!result) {
2161
+ res.status(400).json({ error: 'Cannot reject' });
2162
+ return;
2163
+ }
2164
+ void audit('workflow.rejected', { id: String(req.params.id) });
2165
+ res.json({ data: result });
2166
+ });
2167
+
2168
+ // --- [P14] Agent Protocol routes ---
2169
+ router.get('/agent-protocol/identity', (req: Request, res: Response) => {
2170
+ if (!deps.agentProtocol) {
2171
+ res.status(503).json({ error: 'Agent protocol not available' });
2172
+ return;
2173
+ }
2174
+ const identity = deps.agentProtocol.getIdentity();
2175
+ res.json({ data: identity });
2176
+ });
2177
+
2178
+ router.get('/agent-protocol/inbox', (req: Request, res: Response) => {
2179
+ if (!deps.agentProtocol) {
2180
+ res.json({ data: [] });
2181
+ return;
2182
+ }
2183
+ const limit = parseInt(req.query.limit as string) || 50;
2184
+ const messages = deps.agentProtocol.getInbox(limit);
2185
+ res.json({ data: messages });
2186
+ });
2187
+
2188
+ router.get('/agent-protocol/directory', async (req: Request, res: Response) => {
2189
+ if (!deps.agentProtocol) {
2190
+ res.json({ data: [] });
2191
+ return;
2192
+ }
2193
+ const entries = await deps.agentProtocol.getDirectory();
2194
+ res.json({ data: entries });
2195
+ });
2196
+
2197
+ router.post('/agent-protocol/discover', async (req: Request, res: Response) => {
2198
+ if (!deps.agentProtocol) {
2199
+ res.json({ data: [] });
2200
+ return;
2201
+ }
2202
+ const { query } = req.body as { query?: string };
2203
+ if (!query) {
2204
+ res.status(400).json({ error: 'query is required' });
2205
+ return;
2206
+ }
2207
+ const results = await deps.agentProtocol.discover(query);
2208
+ res.json({ data: results });
2209
+ });
2210
+
2211
+ // --- [P15] Screen routes ---
2212
+ router.get('/screen/capture', async (req: Request, res: Response) => {
2213
+ if (!deps.screen) {
2214
+ res.status(503).json({ error: 'Screen capture not available' });
2215
+ return;
2216
+ }
2217
+ const capture = await deps.screen.capture();
2218
+ res.json({ data: capture });
2219
+ });
2220
+
2221
+ router.post('/screen/analyze', async (req: Request, res: Response) => {
2222
+ if (!deps.screen) {
2223
+ res.status(503).json({ error: 'Screen analysis not available' });
2224
+ return;
2225
+ }
2226
+ const { question } = req.body as { question?: string };
2227
+ const analysis = await deps.screen.analyze(question);
2228
+ res.json({ data: { analysis } });
2229
+ });
2230
+
2231
+ // --- [P15] Ambient routes ---
2232
+ router.get('/ambient/patterns', (req: Request, res: Response) => {
2233
+ if (!deps.ambient) {
2234
+ res.json({ data: [] });
2235
+ return;
2236
+ }
2237
+ const patterns = deps.ambient.getPatterns();
2238
+ res.json({ data: patterns });
2239
+ });
2240
+
2241
+ router.get('/ambient/notifications', (req: Request, res: Response) => {
2242
+ if (!deps.ambient) {
2243
+ res.json({ data: [] });
2244
+ return;
2245
+ }
2246
+ const notifications = deps.ambient.getNotifications();
2247
+ res.json({ data: notifications });
2248
+ });
2249
+
2250
+ router.post('/ambient/notifications/:id/dismiss', (req: Request, res: Response) => {
2251
+ if (!deps.ambient) {
2252
+ res.status(503).json({ error: 'Ambient not available' });
2253
+ return;
2254
+ }
2255
+ const dismissed = deps.ambient.dismissNotification(String(req.params.id));
2256
+ if (!dismissed) {
2257
+ res.status(404).json({ error: 'Notification not found' });
2258
+ return;
2259
+ }
2260
+ res.json({ data: { dismissed: true } });
2261
+ });
2262
+
2263
+ router.get('/ambient/briefing', (req: Request, res: Response) => {
2264
+ if (!deps.ambient) {
2265
+ res.status(503).json({ error: 'Ambient not available' });
2266
+ return;
2267
+ }
2268
+ const time = (req.query.time as string) || 'morning';
2269
+ const briefing = deps.ambient.getBriefing(time);
2270
+ res.json({ data: briefing });
2271
+ });
2272
+
2273
+ router.get('/ambient/anticipations', (req: Request, res: Response) => {
2274
+ if (!deps.ambient) {
2275
+ res.json({ data: [] });
2276
+ return;
2277
+ }
2278
+ const anticipations = deps.ambient.getAnticipations();
2279
+ res.json({ data: anticipations });
2280
+ });
2281
+
2282
+ // Ambient config (persisted to vault)
2283
+ router.get('/ambient/config', (_req: Request, res: Response) => {
2284
+ const raw = deps.vault.get('ambient.config');
2285
+ if (!raw) {
2286
+ res.json({ data: {} });
2287
+ return;
2288
+ }
2289
+ try {
2290
+ res.json({ data: JSON.parse(raw) });
2291
+ } catch {
2292
+ res.json({ data: {} });
2293
+ }
2294
+ });
2295
+
2296
+ router.post('/ambient/config', async (req: Request, res: Response) => {
2297
+ try {
2298
+ const config = req.body as Record<string, unknown>;
2299
+ await deps.vault.add('ambient.config', JSON.stringify(config));
2300
+ res.json({ success: true });
2301
+ } catch {
2302
+ res.status(500).json({ error: 'Failed to save ambient config' });
2303
+ }
2304
+ });
2305
+
2306
+ // --- Appearance routes ---
2307
+ const VALID_THEMES = ['nebula', 'monolith', 'signal', 'polar', 'neon', 'terra'] as const;
2308
+
2309
+ router.get('/appearance', (_req: Request, res: Response) => {
2310
+ const raw = deps.vault.get('appearance.config');
2311
+ if (!raw) {
2312
+ res.json({ data: { theme: 'nebula' } });
2313
+ return;
2314
+ }
2315
+ try {
2316
+ res.json({ data: JSON.parse(raw) });
2317
+ } catch {
2318
+ res.json({ data: { theme: 'nebula' } });
2319
+ }
2320
+ });
2321
+
2322
+ router.post('/appearance', async (req: Request, res: Response) => {
2323
+ try {
2324
+ const { theme } = req.body as { theme: string };
2325
+ if (!theme || !VALID_THEMES.includes(theme as (typeof VALID_THEMES)[number])) {
2326
+ res.status(400).json({ error: `Invalid theme. Valid themes: ${VALID_THEMES.join(', ')}` });
2327
+ return;
2328
+ }
2329
+ await deps.vault.add('appearance.config', JSON.stringify({ theme }));
2330
+ res.json({ success: true });
2331
+ } catch {
2332
+ res.status(500).json({ error: 'Failed to save appearance config' });
2333
+ }
2334
+ });
2335
+
2336
+ // --- Notification routes ---
2337
+ router.get('/notifications', (_req: Request, res: Response) => {
2338
+ const raw = deps.vault.get('notifications.recent');
2339
+ if (!raw) {
2340
+ res.json({ data: [] });
2341
+ return;
2342
+ }
2343
+ try {
2344
+ res.json({ data: JSON.parse(raw) });
2345
+ } catch {
2346
+ res.json({ data: [] });
2347
+ }
2348
+ });
2349
+
2350
+ router.post('/notifications/:id/dismiss', async (req: Request, res: Response) => {
2351
+ const id = String(req.params.id);
2352
+ const raw = deps.vault.get('notifications.recent');
2353
+ if (!raw) {
2354
+ res.status(404).json({ error: 'Notification not found' });
2355
+ return;
2356
+ }
2357
+ try {
2358
+ const notifications = JSON.parse(raw) as Array<{ id: string }>;
2359
+ const filtered = notifications.filter(n => n.id !== id);
2360
+ if (filtered.length === notifications.length) {
2361
+ res.status(404).json({ error: 'Notification not found' });
2362
+ return;
2363
+ }
2364
+ await deps.vault.add('notifications.recent', JSON.stringify(filtered));
2365
+ res.json({ data: { dismissed: true } });
2366
+ } catch {
2367
+ res.status(500).json({ error: 'Failed to dismiss notification' });
2368
+ }
2369
+ });
2370
+
2371
+ router.get('/notifications/preferences', (_req: Request, res: Response) => {
2372
+ const raw = deps.vault.get('notifications.preferences');
2373
+ if (!raw) {
2374
+ res.json({ data: {} });
2375
+ return;
2376
+ }
2377
+ try {
2378
+ res.json({ data: JSON.parse(raw) });
2379
+ } catch {
2380
+ res.json({ data: {} });
2381
+ }
2382
+ });
2383
+
2384
+ router.post('/notifications/preferences', async (req: Request, res: Response) => {
2385
+ try {
2386
+ const prefs = req.body as Record<string, unknown>;
2387
+ await deps.vault.add('notifications.preferences', JSON.stringify(prefs));
2388
+ void audit('settings.notification_preferences', {});
2389
+ res.json({ success: true });
2390
+ } catch {
2391
+ res.status(500).json({ error: 'Failed to save notification preferences' });
2392
+ }
2393
+ });
2394
+
2395
+ // --- [P15] Conversation routes ---
2396
+ router.get('/conversation/state', (req: Request, res: Response) => {
2397
+ if (!deps.conversation) {
2398
+ res.json({ data: { state: 'unavailable' } });
2399
+ return;
2400
+ }
2401
+ res.json({ data: { state: deps.conversation.getState(), turnCount: deps.conversation.getTurnCount() } });
2402
+ });
2403
+
2404
+ router.post('/conversation/start', (req: Request, res: Response) => {
2405
+ if (!deps.conversation) {
2406
+ res.status(503).json({ error: 'Conversation engine not available' });
2407
+ return;
2408
+ }
2409
+ deps.conversation.start();
2410
+ res.json({ data: { state: deps.conversation.getState() } });
2411
+ });
2412
+
2413
+ router.post('/conversation/stop', (req: Request, res: Response) => {
2414
+ if (!deps.conversation) {
2415
+ res.status(503).json({ error: 'Conversation engine not available' });
2416
+ return;
2417
+ }
2418
+ deps.conversation.stop();
2419
+ res.json({ data: { state: 'idle' } });
2420
+ });
2421
+
2422
+ // --- Trust / Autonomy routes (Phase 12) ---
2423
+ // NOTE: specific routes must come before parameterized :domain routes
2424
+ router.get('/trust', (req: Request, res: Response) => {
2425
+ if (!deps.trust) {
2426
+ res.status(503).json({ error: 'Trust engine not available' });
2427
+ return;
2428
+ }
2429
+ const levels = deps.trust.getLevels();
2430
+ res.json({ data: levels });
2431
+ });
2432
+
2433
+ router.get('/trust/audit', (req: Request, res: Response) => {
2434
+ if (!deps.trust) {
2435
+ res.status(503).json({ error: 'Trust engine not available' });
2436
+ return;
2437
+ }
2438
+ const limit = parseInt(req.query.limit as string) || 50;
2439
+ const entries = deps.trust.getAuditEntries(limit);
2440
+ res.json({ data: entries });
2441
+ });
2442
+
2443
+ router.post('/trust/audit/:id/rollback', async (req: Request, res: Response) => {
2444
+ if (!deps.trust) {
2445
+ res.status(503).json({ error: 'Trust engine not available' });
2446
+ return;
2447
+ }
2448
+ const id = String(req.params.id);
2449
+ const result = await deps.trust.rollback(id);
2450
+ if (!result.success) {
2451
+ res.status(400).json({ error: result.error });
2452
+ return;
2453
+ }
2454
+ void audit('trust.action_rolled_back', { auditId: id });
2455
+ res.json({ data: { rolledBack: true } });
2456
+ });
2457
+
2458
+ router.get('/trust/promotions', (req: Request, res: Response) => {
2459
+ if (!deps.trust) {
2460
+ res.status(503).json({ error: 'Trust engine not available' });
2461
+ return;
2462
+ }
2463
+ const promotions = deps.trust.getPromotions();
2464
+ res.json({ data: promotions });
2465
+ });
2466
+
2467
+ router.get('/trust/:domain', (req: Request, res: Response) => {
2468
+ if (!deps.trust) {
2469
+ res.status(503).json({ error: 'Trust engine not available' });
2470
+ return;
2471
+ }
2472
+ const domain = String(req.params.domain);
2473
+ const level = deps.trust.getLevel(domain);
2474
+ res.json({ data: { domain, level } });
2475
+ });
2476
+
2477
+ router.post('/trust/:domain', async (req: Request, res: Response) => {
2478
+ if (!deps.trust) {
2479
+ res.status(503).json({ error: 'Trust engine not available' });
2480
+ return;
2481
+ }
2482
+ const domain = String(req.params.domain);
2483
+ const { level, reason } = req.body as { level?: number; reason?: string };
2484
+ if (level === undefined || typeof level !== 'number' || level < 0 || level > 4) {
2485
+ res.status(400).json({ error: 'level must be 0-4' });
2486
+ return;
2487
+ }
2488
+ await deps.trust.setLevel(domain, level, reason ?? 'Set via dashboard');
2489
+ void audit('trust.level_changed', { domain, level, reason });
2490
+ res.json({ data: { domain, level } });
2491
+ });
2492
+
2493
+ return { router, auth };
2494
+ }