@kibibit/configit 2.12.2 → 2.13.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 (38) hide show
  1. package/lib/config.service.d.ts +2 -1
  2. package/lib/config.service.d.ts.map +1 -1
  3. package/lib/config.service.js +5 -0
  4. package/lib/config.service.js.map +1 -1
  5. package/lib/tsconfig.tsbuildinfo +1 -1
  6. package/lib/vault/__tests__/vault-integration.test.d.ts.map +1 -1
  7. package/lib/vault/__tests__/vault-integration.test.js +124 -0
  8. package/lib/vault/__tests__/vault-integration.test.js.map +1 -1
  9. package/lib/vault/build-vault-config.d.ts +8 -0
  10. package/lib/vault/build-vault-config.d.ts.map +1 -0
  11. package/lib/vault/build-vault-config.js +41 -0
  12. package/lib/vault/build-vault-config.js.map +1 -0
  13. package/lib/vault/index.d.ts +2 -0
  14. package/lib/vault/index.d.ts.map +1 -1
  15. package/lib/vault/index.js +3 -1
  16. package/lib/vault/index.js.map +1 -1
  17. package/lib/vault/secret-refresh-manager.d.ts +6 -2
  18. package/lib/vault/secret-refresh-manager.d.ts.map +1 -1
  19. package/lib/vault/secret-refresh-manager.js +91 -27
  20. package/lib/vault/secret-refresh-manager.js.map +1 -1
  21. package/lib/vault/types.d.ts +9 -0
  22. package/lib/vault/types.d.ts.map +1 -1
  23. package/lib/vault/vault-integration.d.ts +2 -1
  24. package/lib/vault/vault-integration.d.ts.map +1 -1
  25. package/lib/vault/vault-integration.js +6 -0
  26. package/lib/vault/vault-integration.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/config.service.ts +21 -1
  29. package/src/config.service.vault.spec.ts +47 -1
  30. package/src/vault/__tests__/build-vault-config.spec.ts +116 -0
  31. package/src/vault/__tests__/secret-refresh-manager.spec.ts +328 -0
  32. package/src/vault/__tests__/vault-integration-callback.spec.ts +73 -0
  33. package/src/vault/__tests__/vault-integration.test.ts +174 -2
  34. package/src/vault/build-vault-config.ts +73 -0
  35. package/src/vault/index.ts +4 -0
  36. package/src/vault/secret-refresh-manager.ts +123 -71
  37. package/src/vault/types.ts +38 -0
  38. package/src/vault/vault-integration.ts +14 -1
@@ -9,8 +9,9 @@
9
9
 
10
10
  import { IsString } from 'class-validator';
11
11
 
12
- import { VaultKey, VaultPath } from '../decorators';
13
- import { IVaultConfigOptions } from '../types';
12
+ import { VaultEngine, VaultKey, VaultPath } from '../decorators';
13
+ import { buildVaultConfigFromEnv } from '../build-vault-config';
14
+ import { IVaultConfigOptions, SecretRefreshEvent } from '../types';
14
15
  import { VaultIntegration } from '../vault-integration';
15
16
 
16
17
  import 'reflect-metadata';
@@ -176,6 +177,177 @@ describeVault('VaultIntegration (requires running Vault)', () => {
176
177
  .toThrow(/authentication.*failed/i);
177
178
  });
178
179
  });
