webspresso 0.0.98 → 0.0.100

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 (88) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +2 -2
  3. package/.agents/AUTH.md +415 -0
  4. package/.agents/CLI.md +47 -0
  5. package/.agents/ERRORS.md +59 -0
  6. package/.agents/ORM.md +178 -4
  7. package/.agents/PLUGINS.md +113 -8
  8. package/.agents/QUEUE.md +92 -0
  9. package/.agents/ROUTING.md +160 -7
  10. package/.agents/STREAMING.md +35 -0
  11. package/AGENTS.md +2 -0
  12. package/README.md +145 -93
  13. package/bin/commands/add-docker.js +467 -0
  14. package/bin/commands/deploy-check.js +339 -0
  15. package/bin/webspresso.js +4 -0
  16. package/core/auth/index.js +123 -1
  17. package/core/auth/magic-link.js +170 -0
  18. package/core/auth/manager.js +477 -3
  19. package/core/auth/middleware.js +118 -5
  20. package/core/auth/oauth.js +517 -0
  21. package/core/auth/qrcode.js +499 -0
  22. package/core/auth/session-manager.js +255 -0
  23. package/core/auth/sessions.js +351 -0
  24. package/core/auth/tokens.js +1 -0
  25. package/core/auth/totp.js +507 -0
  26. package/core/auth/user-agent.js +161 -0
  27. package/core/orm/eager-loader.js +144 -10
  28. package/core/orm/index.js +10 -1
  29. package/core/orm/model.js +55 -1
  30. package/core/orm/query-builder.js +90 -4
  31. package/core/orm/repository.js +327 -16
  32. package/core/orm/scopes.js +3 -2
  33. package/core/orm/search.js +213 -0
  34. package/core/orm/types.js +6 -1
  35. package/core/queue/cron.js +544 -0
  36. package/core/queue/index.js +8 -1
  37. package/core/queue/manager.js +100 -1
  38. package/core/queue/scheduler.js +415 -0
  39. package/core/sse/index.js +19 -0
  40. package/core/sse/stream.js +430 -0
  41. package/core/ssr/stream.js +62 -2
  42. package/index.d.ts +794 -6
  43. package/index.js +48 -0
  44. package/package.json +7 -5
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  46. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  47. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  48. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  49. package/plugins/admin-panel/field-renderers/index.js +2 -0
  50. package/plugins/admin-panel/modules/admin-users.js +1 -1
  51. package/plugins/api-key/crypto.js +101 -0
  52. package/plugins/api-key/index.js +288 -0
  53. package/plugins/api-key/middleware.js +461 -0
  54. package/plugins/api-key/migration-template.js +48 -0
  55. package/plugins/api-key/model.js +50 -0
  56. package/plugins/api-key/scopes.js +125 -0
  57. package/plugins/audit-log/admin-component.js +487 -70
  58. package/plugins/audit-log/api-handlers.js +114 -0
  59. package/plugins/audit-log/index.js +24 -0
  60. package/plugins/content/client/inline-edit.js +2 -46
  61. package/plugins/cors.js +13 -2
  62. package/plugins/email/auth-bridge.js +66 -0
  63. package/plugins/email/bundled-templates.js +15 -0
  64. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  65. package/plugins/file-manager/admin-component.js +69 -53
  66. package/plugins/file-manager/index.js +25 -8
  67. package/plugins/index.js +8 -0
  68. package/plugins/oauth/index.js +338 -0
  69. package/plugins/queue/index.js +4 -0
  70. package/plugins/webhooks/admin-component.js +608 -0
  71. package/plugins/webhooks/api-handlers.js +237 -0
  72. package/plugins/webhooks/crypto.js +144 -0
  73. package/plugins/webhooks/dispatcher.js +708 -0
  74. package/plugins/webhooks/index.js +263 -0
  75. package/plugins/webhooks/matcher.js +113 -0
  76. package/plugins/webhooks/migration-template.js +75 -0
  77. package/plugins/webhooks/models.js +98 -0
  78. package/plugins/webhooks/sender.js +176 -0
  79. package/src/api/api-loader.js +1 -0
  80. package/src/bundle/index.js +2 -1
  81. package/src/discovery/file-route-parser.js +44 -1
  82. package/src/discovery/scan-routes.js +2 -2
  83. package/src/file-router.js +310 -104
  84. package/src/helpers.js +16 -1
  85. package/src/njk-frontmatter.js +3 -0
  86. package/src/routing/cascading.js +490 -0
  87. package/src/routing/middleware-resolver.js +92 -0
  88. package/src/server.js +6 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Webhook Sender Module
