From a018062dacf4de600faf6a7baca9095e0f507878 Mon Sep 17 00:00:00 2001 From: Verner Codoceo Date: Wed, 24 Jun 2026 12:15:39 -0400 Subject: [PATCH 1/3] feat: surface IP allow list rejections as actionable errors When an org restricts API access by IP, the API replies 401 with code allowed_cidr_blocks_does_not_contain_ip (or missing_allowed_cidr_block). These fell into the generic "Authentication failed: check your API key" path, which misled callers whose key was actually fine. Detect those codes in handleError and point users to the dashboard allow list instead. Export parseFintocError so doctor can reuse the parsing. --- src/lib/__tests__/errors.test.ts | 37 ++++++++++++++++++++++++++++++++ src/lib/constants.ts | 5 +++++ src/lib/errors.ts | 12 ++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/lib/__tests__/errors.test.ts b/src/lib/__tests__/errors.test.ts index ab132fc..e479225 100644 --- a/src/lib/__tests__/errors.test.ts +++ b/src/lib/__tests__/errors.test.ts @@ -182,6 +182,43 @@ describe('handleError', () => { }) }) + describe('IP not allow-listed', () => { + test('formats allowed_cidr_blocks_does_not_contain_ip with IP allow list next steps', () => { + const err = createFintocError('AuthenticationError', { + type: 'authentication_error', + code: 'allowed_cidr_blocks_does_not_contain_ip', + message: + 'IP is not in allowed CIDR blocks. Please check that your allowed CIDR blocks contains your IP.', + }) + + expect(() => handleError(err)).toThrow('process.exit') + + expect(error).toHaveBeenCalledWith( + 'IP is not in allowed CIDR blocks. Please check that your allowed CIDR blocks contains your IP.', + ) + expect(hint).toHaveBeenCalledWith(' Your organization restricts API access by IP address.') + expect(hint).toHaveBeenCalledWith( + ' Add your IP to the allow list at: https://dashboard.fintoc.com/api-keys', + ) + expect(hint).not.toHaveBeenCalledWith(expect.stringContaining('Check your API key')) + }) + + test('formats missing_allowed_cidr_block as an IP allow list error', () => { + const err = createFintocError('AuthenticationError', { + type: 'authentication_error', + code: 'missing_allowed_cidr_block', + message: + 'IP Restriction is enabled but no allowed CIDR blocks were found. Please add some allowed CIDR blocks or turn off IP Restriction from the dashboard.', + }) + + expect(() => handleError(err)).toThrow('process.exit') + + expect(hint).toHaveBeenCalledWith( + ' Add your IP to the allow list at: https://dashboard.fintoc.com/api-keys', + ) + }) + }) + describe('authentication error', () => { test('formats AuthenticationError with next steps', () => { const err = createFintocError('AuthenticationError', { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index b575d32..eb6047e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,5 +1,10 @@ export const API_HOST = 'api.fintoc.com' export const NPM_PACKAGE_NAME = '@fintoc/cli' + +export const IP_ALLOWLIST_ERROR_CODES = new Set([ + 'allowed_cidr_blocks_does_not_contain_ip', + 'missing_allowed_cidr_block', +]) export const DEFAULT_LIST_LIMIT = 10 export const NPM_CHECK_TIMEOUT_MS = 10_000 export const CONFIG_FILE_PERMISSIONS = 0o600 diff --git a/src/lib/errors.ts b/src/lib/errors.ts index f921a86..95817f9 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -3,6 +3,7 @@ import { DASHBOARD_API_KEYS_URL, DOCS_LINKS_URL, DOCS_TRANSFERS_URL, + IP_ALLOWLIST_ERROR_CODES, } from './constants.js' import { error, hint } from './output.js' @@ -43,7 +44,7 @@ const FINTOC_ERROR_CLASSES = new Set([ const isFintocError = (err: unknown): err is Error => err instanceof Error && FINTOC_ERROR_CLASSES.has(err.constructor.name) -const parseFintocError = (err: unknown): FintocErrorFields | undefined => { +export const parseFintocError = (err: unknown): FintocErrorFields | undefined => { if (isFintocError(err)) { const lines = err.message.split('\n') const firstLine = lines[0] ?? '' @@ -166,6 +167,15 @@ export const handleError = (err: unknown, context?: ErrorContext): never => { return process.exit(1) } + if (fields.code && IP_ALLOWLIST_ERROR_CODES.has(fields.code)) { + error(fields.message ?? 'Your IP is not in the allowed CIDR blocks.') + printNextSteps([ + 'Your organization restricts API access by IP address.', + `Add your IP to the allow list at: ${DASHBOARD_API_KEYS_URL}`, + ]) + return process.exit(1) + } + if (fields.type === 'authentication_error') { error(`Authentication failed: ${fields.message ?? 'Invalid API key'}`) printNextSteps([ From ac44e685f0b3078d69ba93c382399fd90c07b47b Mon Sep 17 00:00:00 2001 From: Verner Codoceo Date: Wed, 24 Jun 2026 12:15:39 -0400 Subject: [PATCH 2/3] feat(doctor): diagnose IP allow list rejection in connectivity check checkConnectivity swallowed every failure into "could not reach api.fintoc.com", so an IP-not-allow-listed org looked like a network outage even though the request reached the API. Classify the caught error: report the host as reachable but the IP as blocked (with a link to fix it), distinguish a rejected key, and keep the generic message only for real network failures. --- src/commands/__tests__/doctor.test.ts | 49 +++++++++++++++++++++++++++ src/commands/doctor.ts | 21 +++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/commands/__tests__/doctor.test.ts b/src/commands/__tests__/doctor.test.ts index 886e043..e595502 100644 --- a/src/commands/__tests__/doctor.test.ts +++ b/src/commands/__tests__/doctor.test.ts @@ -141,6 +141,55 @@ describe('doctor command', () => { }) }) + describe('when the IP is not allow-listed', () => { + beforeEach(() => { + const err = new Error( + 'authentication_error: allowed_cidr_blocks_does_not_contain_ip\n' + + 'IP is not in allowed CIDR blocks. Please check that your allowed CIDR blocks contains your IP.', + ) + Object.defineProperty(err, 'constructor', { value: { name: 'AuthenticationError' } }) + vi.mocked(whoami).mockRejectedValue(err) + }) + + test('reports the API as reachable but the IP as blocked', async () => { + const program = createProgram() + await program.parseAsync(['doctor'], { from: 'user' }) + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('reachable, but your IP is not allow-listed'), + ) + expect(error).not.toHaveBeenCalledWith(expect.stringContaining('could not reach')) + }) + + test('points to the dashboard to fix the allow list', async () => { + const program = createProgram() + await program.parseAsync(['doctor'], { from: 'user' }) + + expect(hint).toHaveBeenCalledWith(expect.stringContaining('allowed CIDR blocks')) + expect(hint).toHaveBeenCalledWith( + expect.stringContaining('https://dashboard.fintoc.com/api-keys'), + ) + }) + }) + + describe('when the API key is rejected', () => { + beforeEach(() => { + const err = new Error('authentication_error\nInvalid API key') + Object.defineProperty(err, 'constructor', { value: { name: 'AuthenticationError' } }) + vi.mocked(whoami).mockRejectedValue(err) + }) + + test('reports the API as reachable but the key as rejected', async () => { + const program = createProgram() + await program.parseAsync(['doctor'], { from: 'user' }) + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('reachable, but the API key was rejected'), + ) + expect(error).not.toHaveBeenCalledWith(expect.stringContaining('could not reach')) + }) + }) + describe('when CLI version is outdated', () => { beforeEach(() => { vi.mocked(execSync).mockReturnValue('0.2.0\n') diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index e3bf159..cb91a67 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -6,9 +6,12 @@ import { CONFIG_PATH, readConfig } from '../lib/config.js' import { API_HOST, CONFIG_FILE_PERMISSIONS, + DASHBOARD_API_KEYS_URL, + IP_ALLOWLIST_ERROR_CODES, NPM_CHECK_TIMEOUT_MS, NPM_PACKAGE_NAME, } from '../lib/constants.js' +import { parseFintocError } from '../lib/errors.js' import { error, hint, info, success, warn } from '../lib/output.js' import { getCliVersion } from '../lib/version.js' @@ -71,7 +74,23 @@ const checkConnectivity = async (secretKey: string) => { const info = await whoami(secretKey) success(`Connectivity ${API_HOST} reachable`) success(`Organization ${info.organizationName} (${info.mode} mode)`) - } catch { + } catch (err) { + const fields = parseFintocError(err) + + if (fields?.code && IP_ALLOWLIST_ERROR_CODES.has(fields.code)) { + error(`Connectivity ${API_HOST} reachable, but your IP is not allow-listed`) + hint(` ${fields.message ?? 'Your IP is not in the allowed CIDR blocks.'}`) + hint(' Add your IP to the allow list in the dashboard:') + hint(` ${DASHBOARD_API_KEYS_URL}`) + return + } + + if (fields?.type === 'authentication_error') { + error(`Connectivity ${API_HOST} reachable, but the API key was rejected`) + hint(' Check your API key is valid for this environment.') + return + } + error(`Connectivity could not reach ${API_HOST}`) hint(' Check your internet connection and API key') } From acf071f7e513f314b67f1c8d2c5f0fda0c3fd309 Mon Sep 17 00:00:00 2001 From: Verner Codoceo Date: Wed, 24 Jun 2026 12:25:05 -0400 Subject: [PATCH 3/3] fix(doctor): align check labels, values and continuation lines The continuation lines (e.g. the "Run `fintoc login`" hint) were indented to column 20, but values sit at column 22 (status glyph + space + label), so they drifted left. The skipped checks were also printed as raw hints with a leading indent and a width-ambiguous skip glyph, breaking the column entirely. Route every line through row()/detail() helpers that pad to a single width, and render skipped checks via info() like the rest, so labels, values and follow-up lines share one column. --- src/commands/__tests__/doctor.test.ts | 2 +- src/commands/doctor.ts | 59 +++++++++++++++------------ 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/commands/__tests__/doctor.test.ts b/src/commands/__tests__/doctor.test.ts index e595502..b5d29da 100644 --- a/src/commands/__tests__/doctor.test.ts +++ b/src/commands/__tests__/doctor.test.ts @@ -123,7 +123,7 @@ describe('doctor command', () => { await program.parseAsync(['doctor'], { from: 'user' }) expect(error).toHaveBeenCalledWith(expect.stringContaining('API key')) - expect(hint).toHaveBeenCalledWith(expect.stringContaining('skipped')) + expect(info).toHaveBeenCalledWith(expect.stringContaining('skipped')) expect(whoami).not.toHaveBeenCalled() }) }) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index cb91a67..806ba0a 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -15,6 +15,13 @@ import { parseFintocError } from '../lib/errors.js' import { error, hint, info, success, warn } from '../lib/output.js' import { getCliVersion } from '../lib/version.js' +const LABEL_WIDTH = 20 +const DETAIL_INDENT = 2 + LABEL_WIDTH + +const row = (label: string, value: string) => `${label.padEnd(LABEL_WIDTH)}${value}` + +const detail = (text: string) => hint(`${' '.repeat(DETAIL_INDENT)}${text}`) + const checkCliVersion = () => { const currentVersion = getCliVersion() try { @@ -25,22 +32,22 @@ const checkCliVersion = () => { }).trim() if (latest === currentVersion) { - success(`CLI version ${currentVersion} (latest)`) + success(row('CLI version', `${currentVersion} (latest)`)) } else { - warn(`CLI version ${currentVersion} (latest: ${latest})`) + warn(row('CLI version', `${currentVersion} (latest: ${latest})`)) } } catch { - success(`CLI version ${currentVersion}`) - hint(' (could not check for updates)') + success(row('CLI version', currentVersion)) + detail('(could not check for updates)') } } const checkConfigFile = (authSource?: string) => { if (!existsSync(CONFIG_PATH)) { if (authSource) { - warn(`Config file ${CONFIG_PATH} not found (using ${authSource})`) + warn(row('Config file', `${CONFIG_PATH} not found (using ${authSource})`)) } else { - error(`Config file ${CONFIG_PATH} not found`) + error(row('Config file', `${CONFIG_PATH} not found`)) } return } @@ -49,22 +56,22 @@ const checkConfigFile = (authSource?: string) => { const mode = stats.mode & 0o777 if (mode !== CONFIG_FILE_PERMISSIONS) { warn( - `Config file ${CONFIG_PATH} found (permissions: ${mode.toString(8)}, expected: 600)`, + row('Config file', `${CONFIG_PATH} found (permissions: ${mode.toString(8)}, expected: 600)`), ) return } - success(`Config file ${CONFIG_PATH} found`) + success(row('Config file', `${CONFIG_PATH} found`)) } const checkApiKey = (options?: { apiKey?: string }) => { try { const auth = resolveAuth(options) - success(`API key ${maskKey(auth.secretKey)} (source: ${auth.source})`) + success(row('API key', `${maskKey(auth.secretKey)} (source: ${auth.source})`)) return auth } catch { - error('API key not configured') - hint(' Run `fintoc login` or set FINTOC_API_KEY') + error(row('API key', 'not configured')) + detail('Run `fintoc login` or set FINTOC_API_KEY') return null } } @@ -72,43 +79,43 @@ const checkApiKey = (options?: { apiKey?: string }) => { const checkConnectivity = async (secretKey: string) => { try { const info = await whoami(secretKey) - success(`Connectivity ${API_HOST} reachable`) - success(`Organization ${info.organizationName} (${info.mode} mode)`) + success(row('Connectivity', `${API_HOST} reachable`)) + success(row('Organization', `${info.organizationName} (${info.mode} mode)`)) } catch (err) { const fields = parseFintocError(err) if (fields?.code && IP_ALLOWLIST_ERROR_CODES.has(fields.code)) { - error(`Connectivity ${API_HOST} reachable, but your IP is not allow-listed`) - hint(` ${fields.message ?? 'Your IP is not in the allowed CIDR blocks.'}`) - hint(' Add your IP to the allow list in the dashboard:') - hint(` ${DASHBOARD_API_KEYS_URL}`) + error(row('Connectivity', `${API_HOST} reachable, but your IP is not allow-listed`)) + detail(fields.message ?? 'Your IP is not in the allowed CIDR blocks.') + detail('Add your IP to the allow list in the dashboard:') + detail(DASHBOARD_API_KEYS_URL) return } if (fields?.type === 'authentication_error') { - error(`Connectivity ${API_HOST} reachable, but the API key was rejected`) - hint(' Check your API key is valid for this environment.') + error(row('Connectivity', `${API_HOST} reachable, but the API key was rejected`)) + detail('Check your API key is valid for this environment.') return } - error(`Connectivity could not reach ${API_HOST}`) - hint(' Check your internet connection and API key') + error(row('Connectivity', `could not reach ${API_HOST}`)) + detail('Check your internet connection and API key') } } const checkJwsKey = () => { const config = readConfig() if (!config.jws_private_key) { - info('JWS private key not configured (only needed for transfers create)') + info(row('JWS private key', 'not configured (only needed for transfers create)')) return } if (!existsSync(config.jws_private_key)) { - error(`JWS private key file not found: ${config.jws_private_key}`) + error(row('JWS private key', `file not found: ${config.jws_private_key}`)) return } - success(`JWS private key ${config.jws_private_key}`) + success(row('JWS private key', config.jws_private_key)) } export const doctorCommand = (program: Command) => { @@ -125,8 +132,8 @@ export const doctorCommand = (program: Command) => { if (auth) { await checkConnectivity(auth.secretKey) } else { - hint(' ⏭ Connectivity skipped (no API key)') - hint(' ⏭ Organization skipped (no API key)') + info(row('Connectivity', 'skipped (no API key)')) + info(row('Organization', 'skipped (no API key)')) } checkJwsKey()