import * as plugins from './bunq.plugins.js'; import { BunqCrypto } from './bunq.classes.crypto.js'; import type { IBunqApiContext, IBunqError, IBunqRequestOptions } from './bunq.interfaces.js'; export class BunqHttpClient { private crypto: BunqCrypto; private context: IBunqApiContext; private requestCounter: number = 0; constructor(crypto: BunqCrypto, context: IBunqApiContext) { this.crypto = crypto; this.context = context; } /** * Update the API context (used after getting session token) */ public updateContext(context: Partial): void { this.context = { ...this.context, ...context }; } /** * Make an API request to bunq */ public async request(options: IBunqRequestOptions): Promise { const url = `${this.context.baseUrl}${options.endpoint}`; // Prepare headers const headers = this.prepareHeaders(options); // Prepare body const body = options.body ? JSON.stringify(options.body) : undefined; // Add signature if required if (options.useSigning !== false && this.crypto.getPrivateKey()) { headers['X-Bunq-Client-Signature'] = this.crypto.createSignatureHeader( options.method, options.endpoint, headers, body || '' ); } // Make the request const requestOptions: any = { method: options.method === 'LIST' ? 'GET' : options.method, headers: headers, requestBody: body }; if (options.params) { const params = new URLSearchParams(); Object.entries(options.params).forEach(([key, value]) => { if (value !== undefined && value !== null) { params.append(key, String(value)); } }); requestOptions.queryParams = params.toString(); } try { const response = await plugins.smartrequest.request(url, requestOptions); // Verify response signature if we have server public key if (this.context.serverPublicKey) { // Convert headers to string-only format const stringHeaders: { [key: string]: string } = {}; for (const [key, value] of Object.entries(response.headers)) { if (typeof value === 'string') { stringHeaders[key] = value; } else if (Array.isArray(value)) { stringHeaders[key] = value.join(', '); } } // Convert body to string if needed for signature verification const bodyString = typeof response.body === 'string' ? response.body : JSON.stringify(response.body); const isValid = this.crypto.verifyResponseSignature( response.statusCode, stringHeaders, bodyString, this.context.serverPublicKey ); // For now, only enforce signature verification for payment-related endpoints // TODO: Fix signature verification for all endpoints const paymentEndpoints = ['/v1/payment', '/v1/payment-batch', '/v1/draft-payment']; const isPaymentEndpoint = paymentEndpoints.some(ep => options.endpoint.startsWith(ep)); if (!isValid && isPaymentEndpoint) { throw new Error('Invalid response signature'); } } // Parse response - smartrequest may already parse JSON automatically let responseData; if (typeof response.body === 'string') { try { responseData = JSON.parse(response.body); } catch (parseError) { throw new Error(`Failed to parse JSON response: ${parseError.message}`); } } else { // Response is already parsed responseData = response.body; } // Check for errors if (responseData.Error) { throw new BunqApiError(responseData.Error); } return responseData; } catch (error) { if (error instanceof BunqApiError) { throw error; } // Handle network errors let errorMessage = 'Request failed: '; if (error instanceof Error) { errorMessage += error.message; } else if (typeof error === 'string') { errorMessage += error; } else { errorMessage += JSON.stringify(error); } throw new Error(errorMessage); } } /** * Prepare headers for the request */ private prepareHeaders(options: IBunqRequestOptions): { [key: string]: string } { const headers: { [key: string]: string } = { 'Cache-Control': 'no-cache', 'User-Agent': 'bunq-api-client/1.0.0', 'X-Bunq-Language': 'en_US', 'X-Bunq-Region': 'nl_NL', 'X-Bunq-Client-Request-Id': this.crypto.generateRequestId(), 'X-Bunq-Geolocation': '0 0 0 0 NL', 'Content-Type': 'application/json' }; // Add authentication token if (options.useSessionToken !== false) { if (this.context.sessionToken) { headers['X-Bunq-Client-Authentication'] = this.context.sessionToken; } else if (this.context.installationToken && options.endpoint !== '/v1/installation') { headers['X-Bunq-Client-Authentication'] = this.context.installationToken; } } return headers; } /** * LIST request helper */ public async list(endpoint: string, params?: any): Promise { return this.request({ method: 'LIST', endpoint, params }); } /** * GET request helper */ public async get(endpoint: string): Promise { return this.request({ method: 'GET', endpoint }); } /** * POST request helper */ public async post(endpoint: string, body?: any): Promise { return this.request({ method: 'POST', endpoint, body }); } /** * PUT request helper */ public async put(endpoint: string, body?: any): Promise { return this.request({ method: 'PUT', endpoint, body }); } /** * DELETE request helper */ public async delete(endpoint: string): Promise { return this.request({ method: 'DELETE', endpoint }); } } /** * Custom error class for bunq API errors */ export class BunqApiError extends Error { public errors: Array<{ error_description: string; error_description_translated: string; }>; constructor(errors: Array) { const message = errors.map(e => e.error_description).join('; '); super(message); this.name = 'BunqApiError'; this.errors = errors; } }