s3db.js 13.5.1 → 13.6.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 (108) hide show
  1. package/README.md +89 -19
  2. package/dist/{s3db.cjs.js → s3db.cjs} +29780 -24384
  3. package/dist/s3db.cjs.map +1 -0
  4. package/dist/s3db.es.js +24263 -18860
  5. package/dist/s3db.es.js.map +1 -1
  6. package/package.json +227 -21
  7. package/src/concerns/id.js +90 -6
  8. package/src/concerns/index.js +2 -1
  9. package/src/concerns/password-hashing.js +150 -0
  10. package/src/database.class.js +4 -0
  11. package/src/plugins/api/auth/basic-auth.js +23 -1
  12. package/src/plugins/api/auth/index.js +49 -3
  13. package/src/plugins/api/auth/oauth2-auth.js +171 -0
  14. package/src/plugins/api/auth/oidc-auth.js +789 -0
  15. package/src/plugins/api/auth/oidc-client.js +462 -0
  16. package/src/plugins/api/auth/path-auth-matcher.js +284 -0
  17. package/src/plugins/api/concerns/event-emitter.js +134 -0
  18. package/src/plugins/api/concerns/failban-manager.js +651 -0
  19. package/src/plugins/api/concerns/guards-helpers.js +402 -0
  20. package/src/plugins/api/concerns/metrics-collector.js +346 -0
  21. package/src/plugins/api/concerns/opengraph-helper.js +116 -0
  22. package/src/plugins/api/concerns/state-machine.js +288 -0
  23. package/src/plugins/api/index.js +514 -54
  24. package/src/plugins/api/middlewares/failban.js +305 -0
  25. package/src/plugins/api/middlewares/rate-limit.js +301 -0
  26. package/src/plugins/api/middlewares/request-id.js +74 -0
  27. package/src/plugins/api/middlewares/security-headers.js +120 -0
  28. package/src/plugins/api/middlewares/session-tracking.js +194 -0
  29. package/src/plugins/api/routes/auth-routes.js +23 -3
  30. package/src/plugins/api/routes/resource-routes.js +71 -29
  31. package/src/plugins/api/server.js +1017 -94
  32. package/src/plugins/api/utils/guards.js +213 -0
  33. package/src/plugins/api/utils/mime-types.js +154 -0
  34. package/src/plugins/api/utils/openapi-generator.js +44 -11
  35. package/src/plugins/api/utils/path-matcher.js +173 -0
  36. package/src/plugins/api/utils/static-filesystem.js +262 -0
  37. package/src/plugins/api/utils/static-s3.js +231 -0
  38. package/src/plugins/api/utils/template-engine.js +262 -0
  39. package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +853 -0
  40. package/src/plugins/cloud-inventory/drivers/aws-driver.js +2554 -0
  41. package/src/plugins/cloud-inventory/drivers/azure-driver.js +637 -0
  42. package/src/plugins/cloud-inventory/drivers/base-driver.js +99 -0
  43. package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +620 -0
  44. package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +698 -0
  45. package/src/plugins/cloud-inventory/drivers/gcp-driver.js +645 -0
  46. package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +559 -0
  47. package/src/plugins/cloud-inventory/drivers/linode-driver.js +614 -0
  48. package/src/plugins/cloud-inventory/drivers/mock-drivers.js +449 -0
  49. package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +771 -0
  50. package/src/plugins/cloud-inventory/drivers/oracle-driver.js +768 -0
  51. package/src/plugins/cloud-inventory/drivers/vultr-driver.js +636 -0
  52. package/src/plugins/cloud-inventory/index.js +20 -0
  53. package/src/plugins/cloud-inventory/registry.js +146 -0
  54. package/src/plugins/cloud-inventory/terraform-exporter.js +362 -0
  55. package/src/plugins/cloud-inventory.plugin.js +1333 -0
  56. package/src/plugins/concerns/plugin-dependencies.js +61 -1
  57. package/src/plugins/eventual-consistency/analytics.js +1 -0
  58. package/src/plugins/identity/README.md +335 -0
  59. package/src/plugins/identity/concerns/mfa-manager.js +204 -0
  60. package/src/plugins/identity/concerns/password.js +138 -0
  61. package/src/plugins/identity/concerns/resource-schemas.js +273 -0
  62. package/src/plugins/identity/concerns/token-generator.js +172 -0
  63. package/src/plugins/identity/email-service.js +422 -0
  64. package/src/plugins/identity/index.js +1052 -0
  65. package/src/plugins/identity/oauth2-server.js +1033 -0
  66. package/src/plugins/identity/oidc-discovery.js +285 -0
  67. package/src/plugins/identity/rsa-keys.js +323 -0
  68. package/src/plugins/identity/server.js +500 -0
  69. package/src/plugins/identity/session-manager.js +453 -0
  70. package/src/plugins/identity/ui/layouts/base.js +251 -0
  71. package/src/plugins/identity/ui/middleware.js +135 -0
  72. package/src/plugins/identity/ui/pages/admin/client-form.js +247 -0
  73. package/src/plugins/identity/ui/pages/admin/clients.js +179 -0
  74. package/src/plugins/identity/ui/pages/admin/dashboard.js +181 -0
  75. package/src/plugins/identity/ui/pages/admin/user-form.js +283 -0
  76. package/src/plugins/identity/ui/pages/admin/users.js +263 -0
  77. package/src/plugins/identity/ui/pages/consent.js +262 -0
  78. package/src/plugins/identity/ui/pages/forgot-password.js +104 -0
  79. package/src/plugins/identity/ui/pages/login.js +144 -0
  80. package/src/plugins/identity/ui/pages/mfa-backup-codes.js +180 -0
  81. package/src/plugins/identity/ui/pages/mfa-enrollment.js +187 -0
  82. package/src/plugins/identity/ui/pages/mfa-verification.js +178 -0
  83. package/src/plugins/identity/ui/pages/oauth-error.js +225 -0
  84. package/src/plugins/identity/ui/pages/profile.js +361 -0
  85. package/src/plugins/identity/ui/pages/register.js +226 -0
  86. package/src/plugins/identity/ui/pages/reset-password.js +128 -0
  87. package/src/plugins/identity/ui/pages/verify-email.js +172 -0
  88. package/src/plugins/identity/ui/routes.js +2541 -0
  89. package/src/plugins/identity/ui/styles/main.css +465 -0
  90. package/src/plugins/index.js +4 -1
  91. package/src/plugins/ml/base-model.class.js +32 -7
  92. package/src/plugins/ml/classification-model.class.js +1 -1
  93. package/src/plugins/ml/timeseries-model.class.js +3 -1
  94. package/src/plugins/ml.plugin.js +124 -32
  95. package/src/plugins/shared/error-handler.js +147 -0
  96. package/src/plugins/shared/index.js +9 -0
  97. package/src/plugins/shared/middlewares/compression.js +117 -0
  98. package/src/plugins/shared/middlewares/cors.js +49 -0
  99. package/src/plugins/shared/middlewares/index.js +11 -0
  100. package/src/plugins/shared/middlewares/logging.js +54 -0
  101. package/src/plugins/shared/middlewares/rate-limit.js +73 -0
  102. package/src/plugins/shared/middlewares/security.js +158 -0
  103. package/src/plugins/shared/response-formatter.js +264 -0
  104. package/src/plugins/tfstate/README.md +126 -126
  105. package/src/resource.class.js +140 -12
  106. package/src/schema.class.js +30 -1
  107. package/src/validator.class.js +57 -6
  108. package/dist/s3db.cjs.js.map +0 -1
