webspresso 0.0.90 → 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 +358 -8
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/middleware.js +9 -0
- 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 +2 -0
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- 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 +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
- 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 +17 -24
- 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 +90 -17
- 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/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 +196 -21
- package/src/helpers.js +20 -2
- package/src/plugin-manager.js +124 -4
- package/src/server.js +304 -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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Service Registry
|
|
3
|
+
* Central registry holding discovered and programmatically registered services
|
|
4
|
+
* @module src/services/registry
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { ConfigurationError, WebspressoError } = require('../../core/errors');
|
|
9
|
+
const { discoverServices } = require('./discovery');
|
|
10
|
+
const { executeService } = require('./executor');
|
|
11
|
+
const { memoize } = require('./memoize');
|
|
12
|
+
|
|
13
|
+
class ServiceRegistry {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} [options]
|
|
16
|
+
* @param {string} [options.servicesDir] - Path to services directory
|
|
17
|
+
* @param {boolean} [options.isDev=false] - Whether in development mode
|
|
18
|
+
* @param {Object} [options.logger] - Logger instance
|
|
19
|
+
*/
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.servicesDir = options.servicesDir ? path.resolve(options.servicesDir) : null;
|
|
22
|
+
this.isDev = Boolean(options.isDev);
|
|
23
|
+
this.logger = options.logger || null;
|
|
24
|
+
|
|
25
|
+
/** @type {Map<string, Object>} */
|
|
26
|
+
this.services = new Map();
|
|
27
|
+
|
|
28
|
+
/** @type {Map<string, string>} */
|
|
29
|
+
this.aliases = new Map();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Discover and load all services from servicesDir
|
|
34
|
+
* @returns {ServiceRegistry}
|
|
35
|
+
*/
|
|
36
|
+
load() {
|
|
37
|
+
if (!this.servicesDir) {
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const discovered = discoverServices(this.servicesDir);
|
|
42
|
+
|
|
43
|
+
for (const item of discovered) {
|
|
44
|
+
const { name, aliases, filePath } = item;
|
|
45
|
+
|
|
46
|
+
// Duplicate check
|
|
47
|
+
if (this.services.has(name)) {
|
|
48
|
+
const existing = this.services.get(name);
|
|
49
|
+
throw new ConfigurationError(
|
|
50
|
+
`Duplicate service registered: ${name} (files: ${existing.filePath} and ${filePath})`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// In dev mode, ensure fresh require
|
|
55
|
+
if (this.isDev) {
|
|
56
|
+
try {
|
|
57
|
+
const resolved = require.resolve(filePath);
|
|
58
|
+
delete require.cache[resolved];
|
|
59
|
+
} catch (e) {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let mod;
|
|
63
|
+
try {
|
|
64
|
+
mod = require(filePath);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
throw new ConfigurationError(`Failed to load service file '${filePath}': ${err.message}`, {
|
|
67
|
+
cause: err,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const raw = mod && mod.__esModule && mod.default ? mod.default : (mod && mod.default ? mod.default : mod);
|
|
72
|
+
const definition = typeof raw === 'function' ? { handler: raw } : raw;
|
|
73
|
+
|
|
74
|
+
if (!definition || typeof definition !== 'object') {
|
|
75
|
+
throw new ConfigurationError(
|
|
76
|
+
`Service file '${filePath}' must export an object or handler function`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof definition.handler !== 'function') {
|
|
81
|
+
throw new ConfigurationError(
|
|
82
|
+
`Service '${name}' in '${filePath}' must provide a handler function`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let handler = definition.handler;
|
|
87
|
+
let memoizeFn = null;
|
|
88
|
+
|
|
89
|
+
if (definition.cache) {
|
|
90
|
+
const memoizeOpts = typeof definition.cache === 'object' && definition.cache !== null
|
|
91
|
+
? definition.cache
|
|
92
|
+
: { ttl: definition.cache };
|
|
93
|
+
memoizeFn = memoize(definition.handler, memoizeOpts);
|
|
94
|
+
handler = memoizeFn;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const serviceDef = {
|
|
98
|
+
name,
|
|
99
|
+
filePath,
|
|
100
|
+
schema: definition.schema || null,
|
|
101
|
+
handler,
|
|
102
|
+
rawHandler: definition.handler,
|
|
103
|
+
memoize: memoizeFn,
|
|
104
|
+
metadata: definition.metadata || {},
|
|
105
|
+
auth: definition.auth,
|
|
106
|
+
cache: definition.cache,
|
|
107
|
+
timeout: definition.timeout,
|
|
108
|
+
transaction: definition.transaction,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
this.services.set(name, serviceDef);
|
|
112
|
+
|
|
113
|
+
for (const alias of aliases) {
|
|
114
|
+
this.aliases.set(alias, name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Programmatically register a service
|
|
123
|
+
* @param {string} name - Service name (e.g. 'user.get')
|
|
124
|
+
* @param {Object|Function} definition - Service definition or handler function
|
|
125
|
+
* @returns {ServiceRegistry}
|
|
126
|
+
*/
|
|
127
|
+
register(name, definition) {
|
|
128
|
+
if (!name || typeof name !== 'string') {
|
|
129
|
+
throw new WebspressoError('Service name must be a non-empty string');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.services.has(name)) {
|
|
133
|
+
throw new ConfigurationError(`Duplicate service registered: ${name}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const def = typeof definition === 'function' ? { handler: definition } : definition;
|
|
137
|
+
|
|
138
|
+
if (!def || typeof def !== 'object' || typeof def.handler !== 'function') {
|
|
139
|
+
throw new ConfigurationError(`Service '${name}' must provide a handler function`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let handler = def.handler;
|
|
143
|
+
let memoizeFn = null;
|
|
144
|
+
|
|
145
|
+
if (def.cache) {
|
|
146
|
+
const memoizeOpts = typeof def.cache === 'object' && def.cache !== null
|
|
147
|
+
? def.cache
|
|
148
|
+
: { ttl: def.cache };
|
|
149
|
+
memoizeFn = memoize(def.handler, memoizeOpts);
|
|
150
|
+
handler = memoizeFn;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const serviceDef = {
|
|
154
|
+
name,
|
|
155
|
+
filePath: def.filePath || null,
|
|
156
|
+
schema: def.schema || null,
|
|
157
|
+
handler,
|
|
158
|
+
rawHandler: def.handler,
|
|
159
|
+
memoize: memoizeFn,
|
|
160
|
+
metadata: def.metadata || {},
|
|
161
|
+
auth: def.auth,
|
|
162
|
+
cache: def.cache,
|
|
163
|
+
timeout: def.timeout,
|
|
164
|
+
transaction: def.transaction,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
this.services.set(name, serviceDef);
|
|
168
|
+
return this;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get a service definition by name or alias
|
|
173
|
+
* @param {string} name - Service name
|
|
174
|
+
* @returns {Object|undefined}
|
|
175
|
+
*/
|
|
176
|
+
get(name) {
|
|
177
|
+
if (!name || typeof name !== 'string') {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (this.services.has(name)) {
|
|
182
|
+
return this.services.get(name);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const aliasedName = this.aliases.get(name);
|
|
186
|
+
if (aliasedName && this.services.has(aliasedName)) {
|
|
187
|
+
return this.services.get(aliasedName);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check if a service exists
|
|
195
|
+
* @param {string} name - Service name
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
has(name) {
|
|
199
|
+
return this.get(name) !== undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* List all registered service names
|
|
204
|
+
* @returns {string[]}
|
|
205
|
+
*/
|
|
206
|
+
list() {
|
|
207
|
+
return Array.from(this.services.keys());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Invalidate cached results for a specific service and input
|
|
212
|
+
* @param {string} name - Service name
|
|
213
|
+
* @param {*} [input] - Input arguments
|
|
214
|
+
* @param {Object} [ctx] - Context
|
|
215
|
+
* @returns {boolean} Whether cache was invalidated
|
|
216
|
+
*/
|
|
217
|
+
invalidate(name, input, ctx) {
|
|
218
|
+
const serviceDef = this.get(name);
|
|
219
|
+
if (serviceDef && serviceDef.memoize) {
|
|
220
|
+
return serviceDef.memoize.invalidate(input, ctx);
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Clear cache for a specific service or all services
|
|
227
|
+
* @param {string} [name] - Optional service name (omitting clears all)
|
|
228
|
+
* @returns {boolean}
|
|
229
|
+
*/
|
|
230
|
+
clearCache(name) {
|
|
231
|
+
if (name) {
|
|
232
|
+
const serviceDef = this.get(name);
|
|
233
|
+
if (serviceDef && serviceDef.memoize) {
|
|
234
|
+
serviceDef.memoize.clear();
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (const serviceDef of this.services.values()) {
|
|
241
|
+
if (serviceDef.memoize) {
|
|
242
|
+
serviceDef.memoize.clear();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Clear all service caches
|
|
250
|
+
* @returns {boolean}
|
|
251
|
+
*/
|
|
252
|
+
clearAllCaches() {
|
|
253
|
+
return this.clearCache();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Execute a service by name
|
|
258
|
+
* @param {string} name - Service name
|
|
259
|
+
* @param {*} [input={}] - Input data
|
|
260
|
+
* @param {Object} [ctx={}] - Context object
|
|
261
|
+
* @param {Object} [options={}] - Options
|
|
262
|
+
* @returns {Promise<*>}
|
|
263
|
+
*/
|
|
264
|
+
call(name, input = {}, ctx = {}, options = {}) {
|
|
265
|
+
return executeService(this, name, input, ctx, options);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Alias for call()
|
|
270
|
+
*/
|
|
271
|
+
execute(name, input = {}, ctx = {}, options = {}) {
|
|
272
|
+
return this.call(name, input, ctx, options);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Reload services from disk (useful in development)
|
|
277
|
+
* @returns {ServiceRegistry}
|
|
278
|
+
*/
|
|
279
|
+
reload() {
|
|
280
|
+
this.services.clear();
|
|
281
|
+
this.aliases.clear();
|
|
282
|
+
return this.load();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = {
|
|
287
|
+
ServiceRegistry,
|
|
288
|
+
};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Services Input Validation
|
|
3
|
+
* Powered by Zod with sync/async validation, prototype poisoning defense, and functional schema compilation
|
|
4
|
+
* @module src/services/validator
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const z = require('zod');
|
|
8
|
+
const { ValidationError } = require('../../core/errors');
|
|
9
|
+
const { extendZ } = require('../../core/orm/utils/nanoid');
|
|
10
|
+
|
|
11
|
+
/** Zod instance extended with z.nanoid() */
|
|
12
|
+
const zForServices = extendZ(z);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if object contains dangerous prototype pollution keys
|
|
16
|
+
* @param {*} obj
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function hasDangerousKeys(obj) {
|
|
20
|
+
if (obj === null || typeof obj !== 'object') {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(obj)) {
|
|
24
|
+
return obj.some(item => hasDangerousKeys(item));
|
|
25
|
+
}
|
|
26
|
+
for (const key of Object.keys(obj)) {
|
|
27
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
if (typeof obj[key] === 'object' && obj[key] !== null && hasDangerousKeys(obj[key])) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sanitize input against prototype pollution
|
|
39
|
+
* @param {*} input
|
|
40
|
+
* @returns {*} Sanitized input
|
|
41
|
+
*/
|
|
42
|
+
function sanitizeInput(input) {
|
|
43
|
+
if (input === null || typeof input !== 'object') {
|
|
44
|
+
return input;
|
|
45
|
+
}
|
|
46
|
+
if (!hasDangerousKeys(input)) {
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(input)) {
|
|
50
|
+
return input.map(item => sanitizeInput(item));
|
|
51
|
+
}
|
|
52
|
+
const clean = {};
|
|
53
|
+
for (const key of Object.keys(input)) {
|
|
54
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
clean[key] = sanitizeInput(input[key]);
|
|
58
|
+
}
|
|
59
|
+
return clean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compile a concise type string descriptor into a Zod schema
|
|
64
|
+
* @param {string} typeName - Type name descriptor (e.g. 'number', 'string', 'email', 'string?')
|
|
65
|
+
* @returns {z.ZodTypeAny}
|
|
66
|
+
*/
|
|
67
|
+
function descriptorToZod(typeName) {
|
|
68
|
+
const isOptional = typeName.endsWith('?');
|
|
69
|
+
const baseType = isOptional ? typeName.slice(0, -1).toLowerCase() : typeName.toLowerCase();
|
|
70
|
+
|
|
71
|
+
let schema;
|
|
72
|
+
switch (baseType) {
|
|
73
|
+
case 'string':
|
|
74
|
+
case 'text':
|
|
75
|
+
schema = z.string();
|
|
76
|
+
break;
|
|
77
|
+
case 'number':
|
|
78
|
+
case 'int':
|
|
79
|
+
case 'integer':
|
|
80
|
+
case 'float':
|
|
81
|
+
schema = z.number();
|
|
82
|
+
break;
|
|
83
|
+
case 'boolean':
|
|
84
|
+
case 'bool':
|
|
85
|
+
schema = z.boolean();
|
|
86
|
+
break;
|
|
87
|
+
case 'email':
|
|
88
|
+
schema = z.string().email();
|
|
89
|
+
break;
|
|
90
|
+
case 'uuid':
|
|
91
|
+
schema = z.string().uuid();
|
|
92
|
+
break;
|
|
93
|
+
case 'nanoid':
|
|
94
|
+
schema = zForServices.nanoid ? zForServices.nanoid() : z.string();
|
|
95
|
+
break;
|
|
96
|
+
case 'array':
|
|
97
|
+
schema = z.array(z.any());
|
|
98
|
+
break;
|
|
99
|
+
case 'object':
|
|
100
|
+
schema = z.record(z.any());
|
|
101
|
+
break;
|
|
102
|
+
case 'date':
|
|
103
|
+
schema = z.date().or(z.string().datetime()).or(z.string().refine(val => !Number.isNaN(Date.parse(val)), { message: 'Invalid date string' }));
|
|
104
|
+
break;
|
|
105
|
+
case 'function':
|
|
106
|
+
schema = z.function();
|
|
107
|
+
break;
|
|
108
|
+
case 'any':
|
|
109
|
+
default:
|
|
110
|
+
schema = z.any();
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return isOptional ? schema.optional() : schema;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compile service schema definition into an executable validator
|
|
119
|
+
* @param {Function|Object|null} rawSchema - Schema definition
|
|
120
|
+
* @returns {Object|Function|null} Compiled schema
|
|
121
|
+
*/
|
|
122
|
+
function compileServiceSchema(rawSchema) {
|
|
123
|
+
if (!rawSchema) return null;
|
|
124
|
+
|
|
125
|
+
// If already a Zod schema
|
|
126
|
+
if (rawSchema instanceof z.ZodType || typeof rawSchema.safeParse === 'function') {
|
|
127
|
+
return rawSchema;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// If function: check if ({ z }) schema compiler or custom validator function
|
|
131
|
+
if (typeof rawSchema === 'function') {
|
|
132
|
+
try {
|
|
133
|
+
const compiled = rawSchema({ z: zForServices });
|
|
134
|
+
if (compiled && (compiled instanceof z.ZodType || typeof compiled.safeParse === 'function')) {
|
|
135
|
+
return compiled;
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
// If invoking with { z } throws, treat as a direct validator function
|
|
139
|
+
}
|
|
140
|
+
return rawSchema;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// If object descriptor: { id: 'number', name: 'string', email: 'email' }
|
|
144
|
+
if (typeof rawSchema === 'object' && rawSchema !== null) {
|
|
145
|
+
const keys = Object.keys(rawSchema);
|
|
146
|
+
if (keys.length === 0) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const shape = {};
|
|
150
|
+
for (const [key, val] of Object.entries(rawSchema)) {
|
|
151
|
+
if (typeof val === 'string') {
|
|
152
|
+
shape[key] = descriptorToZod(val);
|
|
153
|
+
} else if (val instanceof z.ZodType || (val && typeof val.safeParse === 'function')) {
|
|
154
|
+
shape[key] = val;
|
|
155
|
+
} else if (typeof val === 'object' && val !== null) {
|
|
156
|
+
const isOpt = val.optional === true || val.required === false;
|
|
157
|
+
let base = val.type ? descriptorToZod(String(val.type)) : z.any();
|
|
158
|
+
shape[key] = isOpt ? base.optional() : base;
|
|
159
|
+
} else if (typeof val === 'function') {
|
|
160
|
+
shape[key] = z.custom(val);
|
|
161
|
+
} else {
|
|
162
|
+
shape[key] = z.any();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return z.object(shape);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return rawSchema;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Format Zod validation errors into a clean string and fields map
|
|
173
|
+
* @param {z.ZodError} zodError
|
|
174
|
+
* @param {string} serviceName
|
|
175
|
+
* @returns {{ message: string, fields: Record<string, string> }}
|
|
176
|
+
*/
|
|
177
|
+
function formatZodErrors(zodError, serviceName) {
|
|
178
|
+
const fields = {};
|
|
179
|
+
const lines = [];
|
|
180
|
+
|
|
181
|
+
for (const issue of zodError.issues || []) {
|
|
182
|
+
const pathKey = issue.path && issue.path.length > 0 ? issue.path.join('.') : '_root';
|
|
183
|
+
const fieldMsg = issue.message || 'invalid value';
|
|
184
|
+
fields[pathKey] = fieldMsg;
|
|
185
|
+
lines.push(`- ${pathKey}: ${fieldMsg}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const message = `Invalid service input for ${serviceName}:\n${lines.join('\n')}`;
|
|
189
|
+
return { message, fields };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validate service input against compiled schema (supports sync & async Zod/custom schemas)
|
|
194
|
+
* @param {Object|Function|null} schema - Schema definition
|
|
195
|
+
* @param {*} input - Input to validate
|
|
196
|
+
* @param {string} serviceName - Service name
|
|
197
|
+
* @returns {*|Promise<*>} Validated input
|
|
198
|
+
* @throws {ValidationError} When validation fails
|
|
199
|
+
*/
|
|
200
|
+
function validateServiceInput(schema, input, serviceName) {
|
|
201
|
+
const sanitized = sanitizeInput(input);
|
|
202
|
+
|
|
203
|
+
if (!schema) {
|
|
204
|
+
return sanitized;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const compiled = compileServiceSchema(schema);
|
|
208
|
+
if (!compiled) {
|
|
209
|
+
return sanitized;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 1. Zod schema validation
|
|
213
|
+
if (compiled instanceof z.ZodType || typeof compiled.safeParse === 'function') {
|
|
214
|
+
try {
|
|
215
|
+
const result = compiled.safeParse(sanitized);
|
|
216
|
+
if (!result.success) {
|
|
217
|
+
const { message, fields } = formatZodErrors(result.error, serviceName);
|
|
218
|
+
throw new ValidationError(message, {
|
|
219
|
+
fields,
|
|
220
|
+
details: result.error.issues,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return result.data;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err instanceof ValidationError) {
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
// If error occurred because of async refinements, evaluate with safeParseAsync
|
|
229
|
+
if (typeof compiled.safeParseAsync === 'function') {
|
|
230
|
+
return compiled.safeParseAsync(sanitized).then((asyncResult) => {
|
|
231
|
+
if (!asyncResult.success) {
|
|
232
|
+
const { message, fields } = formatZodErrors(asyncResult.error, serviceName);
|
|
233
|
+
throw new ValidationError(message, {
|
|
234
|
+
fields,
|
|
235
|
+
details: asyncResult.error.issues,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return asyncResult.data;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 2. Custom validation function (sync or async)
|
|
246
|
+
if (typeof compiled === 'function') {
|
|
247
|
+
try {
|
|
248
|
+
const res = compiled(sanitized);
|
|
249
|
+
if (res instanceof Promise) {
|
|
250
|
+
return res.then((asyncRes) => {
|
|
251
|
+
if (asyncRes === false) {
|
|
252
|
+
throw new ValidationError(`Invalid service input for ${serviceName}: custom validation rejected input`);
|
|
253
|
+
}
|
|
254
|
+
return typeof asyncRes === 'object' && asyncRes !== null ? asyncRes : sanitized;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (res === false) {
|
|
258
|
+
throw new ValidationError(`Invalid service input for ${serviceName}: custom validation rejected input`);
|
|
259
|
+
}
|
|
260
|
+
return typeof res === 'object' && res !== null ? res : sanitized;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (err instanceof ValidationError) {
|
|
263
|
+
throw err;
|
|
264
|
+
}
|
|
265
|
+
throw new ValidationError(`Invalid service input for ${serviceName}: ${err.message}`, {
|
|
266
|
+
cause: err,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return sanitized;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
z: zForServices,
|
|
276
|
+
compileServiceSchema,
|
|
277
|
+
descriptorToZod,
|
|
278
|
+
formatZodErrors,
|
|
279
|
+
sanitizeInput,
|
|
280
|
+
validateServiceInput,
|
|
281
|
+
};
|