webspresso 0.0.101 → 0.0.103

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 (62) hide show
  1. package/.agents/AGENTS.md +1 -1
  2. package/.agents/ARCHITECTURE.md +11 -0
  3. package/.agents/CONVENTIONS.md +4 -0
  4. package/.agents/ORM.md +13 -4
  5. package/.agents/PLUGINS.md +32 -6
  6. package/.agents/ROUTING.md +99 -2
  7. package/.agents/skills/webspresso-framework/SKILL.md +32 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
  10. package/AGENTS.md +6 -0
  11. package/README.md +20 -7
  12. package/bin/commands/add-tailwind.js +10 -0
  13. package/bin/commands/build.js +34 -0
  14. package/bin/commands/routes.js +8 -0
  15. package/core/applySchema.js +18 -1
  16. package/core/auth/jwt.js +10 -17
  17. package/core/auth/user-agent.js +61 -42
  18. package/core/compression/compression-stream.js +2 -2
  19. package/core/compression/middleware.js +1 -1
  20. package/core/compression/negotiate.js +11 -4
  21. package/core/events/dispatcher.js +56 -33
  22. package/core/openapi/orm-components.js +2 -3
  23. package/core/orm/cache/listeners.js +12 -2
  24. package/core/orm/json-fields.js +7 -1
  25. package/core/orm/model.js +99 -8
  26. package/core/orm/query-builder.js +71 -25
  27. package/core/orm/repository.js +42 -17
  28. package/core/orm/schema-helpers.js +49 -0
  29. package/core/orm/scopes.js +40 -9
  30. package/core/orm/types.js +12 -1
  31. package/core/queue/manager.js +5 -0
  32. package/core/validation/index.js +9 -3
  33. package/index.d.ts +178 -6
  34. package/index.js +71 -16
  35. package/package.json +6 -5
  36. package/plugins/admin-panel/index.js +12 -0
  37. package/plugins/audit-log/middleware.js +39 -4
  38. package/plugins/csrf/index.js +1 -1
  39. package/plugins/email/service.js +32 -1
  40. package/plugins/file-manager/api-handlers.js +10 -17
  41. package/plugins/file-manager/index.js +24 -9
  42. package/plugins/file-manager/security.js +18 -6
  43. package/plugins/index.js +2 -0
  44. package/plugins/lazy.js +258 -0
  45. package/plugins/site-analytics/bot-patterns.js +3 -0
  46. package/plugins/site-analytics/client-error-handler.js +3 -1
  47. package/plugins/site-analytics/index.js +10 -1
  48. package/plugins/site-analytics/tracking.js +245 -46
  49. package/src/api/api-loader.js +18 -2
  50. package/src/api/validation-i18n.js +231 -0
  51. package/src/discovery/route-manifest.js +197 -0
  52. package/src/discovery/scan-routes.js +84 -8
  53. package/src/file-router.js +215 -97
  54. package/src/helpers.js +139 -26
  55. package/src/njk-frontmatter.js +17 -1
  56. package/src/plugin-manager.js +46 -13
  57. package/src/routing/route-table.js +15 -5
  58. package/src/server.js +86 -15
  59. package/src/services/builtins/system.js +4 -6
  60. package/src/services/registry.js +12 -2
  61. package/src/services/validator.js +57 -32
  62. package/src/template/precompile.js +247 -0
@@ -44,8 +44,21 @@ function fileManagerPlugin(options = {}) {
44
44
  permissions = null,
45
45
  security: securityOpts = { validateMagicBytes: true, sanitizeSvg: true },
46
46
  includePage = true,
47
+ lazy: lazyOption,
47
48
  } = options;
48
49
 
