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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response Formatter - Standard JSON API responses
|
|
3
|
+
*
|
|
4
|
+
* Provides consistent response formatting across all API endpoints
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format successful response
|
|
9
|
+
* @param {Object} data - Response data
|
|
10
|
+
* @param {Object} options - Response options
|
|
11
|
+
* @param {number} options.status - HTTP status code (default: 200)
|
|
12
|
+
* @param {Object} options.meta - Additional metadata
|
|
13
|
+
* @returns {Object} Formatted response
|
|
14
|
+
*/
|
|
15
|
+
export function success(data, options = {}) {
|
|
16
|
+
const { status = 200, meta = {} } = options;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
success: true,
|
|
20
|
+
data,
|
|
21
|
+
meta: {
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
...meta
|
|
24
|
+
},
|
|
25
|
+
_status: status
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format error response
|
|
31
|
+
* @param {string|Error} error - Error message or Error object
|
|
32
|
+
* @param {Object} options - Error options
|
|
33
|
+
* @param {number} options.status - HTTP status code (default: 500)
|
|
34
|
+
* @param {string} options.code - Error code
|
|
35
|
+
* @param {Object} options.details - Additional error details
|
|
36
|
+
* @returns {Object} Formatted error response
|
|
37
|
+
*/
|
|
38
|
+
export function error(error, options = {}) {
|
|
39
|
+
const { status = 500, code = 'INTERNAL_ERROR', details = {} } = options;
|
|
40
|
+
|
|
41
|
+
const errorMessage = error instanceof Error ? error.message : error;
|
|
42
|
+
const errorStack = error instanceof Error && process.env.NODE_ENV !== 'production'
|
|
43
|
+
? error.stack
|
|
44
|
+
: undefined;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
success: false,
|
|
48
|
+
error: {
|
|
49
|
+
message: errorMessage,
|
|
50
|
+
code,
|
|
51
|
+
details,
|
|
52
|
+
stack: errorStack
|
|
53
|
+
},
|
|
54
|
+
meta: {
|
|
55
|
+
timestamp: new Date().toISOString()
|
|
56
|
+
},
|
|
57
|
+
_status: status
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format list response with pagination
|
|
63
|
+
* @param {Array} items - List items
|
|
64
|
+
* @param {Object} pagination - Pagination info
|
|
65
|
+
* @param {number} pagination.total - Total count
|
|
66
|
+
* @param {number} pagination.page - Current page
|
|
67
|
+
* @param {number} pagination.pageSize - Items per page
|
|
68
|
+
* @param {number} pagination.pageCount - Total pages
|
|
69
|
+
* @returns {Object} Formatted list response
|
|
70
|
+
*/
|
|
71
|
+
export function list(items, pagination = {}) {
|
|
72
|
+
const { total, page, pageSize, pageCount } = pagination;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
success: true,
|
|
76
|
+
data: items,
|
|
77
|
+
pagination: {
|
|
78
|
+
total: total || items.length,
|
|
79
|
+
page: page || 1,
|
|
80
|
+
pageSize: pageSize || items.length,
|
|
81
|
+
pageCount: pageCount || 1
|
|
82
|
+
},
|
|
83
|
+
meta: {
|
|
84
|
+
timestamp: new Date().toISOString()
|
|
85
|
+
},
|
|
86
|
+
_status: 200
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Format created response
|
|
92
|
+
* @param {Object} data - Created resource data
|
|
93
|
+
* @param {string} location - Resource location URL
|
|
94
|
+
* @returns {Object} Formatted created response
|
|
95
|
+
*/
|
|
96
|
+
export function created(data, location) {
|
|
97
|
+
return {
|
|
98
|
+
success: true,
|
|
99
|
+
data,
|
|
100
|
+
meta: {
|
|
101
|
+
timestamp: new Date().toISOString(),
|
|
102
|
+
location
|
|
103
|
+
},
|
|
104
|
+
_status: 201
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Format no content response
|
|
110
|
+
* @returns {Object} Formatted no content response
|
|
111
|
+
*/
|
|
112
|
+
export function noContent() {
|
|
113
|
+
return {
|
|
114
|
+
success: true,
|
|
115
|
+
data: null,
|
|
116
|
+
meta: {
|
|
117
|
+
timestamp: new Date().toISOString()
|
|
118
|
+
},
|
|
119
|
+
_status: 204
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Format validation error response
|
|
125
|
+
* @param {Array} errors - Validation errors
|
|
126
|
+
* @returns {Object} Formatted validation error response
|
|
127
|
+
*/
|
|
128
|
+
export function validationError(errors) {
|
|
129
|
+
return error('Validation failed', {
|
|
130
|
+
status: 400,
|
|
131
|
+
code: 'VALIDATION_ERROR',
|
|
132
|
+
details: { errors }
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Format not found response
|
|
138
|
+
* @param {string} resource - Resource name
|
|
139
|
+
* @param {string} id - Resource ID
|
|
140
|
+
* @returns {Object} Formatted not found response
|
|
141
|
+
*/
|
|
142
|
+
export function notFound(resource, id) {
|
|
143
|
+
return error(`${resource} with id '${id}' not found`, {
|
|
144
|
+
status: 404,
|
|
145
|
+
code: 'NOT_FOUND',
|
|
146
|
+
details: { resource, id }
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Format unauthorized response
|
|
152
|
+
* @param {string} message - Unauthorized message
|
|
153
|
+
* @returns {Object} Formatted unauthorized response
|
|
154
|
+
*/
|
|
155
|
+
export function unauthorized(message = 'Unauthorized') {
|
|
156
|
+
return error(message, {
|
|
157
|
+
status: 401,
|
|
158
|
+
code: 'UNAUTHORIZED'
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Format forbidden response
|
|
164
|
+
* @param {string} message - Forbidden message
|
|
165
|
+
* @returns {Object} Formatted forbidden response
|
|
166
|
+
*/
|
|
167
|
+
export function forbidden(message = 'Forbidden') {
|
|
168
|
+
return error(message, {
|
|
169
|
+
status: 403,
|
|
170
|
+
code: 'FORBIDDEN'
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Format rate limit exceeded response
|
|
176
|
+
* @param {number} retryAfter - Retry after seconds
|
|
177
|
+
* @returns {Object} Formatted rate limit response
|
|
178
|
+
*/
|
|
179
|
+
export function rateLimitExceeded(retryAfter) {
|
|
180
|
+
return error('Rate limit exceeded', {
|
|
181
|
+
status: 429,
|
|
182
|
+
code: 'RATE_LIMIT_EXCEEDED',
|
|
183
|
+
details: { retryAfter }
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Format payload too large response
|
|
189
|
+
* @param {number} size - Received payload size in bytes
|
|
190
|
+
* @param {number} limit - Maximum allowed size in bytes
|
|
191
|
+
* @returns {Object} Formatted payload too large response
|
|
192
|
+
*/
|
|
193
|
+
export function payloadTooLarge(size, limit) {
|
|
194
|
+
return error('Request payload too large', {
|
|
195
|
+
status: 413,
|
|
196
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
197
|
+
details: {
|
|
198
|
+
receivedSize: size,
|
|
199
|
+
maxSize: limit,
|
|
200
|
+
receivedMB: (size / 1024 / 1024).toFixed(2),
|
|
201
|
+
maxMB: (limit / 1024 / 1024).toFixed(2)
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Create custom formatters with override support
|
|
208
|
+
*
|
|
209
|
+
* Allows customization of response formats while maintaining fallbacks.
|
|
210
|
+
* Useful for adapting to existing API contracts or organizational standards.
|
|
211
|
+
*
|
|
212
|
+
* @param {Object} customFormatters - Custom formatter functions
|
|
213
|
+
* @param {Function} customFormatters.success - Custom success formatter
|
|
214
|
+
* @param {Function} customFormatters.error - Custom error formatter
|
|
215
|
+
* @param {Function} customFormatters.list - Custom list formatter
|
|
216
|
+
* @param {Function} customFormatters.created - Custom created formatter
|
|
217
|
+
* @returns {Object} Formatters object with custom overrides
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* const formatters = createCustomFormatters({
|
|
221
|
+
* success: (data, meta) => ({ ok: true, result: data, ...meta }),
|
|
222
|
+
* error: (err, status) => ({ ok: false, message: err.message, code: status })
|
|
223
|
+
* });
|
|
224
|
+
*
|
|
225
|
+
* // Use in API routes:
|
|
226
|
+
* return c.json(formatters.success(user));
|
|
227
|
+
*/
|
|
228
|
+
export function createCustomFormatters(customFormatters = {}) {
|
|
229
|
+
// Default formatters
|
|
230
|
+
const defaults = {
|
|
231
|
+
success: (data, meta = {}) => success(data, { meta }),
|
|
232
|
+
error: (err, status, code) => error(err, { status, code }),
|
|
233
|
+
list: (items, pagination) => list(items, pagination),
|
|
234
|
+
created: (data, location) => created(data, location),
|
|
235
|
+
noContent: () => noContent(),
|
|
236
|
+
validationError: (errors) => validationError(errors),
|
|
237
|
+
notFound: (resource, id) => notFound(resource, id),
|
|
238
|
+
unauthorized: (message) => unauthorized(message),
|
|
239
|
+
forbidden: (message) => forbidden(message),
|
|
240
|
+
rateLimitExceeded: (retryAfter) => rateLimitExceeded(retryAfter),
|
|
241
|
+
payloadTooLarge: (size, limit) => payloadTooLarge(size, limit)
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// Merge custom formatters with defaults
|
|
245
|
+
return {
|
|
246
|
+
...defaults,
|
|
247
|
+
...customFormatters
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export default {
|
|
252
|
+
success,
|
|
253
|
+
error,
|
|
254
|
+
list,
|
|
255
|
+
created,
|
|
256
|
+
noContent,
|
|
257
|
+
validationError,
|
|
258
|
+
notFound,
|
|
259
|
+
unauthorized,
|
|
260
|
+
forbidden,
|
|
261
|
+
rateLimitExceeded,
|
|
262
|
+
payloadTooLarge,
|
|
263
|
+
createCustomFormatters
|
|
264
|
+
};
|
|
@@ -133,10 +133,46 @@ export class StateMachinePlugin extends Plugin {
|
|
|
133
133
|
this.machines = new Map();
|
|
134
134
|
this.triggerIntervals = [];
|
|
135
135
|
this.schedulerPlugin = null;
|
|
136
|
+
this._pendingEventHandlers = new Set();
|
|
136
137
|
|
|
137
138
|
this._validateConfiguration();
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Wait for all pending event handlers to complete
|
|
143
|
+
* Useful when working with async events (asyncEvents: true)
|
|
144
|
+
* @param {number} timeout - Maximum time to wait in milliseconds (default: 5000)
|
|
145
|
+
* @returns {Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
async waitForPendingEvents(timeout = 5000) {
|
|
148
|
+
if (this._pendingEventHandlers.size === 0) {
|
|
149
|
+
return; // No pending events
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const startTime = Date.now();
|
|
153
|
+
|
|
154
|
+
while (this._pendingEventHandlers.size > 0) {
|
|
155
|
+
if (Date.now() - startTime > timeout) {
|
|
156
|
+
throw new StateMachineError(
|
|
157
|
+
`Timeout waiting for ${this._pendingEventHandlers.size} pending event handlers`,
|
|
158
|
+
{
|
|
159
|
+
operation: 'waitForPendingEvents',
|
|
160
|
+
pendingCount: this._pendingEventHandlers.size,
|
|
161
|
+
timeout
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Wait for at least one handler to complete
|
|
167
|
+
if (this._pendingEventHandlers.size > 0) {
|
|
168
|
+
await Promise.race(Array.from(this._pendingEventHandlers));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Small delay before checking again
|
|
172
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
140
176
|
_validateConfiguration() {
|
|
141
177
|
if (!this.config.stateMachines || Object.keys(this.config.stateMachines).length === 0) {
|
|
142
178
|
throw new StateMachineError('At least one state machine must be defined', {
|
|
@@ -1331,10 +1367,29 @@ export class StateMachinePlugin extends Plugin {
|
|
|
1331
1367
|
// Resource events are typically: inserted, updated, deleted
|
|
1332
1368
|
const baseEvent = typeof baseEventName === 'function' ? 'updated' : baseEventName;
|
|
1333
1369
|
|
|
1334
|
-
|
|
1370
|
+
// IMPORTANT: For resources with async events, we need to ensure the event handler
|
|
1371
|
+
// completes before returning control. We wrap the handler to track pending operations.
|
|
1372
|
+
const wrappedHandler = async (...args) => {
|
|
1373
|
+
// Track this as a pending operation
|
|
1374
|
+
const handlerPromise = eventHandler(...args);
|
|
1375
|
+
|
|
1376
|
+
// Store promise if state machine has event tracking
|
|
1377
|
+
if (!this._pendingEventHandlers) {
|
|
1378
|
+
this._pendingEventHandlers = new Set();
|
|
1379
|
+
}
|
|
1380
|
+
this._pendingEventHandlers.add(handlerPromise);
|
|
1381
|
+
|
|
1382
|
+
try {
|
|
1383
|
+
await handlerPromise;
|
|
1384
|
+
} finally {
|
|
1385
|
+
this._pendingEventHandlers.delete(handlerPromise);
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
eventSource.on(baseEvent, wrappedHandler);
|
|
1335
1390
|
|
|
1336
1391
|
if (this.config.verbose) {
|
|
1337
|
-
console.log(`[StateMachinePlugin] Listening to resource event '${baseEvent}' from '${eventSource.name}' for trigger '${triggerName}'`);
|
|
1392
|
+
console.log(`[StateMachinePlugin] Listening to resource event '${baseEvent}' from '${eventSource.name}' for trigger '${triggerName}' (async-safe)`);
|
|
1338
1393
|
}
|
|
1339
1394
|
} else {
|
|
1340
1395
|
// Original behavior: listen to database or plugin events
|
package/src/resource.class.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
3
|
import AsyncEventEmitter from "./concerns/async-event-emitter.js";
|
|
4
|
-
import { customAlphabet, urlAlphabet } from 'nanoid';
|
|
5
4
|
import jsonStableStringify from "json-stable-stringify";
|
|
6
5
|
import { PromisePool } from "@supercharge/promise-pool";
|
|
7
6
|
import { chunk, cloneDeep, merge, isEmpty, isObject } from "lodash-es";
|
|
@@ -12,7 +11,7 @@ import { streamToString } from "./stream/index.js";
|
|
|
12
11
|
import tryFn, { tryFnSync } from "./concerns/try-fn.js";
|
|
13
12
|
import { ResourceReader, ResourceWriter } from "./stream/index.js"
|
|
14
13
|
import { getBehavior, DEFAULT_BEHAVIOR } from "./behaviors/index.js";
|
|
15
|
-
import { idGenerator as defaultIdGenerator } from "./concerns/id.js";
|
|
14
|
+
import { idGenerator as defaultIdGenerator, createCustomGenerator, getUrlAlphabet } from "./concerns/id.js";
|
|
16
15
|
import { calculateTotalSize, calculateEffectiveLimit } from "./concerns/calculator.js";
|
|
17
16
|
import { mapAwsError, InvalidResourceItem, ResourceError, PartitionError, ValidationError } from "./errors.js";
|
|
18
17
|
|
|
@@ -26,7 +25,8 @@ export class Resource extends AsyncEventEmitter {
|
|
|
26
25
|
* @param {string} [config.version='v1'] - Resource version
|
|
27
26
|
* @param {Object} [config.attributes={}] - Resource attributes schema
|
|
28
27
|
* @param {string} [config.behavior='user-managed'] - Resource behavior strategy
|
|
29
|
-
* @param {string} [config.passphrase='secret'] - Encryption passphrase
|
|
28
|
+
* @param {string} [config.passphrase='secret'] - Encryption passphrase (for 'secret' type)
|
|
29
|
+
* @param {number} [config.bcryptRounds=10] - Bcrypt rounds (for 'password' type)
|
|
30
30
|
* @param {number} [config.parallelism=10] - Parallelism for bulk operations
|
|
31
31
|
* @param {Array} [config.observers=[]] - Observer instances
|
|
32
32
|
* @param {boolean} [config.cache=false] - Enable caching
|
|
@@ -123,6 +123,7 @@ export class Resource extends AsyncEventEmitter {
|
|
|
123
123
|
attributes = {},
|
|
124
124
|
behavior = DEFAULT_BEHAVIOR,
|
|
125
125
|
passphrase = 'secret',
|
|
126
|
+
bcryptRounds = 10,
|
|
126
127
|
parallelism = 10,
|
|
127
128
|
observers = [],
|
|
128
129
|
cache = false,
|
|
@@ -140,7 +141,8 @@ export class Resource extends AsyncEventEmitter {
|
|
|
140
141
|
asyncEvents = true,
|
|
141
142
|
asyncPartitions = true,
|
|
142
143
|
strictPartitions = false,
|
|
143
|
-
createdBy = 'user'
|
|
144
|
+
createdBy = 'user',
|
|
145
|
+
guard
|
|
144
146
|
} = config;
|
|
145
147
|
|
|
146
148
|
// Set instance properties
|
|
@@ -151,6 +153,7 @@ export class Resource extends AsyncEventEmitter {
|
|
|
151
153
|
this.observers = observers;
|
|
152
154
|
this.parallelism = parallelism;
|
|
153
155
|
this.passphrase = passphrase ?? 'secret';
|
|
156
|
+
this.bcryptRounds = bcryptRounds;
|
|
154
157
|
this.versioningEnabled = versioningEnabled;
|
|
155
158
|
this.strictValidation = strictValidation;
|
|
156
159
|
|
|
@@ -281,6 +284,10 @@ export class Resource extends AsyncEventEmitter {
|
|
|
281
284
|
}
|
|
282
285
|
}
|
|
283
286
|
|
|
287
|
+
// --- GUARDS SYSTEM ---
|
|
288
|
+
// Normalize and store guards (framework-agnostic authorization)
|
|
289
|
+
this.guard = this._normalizeGuard(guard);
|
|
290
|
+
|
|
284
291
|
// --- MIDDLEWARE SYSTEM ---
|
|
285
292
|
this._initMiddleware();
|
|
286
293
|
// Debug: print method names and typeof update at construction
|
|
@@ -303,11 +310,11 @@ export class Resource extends AsyncEventEmitter {
|
|
|
303
310
|
}
|
|
304
311
|
// If customIdGenerator is a number (size), create a generator with that size
|
|
305
312
|
if (typeof customIdGenerator === 'number' && customIdGenerator > 0) {
|
|
306
|
-
return
|
|
313
|
+
return createCustomGenerator(getUrlAlphabet(), customIdGenerator);
|
|
307
314
|
}
|
|
308
315
|
// If idSize is provided, create a generator with that size
|
|
309
316
|
if (typeof idSize === 'number' && idSize > 0 && idSize !== 22) {
|
|
310
|
-
return
|
|
317
|
+
return createCustomGenerator(getUrlAlphabet(), idSize);
|
|
311
318
|
}
|
|
312
319
|
// Default to the standard idGenerator (22 chars)
|
|
313
320
|
return defaultIdGenerator;
|
|
@@ -400,6 +407,7 @@ export class Resource extends AsyncEventEmitter {
|
|
|
400
407
|
name: this.name,
|
|
401
408
|
attributes: this.attributes,
|
|
402
409
|
passphrase: this.passphrase,
|
|
410
|
+
bcryptRounds: this.bcryptRounds,
|
|
403
411
|
version: this.version,
|
|
404
412
|
options: {
|
|
405
413
|
autoDecrypt: this.config.autoDecrypt,
|
|
@@ -1060,9 +1068,7 @@ export class Resource extends AsyncEventEmitter {
|
|
|
1060
1068
|
* });
|
|
1061
1069
|
*/
|
|
1062
1070
|
async insert({ id, ...attributes }) {
|
|
1063
|
-
const
|
|
1064
|
-
if (exists) throw new Error(`Resource with id '${id}' already exists`);
|
|
1065
|
-
const keyDebug = this.getResourceKey(id || '(auto)');
|
|
1071
|
+
const providedId = id !== undefined && id !== null && String(id).trim() !== '';
|
|
1066
1072
|
if (this.config.timestamps) {
|
|
1067
1073
|
attributes.createdAt = new Date().toISOString();
|
|
1068
1074
|
attributes.updatedAt = new Date().toISOString();
|
|
@@ -1086,11 +1092,12 @@ export class Resource extends AsyncEventEmitter {
|
|
|
1086
1092
|
const extraData = {};
|
|
1087
1093
|
for (const k of extraProps) extraData[k] = preProcessedData[k];
|
|
1088
1094
|
|
|
1095
|
+
const shouldValidateId = preProcessedData.id !== undefined && preProcessedData.id !== null;
|
|
1089
1096
|
const {
|
|
1090
1097
|
errors,
|
|
1091
1098
|
isValid,
|
|
1092
1099
|
data: validated,
|
|
1093
|
-
} = await this.validate(preProcessedData, { includeId:
|
|
1100
|
+
} = await this.validate(preProcessedData, { includeId: shouldValidateId });
|
|
1094
1101
|
|
|
1095
1102
|
if (!isValid) {
|
|
1096
1103
|
const errorMsg = (errors && errors.length && errors[0].message) ? errors[0].message : 'Insert failed';
|
|
@@ -1109,7 +1116,7 @@ export class Resource extends AsyncEventEmitter {
|
|
|
1109
1116
|
Object.assign(validatedAttributes, extraData);
|
|
1110
1117
|
|
|
1111
1118
|
// Generate ID with fallback for empty generators
|
|
1112
|
-
let finalId = validatedId || id;
|
|
1119
|
+
let finalId = validatedId || preProcessedData.id || id;
|
|
1113
1120
|
if (!finalId) {
|
|
1114
1121
|
finalId = this.idGenerator();
|
|
1115
1122
|
// Fallback to default generator if custom generator returns empty
|
|
@@ -1133,6 +1140,31 @@ export class Resource extends AsyncEventEmitter {
|
|
|
1133
1140
|
|
|
1134
1141
|
// Add version metadata (required for all objects)
|
|
1135
1142
|
const finalMetadata = processedMetadata;
|
|
1143
|
+
|
|
1144
|
+
if (!finalId || String(finalId).trim() === '') {
|
|
1145
|
+
throw new InvalidResourceItem({
|
|
1146
|
+
bucket: this.client.config.bucket,
|
|
1147
|
+
resourceName: this.name,
|
|
1148
|
+
attributes: preProcessedData,
|
|
1149
|
+
validation: [{ message: 'Generated ID is invalid', field: 'id' }],
|
|
1150
|
+
message: 'Generated ID is invalid'
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const shouldCheckExists = providedId || shouldValidateId || validatedId !== undefined;
|
|
1155
|
+
if (shouldCheckExists) {
|
|
1156
|
+
const alreadyExists = await this.exists(finalId);
|
|
1157
|
+
if (alreadyExists) {
|
|
1158
|
+
throw new InvalidResourceItem({
|
|
1159
|
+
bucket: this.client.config.bucket,
|
|
1160
|
+
resourceName: this.name,
|
|
1161
|
+
attributes: preProcessedData,
|
|
1162
|
+
validation: [{ message: `Resource with id '${finalId}' already exists`, field: 'id' }],
|
|
1163
|
+
message: `Resource with id '${finalId}' already exists`
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1136
1168
|
const key = this.getResourceKey(finalId);
|
|
1137
1169
|
// Determine content type based on body content
|
|
1138
1170
|
let contentType = undefined;
|
|
@@ -3653,6 +3685,102 @@ export class Resource extends AsyncEventEmitter {
|
|
|
3653
3685
|
return filtered;
|
|
3654
3686
|
}
|
|
3655
3687
|
|
|
3688
|
+
// --- GUARDS SYSTEM ---
|
|
3689
|
+
/**
|
|
3690
|
+
* Normalize guard configuration
|
|
3691
|
+
* @param {Object|Array|undefined} guard - Guard configuration
|
|
3692
|
+
* @returns {Object|null} Normalized guard config
|
|
3693
|
+
* @private
|
|
3694
|
+
*/
|
|
3695
|
+
_normalizeGuard(guard) {
|
|
3696
|
+
if (!guard) return null;
|
|
3697
|
+
|
|
3698
|
+
// String array simples → aplica para todas as operações
|
|
3699
|
+
if (Array.isArray(guard)) {
|
|
3700
|
+
return { '*': guard };
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
return guard;
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
/**
|
|
3707
|
+
* Execute guard for operation
|
|
3708
|
+
* @param {string} operation - Operation name (list, get, insert, update, etc)
|
|
3709
|
+
* @param {Object} context - Framework-agnostic context
|
|
3710
|
+
* @param {Object} context.user - Decoded JWT token
|
|
3711
|
+
* @param {Object} context.params - Route params
|
|
3712
|
+
* @param {Object} context.body - Request body
|
|
3713
|
+
* @param {Object} context.query - Query string
|
|
3714
|
+
* @param {Object} context.headers - Request headers
|
|
3715
|
+
* @param {Function} context.setPartition - Helper to set partition
|
|
3716
|
+
* @param {Object} [resource] - Resource record (for get/update/delete)
|
|
3717
|
+
* @returns {Promise<boolean>} True if allowed, false if denied
|
|
3718
|
+
*/
|
|
3719
|
+
async executeGuard(operation, context, resource = null) {
|
|
3720
|
+
if (!this.guard) return true; // No guard = allow
|
|
3721
|
+
|
|
3722
|
+
// 1. Try operation-specific guard
|
|
3723
|
+
let guardFn = this.guard[operation];
|
|
3724
|
+
|
|
3725
|
+
// 2. Fallback to wildcard
|
|
3726
|
+
if (!guardFn) {
|
|
3727
|
+
guardFn = this.guard['*'];
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
// 3. No guard = allow
|
|
3731
|
+
if (!guardFn) return true;
|
|
3732
|
+
|
|
3733
|
+
// 4. Boolean simple
|
|
3734
|
+
if (typeof guardFn === 'boolean') {
|
|
3735
|
+
return guardFn;
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
// 5. Array of roles/scopes
|
|
3739
|
+
if (Array.isArray(guardFn)) {
|
|
3740
|
+
return this._checkRolesScopes(guardFn, context.user);
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
// 6. Custom function
|
|
3744
|
+
if (typeof guardFn === 'function') {
|
|
3745
|
+
try {
|
|
3746
|
+
const result = await guardFn(context, resource);
|
|
3747
|
+
return result === true; // Force boolean
|
|
3748
|
+
} catch (err) {
|
|
3749
|
+
// Guard error = deny access
|
|
3750
|
+
console.error(`Guard error for ${operation}:`, err);
|
|
3751
|
+
return false;
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
return false; // Default: deny
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
/**
|
|
3759
|
+
* Check if user has required roles or scopes
|
|
3760
|
+
* @param {Array<string>} requiredRolesScopes - Required roles/scopes
|
|
3761
|
+
* @param {Object} user - User from JWT token
|
|
3762
|
+
* @returns {boolean} True if user has any of required roles/scopes
|
|
3763
|
+
* @private
|
|
3764
|
+
*/
|
|
3765
|
+
_checkRolesScopes(requiredRolesScopes, user) {
|
|
3766
|
+
if (!user) return false;
|
|
3767
|
+
|
|
3768
|
+
// User scopes (OpenID scope claim)
|
|
3769
|
+
const userScopes = user.scope?.split(' ') || [];
|
|
3770
|
+
|
|
3771
|
+
// User roles - support multiple formats (Keycloak, Azure AD)
|
|
3772
|
+
const clientId = user.azp || process.env.CLIENT_ID || 'default';
|
|
3773
|
+
const clientRoles = user.resource_access?.[clientId]?.roles || [];
|
|
3774
|
+
const realmRoles = user.realm_access?.roles || [];
|
|
3775
|
+
const azureRoles = user.roles || [];
|
|
3776
|
+
const userRoles = [...clientRoles, ...realmRoles, ...azureRoles];
|
|
3777
|
+
|
|
3778
|
+
// Check if user has any of required
|
|
3779
|
+
return requiredRolesScopes.some(required => {
|
|
3780
|
+
return userScopes.includes(required) || userRoles.includes(required);
|
|
3781
|
+
});
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3656
3784
|
// --- MIDDLEWARE SYSTEM ---
|
|
3657
3785
|
_initMiddleware() {
|
|
3658
3786
|
// Map of methodName -> array of middleware functions
|
|
@@ -4012,4 +4140,4 @@ function validateResourceConfig(config) {
|
|
|
4012
4140
|
};
|
|
4013
4141
|
}
|
|
4014
4142
|
|
|
4015
|
-
export default Resource;
|
|
4143
|
+
export default Resource;
|
package/src/schema.class.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "lodash-es";
|
|
14
14
|
|
|
15
15
|
import { encrypt, decrypt } from "./concerns/crypto.js";
|
|
16
|
+
import { hashPassword, compactHash } from "./concerns/password-hashing.js";
|
|
16
17
|
import { ValidatorManager } from "./validator.class.js";
|
|
17
18
|
import { tryFn, tryFnSync } from "./concerns/try-fn.js";
|
|
18
19
|
import { SchemaError } from "./errors.js";
|
|
@@ -121,6 +122,16 @@ export const SchemaActions = {
|
|
|
121
122
|
return raw;
|
|
122
123
|
},
|
|
123
124
|
|
|
125
|
+
hashPassword: async (value, { bcryptRounds = 10 }) => {
|
|
126
|
+
if (value === null || value === undefined) return value;
|
|
127
|
+
// Hash with bcrypt
|
|
128
|
+
const [okHash, errHash, hash] = await tryFn(() => hashPassword(String(value), bcryptRounds));
|
|
129
|
+
if (!okHash) return value; // Return original on error
|
|
130
|
+
// Compact hash to save space (60 → 53 bytes)
|
|
131
|
+
const [okCompact, errCompact, compacted] = tryFnSync(() => compactHash(hash));
|
|
132
|
+
return okCompact ? compacted : hash; // Return compacted or fallback to full hash
|
|
133
|
+
},
|
|
134
|
+
|
|
124
135
|
toString: (value) => value == null ? value : String(value),
|
|
125
136
|
|
|
126
137
|
fromArray: (value, { separator }) => {
|
|
@@ -535,6 +546,7 @@ export class Schema {
|
|
|
535
546
|
name,
|
|
536
547
|
attributes,
|
|
537
548
|
passphrase,
|
|
549
|
+
bcryptRounds,
|
|
538
550
|
version = 1,
|
|
539
551
|
options = {},
|
|
540
552
|
_pluginAttributeMetadata,
|
|
@@ -545,6 +557,7 @@ export class Schema {
|
|
|
545
557
|
this.version = version;
|
|
546
558
|
this.attributes = attributes || {};
|
|
547
559
|
this.passphrase = passphrase ?? "secret";
|
|
560
|
+
this.bcryptRounds = bcryptRounds ?? 10;
|
|
548
561
|
this.options = merge({}, this.defaultOptions(), options);
|
|
549
562
|
this.allNestedObjectsOptional = this.options.allNestedObjectsOptional ?? false;
|
|
550
563
|
|
|
@@ -555,7 +568,11 @@ export class Schema {
|
|
|
555
568
|
// Preprocess attributes to handle nested objects for validator compilation
|
|
556
569
|
const processedAttributes = this.preprocessAttributesForValidation(this.attributes);
|
|
557
570
|
|
|
558
|
-
this.validator = new ValidatorManager({
|
|
571
|
+
this.validator = new ValidatorManager({
|
|
572
|
+
autoEncrypt: false,
|
|
573
|
+
passphrase: this.passphrase,
|
|
574
|
+
bcryptRounds: this.bcryptRounds
|
|
575
|
+
}).compile(merge(
|
|
559
576
|
{ $$async: true, $$strict: false },
|
|
560
577
|
processedAttributes,
|
|
561
578
|
))
|
|
@@ -824,6 +841,16 @@ export class Schema {
|
|
|
824
841
|
continue;
|
|
825
842
|
}
|
|
826
843
|
|
|
844
|
+
// Handle passwords
|
|
845
|
+
if (defStr.includes("password") || defType === 'password') {
|
|
846
|
+
if (this.options.autoEncrypt) {
|
|
847
|
+
this.addHook("beforeMap", name, "hashPassword");
|
|
848
|
+
}
|
|
849
|
+
// No afterUnmap hook - passwords are one-way hashed
|
|
850
|
+
// Skip other processing for passwords
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
|
|
827
854
|
// Handle ip4 type
|
|
828
855
|
if (defStr.includes("ip4") || defType === 'ip4') {
|
|
829
856
|
this.addHook("beforeMap", name, "encodeIPv4");
|
|
@@ -1067,6 +1094,7 @@ export class Schema {
|
|
|
1067
1094
|
if (value !== undefined && typeof SchemaActions[actionName] === 'function') {
|
|
1068
1095
|
set(cloned, attribute, await SchemaActions[actionName](value, {
|
|
1069
1096
|
passphrase: this.passphrase,
|
|
1097
|
+
bcryptRounds: this.bcryptRounds,
|
|
1070
1098
|
separator: this.options.arraySeparator,
|
|
1071
1099
|
...actionParams // Merge custom parameters (currency, precision, etc.)
|
|
1072
1100
|
}))
|
|
@@ -1181,6 +1209,7 @@ export class Schema {
|
|
|
1181
1209
|
if (typeof SchemaActions[actionName] === 'function') {
|
|
1182
1210
|
parsedValue = await SchemaActions[actionName](parsedValue, {
|
|
1183
1211
|
passphrase: this.passphrase,
|
|
1212
|
+
bcryptRounds: this.bcryptRounds,
|
|
1184
1213
|
separator: this.options.arraySeparator,
|
|
1185
1214
|
...actionParams // Merge custom parameters (currency, precision, etc.)
|
|
1186
1215
|
});
|