@ant.sh/colony 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/dist/cjs/cli.js +281 -0
  4. package/dist/cjs/cli.js.map +7 -0
  5. package/dist/cjs/index.js +383 -0
  6. package/dist/cjs/index.js.map +7 -0
  7. package/dist/cjs/package.json +3 -0
  8. package/dist/cjs/parser.js +319 -0
  9. package/dist/cjs/parser.js.map +7 -0
  10. package/dist/cjs/providers/aws.js +115 -0
  11. package/dist/cjs/providers/aws.js.map +7 -0
  12. package/dist/cjs/providers/openbao.js +49 -0
  13. package/dist/cjs/providers/openbao.js.map +7 -0
  14. package/dist/cjs/providers/vault-base.js +98 -0
  15. package/dist/cjs/providers/vault-base.js.map +7 -0
  16. package/dist/cjs/providers/vault.js +49 -0
  17. package/dist/cjs/providers/vault.js.map +7 -0
  18. package/dist/cjs/resolver.js +247 -0
  19. package/dist/cjs/resolver.js.map +7 -0
  20. package/dist/cjs/secrets.js +238 -0
  21. package/dist/cjs/secrets.js.map +7 -0
  22. package/dist/cjs/strings.js +99 -0
  23. package/dist/cjs/strings.js.map +7 -0
  24. package/dist/cjs/util.js +74 -0
  25. package/dist/cjs/util.js.map +7 -0
  26. package/dist/esm/cli.js +281 -0
  27. package/dist/esm/cli.js.map +7 -0
  28. package/dist/esm/index.d.ts +342 -0
  29. package/dist/esm/index.js +347 -0
  30. package/dist/esm/index.js.map +7 -0
  31. package/dist/esm/package.json +3 -0
  32. package/dist/esm/parser.js +286 -0
  33. package/dist/esm/parser.js.map +7 -0
  34. package/dist/esm/providers/aws.js +82 -0
  35. package/dist/esm/providers/aws.js.map +7 -0
  36. package/dist/esm/providers/openbao.js +26 -0
  37. package/dist/esm/providers/openbao.js.map +7 -0
  38. package/dist/esm/providers/vault-base.js +75 -0
  39. package/dist/esm/providers/vault-base.js.map +7 -0
  40. package/dist/esm/providers/vault.js +26 -0
  41. package/dist/esm/providers/vault.js.map +7 -0
  42. package/dist/esm/resolver.js +224 -0
  43. package/dist/esm/resolver.js.map +7 -0
  44. package/dist/esm/secrets.js +209 -0
  45. package/dist/esm/secrets.js.map +7 -0
  46. package/dist/esm/strings.js +75 -0
  47. package/dist/esm/strings.js.map +7 -0
  48. package/dist/esm/util.js +47 -0
  49. package/dist/esm/util.js.map +7 -0
  50. package/package.json +66 -0
  51. package/src/cli.js +353 -0
  52. package/src/index.d.ts +342 -0
  53. package/src/index.js +473 -0
  54. package/src/parser.js +381 -0
  55. package/src/providers/aws.js +112 -0
  56. package/src/providers/openbao.js +32 -0
  57. package/src/providers/vault-base.js +92 -0
  58. package/src/providers/vault.js +31 -0
  59. package/src/resolver.js +286 -0
  60. package/src/secrets.js +313 -0
  61. package/src/strings.js +84 -0
  62. package/src/util.js +49 -0
