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.
Files changed (94) hide show
  1. package/README.md +358 -8
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/middleware.js +9 -0
  5. package/core/compression/compressible.js +51 -0
  6. package/core/compression/compression-stream.js +59 -0
  7. package/core/compression/index.js +25 -0
  8. package/core/compression/middleware.js +301 -0
  9. package/core/compression/negotiate.js +153 -0
  10. package/core/errors/base.js +31 -0
  11. package/core/errors/domain.js +193 -0
  12. package/core/errors/http.js +133 -0
  13. package/core/errors/index.js +77 -0
  14. package/core/errors/middleware.js +162 -0
  15. package/core/errors/normalize.js +171 -0
  16. package/core/orm/complexity.js +120 -0
  17. package/core/orm/events.js +64 -14
  18. package/core/orm/index.js +45 -10
  19. package/core/orm/model.js +2 -0
  20. package/core/orm/query-builder.js +30 -4
  21. package/core/orm/repository.js +57 -25
  22. package/core/orm/transaction.js +39 -5
  23. package/core/orm/utils.js +6 -3
  24. package/core/queue/adapters/database.js +284 -0
  25. package/core/queue/adapters/memory.js +155 -0
  26. package/core/queue/adapters/redis.js +336 -0
  27. package/core/queue/index.js +21 -0
  28. package/core/queue/job.js +103 -0
  29. package/core/queue/manager.js +387 -0
  30. package/core/realtime/adapters/redis.js +431 -0
  31. package/core/realtime/adapters/socket-io.js +203 -0
  32. package/core/realtime/adapters/sse.js +210 -0
  33. package/core/realtime/adapters/websocket.js +337 -0
  34. package/core/realtime/auth-manager.js +140 -0
  35. package/core/realtime/core.js +359 -0
  36. package/core/realtime/emitter.js +98 -0
  37. package/core/realtime/errors.js +71 -0
  38. package/core/realtime/index.js +79 -0
  39. package/core/realtime/reconnect-manager.js +118 -0
  40. package/core/realtime/registry.js +243 -0
  41. package/core/shutdown/http-adapter.js +134 -0
  42. package/core/shutdown/index.js +12 -0
  43. package/core/shutdown/shutdown-manager.js +312 -0
  44. package/core/ssr/index.js +13 -0
  45. package/core/ssr/stream.js +174 -0
  46. package/index.d.ts +829 -52
  47. package/index.js +103 -0
  48. package/package.json +22 -2
  49. package/plugins/admin-panel/api.js +37 -4
  50. package/plugins/admin-panel/app.js +57 -17
  51. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  52. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  53. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  54. package/plugins/admin-panel/client/parts/08-record-list.js +17 -24
  55. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  56. package/plugins/admin-panel/core/admin-module.js +2 -2
  57. package/plugins/admin-panel/core/api-extensions.js +149 -0
  58. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  59. package/plugins/admin-panel/index.js +90 -17
  60. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  61. package/plugins/admin-panel/modules/menu.js +85 -0
  62. package/plugins/admin-panel/modules/user-management.js +5 -2
  63. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  64. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  65. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  66. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  67. package/plugins/basic-auth/index.js +293 -0
  68. package/plugins/data-exchange/export-xlsx.js +5 -1
  69. package/plugins/data-exchange/import.js +16 -2
  70. package/plugins/data-exchange/index.js +14 -0
  71. package/plugins/email/index.js +14 -1
  72. package/plugins/index.js +29 -0
  73. package/plugins/queue/index.js +108 -0
  74. package/plugins/realtime/index.js +150 -0
  75. package/plugins/recaptcha/index.js +11 -0
  76. package/plugins/redirect/index.js +9 -1
  77. package/plugins/rest-resources/index.js +280 -11
  78. package/plugins/upload/index.js +16 -0
  79. package/src/app-context.js +46 -6
  80. package/src/file-router.js +196 -21
  81. package/src/helpers.js +20 -2
  82. package/src/plugin-manager.js +124 -4
  83. package/src/server.js +304 -44
  84. package/src/services/builtins/auth.js +273 -0
  85. package/src/services/builtins/exchange.js +229 -0
  86. package/src/services/builtins/mail.js +134 -0
  87. package/src/services/builtins/media.js +196 -0
  88. package/src/services/builtins/system.js +221 -0
  89. package/src/services/discovery.js +140 -0
  90. package/src/services/executor.js +292 -0
  91. package/src/services/index.js +70 -0
  92. package/src/services/memoize.js +256 -0
  93. package/src/services/registry.js +288 -0
  94. 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
+ };