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.
- package/README.md +450 -53
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/manager.js +36 -26
- package/core/auth/middleware.js +21 -2
- package/core/compression/compressible.js +51 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +301 -0
- package/core/compression/negotiate.js +153 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/orm/complexity.js +120 -0
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +45 -10
- package/core/orm/model.js +10 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/scopes.js +2 -1
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +284 -0
- package/core/queue/adapters/memory.js +155 -0
- package/core/queue/adapters/redis.js +336 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +387 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/index.d.ts +829 -52
- package/index.js +103 -0
- package/package.json +22 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +67 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
- package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +131 -18
- package/plugins/admin-panel/modules/admin-users.js +770 -0
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/content/client/inline-edit.js +41 -17
- package/plugins/cors.js +12 -8
- package/plugins/csrf/index.js +8 -8
- package/plugins/data-exchange/export-xlsx.js +5 -1
- package/plugins/data-exchange/import.js +16 -2
- package/plugins/data-exchange/index.js +14 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +29 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +220 -28
- package/src/helpers.js +24 -5
- package/src/plugin-manager.js +134 -7
- package/src/server.js +470 -44
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +140 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +288 -0
- package/src/services/validator.js +281 -0
- 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, '&')
|
|
66
|
+
.replace(/</g, '<')
|
|
67
|
+
.replace(/>/g, '>')
|
|
68
|
+
.replace(/"/g, '"')
|
|
69
|
+
.replace(/'/g, ''');
|
|
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
|
+
};
|