s3db.js 13.5.1 → 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} +30323 -24958
- package/dist/s3db.cjs.map +1 -0
- package/dist/s3db.es.js +24026 -18654
- package/dist/s3db.es.js.map +1 -1
- package/package.json +216 -20
- 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 +4 -0
- package/src/plugins/api/auth/basic-auth.js +23 -1
- 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 +503 -54
- 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 +23 -3
- package/src/plugins/api/routes/resource-routes.js +71 -29
- package/src/plugins/api/server.js +1017 -94
- 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 +44 -11
- 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 +61 -1
- package/src/plugins/eventual-consistency/analytics.js +1 -0
- 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 +32 -7
- 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 +124 -32
- 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/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,6 +130,8 @@ 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,
|
|
@@ -68,27 +150,22 @@ export class ApiPlugin extends Plugin {
|
|
|
68
150
|
description: options.docs?.description || options.apiDescription || 'Auto-generated REST API for s3db.js resources'
|
|
69
151
|
},
|
|
70
152
|
|
|
71
|
-
// Authentication configuration (
|
|
72
|
-
auth:
|
|
73
|
-
driver: options.auth.driver || null, // 'jwt' or 'basic'
|
|
74
|
-
resource: options.auth.resource || 'users', // Resource that manages auth
|
|
75
|
-
usernameField: options.auth.usernameField || 'email', // Default: email
|
|
76
|
-
passwordField: options.auth.passwordField || 'password', // Default: password
|
|
77
|
-
config: options.auth.config || {} // Driver-specific config
|
|
78
|
-
} : {
|
|
79
|
-
driver: null,
|
|
80
|
-
resource: 'users',
|
|
81
|
-
usernameField: 'email',
|
|
82
|
-
passwordField: 'password',
|
|
83
|
-
config: {}
|
|
84
|
-
},
|
|
85
|
-
|
|
86
|
-
// Resource configuration
|
|
87
|
-
resources: options.resources || {},
|
|
153
|
+
// Authentication configuration (multiple drivers)
|
|
154
|
+
auth: normalizedAuth,
|
|
88
155
|
|
|
89
156
|
// Custom routes (plugin-level)
|
|
90
157
|
routes: options.routes || {},
|
|
91
158
|
|
|
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
|
+
},
|
|
168
|
+
|
|
92
169
|
// CORS configuration
|
|
93
170
|
cors: {
|
|
94
171
|
enabled: options.cors?.enabled || false,
|
|
@@ -130,19 +207,83 @@ export class ApiPlugin extends Plugin {
|
|
|
130
207
|
returnValidationErrors: options.validation?.returnValidationErrors !== false
|
|
131
208
|
},
|
|
132
209
|
|
|
133
|
-
//
|
|
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)
|
|
134
283
|
csp: {
|
|
135
284
|
enabled: options.csp?.enabled || false,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
'default-src': ["'self'"],
|
|
139
|
-
'script-src': ["'self'", "'unsafe-inline'", 'https://cdn.redoc.ly/redoc/v2.5.1/'],
|
|
140
|
-
'style-src': ["'self'", "'unsafe-inline'", 'https://cdn.redoc.ly/redoc/v2.5.1/', 'https://fonts.googleapis.com'],
|
|
141
|
-
'font-src': ["'self'", 'https://fonts.gstatic.com'],
|
|
142
|
-
'img-src': ["'self'", 'data:', 'https:'],
|
|
143
|
-
'connect-src': ["'self'"]
|
|
144
|
-
},
|
|
145
|
-
reportOnly: options.csp?.reportOnly || false, // If true, uses Content-Security-Policy-Report-Only
|
|
285
|
+
directives: options.csp?.directives || {},
|
|
286
|
+
reportOnly: options.csp?.reportOnly || false,
|
|
146
287
|
reportUri: options.csp?.reportUri || null
|
|
147
288
|
},
|
|
148
289
|
|
|
@@ -150,10 +291,78 @@ export class ApiPlugin extends Plugin {
|
|
|
150
291
|
middlewares: options.middlewares || []
|
|
151
292
|
};
|
|
152
293
|
|
|
294
|
+
this.config.resources = this._normalizeResourcesConfig(options.resources);
|
|
295
|
+
|
|
153
296
|
this.server = null;
|
|
154
297
|
this.usersResource = null;
|
|
155
298
|
}
|
|
156
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
|
+
|
|
157
366
|
/**
|
|
158
367
|
* Validate plugin dependencies
|
|
159
368
|
* @private
|
|
@@ -181,8 +390,8 @@ export class ApiPlugin extends Plugin {
|
|
|
181
390
|
throw err;
|
|
182
391
|
}
|
|
183
392
|
|
|
184
|
-
// Create users resource if authentication
|
|
185
|
-
const authEnabled = this.config.auth.
|
|
393
|
+
// Create users resource if authentication drivers are configured
|
|
394
|
+
const authEnabled = this.config.auth.drivers.length > 0;
|
|
186
395
|
|
|
187
396
|
if (authEnabled) {
|
|
188
397
|
await this._createUsersResource();
|
|
@@ -207,11 +416,12 @@ export class ApiPlugin extends Plugin {
|
|
|
207
416
|
attributes: {
|
|
208
417
|
id: 'string|required',
|
|
209
418
|
username: 'string|required|minlength:3',
|
|
210
|
-
email: 'string|
|
|
419
|
+
email: 'string|required|email', // Required to support email-based auth
|
|
211
420
|
password: 'secret|required|minlength:8',
|
|
212
421
|
apiKey: 'string|optional',
|
|
213
422
|
jwtSecret: 'string|optional',
|
|
214
423
|
role: 'string|default:user',
|
|
424
|
+
scopes: 'array|items:string|optional', // Authorization scopes (e.g., ['read:users', 'write:cars'])
|
|
215
425
|
active: 'boolean|default:true',
|
|
216
426
|
createdAt: 'string|optional',
|
|
217
427
|
lastLoginAt: 'string|optional',
|
|
@@ -238,7 +448,6 @@ export class ApiPlugin extends Plugin {
|
|
|
238
448
|
throw err;
|
|
239
449
|
}
|
|
240
450
|
}
|
|
241
|
-
|
|
242
451
|
/**
|
|
243
452
|
* Setup middlewares
|
|
244
453
|
* @private
|
|
@@ -248,19 +457,26 @@ export class ApiPlugin extends Plugin {
|
|
|
248
457
|
|
|
249
458
|
// Add request ID middleware
|
|
250
459
|
middlewares.push(async (c, next) => {
|
|
251
|
-
c.set('requestId',
|
|
460
|
+
c.set('requestId', idGenerator());
|
|
252
461
|
c.set('verbose', this.config.verbose);
|
|
253
462
|
await next();
|
|
254
463
|
});
|
|
255
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
|
+
|
|
256
471
|
// Add CORS middleware
|
|
257
472
|
if (this.config.cors.enabled) {
|
|
258
473
|
const corsMiddleware = await this._createCorsMiddleware();
|
|
259
474
|
middlewares.push(corsMiddleware);
|
|
260
475
|
}
|
|
261
476
|
|
|
262
|
-
// Add CSP middleware
|
|
263
|
-
|
|
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) {
|
|
264
480
|
const cspMiddleware = await this._createCSPMiddleware();
|
|
265
481
|
middlewares.push(cspMiddleware);
|
|
266
482
|
}
|
|
@@ -291,8 +507,11 @@ export class ApiPlugin extends Plugin {
|
|
|
291
507
|
}
|
|
292
508
|
|
|
293
509
|
/**
|
|
294
|
-
* Create CORS middleware
|
|
510
|
+
* Create CORS middleware
|
|
295
511
|
* @private
|
|
512
|
+
*
|
|
513
|
+
* Handles Cross-Origin Resource Sharing (CORS) headers and preflight requests.
|
|
514
|
+
* Supports wildcard origins, credential-based requests, and OPTIONS preflight.
|
|
296
515
|
*/
|
|
297
516
|
async _createCorsMiddleware() {
|
|
298
517
|
return async (c, next) => {
|
|
@@ -356,8 +575,12 @@ export class ApiPlugin extends Plugin {
|
|
|
356
575
|
}
|
|
357
576
|
|
|
358
577
|
/**
|
|
359
|
-
* Create rate limiting middleware
|
|
578
|
+
* Create rate limiting middleware
|
|
360
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.
|
|
361
584
|
*/
|
|
362
585
|
async _createRateLimitMiddleware() {
|
|
363
586
|
const requests = new Map();
|
|
@@ -413,10 +636,23 @@ export class ApiPlugin extends Plugin {
|
|
|
413
636
|
}
|
|
414
637
|
|
|
415
638
|
/**
|
|
416
|
-
* Create logging middleware
|
|
639
|
+
* Create logging middleware with customizable format
|
|
417
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'
|
|
418
652
|
*/
|
|
419
653
|
async _createLoggingMiddleware() {
|
|
654
|
+
const { format } = this.config.logging;
|
|
655
|
+
|
|
420
656
|
return async (c, next) => {
|
|
421
657
|
const start = Date.now();
|
|
422
658
|
const method = c.req.method;
|
|
@@ -427,32 +663,236 @@ export class ApiPlugin extends Plugin {
|
|
|
427
663
|
|
|
428
664
|
const duration = Date.now() - start;
|
|
429
665
|
const status = c.res.status;
|
|
430
|
-
const user = c.get('user')?.username || 'anonymous';
|
|
431
|
-
|
|
432
|
-
|
|
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}`);
|
|
433
678
|
};
|
|
434
679
|
}
|
|
435
680
|
|
|
436
681
|
/**
|
|
437
|
-
* Create compression middleware (
|
|
682
|
+
* Create compression middleware (using Node.js zlib)
|
|
438
683
|
* @private
|
|
439
684
|
*/
|
|
440
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
|
+
|
|
441
701
|
return async (c, next) => {
|
|
442
702
|
await next();
|
|
443
703
|
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
|
|
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();
|
|
456
896
|
};
|
|
457
897
|
}
|
|
458
898
|
|
|
@@ -469,8 +909,10 @@ export class ApiPlugin extends Plugin {
|
|
|
469
909
|
port: this.config.port,
|
|
470
910
|
host: this.config.host,
|
|
471
911
|
database: this.database,
|
|
912
|
+
versionPrefix: this.config.versionPrefix,
|
|
472
913
|
resources: this.config.resources,
|
|
473
914
|
routes: this.config.routes,
|
|
915
|
+
templates: this.config.templates,
|
|
474
916
|
middlewares: this.compiledMiddlewares,
|
|
475
917
|
verbose: this.config.verbose,
|
|
476
918
|
auth: this.config.auth,
|
|
@@ -546,3 +988,10 @@ export class ApiPlugin extends Plugin {
|
|
|
546
988
|
return this.server ? this.server.getApp() : null;
|
|
547
989
|
}
|
|
548
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';
|