webspresso 0.0.89 → 0.0.91

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 (102) hide show
  1. package/README.md +450 -53
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/manager.js +36 -26
  5. package/core/auth/middleware.js +21 -2
  6. package/core/compression/compressible.js +51 -0
  7. package/core/compression/compression-stream.js +59 -0
  8. package/core/compression/index.js +25 -0
  9. package/core/compression/middleware.js +301 -0
  10. package/core/compression/negotiate.js +153 -0
  11. package/core/errors/base.js +31 -0
  12. package/core/errors/domain.js +193 -0
  13. package/core/errors/http.js +133 -0
  14. package/core/errors/index.js +77 -0
  15. package/core/errors/middleware.js +162 -0
  16. package/core/errors/normalize.js +171 -0
  17. package/core/orm/complexity.js +120 -0
  18. package/core/orm/events.js +64 -14
  19. package/core/orm/index.js +45 -10
  20. package/core/orm/model.js +10 -2
  21. package/core/orm/query-builder.js +30 -4
  22. package/core/orm/repository.js +57 -25
  23. package/core/orm/scopes.js +2 -1
  24. package/core/orm/transaction.js +39 -5
  25. package/core/orm/utils.js +6 -3
  26. package/core/queue/adapters/database.js +284 -0
  27. package/core/queue/adapters/memory.js +155 -0
  28. package/core/queue/adapters/redis.js +336 -0
  29. package/core/queue/index.js +21 -0
  30. package/core/queue/job.js +103 -0
  31. package/core/queue/manager.js +387 -0
  32. package/core/realtime/adapters/redis.js +431 -0
  33. package/core/realtime/adapters/socket-io.js +203 -0
  34. package/core/realtime/adapters/sse.js +210 -0
  35. package/core/realtime/adapters/websocket.js +337 -0
  36. package/core/realtime/auth-manager.js +140 -0
  37. package/core/realtime/core.js +359 -0
  38. package/core/realtime/emitter.js +98 -0
  39. package/core/realtime/errors.js +71 -0
  40. package/core/realtime/index.js +79 -0
  41. package/core/realtime/reconnect-manager.js +118 -0
  42. package/core/realtime/registry.js +243 -0
  43. package/core/shutdown/http-adapter.js +134 -0
  44. package/core/shutdown/index.js +12 -0
  45. package/core/shutdown/shutdown-manager.js +312 -0
  46. package/core/ssr/index.js +13 -0
  47. package/core/ssr/stream.js +174 -0
  48. package/index.d.ts +829 -52
  49. package/index.js +103 -0
  50. package/package.json +22 -2
  51. package/plugins/admin-panel/api.js +37 -4
  52. package/plugins/admin-panel/app.js +67 -17
  53. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  54. package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
  55. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  56. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  57. package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
  58. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  59. package/plugins/admin-panel/core/admin-module.js +2 -2
  60. package/plugins/admin-panel/core/api-extensions.js +149 -0
  61. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  62. package/plugins/admin-panel/index.js +131 -18
  63. package/plugins/admin-panel/modules/admin-users.js +770 -0
  64. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  65. package/plugins/admin-panel/modules/menu.js +85 -0
  66. package/plugins/admin-panel/modules/user-management.js +5 -2
  67. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  68. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  69. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  70. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  71. package/plugins/basic-auth/index.js +293 -0
  72. package/plugins/content/client/inline-edit.js +41 -17
  73. package/plugins/cors.js +12 -8
  74. package/plugins/csrf/index.js +8 -8
  75. package/plugins/data-exchange/export-xlsx.js +5 -1
  76. package/plugins/data-exchange/import.js +16 -2
  77. package/plugins/data-exchange/index.js +14 -0
  78. package/plugins/email/index.js +14 -1
  79. package/plugins/index.js +29 -0
  80. package/plugins/queue/index.js +108 -0
  81. package/plugins/realtime/index.js +150 -0
  82. package/plugins/recaptcha/index.js +11 -0
  83. package/plugins/redirect/index.js +9 -1
  84. package/plugins/rest-resources/index.js +280 -11
  85. package/plugins/upload/index.js +16 -0
  86. package/src/app-context.js +46 -6
  87. package/src/file-router.js +220 -28
  88. package/src/helpers.js +24 -5
  89. package/src/plugin-manager.js +134 -7
  90. package/src/server.js +470 -44
  91. package/src/services/builtins/auth.js +273 -0
  92. package/src/services/builtins/exchange.js +229 -0
  93. package/src/services/builtins/mail.js +134 -0
  94. package/src/services/builtins/media.js +196 -0
  95. package/src/services/builtins/system.js +221 -0
  96. package/src/services/discovery.js +140 -0
  97. package/src/services/executor.js +292 -0
  98. package/src/services/index.js +70 -0
  99. package/src/services/memoize.js +256 -0
  100. package/src/services/registry.js +288 -0
  101. package/src/services/validator.js +281 -0
  102. package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