180
+
181
+ describe('onSecretRefreshed callback', () => {
182
+ it('should fire callback with correct event when configured via constructor', async () => {
183
+ const events: SecretRefreshEvent[] = [];
184
+ const configWithCallback: IVaultConfigOptions = {
185
+ ...VAULT_CONFIG,
186
+ onSecretRefreshed: (event) => { events.push(event); }
187
+ };
188
+
189
+ vaultIntegration = new VaultIntegration(configWithCallback);
190
+ await vaultIntegration.initialize();
191
+ await vaultIntegration.loadSecrets(TestVaultConfig);
192
+
193
+ expect(events).toHaveLength(0);
194
+ });
195
+
196
+ it('should fire callback via runtime registration', async () => {
197
+ const events: SecretRefreshEvent[] = [];
198
+
199
+ vaultIntegration = new VaultIntegration(VAULT_CONFIG);
200
+ vaultIntegration.onSecretRefreshed((event) => { events.push(event); });
201
+ await vaultIntegration.initialize();
202
+ await vaultIntegration.loadSecrets(TestVaultConfig);
203
+
204
+ expect(events).toHaveLength(0);
205
+ });
206
+ });
207
+
208
+ describe('dynamic secrets with TTL-based refresh', () => {
209
+ /**
210
+ * Config class using the database engine with dynamic credentials.
211
+ * database/creds/configit-readonly has a 60s TTL in the test Vault setup.
212
+ */
213
+ class DynamicConfig {
214
+ @VaultPath('creds/configit-readonly')
215
+ @VaultKey('username')
216
+ @VaultEngine('database')
217
+ @IsString()
218
+ DB_USERNAME!: string;
219
+
220
+ @VaultPath('creds/configit-readonly')
221
+ @VaultKey('password')
222
+ @VaultEngine('database')
223
+ @IsString()
224
+ DB_PASSWORD!: string;
225
+ }
226
+
227
+ it('should load dynamic database credentials', async () => {
228
+ vaultIntegration = new VaultIntegration({
229
+ ...VAULT_CONFIG,
230
+ refreshBuffer: 30
231
+ });
232
+ await vaultIntegration.initialize();
233
+ await vaultIntegration.loadSecrets(DynamicConfig);
234
+
235
+ const username = vaultIntegration.getSecret('DB_USERNAME');
236
+ const password = vaultIntegration.getSecret('DB_PASSWORD');
237
+
238
+ expect(username).toBeDefined();
239
+ expect(username).toMatch(/^v-token-/);
240
+ expect(password).toBeDefined();
241
+ expect(password!.length).toBeGreaterThan(0);
242
+ });
243
+
244
+ it('should schedule refresh for dynamic secrets', async () => {
245
+ vaultIntegration = new VaultIntegration({
246
+ ...VAULT_CONFIG,
247
+ refreshBuffer: 30
248
+ });
249
+ await vaultIntegration.initialize();
250
+ await vaultIntegration.loadSecrets(DynamicConfig);
251
+
252
+ const health = vaultIntegration.getHealthDetails();
253
+ expect(health.refreshQueueSize).toBeGreaterThanOrEqual(1);
254
+
255
+ const dbUserStatus = health.refreshStatus.find((s) => s.propertyName === 'DB_USERNAME');
256
+ expect(dbUserStatus).toBeDefined();
257
+ expect(dbUserStatus!.scheduled).toBe(true);
258
+ expect(dbUserStatus!.timeUntilRefresh).toBeLessThanOrEqual(60000);
259
+ });
260
+
261
+ it('should perform path-level atomic refresh and fire callback', async () => {
262
+ const events: SecretRefreshEvent[] = [];
263
+
264
+ vaultIntegration = new VaultIntegration({
265
+ ...VAULT_CONFIG,
266
+ refreshBuffer: 55, // 60s TTL minus 55s buffer = refresh after ~5s
267
+ onSecretRefreshed: (event) => { events.push(event); }
268
+ });
269
+ await vaultIntegration.initialize();
270
+ await vaultIntegration.loadSecrets(DynamicConfig);
271
+
272
+ const initialUser = vaultIntegration.getSecret('DB_USERNAME');
273
+ const initialPass = vaultIntegration.getSecret('DB_PASSWORD');
274
+
275
+ // Wait for the refresh to trigger (~5s + some margin)
276
+ await new Promise((resolve) => setTimeout(resolve, 10000));
277
+
278
+ const refreshedUser = vaultIntegration.getSecret('DB_USERNAME');
279
+ const refreshedPass = vaultIntegration.getSecret('DB_PASSWORD');
280
+
281
+ // Credentials should have changed
282
+ expect(refreshedUser).not.toBe(initialUser);
283
+ expect(refreshedPass).not.toBe(initialPass);
284
+
285
+ // Exactly one callback event for the path
286
+ expect(events.length).toBeGreaterThanOrEqual(1);
287
+ const dbEvent = events.find((e) => e.engine === 'database');
288
+ expect(dbEvent).toBeDefined();
289
+ expect(dbEvent!.properties).toContain('DB_USERNAME');
290
+ expect(dbEvent!.properties).toContain('DB_PASSWORD');
291
+ expect(dbEvent!.vaultPath).toContain('configit-readonly');
292
+ expect(dbEvent!.refreshCount).toBeGreaterThanOrEqual(1);
293
+ }, 20000);
294
+ });
295
+
296
+ describe('buildVaultConfigFromEnv end-to-end', () => {
297
+ const savedEnv: Record<string, string | undefined> = {};
298
+
299
+ beforeEach(() => {
300
+ savedEnv.VAULT_ADDR = process.env.VAULT_ADDR;
301
+ savedEnv.VAULT_TOKEN = process.env.VAULT_TOKEN;
302
+ savedEnv.VAULT_GCP_ROLE = process.env.VAULT_GCP_ROLE;
303
+ });
304
+
305
+ afterEach(() => {
306
+ for (const [key, val] of Object.entries(savedEnv)) {
307
+ if (val !== undefined) {
308
+ process.env[key] = val;
309
+ } else {
310
+ delete process.env[key];
311
+ }
312
+ }
313
+ });
314
+
315
+ it('should produce a config that VaultIntegration can use', async () => {
316
+ process.env.VAULT_ADDR = 'http://127.0.0.1:8200';
317
+ process.env.VAULT_TOKEN = 'configit-dev-token';
318
+ delete process.env.VAULT_GCP_ROLE;
319
+
320
+ const config = buildVaultConfigFromEnv();
321
+ expect(config).toBeDefined();
322
+
323
+ vaultIntegration = new VaultIntegration(config!);
324
+ await vaultIntegration.initialize();
325
+ expect(vaultIntegration.isInitialized()).toBe(true);
326
+
327
+ await vaultIntegration.loadSecrets(TestVaultConfig);
328
+ expect(vaultIntegration.getSecret('API_KEY')).toBe('test-api-key-123');
329
+ });
330
+
331
+ it('should wire onSecretRefreshed callback through to VaultIntegration', async () => {
332
+ process.env.VAULT_ADDR = 'http://127.0.0.1:8200';
333
+ process.env.VAULT_TOKEN = 'configit-dev-token';
334
+ delete process.env.VAULT_GCP_ROLE;
335
+
336
+ const events: SecretRefreshEvent[] = [];
337
+ const config = buildVaultConfigFromEnv({
338
+ onSecretRefreshed: (event) => { events.push(event); }
339
+ });
340
+ expect(config).toBeDefined();
341
+ expect(config!.onSecretRefreshed).toBeDefined();
342
+
343
+ vaultIntegration = new VaultIntegration(config!);
344
+ await vaultIntegration.initialize();
345
+ await vaultIntegration.loadSecrets(TestVaultConfig);
346
+
347
+ // No events yet (KV secrets have no TTL-based refresh in this setup)
348
+ expect(events).toHaveLength(0);
349
+ });
350
+ });
179
351
  });
