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,102 @@
1
+ /**
2
+ * Custom Routes Utilities
3
+ *
4
+ * Parse and mount custom routes defined in resources or plugins
5
+ * Inspired by moleculer-js route syntax
6
+ */
7
+
8
+ import { asyncHandler } from './error-handler.js';
9
+
10
+ /**
11
+ * Parse route definition from key
12
+ * @param {string} key - Route key (e.g., 'GET /users', 'POST /custom/:id/action')
13
+ * @returns {Object} { method, path }
14
+ */
15
+ export function parseRouteKey(key) {
16
+ const match = key.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/i);
17
+
18
+ if (!match) {
19
+ throw new Error(`Invalid route key format: "${key}". Expected format: "METHOD /path"`);
20
+ }
21
+
22
+ return {
23
+ method: match[1].toUpperCase(),
24
+ path: match[2]
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Mount custom routes on Hono app
30
+ * @param {Object} app - Hono app instance
31
+ * @param {Object} routes - Routes object { 'METHOD /path': handler }
32
+ * @param {Object} context - Context to pass to handlers (resource, database, etc.)
33
+ * @param {boolean} verbose - Enable verbose logging
34
+ */
35
+ export function mountCustomRoutes(app, routes, context = {}, verbose = false) {
36
+ if (!routes || typeof routes !== 'object') {
37
+ return;
38
+ }
39
+
40
+ for (const [key, handler] of Object.entries(routes)) {
41
+ try {
42
+ const { method, path } = parseRouteKey(key);
43
+
44
+ // Wrap handler with async error handler and context
45
+ const wrappedHandler = asyncHandler(async (c) => {
46
+ // Inject context into Hono context
47
+ c.set('customRouteContext', context);
48
+
49
+ // Call user handler with Hono context
50
+ return await handler(c);
51
+ });
52
+
53
+ // Mount route
54
+ app.on(method, path, wrappedHandler);
55
+
56
+ if (verbose) {
57
+ console.log(`[Custom Routes] Mounted ${method} ${path}`);
58
+ }
59
+ } catch (err) {
60
+ console.error(`[Custom Routes] Error mounting route "${key}":`, err.message);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Validate custom routes object
67
+ * @param {Object} routes - Routes to validate
68
+ * @returns {Array} Array of validation errors
69
+ */
70
+ export function validateCustomRoutes(routes) {
71
+ const errors = [];
72
+
73
+ if (!routes || typeof routes !== 'object') {
74
+ return errors;
75
+ }
76
+
77
+ for (const [key, handler] of Object.entries(routes)) {
78
+ // Validate key format
79
+ try {
80
+ parseRouteKey(key);
81
+ } catch (err) {
82
+ errors.push({ key, error: err.message });
83
+ continue;
84
+ }
85
+
86
+ // Validate handler is a function
87
+ if (typeof handler !== 'function') {
88
+ errors.push({
89
+ key,
90
+ error: `Handler must be a function, got ${typeof handler}`
91
+ });
92
+ }
93
+ }
94
+
95
+ return errors;
96
+ }
97
+
98
+ export default {
99
+ parseRouteKey,
100
+ mountCustomRoutes,
101
+ validateCustomRoutes
102
+ };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Guards - Authorization checks for resources
3
+ *
4
+ * Guards determine if a user can perform an operation on a resource.
5
+ * Supports: functions, scopes, roles, and combined logic.
6
+ */
7
+
8
+ /**
9
+ * Check if user passes guard
10
+ * @param {Object} user - Authenticated user object
11
+ * @param {Function|string|Array|Object|null} guard - Guard configuration
12
+ * @param {Object} context - Additional context (data, resourceName, operation)
13
+ * @returns {boolean} True if authorized
14
+ */
15
+ export function checkGuard(user, guard, context = {}) {
16
+ // No guard = public access
17
+ if (!guard) {
18
+ return true;
19
+ }
20
+
21
+ // No user = unauthorized (unless guard explicitly allows)
22
+ if (!user && guard !== true) {
23
+ return false;
24
+ }
25
+
26
+ // Guard is boolean
27
+ if (typeof guard === 'boolean') {
28
+ return guard;
29
+ }
30
+
31
+ // Guard is function: (user, context) => boolean
32
+ if (typeof guard === 'function') {
33
+ try {
34
+ return guard(user, context);
35
+ } catch (err) {
36
+ console.error('[Guards] Error executing guard function:', err);
37
+ return false;
38
+ }
39
+ }
40
+
41
+ // Guard is string: scope name (e.g., 'read:users')
42
+ if (typeof guard === 'string') {
43
+ return hasScope(user, guard);
44
+ }
45
+
46
+ // Guard is array: any scope matches (OR logic)
47
+ if (Array.isArray(guard)) {
48
+ return guard.some(scope => hasScope(user, scope));
49
+ }
50
+
51
+ // Guard is object: check properties
52
+ if (typeof guard === 'object') {
53
+ // Check role
54
+ if (guard.role) {
55
+ if (Array.isArray(guard.role)) {
56
+ if (!guard.role.includes(user.role)) {
57
+ return false;
58
+ }
59
+ } else if (user.role !== guard.role) {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ // Check scopes (all must match - AND logic)
65
+ if (guard.scopes) {
66
+ const requiredScopes = Array.isArray(guard.scopes) ? guard.scopes : [guard.scopes];
67
+ if (!requiredScopes.every(scope => hasScope(user, scope))) {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ // Check custom function
73
+ if (guard.check && typeof guard.check === 'function') {
74
+ try {
75
+ return guard.check(user, context);
76
+ } catch (err) {
77
+ console.error('[Guards] Error executing guard.check function:', err);
78
+ return false;
79
+ }
80
+ }
81
+
82
+ return true;
83
+ }
84
+
85
+ // Unknown guard type = deny
86
+ return false;
87
+ }
88
+
89
+ /**
90
+ * Check if user has specific scope
91
+ * @param {Object} user - User object
92
+ * @param {string} scope - Scope name (e.g., 'read:users')
93
+ * @returns {boolean} True if user has scope
94
+ */
95
+ export function hasScope(user, scope) {
96
+ if (!user || !user.scopes) {
97
+ return false;
98
+ }
99
+
100
+ if (!Array.isArray(user.scopes)) {
101
+ return false;
102
+ }
103
+
104
+ // Direct match
105
+ if (user.scopes.includes(scope)) {
106
+ return true;
107
+ }
108
+
109
+ // Wildcard match (e.g., 'admin:*' matches 'admin:users')
110
+ const wildcards = user.scopes.filter(s => s.endsWith(':*'));
111
+ for (const wildcard of wildcards) {
112
+ const prefix = wildcard.slice(0, -2); // Remove ':*'
113
+ if (scope.startsWith(prefix + ':')) {
114
+ return true;
115
+ }
116
+ }
117
+
118
+ // Super admin wildcard ('*' matches everything)
119
+ if (user.scopes.includes('*')) {
120
+ return true;
121
+ }
122
+
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Get operation-specific guard from guards config
128
+ * @param {Object} guards - Guards configuration
129
+ * @param {string} operation - Operation name ('list', 'get', 'create', 'update', 'delete')
130
+ * @returns {Function|string|Array|Object|null} Guard for operation
131
+ */
132
+ export function getOperationGuard(guards, operation) {
133
+ if (!guards) {
134
+ return null;
135
+ }
136
+
137
+ // If guards is a function/string/array, apply to all operations
138
+ if (typeof guards === 'function' || typeof guards === 'string' || Array.isArray(guards)) {
139
+ return guards;
140
+ }
141
+
142
+ // If guards is object, get operation-specific guard
143
+ if (typeof guards === 'object') {
144
+ // Check for specific operation
145
+ if (guards[operation] !== undefined) {
146
+ return guards[operation];
147
+ }
148
+
149
+ // Fallback to 'all' or default
150
+ if (guards.all !== undefined) {
151
+ return guards.all;
152
+ }
153
+
154
+ // Map operation aliases
155
+ const aliases = {
156
+ list: 'read',
157
+ get: 'read',
158
+ create: 'write',
159
+ update: 'write',
160
+ delete: 'write'
161
+ };
162
+
163
+ if (aliases[operation] && guards[aliases[operation]] !== undefined) {
164
+ return guards[aliases[operation]];
165
+ }
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * Create guard middleware for Hono
173
+ * @param {Object} guards - Guards configuration
174
+ * @param {string} operation - Operation name
175
+ * @returns {Function} Hono middleware
176
+ */
177
+ export function guardMiddleware(guards, operation) {
178
+ return async (c, next) => {
179
+ const user = c.get('user');
180
+ const guard = getOperationGuard(guards, operation);
181
+
182
+ // Check guard
183
+ const authorized = checkGuard(user, guard, {
184
+ operation,
185
+ resourceName: c.req.param('resource'),
186
+ data: c.req.method !== 'GET' ? await c.req.json().catch(() => ({})) : {}
187
+ });
188
+
189
+ if (!authorized) {
190
+ return c.json({
191
+ success: false,
192
+ error: {
193
+ message: 'Forbidden: Insufficient permissions',
194
+ code: 'FORBIDDEN',
195
+ details: {
196
+ operation,
197
+ user: user ? { id: user.id, role: user.role } : null
198
+ }
199
+ },
200
+ _status: 403
201
+ }, 403);
202
+ }
203
+
204
+ await next();
205
+ };
206
+ }
207
+
208
+ export default {
209
+ checkGuard,
210
+ hasScope,
211
+ getOperationGuard,
212
+ guardMiddleware
213
+ };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * MIME Type Detection
3
+ *
4
+ * Lightweight MIME type detection based on file extensions
5
+ */
6
+
7
+ /**
8
+ * Common MIME types mapped by extension
9
+ */
10
+ const MIME_TYPES = {
11
+ // Text
12
+ 'txt': 'text/plain',
13
+ 'html': 'text/html',
14
+ 'htm': 'text/html',
15
+ 'css': 'text/css',
16
+ 'js': 'text/javascript',
17
+ 'mjs': 'text/javascript',
18
+ 'json': 'application/json',
19
+ 'xml': 'application/xml',
20
+ 'csv': 'text/csv',
21
+ 'md': 'text/markdown',
22
+
23
+ // Images
24
+ 'jpg': 'image/jpeg',
25
+ 'jpeg': 'image/jpeg',
26
+ 'png': 'image/png',
27
+ 'gif': 'image/gif',
28
+ 'webp': 'image/webp',
29
+ 'svg': 'image/svg+xml',
30
+ 'ico': 'image/x-icon',
31
+ 'bmp': 'image/bmp',
32
+ 'tiff': 'image/tiff',
33
+ 'tif': 'image/tiff',
34
+
35
+ // Audio
36
+ 'mp3': 'audio/mpeg',
37
+ 'wav': 'audio/wav',
38
+ 'ogg': 'audio/ogg',
39
+ 'flac': 'audio/flac',
40
+ 'm4a': 'audio/mp4',
41
+
42
+ // Video
43
+ 'mp4': 'video/mp4',
44
+ 'webm': 'video/webm',
45
+ 'ogv': 'video/ogg',
46
+ 'avi': 'video/x-msvideo',
47
+ 'mov': 'video/quicktime',
48
+ 'mkv': 'video/x-matroska',
49
+
50
+ // Documents
51
+ 'pdf': 'application/pdf',
52
+ 'doc': 'application/msword',
53
+ 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
54
+ 'xls': 'application/vnd.ms-excel',
55
+ 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
56
+ 'ppt': 'application/vnd.ms-powerpoint',
57
+ 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
58
+
59
+ // Archives
60
+ 'zip': 'application/zip',
61
+ 'tar': 'application/x-tar',
62
+ 'gz': 'application/gzip',
63
+ 'bz2': 'application/x-bzip2',
64
+ '7z': 'application/x-7z-compressed',
65
+ 'rar': 'application/vnd.rar',
66
+
67
+ // Fonts
68
+ 'ttf': 'font/ttf',
69
+ 'otf': 'font/otf',
70
+ 'woff': 'font/woff',
71
+ 'woff2': 'font/woff2',
72
+ 'eot': 'application/vnd.ms-fontobject',
73
+
74
+ // Application
75
+ 'wasm': 'application/wasm',
76
+ 'bin': 'application/octet-stream'
77
+ };
78
+
79
+ /**
80
+ * Get MIME type from filename
81
+ * @param {string} filename - Filename with extension
82
+ * @returns {string} MIME type (defaults to 'application/octet-stream')
83
+ * @example
84
+ * getMimeType('image.png') // 'image/png'
85
+ * getMimeType('document.pdf') // 'application/pdf'
86
+ * getMimeType('unknown.xyz') // 'application/octet-stream'
87
+ */
88
+ export function getMimeType(filename) {
89
+ if (!filename || typeof filename !== 'string') {
90
+ return 'application/octet-stream';
91
+ }
92
+
93
+ // Extract extension (lowercase)
94
+ const ext = filename.split('.').pop().toLowerCase();
95
+
96
+ return MIME_TYPES[ext] || 'application/octet-stream';
97
+ }
98
+
99
+ /**
100
+ * Check if MIME type is compressible
101
+ * @param {string} mimeType - MIME type
102
+ * @returns {boolean} True if compressible
103
+ */
104
+ export function isCompressible(mimeType) {
105
+ if (!mimeType) return false;
106
+
107
+ // Text-based content is compressible
108
+ if (mimeType.startsWith('text/')) return true;
109
+ if (mimeType.includes('javascript')) return true;
110
+ if (mimeType.includes('json')) return true;
111
+ if (mimeType.includes('xml')) return true;
112
+ if (mimeType.includes('svg')) return true;
113
+
114
+ return false;
115
+ }
116
+
117
+ /**
118
+ * Get charset for MIME type
119
+ * @param {string} mimeType - MIME type
120
+ * @returns {string|null} Charset or null
121
+ */
122
+ export function getCharset(mimeType) {
123
+ if (!mimeType) return null;
124
+
125
+ // Text types should have UTF-8 charset
126
+ if (mimeType.startsWith('text/')) return 'utf-8';
127
+ if (mimeType.includes('javascript')) return 'utf-8';
128
+ if (mimeType.includes('json')) return 'utf-8';
129
+ if (mimeType.includes('xml')) return 'utf-8';
130
+
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Build complete Content-Type header
136
+ * @param {string} filename - Filename
137
+ * @returns {string} Complete Content-Type header
138
+ * @example
139
+ * getContentType('file.html') // 'text/html; charset=utf-8'
140
+ * getContentType('image.png') // 'image/png'
141
+ */
142
+ export function getContentType(filename) {
143
+ const mimeType = getMimeType(filename);
144
+ const charset = getCharset(mimeType);
145
+
146
+ return charset ? `${mimeType}; charset=${charset}` : mimeType;
147
+ }
148
+
149
+ export default {
150
+ getMimeType,
151
+ isCompressible,
152
+ getCharset,
153
+ getContentType
154
+ };
@@ -185,7 +185,20 @@ function generateResourceSchema(resource) {
185
185
  */
186
186
  function generateResourcePaths(resource, version, config = {}) {
187
187
  const resourceName = resource.name;
188
- const basePath = `/${version}/${resourceName}`;
188
+
189
+ // Determine version prefix (same logic as server.js)
190
+ let versionPrefixConfig = config.versionPrefix !== undefined ? config.versionPrefix : false;
191
+
192
+ let prefix = '';
193
+ if (versionPrefixConfig === true) {
194
+ prefix = version;
195
+ } else if (versionPrefixConfig === false) {
196
+ prefix = '';
197
+ } else if (typeof versionPrefixConfig === 'string') {
198
+ prefix = versionPrefixConfig;
199
+ }
200
+
201
+ const basePath = prefix ? `/${prefix}/${resourceName}` : `/${resourceName}`;
189
202
  const schema = generateResourceSchema(resource);
190
203
  const methods = config.methods || ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
191
204
  const authMethods = config.auth || [];
@@ -820,11 +833,14 @@ The response includes pagination metadata in the \`pagination\` object with tota
820
833
  * @param {Object} relationConfig - Relation configuration
821
834
  * @param {string} version - Resource version
822
835
  * @param {Object} relatedSchema - OpenAPI schema for related resource
836
+ * @param {string} versionPrefix - Version prefix to use (empty string for no prefix)
823
837
  * @returns {Object} OpenAPI paths for relation
824
838
  */
825
- function generateRelationalPaths(resource, relationName, relationConfig, version, relatedSchema) {
839
+ function generateRelationalPaths(resource, relationName, relationConfig, version, relatedSchema, versionPrefix = '') {
826
840
  const resourceName = resource.name;
827
- const basePath = `/${version}/${resourceName}/{id}/${relationName}`;
841
+ const basePath = versionPrefix
842
+ ? `/${versionPrefix}/${resourceName}/{id}/${relationName}`
843
+ : `/${resourceName}/{id}/${relationName}`;
828
844
  const relatedResourceName = relationConfig.resource;
829
845
  const isToMany = relationConfig.type === 'hasMany' || relationConfig.type === 'belongsToMany';
830
846
 
@@ -933,14 +949,22 @@ export function generateOpenAPISpec(database, config = {}) {
933
949
  description = 'Auto-generated REST API documentation for s3db.js resources',
934
950
  serverUrl = 'http://localhost:3000',
935
951
  auth = {},
936
- resources: resourceConfigs = {}
952
+ resources: resourceConfigs = {},
953
+ versionPrefix: globalVersionPrefix
937
954
  } = config;
938
955
 
939
956
  // Build resources table for description
940
957
  const resourcesTableRows = [];
941
958
  for (const [name, resource] of Object.entries(database.resources)) {
959
+ const rawConfig = resourceConfigs[name];
960
+
961
+ // Skip resources explicitly disabled
962
+ if (rawConfig?.enabled === false) {
963
+ continue;
964
+ }
965
+
942
966
  // Skip plugin resources unless explicitly configured
943
- if (name.startsWith('plg_') && !resourceConfigs[name]) {
967
+ if (name.startsWith('plg_') && !rawConfig) {
944
968
  continue;
945
969
  }
946
970
 
@@ -950,7 +974,31 @@ export function generateOpenAPISpec(database, config = {}) {
950
974
  ? resourceDescription.resource
951
975
  : resourceDescription || 'No description';
952
976
 
953
- resourcesTableRows.push(`| ${name} | ${descText} | \`/${version}/${name}\` |`);
977
+ // Check version prefix for this resource (same logic as server.js)
978
+ const resourceConfig = rawConfig && typeof rawConfig === 'object' ? rawConfig : {};
979
+ let versionPrefixConfig;
980
+ if (resourceConfig.versionPrefix !== undefined) {
981
+ versionPrefixConfig = resourceConfig.versionPrefix;
982
+ } else if (resource.config && resource.config.versionPrefix !== undefined) {
983
+ versionPrefixConfig = resource.config.versionPrefix;
984
+ } else if (globalVersionPrefix !== undefined) {
985
+ versionPrefixConfig = globalVersionPrefix;
986
+ } else {
987
+ versionPrefixConfig = false; // Default to no prefix
988
+ }
989
+
990
+ let prefix = '';
991
+ if (versionPrefixConfig === true) {
992
+ prefix = version;
993
+ } else if (versionPrefixConfig === false) {
994
+ prefix = '';
995
+ } else if (typeof versionPrefixConfig === 'string') {
996
+ prefix = versionPrefixConfig;
997
+ }
998
+
999
+ const basePath = prefix ? `/${prefix}/${name}` : `/${name}`;
1000
+
1001
+ resourcesTableRows.push(`| ${name} | ${descText} | \`${basePath}\` |`);
954
1002
  }
955
1003
 
956
1004
  // Build enhanced description with resources table
@@ -1070,13 +1118,19 @@ For detailed information about each endpoint, see the sections below.`;
1070
1118
  const relationsPlugin = database.plugins?.relation || database.plugins?.RelationPlugin || null;
1071
1119
 
1072
1120
  for (const [name, resource] of Object.entries(resources)) {
1121
+ const rawConfig = resourceConfigs[name];
1122
+
1123
+ if (rawConfig?.enabled === false) {
1124
+ continue;
1125
+ }
1126
+
1073
1127
  // Skip plugin resources unless explicitly configured
1074
- if (name.startsWith('plg_') && !resourceConfigs[name]) {
1128
+ if (name.startsWith('plg_') && !rawConfig) {
1075
1129
  continue;
1076
1130
  }
1077
1131
 
1078
1132
  // Get resource configuration
1079
- const config = resourceConfigs[name] || {
1133
+ const resourceConfig = rawConfig && typeof rawConfig === 'object' ? { ...rawConfig } : {
1080
1134
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
1081
1135
  auth: false
1082
1136
  };
@@ -1084,8 +1138,32 @@ For detailed information about each endpoint, see the sections below.`;
1084
1138
  // Determine version
1085
1139
  const version = resource.config?.currentVersion || resource.version || 'v1';
1086
1140
 
1141
+ // Determine version prefix (same logic as server.js)
1142
+ let versionPrefixConfig;
1143
+ if (resourceConfig.versionPrefix !== undefined) {
1144
+ versionPrefixConfig = resourceConfig.versionPrefix;
1145
+ } else if (resource.config && resource.config.versionPrefix !== undefined) {
1146
+ versionPrefixConfig = resource.config.versionPrefix;
1147
+ } else if (globalVersionPrefix !== undefined) {
1148
+ versionPrefixConfig = globalVersionPrefix;
1149
+ } else {
1150
+ versionPrefixConfig = false;
1151
+ }
1152
+
1153
+ let prefix = '';
1154
+ if (versionPrefixConfig === true) {
1155
+ prefix = version;
1156
+ } else if (versionPrefixConfig === false) {
1157
+ prefix = '';
1158
+ } else if (typeof versionPrefixConfig === 'string') {
1159
+ prefix = versionPrefixConfig;
1160
+ }
1161
+
1087
1162
  // Generate paths
1088
- const paths = generateResourcePaths(resource, version, config);
1163
+ const paths = generateResourcePaths(resource, version, {
1164
+ ...resourceConfig,
1165
+ versionPrefix: versionPrefixConfig
1166
+ });
1089
1167
 
1090
1168
  // Merge paths
1091
1169
  Object.assign(spec.paths, paths);
@@ -1115,7 +1193,7 @@ For detailed information about each endpoint, see the sections below.`;
1115
1193
  }
1116
1194
 
1117
1195
  // Check if relation should be exposed (default: yes)
1118
- const exposeRelation = config?.relations?.[relationName]?.expose !== false;
1196
+ const exposeRelation = resourceConfig?.relations?.[relationName]?.expose !== false;
1119
1197
  if (!exposeRelation) {
1120
1198
  continue;
1121
1199
  }
@@ -1128,13 +1206,14 @@ For detailed information about each endpoint, see the sections below.`;
1128
1206
 
1129
1207
  const relatedSchema = generateResourceSchema(relatedResource);
1130
1208
 
1131
- // Generate relational paths
1209
+ // Generate relational paths (using the same prefix calculated above)
1132
1210
  const relationalPaths = generateRelationalPaths(
1133
1211
  resource,
1134
1212
  relationName,
1135
1213
  relationConfig,
1136
1214
  version,
1137
- relatedSchema
1215
+ relatedSchema,
1216
+ prefix
1138
1217
  );
1139
1218
 
1140
1219
  // Merge relational paths