3
+ * Zero-dependency HTTP/HTTPS delivery using native fetch with signatures & timeouts
4
+ * @module plugins/webhooks/sender
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const crypto = require('crypto');
10
+ const { signPayload } = require('./crypto');
11
+
12
+ /**
13
+ * Send a webhook HTTP request to a target endpoint
14
+ *
15
+ * @param {Object} options
16
+ * @param {string} options.url - Destination URL
17
+ * @param {string} options.event - Event name (e.g. 'order.created')
18
+ * @param {any} options.payload - Event payload data
19
+ * @param {string} [options.secret] - Signing secret
20
+ * @param {string} [options.deliveryId] - Unique delivery ID
21
+ * @param {Object} [options.headers] - Custom headers to merge
22
+ * @param {number} [options.timeoutMs=10000] - Request timeout in milliseconds
23
+ * @param {boolean} [options.wrapPayload=true] - Wrap data in standard event envelope
24
+ * @param {number} [options.maxResponseBodyLength=2048] - Max response length to store
25
+ * @returns {Promise<{
26
+ * success: boolean,
27
+ * statusCode: number,
28
+ * responseBody: string,
29
+ * durationMs: number,
30
+ * error: string|null,
31
+ * deliveryId: string,
32
+ * timestamp: number
33
+ * }>}
34
+ */
35
+ async function sendWebhook(options) {
36
+ const {
37
+ url,
38
+ event,
39
+ payload,
40
+ secret,
41
+ deliveryId = crypto.randomUUID ? crypto.randomUUID() : `dlv_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`,
42
+ headers: customHeaders = {},
43
+ timeoutMs = 10000,
44
+ wrapPayload = true,
45
+ maxResponseBodyLength = 2048,
46
+ } = options;
47
+
48
+ if (!url || typeof url !== 'string') {
49
+ return {
50
+ success: false,
51
+ statusCode: 0,
52
+ responseBody: '',
53
+ durationMs: 0,
54
+ error: 'Invalid webhook URL: destination URL is required.',
55
+ deliveryId,
56
+ timestamp: Math.floor(Date.now() / 1000),
57
+ };
58
+ }
59
+
60
+ const timestamp = Math.floor(Date.now() / 1000);
61
+
62
+ // Construct request body
63
+ let bodyObj = payload;
64
+ if (wrapPayload && (typeof payload !== 'object' || payload === null || !payload.event)) {
65
+ bodyObj = {
66
+ id: deliveryId,
67
+ event,
68
+ timestamp,
69
+ data: payload,
70
+ };
71
+ }
72
+ const bodyStr = typeof bodyObj === 'string' ? bodyObj : JSON.stringify(bodyObj);
73
+
74
+ // Calculate cryptographic signatures if secret is configured
75
+ let signatureHeaders = {};
76
+ if (secret) {
77
+ const { signature, timestampedSignature } = signPayload(bodyStr, secret, { timestamp });
78
+ signatureHeaders = {
79
+ 'x-webspresso-signature': signature,
80
+ 'x-hub-signature-256': signature,
81
+ 'webhook-signature': timestampedSignature,
82
+ };
83
+ }
84
+
85
+ // Parse custom headers if stored as JSON string
86
+ let parsedCustomHeaders = {};
87
+ if (typeof customHeaders === 'string') {
88
+ try {
89
+ parsedCustomHeaders = JSON.parse(customHeaders);
90
+ } catch (e) {
91
+ parsedCustomHeaders = {};
92
+ }
93
+ } else if (typeof customHeaders === 'object' && customHeaders !== null) {
94
+ parsedCustomHeaders = customHeaders;
95
+ }
96
+
97
+ const requestHeaders = {
98
+ 'content-type': 'application/json',
99
+ 'accept': 'application/json, text/plain, */*',
100
+ 'user-agent': 'Webspresso-Webhooks/1.0',
101
+ 'x-webspresso-event': event,
102
+ 'x-webspresso-delivery': deliveryId,
103
+ 'x-webspresso-timestamp': String(timestamp),
104
+ 'webhook-id': deliveryId,
105
+ 'webhook-timestamp': String(timestamp),
106
+ ...signatureHeaders,
107
+ ...parsedCustomHeaders,
108
+ };
109
+
110
+ const startTime = Date.now();
111
+ let controller = null;
112
+ let timeoutId = null;
113
+
114
+ try {
115
+ controller = new AbortController();
116
+ timeoutId = setTimeout(() => {
117
+ controller.abort();
118
+ }, timeoutMs);
119
+
120
+ const response = await fetch(url, {
121
+ method: 'POST',
122
+ headers: requestHeaders,
123
+ body: bodyStr,
124
+ signal: controller.signal,
125
+ });
126
+
127
+ clearTimeout(timeoutId);
128
+ const durationMs = Date.now() - startTime;
129
+
130
+ let responseBody = '';
131
+ try {
132
+ const rawText = await response.text();
133
+ responseBody = rawText ? rawText.slice(0, maxResponseBodyLength) : '';
134
+ } catch (textErr) {
135
+ responseBody = '';
136
+ }
137
+
138
+ const success = response.status >= 200 && response.status < 300;
139
+ const error = success ? null : `HTTP ${response.status}: ${response.statusText || 'Delivery Failed'}`;
140
+
141
+ return {
142
+ success,
143
+ statusCode: response.status,
144
+ responseBody,
145
+ durationMs,
146
+ error,
147
+ deliveryId,
148
+ timestamp,
149
+ bodyStr,
150
+ };
151
+ } catch (err) {
152
+ if (timeoutId) {
153
+ clearTimeout(timeoutId);
154
+ }
155
+ const durationMs = Date.now() - startTime;
156
+ const isTimeout = err.name === 'AbortError' || err.code === 'ABORT_ERR';
157
+ const errorMsg = isTimeout
158
+ ? `Webhook delivery timed out after ${timeoutMs}ms`
159
+ : (err.message || 'Network error');
160
+
161
+ return {
162
+ success: false,
163
+ statusCode: 0,
164
+ responseBody: '',
165
+ durationMs,
166
+ error: errorMsg,
167
+ deliveryId,
168
+ timestamp,
169
+ bodyStr,
170
+ };
171
+ }
172
+ }
173
+
174
+ module.exports = {
175
+ sendWebhook,
176
+ };
@@ -198,6 +198,7 @@ function createApiHandler(descriptor, context) {
198
198
  config: appOptions,
199
199
  logger: console,
200
200
  db,
201
+ sse: (sourceOrOptions, maybeOptions) => res.sse(sourceOrOptions, maybeOptions),
201
202
  };
