webspresso 0.0.104 → 0.0.106

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 (233) hide show
  1. package/.agents/AGENTS.md +21 -2
  2. package/.agents/ARCHITECTURE.md +23 -0
  3. package/.agents/CACHE.md +147 -0
  4. package/.agents/CLI.md +220 -8
  5. package/.agents/EARLY_HINTS.md +120 -0
  6. package/.agents/FLAGS.md +118 -0
  7. package/.agents/HEALTH.md +118 -0
  8. package/.agents/LIMITS.md +109 -0
  9. package/.agents/LOGGING.md +93 -0
  10. package/.agents/METRICS.md +127 -0
  11. package/.agents/MIGRATION_JOBS.md +95 -0
  12. package/.agents/ORM.md +155 -1
  13. package/.agents/PLUGINS.md +38 -3
  14. package/.agents/QUEUE.md +42 -0
  15. package/.agents/RATE_LIMIT.md +147 -0
  16. package/.agents/RESILIENCE.md +235 -0
  17. package/.agents/SCALE_OUT.md +109 -0
  18. package/.agents/SECRETS.md +117 -0
  19. package/AGENTS.md +16 -0
  20. package/README.md +2 -2
  21. package/bin/commands/add-aws.js +396 -0
  22. package/bin/commands/add-ci.js +523 -0
  23. package/bin/commands/add-cloudflare.js +240 -0
  24. package/bin/commands/add-cloudrun.js +381 -0
  25. package/bin/commands/add-docker.js +6 -6
  26. package/bin/commands/add-migration-job.js +396 -0
  27. package/bin/commands/assets-sync.js +209 -0
  28. package/bin/commands/client-generate.js +52 -0
  29. package/bin/commands/console.js +53 -0
  30. package/bin/commands/db-migrate.js +47 -2
  31. package/bin/commands/db-studio.js +84 -0
  32. package/bin/commands/deploy-check.js +242 -13
  33. package/bin/commands/dev.js +1 -1
  34. package/bin/commands/export.js +87 -0
  35. package/bin/commands/new.js +5 -5
  36. package/bin/commands/openapi.js +1 -1
  37. package/bin/commands/secrets-pull.js +208 -0
  38. package/bin/commands/start.js +1 -1
  39. package/bin/utils/db.js +7 -1
  40. package/bin/utils/project.js +2 -2
  41. package/bin/webspresso.js +22 -0
  42. package/core/app.js +421 -0
  43. package/core/auth/authenticate.js +93 -0
  44. package/core/auth/hash.js +5 -4
  45. package/core/auth/index.js +34 -4
  46. package/core/auth/jwt.js +5 -54
  47. package/core/auth/magic-link.js +4 -5
  48. package/core/auth/manager.js +53 -10
  49. package/core/auth/middleware.js +16 -68
  50. package/core/auth/oauth.js +8 -17
  51. package/core/auth/redis-session.js +1008 -0
  52. package/core/auth/sessions.js +156 -0
  53. package/core/auth/totp.js +2 -1
  54. package/core/cache/byte-size.js +123 -0
  55. package/core/cache/cdn.js +296 -0
  56. package/core/cache/index.js +33 -0
  57. package/core/cache/lru.js +477 -0
  58. package/core/cache/tagged-lru.js +354 -0
  59. package/core/commands/registry.js +6 -20
  60. package/core/console/commands.js +148 -0
  61. package/core/console/context.js +260 -0
  62. package/core/console/eval.js +70 -0
  63. package/core/console/index.js +22 -0
  64. package/core/console/repl.js +102 -0
  65. package/core/early-hints/index.js +267 -0
  66. package/core/errors/domain.js +49 -0
  67. package/core/errors/http.js +31 -0
  68. package/core/errors/index.js +10 -0
  69. package/core/errors/normalize.js +88 -2
  70. package/core/events/discovery.js +6 -24
  71. package/core/events/dispatcher.js +9 -7
  72. package/core/events/signal.js +7 -0
  73. package/core/export/discovery.js +191 -0
  74. package/core/export/exporter.js +277 -0
  75. package/core/export/index.js +24 -0
  76. package/core/flags/evaluator.js +252 -0
  77. package/core/flags/hasher.js +37 -0
  78. package/core/flags/index.js +21 -0
  79. package/core/flags/manager.js +263 -0
  80. package/core/flags/store.js +126 -0
  81. package/core/health/index.js +41 -0
  82. package/core/health/middleware.js +213 -0
  83. package/core/health/normalize.js +140 -0
  84. package/core/health/probes.js +203 -0
  85. package/core/http/adapter.js +74 -0
  86. package/core/http/express-adapter.js +46 -0
  87. package/core/http/hono-adapter.js +531 -0
  88. package/core/http/index.js +41 -0
  89. package/core/http/pipeline.js +485 -0
  90. package/core/index.js +336 -0
  91. package/core/limits/index.js +34 -0
  92. package/core/limits/middleware.js +325 -0
  93. package/core/limits/normalize.js +222 -0
  94. package/core/logger/formatters.js +264 -0
  95. package/core/logger/index.js +260 -0
  96. package/core/logger/trace.js +137 -0
  97. package/core/metrics/collectors.js +125 -0
  98. package/core/metrics/index.js +45 -0
  99. package/core/metrics/middleware.js +230 -0
  100. package/core/metrics/registry.js +366 -0
  101. package/core/orm/batch.js +70 -0
  102. package/core/orm/cache/fingerprint.js +2 -2
  103. package/core/orm/cache/memory-provider.js +27 -110
  104. package/core/orm/d1.js +265 -0
  105. package/core/orm/index.js +176 -13
  106. package/core/orm/json-fields.js +22 -8
  107. package/core/orm/migrate-cloud.js +194 -0
  108. package/core/orm/model.js +10 -0
  109. package/core/orm/pool.js +97 -0
  110. package/core/orm/query-builder.js +72 -9
  111. package/core/orm/repository.js +318 -19
  112. package/core/orm/schema-helpers.js +8 -10
  113. package/core/orm/scopes.js +321 -1
  114. package/core/orm/transaction.js +10 -2
  115. package/core/orm/utils.js +4 -86
  116. package/core/queue/adapters/cloudflare.js +131 -0
  117. package/core/queue/adapters/database.js +4 -8
  118. package/core/queue/adapters/pubsub.js +400 -0
  119. package/core/queue/adapters/sqs.js +419 -0
  120. package/core/queue/adapters/wait-until.js +77 -0
  121. package/core/queue/index.js +8 -0
  122. package/core/queue/manager.js +66 -41
  123. package/core/rate-limit/index.js +22 -0
  124. package/core/rate-limit/limiter.js +219 -0
  125. package/core/rate-limit/stores/memory.js +129 -0
  126. package/core/rate-limit/stores/redis.js +151 -0
  127. package/core/rate-limit/utils.js +112 -0
  128. package/core/resilience/circuit-breaker.js +422 -0
  129. package/core/resilience/errors.js +63 -0
  130. package/core/resilience/index.js +53 -0
  131. package/core/resilience/registry.js +97 -0
  132. package/core/resilience/retry.js +203 -0
  133. package/core/schema-cache.js +9 -2
  134. package/core/secrets/index.js +113 -0
  135. package/core/secrets/manager.js +308 -0
  136. package/core/secrets/providers/aws.js +288 -0
  137. package/core/secrets/providers/env.js +44 -0
  138. package/core/secrets/providers/gcp.js +307 -0
  139. package/core/secrets/providers/memory.js +76 -0
  140. package/core/shutdown/http-adapter.js +4 -1
  141. package/core/shutdown/shutdown-manager.js +129 -19
  142. package/core/stream/index.js +19 -0
  143. package/core/stream/text-stream.js +326 -0
  144. package/core/studio/index.js +43 -0
  145. package/core/studio/inspector.js +258 -0
  146. package/core/studio/server.js +325 -0
  147. package/core/studio/ui.js +681 -0
  148. package/core/utils/array.js +12 -0
  149. package/core/utils/base64url.js +8 -0
  150. package/core/utils/crypto.js +118 -0
  151. package/core/utils/ensure-leading-slash.js +6 -0
  152. package/core/utils/escape-regexp.js +7 -0
  153. package/core/utils/fetch.js +6 -5
  154. package/core/utils/fs.js +144 -0
  155. package/core/utils/hmac.js +6 -0
  156. package/core/utils/json.js +98 -0
  157. package/core/utils/object.js +106 -0
  158. package/core/utils/random.js +9 -0
  159. package/core/utils/regex.js +77 -0
  160. package/core/utils/sha256.js +6 -0
  161. package/core/utils/string.js +87 -0
  162. package/core/utils/strip-trailing-slash.js +6 -0
  163. package/core/utils/time.js +96 -0
  164. package/core/utils/timeout.js +348 -0
  165. package/core/utils/url.js +106 -0
  166. package/core/utils/walk.js +7 -0
  167. package/index.d.ts +1549 -2
  168. package/index.js +272 -2
  169. package/package.json +3 -2
  170. package/plugins/admin-panel/core/api-extensions.js +2 -5
  171. package/plugins/admin-panel/index.js +13 -6
  172. package/plugins/admin-panel/modules/menu.js +1 -1
  173. package/plugins/api-key/crypto.js +3 -3
  174. package/plugins/api-key/index.js +3 -2
  175. package/plugins/api-key/scopes.js +5 -7
  176. package/plugins/audit-log/api-handlers.js +6 -15
  177. package/plugins/audit-log/index.js +3 -1
  178. package/plugins/audit-log/parse.js +3 -3
  179. package/plugins/basic-auth/index.js +3 -2
  180. package/plugins/cors.js +3 -2
  181. package/plugins/csrf/index.js +9 -12
  182. package/plugins/csv/index.js +8 -5
  183. package/plugins/dashboard/app.js +2 -2
  184. package/plugins/dashboard/index.js +5 -5
  185. package/plugins/data-exchange/record-selection.js +2 -5
  186. package/plugins/email/api-handlers.js +5 -18
  187. package/plugins/email/auth-bridge.js +3 -2
  188. package/plugins/email/index.js +11 -9
  189. package/plugins/health-check.js +2 -1
  190. package/plugins/index.js +5 -0
  191. package/plugins/lazy.js +5 -6
  192. package/plugins/mcp/discovery.js +18 -17
  193. package/plugins/polar/src/api.js +2 -1
  194. package/plugins/polar/src/webhooks.js +5 -6
  195. package/plugins/queue/index.js +18 -2
  196. package/plugins/rate-limit/index.js +3 -1
  197. package/plugins/redirect/index.js +16 -9
  198. package/plugins/rls/index.js +170 -0
  199. package/plugins/seo-checker/index.js +2 -2
  200. package/plugins/site-analytics/index.js +3 -1
  201. package/plugins/site-analytics/tracking.js +2 -2
  202. package/plugins/sitemap.js +3 -2
  203. package/plugins/swagger.js +12 -5
  204. package/plugins/upload/gcs-provider.js +148 -0
  205. package/plugins/upload/index.js +136 -77
  206. package/plugins/upload/orphan-pruner.js +4 -3
  207. package/plugins/upload/s3-provider.js +257 -0
  208. package/plugins/webhooks/crypto.js +6 -11
  209. package/plugins/webhooks/dispatcher.js +8 -8
  210. package/plugins/webhooks/index.js +33 -23
  211. package/plugins/webhooks/matcher.js +7 -11
  212. package/plugins/webhooks/sender.js +8 -11
  213. package/plugins/xlsx/index.js +7 -5
  214. package/src/api/api-loader.js +12 -95
  215. package/src/api/define-api.js +1 -0
  216. package/src/app-context.js +41 -5
  217. package/src/client-runtime/constants.js +10 -0
  218. package/src/client-runtime/mount.js +2 -3
  219. package/src/dev/banner.js +6 -2
  220. package/src/dev/client-gen.js +458 -0
  221. package/src/discovery/scan-routes.js +5 -2
  222. package/src/file-router.js +3 -3
  223. package/src/helpers.js +212 -24
  224. package/src/modules/module-discovery.js +1 -9
  225. package/src/pages/page-loader.js +13 -154
  226. package/src/plugin-manager.js +99 -18
  227. package/src/server.js +335 -133
  228. package/src/services/discovery.js +19 -40
  229. package/src/services/executor.js +95 -25
  230. package/src/services/memoize.js +3 -25
  231. package/src/services/registry.js +15 -1
  232. package/src/services/validator.js +38 -20
  233. package/src/template/precompile.js +8 -19