@@ -7,11 +7,28 @@
7
7
  import { createResourceRoutes, createRelationalRoutes } from './routes/resource-routes.js';
8
8
  import { createAuthRoutes } from './routes/auth-routes.js';
9
9
  import { mountCustomRoutes } from './utils/custom-routes.js';
10
- import { errorHandler } from './utils/error-handler.js';
11
- import * as formatter from './utils/response-formatter.js';
10
+ import { errorHandler } from '../shared/error-handler.js';
11
+ import * as formatter from '../shared/response-formatter.js';
12
12
  import { generateOpenAPISpec } from './utils/openapi-generator.js';
13
+ import { createAuthMiddleware } from './auth/index.js';
14
+ import { createOIDCHandler } from './auth/oidc-auth.js';
15
+ import { findBestMatch, validatePathAuth } from './utils/path-matcher.js';
16
+ import { createFilesystemHandler, validateFilesystemConfig } from './utils/static-filesystem.js';
17
+ import { createS3Handler, validateS3Config } from './utils/static-s3.js';
18
+ import { setupTemplateEngine } from './utils/template-engine.js';
19
+ import { createPathBasedAuthMiddleware, findAuthRule } from './auth/path-auth-matcher.js';
13
20
  import { jwtAuth } from './auth/jwt-auth.js';
21
+ import { apiKeyAuth } from './auth/api-key-auth.js';
14
22
  import { basicAuth } from './auth/basic-auth.js';
23
+ import { createOAuth2Handler } from './auth/oauth2-auth.js';
24
+ import { createRequestIdMiddleware } from './middlewares/request-id.js';
25
+ import { createSecurityHeadersMiddleware } from './middlewares/security-headers.js';
26
+ import { createSessionTrackingMiddleware } from './middlewares/session-tracking.js';
27
+ import { createAuthDriverRateLimiter } from './middlewares/rate-limit.js';
28
+ import { createFailbanMiddleware, setupFailbanViolationListener, createFailbanAdminRoutes } from './middlewares/failban.js';
29
+ import { FailbanManager } from './concerns/failban-manager.js';
30
+ import { ApiEventEmitter } from './concerns/event-emitter.js';
31
+ import { MetricsCollector } from './concerns/metrics-collector.js';
15
32
 
