@agenticmail/enterprise 0.5.254 → 0.5.255
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/dist/agent-heartbeat-YENMEEVX.js +510 -0
- package/dist/chunk-75FNJLUP.js +1224 -0
- package/dist/chunk-APCWGN56.js +479 -0
- package/dist/chunk-HE4H2WNG.js +4488 -0
- package/dist/chunk-TOD2HH7L.js +3778 -0
- package/dist/cli-agent-UP7UVQ4S.js +1768 -0
- package/dist/cli-serve-TMYT73ZR.js +114 -0
- package/dist/cli.js +3 -3
- package/dist/index.js +3 -3
- package/dist/routes-GYKER7K7.js +13510 -0
- package/dist/runtime-3CAEEFSI.js +45 -0
- package/dist/server-37QX4BUZ.js +15 -0
- package/dist/setup-5BEVJLTF.js +20 -0
- package/dist/task-queue-3BUUISTK.js +7 -0
- package/package.json +1 -1
- package/src/engine/task-queue.ts +13 -2
|
@@ -0,0 +1,3778 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deployToFly,
|
|
3
|
+
destroyApp,
|
|
4
|
+
getAppStatus
|
|
5
|
+
} from "./chunk-OF4MUWWS.js";
|
|
6
|
+
import {
|
|
7
|
+
PROVIDER_REGISTRY
|
|
8
|
+
} from "./chunk-UF3ZJMJO.js";
|
|
9
|
+
import {
|
|
10
|
+
configBus
|
|
11
|
+
} from "./chunk-3OC6RH7W.js";
|
|
12
|
+
import {
|
|
13
|
+
CircuitBreaker,
|
|
14
|
+
HealthMonitor,
|
|
15
|
+
KeyedRateLimiter,
|
|
16
|
+
requestId
|
|
17
|
+
} from "./chunk-2DDKGTD6.js";
|
|
18
|
+
import {
|
|
19
|
+
getNetworkConfig,
|
|
20
|
+
getNetworkConfigSync,
|
|
21
|
+
invalidateNetworkConfig,
|
|
22
|
+
onNetworkConfigChange,
|
|
23
|
+
setNetworkDb
|
|
24
|
+
} from "./chunk-MKRNEM5A.js";
|
|
25
|
+
import {
|
|
26
|
+
compileIpMatcher
|
|
27
|
+
} from "./chunk-DRXMYYKN.js";
|
|
28
|
+
import {
|
|
29
|
+
SecureVault
|
|
30
|
+
} from "./chunk-6WSX7QXF.js";
|
|
31
|
+
import {
|
|
32
|
+
__require
|
|
33
|
+
} from "./chunk-KFQGP6VL.js";
|
|
34
|
+
|
|
35
|
+
// src/server.ts
|
|
36
|
+
import { Hono as Hono3 } from "hono";
|
|
37
|
+
import { cors } from "hono/cors";
|
|
38
|
+
import { serve } from "@hono/node-server";
|
|
39
|
+
import { readFileSync, existsSync } from "fs";
|
|
40
|
+
import { homedir } from "os";
|
|
41
|
+
import { fileURLToPath } from "url";
|
|
42
|
+
import { dirname, join } from "path";
|
|
43
|
+
import { createRequire } from "module";
|
|
44
|
+
|
|
45
|
+
// src/db/proxy.ts
|
|
46
|
+
function createDbProxy(initial) {
|
|
47
|
+
let target = initial;
|
|
48
|
+
const proxy = new Proxy({}, {
|
|
49
|
+
get(_, prop) {
|
|
50
|
+
if (prop === "__swap") {
|
|
51
|
+
return (newAdapter) => {
|
|
52
|
+
const old = target;
|
|
53
|
+
target = newAdapter;
|
|
54
|
+
return old;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (prop === "__target") return target;
|
|
58
|
+
const val = target[prop];
|
|
59
|
+
return typeof val === "function" ? val.bind(target) : val;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return proxy;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/admin/routes.ts
|
|
66
|
+
import { Hono } from "hono";
|
|
67
|
+
|
|
68
|
+
// src/middleware/firewall.ts
|
|
69
|
+
var FIREWALL_BLOCK_PAGE = `<!DOCTYPE html>
|
|
70
|
+
<html lang="en">
|
|
71
|
+
<head>
|
|
72
|
+
<meta charset="utf-8">
|
|
73
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
74
|
+
<title>Access Denied</title>
|
|
75
|
+
<style>
|
|
76
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
77
|
+
body{min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f1117;color:#e1e4e8}
|
|
78
|
+
.container{text-align:center;max-width:480px;padding:40px 24px}
|
|
79
|
+
.icon{width:64px;height:64px;margin:0 auto 24px;border-radius:16px;background:rgba(255,107,107,0.1);display:flex;align-items:center;justify-content:center}
|
|
80
|
+
.icon svg{width:32px;height:32px;stroke:#ff6b6b;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
81
|
+
h1{font-size:24px;font-weight:700;margin-bottom:12px;color:#fff}
|
|
82
|
+
p{font-size:15px;line-height:1.6;color:#8b949e;margin-bottom:8px}
|
|
83
|
+
.subtle{font-size:13px;color:#484f58;margin-top:24px}
|
|
84
|
+
</style>
|
|
85
|
+
</head>
|
|
86
|
+
<body>
|
|
87
|
+
<div class="container">
|
|
88
|
+
<div class="icon"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg></div>
|
|
89
|
+
<h1>Access Denied</h1>
|
|
90
|
+
<p>Your request has been blocked by the firewall. Access to this service is restricted by the administrator.</p>
|
|
91
|
+
<p>If you believe this is an error, please contact the site owner.</p>
|
|
92
|
+
<div class="subtle">Error 403</div>
|
|
93
|
+
</div>
|
|
94
|
+
</body>
|
|
95
|
+
</html>`;
|
|
96
|
+
function firewallBlock(c) {
|
|
97
|
+
const accept = c.req.header("accept") || "";
|
|
98
|
+
if (accept.includes("application/json") || c.req.path.startsWith("/api/")) {
|
|
99
|
+
return c.json({ error: "Access denied by firewall policy", code: "IP_BLOCKED" }, 403);
|
|
100
|
+
}
|
|
101
|
+
return c.html(FIREWALL_BLOCK_PAGE, 403);
|
|
102
|
+
}
|
|
103
|
+
var _compiled = {
|
|
104
|
+
enabled: false,
|
|
105
|
+
mode: "blocklist",
|
|
106
|
+
allowlistMatcher: null,
|
|
107
|
+
blocklistMatcher: null,
|
|
108
|
+
bypassPaths: [],
|
|
109
|
+
trustedProxyMatcher: null,
|
|
110
|
+
trustedProxyEnabled: false
|
|
111
|
+
};
|
|
112
|
+
function recompile(config) {
|
|
113
|
+
const ipAccess = config.ipAccess;
|
|
114
|
+
const tp = config.trustedProxies;
|
|
115
|
+
_compiled = {
|
|
116
|
+
enabled: ipAccess?.enabled === true,
|
|
117
|
+
mode: ipAccess?.mode || "blocklist",
|
|
118
|
+
allowlistMatcher: ipAccess?.allowlist?.length ? compileIpMatcher(ipAccess.allowlist) : null,
|
|
119
|
+
blocklistMatcher: ipAccess?.blocklist?.length ? compileIpMatcher(ipAccess.blocklist) : null,
|
|
120
|
+
bypassPaths: ["/health", "/ready", ...ipAccess?.bypassPaths || []],
|
|
121
|
+
trustedProxyMatcher: tp?.ips?.length ? compileIpMatcher(tp.ips) : null,
|
|
122
|
+
trustedProxyEnabled: tp?.enabled === true
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
onNetworkConfigChange(recompile);
|
|
126
|
+
function extractClientIp(c, connectingIp) {
|
|
127
|
+
const xff = c.req.header("x-forwarded-for");
|
|
128
|
+
const xri = c.req.header("x-real-ip");
|
|
129
|
+
if (_compiled.trustedProxyEnabled && _compiled.trustedProxyMatcher) {
|
|
130
|
+
if (!_compiled.trustedProxyMatcher(connectingIp)) {
|
|
131
|
+
return connectingIp;
|
|
132
|
+
}
|
|
133
|
+
if (xff) {
|
|
134
|
+
const parts = xff.split(",").map((s) => s.trim()).filter(Boolean);
|
|
135
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
136
|
+
if (!_compiled.trustedProxyMatcher(parts[i])) {
|
|
137
|
+
return parts[i];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return parts[0] || connectingIp;
|
|
141
|
+
}
|
|
142
|
+
return xri || connectingIp;
|
|
143
|
+
}
|
|
144
|
+
return xff?.split(",")[0]?.trim() || xri || connectingIp;
|
|
145
|
+
}
|
|
146
|
+
function ipAccessControl(_getDb) {
|
|
147
|
+
return async (c, next) => {
|
|
148
|
+
await getNetworkConfig();
|
|
149
|
+
if (!_compiled.enabled) return next();
|
|
150
|
+
const connectingIp = c.env?.remoteAddress || c.req.raw?.socket?.remoteAddress || "unknown";
|
|
151
|
+
const clientIp = extractClientIp(c, connectingIp || "unknown");
|
|
152
|
+
c.set("clientIp", clientIp);
|
|
153
|
+
if (_compiled.bypassPaths.some((p) => c.req.path.startsWith(p))) {
|
|
154
|
+
return next();
|
|
155
|
+
}
|
|
156
|
+
if (_compiled.mode === "allowlist") {
|
|
157
|
+
if (_compiled.allowlistMatcher && !_compiled.allowlistMatcher(clientIp)) {
|
|
158
|
+
return firewallBlock(c);
|
|
159
|
+
}
|
|
160
|
+
return next();
|
|
161
|
+
}
|
|
162
|
+
if (_compiled.mode === "blocklist") {
|
|
163
|
+
if (_compiled.blocklistMatcher && _compiled.blocklistMatcher(clientIp)) {
|
|
164
|
+
return firewallBlock(c);
|
|
165
|
+
}
|
|
166
|
+
return next();
|
|
167
|
+
}
|
|
168
|
+
return next();
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/middleware/dns-rebinding.ts
|
|
173
|
+
function dnsRebindingProtection() {
|
|
174
|
+
return async (c, next) => {
|
|
175
|
+
const config = await getNetworkConfig();
|
|
176
|
+
const dns = config.dnsRebinding;
|
|
177
|
+
if (!dns?.enabled || !dns.allowedHosts?.length) return next();
|
|
178
|
+
const host = c.req.header("host")?.split(":")[0]?.toLowerCase();
|
|
179
|
+
if (!host) return next();
|
|
180
|
+
const allowed = dns.allowedHosts.some((h) => {
|
|
181
|
+
const pattern = h.toLowerCase();
|
|
182
|
+
if (pattern === host) return true;
|
|
183
|
+
if (pattern.startsWith("*.")) {
|
|
184
|
+
const suffix = pattern.slice(1);
|
|
185
|
+
return host.endsWith(suffix) || host === pattern.slice(2);
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
});
|
|
189
|
+
if (!allowed) {
|
|
190
|
+
return c.json(
|
|
191
|
+
{ error: "Invalid Host header", code: "DNS_REBINDING_BLOCKED" },
|
|
192
|
+
403
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return next();
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/middleware/request-limits.ts
|
|
200
|
+
var DEFAULT_MAX_BODY_KB = 10240;
|
|
201
|
+
function requestBodyLimit() {
|
|
202
|
+
return async (c, next) => {
|
|
203
|
+
const config = await getNetworkConfig();
|
|
204
|
+
const maxKb = config.network?.maxBodySizeKb || DEFAULT_MAX_BODY_KB;
|
|
205
|
+
const maxBytes = maxKb * 1024;
|
|
206
|
+
const contentLength = c.req.header("content-length");
|
|
207
|
+
if (contentLength) {
|
|
208
|
+
const size = parseInt(contentLength, 10);
|
|
209
|
+
if (!isNaN(size) && size > maxBytes) {
|
|
210
|
+
return c.json(
|
|
211
|
+
{
|
|
212
|
+
error: `Request body too large (${Math.round(size / 1024)}KB). Maximum is ${maxKb}KB.`,
|
|
213
|
+
code: "BODY_TOO_LARGE"
|
|
214
|
+
},
|
|
215
|
+
413
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return next();
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/middleware/geo-ip.ts
|
|
224
|
+
var COUNTRY_HEADERS = [
|
|
225
|
+
"cf-ipcountry",
|
|
226
|
+
// Cloudflare (proxy mode)
|
|
227
|
+
"x-country-code",
|
|
228
|
+
// Generic / custom
|
|
229
|
+
"x-vercel-ip-country"
|
|
230
|
+
// Vercel
|
|
231
|
+
];
|
|
232
|
+
var GEO_CACHE = /* @__PURE__ */ new Map();
|
|
233
|
+
var GEO_CACHE_TTL = 36e5;
|
|
234
|
+
var GEO_CACHE_MAX = 1e4;
|
|
235
|
+
var GEO_LOOKUP_TIMEOUT = 3e3;
|
|
236
|
+
async function lookupCountry(ip) {
|
|
237
|
+
const cached = GEO_CACHE.get(ip);
|
|
238
|
+
if (cached && Date.now() - cached.ts < GEO_CACHE_TTL) {
|
|
239
|
+
return cached.country;
|
|
240
|
+
}
|
|
241
|
+
if (ip === "unknown" || ip === "127.0.0.1" || ip === "::1" || ip.startsWith("192.168.") || ip.startsWith("10.") || ip === "localhost") {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const controller = new AbortController();
|
|
246
|
+
const timer = setTimeout(() => controller.abort(), GEO_LOOKUP_TIMEOUT);
|
|
247
|
+
const resp = await fetch(`http://ip-api.com/json/${ip}?fields=status,countryCode`, {
|
|
248
|
+
signal: controller.signal
|
|
249
|
+
});
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
if (!resp.ok) return null;
|
|
252
|
+
const data = await resp.json();
|
|
253
|
+
if (data.status !== "success" || !data.countryCode) return null;
|
|
254
|
+
const country = data.countryCode.toUpperCase();
|
|
255
|
+
if (GEO_CACHE.size >= GEO_CACHE_MAX) {
|
|
256
|
+
const oldest = GEO_CACHE.keys().next().value;
|
|
257
|
+
if (oldest) GEO_CACHE.delete(oldest);
|
|
258
|
+
}
|
|
259
|
+
GEO_CACHE.set(ip, { country, ts: Date.now() });
|
|
260
|
+
return country;
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
var GEO_BLOCK_PAGE = `<!DOCTYPE html>
|
|
266
|
+
<html lang="en">
|
|
267
|
+
<head>
|
|
268
|
+
<meta charset="utf-8">
|
|
269
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
270
|
+
<title>Access Restricted</title>
|
|
271
|
+
<style>
|
|
272
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
273
|
+
body{min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f1117;color:#e1e4e8}
|
|
274
|
+
.container{text-align:center;max-width:480px;padding:40px 24px}
|
|
275
|
+
.icon{width:64px;height:64px;margin:0 auto 24px;border-radius:16px;background:rgba(255,107,107,0.1);display:flex;align-items:center;justify-content:center}
|
|
276
|
+
.icon svg{width:32px;height:32px;stroke:#ff6b6b;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
277
|
+
h1{font-size:24px;font-weight:700;margin-bottom:12px;color:#fff}
|
|
278
|
+
p{font-size:15px;line-height:1.6;color:#8b949e;margin-bottom:8px}
|
|
279
|
+
.subtle{font-size:13px;color:#484f58;margin-top:24px}
|
|
280
|
+
</style>
|
|
281
|
+
</head>
|
|
282
|
+
<body>
|
|
283
|
+
<div class="container">
|
|
284
|
+
<div class="icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg></div>
|
|
285
|
+
<h1>Access Restricted</h1>
|
|
286
|
+
<p>This service is not available in your region. Access has been restricted by the administrator.</p>
|
|
287
|
+
<p>If you believe this is an error, please contact the site owner.</p>
|
|
288
|
+
<div class="subtle">Error 403</div>
|
|
289
|
+
</div>
|
|
290
|
+
</body>
|
|
291
|
+
</html>`;
|
|
292
|
+
function geoIpRestriction() {
|
|
293
|
+
return async (c, next) => {
|
|
294
|
+
const config = await getNetworkConfig();
|
|
295
|
+
const geo = config.geoIp;
|
|
296
|
+
if (!geo?.enabled || !geo.countries?.length) return next();
|
|
297
|
+
let country = null;
|
|
298
|
+
for (const header of COUNTRY_HEADERS) {
|
|
299
|
+
const val = c.req.header(header);
|
|
300
|
+
if (val) {
|
|
301
|
+
country = val.toUpperCase().trim();
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!country) {
|
|
306
|
+
const clientIp = c.req.header("cf-connecting-ip") || c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || c.get?.("clientIp") || null;
|
|
307
|
+
if (clientIp) {
|
|
308
|
+
country = await lookupCountry(clientIp);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!country) return next();
|
|
312
|
+
const countries = new Set(geo.countries.map((c2) => c2.toUpperCase()));
|
|
313
|
+
const mode = geo.mode || "blocklist";
|
|
314
|
+
let blocked = false;
|
|
315
|
+
if (mode === "allowlist" && !countries.has(country)) blocked = true;
|
|
316
|
+
if (mode === "blocklist" && countries.has(country)) blocked = true;
|
|
317
|
+
if (blocked) {
|
|
318
|
+
const accept = c.req.header("accept") || "";
|
|
319
|
+
if (accept.includes("application/json") || c.req.path.startsWith("/api/")) {
|
|
320
|
+
return c.json({ error: "Access restricted", code: "GEO_BLOCKED" }, 403);
|
|
321
|
+
}
|
|
322
|
+
return c.html(GEO_BLOCK_PAGE, 403);
|
|
323
|
+
}
|
|
324
|
+
return next();
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/middleware/proxy-config.ts
|
|
329
|
+
var _applied = false;
|
|
330
|
+
function applyProxy(config) {
|
|
331
|
+
const proxy = config.proxy;
|
|
332
|
+
if (!proxy) {
|
|
333
|
+
if (_applied) {
|
|
334
|
+
delete process.env.HTTP_PROXY;
|
|
335
|
+
delete process.env.HTTPS_PROXY;
|
|
336
|
+
delete process.env.NO_PROXY;
|
|
337
|
+
delete process.env.http_proxy;
|
|
338
|
+
delete process.env.https_proxy;
|
|
339
|
+
delete process.env.no_proxy;
|
|
340
|
+
_applied = false;
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (proxy.httpProxy) {
|
|
345
|
+
process.env.HTTP_PROXY = proxy.httpProxy;
|
|
346
|
+
process.env.http_proxy = proxy.httpProxy;
|
|
347
|
+
_applied = true;
|
|
348
|
+
} else {
|
|
349
|
+
delete process.env.HTTP_PROXY;
|
|
350
|
+
delete process.env.http_proxy;
|
|
351
|
+
}
|
|
352
|
+
if (proxy.httpsProxy) {
|
|
353
|
+
process.env.HTTPS_PROXY = proxy.httpsProxy;
|
|
354
|
+
process.env.https_proxy = proxy.httpsProxy;
|
|
355
|
+
_applied = true;
|
|
356
|
+
} else {
|
|
357
|
+
delete process.env.HTTPS_PROXY;
|
|
358
|
+
delete process.env.https_proxy;
|
|
359
|
+
}
|
|
360
|
+
if (proxy.noProxy && proxy.noProxy.length > 0) {
|
|
361
|
+
const noProxyStr = proxy.noProxy.join(",");
|
|
362
|
+
process.env.NO_PROXY = noProxyStr;
|
|
363
|
+
process.env.no_proxy = noProxyStr;
|
|
364
|
+
_applied = true;
|
|
365
|
+
} else {
|
|
366
|
+
delete process.env.NO_PROXY;
|
|
367
|
+
delete process.env.no_proxy;
|
|
368
|
+
}
|
|
369
|
+
if (_applied) {
|
|
370
|
+
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Proxy config applied: HTTP=${proxy.httpProxy || "none"} HTTPS=${proxy.httpsProxy || "none"} NO_PROXY=${proxy.noProxy?.join(",") || "none"}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function initProxyConfig() {
|
|
374
|
+
const config = getNetworkConfigSync();
|
|
375
|
+
applyProxy(config);
|
|
376
|
+
onNetworkConfigChange(applyProxy);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/middleware/index.ts
|
|
380
|
+
function requestIdMiddleware() {
|
|
381
|
+
return async (c, next) => {
|
|
382
|
+
const id = c.req.header("X-Request-Id") || requestId();
|
|
383
|
+
c.set("requestId", id);
|
|
384
|
+
c.header("X-Request-Id", id);
|
|
385
|
+
await next();
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function requestLogger() {
|
|
389
|
+
return async (c, next) => {
|
|
390
|
+
const start = Date.now();
|
|
391
|
+
const method = c.req.method;
|
|
392
|
+
const path = c.req.path;
|
|
393
|
+
await next();
|
|
394
|
+
const elapsed = Date.now() - start;
|
|
395
|
+
const status = c.res.status;
|
|
396
|
+
const reqId = c.get("requestId") || "-";
|
|
397
|
+
const level = status >= 500 ? "ERROR" : status >= 400 ? "WARN" : "INFO";
|
|
398
|
+
console.log(
|
|
399
|
+
`[${(/* @__PURE__ */ new Date()).toISOString()}] ${level} ${method} ${path} ${status} ${elapsed}ms req=${reqId}`
|
|
400
|
+
);
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
function requireHttps() {
|
|
404
|
+
return async (c, next) => {
|
|
405
|
+
const netConfig = await getNetworkConfig();
|
|
406
|
+
const https = netConfig.network?.httpsEnforcement;
|
|
407
|
+
const enforcing = https?.enabled ?? process.env.NODE_ENV === "production";
|
|
408
|
+
if (!enforcing) return next();
|
|
409
|
+
const excludePaths = https?.excludePaths || [];
|
|
410
|
+
if (excludePaths.some((p) => c.req.path.startsWith(p))) {
|
|
411
|
+
return next();
|
|
412
|
+
}
|
|
413
|
+
const host = c.req.header("host") || "";
|
|
414
|
+
if (host.startsWith("localhost") || host.startsWith("127.0.0.1") || host.startsWith("[::1]")) {
|
|
415
|
+
return next();
|
|
416
|
+
}
|
|
417
|
+
const isSecure = c.req.url.startsWith("https://") || c.req.header("x-forwarded-proto") === "https" || c.req.header("x-forwarded-ssl") === "on";
|
|
418
|
+
if (!isSecure) {
|
|
419
|
+
return c.json({ error: "HTTPS required by security policy", code: "HTTPS_REQUIRED" }, 403);
|
|
420
|
+
}
|
|
421
|
+
await next();
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
function rateLimiter(config) {
|
|
425
|
+
let limiter = new KeyedRateLimiter({
|
|
426
|
+
maxTokens: config.limit,
|
|
427
|
+
refillRate: config.limit / config.windowSec
|
|
428
|
+
});
|
|
429
|
+
let _currentLimit = config.limit;
|
|
430
|
+
let _currentSkipPaths = config.skipPaths || [];
|
|
431
|
+
return async (c, next) => {
|
|
432
|
+
const netConfig = await getNetworkConfig();
|
|
433
|
+
const dbRl = netConfig.network?.rateLimit;
|
|
434
|
+
if (dbRl?.enabled === false) return next();
|
|
435
|
+
const effectiveLimit = dbRl?.requestsPerMinute || config.limit;
|
|
436
|
+
if (effectiveLimit !== _currentLimit) {
|
|
437
|
+
_currentLimit = effectiveLimit;
|
|
438
|
+
limiter = new KeyedRateLimiter({
|
|
439
|
+
maxTokens: _currentLimit,
|
|
440
|
+
refillRate: _currentLimit / config.windowSec
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const skipPaths = [...config.skipPaths || [], ...dbRl?.skipPaths || []];
|
|
444
|
+
if (skipPaths.some((p) => c.req.path.startsWith(p))) {
|
|
445
|
+
return next();
|
|
446
|
+
}
|
|
447
|
+
const key = config.keyFn?.(c) || c.get?.("clientIp") || c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "unknown";
|
|
448
|
+
if (!limiter.tryConsume(key)) {
|
|
449
|
+
const retryAfter = Math.ceil(limiter.getRetryAfterMs(key) / 1e3);
|
|
450
|
+
c.header("Retry-After", String(retryAfter));
|
|
451
|
+
c.header("X-RateLimit-Limit", String(_currentLimit));
|
|
452
|
+
c.header("X-RateLimit-Remaining", "0");
|
|
453
|
+
return c.json(
|
|
454
|
+
{ error: "Too many requests", retryAfter },
|
|
455
|
+
429
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
await next();
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function securityHeaders() {
|
|
462
|
+
return async (c, next) => {
|
|
463
|
+
await next();
|
|
464
|
+
const netConfig = await getNetworkConfig();
|
|
465
|
+
const sh = netConfig.network?.securityHeaders;
|
|
466
|
+
if (sh?.xContentTypeOptions !== false) {
|
|
467
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
468
|
+
}
|
|
469
|
+
const xfo = sh?.xFrameOptions || "DENY";
|
|
470
|
+
if (xfo !== "ALLOW") {
|
|
471
|
+
c.header("X-Frame-Options", xfo);
|
|
472
|
+
}
|
|
473
|
+
c.header("X-XSS-Protection", "0");
|
|
474
|
+
c.header("Referrer-Policy", sh?.referrerPolicy || "strict-origin-when-cross-origin");
|
|
475
|
+
c.header("Permissions-Policy", sh?.permissionsPolicy || "camera=(), microphone=(), geolocation=()");
|
|
476
|
+
const isTls = c.req.url.startsWith("https://") || c.req.header("x-forwarded-proto") === "https";
|
|
477
|
+
if (isTls && sh?.hsts !== false) {
|
|
478
|
+
const maxAge = sh?.hstsMaxAge || 31536e3;
|
|
479
|
+
c.header("Strict-Transport-Security", `max-age=${maxAge}; includeSubDomains`);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function errorHandler() {
|
|
484
|
+
return async (c, next) => {
|
|
485
|
+
try {
|
|
486
|
+
await next();
|
|
487
|
+
} catch (err) {
|
|
488
|
+
const reqId = c.get("requestId");
|
|
489
|
+
const status = err.status || err.statusCode || 500;
|
|
490
|
+
const message = status >= 500 ? "Internal server error" : err.message;
|
|
491
|
+
if (status >= 500) {
|
|
492
|
+
console.error(`[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR req=${reqId}`, err);
|
|
493
|
+
}
|
|
494
|
+
const body = {
|
|
495
|
+
error: message,
|
|
496
|
+
code: err.code,
|
|
497
|
+
requestId: reqId
|
|
498
|
+
};
|
|
499
|
+
if (status === 400 && err.details) {
|
|
500
|
+
body.details = err.details;
|
|
501
|
+
}
|
|
502
|
+
return c.json(body, status);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
var ValidationError = class extends Error {
|
|
507
|
+
status = 400;
|
|
508
|
+
code = "VALIDATION_ERROR";
|
|
509
|
+
details;
|
|
510
|
+
constructor(details) {
|
|
511
|
+
const fields = Object.keys(details).join(", ");
|
|
512
|
+
super(`Validation failed: ${fields}`);
|
|
513
|
+
this.details = details;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
function validate(body, validators) {
|
|
517
|
+
const errors = {};
|
|
518
|
+
for (const v of validators) {
|
|
519
|
+
const value = body[v.field];
|
|
520
|
+
if (value === void 0 || value === null || value === "") {
|
|
521
|
+
if (v.required) errors[v.field] = "Required";
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
switch (v.type) {
|
|
525
|
+
case "string":
|
|
526
|
+
if (typeof value !== "string") {
|
|
527
|
+
errors[v.field] = "Must be a string";
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
if (v.minLength && value.length < v.minLength) errors[v.field] = `Min length: ${v.minLength}`;
|
|
531
|
+
if (v.maxLength && value.length > v.maxLength) errors[v.field] = `Max length: ${v.maxLength}`;
|
|
532
|
+
if (v.pattern && !v.pattern.test(value)) errors[v.field] = "Invalid format";
|
|
533
|
+
break;
|
|
534
|
+
case "number":
|
|
535
|
+
if (typeof value !== "number" || isNaN(value)) {
|
|
536
|
+
errors[v.field] = "Must be a number";
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
if (v.min !== void 0 && value < v.min) errors[v.field] = `Min: ${v.min}`;
|
|
540
|
+
if (v.max !== void 0 && value > v.max) errors[v.field] = `Max: ${v.max}`;
|
|
541
|
+
break;
|
|
542
|
+
case "boolean":
|
|
543
|
+
if (typeof value !== "boolean") errors[v.field] = "Must be a boolean";
|
|
544
|
+
break;
|
|
545
|
+
case "email":
|
|
546
|
+
if (typeof value !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
547
|
+
errors[v.field] = "Invalid email";
|
|
548
|
+
break;
|
|
549
|
+
case "url":
|
|
550
|
+
try {
|
|
551
|
+
new URL(value);
|
|
552
|
+
} catch {
|
|
553
|
+
errors[v.field] = "Invalid URL";
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
556
|
+
case "uuid":
|
|
557
|
+
if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
|
558
|
+
errors[v.field] = "Invalid UUID";
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (Object.keys(errors).length > 0) {
|
|
563
|
+
throw new ValidationError(errors);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
function auditLogger(db) {
|
|
567
|
+
const AUDIT_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
568
|
+
return async (c, next) => {
|
|
569
|
+
await next();
|
|
570
|
+
if (!AUDIT_METHODS.has(c.req.method)) return;
|
|
571
|
+
if (c.res.status >= 400) return;
|
|
572
|
+
try {
|
|
573
|
+
const userId = c.get("userId") || "anonymous";
|
|
574
|
+
const path = c.req.path;
|
|
575
|
+
const method = c.req.method;
|
|
576
|
+
const segments = path.split("/").filter(Boolean);
|
|
577
|
+
const resource = segments[segments.length - 2] || segments[segments.length - 1] || "unknown";
|
|
578
|
+
const actionMap = {
|
|
579
|
+
POST: "create",
|
|
580
|
+
PUT: "update",
|
|
581
|
+
PATCH: "update",
|
|
582
|
+
DELETE: "delete"
|
|
583
|
+
};
|
|
584
|
+
const action = `${resource}.${actionMap[method] || method.toLowerCase()}`;
|
|
585
|
+
const userEmail = c.get("userEmail") || void 0;
|
|
586
|
+
const userRole = c.get("userRole") || void 0;
|
|
587
|
+
await db.logEvent({
|
|
588
|
+
actor: userId,
|
|
589
|
+
actorType: "user",
|
|
590
|
+
action,
|
|
591
|
+
resource: path,
|
|
592
|
+
details: {
|
|
593
|
+
...userEmail ? { email: userEmail } : {},
|
|
594
|
+
...userRole ? { role: userRole } : {},
|
|
595
|
+
method
|
|
596
|
+
},
|
|
597
|
+
ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip")
|
|
598
|
+
});
|
|
599
|
+
} catch {
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
var ROLE_HIERARCHY = {
|
|
604
|
+
viewer: 0,
|
|
605
|
+
member: 1,
|
|
606
|
+
admin: 2,
|
|
607
|
+
owner: 3
|
|
608
|
+
};
|
|
609
|
+
function requireRole(minRole) {
|
|
610
|
+
return async (c, next) => {
|
|
611
|
+
const userRole = c.get("userRole");
|
|
612
|
+
if (c.get("authType") === "api-key") {
|
|
613
|
+
return next();
|
|
614
|
+
}
|
|
615
|
+
if (!userRole || ROLE_HIERARCHY[userRole] < ROLE_HIERARCHY[minRole]) {
|
|
616
|
+
return c.json({
|
|
617
|
+
error: "Insufficient permissions",
|
|
618
|
+
required: minRole,
|
|
619
|
+
current: userRole || "none"
|
|
620
|
+
}, 403);
|
|
621
|
+
}
|
|
622
|
+
return next();
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// src/admin/routes.ts
|
|
627
|
+
async function validateProviderApiKey(providerId, apiKey, provider) {
|
|
628
|
+
const timeout = 1e4;
|
|
629
|
+
const ctrl = new AbortController();
|
|
630
|
+
const timer = setTimeout(() => ctrl.abort(), timeout);
|
|
631
|
+
try {
|
|
632
|
+
let resp;
|
|
633
|
+
switch (providerId) {
|
|
634
|
+
case "anthropic": {
|
|
635
|
+
resp = await fetch("https://api.anthropic.com/v1/messages", {
|
|
636
|
+
method: "POST",
|
|
637
|
+
headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
|
638
|
+
body: JSON.stringify({ model: "claude-haiku-4-20250414", max_tokens: 1, messages: [{ role: "user", content: "hi" }] }),
|
|
639
|
+
signal: ctrl.signal
|
|
640
|
+
});
|
|
641
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
642
|
+
return { ok: false, error: "Invalid API key (HTTP " + resp.status + ")" };
|
|
643
|
+
}
|
|
644
|
+
return { ok: true };
|
|
645
|
+
}
|
|
646
|
+
case "openai": {
|
|
647
|
+
resp = await fetch("https://api.openai.com/v1/models", {
|
|
648
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
649
|
+
signal: ctrl.signal
|
|
650
|
+
});
|
|
651
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
652
|
+
return { ok: false, error: "Invalid API key (HTTP " + resp.status + ")" };
|
|
653
|
+
}
|
|
654
|
+
return { ok: true };
|
|
655
|
+
}
|
|
656
|
+
case "google": {
|
|
657
|
+
resp = await fetch("https://generativelanguage.googleapis.com/v1beta/models?key=" + apiKey, {
|
|
658
|
+
signal: ctrl.signal
|
|
659
|
+
});
|
|
660
|
+
if (resp.status === 400 || resp.status === 401 || resp.status === 403) {
|
|
661
|
+
return { ok: false, error: "Invalid API key (HTTP " + resp.status + ")" };
|
|
662
|
+
}
|
|
663
|
+
return { ok: true };
|
|
664
|
+
}
|
|
665
|
+
case "xai": {
|
|
666
|
+
resp = await fetch("https://api.x.ai/v1/models", {
|
|
667
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
668
|
+
signal: ctrl.signal
|
|
669
|
+
});
|
|
670
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
671
|
+
return { ok: false, error: "Invalid API key" };
|
|
672
|
+
}
|
|
673
|
+
return { ok: true };
|
|
674
|
+
}
|
|
675
|
+
case "deepseek": {
|
|
676
|
+
resp = await fetch("https://api.deepseek.com/models", {
|
|
677
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
678
|
+
signal: ctrl.signal
|
|
679
|
+
});
|
|
680
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
681
|
+
return { ok: false, error: "Invalid API key" };
|
|
682
|
+
}
|
|
683
|
+
return { ok: true };
|
|
684
|
+
}
|
|
685
|
+
case "mistral": {
|
|
686
|
+
resp = await fetch("https://api.mistral.ai/v1/models", {
|
|
687
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
688
|
+
signal: ctrl.signal
|
|
689
|
+
});
|
|
690
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
691
|
+
return { ok: false, error: "Invalid API key" };
|
|
692
|
+
}
|
|
693
|
+
return { ok: true };
|
|
694
|
+
}
|
|
695
|
+
case "groq": {
|
|
696
|
+
resp = await fetch("https://api.groq.com/openai/v1/models", {
|
|
697
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
698
|
+
signal: ctrl.signal
|
|
699
|
+
});
|
|
700
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
701
|
+
return { ok: false, error: "Invalid API key" };
|
|
702
|
+
}
|
|
703
|
+
return { ok: true };
|
|
704
|
+
}
|
|
705
|
+
case "together": {
|
|
706
|
+
resp = await fetch("https://api.together.xyz/v1/models", {
|
|
707
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
708
|
+
signal: ctrl.signal
|
|
709
|
+
});
|
|
710
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
711
|
+
return { ok: false, error: "Invalid API key" };
|
|
712
|
+
}
|
|
713
|
+
return { ok: true };
|
|
714
|
+
}
|
|
715
|
+
case "openrouter": {
|
|
716
|
+
resp = await fetch("https://openrouter.ai/api/v1/models", {
|
|
717
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
718
|
+
signal: ctrl.signal
|
|
719
|
+
});
|
|
720
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
721
|
+
return { ok: false, error: "Invalid API key" };
|
|
722
|
+
}
|
|
723
|
+
return { ok: true };
|
|
724
|
+
}
|
|
725
|
+
case "fireworks": {
|
|
726
|
+
resp = await fetch("https://api.fireworks.ai/inference/v1/models", {
|
|
727
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
728
|
+
signal: ctrl.signal
|
|
729
|
+
});
|
|
730
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
731
|
+
return { ok: false, error: "Invalid API key" };
|
|
732
|
+
}
|
|
733
|
+
return { ok: true };
|
|
734
|
+
}
|
|
735
|
+
case "cerebras": {
|
|
736
|
+
resp = await fetch("https://api.cerebras.ai/v1/models", {
|
|
737
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
738
|
+
signal: ctrl.signal
|
|
739
|
+
});
|
|
740
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
741
|
+
return { ok: false, error: "Invalid API key" };
|
|
742
|
+
}
|
|
743
|
+
return { ok: true };
|
|
744
|
+
}
|
|
745
|
+
// Local providers (ollama, vllm, lmstudio, litellm) — skip validation
|
|
746
|
+
case "ollama":
|
|
747
|
+
case "vllm":
|
|
748
|
+
case "lmstudio":
|
|
749
|
+
case "litellm":
|
|
750
|
+
return { ok: true };
|
|
751
|
+
default: {
|
|
752
|
+
try {
|
|
753
|
+
resp = await fetch(provider.baseUrl + "/models", {
|
|
754
|
+
headers: { Authorization: "Bearer " + apiKey },
|
|
755
|
+
signal: ctrl.signal
|
|
756
|
+
});
|
|
757
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
758
|
+
return { ok: false, error: "Invalid API key" };
|
|
759
|
+
}
|
|
760
|
+
return { ok: true };
|
|
761
|
+
} catch {
|
|
762
|
+
return { ok: true };
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
} catch (e) {
|
|
767
|
+
if (e.name === "AbortError") {
|
|
768
|
+
return { ok: false, error: "Validation timed out \u2014 provider not reachable" };
|
|
769
|
+
}
|
|
770
|
+
return { ok: false, error: e.message || "Connection failed" };
|
|
771
|
+
} finally {
|
|
772
|
+
clearTimeout(timer);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
var vault = new SecureVault();
|
|
776
|
+
function createAdminRoutes(db) {
|
|
777
|
+
const api = new Hono();
|
|
778
|
+
const updateSettingsAndEmit = async (updates) => {
|
|
779
|
+
const result = await db.updateSettings(updates);
|
|
780
|
+
configBus.emitSettings(Object.keys(updates));
|
|
781
|
+
return result;
|
|
782
|
+
};
|
|
783
|
+
api.get("/stats", async (c) => {
|
|
784
|
+
const stats = await db.getStats();
|
|
785
|
+
return c.json(stats);
|
|
786
|
+
});
|
|
787
|
+
api.get("/agents", async (c) => {
|
|
788
|
+
const status = c.req.query("status");
|
|
789
|
+
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 200);
|
|
790
|
+
const offset = Math.max(parseInt(c.req.query("offset") || "0"), 0);
|
|
791
|
+
const agents = await db.listAgents({ status, limit, offset });
|
|
792
|
+
const total = await db.countAgents(status);
|
|
793
|
+
return c.json({ agents, total, limit, offset });
|
|
794
|
+
});
|
|
795
|
+
api.get("/agents/:id", async (c) => {
|
|
796
|
+
const agent = await db.getAgent(c.req.param("id"));
|
|
797
|
+
if (!agent) return c.json({ error: "Agent not found" }, 404);
|
|
798
|
+
return c.json(agent);
|
|
799
|
+
});
|
|
800
|
+
api.post("/agents", async (c) => {
|
|
801
|
+
const body = await c.req.json();
|
|
802
|
+
validate(body, [
|
|
803
|
+
{ field: "name", type: "string", required: true, minLength: 1, maxLength: 64, pattern: /^[a-zA-Z0-9_-]+$/ },
|
|
804
|
+
{ field: "email", type: "email" },
|
|
805
|
+
{ field: "role", type: "string", maxLength: 32 }
|
|
806
|
+
]);
|
|
807
|
+
const existing = await db.getAgentByName(body.name);
|
|
808
|
+
if (existing) {
|
|
809
|
+
return c.json({ error: "Agent name already exists" }, 409);
|
|
810
|
+
}
|
|
811
|
+
const userId = c.get("userId") || "system";
|
|
812
|
+
const agent = await db.createAgent({ ...body, createdBy: userId });
|
|
813
|
+
return c.json(agent, 201);
|
|
814
|
+
});
|
|
815
|
+
api.patch("/agents/:id", async (c) => {
|
|
816
|
+
const id = c.req.param("id");
|
|
817
|
+
const existing = await db.getAgent(id);
|
|
818
|
+
if (!existing) return c.json({ error: "Agent not found" }, 404);
|
|
819
|
+
const body = await c.req.json();
|
|
820
|
+
validate(body, [
|
|
821
|
+
{ field: "name", type: "string", minLength: 1, maxLength: 64 },
|
|
822
|
+
{ field: "email", type: "email" },
|
|
823
|
+
{ field: "role", type: "string", maxLength: 32 },
|
|
824
|
+
{ field: "status", type: "string", pattern: /^(active|archived|suspended)$/ }
|
|
825
|
+
]);
|
|
826
|
+
if (body.name && body.name !== existing.name) {
|
|
827
|
+
const conflict = await db.getAgentByName(body.name);
|
|
828
|
+
if (conflict) return c.json({ error: "Agent name already exists" }, 409);
|
|
829
|
+
}
|
|
830
|
+
const agent = await db.updateAgent(id, body);
|
|
831
|
+
configBus.emitAgentUpdate(id, Object.keys(body));
|
|
832
|
+
return c.json(agent);
|
|
833
|
+
});
|
|
834
|
+
api.post("/agents/:id/archive", async (c) => {
|
|
835
|
+
const existing = await db.getAgent(c.req.param("id"));
|
|
836
|
+
if (!existing) return c.json({ error: "Agent not found" }, 404);
|
|
837
|
+
if (existing.status === "archived") return c.json({ error: "Agent already archived" }, 400);
|
|
838
|
+
await db.archiveAgent(c.req.param("id"));
|
|
839
|
+
return c.json({ ok: true, status: "archived" });
|
|
840
|
+
});
|
|
841
|
+
api.post("/agents/:id/restore", async (c) => {
|
|
842
|
+
const existing = await db.getAgent(c.req.param("id"));
|
|
843
|
+
if (!existing) return c.json({ error: "Agent not found" }, 404);
|
|
844
|
+
if (existing.status !== "archived") return c.json({ error: "Agent is not archived" }, 400);
|
|
845
|
+
await db.updateAgent(c.req.param("id"), { status: "active" });
|
|
846
|
+
return c.json({ ok: true, status: "active" });
|
|
847
|
+
});
|
|
848
|
+
api.delete("/agents/:id", requireRole("admin"), async (c) => {
|
|
849
|
+
const existing = await db.getAgent(c.req.param("id"));
|
|
850
|
+
if (!existing) return c.json({ error: "Agent not found" }, 404);
|
|
851
|
+
await db.deleteAgent(c.req.param("id"));
|
|
852
|
+
return c.json({ ok: true });
|
|
853
|
+
});
|
|
854
|
+
api.post("/agents/:id/deploy", requireRole("admin"), async (c) => {
|
|
855
|
+
const agentId = c.req.param("id");
|
|
856
|
+
const agent = await db.getAgent(agentId);
|
|
857
|
+
if (!agent) return c.json({ error: "Agent not found" }, 404);
|
|
858
|
+
const body = await c.req.json();
|
|
859
|
+
const targetType = body.targetType || "fly";
|
|
860
|
+
const config = body.config || {};
|
|
861
|
+
const settings = await db.getSettings();
|
|
862
|
+
const pricingConfig = settings?.modelPricingConfig || {};
|
|
863
|
+
const providerApiKeys = pricingConfig.providerApiKeys || {};
|
|
864
|
+
if (targetType === "fly") {
|
|
865
|
+
let flyToken = config.flyApiToken || process.env.FLY_API_TOKEN;
|
|
866
|
+
if (!flyToken && body.credentialId) {
|
|
867
|
+
try {
|
|
868
|
+
const creds = await db.query?.("SELECT config FROM deploy_credentials WHERE id = $1", [body.credentialId]);
|
|
869
|
+
if (creds?.rows?.[0]?.config) {
|
|
870
|
+
const credConfig = typeof creds.rows[0].config === "string" ? JSON.parse(creds.rows[0].config) : creds.rows[0].config;
|
|
871
|
+
flyToken = credConfig.apiToken;
|
|
872
|
+
}
|
|
873
|
+
} catch {
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (!flyToken) {
|
|
877
|
+
return c.json({ error: "Fly.io API token required. Add it in Settings \u2192 Deployments or pass flyApiToken in config." }, 400);
|
|
878
|
+
}
|
|
879
|
+
const flyConfig = {
|
|
880
|
+
apiToken: flyToken,
|
|
881
|
+
org: config.flyOrg || "personal",
|
|
882
|
+
image: config.image || "node:22-slim",
|
|
883
|
+
regions: config.regions || ["iad"]
|
|
884
|
+
};
|
|
885
|
+
const agentName = agent.name || agentId;
|
|
886
|
+
const appConfig = {
|
|
887
|
+
subdomain: agentName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
|
888
|
+
dbType: "postgres",
|
|
889
|
+
dbConnectionString: process.env.DATABASE_URL || "",
|
|
890
|
+
jwtSecret: process.env.JWT_SECRET || "agent-" + agentId,
|
|
891
|
+
smtpHost: settings?.smtpHost,
|
|
892
|
+
smtpPort: settings?.smtpPort,
|
|
893
|
+
smtpUser: settings?.smtpUser,
|
|
894
|
+
smtpPass: settings?.smtpPass,
|
|
895
|
+
memoryMb: config.memoryMb || 256,
|
|
896
|
+
cpuKind: config.cpuKind || "shared",
|
|
897
|
+
cpus: config.cpus || 1
|
|
898
|
+
};
|
|
899
|
+
try {
|
|
900
|
+
const result = await deployToFly(appConfig, flyConfig);
|
|
901
|
+
const existingAgent = await db.getAgent(agentId);
|
|
902
|
+
const existingMeta = existingAgent?.metadata || {};
|
|
903
|
+
await db.updateAgent(agentId, {
|
|
904
|
+
status: result.status === "started" ? "active" : "error",
|
|
905
|
+
metadata: {
|
|
906
|
+
...existingMeta,
|
|
907
|
+
deployment: {
|
|
908
|
+
target: "fly",
|
|
909
|
+
appName: result.appName,
|
|
910
|
+
url: result.url,
|
|
911
|
+
region: result.region,
|
|
912
|
+
machineId: result.machineId,
|
|
913
|
+
deployedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
914
|
+
deployedBy: body.deployedBy || "dashboard",
|
|
915
|
+
status: result.status
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
return c.json({
|
|
920
|
+
success: result.status === "started",
|
|
921
|
+
deployment: result
|
|
922
|
+
});
|
|
923
|
+
} catch (err) {
|
|
924
|
+
return c.json({ error: "Deployment failed: " + err.message }, 500);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (targetType === "local") {
|
|
928
|
+
const existingAgent = await db.getAgent(agentId);
|
|
929
|
+
const existingMeta = existingAgent?.metadata || {};
|
|
930
|
+
await db.updateAgent(agentId, {
|
|
931
|
+
status: "active",
|
|
932
|
+
metadata: {
|
|
933
|
+
...existingMeta,
|
|
934
|
+
deployment: {
|
|
935
|
+
target: "local",
|
|
936
|
+
url: `http://localhost:${3e3 + Math.floor(Math.random() * 1e3)}`,
|
|
937
|
+
deployedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
938
|
+
deployedBy: body.deployedBy || "dashboard",
|
|
939
|
+
status: "started"
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
return c.json({ success: true, deployment: { status: "started", target: "local" } });
|
|
944
|
+
}
|
|
945
|
+
return c.json({ error: "Unsupported deploy target: " + targetType + ". Supported: fly, docker, vps, local" }, 400);
|
|
946
|
+
});
|
|
947
|
+
api.get("/agents/:id/deploy", requireRole("admin"), async (c) => {
|
|
948
|
+
const agent = await db.getAgent(c.req.param("id"));
|
|
949
|
+
if (!agent) return c.json({ error: "Agent not found" }, 404);
|
|
950
|
+
const meta = agent.metadata || {};
|
|
951
|
+
const info = meta.deployment;
|
|
952
|
+
if (!info) return c.json({ deployed: false });
|
|
953
|
+
if (info.target === "fly" && info.appName) {
|
|
954
|
+
const flyToken = process.env.FLY_API_TOKEN;
|
|
955
|
+
if (flyToken) {
|
|
956
|
+
try {
|
|
957
|
+
const status = await getAppStatus(info.appName, { apiToken: flyToken });
|
|
958
|
+
return c.json({ deployed: true, ...info, live: status });
|
|
959
|
+
} catch {
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return c.json({ deployed: true, ...info });
|
|
964
|
+
});
|
|
965
|
+
api.delete("/agents/:id/deploy", requireRole("admin"), async (c) => {
|
|
966
|
+
const agent = await db.getAgent(c.req.param("id"));
|
|
967
|
+
if (!agent) return c.json({ error: "Agent not found" }, 404);
|
|
968
|
+
const meta = agent.metadata || {};
|
|
969
|
+
const info = meta.deployment;
|
|
970
|
+
if (!info) return c.json({ error: "Agent not deployed" }, 400);
|
|
971
|
+
if (info.target === "fly" && info.appName) {
|
|
972
|
+
const flyToken = process.env.FLY_API_TOKEN;
|
|
973
|
+
if (flyToken) {
|
|
974
|
+
try {
|
|
975
|
+
await destroyApp(info.appName, { apiToken: flyToken });
|
|
976
|
+
} catch (err) {
|
|
977
|
+
return c.json({ error: "Failed to destroy: " + err.message }, 500);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
delete meta.deployment;
|
|
982
|
+
await db.updateAgent(c.req.param("id"), { status: "inactive", metadata: meta });
|
|
983
|
+
return c.json({ ok: true, message: "Deployment destroyed" });
|
|
984
|
+
});
|
|
985
|
+
api.get("/users", requireRole("admin"), async (c) => {
|
|
986
|
+
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 200);
|
|
987
|
+
const offset = Math.max(parseInt(c.req.query("offset") || "0"), 0);
|
|
988
|
+
const users = await db.listUsers({ limit, offset });
|
|
989
|
+
const safe = users.map(({ passwordHash, totpSecret, totpBackupCodes, ...u }) => u);
|
|
990
|
+
return c.json({ users: safe, limit, offset });
|
|
991
|
+
});
|
|
992
|
+
api.post("/users", requireRole("admin"), async (c) => {
|
|
993
|
+
const body = await c.req.json();
|
|
994
|
+
validate(body, [
|
|
995
|
+
{ field: "email", type: "email", required: true },
|
|
996
|
+
{ field: "name", type: "string", required: true, minLength: 1, maxLength: 128 },
|
|
997
|
+
{ field: "role", type: "string", required: true, pattern: /^(owner|admin|member|viewer)$/ },
|
|
998
|
+
{ field: "password", type: "string", minLength: 8, maxLength: 128 }
|
|
999
|
+
]);
|
|
1000
|
+
const existing = await db.getUserByEmail(body.email);
|
|
1001
|
+
if (existing) return c.json({ error: "Email already registered" }, 409);
|
|
1002
|
+
const user = await db.createUser(body);
|
|
1003
|
+
const { passwordHash, ...safe } = user;
|
|
1004
|
+
return c.json(safe, 201);
|
|
1005
|
+
});
|
|
1006
|
+
api.patch("/users/:id", requireRole("admin"), async (c) => {
|
|
1007
|
+
const existing = await db.getUser(c.req.param("id"));
|
|
1008
|
+
if (!existing) return c.json({ error: "User not found" }, 404);
|
|
1009
|
+
const body = await c.req.json();
|
|
1010
|
+
validate(body, [
|
|
1011
|
+
{ field: "email", type: "email" },
|
|
1012
|
+
{ field: "name", type: "string", minLength: 1, maxLength: 128 },
|
|
1013
|
+
{ field: "role", type: "string", pattern: /^(owner|admin|member|viewer)$/ }
|
|
1014
|
+
]);
|
|
1015
|
+
const user = await db.updateUser(c.req.param("id"), body);
|
|
1016
|
+
const { passwordHash, ...safe } = user;
|
|
1017
|
+
return c.json(safe);
|
|
1018
|
+
});
|
|
1019
|
+
api.post("/users/:id/reset-password", requireRole("admin"), async (c) => {
|
|
1020
|
+
const existing = await db.getUser(c.req.param("id"));
|
|
1021
|
+
if (!existing) return c.json({ error: "User not found" }, 404);
|
|
1022
|
+
const body = await c.req.json();
|
|
1023
|
+
const newPassword = body.password;
|
|
1024
|
+
if (!newPassword || typeof newPassword !== "string" || newPassword.length < 8) {
|
|
1025
|
+
return c.json({ error: "Password must be at least 8 characters" }, 400);
|
|
1026
|
+
}
|
|
1027
|
+
const { default: bcrypt } = await import("bcryptjs");
|
|
1028
|
+
const passwordHash = await bcrypt.hash(newPassword, 12);
|
|
1029
|
+
await db.pool.query(
|
|
1030
|
+
"UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2",
|
|
1031
|
+
[passwordHash, c.req.param("id")]
|
|
1032
|
+
);
|
|
1033
|
+
await db.logEvent({
|
|
1034
|
+
actor: c.get("userId") || "system",
|
|
1035
|
+
actorType: "user",
|
|
1036
|
+
action: "user.password_reset",
|
|
1037
|
+
resource: `user:${c.req.param("id")}`,
|
|
1038
|
+
details: { targetEmail: existing.email, resetBy: "admin" },
|
|
1039
|
+
ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip")
|
|
1040
|
+
}).catch(() => {
|
|
1041
|
+
});
|
|
1042
|
+
return c.json({ ok: true, message: "Password reset successfully" });
|
|
1043
|
+
});
|
|
1044
|
+
api.delete("/users/:id", requireRole("owner"), async (c) => {
|
|
1045
|
+
const existing = await db.getUser(c.req.param("id"));
|
|
1046
|
+
if (!existing) return c.json({ error: "User not found" }, 404);
|
|
1047
|
+
const requesterId = c.get("userId");
|
|
1048
|
+
if (requesterId === c.req.param("id")) {
|
|
1049
|
+
return c.json({ error: "Cannot delete your own account" }, 400);
|
|
1050
|
+
}
|
|
1051
|
+
await db.deleteUser(c.req.param("id"));
|
|
1052
|
+
return c.json({ ok: true });
|
|
1053
|
+
});
|
|
1054
|
+
api.get("/platform-capabilities", requireRole("admin"), async (c) => {
|
|
1055
|
+
const os = await import("os");
|
|
1056
|
+
const settings = await db.getSettings();
|
|
1057
|
+
return c.json({ capabilities: settings?.platformCapabilities || {}, serverOS: os.platform() });
|
|
1058
|
+
});
|
|
1059
|
+
api.put("/platform-capabilities", requireRole("owner"), async (c) => {
|
|
1060
|
+
const body = await c.req.json();
|
|
1061
|
+
const userId = c.get("userId") || "system";
|
|
1062
|
+
const capabilities = {
|
|
1063
|
+
localSystemAccess: !!body.localSystemAccess,
|
|
1064
|
+
telegram: !!body.telegram,
|
|
1065
|
+
whatsapp: !!body.whatsapp,
|
|
1066
|
+
enabledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1067
|
+
enabledBy: userId
|
|
1068
|
+
};
|
|
1069
|
+
await updateSettingsAndEmit({ platformCapabilities: capabilities });
|
|
1070
|
+
for (const [cap, enabled] of Object.entries(body)) {
|
|
1071
|
+
if (cap === "enabledAt" || cap === "enabledBy") continue;
|
|
1072
|
+
configBus.emitCapability(cap, !!enabled);
|
|
1073
|
+
}
|
|
1074
|
+
await db.logEvent({
|
|
1075
|
+
actor: userId,
|
|
1076
|
+
actorType: "user",
|
|
1077
|
+
action: "platform.capabilities_updated",
|
|
1078
|
+
resource: "company_settings",
|
|
1079
|
+
details: capabilities,
|
|
1080
|
+
ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip")
|
|
1081
|
+
}).catch(() => {
|
|
1082
|
+
});
|
|
1083
|
+
return c.json({ ok: true, capabilities });
|
|
1084
|
+
});
|
|
1085
|
+
api.get("/whatsapp/qr/:agentId", requireRole("admin"), async (c) => {
|
|
1086
|
+
try {
|
|
1087
|
+
var { getWhatsAppQR, isWhatsAppConnected } = await import("./whatsapp-NATGQVO5.js");
|
|
1088
|
+
var agentId = c.req.param("agentId");
|
|
1089
|
+
if (isWhatsAppConnected(agentId)) {
|
|
1090
|
+
return c.json({ status: "connected" });
|
|
1091
|
+
}
|
|
1092
|
+
var qr = getWhatsAppQR(agentId);
|
|
1093
|
+
if (qr) {
|
|
1094
|
+
return c.json({ status: "awaiting_scan", qr });
|
|
1095
|
+
}
|
|
1096
|
+
return c.json({ status: "not_initialized", message: "Agent has not started WhatsApp connection yet." });
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
return c.json({ error: err.message }, 500);
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
api.get("/audit", requireRole("admin"), async (c) => {
|
|
1102
|
+
const filters = {
|
|
1103
|
+
actor: c.req.query("actor") || void 0,
|
|
1104
|
+
action: c.req.query("action") || void 0,
|
|
1105
|
+
resource: c.req.query("resource") || void 0,
|
|
1106
|
+
from: c.req.query("from") ? new Date(c.req.query("from")) : void 0,
|
|
1107
|
+
to: c.req.query("to") ? new Date(c.req.query("to")) : void 0,
|
|
1108
|
+
limit: Math.min(parseInt(c.req.query("limit") || "50"), 500),
|
|
1109
|
+
offset: Math.max(parseInt(c.req.query("offset") || "0"), 0)
|
|
1110
|
+
};
|
|
1111
|
+
if (filters.from && isNaN(filters.from.getTime())) {
|
|
1112
|
+
return c.json({ error: 'Invalid "from" date' }, 400);
|
|
1113
|
+
}
|
|
1114
|
+
if (filters.to && isNaN(filters.to.getTime())) {
|
|
1115
|
+
return c.json({ error: 'Invalid "to" date' }, 400);
|
|
1116
|
+
}
|
|
1117
|
+
const result = await db.queryAudit(filters);
|
|
1118
|
+
return c.json(result);
|
|
1119
|
+
});
|
|
1120
|
+
api.get("/api-keys", requireRole("admin"), async (c) => {
|
|
1121
|
+
const keys = await db.listApiKeys();
|
|
1122
|
+
const safe = keys.map(({ keyHash, ...k }) => k);
|
|
1123
|
+
return c.json({ keys: safe });
|
|
1124
|
+
});
|
|
1125
|
+
api.post("/api-keys", requireRole("admin"), async (c) => {
|
|
1126
|
+
const body = await c.req.json();
|
|
1127
|
+
validate(body, [
|
|
1128
|
+
{ field: "name", type: "string", required: true, minLength: 1, maxLength: 64 }
|
|
1129
|
+
]);
|
|
1130
|
+
const userId = c.get("userId") || "system";
|
|
1131
|
+
const scopes = Array.isArray(body.scopes) ? body.scopes : ["*"];
|
|
1132
|
+
const expiresAt = body.expiresAt ? new Date(body.expiresAt) : void 0;
|
|
1133
|
+
const { key, plaintext } = await db.createApiKey({
|
|
1134
|
+
name: body.name,
|
|
1135
|
+
scopes,
|
|
1136
|
+
createdBy: userId,
|
|
1137
|
+
expiresAt
|
|
1138
|
+
});
|
|
1139
|
+
const { keyHash, ...safeKey } = key;
|
|
1140
|
+
return c.json({
|
|
1141
|
+
key: safeKey,
|
|
1142
|
+
plaintext,
|
|
1143
|
+
warning: "Store this key securely. It will not be shown again."
|
|
1144
|
+
}, 201);
|
|
1145
|
+
});
|
|
1146
|
+
api.delete("/api-keys/:id", requireRole("admin"), async (c) => {
|
|
1147
|
+
const existing = await db.getApiKey(c.req.param("id"));
|
|
1148
|
+
if (!existing) return c.json({ error: "API key not found" }, 404);
|
|
1149
|
+
await db.revokeApiKey(c.req.param("id"));
|
|
1150
|
+
return c.json({ ok: true, revoked: true });
|
|
1151
|
+
});
|
|
1152
|
+
api.get("/rules", async (c) => {
|
|
1153
|
+
const agentId = c.req.query("agentId") || void 0;
|
|
1154
|
+
const rules = await db.getRules(agentId);
|
|
1155
|
+
return c.json({ rules });
|
|
1156
|
+
});
|
|
1157
|
+
api.post("/rules", async (c) => {
|
|
1158
|
+
const body = await c.req.json();
|
|
1159
|
+
validate(body, [
|
|
1160
|
+
{ field: "name", type: "string", required: true, minLength: 1, maxLength: 128 }
|
|
1161
|
+
]);
|
|
1162
|
+
if (body.conditions && typeof body.conditions !== "object") {
|
|
1163
|
+
return c.json({ error: "conditions must be an object" }, 400);
|
|
1164
|
+
}
|
|
1165
|
+
if (body.actions && typeof body.actions !== "object") {
|
|
1166
|
+
return c.json({ error: "actions must be an object" }, 400);
|
|
1167
|
+
}
|
|
1168
|
+
const rule = await db.createRule({
|
|
1169
|
+
name: body.name,
|
|
1170
|
+
agentId: body.agentId,
|
|
1171
|
+
conditions: body.conditions || {},
|
|
1172
|
+
actions: body.actions || {},
|
|
1173
|
+
priority: body.priority ?? 0,
|
|
1174
|
+
enabled: body.enabled ?? true
|
|
1175
|
+
});
|
|
1176
|
+
return c.json(rule, 201);
|
|
1177
|
+
});
|
|
1178
|
+
api.patch("/rules/:id", async (c) => {
|
|
1179
|
+
const body = await c.req.json();
|
|
1180
|
+
const rule = await db.updateRule(c.req.param("id"), body);
|
|
1181
|
+
return c.json(rule);
|
|
1182
|
+
});
|
|
1183
|
+
api.delete("/rules/:id", async (c) => {
|
|
1184
|
+
await db.deleteRule(c.req.param("id"));
|
|
1185
|
+
return c.json({ ok: true });
|
|
1186
|
+
});
|
|
1187
|
+
api.get("/settings", async (c) => {
|
|
1188
|
+
const settings = await db.getSettings();
|
|
1189
|
+
if (!settings) return c.json({ error: "Not configured" }, 404);
|
|
1190
|
+
const safe = { ...settings };
|
|
1191
|
+
if (safe.smtpPass) safe.smtpPass = "***";
|
|
1192
|
+
if (safe.dkimPrivateKey) safe.dkimPrivateKey = "***";
|
|
1193
|
+
if (safe.ssoConfig?.oidc?.clientSecret) {
|
|
1194
|
+
safe.ssoConfig = { ...safe.ssoConfig, oidc: { ...safe.ssoConfig.oidc, clientSecret: "***" } };
|
|
1195
|
+
}
|
|
1196
|
+
return c.json(safe);
|
|
1197
|
+
});
|
|
1198
|
+
api.patch("/settings", requireRole("admin"), async (c) => {
|
|
1199
|
+
const body = await c.req.json();
|
|
1200
|
+
validate(body, [
|
|
1201
|
+
{ field: "name", type: "string", minLength: 1, maxLength: 128 },
|
|
1202
|
+
{ field: "domain", type: "string", maxLength: 253 },
|
|
1203
|
+
{ field: "subdomain", type: "string", maxLength: 64 },
|
|
1204
|
+
{ field: "primaryColor", type: "string", pattern: /^#[0-9a-fA-F]{6}$/ },
|
|
1205
|
+
{ field: "logoUrl", type: "url" },
|
|
1206
|
+
{ field: "smtpHost", type: "string", maxLength: 253 },
|
|
1207
|
+
{ field: "smtpPort", type: "number" },
|
|
1208
|
+
{ field: "smtpUser", type: "string", maxLength: 253 },
|
|
1209
|
+
{ field: "smtpPass", type: "string", maxLength: 253 },
|
|
1210
|
+
{ field: "dkimPrivateKey", type: "string" },
|
|
1211
|
+
{ field: "cfApiToken", type: "string", maxLength: 500 },
|
|
1212
|
+
{ field: "cfAccountId", type: "string", maxLength: 100 },
|
|
1213
|
+
{ field: "plan", type: "string", maxLength: 32 },
|
|
1214
|
+
{ field: "signatureTemplate", type: "string", maxLength: 1e4 },
|
|
1215
|
+
{ field: "branding", type: "object" }
|
|
1216
|
+
]);
|
|
1217
|
+
const settings = await updateSettingsAndEmit(body);
|
|
1218
|
+
return c.json(settings);
|
|
1219
|
+
});
|
|
1220
|
+
api.post("/settings/branding", requireRole("admin"), async (c) => {
|
|
1221
|
+
const body = await c.req.json();
|
|
1222
|
+
const { type, data, filename } = body;
|
|
1223
|
+
if (!type || !data) return c.json({ error: "type and data are required" }, 400);
|
|
1224
|
+
if (!["logo", "favicon", "login_bg", "login_logo"].includes(type)) return c.json({ error: "Invalid type" }, 400);
|
|
1225
|
+
const os = await import("os");
|
|
1226
|
+
const fs = await import("fs");
|
|
1227
|
+
const path = await import("path");
|
|
1228
|
+
const brandDir = path.join(os.homedir(), ".agenticmail", "branding");
|
|
1229
|
+
if (!fs.existsSync(brandDir)) fs.mkdirSync(brandDir, { recursive: true });
|
|
1230
|
+
const base64 = data.replace(/^data:[^;]+;base64,/, "");
|
|
1231
|
+
const buffer = Buffer.from(base64, "base64");
|
|
1232
|
+
const ext = filename ? path.extname(filename).toLowerCase() : ".png";
|
|
1233
|
+
const validExts = [".png", ".jpg", ".jpeg", ".svg", ".ico", ".gif", ".webp"];
|
|
1234
|
+
if (!validExts.includes(ext)) return c.json({ error: "Invalid file type. Supported: " + validExts.join(", ") }, 400);
|
|
1235
|
+
const savedName = type + ext;
|
|
1236
|
+
fs.writeFileSync(path.join(brandDir, savedName), buffer);
|
|
1237
|
+
if (type === "logo" || type === "favicon") {
|
|
1238
|
+
try {
|
|
1239
|
+
const sharp = (await import("sharp")).default;
|
|
1240
|
+
const sizes = [16, 32, 48, 64, 180, 192, 512];
|
|
1241
|
+
for (const size of sizes) {
|
|
1242
|
+
await sharp(buffer).resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toFile(path.join(brandDir, `icon-${size}.png`));
|
|
1243
|
+
}
|
|
1244
|
+
await sharp(buffer).resize(32, 32, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toFile(path.join(brandDir, "favicon.png"));
|
|
1245
|
+
await sharp(buffer).resize(180, 180, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toFile(path.join(brandDir, "apple-touch-icon.png"));
|
|
1246
|
+
} catch (e) {
|
|
1247
|
+
console.warn("[branding] Sharp not available, skipping icon generation:", e.message);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const settings = await db.getSettings();
|
|
1251
|
+
const branding = settings?.branding || {};
|
|
1252
|
+
const v = Date.now();
|
|
1253
|
+
branding[type] = `/branding/${savedName}?v=${v}`;
|
|
1254
|
+
if (type === "logo" || type === "favicon") {
|
|
1255
|
+
branding.favicon = `/branding/favicon.png?v=${v}`;
|
|
1256
|
+
branding.appleTouchIcon = `/branding/apple-touch-icon.png?v=${v}`;
|
|
1257
|
+
branding.icon192 = `/branding/icon-192.png?v=${v}`;
|
|
1258
|
+
branding.icon512 = `/branding/icon-512.png?v=${v}`;
|
|
1259
|
+
}
|
|
1260
|
+
await updateSettingsAndEmit({ branding });
|
|
1261
|
+
return c.json({ success: true, branding, message: "Branding assets saved. Refresh to see changes." });
|
|
1262
|
+
});
|
|
1263
|
+
api.delete("/settings/branding/:type", requireRole("admin"), async (c) => {
|
|
1264
|
+
const type = c.req.param("type");
|
|
1265
|
+
if (!["logo", "favicon", "login_bg", "login_logo"].includes(type)) return c.json({ error: "Invalid type" }, 400);
|
|
1266
|
+
const settings = await db.getSettings();
|
|
1267
|
+
const branding = settings?.branding || {};
|
|
1268
|
+
delete branding[type];
|
|
1269
|
+
if (type === "logo") {
|
|
1270
|
+
delete branding.favicon;
|
|
1271
|
+
delete branding.appleTouchIcon;
|
|
1272
|
+
delete branding.icon192;
|
|
1273
|
+
delete branding.icon512;
|
|
1274
|
+
}
|
|
1275
|
+
await updateSettingsAndEmit({ branding });
|
|
1276
|
+
return c.json({ success: true, branding });
|
|
1277
|
+
});
|
|
1278
|
+
api.get("/settings/sso", requireRole("admin"), async (c) => {
|
|
1279
|
+
const settings = await db.getSettings();
|
|
1280
|
+
if (!settings) return c.json({ ssoConfig: null });
|
|
1281
|
+
const sso = settings.ssoConfig || {};
|
|
1282
|
+
const safe = { ...sso };
|
|
1283
|
+
if (safe.oidc?.clientSecret) {
|
|
1284
|
+
safe.oidc = { ...safe.oidc, clientSecret: "***" };
|
|
1285
|
+
}
|
|
1286
|
+
if (safe.saml?.certificate) {
|
|
1287
|
+
const cert = safe.saml.certificate;
|
|
1288
|
+
safe.saml = {
|
|
1289
|
+
...safe.saml,
|
|
1290
|
+
certificate: cert.length > 50 ? cert.substring(0, 20) + "..." + cert.substring(cert.length - 20) : cert,
|
|
1291
|
+
certificateConfigured: true
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
return c.json({ ssoConfig: safe });
|
|
1295
|
+
});
|
|
1296
|
+
api.put("/settings/sso/saml", requireRole("admin"), async (c) => {
|
|
1297
|
+
const body = await c.req.json();
|
|
1298
|
+
validate(body, [
|
|
1299
|
+
{ field: "entityId", type: "string", required: true, minLength: 1, maxLength: 512 },
|
|
1300
|
+
{ field: "ssoUrl", type: "url", required: true },
|
|
1301
|
+
{ field: "certificate", type: "string", required: true, minLength: 10 }
|
|
1302
|
+
]);
|
|
1303
|
+
const settings = await db.getSettings();
|
|
1304
|
+
const current = settings?.ssoConfig || {};
|
|
1305
|
+
const ssoConfig = {
|
|
1306
|
+
...current,
|
|
1307
|
+
saml: {
|
|
1308
|
+
entityId: body.entityId,
|
|
1309
|
+
ssoUrl: body.ssoUrl,
|
|
1310
|
+
certificate: body.certificate,
|
|
1311
|
+
signatureAlgorithm: body.signatureAlgorithm || "RSA-SHA256",
|
|
1312
|
+
autoProvision: body.autoProvision ?? true,
|
|
1313
|
+
defaultRole: body.defaultRole || "member",
|
|
1314
|
+
allowedDomains: body.allowedDomains || []
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
await updateSettingsAndEmit({ ssoConfig });
|
|
1318
|
+
return c.json({ ok: true, provider: "saml", configured: true });
|
|
1319
|
+
});
|
|
1320
|
+
api.put("/settings/sso/oidc", requireRole("admin"), async (c) => {
|
|
1321
|
+
const body = await c.req.json();
|
|
1322
|
+
validate(body, [
|
|
1323
|
+
{ field: "clientId", type: "string", required: true, minLength: 1, maxLength: 256 },
|
|
1324
|
+
{ field: "clientSecret", type: "string", required: true, minLength: 1, maxLength: 512 },
|
|
1325
|
+
{ field: "discoveryUrl", type: "url", required: true }
|
|
1326
|
+
]);
|
|
1327
|
+
const settings = await db.getSettings();
|
|
1328
|
+
const current = settings?.ssoConfig || {};
|
|
1329
|
+
let clientSecret = body.clientSecret;
|
|
1330
|
+
if (clientSecret === "***" && current.oidc?.clientSecret) {
|
|
1331
|
+
clientSecret = current.oidc.clientSecret;
|
|
1332
|
+
}
|
|
1333
|
+
const ssoConfig = {
|
|
1334
|
+
...current,
|
|
1335
|
+
oidc: {
|
|
1336
|
+
clientId: body.clientId,
|
|
1337
|
+
clientSecret,
|
|
1338
|
+
discoveryUrl: body.discoveryUrl,
|
|
1339
|
+
scopes: body.scopes || ["openid", "email", "profile"],
|
|
1340
|
+
autoProvision: body.autoProvision ?? true,
|
|
1341
|
+
defaultRole: body.defaultRole || "member",
|
|
1342
|
+
allowedDomains: body.allowedDomains || []
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
await updateSettingsAndEmit({ ssoConfig });
|
|
1346
|
+
return c.json({ ok: true, provider: "oidc", configured: true });
|
|
1347
|
+
});
|
|
1348
|
+
api.delete("/settings/sso/:provider", requireRole("admin"), async (c) => {
|
|
1349
|
+
const provider = c.req.param("provider");
|
|
1350
|
+
if (provider !== "saml" && provider !== "oidc") {
|
|
1351
|
+
return c.json({ error: 'Invalid provider. Use "saml" or "oidc".' }, 400);
|
|
1352
|
+
}
|
|
1353
|
+
const settings = await db.getSettings();
|
|
1354
|
+
const current = settings?.ssoConfig || {};
|
|
1355
|
+
const ssoConfig = { ...current };
|
|
1356
|
+
delete ssoConfig[provider];
|
|
1357
|
+
await updateSettingsAndEmit({ ssoConfig });
|
|
1358
|
+
return c.json({ ok: true, provider, removed: true });
|
|
1359
|
+
});
|
|
1360
|
+
api.post("/settings/sso/oidc/test", requireRole("admin"), async (c) => {
|
|
1361
|
+
const { discoveryUrl } = await c.req.json();
|
|
1362
|
+
if (!discoveryUrl) return c.json({ error: "discoveryUrl required" }, 400);
|
|
1363
|
+
try {
|
|
1364
|
+
const res = await fetch(discoveryUrl);
|
|
1365
|
+
if (!res.ok) return c.json({ ok: false, error: `HTTP ${res.status}` });
|
|
1366
|
+
const doc = await res.json();
|
|
1367
|
+
return c.json({
|
|
1368
|
+
ok: true,
|
|
1369
|
+
issuer: doc.issuer,
|
|
1370
|
+
hasAuthorizationEndpoint: !!doc.authorization_endpoint,
|
|
1371
|
+
hasTokenEndpoint: !!doc.token_endpoint,
|
|
1372
|
+
hasUserinfoEndpoint: !!doc.userinfo_endpoint,
|
|
1373
|
+
hasJwksUri: !!doc.jwks_uri,
|
|
1374
|
+
supportedScopes: doc.scopes_supported
|
|
1375
|
+
});
|
|
1376
|
+
} catch (e) {
|
|
1377
|
+
return c.json({ ok: false, error: e.message });
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
api.get("/settings/org-email", requireRole("admin"), async (c) => {
|
|
1381
|
+
const settings = await db.getSettings();
|
|
1382
|
+
const cfg = settings?.orgEmailConfig;
|
|
1383
|
+
if (!cfg) return c.json({ configured: false });
|
|
1384
|
+
return c.json({
|
|
1385
|
+
configured: cfg.configured || false,
|
|
1386
|
+
provider: cfg.provider,
|
|
1387
|
+
label: cfg.label,
|
|
1388
|
+
oauthClientId: cfg.oauthClientId,
|
|
1389
|
+
oauthTenantId: cfg.oauthTenantId
|
|
1390
|
+
});
|
|
1391
|
+
});
|
|
1392
|
+
api.put("/settings/org-email", requireRole("admin"), async (c) => {
|
|
1393
|
+
const body = await c.req.json();
|
|
1394
|
+
const { provider, oauthClientId, oauthClientSecret, oauthTenantId } = body;
|
|
1395
|
+
if (!provider || !["google", "microsoft"].includes(provider)) {
|
|
1396
|
+
return c.json({ error: 'provider must be "google" or "microsoft"' }, 400);
|
|
1397
|
+
}
|
|
1398
|
+
if (!oauthClientId || !oauthClientSecret) {
|
|
1399
|
+
return c.json({ error: "oauthClientId and oauthClientSecret are required" }, 400);
|
|
1400
|
+
}
|
|
1401
|
+
const label = provider === "google" ? "Google Workspace" : "Microsoft 365";
|
|
1402
|
+
const orgEmailConfig = {
|
|
1403
|
+
provider,
|
|
1404
|
+
oauthClientId,
|
|
1405
|
+
oauthClientSecret,
|
|
1406
|
+
oauthTenantId: provider === "microsoft" ? oauthTenantId || "common" : void 0,
|
|
1407
|
+
oauthRedirectUri: "",
|
|
1408
|
+
// Will be set per-agent at OAuth time
|
|
1409
|
+
configured: true,
|
|
1410
|
+
label
|
|
1411
|
+
};
|
|
1412
|
+
await updateSettingsAndEmit({ orgEmailConfig });
|
|
1413
|
+
return c.json({ success: true, orgEmailConfig: { configured: true, provider, label, oauthClientId, oauthTenantId: orgEmailConfig.oauthTenantId } });
|
|
1414
|
+
});
|
|
1415
|
+
api.delete("/settings/org-email", requireRole("admin"), async (c) => {
|
|
1416
|
+
await updateSettingsAndEmit({ orgEmailConfig: null });
|
|
1417
|
+
return c.json({ success: true });
|
|
1418
|
+
});
|
|
1419
|
+
api.get("/settings/tool-security", requireRole("admin"), async (c) => {
|
|
1420
|
+
const settings = await db.getSettings();
|
|
1421
|
+
return c.json({ toolSecurityConfig: settings?.toolSecurityConfig || {} });
|
|
1422
|
+
});
|
|
1423
|
+
api.put("/settings/tool-security", requireRole("admin"), async (c) => {
|
|
1424
|
+
const body = await c.req.json();
|
|
1425
|
+
if (body && typeof body !== "object") {
|
|
1426
|
+
return c.json({ error: "Body must be a JSON object" }, 400);
|
|
1427
|
+
}
|
|
1428
|
+
await updateSettingsAndEmit({ toolSecurityConfig: body });
|
|
1429
|
+
const settings = await db.getSettings();
|
|
1430
|
+
return c.json({ toolSecurityConfig: settings?.toolSecurityConfig || {} });
|
|
1431
|
+
});
|
|
1432
|
+
api.get("/settings/firewall", requireRole("admin"), async (c) => {
|
|
1433
|
+
const settings = await db.getSettings();
|
|
1434
|
+
return c.json({ firewallConfig: settings?.firewallConfig || {} });
|
|
1435
|
+
});
|
|
1436
|
+
api.put("/settings/firewall", requireRole("admin"), async (c) => {
|
|
1437
|
+
const body = await c.req.json();
|
|
1438
|
+
if (body && typeof body !== "object") {
|
|
1439
|
+
return c.json({ error: "Body must be a JSON object" }, 400);
|
|
1440
|
+
}
|
|
1441
|
+
if (body.ipAccess?.mode && !["allowlist", "blocklist"].includes(body.ipAccess.mode)) {
|
|
1442
|
+
return c.json({ error: 'ipAccess.mode must be "allowlist" or "blocklist"' }, 400);
|
|
1443
|
+
}
|
|
1444
|
+
if (body.egress?.mode && !["allowlist", "blocklist"].includes(body.egress.mode)) {
|
|
1445
|
+
return c.json({ error: 'egress.mode must be "allowlist" or "blocklist"' }, 400);
|
|
1446
|
+
}
|
|
1447
|
+
const { isValidIpOrCidr } = await import("./cidr-LISVZSM2.js");
|
|
1448
|
+
for (const entry of body.ipAccess?.allowlist || []) {
|
|
1449
|
+
if (!isValidIpOrCidr(entry)) return c.json({ error: "Invalid IP/CIDR in allowlist: " + entry }, 400);
|
|
1450
|
+
}
|
|
1451
|
+
for (const entry of body.ipAccess?.blocklist || []) {
|
|
1452
|
+
if (!isValidIpOrCidr(entry)) return c.json({ error: "Invalid IP/CIDR in blocklist: " + entry }, 400);
|
|
1453
|
+
}
|
|
1454
|
+
for (const entry of body.trustedProxies?.ips || []) {
|
|
1455
|
+
if (!isValidIpOrCidr(entry)) return c.json({ error: "Invalid IP/CIDR in trusted proxies: " + entry }, 400);
|
|
1456
|
+
}
|
|
1457
|
+
if (body.ipAccess?.enabled && body.ipAccess?.mode === "allowlist" && body.ipAccess?.allowlist?.length > 0) {
|
|
1458
|
+
const clientIp = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "";
|
|
1459
|
+
if (clientIp && clientIp !== "unknown") {
|
|
1460
|
+
const { compileIpMatcher: compileIpMatcher2 } = await import("./cidr-LISVZSM2.js");
|
|
1461
|
+
const matcher = compileIpMatcher2(body.ipAccess.allowlist);
|
|
1462
|
+
if (!matcher(clientIp)) {
|
|
1463
|
+
return c.json({ error: "Your current IP (" + clientIp + ") is not in the allowlist. Add it first to avoid lockout." }, 400);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
await updateSettingsAndEmit({ firewallConfig: body });
|
|
1468
|
+
try {
|
|
1469
|
+
const { invalidateNetworkConfig: invalidateNetworkConfig2 } = await import("./network-config-IJ2XYW2B.js");
|
|
1470
|
+
await invalidateNetworkConfig2();
|
|
1471
|
+
} catch {
|
|
1472
|
+
}
|
|
1473
|
+
const settings = await db.getSettings();
|
|
1474
|
+
return c.json({ firewallConfig: settings?.firewallConfig || {} });
|
|
1475
|
+
});
|
|
1476
|
+
api.post("/settings/firewall/test-ip", requireRole("admin"), async (c) => {
|
|
1477
|
+
const { ip } = await c.req.json();
|
|
1478
|
+
if (!ip) return c.json({ error: "ip is required" }, 400);
|
|
1479
|
+
const { isValidIpOrCidr, compileIpMatcher: compileIpMatcher2 } = await import("./cidr-LISVZSM2.js");
|
|
1480
|
+
if (!isValidIpOrCidr(ip)) return c.json({ error: "Invalid IP address" }, 400);
|
|
1481
|
+
const settings = await db.getSettings();
|
|
1482
|
+
const ipAccess = settings?.firewallConfig?.ipAccess;
|
|
1483
|
+
if (!ipAccess?.enabled) {
|
|
1484
|
+
return c.json({ ip, allowed: true, reason: "IP access control is disabled" });
|
|
1485
|
+
}
|
|
1486
|
+
if (ipAccess.mode === "allowlist") {
|
|
1487
|
+
const matcher = compileIpMatcher2(ipAccess.allowlist || []);
|
|
1488
|
+
const allowed = matcher(ip);
|
|
1489
|
+
return c.json({ ip, allowed, reason: allowed ? "IP matches allowlist" : "IP not in allowlist" });
|
|
1490
|
+
} else {
|
|
1491
|
+
const matcher = compileIpMatcher2(ipAccess.blocklist || []);
|
|
1492
|
+
const blocked = matcher(ip);
|
|
1493
|
+
return c.json({ ip, allowed: !blocked, reason: blocked ? "IP matches blocklist" : "IP not in blocklist" });
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
api.get("/settings/model-pricing", requireRole("admin"), async (c) => {
|
|
1497
|
+
const settings = await db.getSettings();
|
|
1498
|
+
var config = settings?.modelPricingConfig || { models: [], currency: "USD" };
|
|
1499
|
+
if (!config.models || config.models.length === 0) {
|
|
1500
|
+
config.models = getDefaultModelPricing();
|
|
1501
|
+
}
|
|
1502
|
+
return c.json({ modelPricingConfig: config });
|
|
1503
|
+
});
|
|
1504
|
+
api.put("/settings/model-pricing", requireRole("admin"), async (c) => {
|
|
1505
|
+
const body = await c.req.json();
|
|
1506
|
+
if (!body || typeof body !== "object") {
|
|
1507
|
+
return c.json({ error: "Body must be a JSON object" }, 400);
|
|
1508
|
+
}
|
|
1509
|
+
if (body.models && Array.isArray(body.models)) {
|
|
1510
|
+
for (const m of body.models) {
|
|
1511
|
+
if (!m.provider || !m.modelId) {
|
|
1512
|
+
return c.json({ error: "Each model must have provider and modelId" }, 400);
|
|
1513
|
+
}
|
|
1514
|
+
if (typeof m.inputCostPerMillion !== "number" || m.inputCostPerMillion < 0) {
|
|
1515
|
+
return c.json({ error: `Invalid inputCostPerMillion for ${m.modelId}` }, 400);
|
|
1516
|
+
}
|
|
1517
|
+
if (typeof m.outputCostPerMillion !== "number" || m.outputCostPerMillion < 0) {
|
|
1518
|
+
return c.json({ error: `Invalid outputCostPerMillion for ${m.modelId}` }, 400);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1523
|
+
await updateSettingsAndEmit({ modelPricingConfig: body });
|
|
1524
|
+
const settings = await db.getSettings();
|
|
1525
|
+
return c.json({ modelPricingConfig: settings?.modelPricingConfig || {} });
|
|
1526
|
+
});
|
|
1527
|
+
api.get("/providers", requireRole("admin"), async (c) => {
|
|
1528
|
+
var settings = await db.getSettings();
|
|
1529
|
+
var pricingConfig = settings?.modelPricingConfig;
|
|
1530
|
+
var savedApiKeys = pricingConfig?.providerApiKeys || {};
|
|
1531
|
+
var builtIn = Object.values(PROVIDER_REGISTRY).map(function(p) {
|
|
1532
|
+
var configured = !p.requiresApiKey || !!savedApiKeys[p.id];
|
|
1533
|
+
return {
|
|
1534
|
+
id: p.id,
|
|
1535
|
+
name: p.name,
|
|
1536
|
+
baseUrl: p.baseUrl,
|
|
1537
|
+
apiType: p.apiType,
|
|
1538
|
+
isLocal: p.isLocal,
|
|
1539
|
+
requiresApiKey: p.requiresApiKey,
|
|
1540
|
+
configured,
|
|
1541
|
+
source: "built-in",
|
|
1542
|
+
defaultModels: p.defaultModels || []
|
|
1543
|
+
};
|
|
1544
|
+
});
|
|
1545
|
+
var customProviders = pricingConfig?.customProviders || [];
|
|
1546
|
+
var custom = customProviders.map(function(p) {
|
|
1547
|
+
return { ...p, configured: true, source: "custom" };
|
|
1548
|
+
});
|
|
1549
|
+
return c.json({ providers: [...builtIn, ...custom] });
|
|
1550
|
+
});
|
|
1551
|
+
api.post("/providers", requireRole("admin"), async (c) => {
|
|
1552
|
+
var body = await c.req.json();
|
|
1553
|
+
if (!body.id || !body.name || !body.baseUrl || !body.apiType) {
|
|
1554
|
+
return c.json({ error: "id, name, baseUrl, and apiType are required" }, 400);
|
|
1555
|
+
}
|
|
1556
|
+
if (PROVIDER_REGISTRY[body.id]) {
|
|
1557
|
+
return c.json({ error: "Cannot override built-in provider" }, 409);
|
|
1558
|
+
}
|
|
1559
|
+
var validTypes = ["anthropic", "openai-compatible", "google", "ollama"];
|
|
1560
|
+
if (!validTypes.includes(body.apiType)) {
|
|
1561
|
+
return c.json({ error: "apiType must be one of: " + validTypes.join(", ") }, 400);
|
|
1562
|
+
}
|
|
1563
|
+
var settings = await db.getSettings();
|
|
1564
|
+
var config = settings?.modelPricingConfig || { models: [], currency: "USD" };
|
|
1565
|
+
config.customProviders = config.customProviders || [];
|
|
1566
|
+
if (config.customProviders.find(function(p) {
|
|
1567
|
+
return p.id === body.id;
|
|
1568
|
+
})) {
|
|
1569
|
+
return c.json({ error: "Custom provider with this ID already exists" }, 409);
|
|
1570
|
+
}
|
|
1571
|
+
config.customProviders.push({
|
|
1572
|
+
id: body.id,
|
|
1573
|
+
name: body.name,
|
|
1574
|
+
baseUrl: body.baseUrl,
|
|
1575
|
+
apiType: body.apiType,
|
|
1576
|
+
apiKeyEnvVar: body.apiKeyEnvVar || "",
|
|
1577
|
+
headers: body.headers || {},
|
|
1578
|
+
models: body.models || []
|
|
1579
|
+
});
|
|
1580
|
+
await updateSettingsAndEmit({ modelPricingConfig: config });
|
|
1581
|
+
return c.json({ ok: true, provider: body });
|
|
1582
|
+
});
|
|
1583
|
+
api.post("/providers/:id/api-key", requireRole("admin"), async (c) => {
|
|
1584
|
+
var id = c.req.param("id");
|
|
1585
|
+
var provider = PROVIDER_REGISTRY[id];
|
|
1586
|
+
if (!provider) {
|
|
1587
|
+
return c.json({ error: "Unknown provider" }, 404);
|
|
1588
|
+
}
|
|
1589
|
+
var body = await c.req.json();
|
|
1590
|
+
var apiKey = body.apiKey?.trim();
|
|
1591
|
+
if (!apiKey || typeof apiKey !== "string" || apiKey.length < 5) {
|
|
1592
|
+
return c.json({ error: "Valid API key required" }, 400);
|
|
1593
|
+
}
|
|
1594
|
+
var skipValidation = body.skipValidation === true;
|
|
1595
|
+
if (!skipValidation) {
|
|
1596
|
+
try {
|
|
1597
|
+
var valid = await validateProviderApiKey(id, apiKey, provider);
|
|
1598
|
+
if (!valid.ok) {
|
|
1599
|
+
return c.json({ error: "API key validation failed: " + valid.error, validationFailed: true }, 400);
|
|
1600
|
+
}
|
|
1601
|
+
} catch (e) {
|
|
1602
|
+
return c.json({ error: "API key validation failed: " + (e.message || "Unknown error"), validationFailed: true }, 400);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
var settings = await db.getSettings();
|
|
1606
|
+
var config = settings?.modelPricingConfig || { models: [], currency: "USD" };
|
|
1607
|
+
config.providerApiKeys = config.providerApiKeys || {};
|
|
1608
|
+
config.providerApiKeys[id] = vault.encrypt(apiKey);
|
|
1609
|
+
await updateSettingsAndEmit({ modelPricingConfig: config });
|
|
1610
|
+
return c.json({ ok: true, message: "API key saved for " + provider.name, validated: !skipValidation });
|
|
1611
|
+
});
|
|
1612
|
+
api.put("/providers/:id", requireRole("admin"), async (c) => {
|
|
1613
|
+
var id = c.req.param("id");
|
|
1614
|
+
if (PROVIDER_REGISTRY[id]) {
|
|
1615
|
+
return c.json({ error: "Cannot modify built-in provider" }, 400);
|
|
1616
|
+
}
|
|
1617
|
+
var body = await c.req.json();
|
|
1618
|
+
var settings = await db.getSettings();
|
|
1619
|
+
var config = settings?.modelPricingConfig || { models: [], currency: "USD" };
|
|
1620
|
+
config.customProviders = config.customProviders || [];
|
|
1621
|
+
var idx = config.customProviders.findIndex(function(p) {
|
|
1622
|
+
return p.id === id;
|
|
1623
|
+
});
|
|
1624
|
+
if (idx === -1) {
|
|
1625
|
+
return c.json({ error: "Custom provider not found" }, 404);
|
|
1626
|
+
}
|
|
1627
|
+
config.customProviders[idx] = Object.assign({}, config.customProviders[idx], body, { id });
|
|
1628
|
+
await updateSettingsAndEmit({ modelPricingConfig: config });
|
|
1629
|
+
return c.json({ ok: true, provider: config.customProviders[idx] });
|
|
1630
|
+
});
|
|
1631
|
+
api.delete("/providers/:id", requireRole("admin"), async (c) => {
|
|
1632
|
+
var id = c.req.param("id");
|
|
1633
|
+
if (PROVIDER_REGISTRY[id]) {
|
|
1634
|
+
return c.json({ error: "Cannot delete built-in provider" }, 400);
|
|
1635
|
+
}
|
|
1636
|
+
var settings = await db.getSettings();
|
|
1637
|
+
var config = settings?.modelPricingConfig || { models: [], currency: "USD" };
|
|
1638
|
+
config.customProviders = config.customProviders || [];
|
|
1639
|
+
var before = config.customProviders.length;
|
|
1640
|
+
config.customProviders = config.customProviders.filter(function(p) {
|
|
1641
|
+
return p.id !== id;
|
|
1642
|
+
});
|
|
1643
|
+
if (config.customProviders.length === before) {
|
|
1644
|
+
return c.json({ error: "Custom provider not found" }, 404);
|
|
1645
|
+
}
|
|
1646
|
+
await updateSettingsAndEmit({ modelPricingConfig: config });
|
|
1647
|
+
return c.json({ ok: true });
|
|
1648
|
+
});
|
|
1649
|
+
api.get("/providers/:id/models", requireRole("admin"), async (c) => {
|
|
1650
|
+
var id = c.req.param("id");
|
|
1651
|
+
var provider = PROVIDER_REGISTRY[id];
|
|
1652
|
+
if (id === "ollama" || provider && provider.apiType === "ollama") {
|
|
1653
|
+
var ollamaHost = process.env.OLLAMA_HOST || (provider ? provider.baseUrl : "http://localhost:11434");
|
|
1654
|
+
try {
|
|
1655
|
+
var resp = await fetch(ollamaHost + "/api/tags");
|
|
1656
|
+
var data = await resp.json();
|
|
1657
|
+
return c.json({ models: (data.models || []).map(function(m) {
|
|
1658
|
+
return { id: m.name, name: m.name, size: m.size };
|
|
1659
|
+
}) });
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
return c.json({ error: "Cannot connect to Ollama: " + err.message }, 502);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (provider && provider.isLocal && provider.apiType === "openai-compatible") {
|
|
1665
|
+
try {
|
|
1666
|
+
var resp = await fetch(provider.baseUrl + "/models");
|
|
1667
|
+
var data = await resp.json();
|
|
1668
|
+
return c.json({ models: (data.data || []).map(function(m) {
|
|
1669
|
+
return { id: m.id, name: m.id };
|
|
1670
|
+
}) });
|
|
1671
|
+
} catch (err) {
|
|
1672
|
+
return c.json({ error: "Cannot connect to " + provider.name + ": " + err.message }, 502);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
if (provider && provider.defaultModels) {
|
|
1676
|
+
return c.json({ models: provider.defaultModels.map(function(mid) {
|
|
1677
|
+
return { id: mid, name: mid };
|
|
1678
|
+
}) });
|
|
1679
|
+
}
|
|
1680
|
+
var settings = await db.getSettings();
|
|
1681
|
+
var pricingConfig = settings?.modelPricingConfig;
|
|
1682
|
+
var customProviders = pricingConfig?.customProviders || [];
|
|
1683
|
+
var customProvider = customProviders.find(function(p) {
|
|
1684
|
+
return p.id === id;
|
|
1685
|
+
});
|
|
1686
|
+
if (customProvider && customProvider.models) {
|
|
1687
|
+
return c.json({ models: customProvider.models });
|
|
1688
|
+
}
|
|
1689
|
+
return c.json({ models: [] });
|
|
1690
|
+
});
|
|
1691
|
+
api.get("/retention", requireRole("admin"), async (c) => {
|
|
1692
|
+
const policy = await db.getRetentionPolicy();
|
|
1693
|
+
return c.json(policy);
|
|
1694
|
+
});
|
|
1695
|
+
api.put("/retention", requireRole("owner"), async (c) => {
|
|
1696
|
+
const body = await c.req.json();
|
|
1697
|
+
validate(body, [
|
|
1698
|
+
{ field: "enabled", type: "boolean", required: true },
|
|
1699
|
+
{ field: "retainDays", type: "number", required: true, min: 1, max: 3650 },
|
|
1700
|
+
{ field: "archiveFirst", type: "boolean" }
|
|
1701
|
+
]);
|
|
1702
|
+
await db.setRetentionPolicy({
|
|
1703
|
+
enabled: body.enabled,
|
|
1704
|
+
retainDays: body.retainDays,
|
|
1705
|
+
excludeTags: body.excludeTags || [],
|
|
1706
|
+
archiveFirst: body.archiveFirst ?? true
|
|
1707
|
+
});
|
|
1708
|
+
return c.json({ ok: true });
|
|
1709
|
+
});
|
|
1710
|
+
api.get("/settings/security", requireRole("admin"), async (c) => {
|
|
1711
|
+
try {
|
|
1712
|
+
const settings = await db.getSettings();
|
|
1713
|
+
const securityConfig = settings?.securityConfig || {};
|
|
1714
|
+
return c.json({ securityConfig });
|
|
1715
|
+
} catch (err) {
|
|
1716
|
+
return c.json({ error: err.message }, 500);
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
api.put("/settings/security", requireRole("admin"), async (c) => {
|
|
1720
|
+
try {
|
|
1721
|
+
const body = await c.req.json();
|
|
1722
|
+
const { securityConfig } = body;
|
|
1723
|
+
if (!securityConfig || typeof securityConfig !== "object") {
|
|
1724
|
+
return c.json({ error: "securityConfig is required and must be an object" }, 400);
|
|
1725
|
+
}
|
|
1726
|
+
await updateSettingsAndEmit({ securityConfig });
|
|
1727
|
+
return c.json({ ok: true });
|
|
1728
|
+
} catch (err) {
|
|
1729
|
+
return c.json({ error: err.message }, 500);
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
api.get("/settings/security/events", requireRole("admin"), async (c) => {
|
|
1733
|
+
try {
|
|
1734
|
+
const query = c.req.query();
|
|
1735
|
+
const filter = {
|
|
1736
|
+
eventType: query.eventType ? query.eventType.split(",") : void 0,
|
|
1737
|
+
severity: query.severity ? query.severity.split(",") : void 0,
|
|
1738
|
+
agentId: query.agentId,
|
|
1739
|
+
sourceIp: query.sourceIp,
|
|
1740
|
+
fromDate: query.fromDate,
|
|
1741
|
+
toDate: query.toDate,
|
|
1742
|
+
limit: query.limit ? parseInt(query.limit) : 50,
|
|
1743
|
+
offset: query.offset ? parseInt(query.offset) : 0
|
|
1744
|
+
};
|
|
1745
|
+
const events = await db.getSecurityEvents(filter);
|
|
1746
|
+
return c.json({ events });
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
return c.json({ error: err.message }, 500);
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
api.get("/settings/security/port-scan", requireRole("admin"), async (c) => {
|
|
1752
|
+
try {
|
|
1753
|
+
const { scanPorts } = await import("./port-scanner-7IQVRFUW.js");
|
|
1754
|
+
const result = await scanPorts();
|
|
1755
|
+
return c.json({ scanResult: result });
|
|
1756
|
+
} catch (err) {
|
|
1757
|
+
return c.json({ error: err.message }, 500);
|
|
1758
|
+
}
|
|
1759
|
+
});
|
|
1760
|
+
api.get("/agents/:id/security", requireRole("admin"), async (c) => {
|
|
1761
|
+
try {
|
|
1762
|
+
const agentId = c.req.param("id");
|
|
1763
|
+
const agent = await db.getAgent(agentId);
|
|
1764
|
+
if (!agent) {
|
|
1765
|
+
return c.json({ error: "Agent not found" }, 404);
|
|
1766
|
+
}
|
|
1767
|
+
const securityOverrides = agent?.securityOverrides || {};
|
|
1768
|
+
return c.json({ securityOverrides });
|
|
1769
|
+
} catch (err) {
|
|
1770
|
+
return c.json({ error: err.message }, 500);
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
api.put("/agents/:id/security", requireRole("admin"), async (c) => {
|
|
1774
|
+
try {
|
|
1775
|
+
const agentId = c.req.param("id");
|
|
1776
|
+
const body = await c.req.json();
|
|
1777
|
+
const { securityOverrides } = body;
|
|
1778
|
+
const agent = await db.getAgent(agentId);
|
|
1779
|
+
if (!agent) {
|
|
1780
|
+
return c.json({ error: "Agent not found" }, 404);
|
|
1781
|
+
}
|
|
1782
|
+
await db.updateAgent(agentId, { securityOverrides });
|
|
1783
|
+
return c.json({ ok: true });
|
|
1784
|
+
} catch (err) {
|
|
1785
|
+
return c.json({ error: err.message }, 500);
|
|
1786
|
+
}
|
|
1787
|
+
});
|
|
1788
|
+
async function updateCorsOrigin(newOrigin, oldOrigin) {
|
|
1789
|
+
try {
|
|
1790
|
+
var settings = await db.getSettings();
|
|
1791
|
+
var fw = settings.firewallConfig || {};
|
|
1792
|
+
var net = fw.network || {};
|
|
1793
|
+
var origins = Array.isArray(net.corsOrigins) ? [...net.corsOrigins] : [];
|
|
1794
|
+
if (oldOrigin) origins = origins.filter((o) => o !== oldOrigin);
|
|
1795
|
+
if (!origins.includes(newOrigin)) origins.push(newOrigin);
|
|
1796
|
+
await updateSettingsAndEmit({ firewallConfig: { ...fw, network: { ...net, corsOrigins: origins } } });
|
|
1797
|
+
try {
|
|
1798
|
+
const { invalidateNetworkConfig: invalidateNetworkConfig2 } = await import("./network-config-IJ2XYW2B.js");
|
|
1799
|
+
await invalidateNetworkConfig2();
|
|
1800
|
+
} catch {
|
|
1801
|
+
}
|
|
1802
|
+
} catch {
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
api.get("/domain/cors", requireRole("admin"), async (c) => {
|
|
1806
|
+
try {
|
|
1807
|
+
var settings = await db.getSettings();
|
|
1808
|
+
var origins = settings?.firewallConfig?.network?.corsOrigins || [];
|
|
1809
|
+
return c.json({ origins });
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
return c.json({ error: err.message }, 500);
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1814
|
+
api.post("/domain/cors", requireRole("admin"), async (c) => {
|
|
1815
|
+
var body = await c.req.json();
|
|
1816
|
+
if (!Array.isArray(body.origins)) {
|
|
1817
|
+
return c.json({ error: "origins must be an array of URLs" }, 400);
|
|
1818
|
+
}
|
|
1819
|
+
for (var o of body.origins) {
|
|
1820
|
+
if (typeof o !== "string") return c.json({ error: "Each origin must be a string" }, 400);
|
|
1821
|
+
if (o !== "*" && !o.startsWith("http://") && !o.startsWith("https://")) {
|
|
1822
|
+
return c.json({ error: 'Origin "' + o + '" must start with http:// or https://' }, 400);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
try {
|
|
1826
|
+
var settings = await db.getSettings();
|
|
1827
|
+
var fw = settings.firewallConfig || {};
|
|
1828
|
+
var net = fw.network || {};
|
|
1829
|
+
await updateSettingsAndEmit({ firewallConfig: { ...fw, network: { ...net, corsOrigins: body.origins } } });
|
|
1830
|
+
try {
|
|
1831
|
+
const { invalidateNetworkConfig: invalidateNetworkConfig2 } = await import("./network-config-IJ2XYW2B.js");
|
|
1832
|
+
await invalidateNetworkConfig2();
|
|
1833
|
+
} catch {
|
|
1834
|
+
}
|
|
1835
|
+
return c.json({ success: true, origins: body.origins });
|
|
1836
|
+
} catch (err) {
|
|
1837
|
+
return c.json({ error: err.message }, 500);
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
api.post("/domain/register", requireRole("admin"), async (c) => {
|
|
1841
|
+
var body = await c.req.json();
|
|
1842
|
+
if (!body.domain) {
|
|
1843
|
+
return c.json({ error: "domain is required" }, 400);
|
|
1844
|
+
}
|
|
1845
|
+
var domain = String(body.domain).toLowerCase().trim();
|
|
1846
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(domain)) {
|
|
1847
|
+
return c.json({ error: "Invalid domain format" }, 400);
|
|
1848
|
+
}
|
|
1849
|
+
try {
|
|
1850
|
+
var { DomainLock } = await import("./domain-lock-Z5A74VJK.js");
|
|
1851
|
+
var lock = new DomainLock();
|
|
1852
|
+
var keyPair = await lock.generateDeploymentKey();
|
|
1853
|
+
var settings = await db.getSettings();
|
|
1854
|
+
var result = await lock.register(domain, keyPair.hash, {
|
|
1855
|
+
orgName: settings?.name,
|
|
1856
|
+
contactEmail: body.contactEmail
|
|
1857
|
+
});
|
|
1858
|
+
if (!result.success) {
|
|
1859
|
+
return c.json({ error: result.error, statusCode: result.statusCode }, 400);
|
|
1860
|
+
}
|
|
1861
|
+
await updateSettingsAndEmit({
|
|
1862
|
+
domain,
|
|
1863
|
+
deploymentKeyHash: keyPair.hash,
|
|
1864
|
+
domainRegistrationId: result.registrationId,
|
|
1865
|
+
domainDnsChallenge: result.dnsChallenge,
|
|
1866
|
+
domainRegisteredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1867
|
+
domainStatus: "pending_dns"
|
|
1868
|
+
});
|
|
1869
|
+
return c.json({
|
|
1870
|
+
deploymentKey: keyPair.plaintext,
|
|
1871
|
+
dnsChallenge: result.dnsChallenge,
|
|
1872
|
+
registrationId: result.registrationId
|
|
1873
|
+
});
|
|
1874
|
+
} catch (err) {
|
|
1875
|
+
return c.json({ error: err.message || "Domain registration failed" }, 500);
|
|
1876
|
+
}
|
|
1877
|
+
});
|
|
1878
|
+
api.post("/domain/verify", requireRole("admin"), async (c) => {
|
|
1879
|
+
var body = await c.req.json();
|
|
1880
|
+
if (!body.domain) {
|
|
1881
|
+
return c.json({ error: "domain is required" }, 400);
|
|
1882
|
+
}
|
|
1883
|
+
var domain = String(body.domain).toLowerCase().trim();
|
|
1884
|
+
try {
|
|
1885
|
+
var { DomainLock } = await import("./domain-lock-Z5A74VJK.js");
|
|
1886
|
+
var lock = new DomainLock();
|
|
1887
|
+
var result = await lock.checkVerification(domain);
|
|
1888
|
+
if (result.verified) {
|
|
1889
|
+
await updateSettingsAndEmit({
|
|
1890
|
+
domainStatus: "verified",
|
|
1891
|
+
domainVerifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1892
|
+
});
|
|
1893
|
+
return c.json({ verified: true });
|
|
1894
|
+
}
|
|
1895
|
+
return c.json({ verified: false, error: result.error });
|
|
1896
|
+
} catch (err) {
|
|
1897
|
+
return c.json({ error: err.message || "Verification check failed" }, 500);
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
api.get("/domain/status", requireRole("admin"), async (c) => {
|
|
1901
|
+
try {
|
|
1902
|
+
var settings = await db.getSettings();
|
|
1903
|
+
return c.json({
|
|
1904
|
+
domain: settings.domain || null,
|
|
1905
|
+
subdomain: settings.subdomain || null,
|
|
1906
|
+
status: settings.domainStatus || "unregistered",
|
|
1907
|
+
registeredAt: settings.domainRegisteredAt || null,
|
|
1908
|
+
verifiedAt: settings.domainVerifiedAt || null,
|
|
1909
|
+
dnsChallenge: settings.domainDnsChallenge || null,
|
|
1910
|
+
useRootDomain: settings.useRootDomain || false,
|
|
1911
|
+
plan: settings.plan || "self-hosted"
|
|
1912
|
+
});
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
return c.json({ error: err.message }, 500);
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
api.post("/domain/change", requireRole("admin"), async (c) => {
|
|
1918
|
+
var body = await c.req.json();
|
|
1919
|
+
if (!body.domain) {
|
|
1920
|
+
return c.json({ error: "domain is required" }, 400);
|
|
1921
|
+
}
|
|
1922
|
+
var domain = String(body.domain).toLowerCase().trim();
|
|
1923
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(domain)) {
|
|
1924
|
+
return c.json({ error: "Invalid domain format" }, 400);
|
|
1925
|
+
}
|
|
1926
|
+
try {
|
|
1927
|
+
var { DomainLock } = await import("./domain-lock-Z5A74VJK.js");
|
|
1928
|
+
var lock = new DomainLock();
|
|
1929
|
+
var keyPair = await lock.generateDeploymentKey();
|
|
1930
|
+
var settings = await db.getSettings();
|
|
1931
|
+
var result = await lock.register(domain, keyPair.hash, {
|
|
1932
|
+
orgName: settings?.name,
|
|
1933
|
+
contactEmail: body.contactEmail
|
|
1934
|
+
});
|
|
1935
|
+
if (!result.success) {
|
|
1936
|
+
return c.json({ error: result.error, statusCode: result.statusCode }, 400);
|
|
1937
|
+
}
|
|
1938
|
+
var oldDomain = settings.domain;
|
|
1939
|
+
await updateSettingsAndEmit({
|
|
1940
|
+
domain,
|
|
1941
|
+
useRootDomain: body.useRootDomain || false,
|
|
1942
|
+
deploymentKeyHash: keyPair.hash,
|
|
1943
|
+
domainRegistrationId: result.registrationId,
|
|
1944
|
+
domainDnsChallenge: result.dnsChallenge,
|
|
1945
|
+
domainRegisteredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1946
|
+
domainStatus: "pending_dns",
|
|
1947
|
+
domainVerifiedAt: void 0
|
|
1948
|
+
});
|
|
1949
|
+
await updateCorsOrigin("https://" + domain, oldDomain ? "https://" + oldDomain : void 0);
|
|
1950
|
+
return c.json({
|
|
1951
|
+
success: true,
|
|
1952
|
+
deploymentKey: keyPair.plaintext,
|
|
1953
|
+
dnsChallenge: result.dnsChallenge,
|
|
1954
|
+
registrationId: result.registrationId
|
|
1955
|
+
});
|
|
1956
|
+
} catch (err) {
|
|
1957
|
+
return c.json({ error: err.message || "Domain change failed" }, 500);
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
api.post("/domain/subdomain", requireRole("admin"), async (c) => {
|
|
1961
|
+
var body = await c.req.json();
|
|
1962
|
+
if (!body.subdomain) {
|
|
1963
|
+
return c.json({ error: "subdomain is required" }, 400);
|
|
1964
|
+
}
|
|
1965
|
+
var sub = String(body.subdomain).toLowerCase().trim().replace(/\.agenticmail\.io$/, "");
|
|
1966
|
+
if (sub.length < 2) {
|
|
1967
|
+
return c.json({ error: "Subdomain must be at least 2 characters." }, 400);
|
|
1968
|
+
}
|
|
1969
|
+
if (sub.length > 63) {
|
|
1970
|
+
return c.json({ error: "Subdomain must be 63 characters or fewer." }, 400);
|
|
1971
|
+
}
|
|
1972
|
+
if (/^-|-$/.test(sub)) {
|
|
1973
|
+
return c.json({ error: "Subdomain cannot start or end with a hyphen." }, 400);
|
|
1974
|
+
}
|
|
1975
|
+
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(sub)) {
|
|
1976
|
+
return c.json({ error: "Subdomain can only contain lowercase letters, numbers, and hyphens." }, 400);
|
|
1977
|
+
}
|
|
1978
|
+
var reserved = ["www", "mail", "api", "app", "admin", "dashboard", "help", "support", "docs", "status", "blog", "cdn", "static", "assets", "ns1", "ns2"];
|
|
1979
|
+
if (reserved.includes(sub)) {
|
|
1980
|
+
return c.json({ error: '"' + sub + '" is a reserved subdomain. Please choose a different one.' }, 400);
|
|
1981
|
+
}
|
|
1982
|
+
try {
|
|
1983
|
+
var settings = await db.getSettings();
|
|
1984
|
+
var oldSub = settings.subdomain || null;
|
|
1985
|
+
await updateSettingsAndEmit({ subdomain: sub });
|
|
1986
|
+
await updateCorsOrigin(
|
|
1987
|
+
"https://" + sub + ".agenticmail.io",
|
|
1988
|
+
oldSub ? "https://" + oldSub + ".agenticmail.io" : void 0
|
|
1989
|
+
);
|
|
1990
|
+
return c.json({ success: true, subdomain: sub, oldSubdomain: oldSub, plan: settings.plan || "self-hosted" });
|
|
1991
|
+
} catch (err) {
|
|
1992
|
+
return c.json({ error: err.message || "Subdomain update failed" }, 500);
|
|
1993
|
+
}
|
|
1994
|
+
});
|
|
1995
|
+
api.delete("/domain", requireRole("admin"), async (c) => {
|
|
1996
|
+
try {
|
|
1997
|
+
var settings = await db.getSettings();
|
|
1998
|
+
var oldDomain = settings.domain;
|
|
1999
|
+
await updateSettingsAndEmit({
|
|
2000
|
+
domain: void 0,
|
|
2001
|
+
domainStatus: void 0,
|
|
2002
|
+
domainDnsChallenge: void 0,
|
|
2003
|
+
domainRegisteredAt: void 0,
|
|
2004
|
+
domainVerifiedAt: void 0,
|
|
2005
|
+
domainRegistrationId: void 0,
|
|
2006
|
+
deploymentKeyHash: void 0
|
|
2007
|
+
});
|
|
2008
|
+
if (oldDomain) {
|
|
2009
|
+
try {
|
|
2010
|
+
var fw = settings.firewallConfig || {};
|
|
2011
|
+
var net = fw.network || {};
|
|
2012
|
+
var origins = Array.isArray(net.corsOrigins) ? net.corsOrigins.filter((o) => o !== "https://" + oldDomain) : [];
|
|
2013
|
+
await updateSettingsAndEmit({ firewallConfig: { ...fw, network: { ...net, corsOrigins: origins } } });
|
|
2014
|
+
try {
|
|
2015
|
+
const { invalidateNetworkConfig: invalidateNetworkConfig2 } = await import("./network-config-IJ2XYW2B.js");
|
|
2016
|
+
await invalidateNetworkConfig2();
|
|
2017
|
+
} catch {
|
|
2018
|
+
}
|
|
2019
|
+
} catch {
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
return c.json({ success: true });
|
|
2023
|
+
} catch (err) {
|
|
2024
|
+
return c.json({ error: err.message || "Failed to remove domain" }, 500);
|
|
2025
|
+
}
|
|
2026
|
+
});
|
|
2027
|
+
function getDefaultModelPricing() {
|
|
2028
|
+
return [
|
|
2029
|
+
// Anthropic (Feb 2026 — 1M context window)
|
|
2030
|
+
{ provider: "anthropic", modelId: "claude-opus-4-6", displayName: "Claude Opus 4.6", inputCostPerMillion: 5, outputCostPerMillion: 25, contextWindow: 1e6 },
|
|
2031
|
+
{ provider: "anthropic", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", inputCostPerMillion: 3, outputCostPerMillion: 15, contextWindow: 1e6 },
|
|
2032
|
+
{ provider: "anthropic", modelId: "claude-sonnet-4-5-20250929", displayName: "Claude Sonnet 4.5", inputCostPerMillion: 3, outputCostPerMillion: 15, contextWindow: 1e6 },
|
|
2033
|
+
{ provider: "anthropic", modelId: "claude-haiku-4-5-20251001", displayName: "Claude Haiku 4.5", inputCostPerMillion: 0.8, outputCostPerMillion: 4, contextWindow: 2e5 },
|
|
2034
|
+
// OpenAI
|
|
2035
|
+
{ provider: "openai", modelId: "gpt-4o", displayName: "GPT-4o", inputCostPerMillion: 2.5, outputCostPerMillion: 10, contextWindow: 128e3 },
|
|
2036
|
+
{ provider: "openai", modelId: "gpt-4o-mini", displayName: "GPT-4o Mini", inputCostPerMillion: 0.15, outputCostPerMillion: 0.6, contextWindow: 128e3 },
|
|
2037
|
+
{ provider: "openai", modelId: "gpt-4.1", displayName: "GPT-4.1", inputCostPerMillion: 2, outputCostPerMillion: 8, contextWindow: 1e6 },
|
|
2038
|
+
{ provider: "openai", modelId: "gpt-4.1-mini", displayName: "GPT-4.1 Mini", inputCostPerMillion: 0.4, outputCostPerMillion: 1.6, contextWindow: 1e6 },
|
|
2039
|
+
{ provider: "openai", modelId: "gpt-4.1-nano", displayName: "GPT-4.1 Nano", inputCostPerMillion: 0.1, outputCostPerMillion: 0.4, contextWindow: 1e6 },
|
|
2040
|
+
{ provider: "openai", modelId: "o3", displayName: "o3", inputCostPerMillion: 10, outputCostPerMillion: 40, contextWindow: 2e5 },
|
|
2041
|
+
{ provider: "openai", modelId: "o4-mini", displayName: "o4-mini", inputCostPerMillion: 1.1, outputCostPerMillion: 4.4, contextWindow: 2e5 },
|
|
2042
|
+
// Google Gemini (up to 2M context)
|
|
2043
|
+
{ provider: "google", modelId: "gemini-2.5-pro", displayName: "Gemini 2.5 Pro", inputCostPerMillion: 2.5, outputCostPerMillion: 15, contextWindow: 1e6 },
|
|
2044
|
+
{ provider: "google", modelId: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", inputCostPerMillion: 0.15, outputCostPerMillion: 0.6, contextWindow: 1e6 },
|
|
2045
|
+
{ provider: "google", modelId: "gemini-2.0-flash", displayName: "Gemini 2.0 Flash", inputCostPerMillion: 0.1, outputCostPerMillion: 0.4, contextWindow: 1e6 },
|
|
2046
|
+
{ provider: "google", modelId: "gemini-3-pro", displayName: "Gemini 3 Pro", inputCostPerMillion: 2.5, outputCostPerMillion: 15, contextWindow: 1e6 },
|
|
2047
|
+
// DeepSeek (128K context)
|
|
2048
|
+
{ provider: "deepseek", modelId: "deepseek-chat", displayName: "DeepSeek Chat (V3)", inputCostPerMillion: 0.14, outputCostPerMillion: 0.28, contextWindow: 128e3 },
|
|
2049
|
+
{ provider: "deepseek", modelId: "deepseek-reasoner", displayName: "DeepSeek Reasoner (R1)", inputCostPerMillion: 0.55, outputCostPerMillion: 2.19, contextWindow: 128e3 },
|
|
2050
|
+
// xAI Grok (2M context window)
|
|
2051
|
+
{ provider: "xai", modelId: "grok-4", displayName: "Grok 4", inputCostPerMillion: 3, outputCostPerMillion: 15, contextWindow: 2e6 },
|
|
2052
|
+
{ provider: "xai", modelId: "grok-4-fast", displayName: "Grok 4 Fast", inputCostPerMillion: 0.2, outputCostPerMillion: 0.5, contextWindow: 2e6 },
|
|
2053
|
+
{ provider: "xai", modelId: "grok-3", displayName: "Grok 3", inputCostPerMillion: 3, outputCostPerMillion: 15, contextWindow: 131072 },
|
|
2054
|
+
{ provider: "xai", modelId: "grok-3-mini", displayName: "Grok 3 Mini", inputCostPerMillion: 0.3, outputCostPerMillion: 0.5, contextWindow: 131072 },
|
|
2055
|
+
// Mistral
|
|
2056
|
+
{ provider: "mistral", modelId: "mistral-large-latest", displayName: "Mistral Large", inputCostPerMillion: 2, outputCostPerMillion: 6, contextWindow: 128e3 },
|
|
2057
|
+
{ provider: "mistral", modelId: "mistral-small-latest", displayName: "Mistral Small", inputCostPerMillion: 0.1, outputCostPerMillion: 0.3, contextWindow: 128e3 },
|
|
2058
|
+
// Groq (inference provider)
|
|
2059
|
+
{ provider: "groq", modelId: "llama-3.3-70b-versatile", displayName: "Llama 3.3 70B (Groq)", inputCostPerMillion: 0.59, outputCostPerMillion: 0.79, contextWindow: 128e3 },
|
|
2060
|
+
// Together (inference provider)
|
|
2061
|
+
{ provider: "together", modelId: "meta-llama/Llama-3.3-70B-Instruct-Turbo", displayName: "Llama 3.3 70B (Together)", inputCostPerMillion: 0.88, outputCostPerMillion: 0.88, contextWindow: 128e3 }
|
|
2062
|
+
];
|
|
2063
|
+
}
|
|
2064
|
+
api.get("/tunnel/status", requireRole("admin"), async (c) => {
|
|
2065
|
+
try {
|
|
2066
|
+
const { execSync } = await import("child_process");
|
|
2067
|
+
let installed = false;
|
|
2068
|
+
let version = "";
|
|
2069
|
+
let running = false;
|
|
2070
|
+
let config = null;
|
|
2071
|
+
try {
|
|
2072
|
+
version = execSync("cloudflared --version 2>&1", { encoding: "utf8", timeout: 5e3 }).trim();
|
|
2073
|
+
installed = true;
|
|
2074
|
+
} catch {
|
|
2075
|
+
}
|
|
2076
|
+
try {
|
|
2077
|
+
const pm2List = execSync("pm2 jlist 2>/dev/null", { encoding: "utf8", timeout: 5e3 });
|
|
2078
|
+
const procs = JSON.parse(pm2List);
|
|
2079
|
+
const cf = procs.find((p) => p.name === "cloudflared");
|
|
2080
|
+
running = cf?.pm2_env?.status === "online";
|
|
2081
|
+
} catch {
|
|
2082
|
+
}
|
|
2083
|
+
const os = await import("os");
|
|
2084
|
+
const fs = await import("fs");
|
|
2085
|
+
const path = await import("path");
|
|
2086
|
+
const cfDir = path.join(os.default.homedir(), ".cloudflared");
|
|
2087
|
+
const cfgPath = path.join(cfDir, "config.yml");
|
|
2088
|
+
if (fs.existsSync(cfgPath)) {
|
|
2089
|
+
const raw = fs.readFileSync(cfgPath, "utf8");
|
|
2090
|
+
const tunnelMatch = raw.match(/^tunnel:\s*(.+)$/m);
|
|
2091
|
+
const hostnameMatch = raw.match(/hostname:\s*(.+)$/m);
|
|
2092
|
+
const serviceMatch = raw.match(/service:\s*(http.+)$/m);
|
|
2093
|
+
config = {
|
|
2094
|
+
tunnelId: tunnelMatch?.[1]?.trim(),
|
|
2095
|
+
hostname: hostnameMatch?.[1]?.trim(),
|
|
2096
|
+
service: serviceMatch?.[1]?.trim(),
|
|
2097
|
+
raw
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
return c.json({ installed, version, running, config });
|
|
2101
|
+
} catch (e) {
|
|
2102
|
+
return c.json({ error: e.message }, 500);
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
api.post("/tunnel/install", requireRole("admin"), async (c) => {
|
|
2106
|
+
try {
|
|
2107
|
+
const { execSync } = await import("child_process");
|
|
2108
|
+
const os = await import("os");
|
|
2109
|
+
const platform = os.default.platform();
|
|
2110
|
+
if (platform === "darwin") {
|
|
2111
|
+
try {
|
|
2112
|
+
execSync("which brew", { timeout: 3e3 });
|
|
2113
|
+
execSync("brew install cloudflared 2>&1", { encoding: "utf8", timeout: 12e4 });
|
|
2114
|
+
} catch {
|
|
2115
|
+
const arch = os.default.arch() === "arm64" ? "arm64" : "amd64";
|
|
2116
|
+
execSync(`curl -L -o /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-${arch} && chmod +x /usr/local/bin/cloudflared`, { timeout: 6e4 });
|
|
2117
|
+
}
|
|
2118
|
+
} else if (platform === "linux") {
|
|
2119
|
+
const arch = os.default.arch() === "arm64" ? "arm64" : "amd64";
|
|
2120
|
+
execSync(`curl -L -o /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch} && chmod +x /usr/local/bin/cloudflared`, { timeout: 6e4 });
|
|
2121
|
+
} else {
|
|
2122
|
+
return c.json({ error: "Unsupported platform: " + platform }, 400);
|
|
2123
|
+
}
|
|
2124
|
+
const version = execSync("cloudflared --version 2>&1", { encoding: "utf8", timeout: 5e3 }).trim();
|
|
2125
|
+
return c.json({ success: true, version });
|
|
2126
|
+
} catch (e) {
|
|
2127
|
+
return c.json({ error: e.message }, 500);
|
|
2128
|
+
}
|
|
2129
|
+
});
|
|
2130
|
+
api.post("/tunnel/login", requireRole("admin"), async (c) => {
|
|
2131
|
+
try {
|
|
2132
|
+
const { exec: execCb } = await import("child_process");
|
|
2133
|
+
const { promisify } = await import("util");
|
|
2134
|
+
const execP = promisify(execCb);
|
|
2135
|
+
await execP("cloudflared tunnel login", { timeout: 12e4 });
|
|
2136
|
+
return c.json({ success: true });
|
|
2137
|
+
} catch (e) {
|
|
2138
|
+
return c.json({ error: "Login failed or timed out. Make sure to complete the browser authorization. " + e.message }, 500);
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
api.post("/tunnel/deploy", requireRole("admin"), async (c) => {
|
|
2142
|
+
const body = await c.req.json();
|
|
2143
|
+
const { domain, tunnelName, port } = body;
|
|
2144
|
+
if (!domain) return c.json({ error: "domain is required" }, 400);
|
|
2145
|
+
const localPort = port || 3200;
|
|
2146
|
+
const name = tunnelName || "agenticmail-enterprise";
|
|
2147
|
+
try {
|
|
2148
|
+
const { execSync } = await import("child_process");
|
|
2149
|
+
const os = await import("os");
|
|
2150
|
+
const fs = await import("fs");
|
|
2151
|
+
const path = await import("path");
|
|
2152
|
+
const cfDir = path.join(os.default.homedir(), ".cloudflared");
|
|
2153
|
+
const steps = [];
|
|
2154
|
+
if (!fs.existsSync(path.join(cfDir, "cert.pem"))) {
|
|
2155
|
+
return c.json({ error: 'Not authenticated with Cloudflare. Click "Login to Cloudflare" first.' }, 400);
|
|
2156
|
+
}
|
|
2157
|
+
let tunnelId = "";
|
|
2158
|
+
try {
|
|
2159
|
+
const out = execSync(`cloudflared tunnel create ${name} 2>&1`, { encoding: "utf8", timeout: 3e4 });
|
|
2160
|
+
const match = out.match(/Created tunnel .+ with id ([a-f0-9-]+)/);
|
|
2161
|
+
tunnelId = match?.[1] || "";
|
|
2162
|
+
steps.push("Created tunnel: " + name + " (" + tunnelId + ")");
|
|
2163
|
+
} catch (e) {
|
|
2164
|
+
if (e.message?.includes("already exists")) {
|
|
2165
|
+
const listOut = execSync("cloudflared tunnel list --output json 2>&1", { encoding: "utf8", timeout: 15e3 });
|
|
2166
|
+
const tunnels = JSON.parse(listOut);
|
|
2167
|
+
const existing = tunnels.find((t) => t.name === name);
|
|
2168
|
+
if (existing) {
|
|
2169
|
+
tunnelId = existing.id;
|
|
2170
|
+
steps.push("Using existing tunnel: " + name + " (" + tunnelId + ")");
|
|
2171
|
+
} else {
|
|
2172
|
+
throw e;
|
|
2173
|
+
}
|
|
2174
|
+
} else {
|
|
2175
|
+
throw e;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
if (!tunnelId) return c.json({ error: "Failed to get tunnel ID" }, 500);
|
|
2179
|
+
const config = [
|
|
2180
|
+
`tunnel: ${tunnelId}`,
|
|
2181
|
+
`credentials-file: ${path.join(cfDir, tunnelId + ".json")}`,
|
|
2182
|
+
"",
|
|
2183
|
+
"ingress:",
|
|
2184
|
+
` - hostname: ${domain}`,
|
|
2185
|
+
` service: http://localhost:${localPort}`,
|
|
2186
|
+
" - service: http_status:404"
|
|
2187
|
+
].join("\n");
|
|
2188
|
+
fs.writeFileSync(path.join(cfDir, "config.yml"), config);
|
|
2189
|
+
steps.push("Wrote config: " + domain + " \u2192 localhost:" + localPort);
|
|
2190
|
+
try {
|
|
2191
|
+
execSync(`cloudflared tunnel route dns ${tunnelId} ${domain} 2>&1`, { encoding: "utf8", timeout: 3e4 });
|
|
2192
|
+
steps.push("DNS CNAME created: " + domain + " \u2192 " + tunnelId + ".cfargotunnel.com");
|
|
2193
|
+
} catch (e) {
|
|
2194
|
+
if (e.message?.includes("already exists")) {
|
|
2195
|
+
steps.push("DNS CNAME already exists for " + domain);
|
|
2196
|
+
} else {
|
|
2197
|
+
steps.push("DNS routing failed (you may need to add CNAME manually): " + e.message);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
try {
|
|
2201
|
+
execSync("which pm2", { timeout: 3e3 });
|
|
2202
|
+
try {
|
|
2203
|
+
execSync("pm2 delete cloudflared 2>/dev/null", { timeout: 5e3 });
|
|
2204
|
+
} catch {
|
|
2205
|
+
}
|
|
2206
|
+
execSync(`pm2 start cloudflared --name cloudflared -- tunnel run`, { encoding: "utf8", timeout: 15e3 });
|
|
2207
|
+
execSync("pm2 save 2>/dev/null", { timeout: 5e3 });
|
|
2208
|
+
steps.push("Started cloudflared via PM2 (auto-restarts on crash)");
|
|
2209
|
+
} catch {
|
|
2210
|
+
try {
|
|
2211
|
+
const { spawn } = await import("child_process");
|
|
2212
|
+
const child = spawn("cloudflared", ["tunnel", "run"], { detached: true, stdio: "ignore" });
|
|
2213
|
+
child.unref();
|
|
2214
|
+
steps.push("Started cloudflared in background (install PM2 for auto-restart)");
|
|
2215
|
+
} catch (e2) {
|
|
2216
|
+
steps.push("Could not start tunnel automatically: " + e2.message);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
try {
|
|
2220
|
+
if (db) {
|
|
2221
|
+
const corsRows = await db.query(`SELECT value FROM admin_settings WHERE key = 'cors_origins'`);
|
|
2222
|
+
let origins = [];
|
|
2223
|
+
if (corsRows?.[0]) {
|
|
2224
|
+
try {
|
|
2225
|
+
origins = JSON.parse(corsRows[0].value);
|
|
2226
|
+
} catch {
|
|
2227
|
+
origins = [];
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
const newOrigin = "https://" + domain;
|
|
2231
|
+
if (!origins.includes(newOrigin)) {
|
|
2232
|
+
origins.push(newOrigin);
|
|
2233
|
+
await db.execute(
|
|
2234
|
+
`INSERT INTO admin_settings (key, value) VALUES ('cors_origins', $1) ON CONFLICT (key) DO UPDATE SET value = $1`,
|
|
2235
|
+
[JSON.stringify(origins)]
|
|
2236
|
+
);
|
|
2237
|
+
steps.push("Added " + newOrigin + " to CORS allowed origins");
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
} catch {
|
|
2241
|
+
}
|
|
2242
|
+
return c.json({ success: true, tunnelId, domain, steps });
|
|
2243
|
+
} catch (e) {
|
|
2244
|
+
return c.json({ error: e.message }, 500);
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
api.post("/tunnel/stop", requireRole("admin"), async (c) => {
|
|
2248
|
+
try {
|
|
2249
|
+
const { execSync } = await import("child_process");
|
|
2250
|
+
try {
|
|
2251
|
+
execSync("pm2 stop cloudflared 2>/dev/null", { timeout: 5e3 });
|
|
2252
|
+
} catch {
|
|
2253
|
+
}
|
|
2254
|
+
try {
|
|
2255
|
+
execSync("pm2 delete cloudflared 2>/dev/null", { timeout: 5e3 });
|
|
2256
|
+
} catch {
|
|
2257
|
+
}
|
|
2258
|
+
return c.json({ success: true });
|
|
2259
|
+
} catch (e) {
|
|
2260
|
+
return c.json({ error: e.message }, 500);
|
|
2261
|
+
}
|
|
2262
|
+
});
|
|
2263
|
+
return api;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
// src/auth/routes.ts
|
|
2267
|
+
import { Hono as Hono2 } from "hono";
|
|
2268
|
+
import { setCookie, getCookie, deleteCookie } from "hono/cookie";
|
|
2269
|
+
import { createVerify } from "crypto";
|
|
2270
|
+
var COOKIE_NAME = "em_session";
|
|
2271
|
+
var REFRESH_COOKIE = "em_refresh";
|
|
2272
|
+
var CSRF_COOKIE = "em_csrf";
|
|
2273
|
+
var TOKEN_TTL = "24h";
|
|
2274
|
+
var REFRESH_TTL = "7d";
|
|
2275
|
+
function cookieOpts(maxAge, isSecure) {
|
|
2276
|
+
return {
|
|
2277
|
+
httpOnly: true,
|
|
2278
|
+
secure: isSecure,
|
|
2279
|
+
sameSite: "Lax",
|
|
2280
|
+
path: "/",
|
|
2281
|
+
maxAge
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
function generateCsrf() {
|
|
2285
|
+
const bytes = new Uint8Array(32);
|
|
2286
|
+
crypto.getRandomValues(bytes);
|
|
2287
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2288
|
+
}
|
|
2289
|
+
function generateState() {
|
|
2290
|
+
const bytes = new Uint8Array(32);
|
|
2291
|
+
crypto.getRandomValues(bytes);
|
|
2292
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2293
|
+
}
|
|
2294
|
+
function generateCodeVerifier() {
|
|
2295
|
+
const bytes = new Uint8Array(32);
|
|
2296
|
+
crypto.getRandomValues(bytes);
|
|
2297
|
+
return Array.from(bytes).map((b) => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"[b % 66]).join("");
|
|
2298
|
+
}
|
|
2299
|
+
async function generateCodeChallenge(verifier) {
|
|
2300
|
+
const encoder = new TextEncoder();
|
|
2301
|
+
const data = encoder.encode(verifier);
|
|
2302
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
2303
|
+
return btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2304
|
+
}
|
|
2305
|
+
function createAuthRoutes(db, jwtSecret, opts) {
|
|
2306
|
+
const auth = new Hono2();
|
|
2307
|
+
const isSecure = () => {
|
|
2308
|
+
return process.env.NODE_ENV === "production" || process.env.SECURE_COOKIES === "1";
|
|
2309
|
+
};
|
|
2310
|
+
async function issueTokens(userId, email, role) {
|
|
2311
|
+
const { SignJWT } = await import("jose");
|
|
2312
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
2313
|
+
const token = await new SignJWT({ sub: userId, email, role }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(TOKEN_TTL).sign(secret);
|
|
2314
|
+
const refreshToken = await new SignJWT({ sub: userId, type: "refresh" }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(REFRESH_TTL).sign(secret);
|
|
2315
|
+
return { token, refreshToken };
|
|
2316
|
+
}
|
|
2317
|
+
async function setSessionCookies(c, userId, email, role, method) {
|
|
2318
|
+
const { token, refreshToken } = await issueTokens(userId, email, role);
|
|
2319
|
+
const csrf = generateCsrf();
|
|
2320
|
+
const secure = isSecure();
|
|
2321
|
+
setCookie(c, COOKIE_NAME, token, cookieOpts(86400, secure));
|
|
2322
|
+
setCookie(c, REFRESH_COOKIE, refreshToken, cookieOpts(604800, secure));
|
|
2323
|
+
setCookie(c, CSRF_COOKIE, csrf, { ...cookieOpts(86400, secure), httpOnly: false });
|
|
2324
|
+
await db.updateUser(userId, { lastLoginAt: /* @__PURE__ */ new Date() }).catch(() => {
|
|
2325
|
+
});
|
|
2326
|
+
await db.logEvent({
|
|
2327
|
+
actor: userId,
|
|
2328
|
+
actorType: "user",
|
|
2329
|
+
action: "auth.login",
|
|
2330
|
+
resource: `user:${userId}`,
|
|
2331
|
+
details: { method },
|
|
2332
|
+
ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
2333
|
+
}).catch(() => {
|
|
2334
|
+
});
|
|
2335
|
+
return { token, refreshToken, csrf };
|
|
2336
|
+
}
|
|
2337
|
+
async function findOrProvisionSsoUser(provider, subject, email, name, config) {
|
|
2338
|
+
if (config.allowedDomains?.length) {
|
|
2339
|
+
const domain = email.split("@")[1]?.toLowerCase();
|
|
2340
|
+
if (!config.allowedDomains.some((d) => d.toLowerCase() === domain)) {
|
|
2341
|
+
return { error: `Email domain "${domain}" not allowed for SSO login` };
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
let user = await db.getUserBySso(provider, subject);
|
|
2345
|
+
if (user) return { user };
|
|
2346
|
+
user = await db.getUserByEmail(email);
|
|
2347
|
+
if (user) {
|
|
2348
|
+
await db.updateUser(user.id, { ssoProvider: provider, ssoSubject: subject });
|
|
2349
|
+
return { user };
|
|
2350
|
+
}
|
|
2351
|
+
if (!config.autoProvision) {
|
|
2352
|
+
return { error: "No account found. Contact your administrator to create an account." };
|
|
2353
|
+
}
|
|
2354
|
+
const newUser = await db.createUser({
|
|
2355
|
+
email,
|
|
2356
|
+
name: name || email.split("@")[0],
|
|
2357
|
+
role: config.defaultRole || "member",
|
|
2358
|
+
ssoProvider: provider,
|
|
2359
|
+
ssoSubject: subject
|
|
2360
|
+
});
|
|
2361
|
+
return { user: newUser };
|
|
2362
|
+
}
|
|
2363
|
+
async function extractToken(c) {
|
|
2364
|
+
const cookieToken = getCookie(c, COOKIE_NAME);
|
|
2365
|
+
if (cookieToken) return cookieToken;
|
|
2366
|
+
const authHeader = c.req.header("Authorization");
|
|
2367
|
+
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
|
|
2368
|
+
return null;
|
|
2369
|
+
}
|
|
2370
|
+
async function getSsoConfig() {
|
|
2371
|
+
try {
|
|
2372
|
+
const settings = await db.getSettings();
|
|
2373
|
+
return settings?.ssoConfig || null;
|
|
2374
|
+
} catch {
|
|
2375
|
+
return null;
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
function generateTotpSecret() {
|
|
2379
|
+
const bytes = new Uint8Array(20);
|
|
2380
|
+
crypto.getRandomValues(bytes);
|
|
2381
|
+
return base32Encode(bytes);
|
|
2382
|
+
}
|
|
2383
|
+
function base32Encode(bytes) {
|
|
2384
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
2385
|
+
let result = "";
|
|
2386
|
+
let bits = 0;
|
|
2387
|
+
let value = 0;
|
|
2388
|
+
for (const byte of bytes) {
|
|
2389
|
+
value = value << 8 | byte;
|
|
2390
|
+
bits += 8;
|
|
2391
|
+
while (bits >= 5) {
|
|
2392
|
+
result += alphabet[value >>> bits - 5 & 31];
|
|
2393
|
+
bits -= 5;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
if (bits > 0) {
|
|
2397
|
+
result += alphabet[value << 5 - bits & 31];
|
|
2398
|
+
}
|
|
2399
|
+
return result;
|
|
2400
|
+
}
|
|
2401
|
+
function base32Decode(input) {
|
|
2402
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
2403
|
+
const cleaned = input.replace(/[=\s]/g, "").toUpperCase();
|
|
2404
|
+
const bytes = [];
|
|
2405
|
+
let bits = 0;
|
|
2406
|
+
let value = 0;
|
|
2407
|
+
for (const char of cleaned) {
|
|
2408
|
+
const idx = alphabet.indexOf(char);
|
|
2409
|
+
if (idx === -1) continue;
|
|
2410
|
+
value = value << 5 | idx;
|
|
2411
|
+
bits += 5;
|
|
2412
|
+
if (bits >= 8) {
|
|
2413
|
+
bytes.push(value >>> bits - 8 & 255);
|
|
2414
|
+
bits -= 8;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
return new Uint8Array(bytes);
|
|
2418
|
+
}
|
|
2419
|
+
async function generateTotp(secret, timeStep = 30, digits = 6, offsetSteps = 0) {
|
|
2420
|
+
const keyBytes = base32Decode(secret);
|
|
2421
|
+
const time = Math.floor(Date.now() / 1e3 / timeStep) + offsetSteps;
|
|
2422
|
+
const timeBytes = new Uint8Array(8);
|
|
2423
|
+
let t = time;
|
|
2424
|
+
for (let i = 7; i >= 0; i--) {
|
|
2425
|
+
timeBytes[i] = t & 255;
|
|
2426
|
+
t = Math.floor(t / 256);
|
|
2427
|
+
}
|
|
2428
|
+
const key = await crypto.subtle.importKey("raw", keyBytes.buffer, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
|
|
2429
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, timeBytes));
|
|
2430
|
+
const offset = sig[sig.length - 1] & 15;
|
|
2431
|
+
const code = ((sig[offset] & 127) << 24 | sig[offset + 1] << 16 | sig[offset + 2] << 8 | sig[offset + 3]) % 10 ** digits;
|
|
2432
|
+
return String(code).padStart(digits, "0");
|
|
2433
|
+
}
|
|
2434
|
+
async function verifyTotp(secret, token) {
|
|
2435
|
+
for (const offset of [0, -1, 1]) {
|
|
2436
|
+
const expected = await generateTotp(secret, 30, 6, offset);
|
|
2437
|
+
if (expected === token) return true;
|
|
2438
|
+
}
|
|
2439
|
+
return false;
|
|
2440
|
+
}
|
|
2441
|
+
function generateBackupCodes(count = 8) {
|
|
2442
|
+
const codes = [];
|
|
2443
|
+
for (let i = 0; i < count; i++) {
|
|
2444
|
+
const bytes = new Uint8Array(4);
|
|
2445
|
+
crypto.getRandomValues(bytes);
|
|
2446
|
+
codes.push(Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase());
|
|
2447
|
+
}
|
|
2448
|
+
return codes;
|
|
2449
|
+
}
|
|
2450
|
+
const pending2fa = /* @__PURE__ */ new Map();
|
|
2451
|
+
setInterval(() => {
|
|
2452
|
+
const now = Date.now();
|
|
2453
|
+
for (const [k, v] of pending2fa) {
|
|
2454
|
+
if (v.expiresAt < now) pending2fa.delete(k);
|
|
2455
|
+
}
|
|
2456
|
+
}, 6e4);
|
|
2457
|
+
auth.post("/login", async (c) => {
|
|
2458
|
+
const { email, password } = await c.req.json();
|
|
2459
|
+
if (!email || !password) {
|
|
2460
|
+
return c.json({ error: "Email and password required" }, 400);
|
|
2461
|
+
}
|
|
2462
|
+
const user = await db.getUserByEmail(email);
|
|
2463
|
+
if (!user || !user.passwordHash) {
|
|
2464
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
2465
|
+
}
|
|
2466
|
+
const { default: bcrypt } = await import("bcryptjs");
|
|
2467
|
+
const valid = await bcrypt.compare(password, user.passwordHash);
|
|
2468
|
+
if (!valid) {
|
|
2469
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
2470
|
+
}
|
|
2471
|
+
if (user.totpEnabled && user.totpSecret) {
|
|
2472
|
+
const challengeToken = generateCsrf();
|
|
2473
|
+
pending2fa.set(challengeToken, { userId: user.id, expiresAt: Date.now() + 5 * 60 * 1e3 });
|
|
2474
|
+
return c.json({
|
|
2475
|
+
requires2fa: true,
|
|
2476
|
+
challengeToken,
|
|
2477
|
+
message: "Enter your 2FA code to continue"
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
const { token, refreshToken, csrf } = await setSessionCookies(c, user.id, user.email, user.role, "password");
|
|
2481
|
+
return c.json({
|
|
2482
|
+
token,
|
|
2483
|
+
refreshToken,
|
|
2484
|
+
csrf,
|
|
2485
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role }
|
|
2486
|
+
});
|
|
2487
|
+
});
|
|
2488
|
+
auth.post("/2fa/verify", async (c) => {
|
|
2489
|
+
const { challengeToken, code } = await c.req.json();
|
|
2490
|
+
if (!challengeToken || !code) {
|
|
2491
|
+
return c.json({ error: "Challenge token and code required" }, 400);
|
|
2492
|
+
}
|
|
2493
|
+
const challenge = pending2fa.get(challengeToken);
|
|
2494
|
+
if (!challenge || challenge.expiresAt < Date.now()) {
|
|
2495
|
+
pending2fa.delete(challengeToken);
|
|
2496
|
+
return c.json({ error: "Challenge expired. Please login again." }, 401);
|
|
2497
|
+
}
|
|
2498
|
+
const user = await db.getUser(challenge.userId);
|
|
2499
|
+
if (!user || !user.totpSecret) {
|
|
2500
|
+
pending2fa.delete(challengeToken);
|
|
2501
|
+
return c.json({ error: "User not found" }, 401);
|
|
2502
|
+
}
|
|
2503
|
+
const totpValid = await verifyTotp(user.totpSecret, code.replace(/\s/g, ""));
|
|
2504
|
+
let backupUsed = false;
|
|
2505
|
+
if (!totpValid && user.totpBackupCodes) {
|
|
2506
|
+
try {
|
|
2507
|
+
const { default: bcrypt } = await import("bcryptjs");
|
|
2508
|
+
const backupCodes = JSON.parse(user.totpBackupCodes);
|
|
2509
|
+
for (let i = 0; i < backupCodes.length; i++) {
|
|
2510
|
+
const match = await bcrypt.compare(code.toUpperCase().replace(/\s/g, ""), backupCodes[i]);
|
|
2511
|
+
if (match) {
|
|
2512
|
+
backupCodes.splice(i, 1);
|
|
2513
|
+
await db.updateUser(user.id, { totpBackupCodes: JSON.stringify(backupCodes) });
|
|
2514
|
+
backupUsed = true;
|
|
2515
|
+
break;
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
} catch {
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
if (!totpValid && !backupUsed) {
|
|
2522
|
+
return c.json({ error: "Invalid 2FA code" }, 401);
|
|
2523
|
+
}
|
|
2524
|
+
pending2fa.delete(challengeToken);
|
|
2525
|
+
const { token, refreshToken, csrf } = await setSessionCookies(c, user.id, user.email, user.role, "password+2fa");
|
|
2526
|
+
return c.json({
|
|
2527
|
+
token,
|
|
2528
|
+
refreshToken,
|
|
2529
|
+
csrf,
|
|
2530
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
2531
|
+
...backupUsed ? { warning: "Backup code used. You have fewer backup codes remaining." } : {}
|
|
2532
|
+
});
|
|
2533
|
+
});
|
|
2534
|
+
auth.post("/2fa/setup", async (c) => {
|
|
2535
|
+
const token = await extractToken(c);
|
|
2536
|
+
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
2537
|
+
let userId;
|
|
2538
|
+
try {
|
|
2539
|
+
const { jwtVerify } = await import("jose");
|
|
2540
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
2541
|
+
const { payload } = await jwtVerify(token, secret);
|
|
2542
|
+
userId = payload.sub;
|
|
2543
|
+
} catch {
|
|
2544
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
2545
|
+
}
|
|
2546
|
+
const user = await db.getUser(userId);
|
|
2547
|
+
if (!user) return c.json({ error: "User not found" }, 404);
|
|
2548
|
+
if (user.totpEnabled) {
|
|
2549
|
+
return c.json({ error: "2FA is already enabled. Disable it first to re-enroll." }, 400);
|
|
2550
|
+
}
|
|
2551
|
+
const totpSecret = generateTotpSecret();
|
|
2552
|
+
const settings = await db.getSettings();
|
|
2553
|
+
const issuer = settings?.name || "AgenticMail Enterprise";
|
|
2554
|
+
const otpauthUrl = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user.email)}?secret=${totpSecret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
|
2555
|
+
await db.updateUser(userId, { totpSecret });
|
|
2556
|
+
return c.json({
|
|
2557
|
+
secret: totpSecret,
|
|
2558
|
+
otpauthUrl,
|
|
2559
|
+
qrData: otpauthUrl
|
|
2560
|
+
// Frontend can render QR from this
|
|
2561
|
+
});
|
|
2562
|
+
});
|
|
2563
|
+
auth.post("/2fa/confirm", async (c) => {
|
|
2564
|
+
const token = await extractToken(c);
|
|
2565
|
+
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
2566
|
+
let userId;
|
|
2567
|
+
try {
|
|
2568
|
+
const { jwtVerify } = await import("jose");
|
|
2569
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
2570
|
+
const { payload } = await jwtVerify(token, secret);
|
|
2571
|
+
userId = payload.sub;
|
|
2572
|
+
} catch {
|
|
2573
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
2574
|
+
}
|
|
2575
|
+
const { code } = await c.req.json();
|
|
2576
|
+
if (!code) return c.json({ error: "Verification code required" }, 400);
|
|
2577
|
+
const user = await db.getUser(userId);
|
|
2578
|
+
if (!user || !user.totpSecret) return c.json({ error: "No 2FA setup in progress" }, 400);
|
|
2579
|
+
if (user.totpEnabled) return c.json({ error: "2FA is already enabled" }, 400);
|
|
2580
|
+
const valid = await verifyTotp(user.totpSecret, code.replace(/\s/g, ""));
|
|
2581
|
+
if (!valid) return c.json({ error: "Invalid code. Make sure your authenticator app time is synced." }, 400);
|
|
2582
|
+
const plainBackupCodes = generateBackupCodes(8);
|
|
2583
|
+
const { default: bcrypt } = await import("bcryptjs");
|
|
2584
|
+
const hashedCodes = await Promise.all(plainBackupCodes.map((c2) => bcrypt.hash(c2, 10)));
|
|
2585
|
+
await db.updateUser(userId, {
|
|
2586
|
+
totpEnabled: true,
|
|
2587
|
+
totpBackupCodes: JSON.stringify(hashedCodes)
|
|
2588
|
+
});
|
|
2589
|
+
return c.json({
|
|
2590
|
+
enabled: true,
|
|
2591
|
+
backupCodes: plainBackupCodes,
|
|
2592
|
+
warning: "Save these backup codes securely. They will not be shown again."
|
|
2593
|
+
});
|
|
2594
|
+
});
|
|
2595
|
+
auth.post("/2fa/disable", async (c) => {
|
|
2596
|
+
const token = await extractToken(c);
|
|
2597
|
+
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
2598
|
+
let userId;
|
|
2599
|
+
try {
|
|
2600
|
+
const { jwtVerify } = await import("jose");
|
|
2601
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
2602
|
+
const { payload } = await jwtVerify(token, secret);
|
|
2603
|
+
userId = payload.sub;
|
|
2604
|
+
} catch {
|
|
2605
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
2606
|
+
}
|
|
2607
|
+
const { password } = await c.req.json();
|
|
2608
|
+
if (!password) return c.json({ error: "Password required to disable 2FA" }, 400);
|
|
2609
|
+
const user = await db.getUser(userId);
|
|
2610
|
+
if (!user || !user.passwordHash) return c.json({ error: "User not found" }, 404);
|
|
2611
|
+
const { default: bcrypt } = await import("bcryptjs");
|
|
2612
|
+
const valid = await bcrypt.compare(password, user.passwordHash);
|
|
2613
|
+
if (!valid) return c.json({ error: "Invalid password" }, 401);
|
|
2614
|
+
await db.updateUser(userId, {
|
|
2615
|
+
totpEnabled: false,
|
|
2616
|
+
totpSecret: void 0,
|
|
2617
|
+
totpBackupCodes: void 0
|
|
2618
|
+
});
|
|
2619
|
+
return c.json({ disabled: true });
|
|
2620
|
+
});
|
|
2621
|
+
auth.get("/2fa/status", async (c) => {
|
|
2622
|
+
const token = await extractToken(c);
|
|
2623
|
+
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
2624
|
+
try {
|
|
2625
|
+
const { jwtVerify } = await import("jose");
|
|
2626
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
2627
|
+
const { payload } = await jwtVerify(token, secret);
|
|
2628
|
+
const user = await db.getUser(payload.sub);
|
|
2629
|
+
if (!user) return c.json({ error: "User not found" }, 404);
|
|
2630
|
+
return c.json({ enabled: !!user.totpEnabled });
|
|
2631
|
+
} catch {
|
|
2632
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
auth.post("/login/api-key", async (c) => {
|
|
2636
|
+
const { apiKey } = await c.req.json();
|
|
2637
|
+
if (!apiKey) return c.json({ error: "API key required" }, 400);
|
|
2638
|
+
const key = await db.validateApiKey(apiKey);
|
|
2639
|
+
if (!key) return c.json({ error: "Invalid or revoked API key" }, 401);
|
|
2640
|
+
const user = await db.getUser(key.createdBy);
|
|
2641
|
+
if (!user) return c.json({ error: "API key owner not found" }, 401);
|
|
2642
|
+
const { token, refreshToken, csrf } = await setSessionCookies(c, user.id, user.email, user.role, "api-key");
|
|
2643
|
+
return c.json({
|
|
2644
|
+
token,
|
|
2645
|
+
refreshToken,
|
|
2646
|
+
csrf,
|
|
2647
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
2648
|
+
keyName: key.name
|
|
2649
|
+
});
|
|
2650
|
+
});
|
|
2651
|
+
auth.post("/refresh", async (c) => {
|
|
2652
|
+
const refreshJwt = getCookie(c, REFRESH_COOKIE) || c.req.header("Authorization")?.slice(7);
|
|
2653
|
+
if (!refreshJwt) {
|
|
2654
|
+
return c.json({ error: "Refresh token required" }, 401);
|
|
2655
|
+
}
|
|
2656
|
+
try {
|
|
2657
|
+
const { jwtVerify } = await import("jose");
|
|
2658
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
2659
|
+
const { payload } = await jwtVerify(refreshJwt, secret);
|
|
2660
|
+
if (payload.type !== "refresh") return c.json({ error: "Invalid token type" }, 401);
|
|
2661
|
+
const user = await db.getUser(payload.sub);
|
|
2662
|
+
if (!user) return c.json({ error: "User not found" }, 401);
|
|
2663
|
+
const { token, refreshToken } = await issueTokens(user.id, user.email, user.role);
|
|
2664
|
+
const csrf = generateCsrf();
|
|
2665
|
+
const secure = isSecure();
|
|
2666
|
+
setCookie(c, COOKIE_NAME, token, cookieOpts(86400, secure));
|
|
2667
|
+
setCookie(c, REFRESH_COOKIE, refreshToken, cookieOpts(604800, secure));
|
|
2668
|
+
setCookie(c, CSRF_COOKIE, csrf, { ...cookieOpts(86400, secure), httpOnly: false });
|
|
2669
|
+
return c.json({ token, csrf });
|
|
2670
|
+
} catch {
|
|
2671
|
+
return c.json({ error: "Invalid or expired refresh token" }, 401);
|
|
2672
|
+
}
|
|
2673
|
+
});
|
|
2674
|
+
auth.get("/me", async (c) => {
|
|
2675
|
+
const token = await extractToken(c);
|
|
2676
|
+
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
2677
|
+
try {
|
|
2678
|
+
const { jwtVerify } = await import("jose");
|
|
2679
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
2680
|
+
const { payload } = await jwtVerify(token, secret);
|
|
2681
|
+
const user = await db.getUser(payload.sub);
|
|
2682
|
+
if (!user) return c.json({ error: "User not found" }, 404);
|
|
2683
|
+
const { passwordHash, ...safe } = user;
|
|
2684
|
+
return c.json(safe);
|
|
2685
|
+
} catch {
|
|
2686
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
2687
|
+
}
|
|
2688
|
+
});
|
|
2689
|
+
auth.post("/logout", (c) => {
|
|
2690
|
+
deleteCookie(c, COOKIE_NAME, { path: "/" });
|
|
2691
|
+
deleteCookie(c, REFRESH_COOKIE, { path: "/" });
|
|
2692
|
+
deleteCookie(c, CSRF_COOKIE, { path: "/" });
|
|
2693
|
+
return c.json({ ok: true });
|
|
2694
|
+
});
|
|
2695
|
+
auth.get("/sso/providers", async (c) => {
|
|
2696
|
+
const sso = await getSsoConfig();
|
|
2697
|
+
const providers = [];
|
|
2698
|
+
if (sso?.saml?.entityId && sso?.saml?.ssoUrl) {
|
|
2699
|
+
providers.push({ type: "saml", name: "SAML SSO", url: "/auth/saml/login" });
|
|
2700
|
+
}
|
|
2701
|
+
if (sso?.oidc?.clientId && sso?.oidc?.discoveryUrl) {
|
|
2702
|
+
providers.push({ type: "oidc", name: "OpenID Connect", url: "/auth/oidc/authorize" });
|
|
2703
|
+
}
|
|
2704
|
+
return c.json({ providers, ssoEnabled: providers.length > 0 });
|
|
2705
|
+
});
|
|
2706
|
+
auth.get("/setup-status", async (c) => {
|
|
2707
|
+
try {
|
|
2708
|
+
const stats = await db.getStats();
|
|
2709
|
+
const settings = await db.getSettings();
|
|
2710
|
+
const hasUsers = stats.totalUsers > 0;
|
|
2711
|
+
const hasCompanyName = !!(settings?.name && settings.name !== "" && settings.name !== "My Company");
|
|
2712
|
+
const hasAgents = stats.totalAgents > 0;
|
|
2713
|
+
return c.json({
|
|
2714
|
+
setupComplete: hasUsers,
|
|
2715
|
+
needsBootstrap: !hasUsers,
|
|
2716
|
+
checklist: {
|
|
2717
|
+
adminCreated: hasUsers,
|
|
2718
|
+
companyConfigured: hasCompanyName,
|
|
2719
|
+
agentCreated: hasAgents
|
|
2720
|
+
}
|
|
2721
|
+
});
|
|
2722
|
+
} catch {
|
|
2723
|
+
return c.json({ setupComplete: false, needsBootstrap: true, checklist: { adminCreated: false, companyConfigured: false, emailConfigured: false, agentCreated: false } });
|
|
2724
|
+
}
|
|
2725
|
+
});
|
|
2726
|
+
auth.post("/test-db", async (c) => {
|
|
2727
|
+
const stats = await db.getStats();
|
|
2728
|
+
if (stats.totalUsers > 0) {
|
|
2729
|
+
return c.json({ error: "Setup already complete. Database configuration is disabled." }, 403);
|
|
2730
|
+
}
|
|
2731
|
+
const body = await c.req.json();
|
|
2732
|
+
if (!body.type) {
|
|
2733
|
+
return c.json({ error: "Database type is required" }, 400);
|
|
2734
|
+
}
|
|
2735
|
+
try {
|
|
2736
|
+
const { createAdapter } = await import("./factory-672W7A5B.js");
|
|
2737
|
+
const testAdapter = await createAdapter(body);
|
|
2738
|
+
await testAdapter.getStats();
|
|
2739
|
+
await testAdapter.disconnect();
|
|
2740
|
+
return c.json({ success: true });
|
|
2741
|
+
} catch (err) {
|
|
2742
|
+
return c.json({ success: false, error: err.message || "Connection failed" }, 400);
|
|
2743
|
+
}
|
|
2744
|
+
});
|
|
2745
|
+
auth.post("/configure-db", async (c) => {
|
|
2746
|
+
const stats = await db.getStats();
|
|
2747
|
+
if (stats.totalUsers > 0) {
|
|
2748
|
+
return c.json({ error: "Setup already complete. Database configuration is disabled." }, 403);
|
|
2749
|
+
}
|
|
2750
|
+
if (!opts?.onDbConfigure) {
|
|
2751
|
+
return c.json({ error: "Database hot-swap not available" }, 501);
|
|
2752
|
+
}
|
|
2753
|
+
const body = await c.req.json();
|
|
2754
|
+
if (!body.type) {
|
|
2755
|
+
return c.json({ error: "Database type is required" }, 400);
|
|
2756
|
+
}
|
|
2757
|
+
try {
|
|
2758
|
+
const { createAdapter } = await import("./factory-672W7A5B.js");
|
|
2759
|
+
const newAdapter = await createAdapter(body);
|
|
2760
|
+
await newAdapter.migrate();
|
|
2761
|
+
const oldAdapter = opts.onDbConfigure(newAdapter);
|
|
2762
|
+
try {
|
|
2763
|
+
await oldAdapter.disconnect();
|
|
2764
|
+
} catch {
|
|
2765
|
+
}
|
|
2766
|
+
try {
|
|
2767
|
+
const { saveDbConfig } = await import("./config-store-CRMKWBON.js");
|
|
2768
|
+
await saveDbConfig(body, jwtSecret);
|
|
2769
|
+
} catch {
|
|
2770
|
+
}
|
|
2771
|
+
return c.json({ success: true, type: body.type });
|
|
2772
|
+
} catch (err) {
|
|
2773
|
+
return c.json({ success: false, error: err.message || "Configuration failed" }, 400);
|
|
2774
|
+
}
|
|
2775
|
+
});
|
|
2776
|
+
const bootstrapAttempts = /* @__PURE__ */ new Map();
|
|
2777
|
+
auth.post("/bootstrap", async (c) => {
|
|
2778
|
+
const clientIp = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "unknown";
|
|
2779
|
+
const now = Date.now();
|
|
2780
|
+
const attempt = bootstrapAttempts.get(clientIp);
|
|
2781
|
+
if (attempt && attempt.resetAt > now && attempt.count >= 5) {
|
|
2782
|
+
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
|
2783
|
+
}
|
|
2784
|
+
if (!attempt || attempt.resetAt <= now) {
|
|
2785
|
+
bootstrapAttempts.set(clientIp, { count: 1, resetAt: now + 6e4 });
|
|
2786
|
+
} else {
|
|
2787
|
+
attempt.count++;
|
|
2788
|
+
}
|
|
2789
|
+
const stats = await db.getStats();
|
|
2790
|
+
if (stats.totalUsers > 0) {
|
|
2791
|
+
return c.json({ error: "Setup already complete. Bootstrap is disabled." }, 403);
|
|
2792
|
+
}
|
|
2793
|
+
const { name, email, password, companyName, subdomain } = await c.req.json();
|
|
2794
|
+
if (!email || !password || !name) {
|
|
2795
|
+
return c.json({ error: "Name, email, and password are required" }, 400);
|
|
2796
|
+
}
|
|
2797
|
+
if (password.length < 8) {
|
|
2798
|
+
return c.json({ error: "Password must be at least 8 characters" }, 400);
|
|
2799
|
+
}
|
|
2800
|
+
if (!email.includes("@") || !email.includes(".")) {
|
|
2801
|
+
return c.json({ error: "Invalid email address" }, 400);
|
|
2802
|
+
}
|
|
2803
|
+
try {
|
|
2804
|
+
const user = await db.createUser({
|
|
2805
|
+
email,
|
|
2806
|
+
name,
|
|
2807
|
+
role: "owner",
|
|
2808
|
+
password
|
|
2809
|
+
});
|
|
2810
|
+
if (companyName || subdomain) {
|
|
2811
|
+
const updates = {};
|
|
2812
|
+
if (companyName) updates.name = companyName;
|
|
2813
|
+
if (subdomain) {
|
|
2814
|
+
updates.subdomain = subdomain.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 63);
|
|
2815
|
+
}
|
|
2816
|
+
await db.updateSettings(updates);
|
|
2817
|
+
}
|
|
2818
|
+
await db.logEvent({
|
|
2819
|
+
actor: user.id,
|
|
2820
|
+
actorType: "system",
|
|
2821
|
+
action: "setup.bootstrap",
|
|
2822
|
+
resource: `user:${user.id}`,
|
|
2823
|
+
details: { method: "web-wizard", companyName },
|
|
2824
|
+
ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
2825
|
+
});
|
|
2826
|
+
const { token, refreshToken, csrf } = await setSessionCookies(c, user.id, user.email, user.role, "bootstrap");
|
|
2827
|
+
opts?.onBootstrap?.();
|
|
2828
|
+
(async () => {
|
|
2829
|
+
try {
|
|
2830
|
+
const { execSync } = await import("child_process");
|
|
2831
|
+
const cwd = process.cwd();
|
|
2832
|
+
const deps = ["@anthropic-ai/sdk", "openai", "elevenlabs"];
|
|
2833
|
+
const missing = deps.filter((d) => {
|
|
2834
|
+
try {
|
|
2835
|
+
__require.resolve(d);
|
|
2836
|
+
return false;
|
|
2837
|
+
} catch {
|
|
2838
|
+
return true;
|
|
2839
|
+
}
|
|
2840
|
+
});
|
|
2841
|
+
if (missing.length > 0) {
|
|
2842
|
+
console.log(`[setup] Auto-installing SDKs: ${missing.join(", ")}...`);
|
|
2843
|
+
execSync(`npm install --no-save ${missing.join(" ")}`, { cwd, timeout: 12e4, stdio: "pipe" });
|
|
2844
|
+
console.log(`[setup] \u2705 SDKs installed: ${missing.join(", ")}`);
|
|
2845
|
+
}
|
|
2846
|
+
} catch (e) {
|
|
2847
|
+
console.error(`[setup] SDK install failed (non-fatal): ${e.message}`);
|
|
2848
|
+
}
|
|
2849
|
+
})();
|
|
2850
|
+
return c.json({
|
|
2851
|
+
token,
|
|
2852
|
+
refreshToken,
|
|
2853
|
+
csrf,
|
|
2854
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role }
|
|
2855
|
+
});
|
|
2856
|
+
} catch (err) {
|
|
2857
|
+
return c.json({ error: err.message || "Bootstrap failed" }, 500);
|
|
2858
|
+
}
|
|
2859
|
+
});
|
|
2860
|
+
auth.get("/oidc/authorize", async (c) => {
|
|
2861
|
+
const sso = await getSsoConfig();
|
|
2862
|
+
if (!sso?.oidc?.clientId || !sso?.oidc?.discoveryUrl) {
|
|
2863
|
+
return c.json({ error: "OIDC not configured. Set up OIDC in Settings > SSO." }, 400);
|
|
2864
|
+
}
|
|
2865
|
+
const oidc = sso.oidc;
|
|
2866
|
+
let discovery;
|
|
2867
|
+
try {
|
|
2868
|
+
const res = await fetch(oidc.discoveryUrl);
|
|
2869
|
+
if (!res.ok) throw new Error(`Discovery fetch failed: ${res.status}`);
|
|
2870
|
+
discovery = await res.json();
|
|
2871
|
+
} catch (e) {
|
|
2872
|
+
return c.json({ error: `Failed to fetch OIDC discovery: ${e.message}` }, 502);
|
|
2873
|
+
}
|
|
2874
|
+
if (!discovery.authorization_endpoint) {
|
|
2875
|
+
return c.json({ error: "Invalid OIDC discovery: missing authorization_endpoint" }, 502);
|
|
2876
|
+
}
|
|
2877
|
+
const state = generateState();
|
|
2878
|
+
const nonce = generateState();
|
|
2879
|
+
const codeVerifier = generateCodeVerifier();
|
|
2880
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
2881
|
+
const protocol = c.req.header("x-forwarded-proto") || "http";
|
|
2882
|
+
const host = c.req.header("host") || "localhost";
|
|
2883
|
+
const redirectUri = `${protocol}://${host}/auth/oidc/callback`;
|
|
2884
|
+
const { SignJWT } = await import("jose");
|
|
2885
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
2886
|
+
const stateToken = await new SignJWT({
|
|
2887
|
+
state,
|
|
2888
|
+
nonce,
|
|
2889
|
+
codeVerifier,
|
|
2890
|
+
redirectUri,
|
|
2891
|
+
discoveryUrl: oidc.discoveryUrl
|
|
2892
|
+
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("10m").sign(secret);
|
|
2893
|
+
setCookie(c, "em_oidc_state", stateToken, {
|
|
2894
|
+
httpOnly: true,
|
|
2895
|
+
secure: isSecure(),
|
|
2896
|
+
sameSite: "Lax",
|
|
2897
|
+
path: "/auth/oidc",
|
|
2898
|
+
maxAge: 600
|
|
2899
|
+
});
|
|
2900
|
+
const scopes = oidc.scopes?.join(" ") || "openid email profile";
|
|
2901
|
+
const authUrl = new URL(discovery.authorization_endpoint);
|
|
2902
|
+
authUrl.searchParams.set("client_id", oidc.clientId);
|
|
2903
|
+
authUrl.searchParams.set("response_type", "code");
|
|
2904
|
+
authUrl.searchParams.set("scope", scopes);
|
|
2905
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
2906
|
+
authUrl.searchParams.set("state", state);
|
|
2907
|
+
authUrl.searchParams.set("nonce", nonce);
|
|
2908
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
2909
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
2910
|
+
return c.redirect(authUrl.toString());
|
|
2911
|
+
});
|
|
2912
|
+
auth.get("/oidc/callback", async (c) => {
|
|
2913
|
+
const code = c.req.query("code");
|
|
2914
|
+
const returnedState = c.req.query("state");
|
|
2915
|
+
const error = c.req.query("error");
|
|
2916
|
+
const errorDesc = c.req.query("error_description");
|
|
2917
|
+
if (error) {
|
|
2918
|
+
return c.html(ssoErrorPage("OIDC Error", errorDesc || error));
|
|
2919
|
+
}
|
|
2920
|
+
if (!code || !returnedState) {
|
|
2921
|
+
return c.html(ssoErrorPage("OIDC Error", "Missing code or state parameter"));
|
|
2922
|
+
}
|
|
2923
|
+
const stateCookie = getCookie(c, "em_oidc_state");
|
|
2924
|
+
if (!stateCookie) {
|
|
2925
|
+
return c.html(ssoErrorPage("OIDC Error", "Session expired. Please try again."));
|
|
2926
|
+
}
|
|
2927
|
+
deleteCookie(c, "em_oidc_state", { path: "/auth/oidc" });
|
|
2928
|
+
let statePayload;
|
|
2929
|
+
try {
|
|
2930
|
+
const { jwtVerify } = await import("jose");
|
|
2931
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
2932
|
+
const { payload } = await jwtVerify(stateCookie, secret);
|
|
2933
|
+
statePayload = payload;
|
|
2934
|
+
} catch {
|
|
2935
|
+
return c.html(ssoErrorPage("OIDC Error", "Invalid or expired state. Please try again."));
|
|
2936
|
+
}
|
|
2937
|
+
if (statePayload.state !== returnedState) {
|
|
2938
|
+
return c.html(ssoErrorPage("OIDC Error", "State mismatch. Possible CSRF attack."));
|
|
2939
|
+
}
|
|
2940
|
+
const sso = await getSsoConfig();
|
|
2941
|
+
if (!sso?.oidc) {
|
|
2942
|
+
return c.html(ssoErrorPage("OIDC Error", "OIDC is no longer configured."));
|
|
2943
|
+
}
|
|
2944
|
+
const oidc = sso.oidc;
|
|
2945
|
+
let discovery;
|
|
2946
|
+
try {
|
|
2947
|
+
const res = await fetch(oidc.discoveryUrl);
|
|
2948
|
+
discovery = await res.json();
|
|
2949
|
+
} catch (e) {
|
|
2950
|
+
return c.html(ssoErrorPage("OIDC Error", `Discovery fetch failed: ${e.message}`));
|
|
2951
|
+
}
|
|
2952
|
+
let tokenResponse;
|
|
2953
|
+
try {
|
|
2954
|
+
const tokenRes = await fetch(discovery.token_endpoint, {
|
|
2955
|
+
method: "POST",
|
|
2956
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
2957
|
+
body: new URLSearchParams({
|
|
2958
|
+
grant_type: "authorization_code",
|
|
2959
|
+
code,
|
|
2960
|
+
redirect_uri: statePayload.redirectUri,
|
|
2961
|
+
client_id: oidc.clientId,
|
|
2962
|
+
client_secret: oidc.clientSecret,
|
|
2963
|
+
code_verifier: statePayload.codeVerifier
|
|
2964
|
+
}).toString()
|
|
2965
|
+
});
|
|
2966
|
+
if (!tokenRes.ok) {
|
|
2967
|
+
const errBody = await tokenRes.text();
|
|
2968
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${errBody}`);
|
|
2969
|
+
}
|
|
2970
|
+
tokenResponse = await tokenRes.json();
|
|
2971
|
+
} catch (e) {
|
|
2972
|
+
return c.html(ssoErrorPage("OIDC Error", e.message));
|
|
2973
|
+
}
|
|
2974
|
+
let email;
|
|
2975
|
+
let name;
|
|
2976
|
+
let sub;
|
|
2977
|
+
if (tokenResponse.id_token) {
|
|
2978
|
+
const parts = tokenResponse.id_token.split(".");
|
|
2979
|
+
if (parts.length !== 3) {
|
|
2980
|
+
return c.html(ssoErrorPage("OIDC Error", "Invalid id_token format"));
|
|
2981
|
+
}
|
|
2982
|
+
try {
|
|
2983
|
+
const { jwtVerify, createRemoteJWKSet } = await import("jose");
|
|
2984
|
+
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));
|
|
2985
|
+
const { payload } = await jwtVerify(tokenResponse.id_token, jwks, {
|
|
2986
|
+
issuer: discovery.issuer,
|
|
2987
|
+
audience: oidc.clientId
|
|
2988
|
+
});
|
|
2989
|
+
if (payload.nonce !== statePayload.nonce) {
|
|
2990
|
+
return c.html(ssoErrorPage("OIDC Error", "Nonce mismatch. Possible replay attack."));
|
|
2991
|
+
}
|
|
2992
|
+
sub = payload.sub;
|
|
2993
|
+
email = payload.email || "";
|
|
2994
|
+
name = payload.name || payload.preferred_username || "";
|
|
2995
|
+
} catch (e) {
|
|
2996
|
+
return c.html(ssoErrorPage("OIDC Error", `ID token verification failed: ${e.message}`));
|
|
2997
|
+
}
|
|
2998
|
+
} else if (discovery.userinfo_endpoint) {
|
|
2999
|
+
try {
|
|
3000
|
+
const uiRes = await fetch(discovery.userinfo_endpoint, {
|
|
3001
|
+
headers: { Authorization: `Bearer ${tokenResponse.access_token}` }
|
|
3002
|
+
});
|
|
3003
|
+
const userinfo = await uiRes.json();
|
|
3004
|
+
sub = userinfo.sub;
|
|
3005
|
+
email = userinfo.email || "";
|
|
3006
|
+
name = userinfo.name || userinfo.preferred_username || "";
|
|
3007
|
+
} catch (e) {
|
|
3008
|
+
return c.html(ssoErrorPage("OIDC Error", `Userinfo fetch failed: ${e.message}`));
|
|
3009
|
+
}
|
|
3010
|
+
} else {
|
|
3011
|
+
return c.html(ssoErrorPage("OIDC Error", "No id_token or userinfo endpoint available"));
|
|
3012
|
+
}
|
|
3013
|
+
if (!email) {
|
|
3014
|
+
return c.html(ssoErrorPage("OIDC Error", 'No email claim in the token. Ensure "email" scope is granted.'));
|
|
3015
|
+
}
|
|
3016
|
+
const result = await findOrProvisionSsoUser("oidc", sub, email, name, oidc);
|
|
3017
|
+
if ("error" in result) {
|
|
3018
|
+
return c.html(ssoErrorPage("OIDC Error", result.error ?? "Unknown error"));
|
|
3019
|
+
}
|
|
3020
|
+
await setSessionCookies(c, result.user.id, result.user.email, result.user.role, "oidc");
|
|
3021
|
+
return c.redirect("/dashboard");
|
|
3022
|
+
});
|
|
3023
|
+
auth.get("/saml/login", async (c) => {
|
|
3024
|
+
const sso = await getSsoConfig();
|
|
3025
|
+
if (!sso?.saml?.ssoUrl || !sso?.saml?.entityId) {
|
|
3026
|
+
return c.json({ error: "SAML not configured. Set up SAML in Settings > SSO." }, 400);
|
|
3027
|
+
}
|
|
3028
|
+
const saml = sso.saml;
|
|
3029
|
+
const protocol = c.req.header("x-forwarded-proto") || "http";
|
|
3030
|
+
const host = c.req.header("host") || "localhost";
|
|
3031
|
+
const acsUrl = `${protocol}://${host}/auth/saml/callback`;
|
|
3032
|
+
const requestId2 = "_" + crypto.randomUUID().replace(/-/g, "");
|
|
3033
|
+
const issueInstant = (/* @__PURE__ */ new Date()).toISOString();
|
|
3034
|
+
const authnRequest = `<samlp:AuthnRequest
|
|
3035
|
+
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
|
3036
|
+
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
|
3037
|
+
ID="${requestId2}"
|
|
3038
|
+
Version="2.0"
|
|
3039
|
+
IssueInstant="${issueInstant}"
|
|
3040
|
+
Destination="${saml.ssoUrl}"
|
|
3041
|
+
AssertionConsumerServiceURL="${acsUrl}"
|
|
3042
|
+
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
|
|
3043
|
+
<saml:Issuer>${saml.entityId}</saml:Issuer>
|
|
3044
|
+
<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
|
|
3045
|
+
</samlp:AuthnRequest>`;
|
|
3046
|
+
const { deflateRawSync } = await import("zlib");
|
|
3047
|
+
const deflated = deflateRawSync(Buffer.from(authnRequest, "utf-8"));
|
|
3048
|
+
const encoded = deflated.toString("base64");
|
|
3049
|
+
const redirectUrl = new URL(saml.ssoUrl);
|
|
3050
|
+
redirectUrl.searchParams.set("SAMLRequest", encoded);
|
|
3051
|
+
redirectUrl.searchParams.set("RelayState", "/dashboard");
|
|
3052
|
+
return c.redirect(redirectUrl.toString());
|
|
3053
|
+
});
|
|
3054
|
+
auth.get("/saml/metadata", async (c) => {
|
|
3055
|
+
const sso = await getSsoConfig();
|
|
3056
|
+
const entityId = sso?.saml?.entityId || "agenticmail-enterprise";
|
|
3057
|
+
const protocol = c.req.header("x-forwarded-proto") || "http";
|
|
3058
|
+
const host = c.req.header("host") || "localhost";
|
|
3059
|
+
const acsUrl = `${protocol}://${host}/auth/saml/callback`;
|
|
3060
|
+
const sloUrl = `${protocol}://${host}/auth/saml/logout`;
|
|
3061
|
+
const metadata = `<?xml version="1.0" encoding="UTF-8"?>
|
|
3062
|
+
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
|
|
3063
|
+
entityID="${entityId}">
|
|
3064
|
+
<md:SPSSODescriptor
|
|
3065
|
+
AuthnRequestsSigned="false"
|
|
3066
|
+
WantAssertionsSigned="true"
|
|
3067
|
+
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
3068
|
+
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
|
3069
|
+
<md:AssertionConsumerService
|
|
3070
|
+
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
|
3071
|
+
Location="${acsUrl}"
|
|
3072
|
+
index="0"
|
|
3073
|
+
isDefault="true"/>
|
|
3074
|
+
<md:SingleLogoutService
|
|
3075
|
+
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
|
3076
|
+
Location="${sloUrl}"/>
|
|
3077
|
+
</md:SPSSODescriptor>
|
|
3078
|
+
</md:EntityDescriptor>`;
|
|
3079
|
+
return c.body(metadata, 200, {
|
|
3080
|
+
"Content-Type": "application/xml"
|
|
3081
|
+
});
|
|
3082
|
+
});
|
|
3083
|
+
auth.post("/saml/callback", async (c) => {
|
|
3084
|
+
const sso = await getSsoConfig();
|
|
3085
|
+
if (!sso?.saml?.certificate) {
|
|
3086
|
+
return c.html(ssoErrorPage("SAML Error", "SAML not configured."));
|
|
3087
|
+
}
|
|
3088
|
+
const saml = sso.saml;
|
|
3089
|
+
let samlResponse;
|
|
3090
|
+
const contentType = c.req.header("content-type") || "";
|
|
3091
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
3092
|
+
const body = await c.req.parseBody();
|
|
3093
|
+
samlResponse = body["SAMLResponse"];
|
|
3094
|
+
} else {
|
|
3095
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3096
|
+
samlResponse = body.SAMLResponse;
|
|
3097
|
+
}
|
|
3098
|
+
if (!samlResponse) {
|
|
3099
|
+
return c.html(ssoErrorPage("SAML Error", "Missing SAMLResponse"));
|
|
3100
|
+
}
|
|
3101
|
+
let xml;
|
|
3102
|
+
try {
|
|
3103
|
+
xml = Buffer.from(samlResponse, "base64").toString("utf-8");
|
|
3104
|
+
} catch {
|
|
3105
|
+
return c.html(ssoErrorPage("SAML Error", "Invalid base64 encoding"));
|
|
3106
|
+
}
|
|
3107
|
+
const assertion = parseSamlAssertion(xml, saml.certificate);
|
|
3108
|
+
if (assertion.error) {
|
|
3109
|
+
return c.html(ssoErrorPage("SAML Error", assertion.error));
|
|
3110
|
+
}
|
|
3111
|
+
if (!assertion.email) {
|
|
3112
|
+
return c.html(ssoErrorPage("SAML Error", "No email found in SAML assertion. Check your IdP attribute mapping."));
|
|
3113
|
+
}
|
|
3114
|
+
if (assertion.notBefore && new Date(assertion.notBefore) > /* @__PURE__ */ new Date()) {
|
|
3115
|
+
return c.html(ssoErrorPage("SAML Error", "Assertion not yet valid"));
|
|
3116
|
+
}
|
|
3117
|
+
if (assertion.notOnOrAfter && new Date(assertion.notOnOrAfter) <= /* @__PURE__ */ new Date()) {
|
|
3118
|
+
return c.html(ssoErrorPage("SAML Error", "Assertion has expired"));
|
|
3119
|
+
}
|
|
3120
|
+
const subject = assertion.nameId || assertion.email;
|
|
3121
|
+
const result = await findOrProvisionSsoUser("saml", subject, assertion.email, assertion.name || "", saml);
|
|
3122
|
+
if ("error" in result) {
|
|
3123
|
+
return c.html(ssoErrorPage("SAML Error", result.error ?? "Unknown error"));
|
|
3124
|
+
}
|
|
3125
|
+
await setSessionCookies(c, result.user.id, result.user.email, result.user.role, "saml");
|
|
3126
|
+
return c.redirect("/dashboard");
|
|
3127
|
+
});
|
|
3128
|
+
return auth;
|
|
3129
|
+
}
|
|
3130
|
+
function parseSamlAssertion(xml, certificate) {
|
|
3131
|
+
const result = {};
|
|
3132
|
+
try {
|
|
3133
|
+
const statusMatch = xml.match(/<samlp?:StatusCode[^>]*Value="([^"]+)"/);
|
|
3134
|
+
if (statusMatch) {
|
|
3135
|
+
const statusValue = statusMatch[1];
|
|
3136
|
+
if (!statusValue.includes(":Success")) {
|
|
3137
|
+
result.error = `SAML authentication failed with status: ${statusValue}`;
|
|
3138
|
+
return result;
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
const nameIdMatch = xml.match(/<(?:saml2?:)?NameID[^>]*>([^<]+)<\/(?:saml2?:)?NameID>/);
|
|
3142
|
+
if (nameIdMatch) {
|
|
3143
|
+
result.nameId = nameIdMatch[1].trim();
|
|
3144
|
+
}
|
|
3145
|
+
const issuerMatch = xml.match(/<(?:saml2?:)?Issuer[^>]*>([^<]+)<\/(?:saml2?:)?Issuer>/);
|
|
3146
|
+
if (issuerMatch) {
|
|
3147
|
+
result.issuer = issuerMatch[1].trim();
|
|
3148
|
+
}
|
|
3149
|
+
const condMatch = xml.match(/<(?:saml2?:)?Conditions\s+NotBefore="([^"]+)"\s+NotOnOrAfter="([^"]+)"/);
|
|
3150
|
+
if (condMatch) {
|
|
3151
|
+
result.notBefore = condMatch[1];
|
|
3152
|
+
result.notOnOrAfter = condMatch[2];
|
|
3153
|
+
}
|
|
3154
|
+
const sessionMatch = xml.match(/SessionIndex="([^"]+)"/);
|
|
3155
|
+
if (sessionMatch) {
|
|
3156
|
+
result.sessionIndex = sessionMatch[1];
|
|
3157
|
+
}
|
|
3158
|
+
const attrRegex = /<(?:saml2?:)?Attribute\s+Name="([^"]+)"[^>]*>[\s\S]*?<(?:saml2?:)?AttributeValue[^>]*>([^<]*)<\/(?:saml2?:)?AttributeValue>/g;
|
|
3159
|
+
let match;
|
|
3160
|
+
while ((match = attrRegex.exec(xml)) !== null) {
|
|
3161
|
+
const attrName = match[1].toLowerCase();
|
|
3162
|
+
const attrValue = match[2].trim();
|
|
3163
|
+
if (attrName.includes("emailaddress") || attrName.includes("email") || attrName === "mail") {
|
|
3164
|
+
result.email = attrValue;
|
|
3165
|
+
} else if (attrName.includes("displayname") || attrName === "name") {
|
|
3166
|
+
result.name = attrValue;
|
|
3167
|
+
} else if (attrName.includes("givenname") || attrName.includes("firstname")) {
|
|
3168
|
+
result.firstName = attrValue;
|
|
3169
|
+
} else if (attrName.includes("surname") || attrName.includes("lastname")) {
|
|
3170
|
+
result.lastName = attrValue;
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
if (!result.email && result.nameId?.includes("@")) {
|
|
3174
|
+
result.email = result.nameId;
|
|
3175
|
+
}
|
|
3176
|
+
if (!result.name && (result.firstName || result.lastName)) {
|
|
3177
|
+
result.name = [result.firstName, result.lastName].filter(Boolean).join(" ");
|
|
3178
|
+
}
|
|
3179
|
+
result.signatureValid = verifySamlSignature(xml, certificate);
|
|
3180
|
+
if (!result.signatureValid) {
|
|
3181
|
+
result.error = "SAML assertion signature verification failed. Check IdP certificate.";
|
|
3182
|
+
return result;
|
|
3183
|
+
}
|
|
3184
|
+
} catch (e) {
|
|
3185
|
+
result.error = `Failed to parse SAML assertion: ${e.message}`;
|
|
3186
|
+
}
|
|
3187
|
+
return result;
|
|
3188
|
+
}
|
|
3189
|
+
function verifySamlSignature(xml, certPem) {
|
|
3190
|
+
try {
|
|
3191
|
+
const sigMatch = xml.match(/<(?:ds:)?SignatureValue[^>]*>([\s\S]*?)<\/(?:ds:)?SignatureValue>/);
|
|
3192
|
+
if (!sigMatch) return true;
|
|
3193
|
+
const signedInfoMatch = xml.match(/<(?:ds:)?SignedInfo[^>]*>[\s\S]*?<\/(?:ds:)?SignedInfo>/);
|
|
3194
|
+
if (!signedInfoMatch) return false;
|
|
3195
|
+
let cert = certPem.trim();
|
|
3196
|
+
if (!cert.startsWith("-----BEGIN CERTIFICATE-----")) {
|
|
3197
|
+
cert = cert.replace(/\s/g, "");
|
|
3198
|
+
cert = `-----BEGIN CERTIFICATE-----
|
|
3199
|
+
${cert.match(/.{1,64}/g)?.join("\n")}
|
|
3200
|
+
-----END CERTIFICATE-----`;
|
|
3201
|
+
}
|
|
3202
|
+
const algMatch = xml.match(/SignatureMethod\s+Algorithm="([^"]+)"/);
|
|
3203
|
+
const algorithm = algMatch?.[1]?.includes("rsa-sha256") ? "RSA-SHA256" : "RSA-SHA1";
|
|
3204
|
+
const signature = Buffer.from(sigMatch[1].replace(/\s/g, ""), "base64");
|
|
3205
|
+
const signedInfo = signedInfoMatch[0];
|
|
3206
|
+
const canonicalized = signedInfo.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
|
3207
|
+
const verifier = createVerify(algorithm);
|
|
3208
|
+
verifier.update(canonicalized);
|
|
3209
|
+
return verifier.verify(cert, signature);
|
|
3210
|
+
} catch {
|
|
3211
|
+
return false;
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
function ssoErrorPage(title, message) {
|
|
3215
|
+
return `<!DOCTYPE html>
|
|
3216
|
+
<html>
|
|
3217
|
+
<head><title>${title}</title>
|
|
3218
|
+
<style>
|
|
3219
|
+
body { font-family: system-ui, -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f8f9fa; }
|
|
3220
|
+
.card { background: white; border-radius: 12px; padding: 40px; max-width: 480px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }
|
|
3221
|
+
h1 { color: #dc2626; font-size: 1.5rem; margin: 0 0 16px; }
|
|
3222
|
+
p { color: #4b5563; margin: 0 0 24px; line-height: 1.5; }
|
|
3223
|
+
a { display: inline-block; padding: 10px 24px; background: #6366f1; color: white; border-radius: 8px; text-decoration: none; }
|
|
3224
|
+
a:hover { background: #4f46e5; }
|
|
3225
|
+
</style></head>
|
|
3226
|
+
<body>
|
|
3227
|
+
<div class="card">
|
|
3228
|
+
<h1>${title}</h1>
|
|
3229
|
+
<p>${message}</p>
|
|
3230
|
+
<a href="/dashboard">Back to Dashboard</a>
|
|
3231
|
+
</div>
|
|
3232
|
+
</body></html>`;
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
// src/server.ts
|
|
3236
|
+
var ENTERPRISE_VERSION = "unknown";
|
|
3237
|
+
try {
|
|
3238
|
+
const _require = createRequire(import.meta.url);
|
|
3239
|
+
ENTERPRISE_VERSION = _require("../package.json").version;
|
|
3240
|
+
} catch {
|
|
3241
|
+
try {
|
|
3242
|
+
ENTERPRISE_VERSION = JSON.parse(readFileSync(join(process.cwd(), "node_modules", "@agenticmail", "enterprise", "package.json"), "utf-8")).version;
|
|
3243
|
+
} catch {
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
function createServer(config) {
|
|
3247
|
+
const app = new Hono3();
|
|
3248
|
+
const dbProxy = createDbProxy(config.db);
|
|
3249
|
+
config.db = dbProxy;
|
|
3250
|
+
setNetworkDb(config.db);
|
|
3251
|
+
invalidateNetworkConfig().catch(() => {
|
|
3252
|
+
});
|
|
3253
|
+
initProxyConfig();
|
|
3254
|
+
const dbBreaker = new CircuitBreaker({
|
|
3255
|
+
failureThreshold: 5,
|
|
3256
|
+
recoveryTimeMs: 3e4,
|
|
3257
|
+
timeout: 1e4
|
|
3258
|
+
});
|
|
3259
|
+
const healthMonitor = new HealthMonitor(
|
|
3260
|
+
async () => {
|
|
3261
|
+
await config.db.getStats();
|
|
3262
|
+
},
|
|
3263
|
+
{ intervalMs: 3e4, timeoutMs: 5e3, unhealthyThreshold: 3 }
|
|
3264
|
+
);
|
|
3265
|
+
healthMonitor.onStatusChange((healthy) => {
|
|
3266
|
+
console.log(
|
|
3267
|
+
`[${(/* @__PURE__ */ new Date()).toISOString()}] ${healthy ? "\u2705" : "\u274C"} Database health: ${healthy ? "healthy" : "unhealthy"}`
|
|
3268
|
+
);
|
|
3269
|
+
});
|
|
3270
|
+
app.use("*", requestIdMiddleware());
|
|
3271
|
+
app.use("*", errorHandler());
|
|
3272
|
+
app.use("*", securityHeaders());
|
|
3273
|
+
app.use("*", requireHttps());
|
|
3274
|
+
app.use("*", dnsRebindingProtection());
|
|
3275
|
+
app.use("*", requestBodyLimit());
|
|
3276
|
+
app.use("*", geoIpRestriction());
|
|
3277
|
+
app.use("*", ipAccessControl());
|
|
3278
|
+
async function getCorsOrigins() {
|
|
3279
|
+
if (config.corsOrigins && config.corsOrigins.length && config.corsOrigins[0] !== "*") {
|
|
3280
|
+
return config.corsOrigins;
|
|
3281
|
+
}
|
|
3282
|
+
const { getNetworkConfig: getNetworkConfig2 } = await import("./network-config-IJ2XYW2B.js");
|
|
3283
|
+
const netConfig = await getNetworkConfig2();
|
|
3284
|
+
const origins = netConfig.network?.corsOrigins;
|
|
3285
|
+
if (origins && Array.isArray(origins) && origins.length > 0) return origins;
|
|
3286
|
+
return [];
|
|
3287
|
+
}
|
|
3288
|
+
app.use("*", cors({
|
|
3289
|
+
origin: async (origin, c) => {
|
|
3290
|
+
const allowed = await getCorsOrigins();
|
|
3291
|
+
if (!allowed.length) return origin || "*";
|
|
3292
|
+
if (origin && allowed.includes(origin)) return origin;
|
|
3293
|
+
if (!origin) return allowed[0];
|
|
3294
|
+
return "";
|
|
3295
|
+
},
|
|
3296
|
+
credentials: true,
|
|
3297
|
+
allowHeaders: ["Content-Type", "Authorization", "X-API-Key", "X-Request-Id", "X-CSRF-Token"],
|
|
3298
|
+
exposeHeaders: ["X-Request-Id", "X-RateLimit-Limit", "X-RateLimit-Remaining", "Retry-After"]
|
|
3299
|
+
}));
|
|
3300
|
+
app.use("*", rateLimiter({
|
|
3301
|
+
limit: config.rateLimit ?? 300,
|
|
3302
|
+
windowSec: 60,
|
|
3303
|
+
skipPaths: ["/health", "/ready", "/dashboard", "/api/engine/agent-status"]
|
|
3304
|
+
}));
|
|
3305
|
+
if (config.logging !== false) {
|
|
3306
|
+
app.use("*", requestLogger());
|
|
3307
|
+
}
|
|
3308
|
+
app.get("/health", (c) => c.json({
|
|
3309
|
+
status: "ok",
|
|
3310
|
+
version: "0.4.0",
|
|
3311
|
+
uptime: process.uptime()
|
|
3312
|
+
}));
|
|
3313
|
+
app.get("/ready", async (c) => {
|
|
3314
|
+
const dbHealthy = healthMonitor.isHealthy();
|
|
3315
|
+
const status = dbHealthy ? 200 : 503;
|
|
3316
|
+
return c.json({
|
|
3317
|
+
ready: dbHealthy,
|
|
3318
|
+
checks: {
|
|
3319
|
+
database: dbHealthy ? "ok" : "unhealthy",
|
|
3320
|
+
circuitBreaker: dbBreaker.getState()
|
|
3321
|
+
}
|
|
3322
|
+
}, status);
|
|
3323
|
+
});
|
|
3324
|
+
let _setupComplete = false;
|
|
3325
|
+
(async () => {
|
|
3326
|
+
try {
|
|
3327
|
+
const stats = await config.db.getStats();
|
|
3328
|
+
if (stats.totalUsers > 0) _setupComplete = true;
|
|
3329
|
+
} catch {
|
|
3330
|
+
}
|
|
3331
|
+
})();
|
|
3332
|
+
const authRoutes = createAuthRoutes(config.db, config.jwtSecret, {
|
|
3333
|
+
onBootstrap: () => {
|
|
3334
|
+
_setupComplete = true;
|
|
3335
|
+
},
|
|
3336
|
+
onDbConfigure: (newAdapter) => {
|
|
3337
|
+
const old = dbProxy.__swap(newAdapter);
|
|
3338
|
+
engineInitialized = false;
|
|
3339
|
+
return old;
|
|
3340
|
+
}
|
|
3341
|
+
});
|
|
3342
|
+
app.route("/auth", authRoutes);
|
|
3343
|
+
const api = new Hono3();
|
|
3344
|
+
api.use("*", async (c, next) => {
|
|
3345
|
+
if (c.req.path.endsWith("/oauth/callback") && c.req.method === "GET") {
|
|
3346
|
+
return next();
|
|
3347
|
+
}
|
|
3348
|
+
if (c.req.path.includes("/chat-webhook") && c.req.method === "POST") {
|
|
3349
|
+
return next();
|
|
3350
|
+
}
|
|
3351
|
+
if (c.req.path.includes("/engine/agent-status") || c.req.path.includes("/whatsapp/proxy-send") && (c.req.header("host") || "").startsWith("localhost")) {
|
|
3352
|
+
return next();
|
|
3353
|
+
}
|
|
3354
|
+
if (c.req.path.includes("/runtime/chat") && c.req.method === "POST") {
|
|
3355
|
+
return next();
|
|
3356
|
+
}
|
|
3357
|
+
if (c.req.path.includes("/runtime/hooks/") && c.req.method === "POST") {
|
|
3358
|
+
return next();
|
|
3359
|
+
}
|
|
3360
|
+
const apiKeyHeader = c.req.header("X-API-Key");
|
|
3361
|
+
if (apiKeyHeader) {
|
|
3362
|
+
const key = await dbBreaker.execute(() => config.db.validateApiKey(apiKeyHeader));
|
|
3363
|
+
if (!key) return c.json({ error: "Invalid API key" }, 401);
|
|
3364
|
+
c.set("userId", key.createdBy);
|
|
3365
|
+
c.set("authType", "api-key");
|
|
3366
|
+
c.set("apiKeyScopes", key.scopes);
|
|
3367
|
+
return next();
|
|
3368
|
+
}
|
|
3369
|
+
const { getCookie: getCookie2 } = await import("hono/cookie");
|
|
3370
|
+
const cookieToken = getCookie2(c, "em_session");
|
|
3371
|
+
const authHeader = c.req.header("Authorization");
|
|
3372
|
+
const jwt = cookieToken || (authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null);
|
|
3373
|
+
if (!jwt) {
|
|
3374
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
3375
|
+
}
|
|
3376
|
+
try {
|
|
3377
|
+
const { jwtVerify } = await import("jose");
|
|
3378
|
+
const secret = new TextEncoder().encode(config.jwtSecret);
|
|
3379
|
+
const { payload } = await jwtVerify(jwt, secret);
|
|
3380
|
+
c.set("userId", payload.sub);
|
|
3381
|
+
c.set("userRole", payload.role || "");
|
|
3382
|
+
c.set("userEmail", payload.email || "");
|
|
3383
|
+
c.set("authType", cookieToken ? "cookie" : "jwt");
|
|
3384
|
+
return next();
|
|
3385
|
+
} catch {
|
|
3386
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
3387
|
+
}
|
|
3388
|
+
});
|
|
3389
|
+
api.use("*", auditLogger(config.db));
|
|
3390
|
+
const adminRoutes = createAdminRoutes(config.db);
|
|
3391
|
+
api.route("/", adminRoutes);
|
|
3392
|
+
let engineInitialized = false;
|
|
3393
|
+
api.all("/engine/*", async (c, next) => {
|
|
3394
|
+
try {
|
|
3395
|
+
const { engineRoutes, setEngineDb } = await import("./routes-GYKER7K7.js");
|
|
3396
|
+
const { EngineDatabase } = await import("./db-adapter-FBLIO7QY.js");
|
|
3397
|
+
if (!engineInitialized) {
|
|
3398
|
+
engineInitialized = true;
|
|
3399
|
+
const engineDbInterface = config.db.getEngineDB();
|
|
3400
|
+
if (!engineDbInterface) {
|
|
3401
|
+
return c.json({
|
|
3402
|
+
error: "Engine not available",
|
|
3403
|
+
detail: `Engine requires a SQL-compatible database. "${config.db.type}" does not support raw SQL queries. Use postgres, mysql, sqlite, or turso.`
|
|
3404
|
+
}, 501);
|
|
3405
|
+
}
|
|
3406
|
+
const adapterDialect = config.db.getDialect();
|
|
3407
|
+
const dialectMap = {
|
|
3408
|
+
sqlite: "sqlite",
|
|
3409
|
+
postgres: "postgres",
|
|
3410
|
+
supabase: "postgres",
|
|
3411
|
+
neon: "postgres",
|
|
3412
|
+
cockroachdb: "postgres",
|
|
3413
|
+
mysql: "mysql",
|
|
3414
|
+
planetscale: "mysql",
|
|
3415
|
+
turso: "turso"
|
|
3416
|
+
};
|
|
3417
|
+
const engineDialect = dialectMap[adapterDialect] || adapterDialect;
|
|
3418
|
+
const engineDb = new EngineDatabase(engineDbInterface, engineDialect);
|
|
3419
|
+
const migrationResult = await engineDb.migrate();
|
|
3420
|
+
console.log(`[engine] Migrations: ${migrationResult.applied} applied, ${migrationResult.total} total`);
|
|
3421
|
+
await setEngineDb(engineDb, config.db);
|
|
3422
|
+
engineInitialized = true;
|
|
3423
|
+
if (config.runtime?.enabled) {
|
|
3424
|
+
try {
|
|
3425
|
+
const { createAgentRuntime } = await import("./runtime-3CAEEFSI.js");
|
|
3426
|
+
const { mountRuntimeApp, setRuntime } = await import("./routes-GYKER7K7.js");
|
|
3427
|
+
let getEmailConfig;
|
|
3428
|
+
let onTokenRefresh;
|
|
3429
|
+
let agentMemoryMgr;
|
|
3430
|
+
try {
|
|
3431
|
+
const { lifecycle: lc, memoryManager: mm } = await import("./routes-GYKER7K7.js");
|
|
3432
|
+
agentMemoryMgr = mm;
|
|
3433
|
+
if (lc) {
|
|
3434
|
+
getEmailConfig = (agentId) => {
|
|
3435
|
+
const managed = lc.getAgent(agentId);
|
|
3436
|
+
return managed?.config?.emailConfig || null;
|
|
3437
|
+
};
|
|
3438
|
+
onTokenRefresh = (agentId, tokens) => {
|
|
3439
|
+
const managed = lc.getAgent(agentId);
|
|
3440
|
+
if (managed?.config?.emailConfig) {
|
|
3441
|
+
if (tokens.accessToken) managed.config.emailConfig.oauthAccessToken = tokens.accessToken;
|
|
3442
|
+
if (tokens.refreshToken) managed.config.emailConfig.oauthRefreshToken = tokens.refreshToken;
|
|
3443
|
+
if (tokens.expiresAt) managed.config.emailConfig.oauthTokenExpiry = tokens.expiresAt;
|
|
3444
|
+
lc.saveAgent(agentId).catch(() => {
|
|
3445
|
+
});
|
|
3446
|
+
}
|
|
3447
|
+
};
|
|
3448
|
+
}
|
|
3449
|
+
} catch {
|
|
3450
|
+
}
|
|
3451
|
+
const { vault: vaultRef, permissionEngine: permRef } = await import("./routes-GYKER7K7.js");
|
|
3452
|
+
const runtime = createAgentRuntime({
|
|
3453
|
+
engineDb,
|
|
3454
|
+
adminDb: config.db,
|
|
3455
|
+
defaultModel: config.runtime.defaultModel,
|
|
3456
|
+
apiKeys: config.runtime.apiKeys,
|
|
3457
|
+
gatewayEnabled: true,
|
|
3458
|
+
getEmailConfig,
|
|
3459
|
+
onTokenRefresh,
|
|
3460
|
+
agentMemoryManager: agentMemoryMgr,
|
|
3461
|
+
vault: vaultRef,
|
|
3462
|
+
permissionEngine: permRef
|
|
3463
|
+
});
|
|
3464
|
+
await runtime.start();
|
|
3465
|
+
const runtimeApp = runtime.getApp();
|
|
3466
|
+
if (runtimeApp) {
|
|
3467
|
+
mountRuntimeApp(runtimeApp);
|
|
3468
|
+
}
|
|
3469
|
+
setRuntime(runtime);
|
|
3470
|
+
console.log("[runtime] Agent runtime started and mounted at /api/engine/runtime/*");
|
|
3471
|
+
} catch (runtimeErr) {
|
|
3472
|
+
console.warn(`[runtime] Failed to start agent runtime: ${runtimeErr.message}`);
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
const originalUrl = new URL(c.req.url);
|
|
3477
|
+
const subPath = (c.req.path.replace(/^\/api\/engine/, "") || "/") + originalUrl.search;
|
|
3478
|
+
const headers = new Headers(c.req.raw.headers);
|
|
3479
|
+
const userId = c.get("userId");
|
|
3480
|
+
const userRole = c.get("userRole");
|
|
3481
|
+
const userEmail = c.get("userEmail");
|
|
3482
|
+
const authType = c.get("authType");
|
|
3483
|
+
const requestId2 = c.get("requestId");
|
|
3484
|
+
if (userId) headers.set("X-User-Id", String(userId));
|
|
3485
|
+
if (userRole) headers.set("X-User-Role", String(userRole));
|
|
3486
|
+
if (userEmail) headers.set("X-User-Email", String(userEmail));
|
|
3487
|
+
if (authType) headers.set("X-Auth-Type", String(authType));
|
|
3488
|
+
if (requestId2) headers.set("X-Request-Id", String(requestId2));
|
|
3489
|
+
const subReq = new Request(new URL(subPath, "http://localhost"), {
|
|
3490
|
+
method: c.req.method,
|
|
3491
|
+
headers,
|
|
3492
|
+
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : void 0
|
|
3493
|
+
});
|
|
3494
|
+
return engineRoutes.fetch(subReq);
|
|
3495
|
+
} catch (e) {
|
|
3496
|
+
console.error("[engine] Error:", e.message);
|
|
3497
|
+
return c.json({ error: "Engine module not available", detail: e.message }, 501);
|
|
3498
|
+
}
|
|
3499
|
+
});
|
|
3500
|
+
app.route("/api", api);
|
|
3501
|
+
let dashboardHtml = null;
|
|
3502
|
+
function getDashboardHtml() {
|
|
3503
|
+
if (!dashboardHtml) {
|
|
3504
|
+
try {
|
|
3505
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
3506
|
+
dashboardHtml = readFileSync(join(dir, "dashboard", "index.html"), "utf-8");
|
|
3507
|
+
} catch {
|
|
3508
|
+
try {
|
|
3509
|
+
dashboardHtml = readFileSync(join(process.cwd(), "node_modules", "@agenticmail", "enterprise", "dist", "dashboard", "index.html"), "utf-8");
|
|
3510
|
+
} catch {
|
|
3511
|
+
dashboardHtml = "<html><body><h1>Dashboard not found</h1><p>The dashboard HTML file could not be located.</p></body></html>";
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
return dashboardHtml;
|
|
3516
|
+
}
|
|
3517
|
+
async function serveDashboard(c) {
|
|
3518
|
+
let html = getDashboardHtml();
|
|
3519
|
+
html = html.replace("</head>", `<script>window.__ENTERPRISE_VERSION__="${ENTERPRISE_VERSION}";</script></head>`);
|
|
3520
|
+
if (!_setupComplete) {
|
|
3521
|
+
const injection = `<script>window.__EM_SETUP_STATE__=${JSON.stringify({ needsBootstrap: true })};</script>`;
|
|
3522
|
+
html = html.replace("</head>", injection + "</head>");
|
|
3523
|
+
}
|
|
3524
|
+
try {
|
|
3525
|
+
const settings0 = await config.db.getSettings();
|
|
3526
|
+
const branding = settings0?.branding || {};
|
|
3527
|
+
if (settings0?.name && !branding.companyName) branding.companyName = settings0.name;
|
|
3528
|
+
if (Object.keys(branding).length > 0) {
|
|
3529
|
+
const brandScript = `<script>window.__EM_BRANDING__=${JSON.stringify(branding)};</script>`;
|
|
3530
|
+
html = html.replace("</head>", brandScript + "</head>");
|
|
3531
|
+
}
|
|
3532
|
+
} catch {
|
|
3533
|
+
}
|
|
3534
|
+
try {
|
|
3535
|
+
const settings = await config.db.getSettings();
|
|
3536
|
+
if (settings.domain && settings.domainStatus) {
|
|
3537
|
+
const domainState = {
|
|
3538
|
+
domain: settings.domain,
|
|
3539
|
+
status: settings.domainStatus,
|
|
3540
|
+
verifiedAt: settings.domainVerifiedAt,
|
|
3541
|
+
dnsChallenge: settings.domainDnsChallenge
|
|
3542
|
+
};
|
|
3543
|
+
const domainScript = `<script>window.__EM_DOMAIN_STATE__=${JSON.stringify(domainState)};</script>`;
|
|
3544
|
+
html = html.replace("</head>", domainScript + "</head>");
|
|
3545
|
+
}
|
|
3546
|
+
} catch {
|
|
3547
|
+
}
|
|
3548
|
+
return c.html(html);
|
|
3549
|
+
}
|
|
3550
|
+
app.get("/branding/*", (c) => {
|
|
3551
|
+
const reqPath = c.req.path.replace("/branding/", "").split("?")[0];
|
|
3552
|
+
if (reqPath.includes("..")) return c.json({ error: "Forbidden" }, 403);
|
|
3553
|
+
const ext = reqPath.substring(reqPath.lastIndexOf("."));
|
|
3554
|
+
const mimeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".gif": "image/gif", ".webp": "image/webp" };
|
|
3555
|
+
const mime = mimeMap[ext];
|
|
3556
|
+
if (!mime) return c.json({ error: "Not found" }, 404);
|
|
3557
|
+
const brandDir = join(homedir(), ".agenticmail", "branding");
|
|
3558
|
+
const filePath = join(brandDir, reqPath);
|
|
3559
|
+
if (!filePath.startsWith(brandDir) || !existsSync(filePath)) return c.json({ error: "Not found" }, 404);
|
|
3560
|
+
const content = readFileSync(filePath);
|
|
3561
|
+
return new Response(content, { status: 200, headers: { "Content-Type": mime, "Cache-Control": "public, max-age=3600" } });
|
|
3562
|
+
});
|
|
3563
|
+
app.get("/", (c) => c.redirect("/dashboard"));
|
|
3564
|
+
app.get("/dashboard", serveDashboard);
|
|
3565
|
+
app.get("/docs/:page", (c) => {
|
|
3566
|
+
const page = c.req.param("page").replace(/[^a-z0-9-]/gi, "");
|
|
3567
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
3568
|
+
const filePath = join(dir, "dashboard", "docs", page + ".html");
|
|
3569
|
+
if (existsSync(filePath)) {
|
|
3570
|
+
const content = readFileSync(filePath, "utf-8");
|
|
3571
|
+
return new Response(content, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
3572
|
+
}
|
|
3573
|
+
return c.json({ error: "Documentation page not found" }, 404);
|
|
3574
|
+
});
|
|
3575
|
+
const STATIC_MIME = { ".js": "application/javascript; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".gif": "image/gif", ".webp": "image/webp", ".css": "text/css; charset=utf-8" };
|
|
3576
|
+
app.get("/dashboard/*", (c) => {
|
|
3577
|
+
const reqPath = c.req.path.replace("/dashboard/", "");
|
|
3578
|
+
const ext = reqPath.substring(reqPath.lastIndexOf("."));
|
|
3579
|
+
const mime = STATIC_MIME[ext];
|
|
3580
|
+
if (mime) {
|
|
3581
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
3582
|
+
const filePath = join(dir, "dashboard", reqPath);
|
|
3583
|
+
if (!filePath.startsWith(join(dir, "dashboard"))) {
|
|
3584
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
3585
|
+
}
|
|
3586
|
+
if (existsSync(filePath)) {
|
|
3587
|
+
const content = readFileSync(filePath);
|
|
3588
|
+
return new Response(content, { status: 200, headers: { "Content-Type": mime, "Cache-Control": ext === ".js" ? "no-cache, no-store, must-revalidate" : "public, max-age=86400" } });
|
|
3589
|
+
}
|
|
3590
|
+
return c.json({ error: "Not found", path: c.req.path }, 404);
|
|
3591
|
+
}
|
|
3592
|
+
return serveDashboard(c);
|
|
3593
|
+
});
|
|
3594
|
+
app.notFound((c) => {
|
|
3595
|
+
return c.json({ error: "Not found", path: c.req.path }, 404);
|
|
3596
|
+
});
|
|
3597
|
+
return {
|
|
3598
|
+
app,
|
|
3599
|
+
healthMonitor,
|
|
3600
|
+
start: () => {
|
|
3601
|
+
return new Promise((resolve) => {
|
|
3602
|
+
const server = serve(
|
|
3603
|
+
{ fetch: app.fetch, port: config.port },
|
|
3604
|
+
(info) => {
|
|
3605
|
+
console.log(`
|
|
3606
|
+
\u{1F3E2} AgenticMail Enterprise v${ENTERPRISE_VERSION}`);
|
|
3607
|
+
console.log(` API: http://localhost:${info.port}/api`);
|
|
3608
|
+
console.log(` Auth: http://localhost:${info.port}/auth`);
|
|
3609
|
+
console.log(` Health: http://localhost:${info.port}/health`);
|
|
3610
|
+
console.log("");
|
|
3611
|
+
healthMonitor.start();
|
|
3612
|
+
config.db.getSettings().then(async (settings) => {
|
|
3613
|
+
const { SecureVault: SecureVault2 } = await import("./vault-JZFISH5D.js");
|
|
3614
|
+
const vaultInst = new SecureVault2();
|
|
3615
|
+
const keys = settings?.modelPricingConfig?.providerApiKeys;
|
|
3616
|
+
if (keys && typeof keys === "object") {
|
|
3617
|
+
for (const [providerId, apiKey] of Object.entries(keys)) {
|
|
3618
|
+
if (apiKey && typeof apiKey === "string") {
|
|
3619
|
+
let decrypted;
|
|
3620
|
+
try {
|
|
3621
|
+
decrypted = vaultInst.decrypt(apiKey);
|
|
3622
|
+
} catch {
|
|
3623
|
+
decrypted = apiKey;
|
|
3624
|
+
}
|
|
3625
|
+
if (config.runtime?.apiKeys) {
|
|
3626
|
+
config.runtime.apiKeys[providerId] = decrypted;
|
|
3627
|
+
}
|
|
3628
|
+
console.log(` \u{1F511} Loaded API key for ${providerId} from DB`);
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
}).catch(() => {
|
|
3633
|
+
});
|
|
3634
|
+
(async () => {
|
|
3635
|
+
try {
|
|
3636
|
+
const { engineRoutes, setEngineDb } = await import("./routes-GYKER7K7.js");
|
|
3637
|
+
const { EngineDatabase } = await import("./db-adapter-FBLIO7QY.js");
|
|
3638
|
+
if (!engineInitialized) {
|
|
3639
|
+
engineInitialized = true;
|
|
3640
|
+
const engineDbInterface = config.db.getEngineDB();
|
|
3641
|
+
if (engineDbInterface) {
|
|
3642
|
+
const adapterDialect = config.db.getDialect();
|
|
3643
|
+
const dialectMap = {
|
|
3644
|
+
sqlite: "sqlite",
|
|
3645
|
+
postgres: "postgres",
|
|
3646
|
+
supabase: "postgres",
|
|
3647
|
+
neon: "postgres",
|
|
3648
|
+
cockroachdb: "postgres",
|
|
3649
|
+
mysql: "mysql",
|
|
3650
|
+
planetscale: "mysql",
|
|
3651
|
+
turso: "turso"
|
|
3652
|
+
};
|
|
3653
|
+
const engineDialect = dialectMap[adapterDialect] || adapterDialect;
|
|
3654
|
+
const engineDb = new EngineDatabase(engineDbInterface, engineDialect);
|
|
3655
|
+
const migrationResult = await engineDb.migrate();
|
|
3656
|
+
console.log(`[engine] Migrations: ${migrationResult.applied} applied, ${migrationResult.total} total`);
|
|
3657
|
+
await setEngineDb(engineDb, config.db);
|
|
3658
|
+
if (config.runtime?.enabled) {
|
|
3659
|
+
try {
|
|
3660
|
+
const { createAgentRuntime } = await import("./runtime-3CAEEFSI.js");
|
|
3661
|
+
const { mountRuntimeApp, setRuntime } = await import("./routes-GYKER7K7.js");
|
|
3662
|
+
let getEmailConfig;
|
|
3663
|
+
let onTokenRefresh;
|
|
3664
|
+
let agentMemoryMgr;
|
|
3665
|
+
try {
|
|
3666
|
+
const { lifecycle: lc, memoryManager: mm } = await import("./routes-GYKER7K7.js");
|
|
3667
|
+
agentMemoryMgr = mm;
|
|
3668
|
+
if (lc) {
|
|
3669
|
+
getEmailConfig = (agentId) => {
|
|
3670
|
+
const managed = lc.getAgent(agentId);
|
|
3671
|
+
return managed?.config?.emailConfig || null;
|
|
3672
|
+
};
|
|
3673
|
+
onTokenRefresh = (agentId, tokens) => {
|
|
3674
|
+
const managed = lc.getAgent(agentId);
|
|
3675
|
+
if (managed?.config?.emailConfig) {
|
|
3676
|
+
if (tokens.accessToken) managed.config.emailConfig.oauthAccessToken = tokens.accessToken;
|
|
3677
|
+
if (tokens.refreshToken) managed.config.emailConfig.oauthRefreshToken = tokens.refreshToken;
|
|
3678
|
+
if (tokens.expiresAt) managed.config.emailConfig.oauthTokenExpiry = tokens.expiresAt;
|
|
3679
|
+
lc.saveAgent(agentId).catch(() => {
|
|
3680
|
+
});
|
|
3681
|
+
}
|
|
3682
|
+
};
|
|
3683
|
+
}
|
|
3684
|
+
} catch {
|
|
3685
|
+
}
|
|
3686
|
+
const { vault: vaultRef2, permissionEngine: permRef2 } = await import("./routes-GYKER7K7.js");
|
|
3687
|
+
const runtime = createAgentRuntime({
|
|
3688
|
+
engineDb,
|
|
3689
|
+
adminDb: config.db,
|
|
3690
|
+
defaultModel: config.runtime.defaultModel,
|
|
3691
|
+
apiKeys: config.runtime.apiKeys,
|
|
3692
|
+
gatewayEnabled: true,
|
|
3693
|
+
getEmailConfig,
|
|
3694
|
+
onTokenRefresh,
|
|
3695
|
+
agentMemoryManager: agentMemoryMgr,
|
|
3696
|
+
vault: vaultRef2,
|
|
3697
|
+
permissionEngine: permRef2,
|
|
3698
|
+
hierarchyManager: (await import("./routes-GYKER7K7.js")).hierarchyManager ?? void 0
|
|
3699
|
+
});
|
|
3700
|
+
await runtime.start();
|
|
3701
|
+
const runtimeApp = runtime.getApp();
|
|
3702
|
+
if (runtimeApp) mountRuntimeApp(runtimeApp);
|
|
3703
|
+
setRuntime(runtime);
|
|
3704
|
+
console.log("[runtime] Agent runtime started");
|
|
3705
|
+
} catch (runtimeErr) {
|
|
3706
|
+
console.warn(`[runtime] Failed to start: ${runtimeErr.message}`);
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
console.log("[engine] Eagerly initialized");
|
|
3710
|
+
try {
|
|
3711
|
+
const { lifecycle: lcRef } = await import("./routes-GYKER7K7.js");
|
|
3712
|
+
if (lcRef) {
|
|
3713
|
+
const agents = Array.from(lcRef.agents?.values?.() || []);
|
|
3714
|
+
const hasLocalPm2 = agents.some((a) => {
|
|
3715
|
+
const target = a?.config?.deployment?.target;
|
|
3716
|
+
const pm = a?.config?.deployment?.config?.local?.processManager;
|
|
3717
|
+
return target === "local" && (!pm || pm === "pm2");
|
|
3718
|
+
});
|
|
3719
|
+
if (hasLocalPm2) {
|
|
3720
|
+
const { ensurePm2 } = await import("./deployer-NKRPK6LJ.js");
|
|
3721
|
+
const pm2 = await ensurePm2();
|
|
3722
|
+
if (pm2.installed) {
|
|
3723
|
+
console.log(`[startup] PM2 v${pm2.version} available for local deployments`);
|
|
3724
|
+
} else {
|
|
3725
|
+
console.warn(`[startup] PM2 auto-install failed: ${pm2.error}`);
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
}
|
|
3729
|
+
} catch {
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
} catch (e) {
|
|
3734
|
+
console.warn(`[engine] Eager init failed: ${e.message}`);
|
|
3735
|
+
}
|
|
3736
|
+
})();
|
|
3737
|
+
const shutdown = () => {
|
|
3738
|
+
console.log("\n\u23F3 Shutting down gracefully...");
|
|
3739
|
+
healthMonitor.stop();
|
|
3740
|
+
server.close(() => {
|
|
3741
|
+
config.db.disconnect().then(() => {
|
|
3742
|
+
console.log("\u2705 Shutdown complete");
|
|
3743
|
+
process.exit(0);
|
|
3744
|
+
});
|
|
3745
|
+
});
|
|
3746
|
+
setTimeout(() => {
|
|
3747
|
+
process.exit(1);
|
|
3748
|
+
}, 1e4).unref();
|
|
3749
|
+
};
|
|
3750
|
+
process.on("SIGINT", shutdown);
|
|
3751
|
+
process.on("SIGTERM", shutdown);
|
|
3752
|
+
resolve({
|
|
3753
|
+
close: () => {
|
|
3754
|
+
healthMonitor.stop();
|
|
3755
|
+
server.close();
|
|
3756
|
+
}
|
|
3757
|
+
});
|
|
3758
|
+
}
|
|
3759
|
+
);
|
|
3760
|
+
});
|
|
3761
|
+
}
|
|
3762
|
+
};
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
export {
|
|
3766
|
+
requestIdMiddleware,
|
|
3767
|
+
requestLogger,
|
|
3768
|
+
rateLimiter,
|
|
3769
|
+
securityHeaders,
|
|
3770
|
+
errorHandler,
|
|
3771
|
+
ValidationError,
|
|
3772
|
+
validate,
|
|
3773
|
+
auditLogger,
|
|
3774
|
+
requireRole,
|
|
3775
|
+
createAdminRoutes,
|
|
3776
|
+
createAuthRoutes,
|
|
3777
|
+
createServer
|
|
3778
|
+
};
|