@enspirit/emb 0.14.1 → 0.17.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 (60) hide show
  1. package/README.md +162 -43
  2. package/dist/src/cli/abstract/BaseCommand.d.ts +1 -0
  3. package/dist/src/cli/abstract/BaseCommand.js +23 -4
  4. package/dist/src/cli/abstract/FlavouredCommand.d.ts +1 -0
  5. package/dist/src/cli/abstract/KubernetesCommand.d.ts +1 -0
  6. package/dist/src/cli/commands/components/logs.d.ts +2 -1
  7. package/dist/src/cli/commands/components/logs.js +21 -24
  8. package/dist/src/cli/commands/secrets/index.d.ts +14 -0
  9. package/dist/src/cli/commands/secrets/index.js +71 -0
  10. package/dist/src/cli/commands/secrets/providers.d.ts +12 -0
  11. package/dist/src/cli/commands/secrets/providers.js +50 -0
  12. package/dist/src/cli/commands/secrets/validate.d.ts +18 -0
  13. package/dist/src/cli/commands/secrets/validate.js +145 -0
  14. package/dist/src/cli/hooks/init.js +7 -1
  15. package/dist/src/cli/hooks/postrun.d.ts +7 -0
  16. package/dist/src/cli/hooks/postrun.js +128 -0
  17. package/dist/src/config/index.d.ts +10 -1
  18. package/dist/src/config/index.js +28 -3
  19. package/dist/src/config/schema.d.ts +7 -4
  20. package/dist/src/config/schema.json +173 -9
  21. package/dist/src/context.d.ts +9 -0
  22. package/dist/src/context.js +19 -0
  23. package/dist/src/docker/compose/operations/ComposeLogsOperation.d.ts +21 -0
  24. package/dist/src/docker/compose/operations/ComposeLogsOperation.js +85 -0
  25. package/dist/src/docker/compose/operations/index.d.ts +1 -0
  26. package/dist/src/docker/compose/operations/index.js +1 -0
  27. package/dist/src/docker/resources/DockerImageResource.js +16 -6
  28. package/dist/src/index.d.ts +1 -0
  29. package/dist/src/index.js +1 -0
  30. package/dist/src/monorepo/monorepo.js +13 -5
  31. package/dist/src/monorepo/operations/resources/BuildResourcesOperation.js +1 -2
  32. package/dist/src/monorepo/operations/tasks/RunTasksOperation.d.ts +1 -1
  33. package/dist/src/monorepo/operations/tasks/RunTasksOperation.js +1 -1
  34. package/dist/src/monorepo/plugins/VaultPlugin.d.ts +46 -0
  35. package/dist/src/monorepo/plugins/VaultPlugin.js +91 -0
  36. package/dist/src/monorepo/plugins/index.d.ts +1 -0
  37. package/dist/src/monorepo/plugins/index.js +3 -0
  38. package/dist/src/monorepo/resources/index.d.ts +1 -0
  39. package/dist/src/monorepo/resources/index.js +1 -0
  40. package/dist/src/secrets/SecretDiscovery.d.ts +46 -0
  41. package/dist/src/secrets/SecretDiscovery.js +82 -0
  42. package/dist/src/secrets/SecretManager.d.ts +52 -0
  43. package/dist/src/secrets/SecretManager.js +75 -0
  44. package/dist/src/secrets/SecretProvider.d.ts +45 -0
  45. package/dist/src/secrets/SecretProvider.js +38 -0
  46. package/dist/src/secrets/index.d.ts +3 -0
  47. package/dist/src/secrets/index.js +3 -0
  48. package/dist/src/secrets/providers/VaultOidcHelper.d.ts +39 -0
  49. package/dist/src/secrets/providers/VaultOidcHelper.js +226 -0
  50. package/dist/src/secrets/providers/VaultProvider.d.ts +74 -0
  51. package/dist/src/secrets/providers/VaultProvider.js +266 -0
  52. package/dist/src/secrets/providers/VaultTokenCache.d.ts +60 -0
  53. package/dist/src/secrets/providers/VaultTokenCache.js +188 -0
  54. package/dist/src/secrets/providers/index.d.ts +2 -0
  55. package/dist/src/secrets/providers/index.js +2 -0
  56. package/dist/src/types.d.ts +2 -0
  57. package/dist/src/utils/TemplateExpander.d.ts +13 -1
  58. package/dist/src/utils/TemplateExpander.js +68 -15
  59. package/oclif.manifest.json +473 -68
  60. package/package.json +36 -30