@@ -0,0 +1,286 @@
1
+ import { applyInterpolationDeep } from "./strings.js";
2
+ import { getDeep, setDeep, deepMerge, isPlainObject } from "./util.js";
3
+
4
+ export function resolveRules({ rules, dims, ctx, vars, allowedEnvVars = null, allowedVars = null, warnings = [] }) {
5
+ const indexed = [];
6
+ for (const r of rules) {
7
+ const scope = r.keySegments.slice(0, dims.length);
8
+ const keyPath = r.keySegments.slice(dims.length);
9
+
10
+ if (scope.length !== dims.length || keyPath.length === 0) {
11
+ throw new Error(
12
+ `${r.filePath}:${r.line}: Key must have ${dims.length} scope segments + at least one key segment: ${r.keyRaw}`
13
+ );
14
+ }
15
+
16
+ indexed.push({
17
+ ...r,
18
+ scope,
19
+ keyPath,
20
+ keyPathStr: keyPath.join("."),
21
+ });
22
+ }
23
+
24
+ const ctxScope = dims.map((d) => String(ctx[d] ?? ""));
25
+
26
+ const candidatesByKey = new Map();
27
+ const postOps = [];
28
+
29
+ for (const r of indexed) {
30
+ if (!matches(r.scope, ctxScope)) continue;
31
+
32
+ if (r.op === "+=" || r.op === "-=") postOps.push(r);
33
+ else {
34
+ if (!candidatesByKey.has(r.keyPathStr)) candidatesByKey.set(r.keyPathStr, []);
35
+ candidatesByKey.get(r.keyPathStr).push(r);
36
+ }
37
+ }
38
+
39
+ const out = {};
40
+ const trace = new Map();
41
+
42
+ for (const [key, cand] of candidatesByKey.entries()) {
43
+ let winner = cand[0];
44
+ let best = specificity(winner.scope);
45
+ for (let i = 1; i < cand.length; i++) {
46
+ const s = specificity(cand[i].scope);
47
+ if (s > best) {
48
+ best = s;
49
+ winner = cand[i];
50
+ } else if (s === best) {
51
+ winner = cand[i];
52
+ }
53
+ }
54
+
55
+ const existing = getDeep(out, winner.keyPath);
56
+
57
+ if (winner.op === ":=") {
58
+ if (existing === undefined) {
59
+ setDeep(out, winner.keyPath, clone(winner.value));
60
+ trace.set(key, packTrace(winner, best));
61
+ }
62
+ continue;
63
+ }
64
+
65
+ if (winner.op === "|=") {
66
+ if (existing === undefined) {
67
+ setDeep(out, winner.keyPath, clone(winner.value));
68
+ } else if (isPlainObject(existing) && isPlainObject(winner.value)) {
69
+ setDeep(out, winner.keyPath, deepMerge(existing, winner.value));
70
+ } else {
71
+ setDeep(out, winner.keyPath, clone(winner.value));
72
+ }
73
+ trace.set(key, packTrace(winner, best));
74
+ continue;
75
+ }
76
+
77
+ setDeep(out, winner.keyPath, clone(winner.value));
78
+ trace.set(key, packTrace(winner, best));
79
+ }
80
+
81
+ postOps.sort((a, b) => specificity(a.scope) - specificity(b.scope));
82
+
83
+ for (const r of postOps) {
84
+ const key = r.keyPathStr;
85
+ const best = specificity(r.scope);
86
+
87
+ const existing = getDeep(out, r.keyPath);
88
+ const val = clone(r.value);
89
+
90
+ if (r.op === "+=") {
91
+ const add = Array.isArray(val) ? val : [val];
92
+ if (existing === undefined) setDeep(out, r.keyPath, add);
93
+ else if (Array.isArray(existing)) setDeep(out, r.keyPath, existing.concat(add));
94
+ else setDeep(out, r.keyPath, [existing].concat(add));
95
+ trace.set(key, packTrace(r, best));
96
+ continue;
97
+ }
98
+
99
+ if (r.op === "-=") {
100
+ const remove = new Set(Array.isArray(val) ? val : [val]);
101
+ if (Array.isArray(existing)) {
102
+ setDeep(out, r.keyPath, existing.filter((x) => !remove.has(x)));
103
+ trace.set(key, packTrace(r, best));
104
+ }
105
+ continue;
106
+ }
107
+ }
108
+
109
+ const finalCfg = applyInterpolationDeep(out, { ctx, vars, allowedEnvVars, allowedVars, warnings });
110
+
111
+ Object.defineProperties(finalCfg, {
112
+ // Core methods
113
+ get: { enumerable: false, value: (p) => getByPath(finalCfg, p) },
114
+ explain: { enumerable: false, value: (p) => explainByPath(trace, p) },
115
+
116
+ // Serialization - returns a plain object copy without non-enumerable methods
117
+ toJSON: {
118
+ enumerable: false,
119
+ value: () => {
120
+ const plain = {};
121
+ for (const [k, v] of Object.entries(finalCfg)) {
122
+ plain[k] = clone(v);
123
+ }
124
+ return plain;
125
+ },
126
+ },
127
+
128
+ // List all keys (dot-notation paths)
129
+ keys: {
130
+ enumerable: false,
131
+ value: () => collectKeys(finalCfg),
132
+ },
133
+
134
+ // Diff against another config
135
+ diff: {
136
+ enumerable: false,
137
+ value: (other) => diffConfigs(finalCfg, other),
138
+ },
139
+
140
+ // Internal trace data
141
+ _trace: { enumerable: false, value: trace },
142
+ });
143
+
144
+ return finalCfg;
145
+ }
146
+
147
+ /**
148
+ * Collect all leaf keys in dot notation
149
+ * @param {object} obj
150
+ * @param {string} prefix
151
+ * @returns {string[]}
152
+ */
153
+ function collectKeys(obj, prefix = "") {
154
+ const keys = [];
155
+
156
+ for (const [k, v] of Object.entries(obj)) {
157
+ const path = prefix ? `${prefix}.${k}` : k;
158
+
159
+ if (isPlainObject(v)) {
160
+ keys.push(...collectKeys(v, path));
161
+ } else {
162
+ keys.push(path);
163
+ }
164
+ }
165
+
166
+ return keys.sort();
167
+ }
168
+
169
+ /**
170
+ * Diff two configs, returning added, removed, and changed keys
171
+ * @param {object} a - First config
172
+ * @param {object} b - Second config
173
+ * @returns {{ added: string[], removed: string[], changed: Array<{key: string, from: any, to: any}> }}
174
+ */
175
+ function diffConfigs(a, b) {
176
+ const aKeys = new Set(collectKeys(a));
177
+ const bKeys = new Set(collectKeys(b));
178
+
179
+ const added = [];
180
+ const removed = [];
181
+ const changed = [];
182
+
183
+ // Keys in b but not in a
184
+ for (const key of bKeys) {
185
+ if (!aKeys.has(key)) {
186
+ added.push(key);
187
+ }
188
+ }
189
+
190
+ // Keys in a but not in b
191
+ for (const key of aKeys) {
192
+ if (!bKeys.has(key)) {
193
+ removed.push(key);
194
+ }
195
+ }
196
+
197
+ // Keys in both - check for changes
198
+ for (const key of aKeys) {
199
+ if (bKeys.has(key)) {
200
+ const aVal = getByPath(a, key);
201
+ const bVal = getByPath(b, key);
202
+
203
+ if (!deepEqual(aVal, bVal)) {
204
+ changed.push({ key, from: aVal, to: bVal });
205
+ }
206
+ }
207
+ }
208
+
209
+ return {
210
+ added: added.sort(),
211
+ removed: removed.sort(),
212
+ changed: changed.sort((x, y) => x.key.localeCompare(y.key)),
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Deep equality check for config values.
218
+ * Note: Does not handle circular references (will stack overflow).
219
+ * Config values should never be circular in practice.
220
+ */
221
+ function deepEqual(a, b) {
222
+ if (a === b) return true;
223
+ if (typeof a !== typeof b) return false;
224
+ if (a === null || b === null) return a === b;
225
+
226
+ if (Array.isArray(a) && Array.isArray(b)) {
227
+ if (a.length !== b.length) return false;
228
+ return a.every((v, i) => deepEqual(v, b[i]));
229
+ }
230
+
231
+ if (typeof a === "object" && typeof b === "object") {
232
+ const aKeys = Object.keys(a);
233
+ const bKeys = Object.keys(b);
234
+ if (aKeys.length !== bKeys.length) return false;
235
+ return aKeys.every((k) => deepEqual(a[k], b[k]));
236
+ }
237
+
238
+ return false;
239
+ }
240
+
241
+ function getByPath(obj, p) {
242
+ const segs = String(p).split(".").filter(Boolean);
243
+ return getDeep(obj, segs);
244
+ }
245
+
246
+ function explainByPath(trace, p) {
247
+ const key = String(p);
248
+ return trace.get(key) ?? null;
249
+ }
250
+
251
+ function matches(ruleScope, ctxScope) {
252
+ for (let i = 0; i < ruleScope.length; i++) {
253
+ const r = String(ruleScope[i]);
254
+ const c = String(ctxScope[i]);
255
+ if (r === "*") continue;
256
+ if (r !== c) return false;
257
+ }
258
+ return true;
259
+ }
260
+
261
+ function specificity(ruleScope) {
262
+ let s = 0;
263
+ for (const seg of ruleScope) if (seg !== "*") s++;
264
+ return s;
265
+ }
266
+
267
+ function packTrace(rule, spec) {
268
+ return {
269
+ op: rule.op,
270
+ scope: rule.scope.map(String),
271
+ specificity: spec,
272
+ filePath: rule.filePath,
273
+ line: rule.line,
274
+ col: rule.col ?? 0,
275
+ keyRaw: rule.keyRaw,
276
+ // Source map style location
277
+ source: `${rule.filePath}:${rule.line}:${rule.col ?? 0}`,
278
+ };
279
+ }
280
+
281
+ function clone(v) {
282
+ if (v === null || v === undefined) return v;
283
+ if (Array.isArray(v)) return v.map(clone);
284
+ if (typeof v === "object") return structuredClone(v);
285
+ return v;
286
+ }
package/src/secrets.js ADDED
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Secrets provider system for colony
3
+ */
4
+
5
+ import { isPlainObject } from "./util.js";
6
+
7
+ // Global provider registry
8
+ const globalRegistry = new Map();
9
+
10
+ /**
11
+ * Register a secret provider globally
12
+ * @param {object} provider - Provider with prefix and fetch()
13
+ */
14
+ export function registerSecretProvider(provider) {
15
+ if (!provider.prefix || typeof provider.fetch !== "function") {
16
+ throw new Error("Invalid provider: must have prefix and fetch()");
17
+ }
18
+ globalRegistry.set(provider.prefix.toUpperCase(), provider);
19
+ }
20
+
21
+ /**
22
+ * Unregister a provider by prefix
23
+ * @param {string} prefix - Provider prefix to remove
24
+ * @returns {boolean} True if provider was removed
25
+ */
26
+ export function unregisterSecretProvider(prefix) {
27
+ return globalRegistry.delete(prefix.toUpperCase());
28
+ }
29
+
30
+ /**
31
+ * Clear all registered providers
32
+ */
33
+ export function clearSecretProviders() {
34
+ globalRegistry.clear();
35
+ }
36
+
37
+ /**
38
+ * Check if any global providers are registered
39
+ * @returns {boolean}
40
+ */
41
+ export function hasGlobalProviders() {
42
+ return globalRegistry.size > 0;
43
+ }
44
+
45
+ /**
46
+ * Simple LRU cache for secrets
47
+ */
48
+ export class SecretCache {
49
+ constructor(maxSize = 100) {
50
+ this.maxSize = maxSize;
51
+ this.cache = new Map();
52
+ }
53
+
54
+ get(key) {
55
+ const entry = this.cache.get(key);
56
+ if (!entry) return undefined;
57
+ if (Date.now() > entry.expires) {
58
+ this.cache.delete(key);
59
+ return undefined;
60
+ }
61
+ // Move to end for LRU behavior
62
+ this.cache.delete(key);
63
+ this.cache.set(key, entry);
64
+ return entry.value;
65
+ }
66
+
67
+ set(key, value, ttl) {
68
+ if (this.cache.size >= this.maxSize) {
69
+ // Remove oldest (first) entry
70
+ const firstKey = this.cache.keys().next().value;
71
+ this.cache.delete(firstKey);
72
+ }
73
+ this.cache.set(key, { value, expires: Date.now() + ttl });
74
+ }
75
+
76
+ invalidate(pattern) {
77
+ if (!pattern) {
78
+ this.cache.clear();
79
+ return;
80
+ }
81
+ const regex = new RegExp("^" + globToRegex(pattern) + "$");
82
+ for (const key of this.cache.keys()) {
83
+ if (regex.test(key)) this.cache.delete(key);
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Convert a glob pattern to regex, escaping special chars except *
90
+ * @param {string} pattern - Glob pattern (e.g., "myapp/*")
91
+ * @returns {string} Regex pattern string
92
+ */
93
+ function globToRegex(pattern) {
94
+ return pattern
95
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&") // escape regex special chars
96
+ .replace(/\*/g, ".*"); // convert glob * to regex .*
97
+ }
98
+
99
+ // Regex to match secret interpolations: ${PROVIDER:key}
100
+ // Provider must start with uppercase letter, followed by uppercase letters, digits, or underscores
101
+ const RX_SECRET = /\$\{([A-Z][A-Z0-9_]*):([^}]+)\}/g;
102
+
103
+ // Reserved prefixes that are not secrets
104
+ const RESERVED = new Set(["ENV", "VAR"]);
105
+
106
+ /**
107
+ * Collect all secret references from a value tree
108
+ * @param {any} value - Value to scan
109
+ * @param {Map} refs - Map to collect refs into
110
+ * @returns {Map} Map of fullKey -> { provider, key }
111
+ */
112
+ export function collectSecretRefs(value, refs = new Map()) {
113
+ if (typeof value === "string") {
114
+ let match;
115
+ RX_SECRET.lastIndex = 0;
116
+ while ((match = RX_SECRET.exec(value)) !== null) {
117
+ const [, provider, key] = match;
118
+ if (RESERVED.has(provider)) continue;
119
+
120
+ const fullKey = `${provider}:${key.trim()}`;
121
+ if (!refs.has(fullKey)) {
122
+ refs.set(fullKey, { provider, key: key.trim() });
123
+ }
124
+ }
125
+ return refs;
126
+ }
127
+
128
+ if (Array.isArray(value)) {
129
+ for (const item of value) {
130
+ collectSecretRefs(item, refs);
131
+ }
132
+ return refs;
133
+ }
134
+
135
+ if (isPlainObject(value)) {
136
+ for (const v of Object.values(value)) {
137
+ collectSecretRefs(v, refs);
138
+ }
139
+ }
140
+
141
+ return refs;
142
+ }
143
+
144
+ /**
145
+ * Check if a secret key matches any allowed pattern
146
+ * @param {string} fullKey - Full key like "AWS:myapp/db"
147
+ * @param {string[]|null} allowedSecrets - Allowed patterns
148
+ * @returns {boolean}
149
+ */
150
+ function isAllowed(fullKey, allowedSecrets) {
151
+ if (allowedSecrets === null || allowedSecrets === undefined) return true;
152
+
153
+ for (const pattern of allowedSecrets) {
154
+ // Exact match
155
+ if (pattern === fullKey) return true;
156
+
157
+ // Glob pattern match on full key
158
+ const regex = new RegExp("^" + globToRegex(pattern) + "$");
159
+ if (regex.test(fullKey)) return true;
160
+
161
+ // Pattern without provider matches any provider
162
+ if (!pattern.includes(":")) {
163
+ const keyOnly = fullKey.split(":")[1];
164
+ const keyRegex = new RegExp("^" + globToRegex(pattern) + "$");
165
+ if (keyRegex.test(keyOnly)) return true;
166
+ }
167
+ }
168
+
169
+ return false;
170
+ }
171
+
172
+ /**
173
+ * Fetch all secrets and apply to value tree
174
+ * @param {any} value - Config value tree
175
+ * @param {object} options - Options
176
+ * @returns {Promise<any>} Value tree with secrets replaced
177
+ */
178
+ export async function applySecretsDeep(value, options = {}) {
179
+ const {
180
+ providers = [],
181
+ allowedSecrets = null,
182
+ cache = null,
183
+ cacheTtl = 300000,
184
+ onNotFound = "warn",
185
+ warnings = [],
186
+ } = options;
187
+
188
+ // Merge local providers with global registry
189
+ const registry = new Map(globalRegistry);
190
+ for (const p of providers) {
191
+ registry.set(p.prefix.toUpperCase(), p);
192
+ }
193
+
194
+ // Collect all secret references
195
+ const refs = collectSecretRefs(value);
196
+ if (refs.size === 0) return value;
197
+
198
+ // Fetch all secrets in parallel
199
+ const resolved = new Map();
200
+ const fetchPromises = [];
201
+
202
+ for (const [fullKey, { provider, key }] of refs) {
203
+ // Check allowlist
204
+ if (!isAllowed(fullKey, allowedSecrets)) {
205
+ warnings.push({
206
+ type: "blocked_secret",
207
+ provider,
208
+ key,
209
+ message: `Access to secret "${fullKey}" blocked by allowedSecrets`,
210
+ });
211
+ resolved.set(fullKey, "");
212
+ continue;
213
+ }
214
+
215
+ // Check cache
216
+ if (cache) {
217
+ const cached = cache.get(fullKey);
218
+ if (cached !== undefined) {
219
+ resolved.set(fullKey, cached);
220
+ continue;
221
+ }
222
+ }
223
+
224
+ // Check provider exists
225
+ const providerInstance = registry.get(provider);
226
+ if (!providerInstance) {
227
+ warnings.push({
228
+ type: "unknown_provider",
229
+ provider,
230
+ key,
231
+ message: `No provider registered for "${provider}"`,
232
+ });
233
+ resolved.set(fullKey, "");
234
+ continue;
235
+ }
236
+
237
+ // Queue fetch
238
+ fetchPromises.push(
239
+ providerInstance
240
+ .fetch(key)
241
+ .then((val) => {
242
+ const strVal = val ?? "";
243
+ if (cache) cache.set(fullKey, strVal, cacheTtl);
244
+ resolved.set(fullKey, strVal);
245
+ })
246
+ .catch((err) => {
247
+ const isNotFound =
248
+ err.code === "NOT_FOUND" ||
249
+ err.name === "ResourceNotFoundException" ||
250
+ err.message?.includes("not found");
251
+
252
+ if (isNotFound) {
253
+ if (onNotFound === "error") {
254
+ throw new Error(`COLONY: Secret not found: ${fullKey}`);
255
+ }
256
+ warnings.push({
257
+ type: "secret_not_found",
258
+ provider,
259
+ key,
260
+ message: `Secret "${fullKey}" not found`,
261
+ });
262
+ resolved.set(fullKey, "");
263
+ } else {
264
+ if (onNotFound === "error") {
265
+ throw err;
266
+ }
267
+ warnings.push({
268
+ type: "secret_fetch_error",
269
+ provider,
270
+ key,
271
+ message: `Failed to fetch "${fullKey}": ${err.message}`,
272
+ });
273
+ resolved.set(fullKey, "");
274
+ }
275
+ })
276
+ );
277
+ }
278
+
279
+ await Promise.all(fetchPromises);
280
+
281
+ // Apply resolved secrets to value tree
282
+ return replaceSecrets(value, resolved);
283
+ }
284
+
285
+ /**
286
+ * Replace secret placeholders with resolved values
287
+ * @param {any} value - Value to process
288
+ * @param {Map} resolved - Map of fullKey -> resolved value
289
+ * @returns {any} Value with secrets replaced
290
+ */
291
+ function replaceSecrets(value, resolved) {
292
+ if (typeof value === "string") {
293
+ return value.replace(RX_SECRET, (match, provider, key) => {
294
+ if (RESERVED.has(provider)) return match;
295
+ const fullKey = `${provider}:${key.trim()}`;
296
+ return resolved.get(fullKey) ?? "";
297
+ });
298
+ }
299
+
300
+ if (Array.isArray(value)) {
301
+ return value.map((v) => replaceSecrets(v, resolved));
302
+ }
303
+
304
+ if (isPlainObject(value)) {
305
+ const out = {};
306
+ for (const [k, v] of Object.entries(value)) {
307
+ out[k] = replaceSecrets(v, resolved);
308
+ }
309
+ return out;
310
+ }
311
+
312
+ return value;
313
+ }
package/src/strings.js ADDED
@@ -0,0 +1,84 @@
1
+ import { isPlainObject } from "./util.js";
2
+
3
+ // Regex to detect secret provider patterns: ${PROVIDER:key}
4
+ // Provider must start with uppercase letter, followed by uppercase letters, digits, or underscores
5
+ const RX_SECRET_PROVIDER = /^[A-Z][A-Z0-9_]*:/;
6
+
7
+ export function applyInterpolationDeep(value, { ctx, vars, allowedEnvVars = null, allowedVars = null, warnings = [] }) {
8
+ if (typeof value === "string") return interpolate(value, { ctx, vars, allowedEnvVars, allowedVars, warnings });
9
+ if (Array.isArray(value)) return value.map((v) => applyInterpolationDeep(v, { ctx, vars, allowedEnvVars, allowedVars, warnings }));
10
+ if (isPlainObject(value)) {
11
+ const out = {};
12
+ for (const [k, v] of Object.entries(value)) {
13
+ out[k] = applyInterpolationDeep(v, { ctx, vars, allowedEnvVars, allowedVars, warnings });
14
+ }
15
+ return out;
16
+ }
17
+ return value;
18
+ }
19
+
20
+ export function interpolate(s, { ctx, vars, allowedEnvVars = null, allowedVars = null, warnings = [] }) {
21
+ return s.replace(/\$\{([^}]+)\}/g, (match, exprRaw) => {
22
+ const expr = exprRaw.trim();
23
+
24
+ if (expr.startsWith("ENV:")) {
25
+ const k = expr.slice(4).trim();
26
+ // Security: check if env var is allowed
27
+ if (allowedEnvVars !== null && !allowedEnvVars.includes(k)) {
28
+ warnings.push({
29
+ type: "blocked_env_var",
30
+ var: k,
31
+ message: `Access to environment variable "${k}" blocked by allowedEnvVars whitelist`,
32
+ });
33
+ return "";
34
+ }
35
+ return process.env[k] ?? "";
36
+ }
37
+
38
+ if (expr.startsWith("VAR:")) {
39
+ const k = expr.slice(4).trim();
40
+ // Security: check if custom var is allowed
41
+ if (allowedVars !== null && !allowedVars.includes(k)) {
42
+ warnings.push({
43
+ type: "blocked_var",
44
+ var: k,
45
+ message: `Access to custom variable "${k}" blocked by allowedVars whitelist`,
46
+ });
47
+ return "";
48
+ }
49
+ if (vars?.[k] === undefined) {
50
+ warnings.push({
51
+ type: "unknown_var",
52
+ var: k,
53
+ message: `Unknown VAR "${k}" in interpolation ${match}`,
54
+ });
55
+ }
56
+ return String(vars?.[k] ?? "");
57
+ }
58
+
59
+ if (expr.startsWith("ctx.")) {
60
+ const k = expr.slice(4).trim();
61
+ if (ctx?.[k] === undefined) {
62
+ warnings.push({
63
+ type: "unknown_ctx",
64
+ var: k,
65
+ message: `Unknown ctx dimension "${k}" in interpolation ${match}`,
66
+ });
67
+ }
68
+ return String(ctx?.[k] ?? "");
69
+ }
70
+
71
+ // Secret provider patterns (e.g., ${AWS:...}, ${OPENBAO:...}) - leave for secrets.js
72
+ if (RX_SECRET_PROVIDER.test(expr)) {
73
+ return match; // Keep the pattern intact for later secret processing
74
+ }
75
+
76
+ // Unknown interpolation pattern
77
+ warnings.push({
78
+ type: "unknown_interpolation",
79
+ pattern: match,
80
+ message: `Unknown interpolation pattern: ${match}`,
81
+ });
82
+ return "";
83
+ });
84
+ }