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.
Files changed (110) hide show
  1. package/README.md +25 -10
  2. package/dist/{s3db.cjs.js → s3db.cjs} +38801 -32446
  3. package/dist/s3db.cjs.map +1 -0
  4. package/dist/s3db.es.js +38653 -32291
  5. package/dist/s3db.es.js.map +1 -1
  6. package/package.json +218 -22
  7. package/src/concerns/id.js +90 -6
  8. package/src/concerns/index.js +2 -1
  9. package/src/concerns/password-hashing.js +150 -0
  10. package/src/database.class.js +6 -2
  11. package/src/plugins/api/auth/basic-auth.js +40 -10
  12. package/src/plugins/api/auth/index.js +49 -3
  13. package/src/plugins/api/auth/oauth2-auth.js +171 -0
  14. package/src/plugins/api/auth/oidc-auth.js +789 -0
  15. package/src/plugins/api/auth/oidc-client.js +462 -0
  16. package/src/plugins/api/auth/path-auth-matcher.js +284 -0
  17. package/src/plugins/api/concerns/event-emitter.js +134 -0
  18. package/src/plugins/api/concerns/failban-manager.js +651 -0
  19. package/src/plugins/api/concerns/guards-helpers.js +402 -0
  20. package/src/plugins/api/concerns/metrics-collector.js +346 -0
  21. package/src/plugins/api/index.js +510 -57
  22. package/src/plugins/api/middlewares/failban.js +305 -0
  23. package/src/plugins/api/middlewares/rate-limit.js +301 -0
  24. package/src/plugins/api/middlewares/request-id.js +74 -0
  25. package/src/plugins/api/middlewares/security-headers.js +120 -0
  26. package/src/plugins/api/middlewares/session-tracking.js +194 -0
  27. package/src/plugins/api/routes/auth-routes.js +119 -78
  28. package/src/plugins/api/routes/resource-routes.js +73 -30
  29. package/src/plugins/api/server.js +1139 -45
  30. package/src/plugins/api/utils/custom-routes.js +102 -0
  31. package/src/plugins/api/utils/guards.js +213 -0
  32. package/src/plugins/api/utils/mime-types.js +154 -0
  33. package/src/plugins/api/utils/openapi-generator.js +91 -12
  34. package/src/plugins/api/utils/path-matcher.js +173 -0
  35. package/src/plugins/api/utils/static-filesystem.js +262 -0
  36. package/src/plugins/api/utils/static-s3.js +231 -0
  37. package/src/plugins/api/utils/template-engine.js +188 -0
  38. package/src/plugins/cloud-inventory/drivers/alibaba-driver.js +853 -0
  39. package/src/plugins/cloud-inventory/drivers/aws-driver.js +2554 -0
  40. package/src/plugins/cloud-inventory/drivers/azure-driver.js +637 -0
  41. package/src/plugins/cloud-inventory/drivers/base-driver.js +99 -0
  42. package/src/plugins/cloud-inventory/drivers/cloudflare-driver.js +620 -0
  43. package/src/plugins/cloud-inventory/drivers/digitalocean-driver.js +698 -0
  44. package/src/plugins/cloud-inventory/drivers/gcp-driver.js +645 -0
  45. package/src/plugins/cloud-inventory/drivers/hetzner-driver.js +559 -0
  46. package/src/plugins/cloud-inventory/drivers/linode-driver.js +614 -0
  47. package/src/plugins/cloud-inventory/drivers/mock-drivers.js +449 -0
  48. package/src/plugins/cloud-inventory/drivers/mongodb-atlas-driver.js +771 -0
  49. package/src/plugins/cloud-inventory/drivers/oracle-driver.js +768 -0
  50. package/src/plugins/cloud-inventory/drivers/vultr-driver.js +636 -0
  51. package/src/plugins/cloud-inventory/index.js +20 -0
  52. package/src/plugins/cloud-inventory/registry.js +146 -0
  53. package/src/plugins/cloud-inventory/terraform-exporter.js +362 -0
  54. package/src/plugins/cloud-inventory.plugin.js +1333 -0
  55. package/src/plugins/concerns/plugin-dependencies.js +62 -2
  56. package/src/plugins/eventual-consistency/analytics.js +1 -0
  57. package/src/plugins/eventual-consistency/consolidation.js +2 -2
  58. package/src/plugins/eventual-consistency/garbage-collection.js +2 -2
  59. package/src/plugins/eventual-consistency/install.js +2 -2
  60. package/src/plugins/identity/README.md +335 -0
  61. package/src/plugins/identity/concerns/mfa-manager.js +204 -0
  62. package/src/plugins/identity/concerns/password.js +138 -0
  63. package/src/plugins/identity/concerns/resource-schemas.js +273 -0
  64. package/src/plugins/identity/concerns/token-generator.js +172 -0
  65. package/src/plugins/identity/email-service.js +422 -0
  66. package/src/plugins/identity/index.js +1052 -0
  67. package/src/plugins/identity/oauth2-server.js +1033 -0
  68. package/src/plugins/identity/oidc-discovery.js +285 -0
  69. package/src/plugins/identity/rsa-keys.js +323 -0
  70. package/src/plugins/identity/server.js +500 -0
  71. package/src/plugins/identity/session-manager.js +453 -0
  72. package/src/plugins/identity/ui/layouts/base.js +251 -0
  73. package/src/plugins/identity/ui/middleware.js +135 -0
  74. package/src/plugins/identity/ui/pages/admin/client-form.js +247 -0
  75. package/src/plugins/identity/ui/pages/admin/clients.js +179 -0
  76. package/src/plugins/identity/ui/pages/admin/dashboard.js +181 -0
  77. package/src/plugins/identity/ui/pages/admin/user-form.js +283 -0
  78. package/src/plugins/identity/ui/pages/admin/users.js +263 -0
  79. package/src/plugins/identity/ui/pages/consent.js +262 -0
  80. package/src/plugins/identity/ui/pages/forgot-password.js +104 -0
  81. package/src/plugins/identity/ui/pages/login.js +144 -0
  82. package/src/plugins/identity/ui/pages/mfa-backup-codes.js +180 -0
  83. package/src/plugins/identity/ui/pages/mfa-enrollment.js +187 -0
  84. package/src/plugins/identity/ui/pages/mfa-verification.js +178 -0
  85. package/src/plugins/identity/ui/pages/oauth-error.js +225 -0
  86. package/src/plugins/identity/ui/pages/profile.js +361 -0
  87. package/src/plugins/identity/ui/pages/register.js +226 -0
  88. package/src/plugins/identity/ui/pages/reset-password.js +128 -0
  89. package/src/plugins/identity/ui/pages/verify-email.js +172 -0
  90. package/src/plugins/identity/ui/routes.js +2541 -0
  91. package/src/plugins/identity/ui/styles/main.css +465 -0
  92. package/src/plugins/index.js +4 -1
  93. package/src/plugins/ml/base-model.class.js +65 -16
  94. package/src/plugins/ml/classification-model.class.js +1 -1
  95. package/src/plugins/ml/timeseries-model.class.js +3 -1
  96. package/src/plugins/ml.plugin.js +584 -31
  97. package/src/plugins/shared/error-handler.js +147 -0
  98. package/src/plugins/shared/index.js +9 -0
  99. package/src/plugins/shared/middlewares/compression.js +117 -0
  100. package/src/plugins/shared/middlewares/cors.js +49 -0
  101. package/src/plugins/shared/middlewares/index.js +11 -0
  102. package/src/plugins/shared/middlewares/logging.js +54 -0
  103. package/src/plugins/shared/middlewares/rate-limit.js +73 -0
  104. package/src/plugins/shared/middlewares/security.js +158 -0
  105. package/src/plugins/shared/response-formatter.js +264 -0
  106. package/src/plugins/state-machine.plugin.js +57 -2
  107. package/src/resource.class.js +140 -12
  108. package/src/schema.class.js +30 -1
  109. package/src/validator.class.js +57 -6
  110. 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
- eventSource.on(baseEvent, eventHandler);
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
@@ -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 customAlphabet(urlAlphabet, customIdGenerator);
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 customAlphabet(urlAlphabet, idSize);
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 exists = await this.exists(id);
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: true });
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;
@@ -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({ autoEncrypt: false }).compile(merge(
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
  });