@agenticmail/enterprise 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +929 -0
- package/dashboards/dotnet/Program.cs +5 -5
- package/dashboards/express/app.js +5 -5
- package/dashboards/go/main.go +8 -8
- package/dashboards/html/index.html +41 -18
- package/dashboards/java/AgenticMailDashboard.java +5 -5
- package/dashboards/php/index.php +8 -8
- package/dashboards/python/app.py +8 -8
- package/dashboards/ruby/app.rb +7 -7
- package/dashboards/shared-styles.css +57 -0
- package/dist/chunk-BE7MXVLA.js +757 -0
- package/dist/chunk-BS2WCSHO.js +48 -0
- package/dist/chunk-FL3VQBGL.js +757 -0
- package/dist/chunk-GXIEEA2T.js +48 -0
- package/dist/chunk-JLSQOQ5L.js +255 -0
- package/dist/chunk-TVF23PUW.js +338 -0
- package/dist/cli.js +305 -140
- package/dist/dashboard/index.html +833 -510
- package/dist/factory-HINWFYZ3.js +9 -0
- package/dist/factory-V37IG5AT.js +9 -0
- package/dist/index.js +18 -12
- package/dist/managed-RZITNPXG.js +14 -0
- package/dist/server-32YYCI3A.js +8 -0
- package/dist/server-H3C6WUOS.js +8 -0
- package/dist/sqlite-VLKVAJA4.js +442 -0
- package/package.json +18 -2
- package/src/cli.ts +15 -251
- package/src/dashboard/index.html +833 -510
- package/src/db/sqlite.ts +4 -1
- package/src/server.ts +1 -1
- package/src/setup/company.ts +64 -0
- package/src/setup/database.ts +119 -0
- package/src/setup/deployment.ts +50 -0
- package/src/setup/domain.ts +46 -0
- package/src/setup/index.ts +82 -0
- package/src/setup/provision.ts +226 -0
- package/test-integration.mjs +383 -0
- package/agenticmail-enterprise.db +0 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CircuitBreaker,
|
|
3
|
+
HealthMonitor,
|
|
4
|
+
KeyedRateLimiter,
|
|
5
|
+
requestId
|
|
6
|
+
} from "./chunk-JLSQOQ5L.js";
|
|
7
|
+
|
|
8
|
+
// src/server.ts
|
|
9
|
+
import { Hono as Hono3 } from "hono";
|
|
10
|
+
import { cors } from "hono/cors";
|
|
11
|
+
import { serve } from "@hono/node-server";
|
|
12
|
+
import { readFileSync } from "fs";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import { dirname, join } from "path";
|
|
15
|
+
|
|
16
|
+
// src/admin/routes.ts
|
|
17
|
+
import { Hono } from "hono";
|
|
18
|
+
|
|
19
|
+
// src/middleware/index.ts
|
|
20
|
+
function requestIdMiddleware() {
|
|
21
|
+
return async (c, next) => {
|
|
22
|
+
const id = c.req.header("X-Request-Id") || requestId();
|
|
23
|
+
c.set("requestId", id);
|
|
24
|
+
c.header("X-Request-Id", id);
|
|
25
|
+
await next();
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function requestLogger() {
|
|
29
|
+
return async (c, next) => {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
const method = c.req.method;
|
|
32
|
+
const path = c.req.path;
|
|
33
|
+
await next();
|
|
34
|
+
const elapsed = Date.now() - start;
|
|
35
|
+
const status = c.res.status;
|
|
36
|
+
const reqId = c.get("requestId") || "-";
|
|
37
|
+
const level = status >= 500 ? "ERROR" : status >= 400 ? "WARN" : "INFO";
|
|
38
|
+
console.log(
|
|
39
|
+
`[${(/* @__PURE__ */ new Date()).toISOString()}] ${level} ${method} ${path} ${status} ${elapsed}ms req=${reqId}`
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function rateLimiter(config) {
|
|
44
|
+
const limiter = new KeyedRateLimiter({
|
|
45
|
+
maxTokens: config.limit,
|
|
46
|
+
refillRate: config.limit / config.windowSec
|
|
47
|
+
});
|
|
48
|
+
return async (c, next) => {
|
|
49
|
+
if (config.skipPaths?.some((p) => c.req.path.startsWith(p))) {
|
|
50
|
+
return next();
|
|
51
|
+
}
|
|
52
|
+
const key = config.keyFn?.(c) || c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "unknown";
|
|
53
|
+
if (!limiter.tryConsume(key)) {
|
|
54
|
+
const retryAfter = Math.ceil(limiter.getRetryAfterMs(key) / 1e3);
|
|
55
|
+
c.header("Retry-After", String(retryAfter));
|
|
56
|
+
c.header("X-RateLimit-Limit", String(config.limit));
|
|
57
|
+
c.header("X-RateLimit-Remaining", "0");
|
|
58
|
+
return c.json(
|
|
59
|
+
{ error: "Too many requests", retryAfter },
|
|
60
|
+
429
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
await next();
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function securityHeaders() {
|
|
67
|
+
return async (c, next) => {
|
|
68
|
+
await next();
|
|
69
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
70
|
+
c.header("X-Frame-Options", "DENY");
|
|
71
|
+
c.header("X-XSS-Protection", "0");
|
|
72
|
+
c.header("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
73
|
+
c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
74
|
+
if (c.req.url.startsWith("https://") || c.req.header("x-forwarded-proto") === "https") {
|
|
75
|
+
c.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function errorHandler() {
|
|
80
|
+
return async (c, next) => {
|
|
81
|
+
try {
|
|
82
|
+
await next();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const reqId = c.get("requestId");
|
|
85
|
+
const status = err.status || err.statusCode || 500;
|
|
86
|
+
const message = status >= 500 ? "Internal server error" : err.message;
|
|
87
|
+
if (status >= 500) {
|
|
88
|
+
console.error(`[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR req=${reqId}`, err);
|
|
89
|
+
}
|
|
90
|
+
const body = {
|
|
91
|
+
error: message,
|
|
92
|
+
code: err.code,
|
|
93
|
+
requestId: reqId
|
|
94
|
+
};
|
|
95
|
+
if (status === 400 && err.details) {
|
|
96
|
+
body.details = err.details;
|
|
97
|
+
}
|
|
98
|
+
return c.json(body, status);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
var ValidationError = class extends Error {
|
|
103
|
+
status = 400;
|
|
104
|
+
code = "VALIDATION_ERROR";
|
|
105
|
+
details;
|
|
106
|
+
constructor(details) {
|
|
107
|
+
const fields = Object.keys(details).join(", ");
|
|
108
|
+
super(`Validation failed: ${fields}`);
|
|
109
|
+
this.details = details;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
function validate(body, validators) {
|
|
113
|
+
const errors = {};
|
|
114
|
+
for (const v of validators) {
|
|
115
|
+
const value = body[v.field];
|
|
116
|
+
if (value === void 0 || value === null || value === "") {
|
|
117
|
+
if (v.required) errors[v.field] = "Required";
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
switch (v.type) {
|
|
121
|
+
case "string":
|
|
122
|
+
if (typeof value !== "string") {
|
|
123
|
+
errors[v.field] = "Must be a string";
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
if (v.minLength && value.length < v.minLength) errors[v.field] = `Min length: ${v.minLength}`;
|
|
127
|
+
if (v.maxLength && value.length > v.maxLength) errors[v.field] = `Max length: ${v.maxLength}`;
|
|
128
|
+
if (v.pattern && !v.pattern.test(value)) errors[v.field] = "Invalid format";
|
|
129
|
+
break;
|
|
130
|
+
case "number":
|
|
131
|
+
if (typeof value !== "number" || isNaN(value)) {
|
|
132
|
+
errors[v.field] = "Must be a number";
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
if (v.min !== void 0 && value < v.min) errors[v.field] = `Min: ${v.min}`;
|
|
136
|
+
if (v.max !== void 0 && value > v.max) errors[v.field] = `Max: ${v.max}`;
|
|
137
|
+
break;
|
|
138
|
+
case "boolean":
|
|
139
|
+
if (typeof value !== "boolean") errors[v.field] = "Must be a boolean";
|
|
140
|
+
break;
|
|
141
|
+
case "email":
|
|
142
|
+
if (typeof value !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
143
|
+
errors[v.field] = "Invalid email";
|
|
144
|
+
break;
|
|
145
|
+
case "url":
|
|
146
|
+
try {
|
|
147
|
+
new URL(value);
|
|
148
|
+
} catch {
|
|
149
|
+
errors[v.field] = "Invalid URL";
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
case "uuid":
|
|
153
|
+
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))
|
|
154
|
+
errors[v.field] = "Invalid UUID";
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (Object.keys(errors).length > 0) {
|
|
159
|
+
throw new ValidationError(errors);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function auditLogger(db) {
|
|
163
|
+
const AUDIT_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
164
|
+
return async (c, next) => {
|
|
165
|
+
await next();
|
|
166
|
+
if (!AUDIT_METHODS.has(c.req.method)) return;
|
|
167
|
+
if (c.res.status >= 400) return;
|
|
168
|
+
try {
|
|
169
|
+
const userId = c.get("userId") || "anonymous";
|
|
170
|
+
const path = c.req.path;
|
|
171
|
+
const method = c.req.method;
|
|
172
|
+
const segments = path.split("/").filter(Boolean);
|
|
173
|
+
const resource = segments[segments.length - 2] || segments[segments.length - 1] || "unknown";
|
|
174
|
+
const actionMap = {
|
|
175
|
+
POST: "create",
|
|
176
|
+
PUT: "update",
|
|
177
|
+
PATCH: "update",
|
|
178
|
+
DELETE: "delete"
|
|
179
|
+
};
|
|
180
|
+
const action = `${resource}.${actionMap[method] || method.toLowerCase()}`;
|
|
181
|
+
await db.logEvent({
|
|
182
|
+
actor: userId,
|
|
183
|
+
actorType: "user",
|
|
184
|
+
action,
|
|
185
|
+
resource: path,
|
|
186
|
+
ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip")
|
|
187
|
+
});
|
|
188
|
+
} catch {
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
var ROLE_HIERARCHY = {
|
|
193
|
+
viewer: 0,
|
|
194
|
+
member: 1,
|
|
195
|
+
admin: 2,
|
|
196
|
+
owner: 3
|
|
197
|
+
};
|
|
198
|
+
function requireRole(minRole) {
|
|
199
|
+
return async (c, next) => {
|
|
200
|
+
const userRole = c.get("userRole");
|
|
201
|
+
if (c.get("authType") === "api-key") {
|
|
202
|
+
return next();
|
|
203
|
+
}
|
|
204
|
+
if (!userRole || ROLE_HIERARCHY[userRole] < ROLE_HIERARCHY[minRole]) {
|
|
205
|
+
return c.json({
|
|
206
|
+
error: "Insufficient permissions",
|
|
207
|
+
required: minRole,
|
|
208
|
+
current: userRole || "none"
|
|
209
|
+
}, 403);
|
|
210
|
+
}
|
|
211
|
+
return next();
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/admin/routes.ts
|
|
216
|
+
function createAdminRoutes(db) {
|
|
217
|
+
const api = new Hono();
|
|
218
|
+
api.get("/stats", async (c) => {
|
|
219
|
+
const stats = await db.getStats();
|
|
220
|
+
return c.json(stats);
|
|
221
|
+
});
|
|
222
|
+
api.get("/agents", async (c) => {
|
|
223
|
+
const status = c.req.query("status");
|
|
224
|
+
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 200);
|
|
225
|
+
const offset = Math.max(parseInt(c.req.query("offset") || "0"), 0);
|
|
226
|
+
const agents = await db.listAgents({ status, limit, offset });
|
|
227
|
+
const total = await db.countAgents(status);
|
|
228
|
+
return c.json({ agents, total, limit, offset });
|
|
229
|
+
});
|
|
230
|
+
api.get("/agents/:id", async (c) => {
|
|
231
|
+
const agent = await db.getAgent(c.req.param("id"));
|
|
232
|
+
if (!agent) return c.json({ error: "Agent not found" }, 404);
|
|
233
|
+
return c.json(agent);
|
|
234
|
+
});
|
|
235
|
+
api.post("/agents", async (c) => {
|
|
236
|
+
const body = await c.req.json();
|
|
237
|
+
validate(body, [
|
|
238
|
+
{ field: "name", type: "string", required: true, minLength: 1, maxLength: 64, pattern: /^[a-zA-Z0-9_-]+$/ },
|
|
239
|
+
{ field: "email", type: "email" },
|
|
240
|
+
{ field: "role", type: "string", maxLength: 32 }
|
|
241
|
+
]);
|
|
242
|
+
const existing = await db.getAgentByName(body.name);
|
|
243
|
+
if (existing) {
|
|
244
|
+
return c.json({ error: "Agent name already exists" }, 409);
|
|
245
|
+
}
|
|
246
|
+
const userId = c.get("userId") || "system";
|
|
247
|
+
const agent = await db.createAgent({ ...body, createdBy: userId });
|
|
248
|
+
return c.json(agent, 201);
|
|
249
|
+
});
|
|
250
|
+
api.patch("/agents/:id", async (c) => {
|
|
251
|
+
const id = c.req.param("id");
|
|
252
|
+
const existing = await db.getAgent(id);
|
|
253
|
+
if (!existing) return c.json({ error: "Agent not found" }, 404);
|
|
254
|
+
const body = await c.req.json();
|
|
255
|
+
validate(body, [
|
|
256
|
+
{ field: "name", type: "string", minLength: 1, maxLength: 64 },
|
|
257
|
+
{ field: "email", type: "email" },
|
|
258
|
+
{ field: "role", type: "string", maxLength: 32 },
|
|
259
|
+
{ field: "status", type: "string", pattern: /^(active|archived|suspended)$/ }
|
|
260
|
+
]);
|
|
261
|
+
if (body.name && body.name !== existing.name) {
|
|
262
|
+
const conflict = await db.getAgentByName(body.name);
|
|
263
|
+
if (conflict) return c.json({ error: "Agent name already exists" }, 409);
|
|
264
|
+
}
|
|
265
|
+
const agent = await db.updateAgent(id, body);
|
|
266
|
+
return c.json(agent);
|
|
267
|
+
});
|
|
268
|
+
api.post("/agents/:id/archive", async (c) => {
|
|
269
|
+
const existing = await db.getAgent(c.req.param("id"));
|
|
270
|
+
if (!existing) return c.json({ error: "Agent not found" }, 404);
|
|
271
|
+
if (existing.status === "archived") return c.json({ error: "Agent already archived" }, 400);
|
|
272
|
+
await db.archiveAgent(c.req.param("id"));
|
|
273
|
+
return c.json({ ok: true, status: "archived" });
|
|
274
|
+
});
|
|
275
|
+
api.post("/agents/:id/restore", async (c) => {
|
|
276
|
+
const existing = await db.getAgent(c.req.param("id"));
|
|
277
|
+
if (!existing) return c.json({ error: "Agent not found" }, 404);
|
|
278
|
+
if (existing.status !== "archived") return c.json({ error: "Agent is not archived" }, 400);
|
|
279
|
+
await db.updateAgent(c.req.param("id"), { status: "active" });
|
|
280
|
+
return c.json({ ok: true, status: "active" });
|
|
281
|
+
});
|
|
282
|
+
api.delete("/agents/:id", requireRole("admin"), async (c) => {
|
|
283
|
+
const existing = await db.getAgent(c.req.param("id"));
|
|
284
|
+
if (!existing) return c.json({ error: "Agent not found" }, 404);
|
|
285
|
+
await db.deleteAgent(c.req.param("id"));
|
|
286
|
+
return c.json({ ok: true });
|
|
287
|
+
});
|
|
288
|
+
api.get("/users", requireRole("admin"), async (c) => {
|
|
289
|
+
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 200);
|
|
290
|
+
const offset = Math.max(parseInt(c.req.query("offset") || "0"), 0);
|
|
291
|
+
const users = await db.listUsers({ limit, offset });
|
|
292
|
+
const safe = users.map(({ passwordHash, ...u }) => u);
|
|
293
|
+
return c.json({ users: safe, limit, offset });
|
|
294
|
+
});
|
|
295
|
+
api.post("/users", requireRole("admin"), async (c) => {
|
|
296
|
+
const body = await c.req.json();
|
|
297
|
+
validate(body, [
|
|
298
|
+
{ field: "email", type: "email", required: true },
|
|
299
|
+
{ field: "name", type: "string", required: true, minLength: 1, maxLength: 128 },
|
|
300
|
+
{ field: "role", type: "string", required: true, pattern: /^(owner|admin|member|viewer)$/ },
|
|
301
|
+
{ field: "password", type: "string", minLength: 8, maxLength: 128 }
|
|
302
|
+
]);
|
|
303
|
+
const existing = await db.getUserByEmail(body.email);
|
|
304
|
+
if (existing) return c.json({ error: "Email already registered" }, 409);
|
|
305
|
+
const user = await db.createUser(body);
|
|
306
|
+
const { passwordHash, ...safe } = user;
|
|
307
|
+
return c.json(safe, 201);
|
|
308
|
+
});
|
|
309
|
+
api.patch("/users/:id", requireRole("admin"), async (c) => {
|
|
310
|
+
const existing = await db.getUser(c.req.param("id"));
|
|
311
|
+
if (!existing) return c.json({ error: "User not found" }, 404);
|
|
312
|
+
const body = await c.req.json();
|
|
313
|
+
validate(body, [
|
|
314
|
+
{ field: "email", type: "email" },
|
|
315
|
+
{ field: "name", type: "string", minLength: 1, maxLength: 128 },
|
|
316
|
+
{ field: "role", type: "string", pattern: /^(owner|admin|member|viewer)$/ }
|
|
317
|
+
]);
|
|
318
|
+
const user = await db.updateUser(c.req.param("id"), body);
|
|
319
|
+
const { passwordHash, ...safe } = user;
|
|
320
|
+
return c.json(safe);
|
|
321
|
+
});
|
|
322
|
+
api.delete("/users/:id", requireRole("owner"), async (c) => {
|
|
323
|
+
const existing = await db.getUser(c.req.param("id"));
|
|
324
|
+
if (!existing) return c.json({ error: "User not found" }, 404);
|
|
325
|
+
const requesterId = c.get("userId");
|
|
326
|
+
if (requesterId === c.req.param("id")) {
|
|
327
|
+
return c.json({ error: "Cannot delete your own account" }, 400);
|
|
328
|
+
}
|
|
329
|
+
await db.deleteUser(c.req.param("id"));
|
|
330
|
+
return c.json({ ok: true });
|
|
331
|
+
});
|
|
332
|
+
api.get("/audit", requireRole("admin"), async (c) => {
|
|
333
|
+
const filters = {
|
|
334
|
+
actor: c.req.query("actor") || void 0,
|
|
335
|
+
action: c.req.query("action") || void 0,
|
|
336
|
+
resource: c.req.query("resource") || void 0,
|
|
337
|
+
from: c.req.query("from") ? new Date(c.req.query("from")) : void 0,
|
|
338
|
+
to: c.req.query("to") ? new Date(c.req.query("to")) : void 0,
|
|
339
|
+
limit: Math.min(parseInt(c.req.query("limit") || "50"), 500),
|
|
340
|
+
offset: Math.max(parseInt(c.req.query("offset") || "0"), 0)
|
|
341
|
+
};
|
|
342
|
+
if (filters.from && isNaN(filters.from.getTime())) {
|
|
343
|
+
return c.json({ error: 'Invalid "from" date' }, 400);
|
|
344
|
+
}
|
|
345
|
+
if (filters.to && isNaN(filters.to.getTime())) {
|
|
346
|
+
return c.json({ error: 'Invalid "to" date' }, 400);
|
|
347
|
+
}
|
|
348
|
+
const result = await db.queryAudit(filters);
|
|
349
|
+
return c.json(result);
|
|
350
|
+
});
|
|
351
|
+
api.get("/api-keys", requireRole("admin"), async (c) => {
|
|
352
|
+
const keys = await db.listApiKeys();
|
|
353
|
+
const safe = keys.map(({ keyHash, ...k }) => k);
|
|
354
|
+
return c.json({ keys: safe });
|
|
355
|
+
});
|
|
356
|
+
api.post("/api-keys", requireRole("admin"), async (c) => {
|
|
357
|
+
const body = await c.req.json();
|
|
358
|
+
validate(body, [
|
|
359
|
+
{ field: "name", type: "string", required: true, minLength: 1, maxLength: 64 }
|
|
360
|
+
]);
|
|
361
|
+
const userId = c.get("userId") || "system";
|
|
362
|
+
const scopes = Array.isArray(body.scopes) ? body.scopes : ["*"];
|
|
363
|
+
const expiresAt = body.expiresAt ? new Date(body.expiresAt) : void 0;
|
|
364
|
+
const { key, plaintext } = await db.createApiKey({
|
|
365
|
+
name: body.name,
|
|
366
|
+
scopes,
|
|
367
|
+
createdBy: userId,
|
|
368
|
+
expiresAt
|
|
369
|
+
});
|
|
370
|
+
const { keyHash, ...safeKey } = key;
|
|
371
|
+
return c.json({
|
|
372
|
+
key: safeKey,
|
|
373
|
+
plaintext,
|
|
374
|
+
warning: "Store this key securely. It will not be shown again."
|
|
375
|
+
}, 201);
|
|
376
|
+
});
|
|
377
|
+
api.delete("/api-keys/:id", requireRole("admin"), async (c) => {
|
|
378
|
+
const existing = await db.getApiKey(c.req.param("id"));
|
|
379
|
+
if (!existing) return c.json({ error: "API key not found" }, 404);
|
|
380
|
+
await db.revokeApiKey(c.req.param("id"));
|
|
381
|
+
return c.json({ ok: true, revoked: true });
|
|
382
|
+
});
|
|
383
|
+
api.get("/rules", async (c) => {
|
|
384
|
+
const agentId = c.req.query("agentId") || void 0;
|
|
385
|
+
const rules = await db.getRules(agentId);
|
|
386
|
+
return c.json({ rules });
|
|
387
|
+
});
|
|
388
|
+
api.post("/rules", async (c) => {
|
|
389
|
+
const body = await c.req.json();
|
|
390
|
+
validate(body, [
|
|
391
|
+
{ field: "name", type: "string", required: true, minLength: 1, maxLength: 128 }
|
|
392
|
+
]);
|
|
393
|
+
if (body.conditions && typeof body.conditions !== "object") {
|
|
394
|
+
return c.json({ error: "conditions must be an object" }, 400);
|
|
395
|
+
}
|
|
396
|
+
if (body.actions && typeof body.actions !== "object") {
|
|
397
|
+
return c.json({ error: "actions must be an object" }, 400);
|
|
398
|
+
}
|
|
399
|
+
const rule = await db.createRule({
|
|
400
|
+
name: body.name,
|
|
401
|
+
agentId: body.agentId,
|
|
402
|
+
conditions: body.conditions || {},
|
|
403
|
+
actions: body.actions || {},
|
|
404
|
+
priority: body.priority ?? 0,
|
|
405
|
+
enabled: body.enabled ?? true
|
|
406
|
+
});
|
|
407
|
+
return c.json(rule, 201);
|
|
408
|
+
});
|
|
409
|
+
api.patch("/rules/:id", async (c) => {
|
|
410
|
+
const body = await c.req.json();
|
|
411
|
+
const rule = await db.updateRule(c.req.param("id"), body);
|
|
412
|
+
return c.json(rule);
|
|
413
|
+
});
|
|
414
|
+
api.delete("/rules/:id", async (c) => {
|
|
415
|
+
await db.deleteRule(c.req.param("id"));
|
|
416
|
+
return c.json({ ok: true });
|
|
417
|
+
});
|
|
418
|
+
api.get("/settings", async (c) => {
|
|
419
|
+
const settings = await db.getSettings();
|
|
420
|
+
if (!settings) return c.json({ error: "Not configured" }, 404);
|
|
421
|
+
const safe = { ...settings };
|
|
422
|
+
if (safe.smtpPass) safe.smtpPass = "***";
|
|
423
|
+
if (safe.dkimPrivateKey) safe.dkimPrivateKey = "***";
|
|
424
|
+
return c.json(safe);
|
|
425
|
+
});
|
|
426
|
+
api.patch("/settings", requireRole("admin"), async (c) => {
|
|
427
|
+
const body = await c.req.json();
|
|
428
|
+
validate(body, [
|
|
429
|
+
{ field: "name", type: "string", minLength: 1, maxLength: 128 },
|
|
430
|
+
{ field: "domain", type: "string", maxLength: 253 },
|
|
431
|
+
{ field: "primaryColor", type: "string", pattern: /^#[0-9a-fA-F]{6}$/ },
|
|
432
|
+
{ field: "logoUrl", type: "url" }
|
|
433
|
+
]);
|
|
434
|
+
const settings = await db.updateSettings(body);
|
|
435
|
+
return c.json(settings);
|
|
436
|
+
});
|
|
437
|
+
api.get("/retention", requireRole("admin"), async (c) => {
|
|
438
|
+
const policy = await db.getRetentionPolicy();
|
|
439
|
+
return c.json(policy);
|
|
440
|
+
});
|
|
441
|
+
api.put("/retention", requireRole("owner"), async (c) => {
|
|
442
|
+
const body = await c.req.json();
|
|
443
|
+
validate(body, [
|
|
444
|
+
{ field: "enabled", type: "boolean", required: true },
|
|
445
|
+
{ field: "retainDays", type: "number", required: true, min: 1, max: 3650 },
|
|
446
|
+
{ field: "archiveFirst", type: "boolean" }
|
|
447
|
+
]);
|
|
448
|
+
await db.setRetentionPolicy({
|
|
449
|
+
enabled: body.enabled,
|
|
450
|
+
retainDays: body.retainDays,
|
|
451
|
+
excludeTags: body.excludeTags || [],
|
|
452
|
+
archiveFirst: body.archiveFirst ?? true
|
|
453
|
+
});
|
|
454
|
+
return c.json({ ok: true });
|
|
455
|
+
});
|
|
456
|
+
return api;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/auth/routes.ts
|
|
460
|
+
import { Hono as Hono2 } from "hono";
|
|
461
|
+
function createAuthRoutes(db, jwtSecret) {
|
|
462
|
+
const auth = new Hono2();
|
|
463
|
+
auth.post("/login", async (c) => {
|
|
464
|
+
const { email, password } = await c.req.json();
|
|
465
|
+
if (!email || !password) {
|
|
466
|
+
return c.json({ error: "Email and password required" }, 400);
|
|
467
|
+
}
|
|
468
|
+
const user = await db.getUserByEmail(email);
|
|
469
|
+
if (!user || !user.passwordHash) {
|
|
470
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
471
|
+
}
|
|
472
|
+
const { default: bcrypt } = await import("bcryptjs");
|
|
473
|
+
const valid = await bcrypt.compare(password, user.passwordHash);
|
|
474
|
+
if (!valid) {
|
|
475
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
476
|
+
}
|
|
477
|
+
const { SignJWT } = await import("jose");
|
|
478
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
479
|
+
const token = await new SignJWT({ sub: user.id, email: user.email, role: user.role }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("24h").sign(secret);
|
|
480
|
+
await db.updateUser(user.id, { lastLoginAt: /* @__PURE__ */ new Date() });
|
|
481
|
+
await db.logEvent({
|
|
482
|
+
actor: user.id,
|
|
483
|
+
actorType: "user",
|
|
484
|
+
action: "auth.login",
|
|
485
|
+
resource: `user:${user.id}`,
|
|
486
|
+
details: { method: "password" },
|
|
487
|
+
ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
488
|
+
});
|
|
489
|
+
return c.json({
|
|
490
|
+
token,
|
|
491
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role }
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
auth.post("/refresh", async (c) => {
|
|
495
|
+
const authHeader = c.req.header("Authorization");
|
|
496
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
497
|
+
return c.json({ error: "Token required" }, 401);
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const { jwtVerify, SignJWT } = await import("jose");
|
|
501
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
502
|
+
const { payload } = await jwtVerify(authHeader.slice(7), secret);
|
|
503
|
+
const user = await db.getUser(payload.sub);
|
|
504
|
+
if (!user) return c.json({ error: "User not found" }, 401);
|
|
505
|
+
const token = await new SignJWT({ sub: user.id, email: user.email, role: user.role }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("24h").sign(secret);
|
|
506
|
+
return c.json({ token });
|
|
507
|
+
} catch {
|
|
508
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
auth.get("/me", async (c) => {
|
|
512
|
+
const authHeader = c.req.header("Authorization");
|
|
513
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
514
|
+
return c.json({ error: "Token required" }, 401);
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const { jwtVerify } = await import("jose");
|
|
518
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
519
|
+
const { payload } = await jwtVerify(authHeader.slice(7), secret);
|
|
520
|
+
const user = await db.getUser(payload.sub);
|
|
521
|
+
if (!user) return c.json({ error: "User not found" }, 404);
|
|
522
|
+
const { passwordHash, ...safe } = user;
|
|
523
|
+
return c.json(safe);
|
|
524
|
+
} catch {
|
|
525
|
+
return c.json({ error: "Invalid token" }, 401);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
auth.post("/saml/callback", async (c) => {
|
|
529
|
+
return c.json({ error: "SAML not yet configured" }, 501);
|
|
530
|
+
});
|
|
531
|
+
auth.get("/saml/metadata", async (c) => {
|
|
532
|
+
return c.json({ error: "SAML not yet configured" }, 501);
|
|
533
|
+
});
|
|
534
|
+
auth.get("/oidc/authorize", async (c) => {
|
|
535
|
+
return c.json({ error: "OIDC not yet configured" }, 501);
|
|
536
|
+
});
|
|
537
|
+
auth.get("/oidc/callback", async (c) => {
|
|
538
|
+
return c.json({ error: "OIDC not yet configured" }, 501);
|
|
539
|
+
});
|
|
540
|
+
return auth;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/server.ts
|
|
544
|
+
function createServer(config) {
|
|
545
|
+
const app = new Hono3();
|
|
546
|
+
const dbBreaker = new CircuitBreaker({
|
|
547
|
+
failureThreshold: 5,
|
|
548
|
+
recoveryTimeMs: 3e4,
|
|
549
|
+
timeout: 1e4
|
|
550
|
+
});
|
|
551
|
+
const healthMonitor = new HealthMonitor(
|
|
552
|
+
async () => {
|
|
553
|
+
await config.db.getStats();
|
|
554
|
+
},
|
|
555
|
+
{ intervalMs: 3e4, timeoutMs: 5e3, unhealthyThreshold: 3 }
|
|
556
|
+
);
|
|
557
|
+
healthMonitor.onStatusChange((healthy) => {
|
|
558
|
+
console.log(
|
|
559
|
+
`[${(/* @__PURE__ */ new Date()).toISOString()}] ${healthy ? "\u2705" : "\u274C"} Database health: ${healthy ? "healthy" : "unhealthy"}`
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
app.use("*", requestIdMiddleware());
|
|
563
|
+
app.use("*", errorHandler());
|
|
564
|
+
app.use("*", securityHeaders());
|
|
565
|
+
app.use("*", cors({
|
|
566
|
+
origin: config.corsOrigins || "*",
|
|
567
|
+
credentials: true,
|
|
568
|
+
allowHeaders: ["Content-Type", "Authorization", "X-API-Key", "X-Request-Id"],
|
|
569
|
+
exposeHeaders: ["X-Request-Id", "X-RateLimit-Limit", "X-RateLimit-Remaining", "Retry-After"]
|
|
570
|
+
}));
|
|
571
|
+
app.use("*", rateLimiter({
|
|
572
|
+
limit: config.rateLimit ?? 120,
|
|
573
|
+
windowSec: 60,
|
|
574
|
+
skipPaths: ["/health", "/ready"]
|
|
575
|
+
}));
|
|
576
|
+
if (config.logging !== false) {
|
|
577
|
+
app.use("*", requestLogger());
|
|
578
|
+
}
|
|
579
|
+
app.get("/health", (c) => c.json({
|
|
580
|
+
status: "ok",
|
|
581
|
+
version: "0.2.2",
|
|
582
|
+
uptime: process.uptime()
|
|
583
|
+
}));
|
|
584
|
+
app.get("/ready", async (c) => {
|
|
585
|
+
const dbHealthy = healthMonitor.isHealthy();
|
|
586
|
+
const status = dbHealthy ? 200 : 503;
|
|
587
|
+
return c.json({
|
|
588
|
+
ready: dbHealthy,
|
|
589
|
+
checks: {
|
|
590
|
+
database: dbHealthy ? "ok" : "unhealthy",
|
|
591
|
+
circuitBreaker: dbBreaker.getState()
|
|
592
|
+
}
|
|
593
|
+
}, status);
|
|
594
|
+
});
|
|
595
|
+
const authRoutes = createAuthRoutes(config.db, config.jwtSecret);
|
|
596
|
+
app.route("/auth", authRoutes);
|
|
597
|
+
const api = new Hono3();
|
|
598
|
+
api.use("*", async (c, next) => {
|
|
599
|
+
const apiKeyHeader = c.req.header("X-API-Key");
|
|
600
|
+
if (apiKeyHeader) {
|
|
601
|
+
const key = await dbBreaker.execute(() => config.db.validateApiKey(apiKeyHeader));
|
|
602
|
+
if (!key) return c.json({ error: "Invalid API key" }, 401);
|
|
603
|
+
c.set("userId", key.createdBy);
|
|
604
|
+
c.set("authType", "api-key");
|
|
605
|
+
c.set("apiKeyScopes", key.scopes);
|
|
606
|
+
return next();
|
|
607
|
+
}
|
|
608
|
+
const authHeader = c.req.header("Authorization");
|
|
609
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
610
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
const { jwtVerify } = await import("jose");
|
|
614
|
+
const secret = new TextEncoder().encode(config.jwtSecret);
|
|
615
|
+
const { payload } = await jwtVerify(authHeader.slice(7), secret);
|
|
616
|
+
c.set("userId", payload.sub);
|
|
617
|
+
c.set("userRole", payload.role);
|
|
618
|
+
c.set("authType", "jwt");
|
|
619
|
+
return next();
|
|
620
|
+
} catch {
|
|
621
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
api.use("*", auditLogger(config.db));
|
|
625
|
+
const adminRoutes = createAdminRoutes(config.db);
|
|
626
|
+
api.route("/", adminRoutes);
|
|
627
|
+
let engineInitialized = false;
|
|
628
|
+
api.all("/engine/*", async (c, next) => {
|
|
629
|
+
try {
|
|
630
|
+
const { engineRoutes, setEngineDb } = await import("./routes-2JEPIIKC.js");
|
|
631
|
+
const { EngineDatabase } = await import("./db-adapter-DEWEFNIV.js");
|
|
632
|
+
if (!engineInitialized) {
|
|
633
|
+
const dbType = config.db.type || config.db.config?.type || "sqlite";
|
|
634
|
+
const dialectMap = {
|
|
635
|
+
sqlite: "sqlite",
|
|
636
|
+
postgres: "postgres",
|
|
637
|
+
postgresql: "postgres",
|
|
638
|
+
mysql: "mysql",
|
|
639
|
+
mariadb: "mysql",
|
|
640
|
+
turso: "turso",
|
|
641
|
+
libsql: "turso",
|
|
642
|
+
mongodb: "mongodb",
|
|
643
|
+
dynamodb: "dynamodb"
|
|
644
|
+
};
|
|
645
|
+
const dialect = dialectMap[dbType] || "sqlite";
|
|
646
|
+
const engineDbWrapper = {
|
|
647
|
+
run: async (sql, params) => {
|
|
648
|
+
await config.db.run?.(sql, params) ?? config.db.query?.(sql, params);
|
|
649
|
+
},
|
|
650
|
+
get: async (sql, params) => {
|
|
651
|
+
if (config.db.get) return config.db.get(sql, params);
|
|
652
|
+
const rows = await config.db.query?.(sql, params) ?? [];
|
|
653
|
+
return rows[0];
|
|
654
|
+
},
|
|
655
|
+
all: async (sql, params) => {
|
|
656
|
+
if (config.db.all) return config.db.all(sql, params);
|
|
657
|
+
return await config.db.query?.(sql, params) ?? [];
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
const engineDb = new EngineDatabase(engineDbWrapper, dialect, config.db.rawDriver);
|
|
661
|
+
const migrationResult = await engineDb.migrate();
|
|
662
|
+
console.log(`[engine] Migrations: ${migrationResult.applied} applied, ${migrationResult.total} total`);
|
|
663
|
+
setEngineDb(engineDb);
|
|
664
|
+
engineInitialized = true;
|
|
665
|
+
}
|
|
666
|
+
const subPath = c.req.path.replace(/^\/api\/engine/, "") || "/";
|
|
667
|
+
const subReq = new Request(new URL(subPath, "http://localhost"), {
|
|
668
|
+
method: c.req.method,
|
|
669
|
+
headers: c.req.raw.headers,
|
|
670
|
+
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : void 0
|
|
671
|
+
});
|
|
672
|
+
return engineRoutes.fetch(subReq);
|
|
673
|
+
} catch (e) {
|
|
674
|
+
console.error("[engine] Error:", e.message);
|
|
675
|
+
return c.json({ error: "Engine module not available", detail: e.message }, 501);
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
app.route("/api", api);
|
|
679
|
+
let dashboardHtml = null;
|
|
680
|
+
function getDashboardHtml() {
|
|
681
|
+
if (!dashboardHtml) {
|
|
682
|
+
try {
|
|
683
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
684
|
+
dashboardHtml = readFileSync(join(dir, "dashboard", "index.html"), "utf-8");
|
|
685
|
+
} catch {
|
|
686
|
+
try {
|
|
687
|
+
dashboardHtml = readFileSync(join(process.cwd(), "node_modules", "@agenticmail", "enterprise", "dist", "dashboard", "index.html"), "utf-8");
|
|
688
|
+
} catch {
|
|
689
|
+
dashboardHtml = "<html><body><h1>Dashboard not found</h1><p>The dashboard HTML file could not be located.</p></body></html>";
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return dashboardHtml;
|
|
694
|
+
}
|
|
695
|
+
app.get("/", (c) => c.redirect("/dashboard"));
|
|
696
|
+
app.get("/dashboard", (c) => c.html(getDashboardHtml()));
|
|
697
|
+
app.get("/dashboard/*", (c) => c.html(getDashboardHtml()));
|
|
698
|
+
app.notFound((c) => {
|
|
699
|
+
return c.json({ error: "Not found", path: c.req.path }, 404);
|
|
700
|
+
});
|
|
701
|
+
return {
|
|
702
|
+
app,
|
|
703
|
+
healthMonitor,
|
|
704
|
+
start: () => {
|
|
705
|
+
return new Promise((resolve) => {
|
|
706
|
+
const server = serve(
|
|
707
|
+
{ fetch: app.fetch, port: config.port },
|
|
708
|
+
(info) => {
|
|
709
|
+
console.log(`
|
|
710
|
+
\u{1F3E2} AgenticMail Enterprise`);
|
|
711
|
+
console.log(` API: http://localhost:${info.port}/api`);
|
|
712
|
+
console.log(` Auth: http://localhost:${info.port}/auth`);
|
|
713
|
+
console.log(` Health: http://localhost:${info.port}/health`);
|
|
714
|
+
console.log("");
|
|
715
|
+
healthMonitor.start();
|
|
716
|
+
const shutdown = () => {
|
|
717
|
+
console.log("\n\u23F3 Shutting down gracefully...");
|
|
718
|
+
healthMonitor.stop();
|
|
719
|
+
server.close(() => {
|
|
720
|
+
config.db.disconnect().then(() => {
|
|
721
|
+
console.log("\u2705 Shutdown complete");
|
|
722
|
+
process.exit(0);
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
setTimeout(() => {
|
|
726
|
+
process.exit(1);
|
|
727
|
+
}, 1e4).unref();
|
|
728
|
+
};
|
|
729
|
+
process.on("SIGINT", shutdown);
|
|
730
|
+
process.on("SIGTERM", shutdown);
|
|
731
|
+
resolve({
|
|
732
|
+
close: () => {
|
|
733
|
+
healthMonitor.stop();
|
|
734
|
+
server.close();
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export {
|
|
745
|
+
requestIdMiddleware,
|
|
746
|
+
requestLogger,
|
|
747
|
+
rateLimiter,
|
|
748
|
+
securityHeaders,
|
|
749
|
+
errorHandler,
|
|
750
|
+
ValidationError,
|
|
751
|
+
validate,
|
|
752
|
+
auditLogger,
|
|
753
|
+
requireRole,
|
|
754
|
+
createAdminRoutes,
|
|
755
|
+
createAuthRoutes,
|
|
756
|
+
createServer
|
|
757
|
+
};
|