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