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
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso - Zero-Dependency Retry Engine
5
+ * Exponential backoff with jitter and transient error detection to prevent thundering herd spikes.
6
+ * @module core/resilience/retry
7
+ */
8
+
9
+ const { parseDurationMs } = require('../utils/time');
10
+
11
+ /**
12
+ * Standard transient network error codes that benefit from retries
13
+ */
14
+ const TRANSIENT_NETWORK_CODES = new Set([
15
+ 'ECONNRESET',
16
+ 'ETIMEDOUT',
17
+ 'EAI_AGAIN',
18
+ 'ENOTFOUND',
19
+ 'ECONNREFUSED',
20
+ 'EPIPE',
21
+ 'EHOSTUNREACH',
22
+ ]);
23
+
24
+ /**
25
+ * Default predicate determining if an error is transient and safe to retry
26
+ *
27
+ * @param {Error} error
28
+ * @returns {boolean}
29
+ */
30
+ function isTransientError(error) {
31
+ if (!error) return false;
32
+
33
+ // 1. Check network error code
34
+ if (error.code && TRANSIENT_NETWORK_CODES.has(error.code)) {
35
+ return true;
36
+ }
37
+
38
+ // 2. Check HTTP status code (if available)
39
+ const status = error.status || error.statusCode || error.response?.status;
40
+ if (typeof status === 'number') {
41
+ // 429 Too Many Requests, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
42
+ if (status === 429 || status === 502 || status === 503 || status === 504) {
43
+ return true;
44
+ }
45
+ // Standard 4xx client errors (400, 401, 403, 404, 422) will not succeed on retry
46
+ if (status >= 400 && status < 500) {
47
+ return false;
48
+ }
49
+ // Internal server errors 500
50
+ if (status >= 500) {
51
+ return true;
52
+ }
53
+ }
54
+
55
+ // 3. Fallback: Check common error message keywords
56
+ const message = error.message || '';
57
+ if (/timeout|connection reset|temporarily unavailable|network error/i.test(message)) {
58
+ return true;
59
+ }
60
+
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Calculate exponential backoff delay with optional random jitter
66
+ *
67
+ * @param {number} attempt - Current retry attempt (1-based)
68
+ * @param {number} minTimeoutMs - Initial delay in ms
69
+ * @param {number} maxTimeoutMs - Maximum delay cap in ms
70
+ * @param {number} factor - Backoff factor (e.g. 2)
71
+ * @param {boolean} jitter - Whether to apply full jitter
72
+ * @returns {number} Delay in milliseconds
73
+ */
74
+ function calculateBackoffDelay(attempt, minTimeoutMs, maxTimeoutMs, factor, jitter) {
75
+ const exponential = minTimeoutMs * Math.pow(factor, attempt - 1);
76
+ const capped = Math.min(maxTimeoutMs, exponential);
77
+
78
+ if (!jitter) {
79
+ return Math.floor(capped);
80
+ }
81
+
82
+ // Full jitter: uniformly distributed between 0 and capped delay
83
+ return Math.floor(Math.random() * capped);
84
+ }
85
+
86
+ /**
87
+ * Sleep helper respecting AbortSignal
88
+ * @private
89
+ */
90
+ function sleep(ms, signal) {
91
+ return new Promise((resolve, reject) => {
92
+ if (signal?.aborted) {
93
+ return reject(signal.reason || new Error('Aborted'));
94
+ }
95
+
96
+ let timer = null;
97
+ const onAbort = () => {
98
+ if (timer) clearTimeout(timer);
99
+ reject(signal.reason || new Error('Aborted'));
100
+ };
101
+
102
+ if (signal) {
103
+ signal.addEventListener('abort', onAbort, { once: true });
104
+ }
105
+
106
+ timer = setTimeout(() => {
107
+ if (signal) signal.removeEventListener('abort', onAbort);
108
+ resolve();
109
+ }, ms);
110
+
111
+ if (typeof timer?.unref === 'function') {
112
+ timer.unref();
113
+ }
114
+ });
115
+ }
116
+
117
+ /**
118
+ * @typedef {Object} RetryOptions
119
+ * @property {number} [retries=3] - Maximum retry attempts (0 = no retry)
120
+ * @property {number|string} [delay=100] - Initial delay in ms or duration string
121
+ * @property {number|string} [minTimeout=100] - Alias for delay
122
+ * @property {number|string} [maxTimeout=10000] - Max backoff delay cap in ms or duration string
123
+ * @property {number} [factor=2] - Exponential multiplier
124
+ * @property {boolean} [jitter=true] - Whether to apply random jitter
125
+ * @property {function(Error, number): boolean} [shouldRetry] - Error classifier predicate
126
+ * @property {function({ attempt: number, error: Error, delayMs: number }): void} [onRetry] - Notification callback
127
+ * @property {AbortSignal} [signal] - Optional abort signal
128
+ */
129
+
130
+ /**
131
+ * Execute an async operation with automatic exponential backoff retries.
132
+ *
133
+ * @param {function(number): Promise<*>} asyncFn - Async function to execute (receives attempt index)
134
+ * @param {RetryOptions} [options]
135
+ * @returns {Promise<*>}
136
+ */
137
+ async function retry(asyncFn, options = {}) {
138
+ const maxRetries = typeof options.retries === 'number' ? Math.max(0, options.retries) : 3;
139
+
140
+ const minTimeoutMs = options.delay !== undefined
141
+ ? parseDurationMs(options.delay, 100)
142
+ : (options.minTimeout !== undefined ? parseDurationMs(options.minTimeout, 100) : 100);
143
+
144
+ const maxTimeoutMs = options.maxTimeout !== undefined
145
+ ? parseDurationMs(options.maxTimeout, 10000)
146
+ : 10000;
147
+
148
+ const factor = typeof options.factor === 'number' && options.factor >= 1 ? options.factor : 2;
149
+ const jitter = options.jitter !== false;
150
+ const shouldRetry = typeof options.shouldRetry === 'function' ? options.shouldRetry : isTransientError;
151
+ const onRetry = typeof options.onRetry === 'function' ? options.onRetry : null;
152
+ const signal = options.signal || null;
153
+
154
+ let attempt = 0;
155
+
156
+ while (true) {
157
+ attempt++;
158
+ if (signal?.aborted) {
159
+ throw signal.reason || new Error('Aborted');
160
+ }
161
+
162
+ try {
163
+ return await asyncFn(attempt);
164
+ } catch (err) {
165
+ const isLastAttempt = attempt > maxRetries;
166
+ const canRetry = shouldRetry(err, attempt);
167
+
168
+ if (isLastAttempt || !canRetry || signal?.aborted) {
169
+ throw err;
170
+ }
171
+
172
+ const delayMs = calculateBackoffDelay(attempt, minTimeoutMs, maxTimeoutMs, factor, jitter);
173
+
174
+ if (onRetry) {
175
+ try {
176
+ onRetry({ attempt, error: err, delayMs });
177
+ } catch {
178
+ // Callback must never break retry loop
179
+ }
180
+ }
181
+
182
+ await sleep(delayMs, signal);
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Wrap an async function with retry behavior
189
+ *
190
+ * @param {Function} asyncFn
191
+ * @param {RetryOptions} [options]
192
+ * @returns {Function}
193
+ */
194
+ function withRetry(asyncFn, options = {}) {
195
+ return (...args) => retry((attempt) => asyncFn(...args), options);
196
+ }
197
+
198
+ module.exports = {
199
+ retry,
200
+ withRetry,
201
+ isTransientError,
202
+ calculateBackoffDelay,
203
+ };
@@ -1,10 +1,17 @@
1
+ 'use strict';
2
+
1
3
  /**
2
4
  * Schema Cache
3
- * Caches compiled Zod schemas per file path
5
+ * Caches compiled Zod schemas per file path using memory-bounded LRU eviction
4
6
  * @module core/schema-cache
5
7
  */
6
8
 
7
- const cache = new Map();
9
+ const { createLruCache } = require('./cache');
10
+
11
+ // 2,000 schemas capacity protects against OOM in dynamic routing/JIT compilation
12
+ const cache = createLruCache({
13
+ maxEntries: 2000,
14
+ });
8
15
 
9
16
  /**
10
17
  * Get cached schema for a file path
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Webspresso Cloud Secrets Management
3
+ *
4
+ * Zero-dependency cloud secrets manager supporting Google Cloud Secret Manager,
5
+ * AWS Secrets Manager & SSM Parameter Store, in-memory caching with TTL,
6
+ * JSON bundle unpacking, dot-notation subkeys, and process.env hydration.
7
+ *
8
+ * @module core/secrets
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { SecretManager, SecretNotFoundError, detectCloudProvider } = require('./manager');
14
+ const { EnvSecretProvider } = require('./providers/env');
15
+ const { MemorySecretProvider } = require('./providers/memory');
16
+ const { GcpSecretProvider } = require('./providers/gcp');
17
+ const { AwsSecretProvider } = require('./providers/aws');
18
+
19
+ // Global singleton instance
20
+ let defaultSecretManager = null;
21
+
22
+ /**
23
+ * Factory to create a SecretManager instance
24
+ * @param {Object} [options]
25
+ * @returns {SecretManager}
26
+ */
27
+ function createSecretManager(options = {}) {
28
+ return new SecretManager(options);
29
+ }
30
+
31
+ /**
32
+ * Get or initialize default SecretManager singleton
33
+ * @param {Object} [options]
34
+ * @returns {SecretManager}
35
+ */
36
+ function getDefaultSecretManager(options = {}) {
37
+ if (!defaultSecretManager || Object.keys(options).length > 0) {
38
+ defaultSecretManager = new SecretManager(options);
39
+ }
40
+ return defaultSecretManager;
41
+ }
42
+
43
+ /**
44
+ * Convenience helper to resolve a single secret
45
+ * @param {string} secretName
46
+ * @param {Object} [options]
47
+ * @returns {Promise<any>}
48
+ */
49
+ async function resolveSecret(secretName, options = {}) {
50
+ const manager = getDefaultSecretManager(options);
51
+ return manager.get(secretName, options);
52
+ }
53
+
54
+ /**
55
+ * Convenience helper to load secrets and inject them into process.env at bootstrap
56
+ *
57
+ * Example:
58
+ * ```javascript
59
+ * const { loadSecrets } = require('webspresso/core/secrets');
60
+ *
61
+ * await loadSecrets({
62
+ * provider: 'cloudrun', // or 'auto'
63
+ * secrets: {
64
+ * SESSION_SECRET: 'prod-session-secret:latest',
65
+ * DATABASE_URL: 'production-db-url',
66
+ * }
67
+ * });
68
+ * ```
69
+ *
70
+ * @param {Object} options
71
+ * @param {'auto'|'gcp'|'cloudrun'|'aws'|'env'|'memory'} [options.provider='auto']
72
+ * @param {Record<string, string>|string[]} [options.secrets]
73
+ * @param {string} [options.bundle] - JSON secret bundle to unpack
74
+ * @param {boolean} [options.inject=true] - Set into process.env
75
+ * @param {boolean} [options.override=false] - Override existing process.env
76
+ * @returns {Promise<Record<string, any>>}
77
+ */
78
+ async function loadSecrets(options = {}) {
79
+ const manager = createSecretManager(options);
80
+ const result = {};
81
+
82
+ if (options.secrets) {
83
+ const loaded = await manager.load(options.secrets, {
84
+ inject: options.inject !== false,
85
+ override: options.override === true,
86
+ });
87
+ Object.assign(result, loaded);
88
+ }
89
+
90
+ if (options.bundle) {
91
+ const bundleLoaded = await manager.loadBundle(options.bundle, {
92
+ inject: options.inject !== false,
93
+ override: options.override === true,
94
+ });
95
+ Object.assign(result, bundleLoaded);
96
+ }
97
+
98
+ return result;
99
+ }
100
+
101
+ module.exports = {
102
+ SecretManager,
103
+ SecretNotFoundError,
104
+ createSecretManager,
105
+ getDefaultSecretManager,
106
+ resolveSecret,
107
+ loadSecrets,
108
+ detectCloudProvider,
109
+ EnvSecretProvider,
110
+ MemorySecretProvider,
111
+ GcpSecretProvider,
112
+ AwsSecretProvider,
113
+ };
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Secret Manager Core for Webspresso
3
+ * Provides multi-cloud secret resolution, in-memory caching with TTL,
4
+ * JSON bundle unpacking, subkey dot-notation, and process.env hydration.
5
+ *
6
+ * @module core/secrets/manager
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const { EnvSecretProvider } = require('./providers/env');
12
+ const { MemorySecretProvider } = require('./providers/memory');
13
+ const { GcpSecretProvider } = require('./providers/gcp');
14
+ const { AwsSecretProvider } = require('./providers/aws');
15
+
16
+ /**
17
+ * Parse human TTL string ('5m', '1h', '30s') or number to milliseconds
18
+ * @param {string|number} ttl
19
+ * @returns {number}
20
+ */
21
+ function parseTtlMs(ttl) {
22
+ if (typeof ttl === 'number' && Number.isFinite(ttl)) {
23
+ return Math.max(0, ttl);
24
+ }
25
+ if (typeof ttl === 'string') {
26
+ const trimmed = ttl.trim().toLowerCase();
27
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/);
28
+ if (!match) return 300_000;
29
+ const value = parseFloat(match[1]);
30
+ const unit = match[2] || 'ms';
31
+ switch (unit) {
32
+ case 'ms': return Math.round(value);
33
+ case 's': return Math.round(value * 1000);
34
+ case 'm': return Math.round(value * 60_000);
35
+ case 'h': return Math.round(value * 3_600_000);
36
+ case 'd': return Math.round(value * 86_400_000);
37
+ default: return 300_000;
38
+ }
39
+ }
40
+ return 300_000; // 5 minutes default
41
+ }
42
+
43
+ /**
44
+ * Detect target cloud provider automatically from environment
45
+ * @returns {'gcp'|'aws'|'env'}
46
+ */
47
+ function detectCloudProvider() {
48
+ if (
49
+ process.env.K_SERVICE ||
50
+ process.env.GOOGLE_CLOUD_PROJECT ||
51
+ process.env.GCP_PROJECT ||
52
+ process.env.GOOGLE_APPLICATION_CREDENTIALS
53
+ ) {
54
+ return 'gcp';
55
+ }
56
+
57
+ if (
58
+ process.env.AWS_EXECUTION_ENV ||
59
+ process.env.AWS_LAMBDA_FUNCTION_NAME ||
60
+ (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)
61
+ ) {
62
+ return 'aws';
63
+ }
64
+
65
+ return 'env';
66
+ }
67
+
68
+ class SecretNotFoundError extends Error {
69
+ constructor(secretName) {
70
+ super(`Secret "${secretName}" was not found in the configured secret provider.`);
71
+ this.name = 'SecretNotFoundError';
72
+ this.secretName = secretName;
73
+ }
74
+ }
75
+
76
+ class SecretManager {
77
+ /**
78
+ * @param {Object} [options]
79
+ * @param {'auto'|'gcp'|'cloudrun'|'aws'|'env'|'memory'|Object} [options.provider='auto']
80
+ * @param {string|number} [options.cacheTtl='5m']
81
+ * @param {boolean} [options.cache=true]
82
+ * @param {string} [options.projectId] - For GCP
83
+ * @param {string} [options.region] - For AWS
84
+ * @param {Object} [options.client] - Duck-typed official client
85
+ * @param {Record<string, any>} [options.mockSecrets] - Initial memory secrets
86
+ * @param {Function} [options.httpTransport]
87
+ */
88
+ constructor(options = {}) {
89
+ this.options = options;
90
+ this.cacheEnabled = options.cache !== false;
91
+ this.cacheTtlMs = parseTtlMs(options.cacheTtl ?? '5m');
92
+ this.cacheStore = new Map(); // key -> { value, expiresAt }
93
+
94
+ let providerType = options.provider || 'auto';
95
+ if (providerType === 'auto') {
96
+ providerType = detectCloudProvider();
97
+ }
98
+
99
+ if (typeof providerType === 'object' && providerType !== null) {
100
+ this.provider = providerType;
101
+ } else if (providerType === 'gcp' || providerType === 'cloudrun') {
102
+ this.provider = new GcpSecretProvider(options);
103
+ } else if (providerType === 'aws') {
104
+ this.provider = new AwsSecretProvider(options);
105
+ } else if (providerType === 'memory') {
106
+ this.provider = new MemorySecretProvider({
107
+ secrets: options.mockSecrets || options.secrets,
108
+ });
109
+ } else {
110
+ this.provider = new EnvSecretProvider(options);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Invalidate cached secret
116
+ * @param {string} secretName
117
+ */
118
+ invalidate(secretName) {
119
+ if (secretName) {
120
+ this.cacheStore.delete(secretName);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Clear entire in-memory secrets cache
126
+ */
127
+ clearCache() {
128
+ this.cacheStore.clear();
129
+ }
130
+
131
+ /**
132
+ * Fetch a secret value
133
+ * Supports:
134
+ * - Dot notation for subkeys: e.g. get('my-bundle.DATABASE_URL')
135
+ * - JSON parsing: get('my-bundle', { parseJson: true })
136
+ * - Default fallback: get('MISSING', { fallback: 'default' })
137
+ * - Required validation: get('MISSING', { required: true })
138
+ *
139
+ * @param {string} secretName
140
+ * @param {Object} [options]
141
+ * @param {any} [options.fallback]
142
+ * @param {boolean} [options.required=false]
143
+ * @param {boolean} [options.parseJson=false]
144
+ * @param {boolean} [options.bypassCache=false]
145
+ * @returns {Promise<any>}
146
+ */
147
+ async get(secretName, options = {}) {
148
+ if (!secretName || typeof secretName !== 'string') {
149
+ if (options.required) {
150
+ throw new SecretNotFoundError(String(secretName));
151
+ }
152
+ return options.fallback !== undefined ? options.fallback : null;
153
+ }
154
+
155
+ const { fallback, required = false, parseJson = false, bypassCache = false } = options;
156
+
157
+ // Handle dot notation subkey (e.g. "prod/bundle.DB_PASSWORD")
158
+ if (secretName.includes('.') && !secretName.startsWith('.')) {
159
+ const dotIndex = secretName.indexOf('.');
160
+ const bundleName = secretName.substring(0, dotIndex);
161
+ const subKey = secretName.substring(dotIndex + 1);
162
+
163
+ const bundleVal = await this.get(bundleName, { bypassCache });
164
+ if (bundleVal) {
165
+ try {
166
+ const parsed = typeof bundleVal === 'object' ? bundleVal : JSON.parse(bundleVal);
167
+ if (parsed && Object.prototype.hasOwnProperty.call(parsed, subKey)) {
168
+ return parsed[subKey];
169
+ }
170
+ } catch (_) {}
171
+ }
172
+
173
+ if (required) {
174
+ throw new SecretNotFoundError(secretName);
175
+ }
176
+ return fallback !== undefined ? fallback : null;
177
+ }
178
+
179
+ // Check in-memory cache
180
+ if (this.cacheEnabled && !bypassCache) {
181
+ const cached = this.cacheStore.get(secretName);
182
+ if (cached && Date.now() < cached.expiresAt) {
183
+ return this._formatResult(cached.value, parseJson);
184
+ }
185
+ }
186
+
187
+ // Resolve via provider
188
+ let rawValue = null;
189
+ if (this.provider && typeof this.provider.get === 'function') {
190
+ rawValue = await this.provider.get(secretName);
191
+ }
192
+
193
+ if (rawValue === null || rawValue === undefined) {
194
+ // Fallback to process.env if cloud provider didn't have it
195
+ if (process.env[secretName] !== undefined) {
196
+ rawValue = process.env[secretName];
197
+ }
198
+ }
199
+
200
+ if (rawValue === null || rawValue === undefined) {
201
+ if (required) {
202
+ throw new SecretNotFoundError(secretName);
203
+ }
204
+ return fallback !== undefined ? fallback : null;
205
+ }
206
+
207
+ // Save to cache
208
+ if (this.cacheEnabled) {
209
+ this.cacheStore.set(secretName, {
210
+ value: rawValue,
211
+ expiresAt: Date.now() + this.cacheTtlMs,
212
+ });
213
+ }
214
+
215
+ return this._formatResult(rawValue, parseJson);
216
+ }
217
+
218
+ /**
219
+ * Internal formatter
220
+ * @private
221
+ */
222
+ _formatResult(val, parseJson) {
223
+ if (!parseJson) return val;
224
+ if (typeof val === 'object' && val !== null) return val;
225
+ try {
226
+ return JSON.parse(val);
227
+ } catch (_) {
228
+ return val;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Load multiple secrets and optionally inject them into process.env
234
+ * @param {Record<string, string>|string[]} secretsMapOrArray - Map of { ENV_KEY: 'secret-name' } or array of names
235
+ * @param {Object} [options]
236
+ * @param {boolean} [options.inject=true] - Set into process.env
237
+ * @param {boolean} [options.override=false] - Override existing process.env
238
+ * @returns {Promise<Record<string, string>>}
239
+ */
240
+ async load(secretsMapOrArray, options = {}) {
241
+ const { inject = true, override = false } = options;
242
+ const result = {};
243
+
244
+ let mappings = {};
245
+ if (Array.isArray(secretsMapOrArray)) {
246
+ for (const name of secretsMapOrArray) {
247
+ mappings[name] = name;
248
+ }
249
+ } else if (typeof secretsMapOrArray === 'object' && secretsMapOrArray !== null) {
250
+ mappings = secretsMapOrArray;
251
+ }
252
+
253
+ for (const [envKey, secretName] of Object.entries(mappings)) {
254
+ let val = await this.get(secretName);
255
+ if ((val === null || val === undefined) && envKey !== secretName) {
256
+ val = await this.get(envKey);
257
+ }
258
+ if (val !== null && val !== undefined) {
259
+ const strVal = typeof val === 'object' ? JSON.stringify(val) : String(val);
260
+ result[envKey] = strVal;
261
+ if (inject) {
262
+ if (override || process.env[envKey] === undefined) {
263
+ process.env[envKey] = strVal;
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ return result;
270
+ }
271
+
272
+ /**
273
+ * Unpack a JSON secret bundle and inject all key-values into process.env
274
+ * @param {string} bundleName - Secret name holding JSON object
275
+ * @param {Object} [options]
276
+ * @param {boolean} [options.inject=true]
277
+ * @param {boolean} [options.override=false]
278
+ * @returns {Promise<Record<string, any>>}
279
+ */
280
+ async loadBundle(bundleName, options = {}) {
281
+ const { inject = true, override = false } = options;
282
+ const bundleContent = await this.get(bundleName, { parseJson: true });
283
+
284
+ if (!bundleContent || typeof bundleContent !== 'object') {
285
+ return {};
286
+ }
287
+
288
+ const result = {};
289
+ for (const [key, val] of Object.entries(bundleContent)) {
290
+ const strVal = typeof val === 'object' ? JSON.stringify(val) : String(val ?? '');
291
+ result[key] = strVal;
292
+ if (inject) {
293
+ if (override || process.env[key] === undefined) {
294
+ process.env[key] = strVal;
295
+ }
296
+ }
297
+ }
298
+
299
+ return result;
300
+ }
301
+ }
302
+
303
+ module.exports = {
304
+ SecretManager,
305
+ SecretNotFoundError,
306
+ parseTtlMs,
307
+ detectCloudProvider,
308
+ };