@@ -0,0 +1,266 @@
1
+ /* eslint-disable n/no-unsupported-features/node-builtins -- fetch is stable in Node 20+ */
2
+ import { AbstractSecretProvider } from '../SecretProvider.js';
3
+ import { cacheToken, clearCachedToken, getCachedToken, } from './VaultTokenCache.js';
4
+ /**
5
+ * Error class for Vault-specific errors.
6
+ */
7
+ export class VaultError extends Error {
8
+ code;
9
+ statusCode;
10
+ constructor(message, code, statusCode) {
11
+ super(message);
12
+ this.code = code;
13
+ this.statusCode = statusCode;
14
+ this.name = 'VaultError';
15
+ }
16
+ }
17
+ /**
18
+ * HashiCorp Vault secret provider.
19
+ * Supports KV v2 secrets engine.
20
+ */
21
+ export class VaultProvider extends AbstractSecretProvider {
22
+ token = null;
23
+ async connect() {
24
+ const { auth, address, namespace } = this.config;
25
+ // Try to use cached token first (for methods that benefit from caching)
26
+ if (auth.method === 'oidc') {
27
+ const cached = await getCachedToken(address, namespace);
28
+ if (cached) {
29
+ this.token = cached.token;
30
+ try {
31
+ await this.verifyToken();
32
+ // Cached token is still valid
33
+ return;
34
+ }
35
+ catch {
36
+ // Cached token is invalid, clear it and proceed with fresh auth
37
+ await clearCachedToken(address, namespace);
38
+ this.token = null;
39
+ }
40
+ }
41
+ }
42
+ let authResult;
43
+ switch (auth.method) {
44
+ case 'approle': {
45
+ authResult = await this.loginAppRole(auth.roleId, auth.secretId);
46
+ break;
47
+ }
48
+ case 'jwt': {
49
+ authResult = await this.loginJwt(auth.role, auth.jwt);
50
+ break;
51
+ }
52
+ case 'kubernetes': {
53
+ authResult = await this.loginKubernetes(auth.role);
54
+ break;
55
+ }
56
+ case 'oidc': {
57
+ authResult = await this.loginOidc(auth.role, auth.port);
58
+ break;
59
+ }
60
+ case 'token': {
61
+ // For explicit tokens, we don't know the TTL - use a default
62
+ authResult = { token: auth.token, ttlSeconds: 3600 };
63
+ break;
64
+ }
65
+ default: {
66
+ throw new VaultError(`Unsupported auth method: ${auth.method}`, 'VAULT_AUTH_ERROR');
67
+ }
68
+ }
69
+ this.token = authResult.token;
70
+ // Verify the token works by looking it up
71
+ await this.verifyToken();
72
+ // Cache the token for methods that benefit from caching
73
+ if (auth.method === 'oidc' && authResult.ttlSeconds > 0) {
74
+ await cacheToken(address, authResult.token, authResult.ttlSeconds, namespace);
75
+ }
76
+ }
77
+ async disconnect() {
78
+ this.token = null;
79
+ this.clearCache();
80
+ }
81
+ async fetchSecret(ref) {
82
+ if (!this.token) {
83
+ throw new VaultError('Not connected to Vault', 'VAULT_NOT_CONNECTED');
84
+ }
85
+ // For KV v2, the path needs 'data' inserted after the mount point
86
+ // e.g., "secret/myapp" becomes "secret/data/myapp"
87
+ const path = this.normalizeKvPath(ref.path);
88
+ const url = new URL(`/v1/${path}`, this.config.address);
89
+ if (ref.version) {
90
+ url.searchParams.set('version', ref.version);
91
+ }
92
+ const response = await fetch(url.toString(), {
93
+ method: 'GET',
94
+ headers: this.buildHeaders(),
95
+ });
96
+ if (!response.ok) {
97
+ const error = await this.parseErrorResponse(response);
98
+ const namespace = this.config.namespace
99
+ ? ` (namespace: ${this.config.namespace})`
100
+ : '';
101
+ throw new VaultError(`Failed to read secret at '${ref.path}'${namespace}: ${error.message}`, 'VAULT_READ_ERROR', response.status);
102
+ }
103
+ const data = await response.json();
104
+ // KV v2 wraps the data in a 'data' field
105
+ return (data.data?.data ||
106
+ data.data ||
107
+ {});
108
+ }
109
+ /**
110
+ * Normalize a path for the appropriate secrets engine.
111
+ * - KV v2: Insert '/data/' after the mount point
112
+ * - 1Password Connect: Use path as-is (contains /vaults/ and /items/)
113
+ * - Other engines: Use path as-is
114
+ */
115
+ normalizeKvPath(path) {
116
+ // If path already contains '/data/', assume it's correctly formatted for KV v2
117
+ if (path.includes('/data/')) {
118
+ return path;
119
+ }
120
+ // 1Password Connect paths contain /vaults/ and /items/ - don't modify
121
+ if (path.includes('/vaults/') || path.includes('/items/')) {
122
+ return path;
123
+ }
124
+ // For KV v2, insert /data/ after the mount point
125
+ // Split by first '/' to get mount and rest of path
126
+ const firstSlash = path.indexOf('/');
127
+ if (firstSlash === -1) {
128
+ // Just a mount, no sub-path
129
+ return `${path}/data`;
130
+ }
131
+ const mount = path.slice(0, Math.max(0, firstSlash));
132
+ const subPath = path.slice(Math.max(0, firstSlash + 1));
133
+ return `${mount}/data/${subPath}`;
134
+ }
135
+ buildHeaders() {
136
+ const headers = {
137
+ 'X-Vault-Token': this.token,
138
+ 'Content-Type': 'application/json',
139
+ };
140
+ if (this.config.namespace) {
141
+ headers['X-Vault-Namespace'] = this.config.namespace;
142
+ }
143
+ return headers;
144
+ }
145
+ async loginAppRole(roleId, secretId) {
146
+ const url = new URL('/v1/auth/approle/login', this.config.address);
147
+ const response = await fetch(url.toString(), {
148
+ method: 'POST',
149
+ headers: {
150
+ 'Content-Type': 'application/json',
151
+ ...(this.config.namespace && {
152
+ 'X-Vault-Namespace': this.config.namespace,
153
+ }),
154
+ },
155
+ // Vault API uses snake_case for these properties
156
+ // eslint-disable-next-line camelcase
157
+ body: JSON.stringify({ role_id: roleId, secret_id: secretId }),
158
+ });
159
+ if (!response.ok) {
160
+ const error = await this.parseErrorResponse(response);
161
+ throw new VaultError(`AppRole login failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status);
162
+ }
163
+ const data = (await response.json());
164
+ return {
165
+ token: data.auth?.client_token || '',
166
+ ttlSeconds: data.auth?.lease_duration || 3600,
167
+ };
168
+ }
169
+ async loginKubernetes(role) {
170
+ // Read the service account token from the mounted file
171
+ const fs = await import('node:fs/promises');
172
+ const tokenPath = '/var/run/secrets/kubernetes.io/serviceaccount/token';
173
+ let jwt;
174
+ try {
175
+ jwt = await fs.readFile(tokenPath, 'utf8');
176
+ }
177
+ catch {
178
+ throw new VaultError(`Could not read Kubernetes service account token from ${tokenPath}`, 'VAULT_AUTH_ERROR');
179
+ }
180
+ const url = new URL('/v1/auth/kubernetes/login', this.config.address);
181
+ const response = await fetch(url.toString(), {
182
+ method: 'POST',
183
+ headers: {
184
+ 'Content-Type': 'application/json',
185
+ ...(this.config.namespace && {
186
+ 'X-Vault-Namespace': this.config.namespace,
187
+ }),
188
+ },
189
+ body: JSON.stringify({ role, jwt }),
190
+ });
191
+ if (!response.ok) {
192
+ const error = await this.parseErrorResponse(response);
193
+ throw new VaultError(`Kubernetes login failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status);
194
+ }
195
+ const data = (await response.json());
196
+ return {
197
+ token: data.auth?.client_token || '',
198
+ ttlSeconds: data.auth?.lease_duration || 3600,
199
+ };
200
+ }
201
+ /**
202
+ * Authenticate using JWT (non-interactive).
203
+ * Suitable for CI/CD pipelines where a JWT is provided externally.
204
+ */
205
+ async loginJwt(role, jwt) {
206
+ const url = new URL('/v1/auth/jwt/login', this.config.address);
207
+ const response = await fetch(url.toString(), {
208
+ method: 'POST',
209
+ headers: {
210
+ 'Content-Type': 'application/json',
211
+ ...(this.config.namespace && {
212
+ 'X-Vault-Namespace': this.config.namespace,
213
+ }),
214
+ },
215
+ body: JSON.stringify({ role, jwt }),
216
+ });
217
+ if (!response.ok) {
218
+ const error = await this.parseErrorResponse(response);
219
+ throw new VaultError(`JWT login failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status);
220
+ }
221
+ const data = (await response.json());
222
+ return {
223
+ token: data.auth?.client_token || '',
224
+ ttlSeconds: data.auth?.lease_duration || 3600,
225
+ };
226
+ }
227
+ /**
228
+ * Authenticate using OIDC (interactive browser flow).
229
+ * Opens a browser for the user to authenticate with Keycloak/OIDC provider.
230
+ */
231
+ async loginOidc(role, port) {
232
+ const { performOidcLogin } = await import('./VaultOidcHelper.js');
233
+ const result = await performOidcLogin({
234
+ vaultAddress: this.config.address,
235
+ role,
236
+ port: port ?? 8250,
237
+ namespace: this.config.namespace,
238
+ });
239
+ return {
240
+ token: result.token,
241
+ ttlSeconds: result.ttlSeconds,
242
+ };
243
+ }
244
+ async verifyToken() {
245
+ const url = new URL('/v1/auth/token/lookup-self', this.config.address);
246
+ const response = await fetch(url.toString(), {
247
+ method: 'GET',
248
+ headers: this.buildHeaders(),
249
+ });
250
+ if (!response.ok) {
251
+ const error = await this.parseErrorResponse(response);
252
+ throw new VaultError(`Token verification failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status);
253
+ }
254
+ }
255
+ async parseErrorResponse(response) {
256
+ try {
257
+ const data = (await response.json());
258
+ return {
259
+ message: data.errors?.join(', ') || `HTTP ${response.status}`,
260
+ };
261
+ }
262
+ catch {
263
+ return { message: `HTTP ${response.status}: ${response.statusText}` };
264
+ }
265
+ }
266
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Cached token metadata stored on disk.
3
+ */
4
+ export interface CachedToken {
5
+ /** Unix timestamp (ms) when the token was cached */
6
+ createdAt: number;
7
+ /** Unix timestamp (ms) when the token expires */
8
+ expiresAt: number;
9
+ /** Vault namespace (if any) */
10
+ namespace?: string;
11
+ /** The Vault client token */
12
+ token: string;
13
+ /** Vault address this token is for */
14
+ vaultAddress: string;
15
+ }
16
+ /**
17
+ * Options for token caching.
18
+ */
19
+ export interface TokenCacheOptions {
20
+ /** Custom cache directory (default: ~/.emb/vault-tokens) */
21
+ cacheDir?: string;
22
+ /** Buffer time in ms before expiry to consider token invalid (default: 5 minutes) */
23
+ expiryBuffer?: number;
24
+ }
25
+ /**
26
+ * Retrieve a cached token if it exists and is still valid.
27
+ *
28
+ * @param vaultAddress - The Vault server address
29
+ * @param namespace - Optional Vault namespace
30
+ * @param options - Cache options
31
+ * @returns The cached token or null if not found/expired
32
+ */
33
+ export declare function getCachedToken(vaultAddress: string, namespace?: string, options?: TokenCacheOptions): Promise<CachedToken | null>;
34
+ /**
35
+ * Cache a Vault token to disk (encrypted).
36
+ *
37
+ * @param vaultAddress - The Vault server address
38
+ * @param token - The Vault client token
39
+ * @param ttlSeconds - Token TTL in seconds (from Vault's lease_duration)
40
+ * @param namespace - Optional Vault namespace
41
+ * @param options - Cache options
42
+ */
43
+ export declare function cacheToken(vaultAddress: string, token: string, ttlSeconds: number, namespace?: string, options?: TokenCacheOptions): Promise<void>;
44
+ /**
45
+ * Clear a cached token.
46
+ *
47
+ * @param vaultAddress - The Vault server address
48
+ * @param namespace - Optional Vault namespace
49
+ * @param options - Cache options
50
+ */
51
+ export declare function clearCachedToken(vaultAddress: string, namespace?: string, options?: TokenCacheOptions): Promise<void>;
52
+ /**
53
+ * Check if a cached token exists and is valid (without returning the token).
54
+ *
55
+ * @param vaultAddress - The Vault server address
56
+ * @param namespace - Optional Vault namespace
57
+ * @param options - Cache options
58
+ * @returns True if a valid cached token exists
59
+ */
60
+ export declare function hasCachedToken(vaultAddress: string, namespace?: string, options?: TokenCacheOptions): Promise<boolean>;
@@ -0,0 +1,188 @@
1
+ import { createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomBytes, } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir, hostname, userInfo } from 'node:os';
4
+ import { join } from 'node:path';
5
+ const DEFAULT_EXPIRY_BUFFER = 5 * 60 * 1000; // 5 minutes
6
+ const DEFAULT_CACHE_DIR = join(homedir(), '.emb', 'vault-tokens');
7
+ // Encryption constants
8
+ const ALGORITHM = 'aes-256-gcm';
9
+ const IV_LENGTH = 16;
10
+ const _AUTH_TAG_LENGTH = 16;
11
+ const SALT_LENGTH = 32;
12
+ const KEY_LENGTH = 32;
13
+ const PBKDF2_ITERATIONS = 100_000;
14
+ /**
15
+ * Derive an encryption key from machine-specific data.
16
+ * The key is derived from hostname + username + a static pepper,
17
+ * making the cache file unusable if copied to another machine or user.
18
+ */
19
+ function deriveKey(salt) {
20
+ const machineId = `${hostname()}:${userInfo().username}:emb-vault-cache`;
21
+ return pbkdf2Sync(machineId, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
22
+ }
23
+ /**
24
+ * Encrypt data using AES-256-GCM.
25
+ */
26
+ function encrypt(data) {
27
+ const salt = randomBytes(SALT_LENGTH);
28
+ const key = deriveKey(salt);
29
+ const iv = randomBytes(IV_LENGTH);
30
+ const cipher = createCipheriv(ALGORITHM, key, iv);
31
+ const encrypted = Buffer.concat([
32
+ cipher.update(data, 'utf8'),
33
+ cipher.final(),
34
+ ]);
35
+ const authTag = cipher.getAuthTag();
36
+ return {
37
+ version: 1,
38
+ salt: salt.toString('hex'),
39
+ iv: iv.toString('hex'),
40
+ authTag: authTag.toString('hex'),
41
+ encrypted: encrypted.toString('hex'),
42
+ };
43
+ }
44
+ /**
45
+ * Decrypt data using AES-256-GCM.
46
+ * Returns null if decryption fails (wrong machine, corrupted data, etc.)
47
+ */
48
+ function decrypt(file) {
49
+ try {
50
+ if (file.version !== 1) {
51
+ return null;
52
+ }
53
+ const salt = Buffer.from(file.salt, 'hex');
54
+ const key = deriveKey(salt);
55
+ const iv = Buffer.from(file.iv, 'hex');
56
+ const authTag = Buffer.from(file.authTag, 'hex');
57
+ const encrypted = Buffer.from(file.encrypted, 'hex');
58
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
59
+ decipher.setAuthTag(authTag);
60
+ const decrypted = Buffer.concat([
61
+ decipher.update(encrypted),
62
+ decipher.final(),
63
+ ]);
64
+ return decrypted.toString('utf8');
65
+ }
66
+ catch {
67
+ // Decryption failed - wrong key (different machine/user) or corrupted data
68
+ return null;
69
+ }
70
+ }
71
+ /**
72
+ * Generate a cache key for a Vault address and namespace combination.
73
+ * Uses a hash to create safe filenames.
74
+ */
75
+ function getCacheKey(vaultAddress, namespace) {
76
+ const input = namespace ? `${vaultAddress}::${namespace}` : vaultAddress;
77
+ return createHash('sha256').update(input).digest('hex').slice(0, 16);
78
+ }
79
+ /**
80
+ * Get the path to the cache file for a given Vault address.
81
+ */
82
+ function getCachePath(vaultAddress, namespace, cacheDir = DEFAULT_CACHE_DIR) {
83
+ const key = getCacheKey(vaultAddress, namespace);
84
+ return join(cacheDir, `${key}.json`);
85
+ }
86
+ /**
87
+ * Retrieve a cached token if it exists and is still valid.
88
+ *
89
+ * @param vaultAddress - The Vault server address
90
+ * @param namespace - Optional Vault namespace
91
+ * @param options - Cache options
92
+ * @returns The cached token or null if not found/expired
93
+ */
94
+ export async function getCachedToken(vaultAddress, namespace, options = {}) {
95
+ const { expiryBuffer = DEFAULT_EXPIRY_BUFFER, cacheDir } = options;
96
+ const cachePath = getCachePath(vaultAddress, namespace, cacheDir);
97
+ try {
98
+ const content = await readFile(cachePath, 'utf8');
99
+ const encryptedFile = JSON.parse(content);
100
+ // Decrypt the cached data
101
+ const decrypted = decrypt(encryptedFile);
102
+ if (!decrypted) {
103
+ // Decryption failed - likely different machine/user or corrupted
104
+ await clearCachedToken(vaultAddress, namespace, options);
105
+ return null;
106
+ }
107
+ const cached = JSON.parse(decrypted);
108
+ // Verify the cached token matches the requested address/namespace
109
+ if (cached.vaultAddress !== vaultAddress ||
110
+ cached.namespace !== namespace) {
111
+ return null;
112
+ }
113
+ // Check if token is expired or close to expiry
114
+ const now = Date.now();
115
+ if (cached.expiresAt - expiryBuffer <= now) {
116
+ // Token is expired or about to expire, clear it
117
+ await clearCachedToken(vaultAddress, namespace, options);
118
+ return null;
119
+ }
120
+ return cached;
121
+ }
122
+ catch {
123
+ // File doesn't exist or is invalid
124
+ return null;
125
+ }
126
+ }
127
+ /**
128
+ * Cache a Vault token to disk (encrypted).
129
+ *
130
+ * @param vaultAddress - The Vault server address
131
+ * @param token - The Vault client token
132
+ * @param ttlSeconds - Token TTL in seconds (from Vault's lease_duration)
133
+ * @param namespace - Optional Vault namespace
134
+ * @param options - Cache options
135
+ */
136
+ // eslint-disable-next-line max-params
137
+ export async function cacheToken(vaultAddress, token, ttlSeconds, namespace, options = {}) {
138
+ const { cacheDir = DEFAULT_CACHE_DIR } = options;
139
+ const cachePath = getCachePath(vaultAddress, namespace, cacheDir);
140
+ const now = Date.now();
141
+ const cached = {
142
+ token,
143
+ expiresAt: now + ttlSeconds * 1000,
144
+ createdAt: now,
145
+ namespace,
146
+ vaultAddress,
147
+ };
148
+ // Encrypt the token data
149
+ const encryptedFile = encrypt(JSON.stringify(cached));
150
+ // Ensure cache directory exists
151
+ await mkdir(cacheDir, { recursive: true, mode: 0o700 });
152
+ // Write the encrypted cache file with restricted permissions
153
+ await writeFile(cachePath, JSON.stringify(encryptedFile, null, 2), {
154
+ mode: 0o600,
155
+ encoding: 'utf8',
156
+ });
157
+ // Ensure permissions are correct (writeFile mode may not work on all platforms)
158
+ await chmod(cachePath, 0o600);
159
+ }
160
+ /**
161
+ * Clear a cached token.
162
+ *
163
+ * @param vaultAddress - The Vault server address
164
+ * @param namespace - Optional Vault namespace
165
+ * @param options - Cache options
166
+ */
167
+ export async function clearCachedToken(vaultAddress, namespace, options = {}) {
168
+ const { cacheDir } = options;
169
+ const cachePath = getCachePath(vaultAddress, namespace, cacheDir);
170
+ try {
171
+ await rm(cachePath);
172
+ }
173
+ catch {
174
+ // Ignore errors if file doesn't exist
175
+ }
176
+ }
177
+ /**
178
+ * Check if a cached token exists and is valid (without returning the token).
179
+ *
180
+ * @param vaultAddress - The Vault server address
181
+ * @param namespace - Optional Vault namespace
182
+ * @param options - Cache options
183
+ * @returns True if a valid cached token exists
184
+ */
185
+ export async function hasCachedToken(vaultAddress, namespace, options = {}) {
186
+ const cached = await getCachedToken(vaultAddress, namespace, options);
187
+ return cached !== null;
188
+ }
@@ -0,0 +1,2 @@
1
+ export * from './VaultProvider.js';
2
+ export * from './VaultTokenCache.js';
@@ -0,0 +1,2 @@
1
+ export * from './VaultProvider.js';
2
+ export * from './VaultTokenCache.js';
@@ -1,6 +1,7 @@
1
1
  import type Docker from 'dockerode';
2
2
  import { AppsV1Api, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
3
3
  import { Monorepo } from './monorepo/index.js';
4
+ import { SecretManager } from './secrets/index.js';
4
5
  import { DockerComposeClient } from './docker/index.js';
5
6
  /**
6
7
  * The context is meant to be what all plugins can decorate
@@ -18,4 +19,5 @@ export interface EmbContext {
18
19
  core: CoreV1Api;
19
20
  };
20
21
  monorepo: Monorepo;
22
+ secrets: SecretManager;
21
23
  }
@@ -1,6 +1,18 @@
1
+ /**
2
+ * An async source is a function that returns a promise resolving to the value.
3
+ */
4
+ type AsyncSource = (key: string) => Promise<unknown>;
5
+ /**
6
+ * A static source is a simple key-value record.
7
+ */
8
+ type StaticSource = Record<string, unknown>;
9
+ /**
10
+ * A source can be either static or async.
11
+ */
12
+ type Source = AsyncSource | StaticSource;
1
13
  type ExpandOptions = {
2
14
  default?: string;
3
- sources?: Record<string, Record<string, unknown>>;
15
+ sources?: Record<string, Source>;
4
16
  };
5
17
  export type ExpansionHistory = {
6
18
  source: string;
@@ -1,31 +1,84 @@
1
- const TPL_REGEX = /(?<!\\)\${(?:(\w+):)?(\w+)(?::-(.*?))?}/g;
1
+ // Matches ${source:key} or ${key} patterns
2
+ // - Source name: word characters only (e.g., "vault", "env")
3
+ // - Key: word characters, slashes, hashes, dots, with hyphens allowed between segments
4
+ // (e.g., "secret/path#field", "MY_VAR", "secret/my-app#key")
5
+ // - Optional fallback: :-value
6
+ // The key pattern uses (?:-[\w/#.]+)* to allow hyphens only between valid segments,
7
+ // preventing the key from consuming the :- fallback delimiter.
8
+ const TPL_REGEX = /(?<!\\)\${(?:(\w+):)?([\w/#.]+(?:-[\w/#.]+)*)(?::-(.*?))?}/g;
9
+ /**
10
+ * Check if a source is async (a function).
11
+ */
12
+ function isAsyncSource(source) {
13
+ return typeof source === 'function';
14
+ }
2
15
  export class TemplateExpander {
3
16
  expansions = [];
4
17
  get expansionCount() {
5
18
  return this.expansions.length;
6
19
  }
7
20
  async expand(str, options = {}) {
8
- return (str || '')
9
- .toString()
10
- .replaceAll(TPL_REGEX, (match, source, key, fallback) => {
11
- const src = source ?? options.default ?? '';
12
- const provider = options.sources?.[src];
13
- if (!provider) {
21
+ const input = (str || '').toString();
22
+ // Collect all matches with their positions
23
+ const matches = [...input.matchAll(TPL_REGEX)];
24
+ if (matches.length === 0) {
25
+ return input.replaceAll('\\${', '${');
26
+ }
27
+ // Resolve all values (async for function sources, sync for objects)
28
+ const resolutions = await Promise.all(matches.map(async (match) => {
29
+ const [fullMatch, sourceName, key, fallback] = match;
30
+ const src = sourceName ?? options.default ?? '';
31
+ const source = options.sources?.[src];
32
+ // No source found
33
+ if (!source) {
14
34
  if (fallback !== undefined) {
15
- return this.track(src, key, fallback);
35
+ return {
36
+ match: fullMatch,
37
+ value: this.track(src, key, fallback),
38
+ };
39
+ }
40
+ throw new Error(`Invalid expand provider '${sourceName}' ('${fullMatch}')`);
41
+ }
42
+ // Resolve value based on source type
43
+ let val;
44
+ if (isAsyncSource(source)) {
45
+ try {
46
+ val = await source(key);
16
47
  }
17
- throw new Error(`Invalid expand provider '${source}' ('${match}')`);
48
+ catch (error) {
49
+ if (fallback !== undefined) {
50
+ return {
51
+ match: fullMatch,
52
+ value: this.track(src, key, fallback),
53
+ };
54
+ }
55
+ throw error;
56
+ }
57
+ }
58
+ else {
59
+ val = source[key];
18
60
  }
19
- const val = provider[key];
61
+ // Handle missing values
20
62
  if (!val && fallback === undefined) {
21
- throw new Error(`Could not expand '${match}' and no default value provided`);
63
+ throw new Error(`Could not expand '${fullMatch}' and no default value provided`);
22
64
  }
23
65
  if (val !== undefined && val !== null) {
24
- return this.track(src, key, val);
66
+ return {
67
+ match: fullMatch,
68
+ value: this.track(src, key, val),
69
+ };
25
70
  }
26
- return this.track(src, key, fallback ?? '');
27
- })
28
- .replaceAll('\\${', '${');
71
+ return {
72
+ match: fullMatch,
73
+ value: this.track(src, key, fallback ?? ''),
74
+ };
75
+ }));
76
+ // Build result string by replacing matches with resolved values
77
+ let result = input;
78
+ for (const { match, value } of resolutions) {
79
+ result = result.replace(match, value);
80
+ }
81
+ return result.replaceAll('\\${', '${');
29
82
  }
30
83
  async expandRecord(record, options) {
31
84
  if (typeof record === 'string') {