180
352
 
181
353
  /**
@@ -0,0 +1,73 @@
1
+ /**
2
+ * buildVaultConfigFromEnv
3
+ *
4
+ * Builds IVaultConfigOptions from standard environment variables.
5
+ * Returns undefined when Vault is not configured, causing configit
6
+ * to fall back to env vars / config files.
7
+ *
8
+ * Supported auth flows (checked in order):
9
+ * 1. VAULT_TOKEN → Token auth (local development)
10
+ * 2. VAULT_GCP_ROLE → GCP IAM auth (production / GKE)
11
+ *
12
+ * Common env vars:
13
+ * VAULT_ADDR → Vault server URL (required)
14
+ */
15
+
16
+ import { IVaultConfigOptions, IVaultFallbackConfig, SecretRefreshCallback } from './types';
17
+
18
+ export interface IBuildVaultConfigOptions {
19
+ /** Seconds before expiry to trigger refresh. Default: 10 (token) / 60 (GCP) */
20
+ refreshBuffer?: number;
21
+
22
+ /** Fallback behavior when Vault is unavailable */
23
+ fallback?: IVaultFallbackConfig;
24
+
25
+ /** Callback fired when secrets are refreshed */
26
+ onSecretRefreshed?: SecretRefreshCallback;
27
+ }
28
+
29
+ const DEFAULT_FALLBACK: IVaultFallbackConfig = {
30
+ required: false,
31
+ useCacheOnFailure: true,
32
+ maxCacheAge: 3600000,
33
+ failFast: false
34
+ };
35
+
36
+ export function buildVaultConfigFromEnv(
37
+ options?: IBuildVaultConfigOptions
38
+ ): IVaultConfigOptions | undefined {
39
+ const vaultAddr = process.env.VAULT_ADDR;
40
+ const vaultToken = process.env.VAULT_TOKEN;
41
+ const vaultRole = process.env.VAULT_GCP_ROLE;
42
+
43
+ if (!vaultAddr) {
44
+ // eslint-disable-next-line no-undefined
45
+ return undefined;
46
+ }
47
+
48
+ const fallback = options?.fallback ?? DEFAULT_FALLBACK;
49
+ const onSecretRefreshed = options?.onSecretRefreshed;
50
+
51
+ if (vaultToken) {
52
+ return {
53
+ endpoint: vaultAddr,
54
+ auth: { method: 'token' as const, token: vaultToken },
55
+ refreshBuffer: options?.refreshBuffer ?? 10,
56
+ fallback,
57
+ onSecretRefreshed
58
+ };
59
+ }
60
+
61
+ if (vaultRole) {
62
+ return {
63
+ endpoint: vaultAddr,
64
+ auth: { method: 'gcp' as const, role: vaultRole },
65
+ refreshBuffer: options?.refreshBuffer ?? 60,
66
+ fallback,
67
+ onSecretRefreshed
68
+ };
69
+ }
70
+
71
+ // eslint-disable-next-line no-undefined
72
+ return undefined;
73
+ }
@@ -29,3 +29,7 @@ export { VaultProvider } from './vault-provider';
29
29
  export { VaultCache } from './vault-cache';
