| | | 1 | | import type { SecretClient } from '@azure/keyvault-secrets'; |
| | | 2 | | import type { ISecretProvider } from '../../domain/ports/secret-provider.js'; |
| | | 3 | | import { toAzureSecretName } from './azure-secret-name.js'; |
| | | 4 | | |
| | | 5 | | /** |
| | | 6 | | * {@link ISecretProvider} backed by Azure Key Vault. |
| | | 7 | | * |
| | | 8 | | * Secrets are fetched in parallel. Secrets that return HTTP 404 |
| | | 9 | | * are treated as missing and silently omitted from the result. |
| | | 10 | | * Each reference is mapped to its Key Vault name with |
| | | 11 | | * {@link toAzureSecretName}, the same rule the CLI uses. |
| | | 12 | | */ |
| | | 13 | | export class AzureKeyVaultSecretProvider implements ISecretProvider { |
| | | 14 | | private readonly secretClient: SecretClient; |
| | | 15 | | |
| | | 16 | | constructor(secretClient: SecretClient) { |
| | 9 | 17 | | if (!secretClient) { |
| | 1 | 18 | | throw new Error('secretClient cannot be null'); |
| | | 19 | | } |
| | 8 | 20 | | this.secretClient = secretClient; |
| | | 21 | | } |
| | | 22 | | |
| | | 23 | | async getSecrets(names: string[]): Promise<Map<string, string>> { |
| | 9 | 24 | | const result = new Map<string, string>(); |
| | 9 | 25 | | if (names.length === 0) { |
| | 1 | 26 | | return result; |
| | | 27 | | } |
| | | 28 | | |
| | 8 | 29 | | for (const name of names) { |
| | 14 | 30 | | if (!name?.trim()) { |
| | 1 | 31 | | throw new Error('Secret name cannot be null or empty'); |
| | | 32 | | } |
| | | 33 | | } |
| | 7 | 34 | | const secretNames = names.map(toAzureSecretName); |
| | | 35 | | |
| | 7 | 36 | | const entries = await Promise.all( |
| | | 37 | | names.map(async (name, index) => { |
| | 12 | 38 | | const value = await this.fetchSecret(secretNames[index]); |
| | 11 | 39 | | return [name, value] as const; |
| | | 40 | | }), |
| | | 41 | | ); |
| | | 42 | | |
| | 6 | 43 | | for (const [name, value] of entries) { |
| | 11 | 44 | | if (value !== null) { |
| | 9 | 45 | | result.set(name, value); |
| | | 46 | | } |
| | | 47 | | } |
| | | 48 | | |
| | 6 | 49 | | return result; |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | private async fetchSecret(name: string): Promise<string | null> { |
| | 12 | 53 | | try { |
| | 12 | 54 | | const response = await this.secretClient.getSecret(name); |
| | 9 | 55 | | return response.value ?? null; |
| | | 56 | | } catch (error: unknown) { |
| | 3 | 57 | | if (isNotFound(error)) { |
| | 2 | 58 | | return null; |
| | | 59 | | } |
| | 1 | 60 | | throw error; |
| | | 61 | | } |
| | | 62 | | } |
| | | 63 | | } |
| | | 64 | | |
| | | 65 | | function isNotFound(error: unknown): boolean { |
| | 3 | 66 | | return ( |
| | | 67 | | typeof error === 'object' && |
| | | 68 | | error !== null && |
| | | 69 | | 'statusCode' in error && |
| | | 70 | | (error as { statusCode: number }).statusCode === 404 |
| | | 71 | | ); |
| | | 72 | | } |