202
203
 
203
204
  req.ctx = apiCtx;
@@ -132,7 +132,8 @@ function expandGlob(pattern, cwd = process.cwd()) {
132
132
 
133
133
  const isRecursive = normalized.includes('**');
134
134
  const regexPattern = '^' + normalized
135
- .replace(/\./g, '\\.')
135
+ .replace(/\\/g, '\\\\')
136
+ .replace(/[.+?^${}()|[\]]/g, '\\$&')
136
137
  .replace(/\*\*\//g, '(.+/)?')
137
138
  .replace(/\*\*/g, '.*')
138
139
  .replace(/\*/g, '[^/]*') + '$';
@@ -158,8 +158,11 @@ function parseFileRoute(relativePath, options = {}) {
158
158
  // Replace last segment with cleanFilename
159
159
  const routeSegments = [...segments.slice(0, -1), cleanFilename];
160
160
 
161
+ // Filter out route group segments (e.g. (marketing), (auth))
162
+ const filteredSegments = routeSegments.filter((seg) => !isRouteGroupSegment(seg));
163
+
161
164
  // Process dynamic brackets in segments
162
- const transformedSegments = routeSegments.map(transformDynamicSegment);
165
+ const transformedSegments = filteredSegments.map(transformDynamicSegment);
163
166
 
164
167
  // Handle 'index' at the end of path
165
168
  if (transformedSegments[transformedSegments.length - 1] === 'index') {
@@ -190,8 +193,48 @@ function parseFileRoute(relativePath, options = {}) {
190
193
  };
191
194
  }
192
195
 
196
+ /**
197
+ * Checks if a path segment is a route group, e.g. '(marketing)', '(auth)'
198
+ * @param {string} segment
199
+ * @returns {boolean}
200
+ */
201
+ function isRouteGroupSegment(segment) {
202
+ return (
203
+ typeof segment === 'string' &&
204
+ segment.length > 2 &&
205
+ segment.charCodeAt(0) === 40 /* '(' */ &&
206
+ segment.charCodeAt(segment.length - 1) === 41 /* ')' */
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Checks if a file path belongs to an API route, accounting for optional parent route groups.
212
+ * E.g., 'api/users.get.js' -> true
213
+ * 'api/(v1)/users.get.js' -> true
214
+ * '(marketing)/api/newsletter.post.js' -> true
215
+ * 'about.njk' -> false
216
+ * @param {string} file
217
+ * @returns {boolean}
218
+ */
219
+ function isApiRouteFile(file) {
220
+ if (!file || typeof file !== 'string') return false;
221
+ const normalized = file.split(/[/\\]/).join('/');
222
+ if (normalized.startsWith('api/')) return true;
223
+ const parts = normalized.split('/');
224
+ const apiIndex = parts.indexOf('api');
225
+ if (apiIndex > 0) {
226
+ const prefix = parts.slice(0, apiIndex);
227
+ if (prefix.every(isRouteGroupSegment)) {
228
+ return true;
229
+ }
230
+ }
231
+ return false;
232
+ }
233
+
193
234
  module.exports = {
194
235
  parseFileRoute,
236
+ isRouteGroupSegment,
237
+ isApiRouteFile,
195
238
  isPrivateOrIgnored,
196
239
  normalizePrefix,
197
240
  transformDynamicSegment,
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { parseFileRoute, isPrivateOrIgnored } = require('./file-route-parser');
11
+ const { parseFileRoute, isPrivateOrIgnored, isApiRouteFile } = require('./file-route-parser');
12
12
  const { discoverModuleDetails } = require('../modules/module-discovery');
13
13
 
14
14
  const SUPPORTED_EXTENSIONS = new Set(['.js', '.njk']);
@@ -99,7 +99,7 @@ function scanRoutes(options = {}) {
99
99
  }
100
100
 
101
101
  // Skip api directory inside pages (handled by classic mountPages or explicit api scan)
102
- const isUnderApi = relativePath.startsWith('api' + path.sep) || relativePath.startsWith('api/');
102
+ const isUnderApi = isApiRouteFile(relativePath);
103
103
  if (isUnderApi) {
104
104
  continue;
105
105
  }