@@ -0,0 +1,133 @@
1
+ /**
2
+ * HTTP Exception Hierarchy
3
+ * @module core/errors/http
4
+ */
5
+
6
+ const { WebspressoError } = require('./base');
7
+
8
+ /**
9
+ * Base HTTP Error
10
+ */
11
+ class HttpError extends WebspressoError {
12
+ /**
13
+ * @param {number} status - HTTP status code
14
+ * @param {string} [message] - Error message
15
+ * @param {Object} [options]
16
+ * @param {string} [options.code] - Machine-readable error code
17
+ * @param {unknown} [options.details] - Additional contextual details
18
+ * @param {unknown} [options.cause] - Underlying root cause error
19
+ * @param {Record<string, string>} [options.headers] - Response headers to attach
20
+ * @param {boolean} [options.expose] - Whether to expose message to clients in production
21
+ */
22
+ constructor(status = 500, message = 'Internal Server Error', options = {}) {
23
+ super(message, options);
24
+
25
+ this.status = typeof status === 'number' ? status : 500;
26
+ this.headers = options.headers || {};
27
+ this.expose = options.expose !== undefined ? Boolean(options.expose) : this.status < 500;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * 400 Bad Request
33
+ */
34
+ class BadRequestError extends HttpError {
35
+ constructor(message = 'Bad Request', options = {}) {
36
+ super(400, message, { code: 'BAD_REQUEST', ...options });
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 401 Unauthorized
42
+ */
43
+ class UnauthorizedError extends HttpError {
44
+ constructor(message = 'Unauthorized', options = {}) {
45
+ super(401, message, { code: 'UNAUTHORIZED', ...options });
46
+ }
47
+ }
48
+
49
+ /**
50
+ * 403 Forbidden
51
+ */
52
+ class ForbiddenError extends HttpError {
53
+ constructor(message = 'Forbidden', options = {}) {
54
+ super(403, message, { code: 'FORBIDDEN', ...options });
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 404 Not Found
60
+ */
61
+ class NotFoundError extends HttpError {
62
+ constructor(message = 'Not Found', options = {}) {
63
+ super(404, message, { code: 'NOT_FOUND', ...options });
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 405 Method Not Allowed
69
+ */
70
+ class MethodNotAllowedError extends HttpError {
71
+ constructor(message = 'Method Not Allowed', options = {}) {
72
+ super(405, message, { code: 'METHOD_NOT_ALLOWED', ...options });
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 409 Conflict
78
+ */
79
+ class ConflictError extends HttpError {
80
+ constructor(message = 'Conflict', options = {}) {
81
+ super(409, message, { code: 'CONFLICT', ...options });
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 413 Payload Too Large
87
+ */
88
+ class PayloadTooLargeError extends HttpError {
89
+ constructor(message = 'Payload Too Large', options = {}) {
90
+ super(413, message, { code: 'PAYLOAD_TOO_LARGE', ...options });
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 415 Unsupported Media Type
96
+ */
97
+ class UnsupportedMediaTypeError extends HttpError {
98
+ constructor(message = 'Unsupported Media Type', options = {}) {
99
+ super(415, message, { code: 'UNSUPPORTED_MEDIA_TYPE', ...options });
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 422 Unprocessable Entity
105
+ */
106
+ class UnprocessableEntityError extends HttpError {
107
+ constructor(message = 'Unprocessable Entity', options = {}) {
108
+ super(422, message, { code: 'UNPROCESSABLE_ENTITY', ...options });
109
+ }
110
+ }
111
+
112
+ /**
113
+ * 429 Too Many Requests
114
+ */
115
+ class TooManyRequestsError extends HttpError {
116
+ constructor(message = 'Too Many Requests', options = {}) {
117
+ super(429, message, { code: 'TOO_MANY_REQUESTS', ...options });
118
+ }
119
+ }
120
+
121
+ module.exports = {
122
+ HttpError,
123
+ BadRequestError,
124
+ UnauthorizedError,
125
+ ForbiddenError,
126
+ NotFoundError,
127
+ MethodNotAllowedError,
128
+ ConflictError,
129
+ PayloadTooLargeError,
130
+ UnsupportedMediaTypeError,
131
+ UnprocessableEntityError,
132
+ TooManyRequestsError,
133
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Webspresso Exceptions & Error Handling Module
3
+ * @module core/errors
4
+ */
5
+
6
+ const { WebspressoError } = require('./base');
7
+ const {
8
+ HttpError,
9
+ BadRequestError,
10
+ UnauthorizedError,
11
+ ForbiddenError,
12
+ NotFoundError,
13
+ MethodNotAllowedError,
14
+ ConflictError,
15
+ PayloadTooLargeError,
16
+ UnsupportedMediaTypeError,
17
+ UnprocessableEntityError,
18
+ TooManyRequestsError,
19
+ } = require('./http');
20
+ const {
21
+ ValidationError,
22
+ ConfigurationError,
23
+ PluginError,
24
+ SecurityError,
25
+ RequestError,
26
+ RequestAbortedError,
27
+ RouterError,
28
+ RouteNotFoundError,
29
+ RouteGenerationError,
30
+ } = require('./domain');
31
+ const {
32
+ getStatusTitle,
33
+ normalizeError,
34
+ toErrorResponseObject,
35
+ } = require('./normalize');
36
+ const {
37
+ preferJsonErrorResponse,
38
+ createCentralErrorHandler,
39
+ renderDefaultErrorHtml,
40
+ } = require('./middleware');
41
+
42
+ module.exports = {
43
+ // Base
44
+ WebspressoError,
45
+
46
+ // HTTP Exceptions
47
+ HttpError,
48
+ BadRequestError,
49
+ UnauthorizedError,
50
+ ForbiddenError,
51
+ NotFoundError,
52
+ MethodNotAllowedError,
53
+ ConflictError,
54
+ PayloadTooLargeError,
55
+ UnsupportedMediaTypeError,
56
+ UnprocessableEntityError,
57
+ TooManyRequestsError,
58
+
59
+ // Domain Exceptions
60
+ ValidationError,
61
+ ConfigurationError,
62
+ PluginError,
63
+ SecurityError,
64
+ RequestError,
65
+ RequestAbortedError,
66
+ RouterError,
67
+ RouteNotFoundError,
68
+ RouteGenerationError,
69
+
70
+ // Utilities & Middleware
71
+ getStatusTitle,
72
+ normalizeError,
73
+ toErrorResponseObject,
74
+ preferJsonErrorResponse,
75
+ createCentralErrorHandler,
76
+ renderDefaultErrorHtml,
77
+ };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Central Error Boundary Middleware
3
+ * @module core/errors/middleware
4
+ */
5
+
6
+ const { normalizeError, toErrorResponseObject } = require('./normalize');
7
+ const { RequestAbortedError } = require('./domain');
8
+
9
+ /**
10
+ * Check if the request prefers a JSON error response
11
+ * @param {import('express').Request} req
12
+ * @returns {boolean}
13
+ */
14
+ function preferJsonErrorResponse(req) {
15
+ if (req.path.startsWith('/api/') || req.path.startsWith('/_admin/api/')) {
16
+ return true;
17
+ }
18
+ const accept = req.headers['accept'] || '';
19
+ if (accept.includes('application/json') && !accept.includes('text/html')) {
20
+ return true;
21
+ }
22
+ return req.xhr || req.headers['x-requested-with'] === 'XMLHttpRequest';
23
+ }
24
+
25
+ /**
26
+ * Default HTML error renderer fallback
27
+ * @param {import('./http').HttpError|Error} err
28
+ * @param {boolean} isDev
29
+ * @returns {string}
30
+ */
31
+ function renderDefaultErrorHtml(err, isDev) {
32
+ const status = err.status || 500;
33
+ const message = (err.expose !== false || isDev) ? (err.message || 'Internal Server Error') : 'Internal Server Error';
34
+ const stackHtml = isDev && err.stack
35
+ ? `<pre style="background:#1e293b;color:#f87171;padding:16px;border-radius:8px;overflow:auto;font-size:13px;line-height:1.5;">${escapeHtml(err.stack)}</pre>`
36
+ : '';
37
+
38
+ return `<!DOCTYPE html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="UTF-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
43
+ <title>${status} - ${escapeHtml(message)}</title>
44
+ <style>
45
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #f8fafc; margin: 0; padding: 40px 20px; display: flex; justify-content: center; }
46
+ .card { background: #1e293b; padding: 32px; border-radius: 12px; max-width: 650px; width: 100%; border: 1px solid #334155; }
47
+ h1 { margin-top: 0; color: #ef4444; font-size: 24px; }
48
+ p { color: #cbd5e1; line-height: 1.6; }
49
+ a { color: #38bdf8; text-decoration: none; }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <div class="card">
54
+ <h1>Error ${status}</h1>
55
+ <p>${escapeHtml(message)}</p>
56
+ ${stackHtml}
57
+ <p><a href="/">← Back to Home</a></p>
58
+ </div>
59
+ </body>
60
+ </html>`;
61
+ }
62
+
63
+ function escapeHtml(str) {
64
+ return String(str || '')
65
+ .replace(/&/g, '&amp;')
66
+ .replace(/</g, '&lt;')
67
+ .replace(/>/g, '&gt;')
68
+ .replace(/"/g, '&quot;')
69
+ .replace(/'/g, '&#39;');
70
+ }
71
+
72
+ /**
73
+ * Create Webspresso Central Error Handler Middleware
74
+ * @param {Object} [options]
75
+ * @param {boolean} [options.isDev=false]
76
+ * @param {Function} [options.getCustomHandler]
77
+ * @param {Function} [options.renderHtmlError]
78
+ * @returns {import('express').ErrorRequestHandler}
79
+ */
80
+ function createCentralErrorHandler(options = {}) {
81
+ const isDev = Boolean(options.isDev);
82
+ const getCustomHandler = typeof options.getCustomHandler === 'function' ? options.getCustomHandler : () => null;
83
+ const renderHtmlError = typeof options.renderHtmlError === 'function' ? options.renderHtmlError : null;
84
+
85
+ return async function centralErrorHandler(err, req, res, next) {
86
+ if (res.headersSent) {
87
+ return next(err);
88
+ }
89
+
90
+ // Handle Client Request Aborted
91
+ if (err instanceof RequestAbortedError || req.aborted || (req.socket && req.socket.destroyed)) {
92
+ // Do not attempt to write to closed socket
93
+ return;
94
+ }
95
+
96
+ // Normalize error
97
+ const normalized = normalizeError(err, isDev);
98
+ const status = normalized.status || 500;
99
+
100
+ // Attach custom headers from HttpError if present
101
+ if (normalized.headers && typeof normalized.headers === 'object') {
102
+ for (const [key, value] of Object.entries(normalized.headers)) {
103
+ res.setHeader(key, value);
104
+ }
105
+ }
106
+
107
+ // Check for custom error handler registered via app.setErrorHandler(...)
108
+ const customHandler = getCustomHandler();
109
+ if (typeof customHandler === 'function') {
110
+ try {
111
+ const handled = await customHandler(err, req, res, next);
112
+ if (res.headersSent || handled === true) {
113
+ return;
114
+ }
115
+ } catch (customErr) {
116
+ // Prevent infinite error loops by logging custom handler error and continuing with fallback
117
+ if (typeof console !== 'undefined' && console.error) {
118
+ console.error('[webspresso] Error in custom error handler:', customErr);
119
+ }
120
+ }
121
+ }
122
+
123
+ // Log errors based on severity
124
+ if (status >= 500) {
125
+ if (typeof console !== 'undefined' && console.error) {
126
+ console.error('[webspresso] Server Error:', err);
127
+ }
128
+ } else if (isDev && status >= 400) {
129
+ if (typeof console !== 'undefined' && console.warn) {
130
+ console.warn(`[webspresso] HTTP ${status}:`, normalized.message);
131
+ }
132
+ }
133
+
134
+ res.status(status);
135
+
136
+ // Format response (JSON vs HTML)
137
+ if (preferJsonErrorResponse(req)) {
138
+ return res.json(toErrorResponseObject(normalized, isDev));
139
+ }
140
+
141
+ if (renderHtmlError) {
142
+ try {
143
+ const html = await renderHtmlError(normalized, req, res);
144
+ if (html && !res.headersSent) {
145
+ return res.send(html);
146
+ }
147
+ } catch (renderErr) {
148
+ if (typeof console !== 'undefined' && console.error) {
149
+ console.error('[webspresso] Error rendering HTML error page:', renderErr);
150
+ }
151
+ }
152
+ }
153
+
154
+ return res.send(renderDefaultErrorHtml(normalized, isDev));
155
+ };
156
+ }
157
+
158
+ module.exports = {
159
+ preferJsonErrorResponse,
160
+ createCentralErrorHandler,
161
+ renderDefaultErrorHtml,
162
+ };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Error Normalization & Formatting Utilities
3
+ * Standardizes errors across all layers and formats safe HTTP error payloads.
4
+ * @module core/errors/normalize
5
+ */
6
+
7
+ const { WebspressoError } = require('./base');
8
+ const { HttpError } = require('./http');
9
+
10
+ const HTTP_TITLES = {
11
+ 400: 'Bad Request',
12
+ 401: 'Unauthorized',
13
+ 403: 'Forbidden',
14
+ 404: 'Not Found',
15
+ 405: 'Method Not Allowed',
16
+ 408: 'Request Timeout',
17
+ 409: 'Conflict',
18
+ 413: 'Payload Too Large',
19
+ 415: 'Unsupported Media Type',
20
+ 422: 'Unprocessable Entity',
21
+ 429: 'Too Many Requests',
22
+ 500: 'Internal Server Error',
23
+ 502: 'Bad Gateway',
24
+ 503: 'Service Unavailable',
25
+ 504: 'Gateway Timeout',
26
+ };
27
+
28
+ /**
29
+ * Get human-readable title for an HTTP status code
30
+ * @param {number} status
31
+ * @returns {string}
32
+ */
33
+ function getStatusTitle(status) {
34
+ return HTTP_TITLES[status] || (status >= 500 ? 'Internal Server Error' : 'Error');
35
+ }
36
+
37
+ /**
38
+ * Normalize any thrown value (Error, string, object, null) into a standardized HttpError or WebspressoError
39
+ * @param {unknown} err - The raw thrown error
40
+ * @param {boolean} [isDev=false] - Whether in development environment
41
+ * @returns {HttpError|WebspressoError}
42
+ */
43
+ function normalizeError(err, isDev = false) {
44
+ // Handle nullish throws
45
+ if (err === null || err === undefined) {
46
+ return new HttpError(500, 'Internal Server Error', {
47
+ expose: false,
48
+ });
49
+ }
50
+
51
+ // If already a WebspressoError / HttpError
52
+ if (err instanceof WebspressoError) {
53
+ return err;
54
+ }
55
+
56
+ // If standard JavaScript Error (TypeError, RangeError, standard Error)
57
+ if (err instanceof Error) {
58
+ const status = typeof err.status === 'number'
59
+ ? err.status
60
+ : (typeof err.statusCode === 'number' ? err.statusCode : 500);
61
+
62
+ const isClientError = status < 500;
63
+ const expose = typeof err.expose === 'boolean' ? err.expose : isClientError;
64
+
65
+ const httpErr = new HttpError(
66
+ status,
67
+ (expose || isDev) ? (err.message || getStatusTitle(status)) : getStatusTitle(status),
68
+ {
69
+ code: err.code || (status === 500 ? 'INTERNAL_SERVER_ERROR' : undefined),
70
+ details: err.details,
71
+ cause: err,
72
+ headers: err.headers,
73
+ expose,
74
+ }
75
+ );
76
+
77
+ // Retain original stack
78
+ if (err.stack) {
79
+ httpErr.stack = err.stack;
80
+ }
81
+
82
+ return httpErr;
83
+ }
84
+
85
+ // If primitive string or number thrown
86
+ if (typeof err === 'string' || typeof err === 'number' || typeof err === 'boolean') {
87
+ const msg = String(err);
88
+ return new HttpError(500, isDev ? msg : 'Internal Server Error', {
89
+ details: { rawValue: err },
90
+ expose: false,
91
+ });
92
+ }
93
+
94
+ // If plain object thrown (e.g. { message: 'Failed', status: 400 })
95
+ if (typeof err === 'object') {
96
+ const status = typeof err.status === 'number'
97
+ ? err.status
98
+ : (typeof err.statusCode === 'number' ? err.statusCode : 500);
99
+
100
+ const msg = typeof err.message === 'string' ? err.message : getStatusTitle(status);
101
+
102
+ return new HttpError(status, (status < 500 || isDev) ? msg : getStatusTitle(status), {
103
+ code: err.code,
104
+ details: err.details,
105
+ headers: err.headers,
106
+ expose: typeof err.expose === 'boolean' ? err.expose : status < 500,
107
+ });
108
+ }
109
+
110
+ return new HttpError(500, 'Internal Server Error', { expose: false });
111
+ }
112
+
113
+ /**
114
+ * Format a normalized error into a clean JSON response object conforming to contract
115
+ * @param {HttpError|WebspressoError|Error} error - Normalized error
116
+ * @param {boolean} [isDev=false] - Whether running in development mode
117
+ * @returns {Record<string, unknown>}
118
+ */
119
+ function toErrorResponseObject(error, isDev = false) {
120
+ const status = typeof error.status === 'number' ? error.status : 500;
121
+ const errorTitle = error.name === 'ValidationError'
122
+ ? 'Validation Error'
123
+ : getStatusTitle(status);
124
+
125
+ let shouldExpose;
126
+ if (typeof error.expose === 'boolean') {
127
+ shouldExpose = error.expose || isDev;
128
+ } else {
129
+ shouldExpose = isDev || status < 500;
130
+ }
131
+
132
+ const message = shouldExpose && error.message ? error.message : getStatusTitle(status);
133
+
134
+ const payload = {
135
+ status,
136
+ error: errorTitle,
137
+ message,
138
+ };
139
+
140
+ if (error.code) {
141
+ payload.code = error.code;
142
+ }
143
+
144
+ // Include validation fields if present
145
+ if (error.fields && Object.keys(error.fields).length > 0 && shouldExpose) {
146
+ payload.fields = error.fields;
147
+ }
148
+
149
+ // Include contextual details if expose is true or in dev
150
+ if (error.details !== undefined && shouldExpose) {
151
+ payload.details = error.details;
152
+ }
153
+
154
+ // Include stack and cause strictly in development mode
155
+ if (isDev) {
156
+ if (error.stack) {
157
+ payload.stack = error.stack;
158
+ }
159
+ if (error.cause) {
160
+ payload.cause = error.cause instanceof Error ? (error.cause.stack || error.cause.message) : error.cause;
161
+ }
162
+ }
163
+
164
+ return payload;
165
+ }
166
+
167
+ module.exports = {
168
+ getStatusTitle,
169
+ normalizeError,
170
+ toErrorResponseObject,
171
+ };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * ORM Query Complexity & DoS Protection
3
+ * @module core/orm/complexity
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const { BadRequestError } = require('../errors/http');
9
+
10
+ class QueryComplexityError extends BadRequestError {
11
+ /**
12
+ * @param {string} message
13
+ * @param {Object} [details]
14
+ */
15
+ constructor(message, details = {}) {
16
+ super(message || 'Query complexity limit exceeded', details);
17
+ this.name = 'QueryComplexityError';
18
+ this.code = 'QUERY_COMPLEXITY_EXCEEDED';
19
+ }
20
+ }
21
+
22
+ const DEFAULT_QUERY_LIMITS = Object.freeze({
23
+ maxLimit: 100,
24
+ defaultLimit: 15,
25
+ maxIncludes: 5,
26
+ maxFilterConditions: 25,
27
+ });
28
+
29
+ /**
30
+ * Resolve query limits for a given model
31
+ * @param {import('./types').ModelDefinition} [model]
32
+ * @returns {typeof DEFAULT_QUERY_LIMITS}
33
+ */
34
+ function resolveQueryLimits(model) {
35
+ if (!model || !model.queryLimits) {
36
+ return DEFAULT_QUERY_LIMITS;
37
+ }
38
+ return {
39
+ maxLimit: Number(model.queryLimits.maxLimit) || DEFAULT_QUERY_LIMITS.maxLimit,
40
+ defaultLimit: Number(model.queryLimits.defaultLimit) || DEFAULT_QUERY_LIMITS.defaultLimit,
41
+ maxIncludes: Number(model.queryLimits.maxIncludes) || DEFAULT_QUERY_LIMITS.maxIncludes,
42
+ maxFilterConditions: Number(model.queryLimits.maxFilterConditions) || DEFAULT_QUERY_LIMITS.maxFilterConditions,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Validate and clamp query complexity parameters against model / default limits
48
+ * @param {import('./types').ModelDefinition} [model]
49
+ * @param {Object} params
50
+ * @param {number} [params.perPage]
51
+ * @param {number} [params.limit]
52
+ * @param {string[]} [params.includes]
53
+ * @param {number} [params.filterCount]
54
+ * @param {boolean} [params.strict=false] - If true, throws QueryComplexityError instead of clamping
55
+ * @returns {{ limit: number, perPage: number, includes: string[] }}
56
+ */
57
+ function validateQueryComplexity(model, params = {}) {
58
+ const limits = resolveQueryLimits(model);
59
+ const strict = params.strict === true;
60
+
61
+ // 1. Validate includes
62
+ let includes = Array.isArray(params.includes) ? params.includes : [];
63
+ if (includes.length > limits.maxIncludes) {
64
+ if (strict) {
65
+ throw new QueryComplexityError(
66
+ `Too many eager-loaded relations (${includes.length}). Maximum allowed is ${limits.maxIncludes}.`,
67
+ { maxIncludes: limits.maxIncludes, requested: includes.length }
68
+ );
69
+ }
70
+ includes = includes.slice(0, limits.maxIncludes);
71
+ }
72
+
73
+ // 2. Validate perPage / limit
74
+ let requestedLimit = params.perPage !== undefined ? params.perPage : params.limit;
75
+ if (requestedLimit !== undefined) {
76
+ const numLimit = parseInt(requestedLimit, 10);
77
+ if (!isNaN(numLimit)) {
78
+ if (numLimit > limits.maxLimit) {
79
+ if (strict) {
80
+ throw new QueryComplexityError(
81
+ `Requested limit (${numLimit}) exceeds maximum allowed limit of ${limits.maxLimit}.`,
82
+ { maxLimit: limits.maxLimit, requested: numLimit }
83
+ );
84
+ }
85
+ requestedLimit = limits.maxLimit;
86
+ } else if (numLimit < 1) {
87
+ requestedLimit = limits.defaultLimit;
88
+ } else {
89
+ requestedLimit = numLimit;
90
+ }
91
+ } else {
92
+ requestedLimit = limits.defaultLimit;
93
+ }
94
+ } else {
95
+ requestedLimit = limits.defaultLimit;
96
+ }
97
+
98
+ // 3. Validate filter count
99
+ if (params.filterCount !== undefined && params.filterCount > limits.maxFilterConditions) {
100
+ if (strict) {
101
+ throw new QueryComplexityError(
102
+ `Too many filter conditions (${params.filterCount}). Maximum allowed is ${limits.maxFilterConditions}.`,
103
+ { maxFilterConditions: limits.maxFilterConditions, requested: params.filterCount }
104
+ );
105
+ }
106
+ }
107
+
108
+ return {
109
+ limit: requestedLimit,
110
+ perPage: requestedLimit,
111
+ includes,
112
+ };
113
+ }
114
+
115
+ module.exports = {
116
+ QueryComplexityError,
117
+ DEFAULT_QUERY_LIMITS,
118
+ resolveQueryLimits,
119
+ validateQueryComplexity,
120
+ };