@apiquest/plugin-vault-file 1.0.4

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.
@@ -0,0 +1,31 @@
1
+ import typescript from '@rollup/plugin-typescript';
2
+ import resolve from '@rollup/plugin-node-resolve';
3
+ import commonjs from '@rollup/plugin-commonjs';
4
+
5
+ export default {
6
+ input: 'src/index.ts',
7
+ output: {
8
+ file: 'dist/index.js',
9
+ format: 'esm',
10
+ sourcemap: true,
11
+ },
12
+ external: [
13
+ // Externalize peer dependencies
14
+ '@apiquest/fracture',
15
+ ],
16
+ plugins: [
17
+ // Resolve node modules
18
+ resolve({
19
+ preferBuiltins: true, // Prefer Node.js built-in modules
20
+ exportConditions: ['node', 'import', 'default'],
21
+ }),
22
+ // Convert CommonJS to ESM (for any CJS dependencies)
23
+ commonjs(),
24
+ // Compile TypeScript
25
+ typescript({
26
+ tsconfig: './tsconfig.json',
27
+ sourceMap: true,
28
+ declaration: false, // We'll use tsc for declarations
29
+ }),
30
+ ],
31
+ };
package/src/index.ts ADDED
@@ -0,0 +1,421 @@
1
+ import { IValueProviderPlugin, ValidationResult, ValidationError, ExecutionContext, ILogger } from '@apiquest/types';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as crypto from 'crypto';
5
+
6
+ /**
7
+ * Encrypted file format
8
+ */
9
+ interface EncryptedFile {
10
+ _encrypted: 'aes-256-gcm';
11
+ _iv: string;
12
+ _authTag: string;
13
+ _data: string;
14
+ }
15
+
16
+ /**
17
+ * Plugin configuration
18
+ */
19
+ interface VaultFileConfig {
20
+ filePath: string;
21
+ key?: string;
22
+ source?: 'env';
23
+ }
24
+
25
+ /**
26
+ * File-based Vault Provider Plugin
27
+ * Reads secrets from a JSON file (plain or AES-256-GCM encrypted)
28
+ *
29
+ * Configuration:
30
+ * - filePath: Path to JSON file containing secrets
31
+ * - key: Encryption key or name of environment variable (when source="env")
32
+ * - source: "env" to read key from process.env[key], omit to use key directly
33
+ *
34
+ * Usage - Unencrypted:
35
+ * {
36
+ * "plugins": {
37
+ * "vault:file": {
38
+ * "filePath": "./secrets.json"
39
+ * }
40
+ * }
41
+ * }
42
+ *
43
+ * Usage - Encrypted with env var:
44
+ * {
45
+ * "plugins": {
46
+ * "vault:file": {
47
+ * "filePath": "./secrets.json.enc",
48
+ * "key": "VAULT_KEY",
49
+ * "source": "env"
50
+ * }
51
+ * }
52
+ * }
53
+ *
54
+ * Usage - Encrypted with variable resolution:
55
+ * {
56
+ * "variables": [{"key": "vaultKey", "value": "my-secret"}],
57
+ * "plugins": {
58
+ * "vault:file": {
59
+ * "filePath": "./secrets.json.enc",
60
+ * "key": "{{vaultKey}}"
61
+ * }
62
+ * }
63
+ * }
64
+ *
65
+ * Plain secrets.json format:
66
+ * {
67
+ * "apiKey": "secret-value",
68
+ * "database": {
69
+ * "password": "db-password"
70
+ * }
71
+ * }
72
+ *
73
+ * Encrypted secrets.json.enc format:
74
+ * {
75
+ * "_encrypted": "aes-256-gcm",
76
+ * "_iv": "base64_encoded_iv",
77
+ * "_authTag": "base64_encoded_auth_tag",
78
+ * "_data": "base64_encoded_encrypted_json"
79
+ * }
80
+ *
81
+ * Access nested keys with dot notation: "database.password"
82
+ */
83
+ export class FileVaultProvider implements IValueProviderPlugin {
84
+ provider = 'vault:file';
85
+ name = 'File Vault Provider';
86
+ description = 'Load secrets from a JSON file (supports AES-256-GCM encryption)';
87
+
88
+ configSchema = {
89
+ type: 'object',
90
+ properties: {
91
+ filePath: {
92
+ type: 'string',
93
+ description: 'Path to JSON file containing secrets'
94
+ },
95
+ key: {
96
+ type: 'string',
97
+ description: 'Encryption key or environment variable name'
98
+ },
99
+ source: {
100
+ type: 'string',
101
+ enum: ['env'],
102
+ description: 'Set to "env" to read key from process.env'
103
+ }
104
+ },
105
+ required: ['filePath']
106
+ };
107
+
108
+ private cache = new Map<string, unknown>();
109
+
110
+ async getValue(
111
+ key: string,
112
+ config?: unknown,
113
+ context?: ExecutionContext,
114
+ logger?: ILogger
115
+ ): Promise<string | null> {
116
+ if (config === undefined || config === null || typeof config !== 'object') {
117
+ logger?.error('Vault file configuration missing');
118
+ throw new Error('FileVaultProvider: filePath not configured in options.plugins["vault:file"]');
119
+ }
120
+
121
+ const configObj = config as VaultFileConfig;
122
+
123
+ if (!('filePath' in configObj) || typeof configObj.filePath !== 'string') {
124
+ logger?.error('Vault filePath missing in configuration');
125
+ throw new Error('FileVaultProvider: filePath not configured in options.plugins["vault:file"]');
126
+ }
127
+
128
+ const filePath = path.resolve(configObj.filePath);
129
+ const cacheKey = filePath;
130
+
131
+ // Cache load
132
+ if (!this.cache.has(cacheKey)) {
133
+ try {
134
+ logger?.debug('Loading vault file', { filePath });
135
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
136
+ const fileData = JSON.parse(fileContent) as unknown;
137
+
138
+ if (this.isEncryptedFile(fileData)) {
139
+ const encryptionKey = this.resolveEncryptionKey(configObj);
140
+ if (encryptionKey === null || encryptionKey === undefined || encryptionKey === '') {
141
+ logger?.error('Encrypted vault file missing encryption key');
142
+ throw new Error('FileVaultProvider: Encrypted vault file requires encryption key (config.key)');
143
+ }
144
+
145
+ const decrypted = this.decryptFile(fileData, encryptionKey);
146
+ this.cache.set(cacheKey, decrypted);
147
+ logger?.debug('Encrypted vault file decrypted and cached');
148
+ } else {
149
+ this.cache.set(cacheKey, fileData);
150
+ logger?.debug('Vault file cached');
151
+ }
152
+ } catch (error: unknown) {
153
+ if (error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
154
+ logger?.error('Vault file not found', { filePath });
155
+ throw new Error(`FileVaultProvider: Vault file not found: ${filePath}`);
156
+ }
157
+ if (error instanceof SyntaxError) {
158
+ logger?.error('Vault file JSON parsing failed', { filePath });
159
+ throw new Error(`FileVaultProvider: Invalid JSON in vault file: ${filePath}`);
160
+ }
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ const secrets = this.cache.get(cacheKey);
166
+ const value = this.getNestedValue(secrets, key);
167
+
168
+ if (value === undefined) {
169
+ logger?.trace('Vault key not found', { key });
170
+ return null;
171
+ }
172
+
173
+ logger?.trace('Vault key resolved', { key });
174
+ return String(value);
175
+ }
176
+
177
+ validate(config?: unknown): ValidationResult {
178
+ if (config === undefined || config === null) {
179
+ return {
180
+ valid: false,
181
+ errors: [{
182
+ message: 'Configuration required: must specify filePath',
183
+ location: '',
184
+ source: 'vault'
185
+ }]
186
+ };
187
+ }
188
+
189
+ // Type guard to check if config is an object and has filePath
190
+ if (typeof config !== 'object' || config === null) {
191
+ return {
192
+ valid: false,
193
+ errors: [{
194
+ message: 'Configuration must be an object',
195
+ location: '',
196
+ source: 'vault'
197
+ }]
198
+ };
199
+ }
200
+
201
+ const configObj = config as Record<string, unknown>;
202
+
203
+ if (!('filePath' in configObj) || configObj.filePath === undefined || configObj.filePath === null) {
204
+ return {
205
+ valid: false,
206
+ errors: [{
207
+ message: 'filePath is required in configuration',
208
+ location: '',
209
+ source: 'vault'
210
+ }]
211
+ };
212
+ }
213
+
214
+ if (typeof configObj.filePath !== 'string') {
215
+ return {
216
+ valid: false,
217
+ errors: [{
218
+ message: 'filePath must be a string',
219
+ location: '',
220
+ source: 'vault'
221
+ }]
222
+ };
223
+ }
224
+
225
+ // Check if file exists
226
+ const filePath = path.resolve(configObj.filePath);
227
+ if (!fs.existsSync(filePath)) {
228
+ return {
229
+ valid: false,
230
+ errors: [{
231
+ message: `Vault file not found: ${filePath}`,
232
+ location: '',
233
+ source: 'vault'
234
+ }]
235
+ };
236
+ }
237
+
238
+ // Try to parse JSON and check encryption
239
+ try {
240
+ const content = fs.readFileSync(filePath, 'utf-8');
241
+ const data = JSON.parse(content) as unknown;
242
+
243
+ // If file is encrypted, validate we have a key
244
+ if (this.isEncryptedFile(data)) {
245
+ const vaultConfig: VaultFileConfig = {
246
+ filePath: String(configObj.filePath),
247
+ key: typeof configObj.key === 'string' ? configObj.key : undefined,
248
+ source: configObj.source === 'env' ? 'env' : undefined
249
+ };
250
+ const encryptionKey = this.resolveEncryptionKey(vaultConfig);
251
+
252
+ if (encryptionKey === null || encryptionKey === undefined || encryptionKey === '') {
253
+ return {
254
+ valid: false,
255
+ errors: [{
256
+ message: 'Encrypted vault file requires encryption key (config.key)',
257
+ location: '',
258
+ source: 'vault'
259
+ }]
260
+ };
261
+ }
262
+
263
+ // Try to decrypt to validate key
264
+ try {
265
+ this.decryptFile(data, encryptionKey);
266
+ } catch (decryptError: unknown) {
267
+ const errorMessage = decryptError instanceof Error ? decryptError.message : 'Decryption failed';
268
+ return {
269
+ valid: false,
270
+ errors: [{
271
+ message: `Failed to decrypt vault file: ${errorMessage}`,
272
+ location: '',
273
+ source: 'vault'
274
+ }]
275
+ };
276
+ }
277
+ }
278
+ } catch (error: unknown) {
279
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
280
+ return {
281
+ valid: false,
282
+ errors: [{
283
+ message: `Invalid JSON in vault file: ${errorMessage}`,
284
+ location: '',
285
+ source: 'vault'
286
+ }]
287
+ };
288
+ }
289
+
290
+ return { valid: true };
291
+ }
292
+
293
+ /**
294
+ * Resolve the encryption key from config
295
+ * - If source="env", read from process.env[config.key]
296
+ * - Otherwise, use config.key directly
297
+ */
298
+ private resolveEncryptionKey(config: VaultFileConfig): string | null {
299
+ if (config.key === undefined || config.key === null || config.key === '') {
300
+ return null;
301
+ }
302
+
303
+ if (config.source === 'env') {
304
+ // Read from environment variable
305
+ const envValue = process.env[config.key];
306
+ return envValue ?? null;
307
+ }
308
+
309
+ // Use key directly (could be resolved variable like {{vaultKey}})
310
+ return config.key;
311
+ }
312
+
313
+ /**
314
+ * Check if data is an encrypted file
315
+ */
316
+ private isEncryptedFile(data: unknown): data is EncryptedFile {
317
+ if (typeof data !== 'object' || data === null) {
318
+ return false;
319
+ }
320
+
321
+ const obj = data as Record<string, unknown>;
322
+ return '_encrypted' in obj && obj._encrypted === 'aes-256-gcm';
323
+ }
324
+
325
+ /**
326
+ * Decrypt an encrypted file using AES-256-GCM
327
+ */
328
+ private decryptFile(encryptedFile: EncryptedFile, key: string): unknown {
329
+ try {
330
+ // Derive a 32-byte key from the provided key using SHA-256
331
+ const keyBuffer = crypto.createHash('sha256').update(key).digest();
332
+
333
+ // Decode base64 values
334
+ const iv = Buffer.from(encryptedFile._iv, 'base64');
335
+ const authTag = Buffer.from(encryptedFile._authTag, 'base64');
336
+ const encryptedData = Buffer.from(encryptedFile._data, 'base64');
337
+
338
+ // Create decipher
339
+ const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, iv);
340
+ decipher.setAuthTag(authTag);
341
+
342
+ // Decrypt
343
+ const decrypted = Buffer.concat([
344
+ decipher.update(encryptedData),
345
+ decipher.final()
346
+ ]);
347
+
348
+ // Parse JSON
349
+ return JSON.parse(decrypted.toString('utf-8')) as unknown;
350
+ } catch (error: unknown) {
351
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
352
+ throw new Error(`Failed to decrypt vault file: ${errorMessage}`);
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Encrypt data to create an encrypted file (utility method for generating encrypted files)
358
+ */
359
+ static encryptData(data: unknown, key: string): EncryptedFile {
360
+ // Derive a 32-byte key from the provided key using SHA-256
361
+ const keyBuffer = crypto.createHash('sha256').update(key).digest();
362
+
363
+ // Generate random IV (12 bytes for GCM)
364
+ const iv = crypto.randomBytes(12);
365
+
366
+ // Create cipher
367
+ const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
368
+
369
+ // Encrypt
370
+ const jsonData = JSON.stringify(data);
371
+ const encrypted = Buffer.concat([
372
+ cipher.update(jsonData, 'utf-8'),
373
+ cipher.final()
374
+ ]);
375
+
376
+ // Get auth tag
377
+ const authTag = cipher.getAuthTag();
378
+
379
+ // Return encrypted file format
380
+ return {
381
+ _encrypted: 'aes-256-gcm',
382
+ _iv: iv.toString('base64'),
383
+ _authTag: authTag.toString('base64'),
384
+ _data: encrypted.toString('base64')
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Get nested value from object using dot notation
390
+ * Example: getNestedValue({ a: { b: { c: 'value' } } }, 'a.b.c') => 'value'
391
+ */
392
+ private getNestedValue(obj: unknown, key: string): unknown {
393
+ const keys = key.split('.');
394
+ let current = obj;
395
+
396
+ for (const k of keys) {
397
+ if (current === null || current === undefined) {
398
+ return undefined;
399
+ }
400
+ if (typeof current !== 'object') {
401
+ return undefined;
402
+ }
403
+ current = (current as Record<string, unknown>)[k];
404
+ }
405
+
406
+ return current;
407
+ }
408
+
409
+ /**
410
+ * Clear the cache (useful for testing or forcing reload)
411
+ */
412
+ clearCache(): void {
413
+ this.cache.clear();
414
+ }
415
+ }
416
+
417
+ // Export singleton instance
418
+ export const fileVaultProvider = new FileVaultProvider();
419
+
420
+ // Default export
421
+ export default fileVaultProvider;
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "lib": ["ES2022"],
6
+ "moduleResolution": "node",
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "declaration": true,
10
+ "declarationMap": true,
11
+ "sourceMap": true,
12
+ "strict": true,
13
+ "esModuleInterop": true,
14
+ "skipLibCheck": true,
15
+ "forceConsistentCasingInFileNames": true,
16
+ "resolveJsonModule": true,
17
+ "allowSyntheticDefaultImports": true
18
+ },
19
+ "include": ["src/**/*"],
20
+ "exclude": ["node_modules", "dist"]
21
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "include": ["tests/**/*"],
4
+ "exclude": []
5
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ coverage: {
8
+ provider: 'v8',
9
+ reporter: ['text', 'json', 'html'],
10
+ },
11
+ },
12
+ });