s3db.js 13.4.0 → 13.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -10
- package/dist/{s3db.cjs.js → s3db.cjs} +38801 -32446
- package/dist/s3db.cjs.map +1 -0
- package/dist/s3db.es.js +38653 -32291
- package/dist/s3db.es.js.map +1 -1
- package/package.json +218 -22
- package/src/concerns/id.js +90 -6
- package/src/concerns/index.js +2 -1
- package/src/concerns/password-hashing.js +150 -0
- package/src/database.class.js +6 -2
- package/src/plugins/api/auth/basic-auth.js +40 -10
- package/src/plugins/api/auth/index.js +49 -3
- package/src/plugins/api/auth/oauth2-auth.js +171 -0
- package/src/plugins/api/auth/oidc-auth.js +789 -0
- package/src/plugins/api/auth/oidc-client.js +462 -0
- package/src/plugins/api/auth/path-auth-matcher.js +284 -0
- package/src/plugins/api/concerns/event-emitter.js +134 -0
- package/src/plugins/api/concerns/failban-manager.js +651 -0
- package/src/plugins/api/concerns/guards-helpers.js +402 -0
- package/src/plugins/api/concerns/metrics-collector.js +346 -0
- package/src/plugins/api/index.js +510 -57
- package/src/plugins/api/middlewares/failban.js +305 -0
- package/src/plugins/api/middlewares/rate-limit.js +301 -0
- package/src/plugins/api/middlewares/request-id.js +74 -0
- package/src/plugins/api/middlewares/security-headers.js +120 -0
- package/src/plugins/api/middlewares/session-tracking.js +194 -0
- package/src/plugins/api/routes/auth-routes.js +119 -78
- package/src/plugins/api/routes/resource-routes.js +73 -30
- package/src/plugins/api/server.js +1139 -45
- package/src/plugins/api/utils/custom-routes.js +102 -0
- package/src/plugins/api/utils/guards.js +213 -0
- package/src/plugins/api/utils/mime-types.js +154 -0
- package/src/plugins/api/utils/openapi-generator.js +91 -12
- package/src/plugins/api/utils/path-matcher.js +173 -0
- package/src/plugins/api/utils/static-filesystem.js +262 -0
- package/src/plugins/api/utils/static-s3.js +231 -0
- package/src/plugins/api/utils/template-engine.js +188 -0
- package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +853 -0
- package/src/plugins/cloud-inventory/drivers/aws-driver.js +2554 -0
- package/src/plugins/cloud-inventory/drivers/azure-driver.js +637 -0
- package/src/plugins/cloud-inventory/drivers/base-driver.js +99 -0
- package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +620 -0
- package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +698 -0
- package/src/plugins/cloud-inventory/drivers/gcp-driver.js +645 -0
- package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +559 -0
- package/src/plugins/cloud-inventory/drivers/linode-driver.js +614 -0
- package/src/plugins/cloud-inventory/drivers/mock-drivers.js +449 -0
- package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +771 -0
- package/src/plugins/cloud-inventory/drivers/oracle-driver.js +768 -0
- package/src/plugins/cloud-inventory/drivers/vultr-driver.js +636 -0
- package/src/plugins/cloud-inventory/index.js +20 -0
- package/src/plugins/cloud-inventory/registry.js +146 -0
- package/src/plugins/cloud-inventory/terraform-exporter.js +362 -0
- package/src/plugins/cloud-inventory.plugin.js +1333 -0
- package/src/plugins/concerns/plugin-dependencies.js +62 -2
- package/src/plugins/eventual-consistency/analytics.js +1 -0
- package/src/plugins/eventual-consistency/consolidation.js +2 -2
- package/src/plugins/eventual-consistency/garbage-collection.js +2 -2
- package/src/plugins/eventual-consistency/install.js +2 -2
- package/src/plugins/identity/README.md +335 -0
- package/src/plugins/identity/concerns/mfa-manager.js +204 -0
- package/src/plugins/identity/concerns/password.js +138 -0
- package/src/plugins/identity/concerns/resource-schemas.js +273 -0
- package/src/plugins/identity/concerns/token-generator.js +172 -0
- package/src/plugins/identity/email-service.js +422 -0
- package/src/plugins/identity/index.js +1052 -0
- package/src/plugins/identity/oauth2-server.js +1033 -0
- package/src/plugins/identity/oidc-discovery.js +285 -0
- package/src/plugins/identity/rsa-keys.js +323 -0
- package/src/plugins/identity/server.js +500 -0
- package/src/plugins/identity/session-manager.js +453 -0
- package/src/plugins/identity/ui/layouts/base.js +251 -0
- package/src/plugins/identity/ui/middleware.js +135 -0
- package/src/plugins/identity/ui/pages/admin/client-form.js +247 -0
- package/src/plugins/identity/ui/pages/admin/clients.js +179 -0
- package/src/plugins/identity/ui/pages/admin/dashboard.js +181 -0
- package/src/plugins/identity/ui/pages/admin/user-form.js +283 -0
- package/src/plugins/identity/ui/pages/admin/users.js +263 -0
- package/src/plugins/identity/ui/pages/consent.js +262 -0
- package/src/plugins/identity/ui/pages/forgot-password.js +104 -0
- package/src/plugins/identity/ui/pages/login.js +144 -0
- package/src/plugins/identity/ui/pages/mfa-backup-codes.js +180 -0
- package/src/plugins/identity/ui/pages/mfa-enrollment.js +187 -0
- package/src/plugins/identity/ui/pages/mfa-verification.js +178 -0
- package/src/plugins/identity/ui/pages/oauth-error.js +225 -0
- package/src/plugins/identity/ui/pages/profile.js +361 -0
- package/src/plugins/identity/ui/pages/register.js +226 -0
- package/src/plugins/identity/ui/pages/reset-password.js +128 -0
- package/src/plugins/identity/ui/pages/verify-email.js +172 -0
- package/src/plugins/identity/ui/routes.js +2541 -0
- package/src/plugins/identity/ui/styles/main.css +465 -0
- package/src/plugins/index.js +4 -1
- package/src/plugins/ml/base-model.class.js +65 -16
- package/src/plugins/ml/classification-model.class.js +1 -1
- package/src/plugins/ml/timeseries-model.class.js +3 -1
- package/src/plugins/ml.plugin.js +584 -31
- package/src/plugins/shared/error-handler.js +147 -0
- package/src/plugins/shared/index.js +9 -0
- package/src/plugins/shared/middlewares/compression.js +117 -0
- package/src/plugins/shared/middlewares/cors.js +49 -0
- package/src/plugins/shared/middlewares/index.js +11 -0
- package/src/plugins/shared/middlewares/logging.js +54 -0
- package/src/plugins/shared/middlewares/rate-limit.js +73 -0
- package/src/plugins/shared/middlewares/security.js +158 -0
- package/src/plugins/shared/response-formatter.js +264 -0
- package/src/plugins/state-machine.plugin.js +57 -2
- package/src/resource.class.js +140 -12
- package/src/schema.class.js +30 -1
- package/src/validator.class.js +57 -6
- package/dist/s3db.cjs.js.map +0 -1
package/src/plugins/api/index.js
CHANGED
|
@@ -36,6 +36,86 @@ import { Plugin } from '../plugin.class.js';
|
|
|
36
36
|
import { requirePluginDependency } from '../concerns/plugin-dependencies.js';
|
|
37
37
|
import tryFn from '../../concerns/try-fn.js';
|
|
38
38
|
import { ApiServer } from './server.js';
|
|
39
|
+
import { idGenerator } from '../../concerns/id.js';
|
|
40
|
+
|
|
41
|
+
const AUTH_DRIVER_KEYS = ['jwt', 'apiKey', 'basic', 'oidc', 'oauth2'];
|
|
42
|
+
|
|
43
|
+
function normalizeAuthConfig(authOptions = {}) {
|
|
44
|
+
if (!authOptions) {
|
|
45
|
+
return {
|
|
46
|
+
drivers: [],
|
|
47
|
+
pathRules: [],
|
|
48
|
+
pathAuth: undefined,
|
|
49
|
+
strategy: 'any',
|
|
50
|
+
priorities: {},
|
|
51
|
+
resource: 'users',
|
|
52
|
+
usernameField: 'email',
|
|
53
|
+
passwordField: 'password',
|
|
54
|
+
driver: null
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const normalized = {
|
|
59
|
+
drivers: [],
|
|
60
|
+
pathRules: Array.isArray(authOptions.pathRules) ? authOptions.pathRules : [],
|
|
61
|
+
pathAuth: authOptions.pathAuth,
|
|
62
|
+
strategy: authOptions.strategy || 'any',
|
|
63
|
+
priorities: authOptions.priorities || {},
|
|
64
|
+
resource: authOptions.resource || 'users',
|
|
65
|
+
usernameField: authOptions.usernameField || 'email',
|
|
66
|
+
passwordField: authOptions.passwordField || 'password'
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
|
|
71
|
+
const addDriver = (name, driverConfig = {}) => {
|
|
72
|
+
if (!name) return;
|
|
73
|
+
const driverName = String(name).trim();
|
|
74
|
+
if (!driverName || seen.has(driverName)) return;
|
|
75
|
+
seen.add(driverName);
|
|
76
|
+
normalized.drivers.push({
|
|
77
|
+
driver: driverName,
|
|
78
|
+
config: driverConfig || {}
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Drivers provided as array
|
|
83
|
+
if (Array.isArray(authOptions.drivers)) {
|
|
84
|
+
for (const entry of authOptions.drivers) {
|
|
85
|
+
if (typeof entry === 'string') {
|
|
86
|
+
addDriver(entry, {});
|
|
87
|
+
} else if (entry && typeof entry === 'object') {
|
|
88
|
+
addDriver(entry.driver, entry.config || {});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Single driver shortcut
|
|
94
|
+
if (authOptions.driver) {
|
|
95
|
+
if (typeof authOptions.driver === 'string') {
|
|
96
|
+
addDriver(authOptions.driver, authOptions.config || {});
|
|
97
|
+
} else if (typeof authOptions.driver === 'object') {
|
|
98
|
+
addDriver(authOptions.driver.driver, authOptions.driver.config || authOptions.config || {});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Support legacy per-driver objects (jwt: {...}, apiKey: {...})
|
|
103
|
+
for (const driverName of AUTH_DRIVER_KEYS) {
|
|
104
|
+
if (authOptions[driverName] === undefined) continue;
|
|
105
|
+
|
|
106
|
+
const value = authOptions[driverName];
|
|
107
|
+
if (!value || value.enabled === false) continue;
|
|
108
|
+
|
|
109
|
+
const config = typeof value === 'object' ? { ...value } : {};
|
|
110
|
+
if (config.enabled !== undefined) {
|
|
111
|
+
delete config.enabled;
|
|
112
|
+
}
|
|
113
|
+
addDriver(driverName, config);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
normalized.driver = normalized.drivers.length > 0 ? normalized.drivers[0].driver : null;
|
|
117
|
+
return normalized;
|
|
118
|
+
}
|
|
39
119
|
|
|
40
120
|
/**
|
|
41
121
|
* API Plugin class
|
|
@@ -50,12 +130,18 @@ export class ApiPlugin extends Plugin {
|
|
|
50
130
|
constructor(options = {}) {
|
|
51
131
|
super(options);
|
|
52
132
|
|
|
133
|
+
const normalizedAuth = normalizeAuthConfig(options.auth);
|
|
134
|
+
|
|
53
135
|
this.config = {
|
|
54
136
|
// Server configuration
|
|
55
137
|
port: options.port || 3000,
|
|
56
138
|
host: options.host || '0.0.0.0',
|
|
57
139
|
verbose: options.verbose || false,
|
|
58
140
|
|
|
141
|
+
// Version prefix configuration (global default)
|
|
142
|
+
// Can be: true (use resource version), false (no prefix - DEFAULT), or string (custom prefix like 'api/v1')
|
|
143
|
+
versionPrefix: options.versionPrefix !== undefined ? options.versionPrefix : false,
|
|
144
|
+
|
|
59
145
|
docs: {
|
|
60
146
|
enabled: options.docs?.enabled !== false && options.docsEnabled !== false, // Enable by default
|
|
61
147
|
ui: options.docs?.ui || 'redoc', // 'swagger' or 'redoc' (redoc is prettier!)
|
|
@@ -64,25 +150,21 @@ export class ApiPlugin extends Plugin {
|
|
|
64
150
|
description: options.docs?.description || options.apiDescription || 'Auto-generated REST API for s3db.js resources'
|
|
65
151
|
},
|
|
66
152
|
|
|
67
|
-
// Authentication configuration
|
|
68
|
-
auth:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
expiresIn: options.auth?.jwt?.expiresIn || '7d'
|
|
73
|
-
},
|
|
74
|
-
apiKey: {
|
|
75
|
-
enabled: options.auth?.apiKey?.enabled || false,
|
|
76
|
-
headerName: options.auth?.apiKey?.headerName || 'X-API-Key'
|
|
77
|
-
},
|
|
78
|
-
basic: {
|
|
79
|
-
enabled: options.auth?.basic?.enabled || false,
|
|
80
|
-
realm: options.auth?.basic?.realm || 'API Access'
|
|
81
|
-
}
|
|
82
|
-
},
|
|
153
|
+
// Authentication configuration (multiple drivers)
|
|
154
|
+
auth: normalizedAuth,
|
|
155
|
+
|
|
156
|
+
// Custom routes (plugin-level)
|
|
157
|
+
routes: options.routes || {},
|
|
83
158
|
|
|
84
|
-
//
|
|
85
|
-
|
|
159
|
+
// Template engine configuration
|
|
160
|
+
templates: {
|
|
161
|
+
enabled: options.templates?.enabled || false,
|
|
162
|
+
engine: options.templates?.engine || 'jsx', // 'jsx' (default), 'ejs', 'custom'
|
|
163
|
+
templatesDir: options.templates?.templatesDir || './views',
|
|
164
|
+
layout: options.templates?.layout || null,
|
|
165
|
+
engineOptions: options.templates?.engineOptions || {},
|
|
166
|
+
customRenderer: options.templates?.customRenderer || null
|
|
167
|
+
},
|
|
86
168
|
|
|
87
169
|
// CORS configuration
|
|
88
170
|
cors: {
|
|
@@ -125,19 +207,83 @@ export class ApiPlugin extends Plugin {
|
|
|
125
207
|
returnValidationErrors: options.validation?.returnValidationErrors !== false
|
|
126
208
|
},
|
|
127
209
|
|
|
128
|
-
//
|
|
210
|
+
// Security Headers (Helmet-like configuration)
|
|
211
|
+
security: {
|
|
212
|
+
enabled: options.security?.enabled !== false, // Enabled by default
|
|
213
|
+
|
|
214
|
+
// Content Security Policy (CSP)
|
|
215
|
+
contentSecurityPolicy: options.security?.contentSecurityPolicy !== false ? {
|
|
216
|
+
enabled: options.security?.contentSecurityPolicy?.enabled !== false,
|
|
217
|
+
directives: options.security?.contentSecurityPolicy?.directives || options.csp?.directives || {
|
|
218
|
+
'default-src': ["'self'"],
|
|
219
|
+
'script-src': ["'self'", "'unsafe-inline'", 'https://cdn.redoc.ly/redoc/v2.5.1/'],
|
|
220
|
+
'style-src': ["'self'", "'unsafe-inline'", 'https://cdn.redoc.ly/redoc/v2.5.1/', 'https://fonts.googleapis.com'],
|
|
221
|
+
'font-src': ["'self'", 'https://fonts.gstatic.com'],
|
|
222
|
+
'img-src': ["'self'", 'data:', 'https:'],
|
|
223
|
+
'connect-src': ["'self'"]
|
|
224
|
+
},
|
|
225
|
+
reportOnly: options.security?.contentSecurityPolicy?.reportOnly || options.csp?.reportOnly || false,
|
|
226
|
+
reportUri: options.security?.contentSecurityPolicy?.reportUri || options.csp?.reportUri || null
|
|
227
|
+
} : false,
|
|
228
|
+
|
|
229
|
+
// X-Frame-Options (clickjacking protection)
|
|
230
|
+
frameguard: options.security?.frameguard !== false ? {
|
|
231
|
+
action: options.security?.frameguard?.action || 'deny' // 'deny' or 'sameorigin'
|
|
232
|
+
} : false,
|
|
233
|
+
|
|
234
|
+
// X-Content-Type-Options (MIME sniffing protection)
|
|
235
|
+
noSniff: options.security?.noSniff !== false, // Enabled by default
|
|
236
|
+
|
|
237
|
+
// Strict-Transport-Security (HSTS - force HTTPS)
|
|
238
|
+
hsts: options.security?.hsts !== false ? {
|
|
239
|
+
maxAge: options.security?.hsts?.maxAge || 15552000, // 180 days (Helmet default)
|
|
240
|
+
includeSubDomains: options.security?.hsts?.includeSubDomains !== false,
|
|
241
|
+
preload: options.security?.hsts?.preload || false
|
|
242
|
+
} : false,
|
|
243
|
+
|
|
244
|
+
// Referrer-Policy (privacy)
|
|
245
|
+
referrerPolicy: options.security?.referrerPolicy !== false ? {
|
|
246
|
+
policy: options.security?.referrerPolicy?.policy || 'no-referrer'
|
|
247
|
+
} : false,
|
|
248
|
+
|
|
249
|
+
// X-DNS-Prefetch-Control (DNS leak protection)
|
|
250
|
+
dnsPrefetchControl: options.security?.dnsPrefetchControl !== false ? {
|
|
251
|
+
allow: options.security?.dnsPrefetchControl?.allow || false
|
|
252
|
+
} : false,
|
|
253
|
+
|
|
254
|
+
// X-Download-Options (IE8+ download security)
|
|
255
|
+
ieNoOpen: options.security?.ieNoOpen !== false, // Enabled by default
|
|
256
|
+
|
|
257
|
+
// X-Permitted-Cross-Domain-Policies (Flash/PDF security)
|
|
258
|
+
permittedCrossDomainPolicies: options.security?.permittedCrossDomainPolicies !== false ? {
|
|
259
|
+
policy: options.security?.permittedCrossDomainPolicies?.policy || 'none'
|
|
260
|
+
} : false,
|
|
261
|
+
|
|
262
|
+
// X-XSS-Protection (legacy XSS filter)
|
|
263
|
+
xssFilter: options.security?.xssFilter !== false ? {
|
|
264
|
+
mode: options.security?.xssFilter?.mode || 'block'
|
|
265
|
+
} : false,
|
|
266
|
+
|
|
267
|
+
// Permissions-Policy (modern feature policy)
|
|
268
|
+
permissionsPolicy: options.security?.permissionsPolicy !== false ? {
|
|
269
|
+
features: options.security?.permissionsPolicy?.features || {
|
|
270
|
+
geolocation: [],
|
|
271
|
+
microphone: [],
|
|
272
|
+
camera: [],
|
|
273
|
+
payment: [],
|
|
274
|
+
usb: [],
|
|
275
|
+
magnetometer: [],
|
|
276
|
+
gyroscope: [],
|
|
277
|
+
accelerometer: []
|
|
278
|
+
}
|
|
279
|
+
} : false
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
// Legacy CSP config (backward compatibility)
|
|
129
283
|
csp: {
|
|
130
284
|
enabled: options.csp?.enabled || false,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
'default-src': ["'self'"],
|
|
134
|
-
'script-src': ["'self'", "'unsafe-inline'", 'https://cdn.redoc.ly/redoc/v2.5.1/'],
|
|
135
|
-
'style-src': ["'self'", "'unsafe-inline'", 'https://cdn.redoc.ly/redoc/v2.5.1/', 'https://fonts.googleapis.com'],
|
|
136
|
-
'font-src': ["'self'", 'https://fonts.gstatic.com'],
|
|
137
|
-
'img-src': ["'self'", 'data:', 'https:'],
|
|
138
|
-
'connect-src': ["'self'"]
|
|
139
|
-
},
|
|
140
|
-
reportOnly: options.csp?.reportOnly || false, // If true, uses Content-Security-Policy-Report-Only
|
|
285
|
+
directives: options.csp?.directives || {},
|
|
286
|
+
reportOnly: options.csp?.reportOnly || false,
|
|
141
287
|
reportUri: options.csp?.reportUri || null
|
|
142
288
|
},
|
|
143
289
|
|
|
@@ -145,10 +291,78 @@ export class ApiPlugin extends Plugin {
|
|
|
145
291
|
middlewares: options.middlewares || []
|
|
146
292
|
};
|
|
147
293
|
|
|
294
|
+
this.config.resources = this._normalizeResourcesConfig(options.resources);
|
|
295
|
+
|
|
148
296
|
this.server = null;
|
|
149
297
|
this.usersResource = null;
|
|
150
298
|
}
|
|
151
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Normalize resources config so array/string inputs become object map
|
|
302
|
+
* @private
|
|
303
|
+
* @param {Object|Array<string|Object>} resources
|
|
304
|
+
* @returns {Object<string, Object>}
|
|
305
|
+
*/
|
|
306
|
+
_normalizeResourcesConfig(resources) {
|
|
307
|
+
if (!resources) {
|
|
308
|
+
return {};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const normalized = {};
|
|
312
|
+
const verbose = this.options?.verbose;
|
|
313
|
+
|
|
314
|
+
const addResourceConfig = (name, config = {}) => {
|
|
315
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
316
|
+
if (verbose) {
|
|
317
|
+
console.warn('[API Plugin] Ignoring resource config with invalid name:', name);
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
normalized[name] = { ...config };
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
if (Array.isArray(resources)) {
|
|
326
|
+
for (const entry of resources) {
|
|
327
|
+
if (typeof entry === 'string') {
|
|
328
|
+
addResourceConfig(entry);
|
|
329
|
+
} else if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
|
|
330
|
+
const { name, ...config } = entry;
|
|
331
|
+
addResourceConfig(name, config);
|
|
332
|
+
} else {
|
|
333
|
+
if (verbose) {
|
|
334
|
+
console.warn('[API Plugin] Ignoring invalid resource config entry (expected string or object with name):', entry);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return normalized;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (typeof resources === 'object') {
|
|
342
|
+
for (const [name, config] of Object.entries(resources)) {
|
|
343
|
+
if (config === false) {
|
|
344
|
+
addResourceConfig(name, { enabled: false });
|
|
345
|
+
} else if (config === true || config === undefined || config === null) {
|
|
346
|
+
addResourceConfig(name);
|
|
347
|
+
} else if (typeof config === 'object') {
|
|
348
|
+
addResourceConfig(name, config);
|
|
349
|
+
} else {
|
|
350
|
+
if (verbose) {
|
|
351
|
+
console.warn('[API Plugin] Coercing resource config to empty object for', name);
|
|
352
|
+
}
|
|
353
|
+
addResourceConfig(name);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return normalized;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (verbose) {
|
|
360
|
+
console.warn('[API Plugin] Invalid resources configuration. Expected object or array, received:', typeof resources);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {};
|
|
364
|
+
}
|
|
365
|
+
|
|
152
366
|
/**
|
|
153
367
|
* Validate plugin dependencies
|
|
154
368
|
* @private
|
|
@@ -176,10 +390,8 @@ export class ApiPlugin extends Plugin {
|
|
|
176
390
|
throw err;
|
|
177
391
|
}
|
|
178
392
|
|
|
179
|
-
// Create users resource if authentication
|
|
180
|
-
const authEnabled = this.config.auth.
|
|
181
|
-
this.config.auth.apiKey.enabled ||
|
|
182
|
-
this.config.auth.basic.enabled;
|
|
393
|
+
// Create users resource if authentication drivers are configured
|
|
394
|
+
const authEnabled = this.config.auth.drivers.length > 0;
|
|
183
395
|
|
|
184
396
|
if (authEnabled) {
|
|
185
397
|
await this._createUsersResource();
|
|
@@ -204,11 +416,12 @@ export class ApiPlugin extends Plugin {
|
|
|
204
416
|
attributes: {
|
|
205
417
|
id: 'string|required',
|
|
206
418
|
username: 'string|required|minlength:3',
|
|
207
|
-
email: 'string|
|
|
419
|
+
email: 'string|required|email', // Required to support email-based auth
|
|
208
420
|
password: 'secret|required|minlength:8',
|
|
209
421
|
apiKey: 'string|optional',
|
|
210
422
|
jwtSecret: 'string|optional',
|
|
211
423
|
role: 'string|default:user',
|
|
424
|
+
scopes: 'array|items:string|optional', // Authorization scopes (e.g., ['read:users', 'write:cars'])
|
|
212
425
|
active: 'boolean|default:true',
|
|
213
426
|
createdAt: 'string|optional',
|
|
214
427
|
lastLoginAt: 'string|optional',
|
|
@@ -235,7 +448,6 @@ export class ApiPlugin extends Plugin {
|
|
|
235
448
|
throw err;
|
|
236
449
|
}
|
|
237
450
|
}
|
|
238
|
-
|
|
239
451
|
/**
|
|
240
452
|
* Setup middlewares
|
|
241
453
|
* @private
|
|
@@ -245,19 +457,26 @@ export class ApiPlugin extends Plugin {
|
|
|
245
457
|
|
|
246
458
|
// Add request ID middleware
|
|
247
459
|
middlewares.push(async (c, next) => {
|
|
248
|
-
c.set('requestId',
|
|
460
|
+
c.set('requestId', idGenerator());
|
|
249
461
|
c.set('verbose', this.config.verbose);
|
|
250
462
|
await next();
|
|
251
463
|
});
|
|
252
464
|
|
|
465
|
+
// Add security headers middleware (FIRST - most critical)
|
|
466
|
+
if (this.config.security.enabled) {
|
|
467
|
+
const securityMiddleware = await this._createSecurityMiddleware();
|
|
468
|
+
middlewares.push(securityMiddleware);
|
|
469
|
+
}
|
|
470
|
+
|
|
253
471
|
// Add CORS middleware
|
|
254
472
|
if (this.config.cors.enabled) {
|
|
255
473
|
const corsMiddleware = await this._createCorsMiddleware();
|
|
256
474
|
middlewares.push(corsMiddleware);
|
|
257
475
|
}
|
|
258
476
|
|
|
259
|
-
// Add CSP middleware
|
|
260
|
-
|
|
477
|
+
// Add legacy CSP middleware (deprecated - use security.contentSecurityPolicy instead)
|
|
478
|
+
// This is kept for backward compatibility with old configs
|
|
479
|
+
if (this.config.csp.enabled && !this.config.security.contentSecurityPolicy) {
|
|
261
480
|
const cspMiddleware = await this._createCSPMiddleware();
|
|
262
481
|
middlewares.push(cspMiddleware);
|
|
263
482
|
}
|
|
@@ -288,8 +507,11 @@ export class ApiPlugin extends Plugin {
|
|
|
288
507
|
}
|
|
289
508
|
|
|
290
509
|
/**
|
|
291
|
-
* Create CORS middleware
|
|
510
|
+
* Create CORS middleware
|
|
292
511
|
* @private
|
|
512
|
+
*
|
|
513
|
+
* Handles Cross-Origin Resource Sharing (CORS) headers and preflight requests.
|
|
514
|
+
* Supports wildcard origins, credential-based requests, and OPTIONS preflight.
|
|
293
515
|
*/
|
|
294
516
|
async _createCorsMiddleware() {
|
|
295
517
|
return async (c, next) => {
|
|
@@ -353,8 +575,12 @@ export class ApiPlugin extends Plugin {
|
|
|
353
575
|
}
|
|
354
576
|
|
|
355
577
|
/**
|
|
356
|
-
* Create rate limiting middleware
|
|
578
|
+
* Create rate limiting middleware
|
|
357
579
|
* @private
|
|
580
|
+
*
|
|
581
|
+
* Implements sliding window rate limiting with configurable window size and max requests.
|
|
582
|
+
* Returns 429 status code with Retry-After header when limit is exceeded.
|
|
583
|
+
* Uses IP address or custom key generator to track request counts.
|
|
358
584
|
*/
|
|
359
585
|
async _createRateLimitMiddleware() {
|
|
360
586
|
const requests = new Map();
|
|
@@ -410,10 +636,23 @@ export class ApiPlugin extends Plugin {
|
|
|
410
636
|
}
|
|
411
637
|
|
|
412
638
|
/**
|
|
413
|
-
* Create logging middleware
|
|
639
|
+
* Create logging middleware with customizable format
|
|
414
640
|
* @private
|
|
641
|
+
*
|
|
642
|
+
* Supported tokens:
|
|
643
|
+
* - :method - HTTP method (GET, POST, etc)
|
|
644
|
+
* - :path - Request path
|
|
645
|
+
* - :status - HTTP status code
|
|
646
|
+
* - :response-time - Response time in milliseconds
|
|
647
|
+
* - :user - Username or 'anonymous'
|
|
648
|
+
* - :requestId - Request ID (UUID)
|
|
649
|
+
*
|
|
650
|
+
* Example format: ':method :path :status :response-time ms - :user'
|
|
651
|
+
* Output: 'GET /api/v1/cars 200 45ms - john'
|
|
415
652
|
*/
|
|
416
653
|
async _createLoggingMiddleware() {
|
|
654
|
+
const { format } = this.config.logging;
|
|
655
|
+
|
|
417
656
|
return async (c, next) => {
|
|
418
657
|
const start = Date.now();
|
|
419
658
|
const method = c.req.method;
|
|
@@ -424,32 +663,236 @@ export class ApiPlugin extends Plugin {
|
|
|
424
663
|
|
|
425
664
|
const duration = Date.now() - start;
|
|
426
665
|
const status = c.res.status;
|
|
427
|
-
const user = c.get('user')?.username || 'anonymous';
|
|
428
|
-
|
|
429
|
-
|
|
666
|
+
const user = c.get('user')?.username || c.get('user')?.email || 'anonymous';
|
|
667
|
+
|
|
668
|
+
// Parse format string with token replacement
|
|
669
|
+
let logMessage = format
|
|
670
|
+
.replace(':method', method)
|
|
671
|
+
.replace(':path', path)
|
|
672
|
+
.replace(':status', status)
|
|
673
|
+
.replace(':response-time', duration)
|
|
674
|
+
.replace(':user', user)
|
|
675
|
+
.replace(':requestId', requestId);
|
|
676
|
+
|
|
677
|
+
console.log(`[API Plugin] ${logMessage}`);
|
|
430
678
|
};
|
|
431
679
|
}
|
|
432
680
|
|
|
433
681
|
/**
|
|
434
|
-
* Create compression middleware (
|
|
682
|
+
* Create compression middleware (using Node.js zlib)
|
|
435
683
|
* @private
|
|
436
684
|
*/
|
|
437
685
|
async _createCompressionMiddleware() {
|
|
686
|
+
const { gzip, brotliCompress } = await import('zlib');
|
|
687
|
+
const { promisify } = await import('util');
|
|
688
|
+
|
|
689
|
+
const gzipAsync = promisify(gzip);
|
|
690
|
+
const brotliAsync = promisify(brotliCompress);
|
|
691
|
+
|
|
692
|
+
const { threshold, level } = this.config.compression;
|
|
693
|
+
|
|
694
|
+
// Content types that should NOT be compressed (already compressed)
|
|
695
|
+
const skipContentTypes = [
|
|
696
|
+
'image/', 'video/', 'audio/',
|
|
697
|
+
'application/zip', 'application/gzip',
|
|
698
|
+
'application/x-gzip', 'application/x-bzip2'
|
|
699
|
+
];
|
|
700
|
+
|
|
438
701
|
return async (c, next) => {
|
|
439
702
|
await next();
|
|
440
703
|
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
|
|
704
|
+
// Skip if response has no body
|
|
705
|
+
if (!c.res || !c.res.body) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Skip if already compressed
|
|
710
|
+
if (c.res.headers.has('content-encoding')) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Skip if content-type should not be compressed
|
|
715
|
+
const contentType = c.res.headers.get('content-type') || '';
|
|
716
|
+
if (skipContentTypes.some(type => contentType.startsWith(type))) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Check Accept-Encoding header
|
|
721
|
+
const acceptEncoding = c.req.header('accept-encoding') || '';
|
|
722
|
+
const supportsBrotli = acceptEncoding.includes('br');
|
|
723
|
+
const supportsGzip = acceptEncoding.includes('gzip');
|
|
724
|
+
|
|
725
|
+
if (!supportsBrotli && !supportsGzip) {
|
|
726
|
+
return; // Client doesn't support compression
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Get response body as buffer
|
|
730
|
+
let body;
|
|
731
|
+
try {
|
|
732
|
+
const text = await c.res.text();
|
|
733
|
+
body = Buffer.from(text, 'utf-8');
|
|
734
|
+
} catch (err) {
|
|
735
|
+
// If body is already consumed or not text, skip compression
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Skip if body is too small
|
|
740
|
+
if (body.length < threshold) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Compress with brotli (better) or gzip (fallback)
|
|
745
|
+
let compressed;
|
|
746
|
+
let encoding;
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
if (supportsBrotli) {
|
|
750
|
+
compressed = await brotliAsync(body);
|
|
751
|
+
encoding = 'br';
|
|
752
|
+
} else {
|
|
753
|
+
compressed = await gzipAsync(body, { level });
|
|
754
|
+
encoding = 'gzip';
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Only use compressed if it's actually smaller
|
|
758
|
+
if (compressed.length >= body.length) {
|
|
759
|
+
return; // Compression didn't help, use original
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Create new response with compressed body
|
|
763
|
+
const headers = new Headers(c.res.headers);
|
|
764
|
+
headers.set('Content-Encoding', encoding);
|
|
765
|
+
headers.set('Content-Length', compressed.length.toString());
|
|
766
|
+
headers.set('Vary', 'Accept-Encoding');
|
|
767
|
+
|
|
768
|
+
// Replace response
|
|
769
|
+
c.res = new Response(compressed, {
|
|
770
|
+
status: c.res.status,
|
|
771
|
+
statusText: c.res.statusText,
|
|
772
|
+
headers
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
} catch (err) {
|
|
776
|
+
// Compression failed, log and continue with uncompressed response
|
|
777
|
+
if (this.config.verbose) {
|
|
778
|
+
console.error('[API Plugin] Compression error:', err.message);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Create security headers middleware (Helmet-like)
|
|
786
|
+
* @private
|
|
787
|
+
*/
|
|
788
|
+
async _createSecurityMiddleware() {
|
|
789
|
+
const { security } = this.config;
|
|
790
|
+
|
|
791
|
+
return async (c, next) => {
|
|
792
|
+
// X-Content-Type-Options: nosniff (MIME sniffing protection)
|
|
793
|
+
if (security.noSniff) {
|
|
794
|
+
c.header('X-Content-Type-Options', 'nosniff');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// X-Frame-Options (clickjacking protection)
|
|
798
|
+
if (security.frameguard) {
|
|
799
|
+
const action = security.frameguard.action.toUpperCase();
|
|
800
|
+
if (action === 'DENY') {
|
|
801
|
+
c.header('X-Frame-Options', 'DENY');
|
|
802
|
+
} else if (action === 'SAMEORIGIN') {
|
|
803
|
+
c.header('X-Frame-Options', 'SAMEORIGIN');
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Strict-Transport-Security (HSTS - force HTTPS)
|
|
808
|
+
if (security.hsts) {
|
|
809
|
+
const parts = [`max-age=${security.hsts.maxAge}`];
|
|
810
|
+
if (security.hsts.includeSubDomains) {
|
|
811
|
+
parts.push('includeSubDomains');
|
|
812
|
+
}
|
|
813
|
+
if (security.hsts.preload) {
|
|
814
|
+
parts.push('preload');
|
|
815
|
+
}
|
|
816
|
+
c.header('Strict-Transport-Security', parts.join('; '));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Referrer-Policy (privacy)
|
|
820
|
+
if (security.referrerPolicy) {
|
|
821
|
+
c.header('Referrer-Policy', security.referrerPolicy.policy);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// X-DNS-Prefetch-Control (DNS leak protection)
|
|
825
|
+
if (security.dnsPrefetchControl) {
|
|
826
|
+
const value = security.dnsPrefetchControl.allow ? 'on' : 'off';
|
|
827
|
+
c.header('X-DNS-Prefetch-Control', value);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// X-Download-Options (IE8+ download security)
|
|
831
|
+
if (security.ieNoOpen) {
|
|
832
|
+
c.header('X-Download-Options', 'noopen');
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// X-Permitted-Cross-Domain-Policies (Flash/PDF security)
|
|
836
|
+
if (security.permittedCrossDomainPolicies) {
|
|
837
|
+
c.header('X-Permitted-Cross-Domain-Policies', security.permittedCrossDomainPolicies.policy);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// X-XSS-Protection (legacy XSS filter)
|
|
841
|
+
if (security.xssFilter) {
|
|
842
|
+
const mode = security.xssFilter.mode;
|
|
843
|
+
c.header('X-XSS-Protection', mode === 'block' ? '1; mode=block' : '0');
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Permissions-Policy (modern feature policy)
|
|
847
|
+
if (security.permissionsPolicy && security.permissionsPolicy.features) {
|
|
848
|
+
const features = security.permissionsPolicy.features;
|
|
849
|
+
const policies = [];
|
|
850
|
+
|
|
851
|
+
for (const [feature, allowList] of Object.entries(features)) {
|
|
852
|
+
if (Array.isArray(allowList)) {
|
|
853
|
+
const value = allowList.length === 0
|
|
854
|
+
? `${feature}=()`
|
|
855
|
+
: `${feature}=(${allowList.join(' ')})`;
|
|
856
|
+
policies.push(value);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (policies.length > 0) {
|
|
861
|
+
c.header('Permissions-Policy', policies.join(', '));
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Content-Security-Policy (CSP)
|
|
866
|
+
// Note: This is also handled by _createCSPMiddleware for backward compatibility
|
|
867
|
+
// We check if legacy csp.enabled is true, otherwise use security.contentSecurityPolicy
|
|
868
|
+
const cspConfig = this.config.csp.enabled
|
|
869
|
+
? this.config.csp
|
|
870
|
+
: security.contentSecurityPolicy;
|
|
871
|
+
|
|
872
|
+
if (cspConfig && cspConfig.enabled !== false && cspConfig.directives) {
|
|
873
|
+
const cspParts = [];
|
|
874
|
+
for (const [directive, values] of Object.entries(cspConfig.directives)) {
|
|
875
|
+
if (Array.isArray(values) && values.length > 0) {
|
|
876
|
+
cspParts.push(`${directive} ${values.join(' ')}`);
|
|
877
|
+
} else if (typeof values === 'string') {
|
|
878
|
+
cspParts.push(`${directive} ${values}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (cspConfig.reportUri) {
|
|
883
|
+
cspParts.push(`report-uri ${cspConfig.reportUri}`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (cspParts.length > 0) {
|
|
887
|
+
const cspValue = cspParts.join('; ');
|
|
888
|
+
const headerName = cspConfig.reportOnly
|
|
889
|
+
? 'Content-Security-Policy-Report-Only'
|
|
890
|
+
: 'Content-Security-Policy';
|
|
891
|
+
c.header(headerName, cspValue);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
await next();
|
|
453
896
|
};
|
|
454
897
|
}
|
|
455
898
|
|
|
@@ -466,7 +909,10 @@ export class ApiPlugin extends Plugin {
|
|
|
466
909
|
port: this.config.port,
|
|
467
910
|
host: this.config.host,
|
|
468
911
|
database: this.database,
|
|
912
|
+
versionPrefix: this.config.versionPrefix,
|
|
469
913
|
resources: this.config.resources,
|
|
914
|
+
routes: this.config.routes,
|
|
915
|
+
templates: this.config.templates,
|
|
470
916
|
middlewares: this.compiledMiddlewares,
|
|
471
917
|
verbose: this.config.verbose,
|
|
472
918
|
auth: this.config.auth,
|
|
@@ -542,3 +988,10 @@ export class ApiPlugin extends Plugin {
|
|
|
542
988
|
return this.server ? this.server.getApp() : null;
|
|
543
989
|
}
|
|
544
990
|
}
|
|
991
|
+
|
|
992
|
+
// Export auth utilities (OIDCClient, guards helpers, etc.)
|
|
993
|
+
export { OIDCClient } from './auth/oidc-client.js';
|
|
994
|
+
export * from './concerns/guards-helpers.js';
|
|
995
|
+
|
|
996
|
+
// Export template engine utilities
|
|
997
|
+
export { setupTemplateEngine, ejsEngine, jsxEngine } from './utils/template-engine.js';
|