30
30
  export { SecretRefreshManager } from './secret-refresh-manager';
31
31
  export { VaultIntegration } from './vault-integration';
32
+
33
+ // Helpers
34
+ export { buildVaultConfigFromEnv } from './build-vault-config';
35
+ export type { IBuildVaultConfigOptions } from './build-vault-config';
@@ -1,9 +1,19 @@
1
1
  /**
2
2
  * SecretRefreshManager
3
- * Manages background refresh of Vault secrets based on TTL
3
+ * Manages background refresh of Vault secrets based on TTL.
4
+ *
5
+ * Refreshes are path-aware: when a secret at a given Vault path is refreshed,
6
+ * ALL properties sharing that path are updated from the same Vault read.
7
+ * This prevents credential mismatch (e.g. username from one read, password
8
+ * from another) for engines like `database` where each read generates new creds.
4
9
  */
5
10
 
6
- import { IRefreshStatus, VaultPropertyMetadata } from './types';
11
+ import {
12
+ IRefreshStatus,
13
+ SecretRefreshCallback,
14
+ SecretRefreshEvent,
15
+ VaultPropertyMetadata
16
+ } from './types';
7
17
  import { VaultCache } from './vault-cache';
8
18
  import { VaultProvider } from './vault-provider';
9
19
 
@@ -13,9 +23,6 @@ interface IRefreshContext {
13
23
  fullPath: string;
14
24
  }
15
25
 
