@agenticmail/enterprise 0.2.1
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/ARCHITECTURE.md +183 -0
- package/agenticmail-enterprise.db +0 -0
- package/dashboards/README.md +120 -0
- package/dashboards/dotnet/Program.cs +261 -0
- package/dashboards/express/app.js +146 -0
- package/dashboards/go/main.go +513 -0
- package/dashboards/html/index.html +535 -0
- package/dashboards/java/AgenticMailDashboard.java +376 -0
- package/dashboards/php/index.php +414 -0
- package/dashboards/python/app.py +273 -0
- package/dashboards/ruby/app.rb +195 -0
- package/dist/chunk-77IDQJL3.js +7 -0
- package/dist/chunk-7RGCCHIT.js +115 -0
- package/dist/chunk-DXNKR3TG.js +1355 -0
- package/dist/chunk-IQWA44WT.js +970 -0
- package/dist/chunk-LCUZGIDH.js +965 -0
- package/dist/chunk-N2JVTNNJ.js +2553 -0
- package/dist/chunk-O462UJBH.js +363 -0
- package/dist/chunk-PNKVD2UK.js +26 -0
- package/dist/cli.js +218 -0
- package/dist/dashboard/index.html +558 -0
- package/dist/db-adapter-DEWEFNIV.js +7 -0
- package/dist/dynamodb-CCGL2E77.js +426 -0
- package/dist/engine/index.js +1261 -0
- package/dist/index.js +522 -0
- package/dist/mongodb-ODTXIVPV.js +319 -0
- package/dist/mysql-RM3S2FV5.js +521 -0
- package/dist/postgres-LN7A6MGQ.js +518 -0
- package/dist/routes-2JEPIIKC.js +441 -0
- package/dist/routes-74ZLKJKP.js +399 -0
- package/dist/server.js +7 -0
- package/dist/sqlite-3K5YOZ4K.js +439 -0
- package/dist/turso-LDWODSDI.js +442 -0
- package/package.json +49 -0
- package/src/admin/routes.ts +331 -0
- package/src/auth/routes.ts +130 -0
- package/src/cli.ts +260 -0
- package/src/dashboard/index.html +558 -0
- package/src/db/adapter.ts +230 -0
- package/src/db/dynamodb.ts +456 -0
- package/src/db/factory.ts +51 -0
- package/src/db/mongodb.ts +360 -0
- package/src/db/mysql.ts +472 -0
- package/src/db/postgres.ts +479 -0
- package/src/db/sql-schema.ts +123 -0
- package/src/db/sqlite.ts +391 -0
- package/src/db/turso.ts +411 -0
- package/src/deploy/fly.ts +368 -0
- package/src/deploy/managed.ts +213 -0
- package/src/engine/activity.ts +474 -0
- package/src/engine/agent-config.ts +429 -0
- package/src/engine/agenticmail-bridge.ts +296 -0
- package/src/engine/approvals.ts +278 -0
- package/src/engine/db-adapter.ts +682 -0
- package/src/engine/db-schema.ts +335 -0
- package/src/engine/deployer.ts +595 -0
- package/src/engine/index.ts +134 -0
- package/src/engine/knowledge.ts +486 -0
- package/src/engine/lifecycle.ts +635 -0
- package/src/engine/openclaw-hook.ts +371 -0
- package/src/engine/routes.ts +528 -0
- package/src/engine/skills.ts +473 -0
- package/src/engine/tenant.ts +345 -0
- package/src/engine/tool-catalog.ts +189 -0
- package/src/index.ts +64 -0
- package/src/lib/resilience.ts +326 -0
- package/src/middleware/index.ts +286 -0
- package/src/server.ts +310 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resilience Utilities
|
|
3
|
+
*
|
|
4
|
+
* Retry logic, circuit breakers, connection health checks,
|
|
5
|
+
* rate limiting, and graceful degradation patterns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ─── Retry with Exponential Backoff ──────────────────────
|
|
9
|
+
|
|
10
|
+
export interface RetryOptions {
|
|
11
|
+
maxAttempts: number;
|
|
12
|
+
baseDelayMs: number;
|
|
13
|
+
maxDelayMs: number;
|
|
14
|
+
backoffMultiplier: number;
|
|
15
|
+
retryableErrors?: (err: Error) => boolean;
|
|
16
|
+
onRetry?: (attempt: number, err: Error, delayMs: number) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_RETRY: RetryOptions = {
|
|
20
|
+
maxAttempts: 3,
|
|
21
|
+
baseDelayMs: 500,
|
|
22
|
+
maxDelayMs: 30_000,
|
|
23
|
+
backoffMultiplier: 2,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function withRetry<T>(
|
|
27
|
+
fn: () => Promise<T>,
|
|
28
|
+
opts: Partial<RetryOptions> = {},
|
|
29
|
+
): Promise<T> {
|
|
30
|
+
const config = { ...DEFAULT_RETRY, ...opts };
|
|
31
|
+
let lastError: Error = new Error('No attempts made');
|
|
32
|
+
|
|
33
|
+
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
|
|
34
|
+
try {
|
|
35
|
+
return await fn();
|
|
36
|
+
} catch (err: any) {
|
|
37
|
+
lastError = err;
|
|
38
|
+
|
|
39
|
+
if (attempt === config.maxAttempts) break;
|
|
40
|
+
if (config.retryableErrors && !config.retryableErrors(err)) break;
|
|
41
|
+
|
|
42
|
+
// Exponential backoff with jitter
|
|
43
|
+
const delay = Math.min(
|
|
44
|
+
config.baseDelayMs * Math.pow(config.backoffMultiplier, attempt - 1) + Math.random() * 200,
|
|
45
|
+
config.maxDelayMs,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
config.onRetry?.(attempt, err, delay);
|
|
49
|
+
await sleep(delay);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw lastError;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Circuit Breaker ─────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export type CircuitState = 'closed' | 'open' | 'half-open';
|
|
59
|
+
|
|
60
|
+
export interface CircuitBreakerOptions {
|
|
61
|
+
failureThreshold: number; // Failures before opening
|
|
62
|
+
recoveryTimeMs: number; // Time before half-open
|
|
63
|
+
successThreshold: number; // Successes in half-open to close
|
|
64
|
+
timeout?: number; // Per-call timeout in ms
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class CircuitBreaker {
|
|
68
|
+
private state: CircuitState = 'closed';
|
|
69
|
+
private failures = 0;
|
|
70
|
+
private successes = 0;
|
|
71
|
+
private lastFailureTime = 0;
|
|
72
|
+
private readonly opts: CircuitBreakerOptions;
|
|
73
|
+
|
|
74
|
+
constructor(opts: Partial<CircuitBreakerOptions> = {}) {
|
|
75
|
+
this.opts = {
|
|
76
|
+
failureThreshold: opts.failureThreshold ?? 5,
|
|
77
|
+
recoveryTimeMs: opts.recoveryTimeMs ?? 30_000,
|
|
78
|
+
successThreshold: opts.successThreshold ?? 2,
|
|
79
|
+
timeout: opts.timeout,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
|
84
|
+
if (this.state === 'open') {
|
|
85
|
+
if (Date.now() - this.lastFailureTime >= this.opts.recoveryTimeMs) {
|
|
86
|
+
this.state = 'half-open';
|
|
87
|
+
this.successes = 0;
|
|
88
|
+
} else {
|
|
89
|
+
throw new CircuitOpenError(
|
|
90
|
+
`Circuit breaker is open. Retry after ${this.opts.recoveryTimeMs}ms`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const result = this.opts.timeout
|
|
97
|
+
? await withTimeout(fn(), this.opts.timeout)
|
|
98
|
+
: await fn();
|
|
99
|
+
|
|
100
|
+
this.onSuccess();
|
|
101
|
+
return result;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
this.onFailure();
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private onSuccess(): void {
|
|
109
|
+
if (this.state === 'half-open') {
|
|
110
|
+
this.successes++;
|
|
111
|
+
if (this.successes >= this.opts.successThreshold) {
|
|
112
|
+
this.state = 'closed';
|
|
113
|
+
this.failures = 0;
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
this.failures = 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private onFailure(): void {
|
|
121
|
+
this.failures++;
|
|
122
|
+
this.lastFailureTime = Date.now();
|
|
123
|
+
if (this.failures >= this.opts.failureThreshold) {
|
|
124
|
+
this.state = 'open';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getState(): CircuitState { return this.state; }
|
|
129
|
+
reset(): void { this.state = 'closed'; this.failures = 0; this.successes = 0; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class CircuitOpenError extends Error {
|
|
133
|
+
constructor(message: string) {
|
|
134
|
+
super(message);
|
|
135
|
+
this.name = 'CircuitOpenError';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Rate Limiter (Token Bucket) ─────────────────────────
|
|
140
|
+
|
|
141
|
+
export interface RateLimiterOptions {
|
|
142
|
+
maxTokens: number; // Bucket capacity
|
|
143
|
+
refillRate: number; // Tokens per second
|
|
144
|
+
refillIntervalMs?: number; // How often to refill (default: 1000)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class RateLimiter {
|
|
148
|
+
private tokens: number;
|
|
149
|
+
private lastRefill: number;
|
|
150
|
+
private readonly opts: Required<RateLimiterOptions>;
|
|
151
|
+
|
|
152
|
+
constructor(opts: RateLimiterOptions) {
|
|
153
|
+
this.opts = {
|
|
154
|
+
maxTokens: opts.maxTokens,
|
|
155
|
+
refillRate: opts.refillRate,
|
|
156
|
+
refillIntervalMs: opts.refillIntervalMs ?? 1000,
|
|
157
|
+
};
|
|
158
|
+
this.tokens = this.opts.maxTokens;
|
|
159
|
+
this.lastRefill = Date.now();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Try to consume a token. Returns true if allowed, false if rate limited.
|
|
164
|
+
*/
|
|
165
|
+
tryConsume(count = 1): boolean {
|
|
166
|
+
this.refill();
|
|
167
|
+
if (this.tokens >= count) {
|
|
168
|
+
this.tokens -= count;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get time in ms until next token is available.
|
|
176
|
+
*/
|
|
177
|
+
getRetryAfterMs(): number {
|
|
178
|
+
this.refill();
|
|
179
|
+
if (this.tokens >= 1) return 0;
|
|
180
|
+
const tokensNeeded = 1 - this.tokens;
|
|
181
|
+
return Math.ceil((tokensNeeded / this.opts.refillRate) * 1000);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private refill(): void {
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const elapsed = now - this.lastRefill;
|
|
187
|
+
const tokensToAdd = (elapsed / 1000) * this.opts.refillRate;
|
|
188
|
+
this.tokens = Math.min(this.opts.maxTokens, this.tokens + tokensToAdd);
|
|
189
|
+
this.lastRefill = now;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Per-Key Rate Limiter (for API endpoints) ────────────
|
|
194
|
+
|
|
195
|
+
export class KeyedRateLimiter {
|
|
196
|
+
private limiters = new Map<string, RateLimiter>();
|
|
197
|
+
private readonly opts: RateLimiterOptions;
|
|
198
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
199
|
+
|
|
200
|
+
constructor(opts: RateLimiterOptions) {
|
|
201
|
+
this.opts = opts;
|
|
202
|
+
// Cleanup stale entries every 5 minutes
|
|
203
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), 5 * 60_000);
|
|
204
|
+
if (this.cleanupTimer && typeof this.cleanupTimer === 'object' && 'unref' in this.cleanupTimer) {
|
|
205
|
+
this.cleanupTimer.unref();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
tryConsume(key: string, count = 1): boolean {
|
|
210
|
+
let limiter = this.limiters.get(key);
|
|
211
|
+
if (!limiter) {
|
|
212
|
+
limiter = new RateLimiter(this.opts);
|
|
213
|
+
this.limiters.set(key, limiter);
|
|
214
|
+
}
|
|
215
|
+
return limiter.tryConsume(count);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
getRetryAfterMs(key: string): number {
|
|
219
|
+
const limiter = this.limiters.get(key);
|
|
220
|
+
return limiter ? limiter.getRetryAfterMs() : 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private cleanup(): void {
|
|
224
|
+
// Remove limiters that haven't been used (all have full tokens)
|
|
225
|
+
for (const [key, limiter] of this.limiters) {
|
|
226
|
+
if (limiter.getRetryAfterMs() === 0) {
|
|
227
|
+
this.limiters.delete(key);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
destroy(): void {
|
|
233
|
+
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
|
|
234
|
+
this.limiters.clear();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Connection Health Monitor ───────────────────────────
|
|
239
|
+
|
|
240
|
+
export interface HealthCheckOptions {
|
|
241
|
+
intervalMs: number; // How often to check (default: 30s)
|
|
242
|
+
timeoutMs: number; // Health check timeout
|
|
243
|
+
unhealthyThreshold: number; // Consecutive failures before unhealthy
|
|
244
|
+
healthyThreshold: number; // Consecutive successes before healthy
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export class HealthMonitor {
|
|
248
|
+
private healthy = true;
|
|
249
|
+
private consecutiveFailures = 0;
|
|
250
|
+
private consecutiveSuccesses = 0;
|
|
251
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
252
|
+
private readonly check: () => Promise<void>;
|
|
253
|
+
private readonly opts: HealthCheckOptions;
|
|
254
|
+
private listeners: ((healthy: boolean) => void)[] = [];
|
|
255
|
+
|
|
256
|
+
constructor(check: () => Promise<void>, opts: Partial<HealthCheckOptions> = {}) {
|
|
257
|
+
this.check = check;
|
|
258
|
+
this.opts = {
|
|
259
|
+
intervalMs: opts.intervalMs ?? 30_000,
|
|
260
|
+
timeoutMs: opts.timeoutMs ?? 5_000,
|
|
261
|
+
unhealthyThreshold: opts.unhealthyThreshold ?? 3,
|
|
262
|
+
healthyThreshold: opts.healthyThreshold ?? 2,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
start(): void {
|
|
267
|
+
if (this.timer) return;
|
|
268
|
+
this.timer = setInterval(() => this.runCheck(), this.opts.intervalMs);
|
|
269
|
+
if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
|
|
270
|
+
this.timer.unref();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
stop(): void {
|
|
275
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
isHealthy(): boolean { return this.healthy; }
|
|
279
|
+
|
|
280
|
+
onStatusChange(fn: (healthy: boolean) => void): void {
|
|
281
|
+
this.listeners.push(fn);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private async runCheck(): Promise<void> {
|
|
285
|
+
try {
|
|
286
|
+
await withTimeout(this.check(), this.opts.timeoutMs);
|
|
287
|
+
this.consecutiveFailures = 0;
|
|
288
|
+
this.consecutiveSuccesses++;
|
|
289
|
+
if (!this.healthy && this.consecutiveSuccesses >= this.opts.healthyThreshold) {
|
|
290
|
+
this.healthy = true;
|
|
291
|
+
this.listeners.forEach(fn => fn(true));
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
this.consecutiveSuccesses = 0;
|
|
295
|
+
this.consecutiveFailures++;
|
|
296
|
+
if (this.healthy && this.consecutiveFailures >= this.opts.unhealthyThreshold) {
|
|
297
|
+
this.healthy = false;
|
|
298
|
+
this.listeners.forEach(fn => fn(false));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Helpers ─────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
function sleep(ms: number): Promise<void> {
|
|
307
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
311
|
+
return new Promise((resolve, reject) => {
|
|
312
|
+
const timer = setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms);
|
|
313
|
+
promise
|
|
314
|
+
.then(v => { clearTimeout(timer); resolve(v); })
|
|
315
|
+
.catch(e => { clearTimeout(timer); reject(e); });
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Request ID Generator ────────────────────────────────
|
|
320
|
+
|
|
321
|
+
let counter = 0;
|
|
322
|
+
const prefix = Math.random().toString(36).substring(2, 8);
|
|
323
|
+
|
|
324
|
+
export function requestId(): string {
|
|
325
|
+
return `${prefix}-${(++counter).toString(36)}-${Date.now().toString(36)}`;
|
|
326
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enterprise Middleware Stack
|
|
3
|
+
*
|
|
4
|
+
* Rate limiting, request logging, error handling, validation,
|
|
5
|
+
* CORS, request IDs, and security headers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context, Next, MiddlewareHandler } from 'hono';
|
|
9
|
+
import { KeyedRateLimiter, requestId } from '../lib/resilience.js';
|
|
10
|
+
import type { DatabaseAdapter } from '../db/adapter.js';
|
|
11
|
+
|
|
12
|
+
// ─── Request ID ──────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export function requestIdMiddleware(): MiddlewareHandler {
|
|
15
|
+
return async (c: Context, next: Next) => {
|
|
16
|
+
const id = c.req.header('X-Request-Id') || requestId();
|
|
17
|
+
c.set('requestId' as any, id);
|
|
18
|
+
c.header('X-Request-Id', id);
|
|
19
|
+
await next();
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Request Logging ─────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export function requestLogger(): MiddlewareHandler {
|
|
26
|
+
return async (c: Context, next: Next) => {
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
const method = c.req.method;
|
|
29
|
+
const path = c.req.path;
|
|
30
|
+
|
|
31
|
+
await next();
|
|
32
|
+
|
|
33
|
+
const elapsed = Date.now() - start;
|
|
34
|
+
const status = c.res.status;
|
|
35
|
+
const reqId = c.get('requestId' as any) || '-';
|
|
36
|
+
|
|
37
|
+
// Structured log line
|
|
38
|
+
const level = status >= 500 ? 'ERROR' : status >= 400 ? 'WARN' : 'INFO';
|
|
39
|
+
console.log(
|
|
40
|
+
`[${new Date().toISOString()}] ${level} ${method} ${path} ${status} ${elapsed}ms req=${reqId}`,
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Rate Limiting ───────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
interface RateLimitConfig {
|
|
48
|
+
/** Requests per window */
|
|
49
|
+
limit: number;
|
|
50
|
+
/** Window in seconds */
|
|
51
|
+
windowSec: number;
|
|
52
|
+
/** Key extractor (default: IP) */
|
|
53
|
+
keyFn?: (c: Context) => string;
|
|
54
|
+
/** Skip rate limiting for these paths */
|
|
55
|
+
skipPaths?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function rateLimiter(config: RateLimitConfig): MiddlewareHandler {
|
|
59
|
+
const limiter = new KeyedRateLimiter({
|
|
60
|
+
maxTokens: config.limit,
|
|
61
|
+
refillRate: config.limit / config.windowSec,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return async (c: Context, next: Next) => {
|
|
65
|
+
// Skip health checks
|
|
66
|
+
if (config.skipPaths?.some(p => c.req.path.startsWith(p))) {
|
|
67
|
+
return next();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const key = config.keyFn?.(c) ||
|
|
71
|
+
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
72
|
+
c.req.header('x-real-ip') ||
|
|
73
|
+
'unknown';
|
|
74
|
+
|
|
75
|
+
if (!limiter.tryConsume(key)) {
|
|
76
|
+
const retryAfter = Math.ceil(limiter.getRetryAfterMs(key) / 1000);
|
|
77
|
+
c.header('Retry-After', String(retryAfter));
|
|
78
|
+
c.header('X-RateLimit-Limit', String(config.limit));
|
|
79
|
+
c.header('X-RateLimit-Remaining', '0');
|
|
80
|
+
return c.json(
|
|
81
|
+
{ error: 'Too many requests', retryAfter },
|
|
82
|
+
429,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await next();
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Security Headers ────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export function securityHeaders(): MiddlewareHandler {
|
|
93
|
+
return async (c: Context, next: Next) => {
|
|
94
|
+
await next();
|
|
95
|
+
|
|
96
|
+
c.header('X-Content-Type-Options', 'nosniff');
|
|
97
|
+
c.header('X-Frame-Options', 'DENY');
|
|
98
|
+
c.header('X-XSS-Protection', '0'); // Modern browsers: CSP is better
|
|
99
|
+
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
100
|
+
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
101
|
+
|
|
102
|
+
// Only set HSTS if behind TLS
|
|
103
|
+
if (c.req.url.startsWith('https://') || c.req.header('x-forwarded-proto') === 'https') {
|
|
104
|
+
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Error Handler ───────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
export interface ApiError {
|
|
112
|
+
error: string;
|
|
113
|
+
code?: string;
|
|
114
|
+
details?: unknown;
|
|
115
|
+
requestId?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function errorHandler(): MiddlewareHandler {
|
|
119
|
+
return async (c: Context, next: Next) => {
|
|
120
|
+
try {
|
|
121
|
+
await next();
|
|
122
|
+
} catch (err: any) {
|
|
123
|
+
const reqId = c.get('requestId' as any);
|
|
124
|
+
const status = err.status || err.statusCode || 500;
|
|
125
|
+
const message = status >= 500 ? 'Internal server error' : err.message;
|
|
126
|
+
|
|
127
|
+
// Log full error for 5xx
|
|
128
|
+
if (status >= 500) {
|
|
129
|
+
console.error(`[${new Date().toISOString()}] ERROR req=${reqId}`, err);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const body: ApiError = {
|
|
133
|
+
error: message,
|
|
134
|
+
code: err.code,
|
|
135
|
+
requestId: reqId,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Include validation details for 400s
|
|
139
|
+
if (status === 400 && err.details) {
|
|
140
|
+
body.details = err.details;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return c.json(body, status);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Input Validation ────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export class ValidationError extends Error {
|
|
151
|
+
status = 400;
|
|
152
|
+
code = 'VALIDATION_ERROR';
|
|
153
|
+
details: Record<string, string>;
|
|
154
|
+
|
|
155
|
+
constructor(details: Record<string, string>) {
|
|
156
|
+
const fields = Object.keys(details).join(', ');
|
|
157
|
+
super(`Validation failed: ${fields}`);
|
|
158
|
+
this.details = details;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type Validator = {
|
|
163
|
+
field: string;
|
|
164
|
+
type: 'string' | 'number' | 'boolean' | 'email' | 'url' | 'uuid';
|
|
165
|
+
required?: boolean;
|
|
166
|
+
minLength?: number;
|
|
167
|
+
maxLength?: number;
|
|
168
|
+
min?: number;
|
|
169
|
+
max?: number;
|
|
170
|
+
pattern?: RegExp;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export function validate(body: Record<string, any>, validators: Validator[]): void {
|
|
174
|
+
const errors: Record<string, string> = {};
|
|
175
|
+
|
|
176
|
+
for (const v of validators) {
|
|
177
|
+
const value = body[v.field];
|
|
178
|
+
|
|
179
|
+
if (value === undefined || value === null || value === '') {
|
|
180
|
+
if (v.required) errors[v.field] = 'Required';
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
switch (v.type) {
|
|
185
|
+
case 'string':
|
|
186
|
+
if (typeof value !== 'string') { errors[v.field] = 'Must be a string'; break; }
|
|
187
|
+
if (v.minLength && value.length < v.minLength) errors[v.field] = `Min length: ${v.minLength}`;
|
|
188
|
+
if (v.maxLength && value.length > v.maxLength) errors[v.field] = `Max length: ${v.maxLength}`;
|
|
189
|
+
if (v.pattern && !v.pattern.test(value)) errors[v.field] = 'Invalid format';
|
|
190
|
+
break;
|
|
191
|
+
case 'number':
|
|
192
|
+
if (typeof value !== 'number' || isNaN(value)) { errors[v.field] = 'Must be a number'; break; }
|
|
193
|
+
if (v.min !== undefined && value < v.min) errors[v.field] = `Min: ${v.min}`;
|
|
194
|
+
if (v.max !== undefined && value > v.max) errors[v.field] = `Max: ${v.max}`;
|
|
195
|
+
break;
|
|
196
|
+
case 'boolean':
|
|
197
|
+
if (typeof value !== 'boolean') errors[v.field] = 'Must be a boolean';
|
|
198
|
+
break;
|
|
199
|
+
case 'email':
|
|
200
|
+
if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
201
|
+
errors[v.field] = 'Invalid email';
|
|
202
|
+
break;
|
|
203
|
+
case 'url':
|
|
204
|
+
try { new URL(value); } catch { errors[v.field] = 'Invalid URL'; }
|
|
205
|
+
break;
|
|
206
|
+
case 'uuid':
|
|
207
|
+
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))
|
|
208
|
+
errors[v.field] = 'Invalid UUID';
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (Object.keys(errors).length > 0) {
|
|
214
|
+
throw new ValidationError(errors);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Audit Logger Middleware ─────────────────────────────
|
|
219
|
+
|
|
220
|
+
export function auditLogger(db: DatabaseAdapter): MiddlewareHandler {
|
|
221
|
+
// Only audit mutating operations
|
|
222
|
+
const AUDIT_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
223
|
+
|
|
224
|
+
return async (c: Context, next: Next) => {
|
|
225
|
+
await next();
|
|
226
|
+
|
|
227
|
+
if (!AUDIT_METHODS.has(c.req.method)) return;
|
|
228
|
+
if (c.res.status >= 400) return; // Don't audit failed requests
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const userId = c.get('userId' as any) || 'anonymous';
|
|
232
|
+
const path = c.req.path;
|
|
233
|
+
const method = c.req.method;
|
|
234
|
+
|
|
235
|
+
// Derive action from path + method
|
|
236
|
+
const segments = path.split('/').filter(Boolean);
|
|
237
|
+
const resource = segments[segments.length - 2] || segments[segments.length - 1] || 'unknown';
|
|
238
|
+
const actionMap: Record<string, string> = {
|
|
239
|
+
POST: 'create', PUT: 'update', PATCH: 'update', DELETE: 'delete',
|
|
240
|
+
};
|
|
241
|
+
const action = `${resource}.${actionMap[method] || method.toLowerCase()}`;
|
|
242
|
+
|
|
243
|
+
await db.logEvent({
|
|
244
|
+
actor: userId,
|
|
245
|
+
actorType: 'user',
|
|
246
|
+
action,
|
|
247
|
+
resource: path,
|
|
248
|
+
ip: c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || c.req.header('x-real-ip'),
|
|
249
|
+
});
|
|
250
|
+
} catch {
|
|
251
|
+
// Never let audit logging break the request
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── RBAC Middleware ─────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
type Role = 'owner' | 'admin' | 'member' | 'viewer';
|
|
259
|
+
|
|
260
|
+
const ROLE_HIERARCHY: Record<Role, number> = {
|
|
261
|
+
viewer: 0,
|
|
262
|
+
member: 1,
|
|
263
|
+
admin: 2,
|
|
264
|
+
owner: 3,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
export function requireRole(minRole: Role): MiddlewareHandler {
|
|
268
|
+
return async (c: Context, next: Next) => {
|
|
269
|
+
const userRole = c.get('userRole' as any) as Role | undefined;
|
|
270
|
+
|
|
271
|
+
// API keys bypass role check (scopes handle auth)
|
|
272
|
+
if (c.get('authType' as any) === 'api-key') {
|
|
273
|
+
return next();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!userRole || ROLE_HIERARCHY[userRole] < ROLE_HIERARCHY[minRole]) {
|
|
277
|
+
return c.json({
|
|
278
|
+
error: 'Insufficient permissions',
|
|
279
|
+
required: minRole,
|
|
280
|
+
current: userRole || 'none',
|
|
281
|
+
}, 403);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return next();
|
|
285
|
+
};
|
|
286
|
+
}
|