16
33
  /**
17
34
  * API Server class
@@ -34,9 +51,18 @@ export class ApiServer {
34
51
  database: options.database,
35
52
  resources: options.resources || {},
36
53
  routes: options.routes || {}, // Plugin-level custom routes
54
+ templates: options.templates || { enabled: false, engine: 'jsx' }, // Template engine config
37
55
  middlewares: options.middlewares || [],
56
+ requestId: options.requestId || { enabled: false }, // Request ID tracking config
57
+ cors: options.cors || { enabled: false }, // CORS configuration
58
+ security: options.security || { enabled: false }, // Security headers config
59
+ sessionTracking: options.sessionTracking || { enabled: false }, // Session tracking config
60
+ events: options.events || { enabled: false }, // Event hooks config
61
+ metrics: options.metrics || { enabled: false }, // Metrics collection config
62
+ failban: options.failban || { enabled: false }, // Failban (fail2ban-style) config
38
63
  verbose: options.verbose || false,
39
64
  auth: options.auth || {},
65
+ static: options.static || [], // Static file serving config
40
66
  docsEnabled: options.docsEnabled !== false, // Enable /docs by default
41
67
  docsUI: options.docsUI || 'redoc', // 'swagger' or 'redoc'
42
68
  maxBodySize: options.maxBodySize || 10 * 1024 * 1024, // 10MB default
@@ -55,6 +81,47 @@ export class ApiServer {
55
81
  this.openAPISpec = null;
56
82
  this.initialized = false;
57
83
 
84
+ // Graceful shutdown tracking
85
+ this.inFlightRequests = new Set(); // Track in-flight requests
86
+ this.acceptingRequests = true; // Accept new requests flag
87
+
88
+ // Event emitter
89
+ this.events = new ApiEventEmitter({
90
+ enabled: this.options.events?.enabled !== false,
91
+ verbose: this.options.events?.verbose || this.options.verbose,
92
+ maxListeners: this.options.events?.maxListeners
93
+ });
94
+
95
+ // Metrics collector
96
+ this.metrics = new MetricsCollector({
97
+ enabled: this.options.metrics?.enabled !== false,
98
+ verbose: this.options.metrics?.verbose || this.options.verbose,
99
+ maxPathsTracked: this.options.metrics?.maxPathsTracked,
100
+ resetInterval: this.options.metrics?.resetInterval
101
+ });
102
+
103
+ // Wire up event listeners to metrics collector
104
+ if (this.options.metrics?.enabled && this.options.events?.enabled !== false) {
105
+ this._setupMetricsEventListeners();
106
+ }
107
+
108
+ // Failban manager (fail2ban-style automatic banning - internal feature)
109
+ this.failban = null;
110
+ if (this.options.failban?.enabled) {
111
+ this.failban = new FailbanManager({
112
+ database: this.options.database,
113
+ enabled: true,
114
+ maxViolations: this.options.failban.maxViolations || 3,
115
+ violationWindow: this.options.failban.violationWindow || 3600000,
116
+ banDuration: this.options.failban.banDuration || 86400000,
117
+ whitelist: this.options.failban.whitelist || ['127.0.0.1', '::1'],
118
+ blacklist: this.options.failban.blacklist || [],
119
+ persistViolations: this.options.failban.persistViolations !== false,
120
+ verbose: this.options.failban.verbose || this.options.verbose,
121
+ geo: this.options.failban.geo || {}
122
+ });
123
+ }
124
+
58
125
  // Detect if RelationPlugin is installed
59
126
  this.relationsPlugin = this.options.database?.plugins?.relation ||
60
127
  this.options.database?.plugins?.RelationPlugin ||
@@ -63,16 +130,310 @@ export class ApiServer {
63
130
  // Routes will be setup in start() after dynamic import
64
131
  }
65
132
 
133
+ /**
134
+ * Setup metrics event listeners
135
+ * @private
136
+ */
137
+ _setupMetricsEventListeners() {
138
+ // Request metrics
139
+ this.events.on('request:end', (data) => {
140
+ this.metrics.recordRequest({
141
+ method: data.method,
142
+ path: data.path,
143
+ status: data.status,
144
+ duration: data.duration
145
+ });
146
+ });
147
+
148
+ this.events.on('request:error', (data) => {
149
+ this.metrics.recordError({
150
+ error: data.error,
151
+ type: 'request'
152
+ });
153
+ });
154
+
155
+ // Auth metrics
156
+ this.events.on('auth:success', (data) => {
157
+ this.metrics.recordAuth({
158
+ success: true,
159
+ method: data.method
160
+ });
161
+ });
162
+
163
+ this.events.on('auth:failure', (data) => {
164
+ this.metrics.recordAuth({
165
+ success: false,
166
+ method: data.allowedMethods?.[0] || 'unknown'
167
+ });
168
+ });
169
+
170
+ // Resource metrics
171
+ this.events.on('resource:created', (data) => {
172
+ this.metrics.recordResourceOperation({
173
+ action: 'created',
174
+ resource: data.resource
175
+ });
176
+ });
177
+
178
+ this.events.on('resource:updated', (data) => {
179
+ this.metrics.recordResourceOperation({
180
+ action: 'updated',
181
+ resource: data.resource
182
+ });
183
+ });
184
+
185
+ this.events.on('resource:deleted', (data) => {
186
+ this.metrics.recordResourceOperation({
187
+ action: 'deleted',
188
+ resource: data.resource
189
+ });
190
+ });
191
+
192
+ // User metrics
193
+ this.events.on('user:created', (data) => {
194
+ this.metrics.recordUserEvent({
195
+ action: 'created'
196
+ });
197
+ });
198
+
199
+ this.events.on('user:login', (data) => {
200
+ this.metrics.recordUserEvent({
201
+ action: 'login'
202
+ });
203
+ });
204
+
205
+ if (this.options.verbose) {
206
+ console.log('[API Server] Metrics event listeners configured');
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Setup request tracking middleware for graceful shutdown
212
+ * @private
213
+ */
214
+ _setupRequestTracking() {
215
+ this.app.use('*', async (c, next) => {
216
+ // Check if we're still accepting requests
217
+ if (!this.acceptingRequests) {
218
+ return c.json({ error: 'Server is shutting down' }, 503);
219
+ }
220
+
221
+ // Track this request
222
+ const requestId = Symbol('request');
223
+ this.inFlightRequests.add(requestId);
224
+
225
+ const startTime = Date.now();
226
+ const requestInfo = {
227
+ requestId: c.get('requestId') || requestId.toString(),
228
+ method: c.req.method,
229
+ path: c.req.path,
230
+ userAgent: c.req.header('user-agent'),
231
+ ip: c.req.header('x-forwarded-for') || c.req.header('x-real-ip')
232
+ };
233
+
234
+ // Emit request:start
235
+ this.events.emitRequestEvent('start', requestInfo);
236
+
237
+ try {
238
+ await next();
239
+
240
+ // Emit request:end
241
+ this.events.emitRequestEvent('end', {
242
+ ...requestInfo,
243
+ duration: Date.now() - startTime,
244
+ status: c.res.status
245
+ });
246
+ } catch (err) {
247
+ // Emit request:error
248
+ this.events.emitRequestEvent('error', {
249
+ ...requestInfo,
250
+ duration: Date.now() - startTime,
251
+ error: err.message,
252
+ stack: err.stack
253
+ });
254
+ throw err; // Re-throw for error handler
255
+ } finally {
256
+ // Remove from tracking when done
257
+ this.inFlightRequests.delete(requestId);
258
+ }
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Stop accepting new requests
264
+ * @returns {void}
265
+ */
266
+ stopAcceptingRequests() {
267
+ this.acceptingRequests = false;
268
+ if (this.options.verbose) {
269
+ console.log('[API Server] Stopped accepting new requests');
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Wait for all in-flight requests to finish
275
+ * @param {Object} options - Options
276
+ * @param {number} options.timeout - Max time to wait in ms (default: 30000)
277
+ * @returns {Promise<boolean>} True if all requests finished, false if timeout
278
+ */
279
+ async waitForRequestsToFinish({ timeout = 30000 } = {}) {
280
+ const startTime = Date.now();
281
+
282
+ while (this.inFlightRequests.size > 0) {
283
+ const elapsed = Date.now() - startTime;
284
+
285
+ if (elapsed >= timeout) {
286
+ if (this.options.verbose) {
287
+ console.warn(`[API Server] Timeout waiting for ${this.inFlightRequests.size} in-flight requests`);
288
+ }
289
+ return false;
290
+ }
291
+
292
+ if (this.options.verbose) {
293
+ console.log(`[API Server] Waiting for ${this.inFlightRequests.size} in-flight requests...`);
294
+ }
295
+
296
+ // Wait 100ms before checking again
297
+ await new Promise(resolve => setTimeout(resolve, 100));
298
+ }
299
+
300
+ if (this.options.verbose) {
301
+ console.log('[API Server] All requests finished');
302
+ }
303
+ return true;
304
+ }
305
+
306
+ /**
307
+ * Graceful shutdown
308
+ * @param {Object} options - Shutdown options
309
+ * @param {number} options.timeout - Max time to wait for requests (default: 30000)
310
+ * @returns {Promise<void>}
311
+ */
312
+ async shutdown({ timeout = 30000 } = {}) {
313
+ if (!this.isRunning) {
314
+ console.warn('[API Server] Server is not running');
315
+ return;
316
+ }
317
+
318
+ console.log('[API Server] Initiating graceful shutdown...');
319
+
320
+ // Stop accepting new requests
321
+ this.stopAcceptingRequests();
322
+
323
+ // Wait for in-flight requests to finish
324
+ const allFinished = await this.waitForRequestsToFinish({ timeout });
325
+
326
+ if (!allFinished) {
327
+ console.warn('[API Server] Some requests did not finish in time');
328
+ }
329
+
330
+ // Close HTTP server
331
+ if (this.server) {
332
+ await new Promise((resolve, reject) => {
333
+ this.server.close((err) => {
334
+ if (err) reject(err);
335
+ else resolve();
336
+ });
337
+ });
338
+ }
339
+
340
+ this.isRunning = false;
341
+ console.log('[API Server] Shutdown complete');
342
+ }
343
+
66
344
  /**
67
345
  * Setup all routes
68
346
  * @private
69
347
  */
70
348
  _setupRoutes() {
349
+ // Request tracking for graceful shutdown (must be first!)
350
+ this._setupRequestTracking();
351
+
352
+ // Failban middleware (check banned IPs early)
353
+ if (this.failban) {
354
+ const failbanMiddleware = createFailbanMiddleware({
355
+ plugin: this.failban,
356
+ events: this.events
357
+ });
358
+ this.app.use('*', failbanMiddleware);
359
+
360
+ // Setup violation listeners (connects events to failban)
361
+ setupFailbanViolationListener({
362
+ plugin: this.failban,
363
+ events: this.events
364
+ });
365
+
366
+ if (this.options.verbose) {
367
+ console.log('[API Server] Failban protection enabled');
368
+ }
369
+ }
370
+
371
+ // Request ID middleware (before all other middlewares)
372
+ if (this.options.requestId?.enabled) {
373
+ const requestIdMiddleware = createRequestIdMiddleware(this.options.requestId);
374
+ this.app.use('*', requestIdMiddleware);
375
+
376
+ if (this.options.verbose) {
377
+ console.log(`[API Server] Request ID tracking enabled (header: ${this.options.requestId.headerName || 'X-Request-ID'})`);
378
+ }
379
+ }
380
+
381
+ // CORS middleware
382
+ if (this.options.cors?.enabled) {
383
+ const corsConfig = this.options.cors;
384
+ this.app.use('*', this.cors({
385
+ origin: corsConfig.origin || '*',
386
+ allowMethods: corsConfig.allowMethods || ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
387
+ allowHeaders: corsConfig.allowHeaders || ['Content-Type', 'Authorization', 'X-Request-ID'],
388
+ exposeHeaders: corsConfig.exposeHeaders || ['X-Request-ID'],
389
+ credentials: corsConfig.credentials || false,
390
+ maxAge: corsConfig.maxAge || 86400 // 24 hours cache by default
391
+ }));
392
+
393
+ if (this.options.verbose) {
394
+ console.log(`[API Server] CORS enabled (maxAge: ${corsConfig.maxAge || 86400}s, origin: ${corsConfig.origin || '*'})`);
395
+ }
396
+ }
397
+
398
+ // Security headers middleware
399
+ if (this.options.security?.enabled) {
400
+ const securityMiddleware = createSecurityHeadersMiddleware(this.options.security);
401
+ this.app.use('*', securityMiddleware);
402
+
403
+ if (this.options.verbose) {
404
+ console.log('[API Server] Security headers enabled');
405
+ }
406
+ }
407
+
408
+ // Session tracking middleware
409
+ if (this.options.sessionTracking?.enabled) {
410
+ const sessionMiddleware = createSessionTrackingMiddleware(
411
+ this.options.sessionTracking,
412
+ this.options.database
413
+ );
414
+ this.app.use('*', sessionMiddleware);
415
+
416
+ if (this.options.verbose) {
417
+ const resource = this.options.sessionTracking.resource ? ` (resource: ${this.options.sessionTracking.resource})` : ' (in-memory)';
418
+ console.log(`[API Server] Session tracking enabled${resource}`);
419
+ }
420
+ }
421
+
71
422
  // Apply global middlewares
72
423
  this.options.middlewares.forEach(middleware => {
73
424
  this.app.use('*', middleware);
74
425
  });
75
426
 
427
+ // Template engine middleware (if enabled)
428
+ if (this.options.templates?.enabled) {
429
+ const templateMiddleware = setupTemplateEngine(this.options.templates);
430
+ this.app.use('*', templateMiddleware);
431
+
432
+ if (this.options.verbose) {
433
+ console.log(`[API Server] Template engine enabled: ${this.options.templates.engine}`);
434
+ }
435
+ }
436
+
76
437
  // Body size limit middleware (only for POST, PUT, PATCH)
77
438
  this.app.use('*', async (c, next) => {
78
439
  const method = c.req.method;
@@ -107,35 +468,91 @@ export class ApiServer {
107
468
 
108
469
  // Kubernetes Readiness Probe - checks if app is ready to receive traffic
109
470
  // If this fails, k8s will remove pod from service endpoints
110
- this.app.get('/health/ready', (c) => {
111
- // Check if database is connected and resources are loaded
112
- const isReady = this.options.database &&
113
- this.options.database.connected &&
114
- Object.keys(this.options.database.resources).length > 0;
115
-
116
- if (!isReady) {
117
- const response = formatter.error('Service not ready', {
118
- status: 503,
119
- code: 'NOT_READY',
120
- details: {
121
- database: {
122
- connected: this.options.database?.connected || false,
123
- resources: Object.keys(this.options.database?.resources || {}).length
124
- }
471
+ this.app.get('/health/ready', async (c) => {
472
+ const checks = {};
473
+ let isHealthy = true;
474
+
475
+ // Get custom checks configuration
476
+ const healthConfig = this.options.health || {};
477
+ const customChecks = healthConfig.readiness?.checks || [];
478
+
479
+ // Built-in: Database check
480
+ try {
481
+ const startTime = Date.now();
482
+ const isDbReady = this.options.database &&
483
+ this.options.database.connected &&
484
+ Object.keys(this.options.database.resources).length > 0;
485
+ const latency = Date.now() - startTime;
486
+
487
+ if (isDbReady) {
488
+ checks.s3db = {
489
+ status: 'healthy',
490
+ latency_ms: latency,
491
+ resources: Object.keys(this.options.database.resources).length
492
+ };
493
+ } else {
494
+ checks.s3db = {
495
+ status: 'unhealthy',
496
+ connected: this.options.database?.connected || false,
497
+ resources: Object.keys(this.options.database?.resources || {}).length
498
+ };
499
+ isHealthy = false;
500
+ }
501
+ } catch (err) {
502
+ checks.s3db = {
503
+ status: 'unhealthy',
504
+ error: err.message
505
+ };
506
+ isHealthy = false;
507
+ }
508
+
509
+ // Execute custom checks
510
+ for (const check of customChecks) {
511
+ try {
512
+ const startTime = Date.now();
513
+ const timeout = check.timeout || 5000;
514
+
515
+ // Run check with timeout
516
+ const result = await Promise.race([
517
+ check.check(),
518
+ new Promise((_, reject) =>
519
+ setTimeout(() => reject(new Error('Timeout')), timeout)
520
+ )
521
+ ]);
522
+
523
+ const latency = Date.now() - startTime;
524
+
525
+ checks[check.name] = {
526
+ status: result.healthy ? 'healthy' : 'unhealthy',
527
+ latency_ms: latency,
528
+ ...result
529
+ };
530
+
531
+ // Only mark as unhealthy if check is not optional
532
+ if (!result.healthy && !check.optional) {
533
+ isHealthy = false;
125
534
  }
126
- });
127
- return c.json(response, 503);
535
+ } catch (err) {
536
+ checks[check.name] = {
537
+ status: 'unhealthy',
538
+ error: err.message
539
+ };
540
+
541
+ // Only mark as unhealthy if check is not optional
542
+ if (!check.optional) {
543
+ isHealthy = false;
544
+ }
545
+ }
128
546
  }
129
547
 
130
- const response = formatter.success({
131
- status: 'ready',
132
- database: {
133
- connected: true,
134
- resources: Object.keys(this.options.database.resources).length
135
- },
136
- timestamp: new Date().toISOString()
137
- });
138
- return c.json(response);
548
+ const status = isHealthy ? 200 : 503;
549
+
550
+ return c.json({
551
+ status: isHealthy ? 'healthy' : 'unhealthy',
552
+ timestamp: new Date().toISOString(),
553
+ uptime: process.uptime(),
554
+ checks
555
+ }, status);
139
556
  });
140
557
 
141
558
  // Generic Health Check endpoint
@@ -152,6 +569,29 @@ export class ApiServer {
152
569
  return c.json(response);
153
570
  });
154
571
 
572
+ // Metrics endpoint
573
+ if (this.options.metrics?.enabled) {
574
+ this.app.get('/metrics', (c) => {
575
+ const summary = this.metrics.getSummary();
576
+ const response = formatter.success(summary);
577
+ return c.json(response);
578
+ });
579
+
580
+ if (this.options.verbose) {
581
+ console.log('[API Server] Metrics endpoint enabled at /metrics');
582
+ }
583
+ }
584
+
585
+ // Failban admin endpoints
586
+ if (this.failban) {
587
+ const failbanAdminRoutes = createFailbanAdminRoutes(this.Hono, this.failban);
588
+ this.app.route('/admin/security', failbanAdminRoutes);
589
+
590
+ if (this.options.verbose) {
591
+ console.log('[API Server] Failban admin endpoints enabled at /admin/security');
592
+ }
593
+ }
594
+
155
595
  // Root endpoint - custom handler or redirect to docs
156
596
  this.app.get('/', (c) => {
157
597
  // If user provided a custom root handler, use it
@@ -163,6 +603,9 @@ export class ApiServer {
163
603
  return c.redirect('/docs', 302);
164
604
  });
165
605
 
606
+ // Setup static file serving (before resource routes to give static files priority)
607
+ this._setupStaticRoutes();
608
+
166
609
  // OpenAPI spec endpoint
167
610
  if (this.options.docsEnabled) {
168
611
  this.app.get('/openapi.json', (c) => {
@@ -204,11 +647,21 @@ export class ApiServer {
204
647
  // Setup resource routes
205
648
  this._setupResourceRoutes();
206
649
 
207
- // Setup authentication routes if driver is configured
208
- if (this.options.auth.driver) {
650
+ // Setup authentication routes if JWT driver is configured
651
+ const hasJwtDriver = Array.isArray(this.options.auth?.drivers)
652
+ ? this.options.auth.drivers.some(d => d.driver === 'jwt')
653
+ : false;
654
+
655
+ if (this.options.auth?.driver || hasJwtDriver) {
209
656
  this._setupAuthRoutes();
210
657
  }
211
658
 
659
+ // Setup OIDC routes if configured
660
+ const oidcDriver = this.options.auth?.drivers?.find(d => d.driver === 'oidc');
661
+ if (oidcDriver) {
662
+ this._setupOIDCRoutes(oidcDriver.config);
663
+ }
664
+
212
665
  // Setup relational routes if RelationPlugin is active
213
666
  if (this.relationsPlugin) {
214
667
  this._setupRelationalRoutes();
@@ -241,33 +694,48 @@ export class ApiServer {
241
694
  * @private
242
695
  */
243
696
  _setupResourceRoutes() {
244
- const { database, resources: resourceConfigs } = this.options;
697
+ const { database, resources: resourceConfigs = {} } = this.options;
245
698
 
246
699
  // Get all resources from database
247
700
  const resources = database.resources;
248
701
 
702
+ // Create global auth middleware (applies to all resources, guards control access)
703
+ const authMiddleware = this._createAuthMiddleware();
704
+
249
705
  for (const [name, resource] of Object.entries(resources)) {
250
- // Skip plugin resources unless explicitly included
251
- if (name.startsWith('plg_') && !resourceConfigs[name]) {
706
+ const resourceConfig = resourceConfigs[name];
707
+ const isPluginResource = name.startsWith('plg_');
708
+
709
+ // Internal plugin resources require explicit opt-in
710
+ if (isPluginResource && !resourceConfig) {
711
+ if (this.options.verbose) {
712
+ console.log(`[API Plugin] Skipping internal resource '${name}' (not included in config.resources)`);
713
+ }
252
714
  continue;
253
715
  }
254
716
 
255
- // Get resource configuration
256
- const config = resourceConfigs[name] || {
257
- methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
258
- auth: false
259
- };
717
+ // Allow explicit disabling via config
718
+ if (resourceConfig?.enabled === false) {
719
+ if (this.options.verbose) {
720
+ console.log(`[API Plugin] Resource '${name}' disabled via config.resources`);
721
+ }
722
+ continue;
723
+ }
260
724
 
261
725
  // Determine version
262
726
  const version = resource.config?.currentVersion || resource.version || 'v1';
263
727
 
264
728
  // Determine version prefix (resource-level overrides global)
265
- // Priority: resource.versionPrefix > global versionPrefix > false (default - no prefix)
266
- let versionPrefixConfig = config.versionPrefix !== undefined
267
- ? config.versionPrefix
268
- : this.options.versionPrefix !== undefined
269
- ? this.options.versionPrefix
270
- : false;
729
+ let versionPrefixConfig;
730
+ if (resourceConfig && resourceConfig.versionPrefix !== undefined) {
731
+ versionPrefixConfig = resourceConfig.versionPrefix;
732
+ } else if (resource.config && resource.config.versionPrefix !== undefined) {
733
+ versionPrefixConfig = resource.config.versionPrefix;
734
+ } else if (this.options.versionPrefix !== undefined) {
735
+ versionPrefixConfig = this.options.versionPrefix;
736
+ } else {
737
+ versionPrefixConfig = false;
738
+ }
271
739
 
272
740
  // Calculate the actual prefix to use
273
741
  let prefix = '';
@@ -283,22 +751,51 @@ export class ApiServer {
283
751
  }
284
752
 
285
753
  // Prepare custom middleware
286
- const middlewares = [...(config.customMiddleware || [])];
754
+ const middlewares = [];
755
+
756
+ // Add global authentication middleware unless explicitly disabled
757
+ const authDisabled = resourceConfig?.auth === false;
287
758
 
288
- // Add authentication middleware if required
289
- if (config.auth && this.options.auth.driver) {
290
- const authMiddleware = this._createAuthMiddleware();
291
- if (authMiddleware) {
292
- middlewares.unshift(authMiddleware); // Add at beginning
759
+ if (authMiddleware && !authDisabled) {
760
+ middlewares.push(authMiddleware);
761
+ }
762
+
763
+ // Add resource-specific middleware from config (support single fn or array)
764
+ const extraMiddleware = resourceConfig?.customMiddleware;
765
+ if (extraMiddleware) {
766
+ const toRegister = Array.isArray(extraMiddleware) ? extraMiddleware : [extraMiddleware];
767
+
768
+ for (const middleware of toRegister) {
769
+ if (typeof middleware === 'function') {
770
+ middlewares.push(middleware);
771
+ } else if (this.options.verbose) {
772
+ console.warn(`[API Plugin] Ignoring non-function middleware for resource '${name}'`);
773
+ }
293
774
  }
294
775
  }
295
776
 
777
+ // Normalize HTTP methods (resource config > resource definition > defaults)
778
+ let methods = resourceConfig?.methods || resource.config?.methods;
779
+ if (!Array.isArray(methods) || methods.length === 0) {
780
+ methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
781
+ } else {
782
+ methods = methods
783
+ .filter(Boolean)
784
+ .map(method => typeof method === 'string' ? method.toUpperCase() : method);
785
+ }
786
+
787
+ // Determine validation toggle
788
+ const enableValidation = resourceConfig?.validation !== undefined
789
+ ? resourceConfig.validation !== false
790
+ : resource.config?.validation !== false;
791
+
296
792
  // Create resource routes
297
793
  const resourceApp = createResourceRoutes(resource, version, {
298
- methods: config.methods,
794
+ methods,
299
795
  customMiddleware: middlewares,
300
- enableValidation: config.validation !== false,
301
- versionPrefix: prefix
796
+ enableValidation,
797
+ versionPrefix: prefix,
798
+ events: this.events
302
799
  }, this.Hono);
303
800
 
304
801
  // Mount resource routes (with or without prefix)
@@ -309,8 +806,8 @@ export class ApiServer {
309
806
  console.log(`[API Plugin] Mounted routes for resource '${name}' at ${mountPath}`);
310
807
  }
311
808
 
312
- // Mount custom routes for this resource (if defined)
313
- if (config.routes) {
809
+ // Mount custom routes for this resource
810
+ if (resource.config?.routes) {
314
811
  const routeContext = {
315
812
  resource,
316
813
  database,
@@ -319,18 +816,26 @@ export class ApiServer {
319
816
  };
320
817
 
321
818
  // Mount on the resourceApp (nested under resource path)
322
- mountCustomRoutes(resourceApp, config.routes, routeContext, this.options.verbose);
819
+ mountCustomRoutes(resourceApp, resource.config.routes, routeContext, this.options.verbose);
323
820
  }
324
821
  }
325
822
  }
326
823
 
327
824
  /**
328
- * Setup authentication routes (when auth driver is configured)
825
+ * Setup authentication routes (when auth drivers are configured)
329
826
  * @private
330
827
  */
331
828
  _setupAuthRoutes() {
332
829
  const { database, auth } = this.options;
333
- const { driver, resource: resourceName, usernameField, passwordField, config } = auth;
830
+ const { drivers, resource: resourceName, usernameField, passwordField } = auth;
831
+
832
+ // Find first JWT driver (for /auth/login endpoint)
833
+ const jwtDriver = drivers.find(d => d.driver === 'jwt');
834
+
835
+ if (!jwtDriver) {
836
+ // No JWT driver = no /auth routes
837
+ return;
838
+ }
334
839
 
335
840
  // Get auth resource from database
336
841
  const authResource = database.resources[resourceName];
@@ -339,15 +844,17 @@ export class ApiServer {
339
844
  return;
340
845
  }
341
846
 
847
+ const driverConfig = jwtDriver.config || {};
848
+
342
849
  // Prepare auth config for routes
343
850
  const authConfig = {
344
- driver,
851
+ driver: 'jwt',
345
852
  usernameField,
346
853
  passwordField,
347
- jwtSecret: config.jwtSecret || config.secret,
348
- jwtExpiresIn: config.jwtExpiresIn || config.expiresIn || '7d',
349
- passphrase: config.passphrase || 'secret',
350
- allowRegistration: config.allowRegistration !== false
854
+ jwtSecret: driverConfig.jwtSecret || driverConfig.secret,
855
+ jwtExpiresIn: driverConfig.jwtExpiresIn || driverConfig.expiresIn || '7d',
856
+ passphrase: driverConfig.passphrase || 'secret',
857
+ allowRegistration: driverConfig.allowRegistration !== false
351
858
  };
352
859
 
353
860
  // Create auth routes
@@ -357,56 +864,339 @@ export class ApiServer {
357
864
  this.app.route('/auth', authApp);
358
865
 
359
866
  if (this.options.verbose) {
360
- console.log(`[API Plugin] Mounted auth routes (driver: ${driver}) at /auth`);
867
+ console.log('[API Plugin] Mounted auth routes (driver: jwt) at /auth');
361
868
  }
362
869
  }
363
870
 
364
871
  /**
365
- * Create authentication middleware based on driver
872
+ * Setup OIDC routes (when oidc driver is configured)
366
873
  * @private
367
- * @returns {Function|null} Auth middleware function
874
+ * @param {Object} config - OIDC driver configuration
875
+ */
876
+ _setupOIDCRoutes(config) {
877
+ const { database, auth } = this.options;
878
+ const authResource = database.resources[auth.resource];
879
+
880
+ if (!authResource) {
881
+ console.error(`[API Plugin] Auth resource '${auth.resource}' not found for OIDC`);
882
+ return;
883
+ }
884
+
885
+ // Create OIDC handler (which creates routes + middleware)
886
+ const oidcHandler = createOIDCHandler(config, this.app, authResource, this.events);
887
+
888
+ // Store middleware for later use in _createAuthMiddleware
889
+ this.oidcMiddleware = oidcHandler.middleware;
890
+
891
+ if (this.options.verbose) {
892
+ console.log('[API Plugin] Mounted OIDC routes:');
893
+ for (const [path, description] of Object.entries(oidcHandler.routes)) {
894
+ console.log(`[API Plugin] ${path} - ${description}`);
895
+ }
896
+ }
897
+ }
898
+
899
+ /**
900
+ * Create authentication middleware based on configured drivers
901
+ * @private
902
+ * @returns {Function|null} Hono middleware or null
368
903
  */
369
904
  _createAuthMiddleware() {
370
905
  const { database, auth } = this.options;
371
- const { driver, resource: resourceName, usernameField, passwordField, config } = auth;
906
+ const { drivers, resource: defaultResourceName, pathAuth, pathRules } = auth;
372
907
 
373
- if (!driver) {
908
+ // If no drivers configured, no auth
909
+ if (!drivers || drivers.length === 0) {
374
910
  return null;
375
911
  }
376
912
 
377
- const authResource = database.resources[resourceName];
913
+ // Get auth resource
914
+ const authResource = database.resources[defaultResourceName];
378
915
  if (!authResource) {
379
- console.error(`[API Plugin] Auth resource '${resourceName}' not found for middleware`);
916
+ console.error(`[API Plugin] Auth resource '${defaultResourceName}' not found for middleware`);
380
917
  return null;
381
918
  }
382
919
 
383
- if (driver === 'jwt') {
384
- const jwtSecret = config.jwtSecret || config.secret;
385
- if (!jwtSecret) {
386
- console.error('[API Plugin] JWT driver requires jwtSecret in config');
387
- return null;
920
+ // NEW: If pathRules configured, use new path-based auth system
921
+ if (pathRules && pathRules.length > 0) {
922
+ return this._createPathRulesAuthMiddleware(authResource, drivers, pathRules);
923
+ }
924
+
925
+ // Validate pathAuth config if provided
926
+ if (pathAuth) {
927
+ try {
928
+ validatePathAuth(pathAuth);
929
+ } catch (err) {
930
+ console.error(`[API Plugin] Invalid pathAuth configuration: ${err.message}`);
931
+ throw err;
388
932
  }
933
+ }
389
934
 
390
- return jwtAuth({
391
- secret: jwtSecret,
392
- authResource,
393
- usernameField,
394
- passwordField
395
- });
935
+ // Helper: Extract driver configs from drivers array
936
+ const extractDriverConfigs = (driverNames) => {
937
+ const configs = {
938
+ jwt: {},
939
+ apiKey: {},
940
+ basic: {},
941
+ oauth2: {}
942
+ };
943
+
944
+ for (const driverDef of drivers) {
945
+ const driverName = driverDef.driver;
946
+ const driverConfig = driverDef.config || {};
947
+
948
+ // Skip if not in requested drivers
949
+ if (driverNames && !driverNames.includes(driverName)) {
950
+ continue;
951
+ }
952
+
953
+ // Skip oauth2-server and oidc drivers (they're handled separately)
954
+ if (driverName === 'oauth2-server' || driverName === 'oidc') {
955
+ continue;
956
+ }
957
+
958
+ // Map driver configs
959
+ if (driverName === 'jwt') {
960
+ configs.jwt = {
961
+ secret: driverConfig.jwtSecret || driverConfig.secret,
962
+ expiresIn: driverConfig.jwtExpiresIn || driverConfig.expiresIn || '7d'
963
+ };
964
+ } else if (driverName === 'apiKey') {
965
+ configs.apiKey = {
966
+ headerName: driverConfig.headerName || 'X-API-Key'
967
+ };
968
+ } else if (driverName === 'basic') {
969
+ configs.basic = {
970
+ realm: driverConfig.realm || 'API Access',
971
+ passphrase: driverConfig.passphrase || 'secret'
972
+ };
973
+ } else if (driverName === 'oauth2') {
974
+ configs.oauth2 = driverConfig;
975
+ }
976
+ }
977
+
978
+ return configs;
979
+ };
980
+
981
+ // If pathAuth is defined, create path-based conditional middleware
982
+ if (pathAuth) {
983
+ return async (c, next) => {
984
+ const requestPath = c.req.path;
985
+
986
+ // Find best matching rule for this path
987
+ const matchedRule = findBestMatch(pathAuth, requestPath);
988
+
989
+ if (this.options.verbose) {
990
+ if (matchedRule) {
991
+ console.log(`[API Plugin] Path ${requestPath} matched rule: ${matchedRule.pattern}`);
992
+ } else {
993
+ console.log(`[API Plugin] Path ${requestPath} no pathAuth rule matched (using global auth)`);
994
+ }
995
+ }
996
+
997
+ // If no rule matched, use global auth (all drivers, optional)
998
+ if (!matchedRule) {
999
+ const methods = drivers
1000
+ .map(d => d.driver)
1001
+ .filter(d => d !== 'oauth2-server' && d !== 'oidc');
1002
+
1003
+ const driverConfigs = extractDriverConfigs(null); // all drivers
1004
+
1005
+ const globalAuth = createAuthMiddleware({
1006
+ methods,
1007
+ jwt: driverConfigs.jwt,
1008
+ apiKey: driverConfigs.apiKey,
1009
+ basic: driverConfigs.basic,
1010
+ oauth2: driverConfigs.oauth2,
1011
+ oidc: this.oidcMiddleware || null,
1012
+ usersResource: authResource,
1013
+ optional: true
1014
+ });
1015
+
1016
+ return await globalAuth(c, next);
1017
+ }
1018
+
1019
+ // Rule matched - check if auth is required
1020
+ if (!matchedRule.required) {
1021
+ // Public path - no auth required
1022
+ return await next();
1023
+ }
1024
+
1025
+ // Auth required - apply with specific drivers from rule
1026
+ const ruleMethods = matchedRule.drivers || [];
1027
+ const driverConfigs = extractDriverConfigs(ruleMethods);
1028
+
1029
+ const ruleAuth = createAuthMiddleware({
1030
+ methods: ruleMethods,
1031
+ jwt: driverConfigs.jwt,
1032
+ apiKey: driverConfigs.apiKey,
1033
+ basic: driverConfigs.basic,
1034
+ oauth2: driverConfigs.oauth2,
1035
+ oidc: this.oidcMiddleware || null,
1036
+ usersResource: authResource,
1037
+ optional: false // Auth is required for this path
1038
+ });
1039
+
1040
+ return await ruleAuth(c, next);
1041
+ };
396
1042
  }
397
1043
 
398
- if (driver === 'basic') {
399
- return basicAuth({
400
- realm: config.realm || 'API Access',
401
- authResource,
402
- usernameField,
403
- passwordField,
404
- passphrase: config.passphrase || 'secret'
405
- });
1044
+ // No pathAuth - use original behavior (global auth, all drivers)
1045
+ const methods = [];
1046
+ const driverConfigs = {
1047
+ jwt: {},
1048
+ apiKey: {},
1049
+ basic: {},
1050
+ oauth2: {}
1051
+ };
1052
+
1053
+ for (const driverDef of drivers) {
1054
+ const driverName = driverDef.driver;
1055
+ const driverConfig = driverDef.config || {};
1056
+
1057
+ // Skip oauth2-server and oidc drivers (they're handled separately)
1058
+ if (driverName === 'oauth2-server' || driverName === 'oidc') {
1059
+ continue;
1060
+ }
1061
+
1062
+ if (!methods.includes(driverName)) {
1063
+ methods.push(driverName);
1064
+ }
1065
+
1066
+ // Map driver configs
1067
+ if (driverName === 'jwt') {
1068
+ driverConfigs.jwt = {
1069
+ secret: driverConfig.jwtSecret || driverConfig.secret,
1070
+ expiresIn: driverConfig.jwtExpiresIn || driverConfig.expiresIn || '7d'
1071
+ };
1072
+ } else if (driverName === 'apiKey') {
1073
+ driverConfigs.apiKey = {
1074
+ headerName: driverConfig.headerName || 'X-API-Key'
1075
+ };
1076
+ } else if (driverName === 'basic') {
1077
+ driverConfigs.basic = {
1078
+ realm: driverConfig.realm || 'API Access',
1079
+ passphrase: driverConfig.passphrase || 'secret'
1080
+ };
1081
+ } else if (driverName === 'oauth2') {
1082
+ driverConfigs.oauth2 = driverConfig;
1083
+ }
406
1084
  }
407
1085
 
408
- console.error(`[API Plugin] Unknown auth driver: ${driver}`);
409
- return null;
1086
+ // Create unified auth middleware
1087
+ return createAuthMiddleware({
1088
+ methods,
1089
+ jwt: driverConfigs.jwt,
1090
+ apiKey: driverConfigs.apiKey,
1091
+ basic: driverConfigs.basic,
1092
+ oauth2: driverConfigs.oauth2,
1093
+ oidc: this.oidcMiddleware || null, // OIDC middleware (if configured)
1094
+ usersResource: authResource,
1095
+ optional: true // Let guards handle authorization
1096
+ });
1097
+ }
1098
+
1099
+ /**
1100
+ * Create path-based auth middleware using pathRules
1101
+ * @private
1102
+ * @param {Object} authResource - Users resource for authentication
1103
+ * @param {Array} drivers - Auth driver configurations
1104
+ * @param {Array} pathRules - Path-based auth rules
1105
+ * @returns {Function} Hono middleware
1106
+ */
1107
+ _createPathRulesAuthMiddleware(authResource, drivers, pathRules) {
1108
+ // Build auth middlewares map by driver type
1109
+ const authMiddlewares = {};
1110
+
1111
+ for (const driverDef of drivers) {
1112
+ const driverType = driverDef.type || driverDef.driver;
1113
+ const driverConfig = driverDef.config || driverDef;
1114
+
1115
+ // Skip oauth2-server (not a request auth method)
1116
+ if (driverType === 'oauth2-server') {
1117
+ continue;
1118
+ }
1119
+
1120
+ // OIDC middleware (already configured)
1121
+ if (driverType === 'oidc') {
1122
+ if (this.oidcMiddleware) {
1123
+ authMiddlewares.oidc = this.oidcMiddleware;
1124
+ }
1125
+ continue;
1126
+ }
1127
+
1128
+ // JWT
1129
+ if (driverType === 'jwt') {
1130
+ authMiddlewares.jwt = jwtAuth({
1131
+ secret: driverConfig.jwtSecret || driverConfig.secret,
1132
+ expiresIn: driverConfig.jwtExpiresIn || driverConfig.expiresIn || '7d',
1133
+ usersResource: authResource,
1134
+ optional: true
1135
+ });
1136
+ }
1137
+
1138
+ // API Key
1139
+ if (driverType === 'apiKey') {
1140
+ authMiddlewares.apiKey = apiKeyAuth({
1141
+ headerName: driverConfig.headerName || 'X-API-Key',
1142
+ usersResource: authResource,
1143
+ optional: true
1144
+ });
1145
+ }
1146
+
1147
+ // Basic Auth
1148
+ if (driverType === 'basic') {
1149
+ authMiddlewares.basic = basicAuth({
1150
+ authResource,
1151
+ usernameField: driverConfig.usernameField || 'email',
1152
+ passwordField: driverConfig.passwordField || 'password',
1153
+ passphrase: driverConfig.passphrase || 'secret',
1154
+ adminUser: driverConfig.adminUser || null,
1155
+ optional: true
1156
+ });
1157
+ }
1158
+
1159
+ // OAuth2
1160
+ if (driverType === 'oauth2') {
1161
+ const oauth2Handler = createOAuth2Handler(driverConfig, authResource);
1162
+ authMiddlewares.oauth2 = async (c, next) => {
1163
+ const user = await oauth2Handler(c);
1164
+ if (user) {
1165
+ c.set('user', user);
1166
+ return await next();
1167
+ }
1168
+ };
1169
+ }
1170
+ }
1171
+
1172
+ if (this.options.verbose) {
1173
+ console.log(`[API Server] Path-based auth with ${pathRules.length} rules`);
1174
+ console.log(`[API Server] Available auth methods: ${Object.keys(authMiddlewares).join(', ')}`);
1175
+ }
1176
+
1177
+ // Create and return path-based auth middleware
1178
+ return createPathBasedAuthMiddleware({
1179
+ rules: pathRules,
1180
+ authMiddlewares,
1181
+ unauthorizedHandler: (c, message) => {
1182
+ // Content negotiation
1183
+ const acceptHeader = c.req.header('accept') || '';
1184
+ const acceptsHtml = acceptHeader.includes('text/html');
1185
+
1186
+ if (acceptsHtml) {
1187
+ // Redirect to login if OIDC is available
1188
+ if (authMiddlewares.oidc) {
1189
+ return c.redirect('/auth/login', 302);
1190
+ }
1191
+ }
1192
+
1193
+ return c.json({
1194
+ error: 'Unauthorized',
1195
+ message
1196
+ }, 401);
1197
+ },
1198
+ events: this.events
1199
+ });
410
1200
  }
411
1201
 
412
1202
  /**
@@ -503,6 +1293,105 @@ export class ApiServer {
503
1293
  }
504
1294
  }
505
1295
 
1296
+ /**
1297
+ * Setup static file serving routes
1298
+ * @private
1299
+ */
1300
+ _setupStaticRoutes() {
1301
+ const { static: staticConfigs, database } = this.options;
1302
+
1303
+ if (!staticConfigs || staticConfigs.length === 0) {
1304
+ return;
1305
+ }
1306
+
1307
+ if (!Array.isArray(staticConfigs)) {
1308
+ throw new Error('Static config must be an array of mount points');
1309
+ }
1310
+
1311
+ for (const [index, config] of staticConfigs.entries()) {
1312
+ try {
1313
+ // Validate required fields
1314
+ if (!config.driver) {
1315
+ throw new Error(`static[${index}]: "driver" is required (filesystem or s3)`);
1316
+ }
1317
+
1318
+ if (!config.path) {
1319
+ throw new Error(`static[${index}]: "path" is required (mount path)`);
1320
+ }
1321
+
1322
+ if (!config.path.startsWith('/')) {
1323
+ throw new Error(`static[${index}]: "path" must start with / (got: ${config.path})`);
1324
+ }
1325
+
1326
+ const driverConfig = config.config || {};
1327
+
1328
+ // Create handler based on driver
1329
+ let handler;
1330
+
1331
+ if (config.driver === 'filesystem') {
1332
+ // Validate filesystem-specific config
1333
+ validateFilesystemConfig({ ...config, ...driverConfig });
1334
+
1335
+ handler = createFilesystemHandler({
1336
+ root: config.root,
1337
+ index: driverConfig.index,
1338
+ fallback: driverConfig.fallback,
1339
+ maxAge: driverConfig.maxAge,
1340
+ dotfiles: driverConfig.dotfiles,
1341
+ etag: driverConfig.etag,
1342
+ cors: driverConfig.cors
1343
+ });
1344
+
1345
+ } else if (config.driver === 's3') {
1346
+ // Validate S3-specific config
1347
+ validateS3Config({ ...config, ...driverConfig });
1348
+
1349
+ // Get S3 client from database
1350
+ const s3Client = database?.client?.client; // S3Client instance
1351
+
1352
+ if (!s3Client) {
1353
+ throw new Error(`static[${index}]: S3 driver requires database with S3 client`);
1354
+ }
1355
+
1356
+ handler = createS3Handler({
1357
+ s3Client,
1358
+ bucket: config.bucket,
1359
+ prefix: config.prefix,
1360
+ streaming: driverConfig.streaming,
1361
+ signedUrlExpiry: driverConfig.signedUrlExpiry,
1362
+ maxAge: driverConfig.maxAge,
1363
+ cacheControl: driverConfig.cacheControl,
1364
+ contentDisposition: driverConfig.contentDisposition,
1365
+ etag: driverConfig.etag,
1366
+ cors: driverConfig.cors
1367
+ });
1368
+
1369
+ } else {
1370
+ throw new Error(
1371
+ `static[${index}]: invalid driver "${config.driver}". Valid drivers: filesystem, s3`
1372
+ );
1373
+ }
1374
+
1375
+ // Mount handler at specified path
1376
+ // Use wildcard to match all sub-paths
1377
+ const mountPath = config.path === '/' ? '/*' : `${config.path}/*`;
1378
+ this.app.get(mountPath, handler);
1379
+ this.app.head(mountPath, handler);
1380
+
1381
+ if (this.options.verbose) {
1382
+ console.log(
1383
+ `[API Plugin] Mounted static files (${config.driver}) at ${config.path}` +
1384
+ (config.driver === 'filesystem' ? ` -> ${config.root}` : ` -> s3://${config.bucket}/${config.prefix || ''}`)
1385
+ );
1386
+ }
1387
+
1388
+ } catch (err) {
1389
+ console.error(`[API Plugin] Failed to setup static files for index ${index}:`, err.message);
1390
+ throw err;
1391
+ }
1392
+ }
1393
+ }
1394
+
506
1395
  /**
507
1396
  * Start the server
508
1397
  * @returns {Promise<void>}
@@ -519,15 +1408,22 @@ export class ApiServer {
519
1408
  const { Hono } = await import('hono');
520
1409
  const { serve } = await import('@hono/node-server');
521
1410
  const { swaggerUI } = await import('@hono/swagger-ui');
1411
+ const { cors } = await import('hono/cors');
522
1412
 
523
1413
  // Store for use in _setupRoutes
524
1414
  this.Hono = Hono;
525
1415
  this.serve = serve;
526
1416
  this.swaggerUI = swaggerUI;
1417
+ this.cors = cors;
527
1418
 
528
1419
  // Initialize app
529
1420
  this.app = new Hono();
530
1421
 
1422
+ // Initialize failban manager if enabled
1423
+ if (this.failban) {
1424
+ await this.failban.initialize();
1425
+ }
1426
+
531
1427
  // Setup all routes
532
1428
  this._setupRoutes();
533
1429
 
@@ -545,6 +1441,22 @@ export class ApiServer {
545
1441
  }, (info) => {
546
1442
  this.isRunning = true;
547
1443
  console.log(`[API Plugin] Server listening on http://${info.address}:${info.port}`);
1444
+
1445
+ // Setup graceful shutdown on SIGTERM/SIGINT
1446
+ const shutdownHandler = async (signal) => {
1447
+ console.log(`[API Server] Received ${signal}, initiating graceful shutdown...`);
1448
+ try {
1449
+ await this.shutdown({ timeout: 30000 });
1450
+ process.exit(0);
1451
+ } catch (err) {
1452
+ console.error('[API Server] Error during shutdown:', err);
1453
+ process.exit(1);
1454
+ }
1455
+ };
1456
+
1457
+ process.once('SIGTERM', () => shutdownHandler('SIGTERM'));
1458
+ process.once('SIGINT', () => shutdownHandler('SIGINT'));
1459
+
548
1460
  resolve();
549
1461
  });
550
1462
  } catch (err) {
@@ -576,6 +1488,16 @@ export class ApiServer {
576
1488
  this.isRunning = false;
577
1489
  console.log('[API Plugin] Server stopped');
578
1490
  }
1491
+
1492
+ // Cleanup metrics collector
1493
+ if (this.metrics) {
1494
+ this.metrics.stop();
1495
+ }
1496
+
1497
+ // Cleanup failban plugin
1498
+ if (this.failban) {
1499
+ await this.failban.cleanup();
1500
+ }
579
1501
  }
580
1502
 
581
1503
  /**
@@ -603,9 +1525,9 @@ export class ApiServer {
603
1525
  * Generate OpenAPI specification
604
1526
  * @private
605
1527
  * @returns {Object} OpenAPI spec
606
- */
1528
+ */
607
1529
  _generateOpenAPISpec() {
608
- const { port, host, database, resources, auth, apiInfo } = this.options;
1530
+ const { port, host, database, resources, auth, apiInfo, versionPrefix } = this.options;
609
1531
 
610
1532
  return generateOpenAPISpec(database, {
611
1533
  title: apiInfo.title,
@@ -613,7 +1535,8 @@ export class ApiServer {
613
1535
  description: apiInfo.description,
614
1536
  serverUrl: `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`,
615
1537
  auth,
616
- resources
1538
+ resources,
1539
+ versionPrefix
617
1540
  });
618
1541
  }
619
1542
  }