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.
- package/.agents/AGENTS.md +21 -2
- package/.agents/ARCHITECTURE.md +23 -0
- package/.agents/CACHE.md +147 -0
- package/.agents/CLI.md +220 -8
- package/.agents/EARLY_HINTS.md +120 -0
- package/.agents/FLAGS.md +118 -0
- package/.agents/HEALTH.md +118 -0
- package/.agents/LIMITS.md +109 -0
- package/.agents/LOGGING.md +93 -0
- package/.agents/METRICS.md +127 -0
- package/.agents/MIGRATION_JOBS.md +95 -0
- package/.agents/ORM.md +155 -1
- package/.agents/PLUGINS.md +38 -3
- package/.agents/QUEUE.md +42 -0
- package/.agents/RATE_LIMIT.md +147 -0
- package/.agents/RESILIENCE.md +235 -0
- package/.agents/SCALE_OUT.md +109 -0
- package/.agents/SECRETS.md +117 -0
- package/AGENTS.md +16 -0
- package/README.md +2 -2
- package/bin/commands/add-aws.js +396 -0
- package/bin/commands/add-ci.js +523 -0
- package/bin/commands/add-cloudflare.js +240 -0
- package/bin/commands/add-cloudrun.js +381 -0
- package/bin/commands/add-docker.js +6 -6
- package/bin/commands/add-migration-job.js +396 -0
- package/bin/commands/assets-sync.js +209 -0
- package/bin/commands/client-generate.js +52 -0
- package/bin/commands/console.js +53 -0
- package/bin/commands/db-migrate.js +47 -2
- package/bin/commands/db-studio.js +84 -0
- package/bin/commands/deploy-check.js +242 -13
- package/bin/commands/dev.js +1 -1
- package/bin/commands/export.js +87 -0
- package/bin/commands/new.js +5 -5
- package/bin/commands/openapi.js +1 -1
- package/bin/commands/secrets-pull.js +208 -0
- package/bin/commands/start.js +1 -1
- package/bin/utils/db.js +7 -1
- package/bin/utils/project.js +2 -2
- package/bin/webspresso.js +22 -0
- package/core/app.js +421 -0
- package/core/auth/authenticate.js +93 -0
- package/core/auth/hash.js +5 -4
- package/core/auth/index.js +34 -4
- package/core/auth/jwt.js +5 -54
- package/core/auth/magic-link.js +4 -5
- package/core/auth/manager.js +53 -10
- package/core/auth/middleware.js +16 -68
- package/core/auth/oauth.js +8 -17
- package/core/auth/redis-session.js +1008 -0
- package/core/auth/sessions.js +156 -0
- package/core/auth/totp.js +2 -1
- package/core/cache/byte-size.js +123 -0
- package/core/cache/cdn.js +296 -0
- package/core/cache/index.js +33 -0
- package/core/cache/lru.js +477 -0
- package/core/cache/tagged-lru.js +354 -0
- package/core/commands/registry.js +6 -20
- package/core/console/commands.js +148 -0
- package/core/console/context.js +260 -0
- package/core/console/eval.js +70 -0
- package/core/console/index.js +22 -0
- package/core/console/repl.js +102 -0
- package/core/early-hints/index.js +267 -0
- package/core/errors/domain.js +49 -0
- package/core/errors/http.js +31 -0
- package/core/errors/index.js +10 -0
- package/core/errors/normalize.js +88 -2
- package/core/events/discovery.js +6 -24
- package/core/events/dispatcher.js +9 -7
- package/core/events/signal.js +7 -0
- package/core/export/discovery.js +191 -0
- package/core/export/exporter.js +277 -0
- package/core/export/index.js +24 -0
- package/core/flags/evaluator.js +252 -0
- package/core/flags/hasher.js +37 -0
- package/core/flags/index.js +21 -0
- package/core/flags/manager.js +263 -0
- package/core/flags/store.js +126 -0
- package/core/health/index.js +41 -0
- package/core/health/middleware.js +213 -0
- package/core/health/normalize.js +140 -0
- package/core/health/probes.js +203 -0
- package/core/http/adapter.js +74 -0
- package/core/http/express-adapter.js +46 -0
- package/core/http/hono-adapter.js +531 -0
- package/core/http/index.js +41 -0
- package/core/http/pipeline.js +485 -0
- package/core/index.js +336 -0
- package/core/limits/index.js +34 -0
- package/core/limits/middleware.js +325 -0
- package/core/limits/normalize.js +222 -0
- package/core/logger/formatters.js +264 -0
- package/core/logger/index.js +260 -0
- package/core/logger/trace.js +137 -0
- package/core/metrics/collectors.js +125 -0
- package/core/metrics/index.js +45 -0
- package/core/metrics/middleware.js +230 -0
- package/core/metrics/registry.js +366 -0
- package/core/orm/batch.js +70 -0
- package/core/orm/cache/fingerprint.js +2 -2
- package/core/orm/cache/memory-provider.js +27 -110
- package/core/orm/d1.js +265 -0
- package/core/orm/index.js +176 -13
- package/core/orm/json-fields.js +22 -8
- package/core/orm/migrate-cloud.js +194 -0
- package/core/orm/model.js +10 -0
- package/core/orm/pool.js +97 -0
- package/core/orm/query-builder.js +72 -9
- package/core/orm/repository.js +318 -19
- package/core/orm/schema-helpers.js +8 -10
- package/core/orm/scopes.js +321 -1
- package/core/orm/transaction.js +10 -2
- package/core/orm/utils.js +4 -86
- package/core/queue/adapters/cloudflare.js +131 -0
- package/core/queue/adapters/database.js +4 -8
- package/core/queue/adapters/pubsub.js +400 -0
- package/core/queue/adapters/sqs.js +419 -0
- package/core/queue/adapters/wait-until.js +77 -0
- package/core/queue/index.js +8 -0
- package/core/queue/manager.js +66 -41
- package/core/rate-limit/index.js +22 -0
- package/core/rate-limit/limiter.js +219 -0
- package/core/rate-limit/stores/memory.js +129 -0
- package/core/rate-limit/stores/redis.js +151 -0
- package/core/rate-limit/utils.js +112 -0
- package/core/resilience/circuit-breaker.js +422 -0
- package/core/resilience/errors.js +63 -0
- package/core/resilience/index.js +53 -0
- package/core/resilience/registry.js +97 -0
- package/core/resilience/retry.js +203 -0
- package/core/schema-cache.js +9 -2
- package/core/secrets/index.js +113 -0
- package/core/secrets/manager.js +308 -0
- package/core/secrets/providers/aws.js +288 -0
- package/core/secrets/providers/env.js +44 -0
- package/core/secrets/providers/gcp.js +307 -0
- package/core/secrets/providers/memory.js +76 -0
- package/core/shutdown/http-adapter.js +4 -1
- package/core/shutdown/shutdown-manager.js +129 -19
- package/core/stream/index.js +19 -0
- package/core/stream/text-stream.js +326 -0
- package/core/studio/index.js +43 -0
- package/core/studio/inspector.js +258 -0
- package/core/studio/server.js +325 -0
- package/core/studio/ui.js +681 -0
- package/core/utils/array.js +12 -0
- package/core/utils/base64url.js +8 -0
- package/core/utils/crypto.js +118 -0
- package/core/utils/ensure-leading-slash.js +6 -0
- package/core/utils/escape-regexp.js +7 -0
- package/core/utils/fetch.js +6 -5
- package/core/utils/fs.js +144 -0
- package/core/utils/hmac.js +6 -0
- package/core/utils/json.js +98 -0
- package/core/utils/object.js +106 -0
- package/core/utils/random.js +9 -0
- package/core/utils/regex.js +77 -0
- package/core/utils/sha256.js +6 -0
- package/core/utils/string.js +87 -0
- package/core/utils/strip-trailing-slash.js +6 -0
- package/core/utils/time.js +96 -0
- package/core/utils/timeout.js +348 -0
- package/core/utils/url.js +106 -0
- package/core/utils/walk.js +7 -0
- package/index.d.ts +1549 -2
- package/index.js +272 -2
- package/package.json +3 -2
- package/plugins/admin-panel/core/api-extensions.js +2 -5
- package/plugins/admin-panel/index.js +13 -6
- package/plugins/admin-panel/modules/menu.js +1 -1
- package/plugins/api-key/crypto.js +3 -3
- package/plugins/api-key/index.js +3 -2
- package/plugins/api-key/scopes.js +5 -7
- package/plugins/audit-log/api-handlers.js +6 -15
- package/plugins/audit-log/index.js +3 -1
- package/plugins/audit-log/parse.js +3 -3
- package/plugins/basic-auth/index.js +3 -2
- package/plugins/cors.js +3 -2
- package/plugins/csrf/index.js +9 -12
- package/plugins/csv/index.js +8 -5
- package/plugins/dashboard/app.js +2 -2
- package/plugins/dashboard/index.js +5 -5
- package/plugins/data-exchange/record-selection.js +2 -5
- package/plugins/email/api-handlers.js +5 -18
- package/plugins/email/auth-bridge.js +3 -2
- package/plugins/email/index.js +11 -9
- package/plugins/health-check.js +2 -1
- package/plugins/index.js +5 -0
- package/plugins/lazy.js +5 -6
- package/plugins/mcp/discovery.js +18 -17
- package/plugins/polar/src/api.js +2 -1
- package/plugins/polar/src/webhooks.js +5 -6
- package/plugins/queue/index.js +18 -2
- package/plugins/rate-limit/index.js +3 -1
- package/plugins/redirect/index.js +16 -9
- package/plugins/rls/index.js +170 -0
- package/plugins/seo-checker/index.js +2 -2
- package/plugins/site-analytics/index.js +3 -1
- package/plugins/site-analytics/tracking.js +2 -2
- package/plugins/sitemap.js +3 -2
- package/plugins/swagger.js +12 -5
- package/plugins/upload/gcs-provider.js +148 -0
- package/plugins/upload/index.js +136 -77
- package/plugins/upload/orphan-pruner.js +4 -3
- package/plugins/upload/s3-provider.js +257 -0
- package/plugins/webhooks/crypto.js +6 -11
- package/plugins/webhooks/dispatcher.js +8 -8
- package/plugins/webhooks/index.js +33 -23
- package/plugins/webhooks/matcher.js +7 -11
- package/plugins/webhooks/sender.js +8 -11
- package/plugins/xlsx/index.js +7 -5
- package/src/api/api-loader.js +12 -95
- package/src/api/define-api.js +1 -0
- package/src/app-context.js +41 -5
- package/src/client-runtime/constants.js +10 -0
- package/src/client-runtime/mount.js +2 -3
- package/src/dev/banner.js +6 -2
- package/src/dev/client-gen.js +458 -0
- package/src/discovery/scan-routes.js +5 -2
- package/src/file-router.js +3 -3
- package/src/helpers.js +212 -24
- package/src/modules/module-discovery.js +1 -9
- package/src/pages/page-loader.js +13 -154
- package/src/plugin-manager.js +99 -18
- package/src/server.js +335 -133
- package/src/services/discovery.js +19 -40
- package/src/services/executor.js +95 -25
- package/src/services/memoize.js +3 -25
- package/src/services/registry.js +15 -1
- package/src/services/validator.js +38 -20
- 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
|
+
};
|
package/core/schema-cache.js
CHANGED
|
@@ -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
|
|
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
|
+
};
|