diff --git a/packages/haystack/README.md b/packages/haystack/README.md index 9fd22ff..f7df137 100644 --- a/packages/haystack/README.md +++ b/packages/haystack/README.md @@ -20,6 +20,8 @@ TypeScript/JavaScript SDK for [Haystack Order Router](https://txnlab.gitbook.io/ | **Free** | `1b72df7e-1131-4449-8ce1-29b79dd3f51e` | 60 requests/min | Development, testing, low-volume integrations | | **Production** | Request from [support@txnlab.dev](mailto:support@txnlab.dev) | Higher limits | Production applications | +The SDK sends the key in the `X-API-Key` header, never in the URL, so it stays out of server logs, browser history and caches. + ## Installation ```bash @@ -413,7 +415,7 @@ new RouterClient(config: ConfigParams) | Option | Description | Type | Default | | ----------------- | ------------------------------------------------------------ | --------------------- | -------------------------------------- | -| `apiKey` | Your Haystack Router API key | `string` | **required** | +| `apiKey` | Your Haystack Router API key, sent in the `X-API-Key` header | `string` | **required** | | `apiBaseUrl` | Base URL for the Haystack Router API | `string` | `https://hayrouter.txnlab.dev` | | `algodUri` | Algod node URI | `string` | `https://mainnet-api.4160.nodely.dev/` | | `algodToken` | Algod node token | `string` | `''` | diff --git a/packages/haystack/src/client.ts b/packages/haystack/src/client.ts index 5dd84ad..6648faf 100644 --- a/packages/haystack/src/client.ts +++ b/packages/haystack/src/client.ts @@ -1,6 +1,7 @@ import { Algodv2, isValidAddress, type TransactionSigner } from 'algosdk' import { SwapComposer, type SignerFunction } from './composer' import { + API_KEY_HEADER, DEFAULT_ALGOD_PORT, DEFAULT_ALGOD_TOKEN, DEFAULT_ALGOD_URI, @@ -189,7 +190,6 @@ export class RouterClient { const url = new URL(`${this.config.apiBaseUrl}/fetchQuote`) - url.searchParams.append('apiKey', this.config.apiKey) url.searchParams.append('algodUri', this.config.algodUri) url.searchParams.append('algodToken', this.config.algodToken) url.searchParams.append('algodPort', String(this.config.algodPort)) @@ -214,7 +214,9 @@ export class RouterClient { url.searchParams.append('referrerAddress', this.config.referrerAddress) } - return request(url.toString()) + return request(url.toString(), { + headers: this.authHeaders(), + }) } /** @@ -283,7 +285,6 @@ export class RouterClient { const url = new URL(`${this.config.apiBaseUrl}/fetchExecuteSwapTxns`) const body: FetchSwapTxnsBody = { - apiKey: this.config.apiKey, address, txnPayloadJSON: quote.txnPayload, slippage, @@ -293,11 +294,21 @@ export class RouterClient { method: 'POST', headers: { 'Content-Type': 'application/json', + ...this.authHeaders(), }, body: JSON.stringify(body), }) } + /** + * Headers that authenticate a request with the API key. The key goes in the + * `X-API-Key` header rather than the URL, where it would end up in logs, browser + * history and CDN caches. The API refuses a secret key (`hay_sk_`) in the URL. + */ + private authHeaders(): Record { + return { [API_KEY_HEADER]: this.config.apiKey } + } + /** * Fetch a quote and return an enhanced quote result * diff --git a/packages/haystack/src/constants.ts b/packages/haystack/src/constants.ts index df40d0e..9bc211b 100644 --- a/packages/haystack/src/constants.ts +++ b/packages/haystack/src/constants.ts @@ -30,6 +30,9 @@ export const DEFAULT_ALGOD_PORT = 443 /** Default Haystack Router API base URL */ export const DEFAULT_API_BASE_URL = 'https://hayrouter.txnlab.dev/api' +/** HTTP header that carries the Haystack Router API key */ +export const API_KEY_HEADER = 'X-API-Key' + /** Default fee in basis points (0.10%) */ export const DEFAULT_FEE_BPS = 10 diff --git a/packages/haystack/src/logger.ts b/packages/haystack/src/logger.ts index 59cbe45..1e6c726 100644 --- a/packages/haystack/src/logger.ts +++ b/packages/haystack/src/logger.ts @@ -89,7 +89,11 @@ export class Logger { for (const [key, value] of Object.entries(data)) { // Redact sensitive fields - if (key === 'apiKey' || key === 'algodToken') { + if ( + key === 'apiKey' || + key === 'algodToken' || + key.toLowerCase() === 'x-api-key' + ) { sanitized[key] = '[REDACTED]' continue } diff --git a/packages/haystack/src/types.ts b/packages/haystack/src/types.ts index f42e272..9f8e376 100644 --- a/packages/haystack/src/types.ts +++ b/packages/haystack/src/types.ts @@ -365,8 +365,6 @@ export interface FetchSwapTxnsParams { * @internal */ export interface FetchSwapTxnsBody { - /** API key for authentication */ - readonly apiKey: string /** Address of the account that will sign the transactions */ readonly address: string /** Encrypted transaction payload from the quote */ diff --git a/packages/haystack/tests/client.test.ts b/packages/haystack/tests/client.test.ts index f2c3b94..ef8ac0f 100644 --- a/packages/haystack/tests/client.test.ts +++ b/packages/haystack/tests/client.test.ts @@ -135,6 +135,18 @@ describe('RouterClient', () => { expect(mockRequest).toHaveBeenCalledOnce() }) + it('should send the API key in the X-API-Key header, not the URL', async () => { + mockRequest.mockResolvedValue({ quote: '1', route: [] }) + + await client.fetchQuote({ fromASAID: 0, toASAID: 31566704, amount: 1 }) + + const [url, options] = mockRequest.mock.calls?.[0] as + [string, RequestInit] | [] + expect(new URL(url as string).searchParams.has('apiKey')).toBe(false) + expect(url).not.toContain('test-api-key') + expect(options?.headers).toEqual({ 'X-API-Key': 'test-api-key' }) + }) + it('should include all query parameters in the request', async () => { const mockQuote: Partial = { fromASAID: 0, @@ -761,9 +773,13 @@ describe('RouterClient', () => { [string, RequestInit] | [] expect(url).toContain('fetchExecuteSwapTxns') expect(options?.method).toBe('POST') - expect(options?.headers).toEqual({ 'Content-Type': 'application/json' }) + expect(options?.headers).toEqual({ + 'Content-Type': 'application/json', + 'X-API-Key': 'test-api-key', + }) const body = JSON.parse(options?.body as string) + expect(body).not.toHaveProperty('apiKey') expect(body.address).toBe(validAddress) expect(body.slippage).toBe(1.0) expect(body.txnPayloadJSON).toEqual(mockQuote.txnPayload) diff --git a/packages/haystack/tests/logger.test.ts b/packages/haystack/tests/logger.test.ts index 7eb5e3e..69a1478 100644 --- a/packages/haystack/tests/logger.test.ts +++ b/packages/haystack/tests/logger.test.ts @@ -148,6 +148,19 @@ describe('Logger', () => { expect(calls[0][1]).toEqual({ apiKey: '[REDACTED]', other: 'visible' }) }) + it('should redact the X-API-Key header in any case', () => { + Logger.setLevel('info') + + Logger.info('test', { + headers: { 'X-API-Key': 'secret-key', 'x-api-key': 'secret-key' }, + }) + + const calls = (console.log as any).mock.calls + expect(calls[0][1]).toEqual({ + headers: { 'X-API-Key': '[REDACTED]', 'x-api-key': '[REDACTED]' }, + }) + }) + it('should redact algodToken field', () => { Logger.setLevel('info')