@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.
Files changed (69) hide show
  1. package/ARCHITECTURE.md +183 -0
  2. package/agenticmail-enterprise.db +0 -0
  3. package/dashboards/README.md +120 -0
  4. package/dashboards/dotnet/Program.cs +261 -0
  5. package/dashboards/express/app.js +146 -0
  6. package/dashboards/go/main.go +513 -0
  7. package/dashboards/html/index.html +535 -0
  8. package/dashboards/java/AgenticMailDashboard.java +376 -0
  9. package/dashboards/php/index.php +414 -0
  10. package/dashboards/python/app.py +273 -0
  11. package/dashboards/ruby/app.rb +195 -0
  12. package/dist/chunk-77IDQJL3.js +7 -0
  13. package/dist/chunk-7RGCCHIT.js +115 -0
  14. package/dist/chunk-DXNKR3TG.js +1355 -0
  15. package/dist/chunk-IQWA44WT.js +970 -0
  16. package/dist/chunk-LCUZGIDH.js +965 -0
  17. package/dist/chunk-N2JVTNNJ.js +2553 -0
  18. package/dist/chunk-O462UJBH.js +363 -0
  19. package/dist/chunk-PNKVD2UK.js +26 -0
  20. package/dist/cli.js +218 -0
  21. package/dist/dashboard/index.html +558 -0
  22. package/dist/db-adapter-DEWEFNIV.js +7 -0
  23. package/dist/dynamodb-CCGL2E77.js +426 -0
  24. package/dist/engine/index.js +1261 -0
  25. package/dist/index.js +522 -0
  26. package/dist/mongodb-ODTXIVPV.js +319 -0
  27. package/dist/mysql-RM3S2FV5.js +521 -0
  28. package/dist/postgres-LN7A6MGQ.js +518 -0
  29. package/dist/routes-2JEPIIKC.js +441 -0
  30. package/dist/routes-74ZLKJKP.js +399 -0
  31. package/dist/server.js +7 -0
  32. package/dist/sqlite-3K5YOZ4K.js +439 -0
  33. package/dist/turso-LDWODSDI.js +442 -0
  34. package/package.json +49 -0
  35. package/src/admin/routes.ts +331 -0
  36. package/src/auth/routes.ts +130 -0
  37. package/src/cli.ts +260 -0
  38. package/src/dashboard/index.html +558 -0
  39. package/src/db/adapter.ts +230 -0
  40. package/src/db/dynamodb.ts +456 -0
  41. package/src/db/factory.ts +51 -0
  42. package/src/db/mongodb.ts +360 -0
  43. package/src/db/mysql.ts +472 -0
  44. package/src/db/postgres.ts +479 -0
  45. package/src/db/sql-schema.ts +123 -0
  46. package/src/db/sqlite.ts +391 -0
  47. package/src/db/turso.ts +411 -0
  48. package/src/deploy/fly.ts +368 -0
  49. package/src/deploy/managed.ts +213 -0
  50. package/src/engine/activity.ts +474 -0
  51. package/src/engine/agent-config.ts +429 -0
  52. package/src/engine/agenticmail-bridge.ts +296 -0
  53. package/src/engine/approvals.ts +278 -0
  54. package/src/engine/db-adapter.ts +682 -0
  55. package/src/engine/db-schema.ts +335 -0
  56. package/src/engine/deployer.ts +595 -0
  57. package/src/engine/index.ts +134 -0
  58. package/src/engine/knowledge.ts +486 -0
  59. package/src/engine/lifecycle.ts +635 -0
  60. package/src/engine/openclaw-hook.ts +371 -0
  61. package/src/engine/routes.ts +528 -0
  62. package/src/engine/skills.ts +473 -0
  63. package/src/engine/tenant.ts +345 -0
  64. package/src/engine/tool-catalog.ts +189 -0
  65. package/src/index.ts +64 -0
  66. package/src/lib/resilience.ts +326 -0
  67. package/src/middleware/index.ts +286 -0
  68. package/src/server.ts +310 -0
  69. 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
+ }