16
- /**
17
- * SecretRefreshManager - Handles TTL-based secret refresh scheduling
18
- */
19
26
  export class SecretRefreshManager {
20
27
  private refreshTimers: Map<string, NodeJS.Timeout> = new Map();
21
28
  private refreshLocks: Map<string, Promise<void>> = new Map();
@@ -24,45 +31,45 @@ export class SecretRefreshManager {
24
31
  private refreshContexts: Map<string, IRefreshContext> = new Map();
25
32
  private vaultProvider: VaultProvider;
26
33
  private cache: VaultCache;
27
- private refreshBuffer: number; // Default refresh buffer in seconds
34
+ private refreshBuffer: number;
35
+ private onRefreshCallbacks: SecretRefreshCallback[] = [];
28
36
 
29
37
  constructor(provider: VaultProvider, cache: VaultCache, refreshBuffer?: number) {
30
38
  this.vaultProvider = provider;
31
39
  this.cache = cache;
32
- // Default: min(10% of TTL, 300s) - but we'll use a fixed buffer per secret
33
- this.refreshBuffer = refreshBuffer || 300; // 5 minutes default
40
+ this.refreshBuffer = refreshBuffer || 300;
34
41
  }
35
42
 
36
43
  /**
37
- * Schedule refresh for a secret based on TTL
44
+ * Register a callback to be invoked after secrets are refreshed.
45
+ * Multiple callbacks can be registered.
38
46
  */
47
+ onSecretRefreshed(callback: SecretRefreshCallback): void {
48
+ this.onRefreshCallbacks.push(callback);
49
+ }
50
+
39
51
  scheduleRefresh(propertyName: string, metadata: VaultPropertyMetadata, targetInstance?: object): void {
40
52
  const entry = this.cache.getEntry(propertyName);
41
53
  if (!entry) {
42
- return; // No cache entry to refresh
54
+ return;
43
55
  }
44
56
 
45
- // Store context for refresh
46
57
  this.refreshContexts.set(propertyName, {
47
58
  metadata,
48
59
  targetInstance,
49
60
  fullPath: entry.vaultPath
50
61
  });
51
62
 
52
- // Cancel existing refresh if any
53
63
  this.cancelRefresh(propertyName);
54
64
 
55
- // Calculate refresh time
56
65
  const now = Date.now();
57
66
  const timeUntilRefresh = Math.max(0, entry.refreshAt - now);
58
67
 
59
68
  if (timeUntilRefresh <= 0) {
60
- // Already past refresh time, refresh immediately
61
69
  this.executeRefresh(propertyName);
62
70
  return;
63
71
  }
64
72
 
65
- // Schedule refresh
66
73
  const timer = setTimeout(() => {
67
74
  this.executeRefresh(propertyName);
68
75
  }, timeUntilRefresh);
@@ -70,9 +77,6 @@ export class SecretRefreshManager {
70
77
  this.refreshTimers.set(propertyName, timer);
71
78
  }
72
79
 
73
- /**
74
- * Cancel scheduled refresh for a property
75
- */
76
80
  cancelRefresh(propertyName: string): void {
77
81
  const timer = this.refreshTimers.get(propertyName);
78
82
  if (timer) {
@@ -81,77 +85,123 @@ export class SecretRefreshManager {
81
85
  }
82
86
  }
83
87
 
84
- /**
85
- * Execute refresh for a secret (with locking to prevent concurrent refreshes)
86
- */
87
88
  private async executeRefresh(propertyName: string): Promise<void> {
88
- // Check if refresh is already in progress
89
- const existingLock = this.refreshLocks.get(propertyName);
89
+ const context = this.refreshContexts.get(propertyName);
90
+ if (!context) {
91
+ return;
92
+ }
93
+
94
+ // Lock by path so sibling properties don't trigger duplicate reads
95
+ const lockKey = `path:${ context.fullPath }`;
96
+ const existingLock = this.refreshLocks.get(lockKey);
90
97
  if (existingLock) {
91
98
  await existingLock;
92
- return; // Refresh already completed
99
+ return;
93
100
  }
94
101
 
95
- // Create refresh lock
96
- const refreshPromise = this.performRefresh(propertyName)
102
+ const refreshPromise = this.performPathRefresh(context.fullPath)
97
103
  .finally(() => {
98
- this.refreshLocks.delete(propertyName);
99
- this.refreshTimers.delete(propertyName);
104
+ this.refreshLocks.delete(lockKey);
100
105
  });
101
106
 
102
- this.refreshLocks.set(propertyName, refreshPromise);
107
+ this.refreshLocks.set(lockKey, refreshPromise);
103
108
  await refreshPromise;
104
109
  }
105
110
 
106
111
  /**
107
- * Perform the actual refresh operation
112
+ * Refresh ALL properties that share the given Vault path in a single read.
108
113
  */
109
- private async performRefresh(propertyName: string): Promise<void> {
110
- const context = this.refreshContexts.get(propertyName);
111
- if (!context) {
112
- console.error(`No refresh context for ${ propertyName }`);
114
+ private async performPathRefresh(fullPath: string): Promise<void> {
115
+ const siblingProperties = this.getSiblingsForPath(fullPath);
116
+ if (siblingProperties.length === 0) {
113
117
  return;
114
118
  }
115
119
 
116
- const { metadata, targetInstance, fullPath } = context;
117
- const refreshCount = (this.refreshCounts.get(propertyName) || 0) + 1;
118
- this.refreshCounts.set(propertyName, refreshCount);
120
+ const pathRefreshCount = (this.refreshCounts.get(fullPath) || 0) + 1;
121
+ this.refreshCounts.set(fullPath, pathRefreshCount);
119
122
 
120
123
  try {
121
- // Read fresh secret from Vault (using full path)
122
124
  const secret = await this.vaultProvider.read(fullPath);
125
+ const updatedProperties: string[] = [];
126
+ let engine = siblingProperties[0].metadata.engine;
127
+
128
+ for (const { propertyName, metadata, targetInstance } of siblingProperties) {
129
+ this.cache.set(propertyName, fullPath, secret, metadata);
123
130
 
124
- // Update cache
125
- this.cache.set(propertyName, fullPath, secret, metadata);
131
+ if (targetInstance) {
132
+ const key = metadata.key || metadata.propertyName;
133
+ const value = secret.data[key];
134
+ (targetInstance as any)[propertyName] = value;
135
+ }
126
136
 
127
- // Update target instance if provided
128
- if (targetInstance) {
129
- const key = metadata.key || metadata.propertyName;
130
- const value = secret.data[key];
131
- (targetInstance as any)[propertyName] = value;
137
+ updatedProperties.push(propertyName);
138
+ engine = metadata.engine;
139
+
140
+ this.cancelRefresh(propertyName);
141
+ this.refreshTimers.delete(propertyName);
132
142
  }
133
143
 
134
- // Record refresh time
135
- this.lastRefreshTimes.set(propertyName, Date.now());
144
+ const now = Date.now();
145
+ this.lastRefreshTimes.set(fullPath, now);
146
+
147
+ // Reschedule all sibling properties
148
+ for (const { propertyName, metadata, targetInstance } of siblingProperties) {
149
+ this.scheduleRefresh(propertyName, metadata, targetInstance);
150
+ }
136
151
 
137
- // Reschedule next refresh
138
- this.scheduleRefresh(propertyName, metadata, targetInstance);
152
+ // Fire callbacks once per path
153
+ const event: SecretRefreshEvent = {
154
+ vaultPath: fullPath,
155
+ properties: updatedProperties,
156
+ engine,
157
+ timestamp: new Date(now).toISOString(),
158
+ refreshCount: pathRefreshCount
159
+ };
160
+
161
+ for (const callback of this.onRefreshCallbacks) {
162
+ try {
163
+ const result = callback(event);
164
+ if (result && typeof (result as Promise<void>).catch === 'function') {
165
+ (result as Promise<void>).catch((err) => {
166
+ console.error(`Secret refresh callback error for ${ fullPath }: ${ this.sanitizeError(err?.message || 'Unknown') }`);
167
+ });
168
+ }
169
+ } catch (err: any) {
170
+ console.error(`Secret refresh callback error for ${ fullPath }: ${ this.sanitizeError(err?.message || 'Unknown') }`);
171
+ }
172
+ }
139
173
  } catch (error: any) {
140
- // Log error (sanitized)
141
174
  const errorMessage = error?.message || 'Unknown error';
142
- console.error(`Failed to refresh secret for ${ propertyName }: ${ this.sanitizeError(errorMessage) }`);
175
+ console.error(`Failed to refresh secrets for path ${ this.sanitizePath(fullPath) }: ${ this.sanitizeError(errorMessage) }`);
143
176
 
144
- // Retry with exponential backoff
145
- const retryDelay = Math.min(1000 * Math.pow(2, refreshCount - 1), 30000); // Max 30s
177
+ const retryDelay = Math.min(1000 * Math.pow(2, pathRefreshCount - 1), 30000);
146
178
  setTimeout(() => {
147
- this.scheduleRefresh(propertyName, metadata, targetInstance);
179
+ for (const { propertyName, metadata, targetInstance } of siblingProperties) {
180
+ this.scheduleRefresh(propertyName, metadata, targetInstance);
181
+ }
148
182
  }, retryDelay);
149
183
  }
150
184
  }
151
185
 
152
186
  /**
153
- * Get refresh status for all secrets
187
+ * Find all properties that share the given Vault path.
154
188
  */
189
+ private getSiblingsForPath(fullPath: string): Array<{ propertyName: string; metadata: VaultPropertyMetadata; targetInstance?: object }> {
190
+ const siblings: Array<{ propertyName: string; metadata: VaultPropertyMetadata; targetInstance?: object }> = [];
191
+
192
+ for (const [ propertyName, context ] of this.refreshContexts.entries()) {
193
+ if (context.fullPath === fullPath) {
194
+ siblings.push({
195
+ propertyName,
196
+ metadata: context.metadata,
197
+ targetInstance: context.targetInstance
198
+ });
199
+ }
200
+ }
201
+
202
+ return siblings;
203
+ }
204
+
155
205
  getRefreshStatus(): IRefreshStatus[] {
156
206
  const statuses: IRefreshStatus[] = [];
157
207
  const cachedProperties = this.cache.getCachedProperties();
@@ -173,17 +223,14 @@ export class SecretRefreshManager {
173
223
  scheduled: timer !== undefined,
174
224
  refreshAt,
175
225
  timeUntilRefresh,
176
- lastRefresh: this.lastRefreshTimes.get(propertyName) || entry.cachedAt,
177
- refreshCount: this.refreshCounts.get(propertyName) || 0
226
+ lastRefresh: this.lastRefreshTimes.get(entry.vaultPath) || entry.cachedAt,
227
+ refreshCount: this.refreshCounts.get(entry.vaultPath) || 0
178
228
  });
179
229
  }
180
230
 
181
231
  return statuses;
182
232
  }
183
233
 
184
- /**
185
- * Get refresh status for a specific property
186
- */
187
234
  getRefreshStatusForProperty(propertyName: string): IRefreshStatus | null {
188
235
  const entry = this.cache.getEntry(propertyName);
189
236
  if (!entry) {
@@ -201,17 +248,13 @@ export class SecretRefreshManager {
201
248
  scheduled: timer !== undefined,
202
249
  refreshAt,
203
250
  timeUntilRefresh,
204
- lastRefresh: this.lastRefreshTimes.get(propertyName) || entry.cachedAt,
205
- refreshCount: this.refreshCounts.get(propertyName) || 0
251
+ lastRefresh: this.lastRefreshTimes.get(entry.vaultPath) || entry.cachedAt,
252
+ refreshCount: this.refreshCounts.get(entry.vaultPath) || 0
206
253
  };
207
254
  }
208
255
 
209
- /**
210
- * Stop all refresh workers
211
- */
212
256
  shutdown(): void {
213
- // Cancel all timers
214
- for (const [ propertyName, timer ] of this.refreshTimers.entries()) {
257
+ for (const [ , timer ] of this.refreshTimers.entries()) {
215
258
  clearTimeout(timer);
216
259
  }
217
260
 
@@ -219,13 +262,10 @@ export class SecretRefreshManager {
219
262
  this.refreshLocks.clear();
220
263
  this.refreshCounts.clear();
221
264
  this.lastRefreshTimes.clear();
265
+ this.onRefreshCallbacks = [];
222
266
  }
223
267
 
224
- /**
225
- * Sanitize error message (remove potential secret values)
226
- */
227
268
  private sanitizeError(message: string): string {
228
- // Remove potential secret values from error messages
229
269
  const sensitivePatterns = [ /password/i, /secret/i, /key/i, /token/i, /credential/i ];
230
270
  let sanitized = message;
231
271
 
@@ -238,4 +278,16 @@ export class SecretRefreshManager {
238
278
 
239
279
  return sanitized;
240
280
  }
281
+
282
+ private sanitizePath(path: string): string {
283
+ const segments = path.split('/');
284
+ if (segments.length > 0) {
285
+ const lastSegment = segments[segments.length - 1];
286
+ const sensitivePatterns = [ /password/i, /secret/i, /key/i, /token/i, /credential/i ];
287
+ if (sensitivePatterns.some((pattern) => pattern.test(lastSegment))) {
288
+ segments[segments.length - 1] = '***';
289
+ }
290
+ }
291
+ return segments.join('/');
292
+ }
241
293
  }
@@ -68,6 +68,14 @@ export interface IVaultConfigOptions {
68
68
  * Circuit breaker configuration
69
69
  */
70
70
  circuitBreaker?: ICircuitBreakerConfig;
71
+
72
+ /**
73
+ * Callback invoked when a secret is refreshed.
74
+ * Fired once per Vault path after all properties from that path are updated.
75
+ * Config values are already set when this fires, so consumers can
76
+ * safely read the new values from the config instance.
77
+ */
78
+ onSecretRefreshed?: SecretRefreshCallback;
71
79
  }
72
80
 
73
81
  /**
@@ -225,6 +233,36 @@ export interface IRetryPolicy {
225
233
  retryableErrors: string[];
226
234
  }
227
235
 
236
+ /**
237
+ * Event emitted when a Vault secret is refreshed.
238
+ * Fired once per Vault path (not per property), so consumers
239
+ * can react to credential rotation (e.g., reconnect a database pool).
240
+ *
241
+ * Secret values are intentionally excluded for security.
242
+ */
243
+ export interface SecretRefreshEvent {
244
+ /** The Vault path that was refreshed (e.g. 'database/creds/my-role') */
245
+ vaultPath: string;
246
+
247
+ /** Config property names updated from this path (e.g. ['DB_USERNAME', 'DB_PASSWORD']) */
248
+ properties: string[];
249
+
250
+ /** Vault engine type */
251
+ engine: VaultEngineType;
252
+
253
+ /** ISO timestamp of the refresh */
254
+ timestamp: string;
255
+
256
+ /** Number of times this path has been refreshed (1-based) */
257
+ refreshCount: number;
258
+ }
259
+
260
+ /**
261
+ * Callback invoked when Vault secrets are refreshed.
262
+ * Config values are already updated when this fires.
263
+ */
264
+ export type SecretRefreshCallback = (event: SecretRefreshEvent) => void | Promise<void>;
265
+
228
266
  /**
229
267
  * Circuit breaker configuration
230
268
  */
@@ -8,6 +8,7 @@ import { SecretRefreshManager } from './secret-refresh-manager';
8
8
  import {
9
9
  IVaultConfigOptions,
10
10
  IVaultHealthDetails,
11
+ SecretRefreshCallback,
11
12
  VaultHealth,
12
13
  VaultPropertyMetadata
13
14
  } from './types';
@@ -31,8 +32,12 @@ export class VaultIntegration {
31
32
  this.config = config;
32
33
  this.provider = new VaultProvider(config);
33
34
  this.cache = new VaultCache();
34
- const refreshBuffer = config.refreshBuffer || 300; // Default 5 minutes
35
+ const refreshBuffer = config.refreshBuffer || 300;
35
36
  this.refreshManager = new SecretRefreshManager(this.provider, this.cache, refreshBuffer);
37
+
38
+ if (config.onSecretRefreshed) {
39
+ this.refreshManager.onSecretRefreshed(config.onSecretRefreshed);
40
+ }
36
41
  }
37
42
 
38
43
  /**
@@ -171,6 +176,14 @@ export class VaultIntegration {
171
176
  }
172
177
  }
173
178
 
179
+ /**
180
+ * Register a callback for secret refresh events.
181
+ * Can be called before or after initialization.
182
+ */
183
+ onSecretRefreshed(callback: SecretRefreshCallback): void {
184
+ this.refreshManager.onSecretRefreshed(callback);
185
+ }
186
+
174
187
  /**
175
188
  * Get Vault health status
176
189
  */