50
+ if (lazyOption) {
51
+ const { lazyPlugin } = require('../lazy');
52
+ const lazyOpts = typeof lazyOption === 'object' ? lazyOption : {};
53
+ return lazyPlugin(() => fileManagerPlugin({ ...options, lazy: false }), {
54
+ name: 'file-manager',
55
+ prefix: apiPrefix,
56
+ services: ['file-manager.list', 'file-manager.upload', 'file-manager.delete', 'file-manager.create-folder'],
57
+ dependencies: { 'admin-panel': '*' },
58
+ ...lazyOpts,
59
+ });
60
+ }
61
+
49
62
  const resolvedBaseDir = path.resolve(baseDir);
50
63
 
51
64
  const handlers = createFileManagerApiHandlers({
@@ -101,15 +114,17 @@ function fileManagerPlugin(options = {}) {
101
114
  // so window.__openFileManagerPicker is available across all admin pages (models, records, rich-text)
102
115
  if (typeof adminApi.getRegistry === 'function') {
103
116
  const registry = adminApi.getRegistry();
104
- registry.configure({
105
- fileManager: {
106
- enabled: true,
107
- apiPrefix,
108
- publicBasePath,
109
- pageTitle,
110
- },
111
- });
112
- registry.registerClientComponent('files', componentCode);
117
+ if (registry) {
118
+ registry.configure({
119
+ fileManager: {
120
+ enabled: true,
121
+ apiPrefix,
122
+ publicBasePath,
123
+ pageTitle,
124
+ },
125
+ });
126
+ registry.registerClientComponent('files', componentCode);
127
+ }
113
128
  }
114
129
 
115
130
  adminApi.registerModule({
@@ -24,6 +24,16 @@ const DANGEROUS_EXTENSIONS = new Set([
24
24
  'htaccess', 'htpasswd', 'ini', 'env', 'config', 'conf', 'passwd', 'shadow',
25
25
  ]);
26
26
 
27
+ /**
28
+ * Common safe extensions allowed when passed as raw extensions (without filename dot)
29
+ */
30
+ const COMMON_SAFE_EXTENSIONS = new Set([
31
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff',
32
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf',
33
+ 'txt', 'csv', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'jsx', 'tsx',
34
+ 'sql', 'yaml', 'yml', 'zip', 'tar', 'gz', 'mp3', 'wav', 'mp4', 'mov', 'webm',
35
+ ]);
36
+
27
37
  /**
28
38
  * Known magic numbers table for high-speed signature verification.
29
39
  * Only validates when extension matches known binary format.
@@ -118,9 +128,8 @@ function validateSafeExtension(filename) {
118
128
  throw new ValidationError('Filename must have a valid extension');
119
129
  }
120
130
 
121
- // Check all dotted segments against dangerous extensions to block double extensions (e.g. payload.php.jpg)
122
- for (let i = 1; i < parts.length; i++) {
123
- const segment = parts[i];
131
+ // Check all segments against dangerous extensions to block double extensions and dangerous dotfiles (e.g. .env, payload.php.jpg)
132
+ for (const segment of parts) {
124
133
  if (DANGEROUS_EXTENSIONS.has(segment)) {
125
134
  throw new SecurityError(
126
135
  `Dangerous extension or double-extension detected: .${segment}`,
@@ -129,11 +138,14 @@ function validateSafeExtension(filename) {
129
138
  }
130
139
  }
131
140
 
132
- const finalExt = parts[parts.length - 1];
133
- if (DANGEROUS_EXTENSIONS.has(finalExt)) {
134
- throw new SecurityError(`File type .${finalExt} is forbidden for upload`, 403);
141
+ if (parts.length < 2 && !cleanName.startsWith('.')) {
142
+ if (COMMON_SAFE_EXTENSIONS.has(parts[0])) {
143
+ return parts[0];
144
+ }
145
+ throw new ValidationError('Filename must have a valid extension');
135
146
  }
136
147
 
148
+ const finalExt = parts[parts.length - 1];
137
149
  return finalExt;
138
150
  }
139
151
 
package/plugins/index.js CHANGED
@@ -61,6 +61,8 @@ module.exports = {
61
61
  get createApiKeyMiddleware() { return require('./api-key').createApiKeyMiddleware; },
62
62
  get webhooksPlugin() { return require('./webhooks').webhooksPlugin; },
63
63
  get WebhookDispatcher() { return require('./webhooks').WebhookDispatcher; },
64
+ get lazyPlugin() { return require('./lazy').lazyPlugin; },
65
+ get defineLazyPlugin() { return require('./lazy').defineLazyPlugin; },
64
66
  };
65
67
 
66
68
 
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Webspresso On-Demand (Lazy) Plugin Wrapper
3
+ *
4
+ * Defers execution of heavy plugin initialization, filesystem scans, route mounting,
5
+ * and dependency loading until the plugin is actually accessed via:
6
+ * 1. HTTP request matching its configured URL prefix (e.g. /_admin)
7
+ * 2. Service execution (e.g. ctx.service('email.send'))
8
+ * 3. Direct inter-plugin API access (e.g. ctx.usePlugin('admin-panel'))
9
+ * 4. Template helper execution (e.g. fsy.adminHelper())
10
+ * 5. Explicit programmatic hydration (lazyPlugin.hydrate())
11
+ *
12
+ * @module plugins/lazy
13
+ */
14
+
15
+ const express = require('express');
16
+
17
+ /**
18
+ * Wraps a plugin definition or factory function into an on-demand lazy plugin.
19
+ *
20
+ * @param {Function|Object} pluginFactory - Factory `() => plugin` or plugin definition object
21
+ * @param {Object} [lazyOptions={}]
22
+ * @param {string} [lazyOptions.name] - Plugin name (inferred if not provided)
23
+ * @param {string|string[]} [lazyOptions.prefix] - URL path prefix(es) that trigger on-demand hydration (e.g. '/_admin')
24
+ * @param {string[]} [lazyOptions.services=[]] - Service names that trigger on-demand hydration
25
+ * @param {string[]} [lazyOptions.helpers=[]] - Template helpers that trigger on-demand hydration
26
+ * @param {string[]} [lazyOptions.events=[]] - Event names that trigger on-demand hydration
27
+ * @param {Object} [lazyOptions.dependencies] - Plugin dependencies
28
+ * @param {Object} [lazyOptions.csp] - Content Security Policy requirements
29
+ * @param {Function} [lazyOptions.onHydrate] - Callback invoked when hydration finishes
30
+ * @returns {Object} Webspresso Lazy Plugin definition
31
+ */
32
+ function lazyPlugin(pluginFactory, lazyOptions = {}) {
33
+ let realPlugin = null;
34
+ let isHydrated = false;
35
+ let isHydrating = false;
36
+
37
+ let regCtx = null;
38
+ let routesCtx = null;
39
+ const router = express.Router();
40
+
41
+ const name =
42
+ lazyOptions.name ||
43
+ (typeof pluginFactory === 'object' && pluginFactory ? pluginFactory.name : null) ||
44
+ 'anonymous-lazy-plugin';
45
+
46
+ const rawPrefixes = lazyOptions.prefix
47
+ ? (Array.isArray(lazyOptions.prefix) ? lazyOptions.prefix : [lazyOptions.prefix])
48
+ : [];
49
+ // Normalize prefixes: ensure leading slash, remove trailing slash (except root '/')
50
+ const prefixes = rawPrefixes.map(p => {
51
+ let norm = String(p).trim();
52
+ if (!norm.startsWith('/')) norm = '/' + norm;
53
+ if (norm.length > 1 && norm.endsWith('/')) norm = norm.slice(0, -1);
54
+ return norm;
55
+ });
56
+
57
+ const lazyServices = Array.isArray(lazyOptions.services) ? lazyOptions.services : [];
58
+ const lazyHelpers = Array.isArray(lazyOptions.helpers) ? lazyOptions.helpers : [];
59
+ const lazyEvents = Array.isArray(lazyOptions.events) ? lazyOptions.events : [];
60
+
61
+ function hydrate() {
62
+ if (isHydrated) return realPlugin;
63
+ if (isHydrating) return realPlugin;
64
+ isHydrating = true;
65
+
66
+ try {
67
+ realPlugin = typeof pluginFactory === 'function' ? pluginFactory() : pluginFactory;
68
+ if (!realPlugin) {
69
+ throw new Error(`[lazyPlugin] Factory for plugin "${name}" did not return a valid plugin object`);
70
+ }
71
+
72
+ // 1. Call setup hook if present
73
+ if (typeof realPlugin.setup === 'function' && regCtx?.app) {
74
+ const disposer = realPlugin.setup(regCtx.app);
75
+ if (typeof disposer === 'function' && regCtx.onDispose) {
76
+ regCtx.onDispose(disposer);
77
+ }
78
+ }
79
+
80
+ // 2. Call register hook
81
+ if (typeof realPlugin.register === 'function' && regCtx) {
82
+ const disposer = realPlugin.register(regCtx);
83
+ if (typeof disposer === 'function' && regCtx.onDispose) {
84
+ regCtx.onDispose(disposer);
85
+ }
86
+ }
87
+
88
+ // 3. Register real routes onto the lazy router
89
+ if (typeof realPlugin.onRoutesReady === 'function' && routesCtx) {
90
+ const customRoutesCtx = {
91
+ ...routesCtx,
92
+ addRoute(method, routePath, ...handlers) {
93
+ const m = String(method).toLowerCase();
94
+ let matchedPrefix = null;
95
+ for (const p of prefixes) {
96
+ if (routePath === p || routePath.startsWith(p + '/')) {
97
+ matchedPrefix = p;
98
+ break;
99
+ }
100
+ }
101
+
102
+ if (matchedPrefix) {
103
+ // Convert absolute route path to router-relative subpath
104
+ let subPath = routePath.slice(matchedPrefix.length);
105
+ if (!subPath || !subPath.startsWith('/')) {
106
+ subPath = '/' + (subPath || '');
107
+ }
108
+ if (router[m]) {
109
+ router[m](subPath, ...handlers);
110
+ } else if (router.all) {
111
+ router.all(subPath, ...handlers);
112
+ }
113
+ } else {
114
+ // Route is outside configured prefix: mount directly on app
115
+ if (routesCtx.addRoute) {
116
+ routesCtx.addRoute(method, routePath, ...handlers);
117
+ } else if (routesCtx.app && routesCtx.app[m]) {
118
+ routesCtx.app[m](routePath, ...handlers);
119
+ }
120
+ }
121
+ },
122
+ };
123
+
124
+ const disposer = realPlugin.onRoutesReady(customRoutesCtx);
125
+ if (typeof disposer === 'function' && routesCtx.onDispose) {
126
+ routesCtx.onDispose(disposer);
127
+ }
128
+ }
129
+
130
+ // 4. Update exposed API
131
+ if (realPlugin.api && lazyWrapper.api) {
132
+ Object.assign(lazyWrapper.api, realPlugin.api);
133
+ }
134
+
135
+ // 5. Fire optional callback
136
+ if (typeof lazyOptions.onHydrate === 'function') {
137
+ try {
138
+ lazyOptions.onHydrate(realPlugin);
139
+ } catch (_) {}
140
+ }
141
+
142
+ isHydrated = true;
143
+ return realPlugin;
144
+ } finally {
145
+ isHydrating = false;
146
+ }
147
+ }
148
+
149
+ // Create API Proxy that auto-hydrates on property access
150
+ const apiProxy = new Proxy({}, {
151
+ get(target, prop, receiver) {
152
+ if (prop === '_isLazyProxy') return true;
153
+ if (prop === 'hydrate') return hydrate;
154
+ if (prop === 'isHydrated') return isHydrated;
155
+ hydrate();
156
+ if (realPlugin && realPlugin.api) {
157
+ const val = Reflect.get(realPlugin.api, prop, receiver);
158
+ return typeof val === 'function' ? val.bind(realPlugin.api) : val;
159
+ }
160
+ return Reflect.get(target, prop, receiver);
161
+ },
162
+ has(target, prop) {
163
+ hydrate();
164
+ return realPlugin && realPlugin.api ? Reflect.has(realPlugin.api, prop) : Reflect.has(target, prop);
165
+ },
166
+ set(target, prop, value, receiver) {
167
+ hydrate();
168
+ if (realPlugin && realPlugin.api) {
169
+ return Reflect.set(realPlugin.api, prop, value, receiver);
170
+ }
171
+ return Reflect.set(target, prop, value, receiver);
172
+ },
173
+ });
174
+
175
+ const lazyWrapper = {
176
+ name,
177
+ version: lazyOptions.version || (typeof pluginFactory === 'object' && pluginFactory?.version) || '1.0.0',
178
+ dependencies: lazyOptions.dependencies || (typeof pluginFactory === 'object' && pluginFactory?.dependencies) || {},
179
+ csp: lazyOptions.csp || (typeof pluginFactory === 'object' && pluginFactory?.csp) || null,
180
+ isLazy: true,
181
+ get isHydrated() { return isHydrated; },
182
+ hydrate,
183
+ api: apiProxy,
184
+
185
+ register(ctx) {
186
+ regCtx = ctx;
187
+
188
+ // Register proxy services in app service registry
189
+ if (ctx.app && ctx.app.serviceRegistry && lazyServices.length > 0) {
190
+ for (const svcName of lazyServices) {
191
+ if (!ctx.app.serviceRegistry.has(svcName)) {
192
+ const proxyHandler = async (input, serviceCtx) => {
193
+ hydrate();
194
+ const realDef = ctx.app.serviceRegistry.get(svcName);
195
+ if (realDef && typeof realDef.handler === 'function' && realDef.handler !== proxyHandler) {
196
+ return typeof realDef.rawHandler === 'function'
197
+ ? realDef.rawHandler(input, serviceCtx)
198
+ : realDef.handler(input, serviceCtx);
199
+ }
200
+ return null;
201
+ };
202
+
203
+ ctx.app.serviceRegistry.register(
204
+ svcName,
205
+ { handler: proxyHandler },
206
+ { overwrite: true }
207
+ );
208
+ }
209
+ }
210
+ }
211
+
212
+ // Register proxy helpers in template engine
213
+ if (typeof ctx.addHelper === 'function' && lazyHelpers.length > 0) {
214
+ for (const helperName of lazyHelpers) {
215
+ ctx.addHelper(helperName, (...args) => {
216
+ hydrate();
217
+ const helpers = regCtx?.pluginManager?.getHelpers ? regCtx.pluginManager.getHelpers() : {};
218
+ if (typeof helpers[helperName] === 'function') {
219
+ return helpers[helperName](...args);
220
+ }
221
+ });
222
+ }
223
+ }
224
+
225
+ // Wire event triggers if eventDispatcher is available
226
+ if (ctx.app?.events && lazyEvents.length > 0) {
227
+ for (const eventName of lazyEvents) {
228
+ ctx.app.events.on(eventName, () => {
229
+ hydrate();
230
+ });
231
+ }
232
+ }
233
+ },
234
+
235
+ onRoutesReady(ctx) {
236
+ routesCtx = ctx;
237
+
238
+ if (!ctx.app) return;
239
+
240
+ // Mount lazy trigger router for each prefix
241
+ for (const prefix of prefixes) {
242
+ ctx.app.use(prefix, (req, res, next) => {
243
+ if (!isHydrated) {
244
+ hydrate();
245
+ }
246
+ router(req, res, next);
247
+ });
248
+ }
249
+ },
250
+ };
251
+
252
+ return lazyWrapper;
253
+ }
254
+
255
+ module.exports = {
256
+ lazyPlugin,
257
+ defineLazyPlugin: lazyPlugin,
258
+ };
@@ -27,9 +27,12 @@ const BOT_PATTERNS = [
27
27
  { pattern: /petalbot/i, name: 'PetalBot' },
28
28
  { pattern: /gptbot/i, name: 'GPTBot' },
29
29
  { pattern: /claudebot/i, name: 'ClaudeBot' },
30
+ { pattern: /amazonbot/i, name: 'Amazon' },
30
31
  { pattern: /anthropic/i, name: 'Anthropic' },
31
32
  { pattern: /ccbot/i, name: 'CCBot' },
32
33
  { pattern: /bytespider/i, name: 'ByteSpider' },
34
+ { pattern: /dataforseo/i, name: 'DataForSEO' },
35
+ { pattern: /yisouspider/i, name: 'Yisou' },
33
36
  { pattern: /open-research-bot/i, name: 'open-research-bot' },
34
37
  { pattern: /ptb12/i, name: 'PTB12' },
35
38
  { pattern: /curl\//i, name: 'curl' },
@@ -4,6 +4,8 @@
4
4
  * @module plugins/site-analytics/client-error-handler
5
5
  */
6
6
 
7
+ const { formatDbError } = require('./tracking');
8
+
7
9
  const DEFAULT_TABLE = 'analytics_client_errors';
8
10
 
9
11
  /**
@@ -71,7 +73,7 @@ function createErrorReportHandler(options) {
71
73
 
72
74
  res.status(202).json({ ok: true });
73
75
  } catch (e) {
74
- console.error('[site-analytics] Client error report failed:', e.message);
76
+ console.error('[site-analytics] Client error report failed:', formatDbError(e));
75
77
  res.status(500).json({ error: 'Report failed' });
76
78
  }
77
79
  };
@@ -140,6 +140,8 @@ function siteAnalyticsPlugin(options = {}) {
140
140
  }
141
141
  }
142
142
 
143
+ let trackingMiddleware = null;
144
+
143
145
  return {
144
146
  name: 'site-analytics',
145
147
  version: '1.0.0',
@@ -177,13 +179,17 @@ function siteAnalyticsPlugin(options = {}) {
177
179
  register(ctx) {
178
180
  const knex = db.knex || db;
179
181
 
180
- const trackingMiddleware = createTrackingMiddleware({
182
+ trackingMiddleware = createTrackingMiddleware({
181
183
  knex,
182
184
  excludePaths,
183
185
  trackBots,
184
186
  tableName,
185
187
  batchSize: options.batchSize ?? 20,
186
188
  flushIntervalMs: options.flushIntervalMs ?? 3000,
189
+ maxRetries: options.maxRetries ?? 3,
190
+ maxBackoffMs: options.maxBackoffMs ?? 60000,
191
+ maxQueueSize: options.maxQueueSize ?? 5000,
192
+ errorLogIntervalMs: options.errorLogIntervalMs ?? 60000,
187
193
  });
188
194
 
189
195
  ctx.app.use(trackingMiddleware);
@@ -199,15 +205,18 @@ function siteAnalyticsPlugin(options = {}) {
199
205
 
200
206
  return () => {
201
207
  stopCleanup();
208
+ if (trackingMiddleware && trackingMiddleware.stop) trackingMiddleware.stop();
202
209
  };
203
210
  },
204
211
 
205
212
  dispose() {
206
213
  stopCleanup();
214
+ if (trackingMiddleware && trackingMiddleware.stop) trackingMiddleware.stop();
207
215
  },
208
216
 
209
217
  shutdown() {
210
218
  stopCleanup();
219
+ if (trackingMiddleware && trackingMiddleware.stop) trackingMiddleware.stop();
211
220
  },
212
221
 
213
222
  onRoutesReady(ctx) {