@@ -6,6 +6,8 @@
6
6
  const multer = require('multer');
7
7
  const path = require('path');
8
8
  const { createLocalFileProvider } = require('./local-file-provider');
9
+ const { ensureLeadingSlash } = require('../../core/utils/url');
10
+ const { ensureArray } = require('../../core/utils/object');
9
11
 
10
12
  /**
11
13
  * @typedef {import('express').Request} ExpressRequest
@@ -37,8 +39,7 @@ const { createLocalFileProvider } = require('./local-file-provider');
37
39
  * @returns {string}
38
40
  */
39
41
  function normalizeRoutePath(p) {
40
- const s = (p || '/api/upload').trim();
41
- return s.startsWith('/') ? s : `/${s}`;
42
+ return ensureLeadingSlash((p || '/api/upload').trim());
42
43
  }
43
44
 
44
45
  /**
@@ -118,9 +119,125 @@ function uploadPlugin(options = {}) {
118
119
  publicBasePath: local?.publicBasePath,
119
120
  });
120
121
 
121
- const mw = (Array.isArray(middleware) ? middleware : [middleware]).filter(Boolean);
122
+ const mw = ensureArray(middleware).filter(Boolean);
122
123
  const parseMultipart = createMulterMiddleware(maxBytes, fieldName, multiple, maxFiles);
123
124
 
125
+ function mountRoutes(ctx) {
126
+ if (!ctx || typeof ctx.addRoute !== 'function') return;
127
+
128
+ if (ctx.app && typeof ctx.app.set === 'function') {
129
+ ctx.app.set('webspresso.uploadPath', normalizedPath);
130
+ }
131
+
132
+ const handler = (req, res) => {
133
+ parseMultipart(req, res, async (err) => {
134
+ if (err) {
135
+ const { status, message } = multerErrorResponse(err);
136
+ return res.status(status).json({ error: message, message });
137
+ }
138
+
139
+ const files = multiple ? (req.files || []) : (req.file ? [req.file] : []);
140
+ if (files.length === 0) {
141
+ return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
142
+ }
143
+
144
+ // Validate all files first
145
+ for (const file of files) {
146
+ if (!file || !file.buffer) {
147
+ return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
148
+ }
149
+
150
+ const mime = file.mimetype || 'application/octet-stream';
151
+ const originalName = file.originalname || 'upload';
152
+ const ext = extensionFromName(originalName);
153
+
154
+ if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
155
+ return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
156
+ }
157
+ if (
158
+ extensionAllowlist &&
159
+ extensionAllowlist.length &&
160
+ (!ext || !extensionAllowlist.includes(ext))
161
+ ) {
162
+ return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
163
+ }
164
+ }
165
+
166
+ try {
167
+ const uploadPromises = files.map(async (file) => {
168
+ const mime = file.mimetype || 'application/octet-stream';
169
+ const originalName = file.originalname || 'upload';
170
+ const result = await provider.put({
171
+ buffer: file.buffer,
172
+ originalName,
173
+ mimeType: mime,
174
+ size: file.size,
175
+ req,
176
+ });
177
+ return {
178
+ url: result.publicUrl,
179
+ publicUrl: result.publicUrl,
180
+ key: result.key,
181
+ };
182
+ });
183
+
184
+ const results = await Promise.all(uploadPromises);
185
+
186
+ if (multiple) {
187
+ res.json(results);
188
+ } else {
189
+ res.json(results[0]);
190
+ }
191
+ } catch (e) {
192
+ const message = e instanceof Error ? e.message : String(e);
193
+ res.status(500).json({ error: message, message });
194
+ }
195
+ });
196
+ };
197
+
198
+ ctx.addRoute('post', normalizedPath, ...mw, handler);
199
+
200
+ // Direct presigned upload endpoint for cloud storage (AWS S3, GCS, R2)
201
+ if (options.presigned) {
202
+ const presignPath = options.presignedRoute ? ensureLeadingSlash(options.presignedRoute) : `${normalizedPath}/presign`;
203
+ const presignMw = ensureArray(options.presignedMiddleware || options.middleware || []);
204
+
205
+ ctx.addRoute('post', presignPath, ...presignMw, async (req, res) => {
206
+ if (!provider || typeof provider.createSignedUploadUrl !== 'function') {
207
+ return res.status(400).json({
208
+ error: 'Configured upload provider does not support direct presigned uploads',
209
+ });
210
+ }
211
+
212
+ const { originalName, mimeType, key, expiresInSeconds, expiresInMs } = req.body || {};
213
+ if (!originalName) {
214
+ return res.status(400).json({ error: 'originalName is required in request body' });
215
+ }
216
+
217
+ try {
218
+ const result = await provider.createSignedUploadUrl({
219
+ originalName,
220
+ mimeType: mimeType || 'application/octet-stream',
221
+ key,
222
+ expiresInSeconds,
223
+ expiresInMs,
224
+ });
225
+ return res.json(result);
226
+ } catch (err) {
227
+ return res.status(500).json({ error: err.message || 'Failed to create presigned upload URL' });
228
+ }
229
+ });
230
+
231
+ if (process.env.NODE_ENV !== 'production') {
232
+ console.log(` Upload plugin: POST ${presignPath} (Presigned URL)`);
233
+ }
234
+ }
235
+
236
+ if (process.env.NODE_ENV !== 'production') {
237
+ console.log(` Upload plugin: POST ${normalizedPath}`);
238
+ }
239
+ }
240
+
124
241
  return {
125
242
  name: 'upload',
126
243
  version: '1.0.0',
@@ -159,83 +276,13 @@ function uploadPlugin(options = {}) {
159
276
  }
160
277
  }
161
278
  }
279
+ if (typeof ctx.addRoute === 'function' && !ctx.core) {
280
+ mountRoutes(ctx);
281
+ }
162
282
  },
163
283
 
164
284
  onRoutesReady(ctx) {
165
- const { app } = ctx;
166
- app.set('webspresso.uploadPath', normalizedPath);
167
-
168
- const handler = (req, res) => {
169
- parseMultipart(req, res, async (err) => {
170
- if (err) {
171
- const { status, message } = multerErrorResponse(err);
172
- return res.status(status).json({ error: message, message });
173
- }
174
-
175
- const files = multiple ? (req.files || []) : (req.file ? [req.file] : []);
176
- if (files.length === 0) {
177
- return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
178
- }
179
-
180
- // Validate all files first
181
- for (const file of files) {
182
- if (!file || !file.buffer) {
183
- return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
184
- }
185
-
186
- const mime = file.mimetype || 'application/octet-stream';
187
- const originalName = file.originalname || 'upload';
188
- const ext = extensionFromName(originalName);
189
-
190
- if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
191
- return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
192
- }
193
- if (
194
- extensionAllowlist &&
195
- extensionAllowlist.length &&
196
- (!ext || !extensionAllowlist.includes(ext))
197
- ) {
198
- return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
199
- }
200
- }
201
-
202
- try {
203
- const uploadPromises = files.map(async (file) => {
204
- const mime = file.mimetype || 'application/octet-stream';
205
- const originalName = file.originalname || 'upload';
206
- const result = await provider.put({
207
- buffer: file.buffer,
208
- originalName,
209
- mimeType: mime,
210
- size: file.size,
211
- req,
212
- });
213
- return {
214
- url: result.publicUrl,
215
- publicUrl: result.publicUrl,
216
- key: result.key,
217
- };
218
- });
219
-
220
- const results = await Promise.all(uploadPromises);
221
-
222
- if (multiple) {
223
- res.json(results);
224
- } else {
225
- res.json(results[0]);
226
- }
227
- } catch (e) {
228
- const message = e instanceof Error ? e.message : String(e);
229
- res.status(500).json({ error: message, message });
230
- }
231
- });
232
- };
233
-
234
- ctx.addRoute('post', normalizedPath, ...mw, handler);
235
-
236
- if (process.env.NODE_ENV !== 'production') {
237
- console.log(` Upload plugin: POST ${normalizedPath}`);
238
- }
285
+ mountRoutes(ctx);
239
286
  },
240
287
  };
241
288
  }
@@ -244,10 +291,22 @@ const {
244
291
  scanOrphanedUploads,
245
292
  pruneOrphanedUploads,
246
293
  } = require('./orphan-pruner');
294
+ const {
295
+ createR2FileProvider,
296
+ createS3FileProvider,
297
+ generateStorageKey,
298
+ } = require('./s3-provider');
299
+ const {
300
+ createGcsFileProvider,
301
+ } = require('./gcs-provider');
247
302
 
248
303
  module.exports = {
249
304
  uploadPlugin,
250
305
  createLocalFileProvider,
306
+ createR2FileProvider,
307
+ createS3FileProvider,
308
+ createGcsFileProvider,
309
+ generateStorageKey,
251
310
  scanOrphanedUploads,
252
311
  pruneOrphanedUploads,
253
312
  };
@@ -11,6 +11,7 @@ const fs = require('fs/promises');
11
11
  const fsSync = require('fs');
12
12
  const path = require('path');
13
13
  const { normalizePublicBase } = require('./local-file-provider');
14
+ const { safeJsonParse } = require('../../core/utils/json');
14
15
 
15
16
  /**
16
17
  * Format bytes into human readable string
@@ -137,10 +138,10 @@ async function extractDbFileReferences(db) {
137
138
  for (const [key, value] of Object.entries(row)) {
138
139
  // If value is a JSON string, try to parse
139
140
  if (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))) {
140
- try {
141
- const parsed = JSON.parse(value);
141
+ const parsed = safeJsonParse(value, null);
142
+ if (parsed) {
142
143
  scanValue(parsed);
143
- } catch (e) {
144
+ } else {
144
145
  addStringReference(value);
145
146
  }
146
147
  } else {
@@ -0,0 +1,257 @@
1
+ /**
2
+ * S3 & Cloudflare R2 Upload Storage Providers for Webspresso
3
+ * Enables direct file storage in Cloudflare R2 (Worker bindings or S3 API) and AWS S3/MinIO
4
+ * @module plugins/upload/s3-provider
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const path = require('path');
10
+ const { randomBytes } = require('crypto');
11
+ const { resolveStoredExtension } = require('./local-file-provider');
12
+
13
+ /**
14
+ * Generate a secure, unique object storage key
15
+ * @param {string} originalName
16
+ * @param {string} mimeType
17
+ * @param {string} [prefix='']
18
+ * @returns {string}
19
+ */
20
+ function generateStorageKey(originalName, mimeType, prefix = '') {
21
+ const base = path.basename(originalName || 'upload');
22
+ let ext = path.extname(base).toLowerCase();
23
+ if (!/^\.[a-z0-9]{1,12}$/.test(ext)) {
24
+ ext = '';
25
+ }
26
+ ext = resolveStoredExtension(mimeType, ext);
27
+
28
+ const cleanPrefix = prefix ? prefix.replace(/^\/+|\/+$/g, '') + '/' : '';
29
+ const uniqueName = `${Date.now()}-${randomBytes(8).toString('hex')}${ext}`;
30
+ return `${cleanPrefix}${uniqueName}`;
31
+ }
32
+
33
+ /**
34
+ * Cloudflare R2 Native Worker Storage Provider
35
+ * Zero-dependency: interacts directly with Cloudflare Workers R2Bucket binding (env.BUCKET)
36
+ * @param {Object} options
37
+ * @param {Object} options.bucket - Cloudflare R2 Bucket binding (e.g. env.BUCKET)
38
+ * @param {string} options.publicBaseUrl - Public CDN or R2 custom domain URL (e.g. 'https://cdn.example.com')
39
+ * @param {string} [options.prefix='uploads'] - Directory prefix inside bucket
40
+ * @returns {import('./index').UploadStorageProvider}
41
+ */
42
+ function createR2FileProvider(options = {}) {
43
+ const { bucket, publicBaseUrl, prefix = 'uploads' } = options;
44
+
45
+ if (!bucket) {
46
+ throw new Error('[createR2FileProvider] R2 Bucket binding (env.BUCKET) is required.');
47
+ }
48
+
49
+ const normalizedBaseUrl = (publicBaseUrl || '').replace(/\/+$/, '');
50
+
51
+ return {
52
+ /**
53
+ * Store file in Cloudflare R2
54
+ */
55
+ async put({ buffer, originalName, mimeType, size, req }) {
56
+ void size;
57
+ void req;
58
+
59
+ const key = generateStorageKey(originalName, mimeType, prefix);
60
+
61
+ if (typeof bucket.put !== 'function') {
62
+ throw new Error('[createR2FileProvider] Provided bucket binding does not implement .put() method.');
63
+ }
64
+
65
+ await bucket.put(key, buffer, {
66
+ httpMetadata: {
67
+ contentType: mimeType || 'application/octet-stream',
68
+ },
69
+ customMetadata: {
70
+ originalName: originalName || '',
71
+ uploadedAt: new Date().toISOString(),
72
+ },
73
+ });
74
+
75
+ const publicUrl = normalizedBaseUrl ? `${normalizedBaseUrl}/${key}` : `/${key}`;
76
+ return { publicUrl, key };
77
+ },
78
+
79
+ /**
80
+ * Delete file from Cloudflare R2
81
+ * @param {string} key
82
+ */
83
+ async delete(key) {
84
+ if (typeof bucket.delete === 'function') {
85
+ await bucket.delete(key);
86
+ return true;
87
+ }
88
+ return false;
89
+ },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Generic S3 Storage Provider (AWS S3, MinIO, or S3-compatible R2)
95
+ * @param {Object} options
96
+ * @param {Object} options.s3Client - S3 Client instance (e.g. @aws-sdk/client-s3 S3Client)
97
+ * @param {string} options.bucket - Bucket name
98
+ * @param {string} [options.publicBaseUrl] - Public CDN / bucket base URL
99
+ * @param {string} [options.prefix='uploads'] - Key prefix
100
+ * @returns {import('./index').UploadStorageProvider}
101
+ */
102
+ function createS3FileProvider(options = {}) {
103
+ const { s3Client, bucket, publicBaseUrl, prefix = 'uploads' } = options;
104
+
105
+ if (!s3Client || typeof s3Client.send !== 'function') {
106
+ throw new Error('[createS3FileProvider] An S3 client instance with a .send() method is required.');
107
+ }
108
+ if (!bucket) {
109
+ throw new Error('[createS3FileProvider] Bucket name is required.');
110
+ }
111
+
112
+ const normalizedBaseUrl = (publicBaseUrl || '').replace(/\/+$/, '');
113
+
114
+ return {
115
+ async put({ buffer, originalName, mimeType, size, req }) {
116
+ void size;
117
+ void req;
118
+
119
+ const key = generateStorageKey(originalName, mimeType, prefix);
120
+
121
+ // Dynamically load PutObjectCommand or construct Command payload
122
+ let command;
123
+ try {
124
+ const { PutObjectCommand } = require('@aws-sdk/client-s3');
125
+ command = new PutObjectCommand({
126
+ Bucket: bucket,
127
+ Key: key,
128
+ Body: buffer,
129
+ ContentType: mimeType || 'application/octet-stream',
130
+ });
131
+ } catch (_) {
132
+ // Fallback for custom or duck-typed S3 client wrappers
133
+ command = {
134
+ constructor: { name: 'PutObjectCommand' },
135
+ input: {
136
+ Bucket: bucket,
137
+ Key: key,
138
+ Body: buffer,
139
+ ContentType: mimeType || 'application/octet-stream',
140
+ },
141
+ };
142
+ }
143
+
144
+ await s3Client.send(command);
145
+
146
+ const publicUrl = normalizedBaseUrl ? `${normalizedBaseUrl}/${key}` : `/${key}`;
147
+ return { publicUrl, key };
148
+ },
149
+
150
+ async delete(key) {
151
+ try {
152
+ let command;
153
+ try {
154
+ const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
155
+ command = new DeleteObjectCommand({
156
+ Bucket: bucket,
157
+ Key: key,
158
+ });
159
+ } catch (_) {
160
+ command = {
161
+ constructor: { name: 'DeleteObjectCommand' },
162
+ input: {
163
+ Bucket: bucket,
164
+ Key: key,
165
+ },
166
+ };
167
+ }
168
+ await s3Client.send(command);
169
+ return true;
170
+ } catch (_) {
171
+ return false;
172
+ }
173
+ },
174
+
175
+ /**
176
+ * Generate a signed PUT URL for direct browser-to-S3 client upload
177
+ * @param {Object} [params]
178
+ * @param {string} [params.originalName]
179
+ * @param {string} [params.mimeType]
180
+ * @param {number} [params.expiresInSeconds=900] - 15 minutes default
181
+ * @param {string} [params.key] - Custom key override
182
+ * @returns {Promise<{ uploadUrl: string, publicUrl: string, key: string, method: string, headers: Record<string, string> }>}
183
+ */
184
+ async createSignedUploadUrl(params = {}) {
185
+ const {
186
+ originalName = 'upload',
187
+ mimeType = 'application/octet-stream',
188
+ expiresInSeconds = 900,
189
+ key: customKey,
190
+ } = params;
191
+
192
+ const key = customKey || generateStorageKey(originalName, mimeType, prefix);
193
+ const publicUrl = normalizedBaseUrl ? `${normalizedBaseUrl}/${key}` : `https://${bucket}.s3.amazonaws.com/${key}`;
194
+
195
+ // 1. Duck-typed client with direct getSignedUrl method (e.g. AWS SDK v2, MinIO, or mock)
196
+ if (typeof s3Client.getSignedUrl === 'function') {
197
+ const uploadUrl = await s3Client.getSignedUrl('putObject', {
198
+ Bucket: bucket,
199
+ Key: key,
200
+ ContentType: mimeType,
201
+ Expires: expiresInSeconds,
202
+ });
203
+ return {
204
+ uploadUrl,
205
+ publicUrl,
206
+ key,
207
+ method: 'PUT',
208
+ headers: { 'Content-Type': mimeType },
209
+ };
210
+ }
211
+
212
+ // 2. Custom signer function option
213
+ if (typeof options.createSignedUrl === 'function') {
214
+ const uploadUrl = await options.createSignedUrl({ bucket, key, mimeType, expiresInSeconds });
215
+ return {
216
+ uploadUrl,
217
+ publicUrl,
218
+ key,
219
+ method: 'PUT',
220
+ headers: { 'Content-Type': mimeType },
221
+ };
222
+ }
223
+
224
+ // 3. AWS SDK v3 with @aws-sdk/s3-request-presigner
225
+ try {
226
+ const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
227
+ const { PutObjectCommand } = require('@aws-sdk/client-s3');
228
+ const uploadUrl = await getSignedUrl(
229
+ s3Client,
230
+ new PutObjectCommand({
231
+ Bucket: bucket,
232
+ Key: key,
233
+ ContentType: mimeType,
234
+ }),
235
+ { expiresIn: expiresInSeconds }
236
+ );
237
+ return {
238
+ uploadUrl,
239
+ publicUrl,
240
+ key,
241
+ method: 'PUT',
242
+ headers: { 'Content-Type': mimeType },
243
+ };
244
+ } catch (err) {
245
+ throw new Error(
246
+ `[createS3FileProvider] Direct signed upload requires @aws-sdk/s3-request-presigner or a mock client with getSignedUrl: ${err.message}`
247
+ );
248
+ }
249
+ },
250
+ };
251
+ }
252
+
253
+ module.exports = {
254
+ createR2FileProvider,
255
+ createS3FileProvider,
256
+ generateStorageKey,
257
+ };
@@ -4,9 +4,7 @@
4
4
  * @module plugins/webhooks/crypto
5
5
  */
6
6
 
7
- 'use strict';
8
-
9
- const crypto = require('crypto');
7
+ const { safeCompare, hmacSha256, randomId } = require('../../core/utils/crypto');
10
8
 
11
9
  /**
12
10
  * Generate a cryptographically secure webhook signing secret
@@ -16,12 +14,9 @@ const crypto = require('crypto');
16
14
  * @returns {string} E.g. 'whsec_e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934c'
17
15
  */
18
16
  function generateSecret(prefix = 'whsec_', byteLength = 24) {
19
- const bytes = crypto.randomBytes(byteLength).toString('hex');
20
- return `${prefix || ''}${bytes}`;
17
+ return randomId(prefix, byteLength, 'hex');
21
18
  }
22
19
 
23
- const { safeCompare } = require('../../core/utils/crypto');
24
-
25
20
  /**
26
21
  * Sign a webhook payload using HMAC-SHA256
27
22
  *
@@ -42,12 +37,12 @@ function signPayload(payload, secret, options = {}) {
42
37
  : Math.floor(Date.now() / 1000);
43
38
 
44
39
  // 1. Direct payload HMAC-SHA256 (compatible with GitHub / standard webhooks)
45
- const hex = crypto.createHmac('sha256', secret).update(payloadStr, 'utf8').digest('hex');
40
+ const hex = hmacSha256(payloadStr, secret, 'hex');
46
41
  const signature = `sha256=${hex}`;
47
42
 
48
43
  // 2. Timestamped HMAC-SHA256 (compatible with Stripe / Svix / Slack)
49
44
  const toSign = `${timestamp}.${payloadStr}`;
50
- const timestampedHex = crypto.createHmac('sha256', secret).update(toSign, 'utf8').digest('hex');
45
+ const timestampedHex = hmacSha256(toSign, secret, 'hex');
51
46
  const timestampedSignature = `t=${timestamp},v1=${timestampedHex}`;
52
47
 
53
48
  return {
@@ -104,7 +99,7 @@ function verifySignature(payload, signatureHeader, secret, options = {}) {
104
99
  }
105
100
 
106
101
  const toSign = `${timestamp}.${payloadStr}`;
107
- const expectedHex = crypto.createHmac('sha256', secret).update(toSign, 'utf8').digest('hex');
102
+ const expectedHex = hmacSha256(toSign, secret, 'hex');
108
103
 
109
104
  return signatures.some((sig) => safeCompare(sig, expectedHex));
110
105
  }
@@ -115,7 +110,7 @@ function verifySignature(payload, signatureHeader, secret, options = {}) {
115
110
  receivedHex = receivedHex.slice(7);
116
111
  }
117
112
 
118
- const expectedHex = crypto.createHmac('sha256', secret).update(payloadStr, 'utf8').digest('hex');
113
+ const expectedHex = hmacSha256(payloadStr, secret, 'hex');
119
114
  return safeCompare(receivedHex, expectedHex);
120
115
  }
121
116
 
@@ -8,9 +8,11 @@
8
8
 
9
9
  const EventEmitter = require('events');
10
10
  const crypto = require('crypto');
11
+ const { randomTimestampedId } = require('../../core/utils/crypto');
11
12
  const { generateSecret } = require('./crypto');
12
13
  const { matchesAnyEvent, normalizeEvents } = require('./matcher');
13
14
  const { sendWebhook } = require('./sender');
15
+ const { safeJsonParse } = require('../../core/utils/json');
14
16
 
15
17
  /**
16
18
  * Generate a standard unique delivery identifier
@@ -20,7 +22,7 @@ function generateDeliveryId() {
20
22
  if (typeof crypto.randomUUID === 'function') {
21
23
  return `dlv_${crypto.randomUUID().replace(/-/g, '')}`;
22
24
  }
23
- return `dlv_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
25
+ return randomTimestampedId('dlv', 8);
24
26
  }
25
27
 
26
28
  class WebhookDispatcher extends EventEmitter {
@@ -47,7 +49,10 @@ class WebhookDispatcher extends EventEmitter {
47
49
  this.deliveriesTable = options.deliveriesTable || 'webhook_deliveries';
48
50
  this.maxAttempts = typeof options.maxAttempts === 'number' ? options.maxAttempts : 3;
49
51
  this.backoffBaseMs = typeof options.backoffBaseMs === 'number' ? options.backoffBaseMs : 1000;
50
- this.timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 10000;
52
+ const defaultWebhookTimeout = options.timeouts?.webhook || 10000;
53
+ this.timeoutMs = typeof options.timeoutMs === 'number'
54
+ ? options.timeoutMs
55
+ : (typeof options.timeout === 'number' ? options.timeout : defaultWebhookTimeout);
51
56
  this.wrapPayload = options.wrapPayload !== undefined ? options.wrapPayload : true;
52
57
  this.queue = options.queue || null;
53
58
  this.logger = options.logger || console;
@@ -527,12 +532,7 @@ class WebhookDispatcher extends EventEmitter {
527
532
  throw new Error(`Target endpoint #${delivery.endpoint_id} no longer exists.`);
528
533
  }
529
534
 
530
- let parsedPayload = delivery.payload;
531
- try {
532
- parsedPayload = JSON.parse(delivery.payload);
533
- } catch (e) {
534
- // Keep string if not json
535
- }
535
+ const parsedPayload = safeJsonParse(delivery.payload, delivery.payload);
536
536
 
537
537
  const nextAttempt = (delivery.attempt || 1) + 1;
538
538
  return this.executeDelivery({