export interface VaultClientOptions { addr: string; mountPoint: string; roleId: string; secretId: string; requestTimeoutMs?: number; } export interface DatabaseSecret { host: string; port: number; name: string; user: string; password: string; } export interface RabbitMqSecret { host: string; port: number; vhost: string; user: string; password: string; } export interface EthereumSecret { rpcUrl?: string; hotWalletPrivateKey?: string; usdtContractAddress?: string; } export interface BootstrappedSecrets { database: DatabaseSecret; rabbitmq: RabbitMqSecret; ethereum?: EthereumSecret; } export class VaultClient { private readonly baseUrl: string; private readonly mountPoint: string; private readonly roleId: string; private readonly secretId: string; private readonly timeoutMs: number; private token?: string; constructor(options: VaultClientOptions) { this.baseUrl = options.addr.replace(/\/+$/, ''); this.mountPoint = options.mountPoint; this.roleId = options.roleId; this.secretId = options.secretId; this.timeoutMs = options.requestTimeoutMs ?? 10_000; } async bootstrap(): Promise { await this.login(); const [database, rabbitmq, ethereum] = await Promise.all([ this.readSecret('database'), this.readSecret('rabbitmq'), this.readOptionalSecret('crypto') ]); return { database: parseDatabaseSecret(database), rabbitmq: parseRabbitMqSecret(rabbitmq), ethereum: ethereum ? parseEthereumSecret(ethereum) : undefined }; } private async login(): Promise { const response = await this.fetch(`${this.baseUrl}/v1/auth/approle/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ role_id: this.roleId, secret_id: this.secretId }) }); const json = (await response.json()) as { auth?: { client_token?: string } }; const token = json.auth?.client_token; if (!token) { throw new Error('Vault AppRole login did not return a client_token'); } this.token = token; } private async readSecret(path: string): Promise> { if (!this.token) { throw new Error('Vault token is missing; call bootstrap() first'); } const url = `${this.baseUrl}/v1/${this.mountPoint}/data/${path}`; const response = await this.fetch(url, { method: 'GET', headers: { 'X-Vault-Token': this.token } }); const json = (await response.json()) as { data?: { data?: Record } }; const data = json.data?.data; if (!data || typeof data !== 'object') { throw new Error(`Vault secret ${this.mountPoint}/${path} is empty or malformed`); } return data; } private async readOptionalSecret(path: string): Promise | null> { try { return await this.readSecret(path); } catch (error) { if (error instanceof Error && (error.message.includes('HTTP 404') || error.message.includes('HTTP 403'))) { return null; } throw error; } } private async fetch(url: string, init: RequestInit): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { const response = await fetch(url, { ...init, signal: controller.signal }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(`Vault request to ${url} failed with HTTP ${response.status}: ${text}`); } return response; } finally { clearTimeout(timeout); } } } function parseDatabaseSecret(raw: Record): DatabaseSecret { return { host: requireString(raw, 'host', 'database'), port: requirePort(raw, 'port', 'database'), name: requireString(raw, 'name', 'database'), user: requireString(raw, 'user', 'database'), password: requireString(raw, 'password', 'database') }; } function parseRabbitMqSecret(raw: Record): RabbitMqSecret { return { host: requireString(raw, 'host', 'rabbitmq'), port: requirePort(raw, 'port', 'rabbitmq'), vhost: requireString(raw, 'vhost', 'rabbitmq'), user: requireString(raw, 'user', 'rabbitmq'), password: requireString(raw, 'password', 'rabbitmq') }; } function parseEthereumSecret(raw: Record): EthereumSecret { return { rpcUrl: optionalString(raw, 'rpc_url', 'ethereum'), hotWalletPrivateKey: optionalString(raw, 'hot_wallet_private_key', 'crypto'), usdtContractAddress: optionalString(raw, 'usdt_contract_address', 'crypto') ?? optionalString(raw, 'usdt_contract_addres', 'crypto') }; } function requireString(raw: Record, key: string, secretName: string): string { const value = raw[key]; if (typeof value !== 'string' || !value) { throw new Error(`Vault secret '${secretName}' is missing string field '${key}'`); } return value; } function optionalString(raw: Record, key: string, secretName: string): string | undefined { const value = raw[key]; if (value === undefined || value === null || value === '') { return undefined; } if (typeof value !== 'string') { throw new Error(`Vault secret '${secretName}' field '${key}' must be a string`); } return value; } function requirePort(raw: Record, key: string, secretName: string): number { const value = raw[key]; const numeric = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(numeric) || numeric <= 0 || numeric > 65535) { throw new Error(`Vault secret '${secretName}' has invalid port '${String(value)}'`); } return Math.trunc(numeric); }