Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-version-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@inflowpayai/inflow': patch
---

Display unsupported CLI version responses from InFlow as actionable upgrade errors.
59 changes: 59 additions & 0 deletions packages/core/src/resources/api-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { InflowApiError } from '../errors.js';
import type { ApiResponse } from '../utils/api-client.js';
import { redactRawBody } from '../utils/redact.js';

interface ProblemError {
code?: unknown;
message?: unknown;
}

interface ProblemEnvelope {
errors?: unknown;
install_url?: unknown;
current_version?: unknown;
minimum_supported_version?: unknown;
latest_version?: unknown;
}

const VERSION_UNSUPPORTED_CODE = 'VERSION_UNSUPPORTED';
const DEFAULT_INSTALL_URL = 'https://inflowcli.ai/';

function firstProblem(data: unknown): ProblemError | undefined {
const envelope = data as ProblemEnvelope | null;
if (!Array.isArray(envelope?.errors)) return undefined;
const first: unknown = envelope.errors[0];
if (first === null || typeof first !== 'object') return undefined;
return first;
}

function stringField(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

function versionUnsupportedMessage(data: unknown): string {
const envelope = data as ProblemEnvelope | null;
const installUrl = stringField(envelope?.install_url) ?? DEFAULT_INSTALL_URL;
return `This InFlow CLI version is no longer supported.\nInstall the latest version: ${installUrl}`;
}

export function isVersionUnsupportedResponse(data: unknown): boolean {
return stringField(firstProblem(data)?.code) === VERSION_UNSUPPORTED_CODE;
}

export function createApiError(response: ApiResponse, fallbackPrefix: string): InflowApiError {
const problem = firstProblem(response.data);
const code = stringField(problem?.code);
const message = stringField(problem?.message);
const fallback = redactRawBody(response.rawBody) || 'unknown error';
const errorMessage =
code === VERSION_UNSUPPORTED_CODE
? versionUnsupportedMessage(response.data)
: `${fallbackPrefix} (${String(response.status)}): ${message ?? fallback}`;

return new InflowApiError(errorMessage, {
status: response.status,
...(code !== undefined ? { code } : {}),
rawBody: response.rawBody,
details: response.data,
});
}
11 changes: 11 additions & 0 deletions packages/core/src/resources/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { InflowApiError, InflowConfigurationError } from '../errors.js';
import type { AuthTokens, DeviceAuthRequest } from '../types/index.js';
import { InflowApiClient } from '../utils/api-client.js';
import { redactRawBody } from '../utils/redact.js';
import { createApiError, isVersionUnsupportedResponse } from './api-error.js';
import type { IAuthResource } from './interfaces.js';

const DEFAULT_SCOPE = 'balances:read deposit-addresses:read transactions:read transactions:write';
Expand Down Expand Up @@ -67,6 +68,9 @@ export class AuthResource implements IAuthResource {
);

if (status < 200 || status >= 300) {
if (isVersionUnsupportedResponse(data)) {
throw createApiError({ status, data, rawBody }, 'Device auth initiation failed');
}
throw new InflowApiError(formatOAuthError('Device auth initiation failed', status, data, rawBody), {
status,
rawBody,
Expand Down Expand Up @@ -138,6 +142,10 @@ export class AuthResource implements IAuthResource {
}
}

if (isVersionUnsupportedResponse(data)) {
throw createApiError({ status, data, rawBody }, 'Token poll failed');
}

throw new InflowApiError(formatOAuthError('Token poll failed', status, data, rawBody), {
status,
rawBody,
Expand All @@ -159,6 +167,9 @@ export class AuthResource implements IAuthResource {
);

if (status < 200 || status >= 300) {
if (isVersionUnsupportedResponse(data)) {
throw createApiError({ status, data, rawBody }, 'Token refresh failed');
}
throw new InflowApiError(formatOAuthError('Token refresh failed', status, data, rawBody), {
status,
rawBody,
Expand Down
15 changes: 4 additions & 11 deletions packages/core/src/resources/balance.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { type InflowOptions, type ResolvedInflowSdkConfig, resolveInflowSdkConfig } from '../config.js';
import { InflowApiError } from '../errors.js';
import type { Balance } from '../types/index.js';
import { InflowApiClient } from '../utils/api-client.js';
import { normalizeDecimalString } from '../utils/decimal.js';
import { redactRawBody } from '../utils/redact.js';
import { createApiError } from './api-error.js';
import type { IBalanceResource } from './interfaces.js';

interface BalancesResponse {
Expand All @@ -20,16 +19,10 @@ export class BalanceResource implements IBalanceResource {

async list(options: { signal?: AbortSignal } = {}): Promise<Balance[]> {
const requestOptions = options.signal !== undefined ? { signal: options.signal } : {};
const { status, data, rawBody } = await this.api.get('/v1/balances', requestOptions);
const response = await this.api.get('/v1/balances', requestOptions);
const { status, data } = response;
if (status < 200 || status >= 300) {
throw new InflowApiError(
`Failed to list balances (${String(status)}): ${redactRawBody(rawBody) || 'unknown error'}`,
{
status,
rawBody,
details: data,
},
);
throw createApiError(response, 'Failed to list balances');
}
const body = data as BalancesResponse | null;
return (body?.balances ?? []).map((balance) => ({
Expand Down
15 changes: 4 additions & 11 deletions packages/core/src/resources/deposit-address.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { type InflowOptions, type ResolvedInflowSdkConfig, resolveInflowSdkConfig } from '../config.js';
import { InflowApiError } from '../errors.js';
import type { DepositAddresses } from '../types/index.js';
import { InflowApiClient } from '../utils/api-client.js';
import { redactRawBody } from '../utils/redact.js';
import { createApiError } from './api-error.js';
import type { IDepositAddressResource } from './interfaces.js';

export class DepositAddressResource implements IDepositAddressResource {
Expand All @@ -15,16 +14,10 @@ export class DepositAddressResource implements IDepositAddressResource {

async list(options: { signal?: AbortSignal } = {}): Promise<DepositAddresses> {
const requestOptions = options.signal !== undefined ? { signal: options.signal } : {};
const { status, data, rawBody } = await this.api.get('/v1/deposit-addresses', requestOptions);
const response = await this.api.get('/v1/deposit-addresses', requestOptions);
const { status, data } = response;
if (status < 200 || status >= 300) {
throw new InflowApiError(
`Failed to list deposit addresses (${String(status)}): ${redactRawBody(rawBody) || 'unknown error'}`,
{
status,
rawBody,
details: data,
},
);
throw createApiError(response, 'Failed to list deposit addresses');
}
const body = (data as Partial<DepositAddresses> | null) ?? {};
return {
Expand Down
15 changes: 4 additions & 11 deletions packages/core/src/resources/user.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { type InflowOptions, type ResolvedInflowSdkConfig, resolveInflowSdkConfig } from '../config.js';
import { InflowApiError } from '../errors.js';
import type { User } from '../types/index.js';
import { InflowApiClient } from '../utils/api-client.js';
import { redactRawBody } from '../utils/redact.js';
import { createApiError } from './api-error.js';
import type { IUserResource } from './interfaces.js';

export class UserResource implements IUserResource {
Expand All @@ -15,16 +14,10 @@ export class UserResource implements IUserResource {

async retrieve(options: { signal?: AbortSignal } = {}): Promise<User> {
const requestOptions = options.signal !== undefined ? { signal: options.signal } : {};
const { status, data, rawBody } = await this.api.get('/v1/users/self', requestOptions);
const response = await this.api.get('/v1/users/self', requestOptions);
const { status, data } = response;
if (status < 200 || status >= 300) {
throw new InflowApiError(
`Failed to retrieve user (${String(status)}): ${redactRawBody(rawBody) || 'unknown error'}`,
{
status,
rawBody,
details: data,
},
);
throw createApiError(response, 'Failed to retrieve user');
}
return data as User;
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export class InflowApiClient {
const attemptHeaders: Record<string, string> = {
Accept: 'application/json',
'User-Agent': SDK_USER_AGENT,
...(skipAuth ? (this.config.defaultHeaders ?? {}) : {}),
...headers,
};
if (!skipAuth) {
Expand Down
75 changes: 75 additions & 0 deletions packages/core/test/unit/resources/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,81 @@ describe('UserResource', () => {
await expect(r.retrieve()).rejects.toBeInstanceOf(InflowApiError);
});

it('surfaces unsupported CLI version responses with the upgrade link', async () => {
server.use(
http.get(`${BASE_URL}/v1/users/self`, () =>
HttpResponse.json(
{
errors: [
{
code: 'VERSION_UNSUPPORTED',
message: 'This version of the InFlow CLI is not supported.',
parameter: 'InFlow-CLI-Version',
},
],
current_version: '0.8.0',
minimum_supported_version: '0.9.0',
latest_version: '0.9.1',
install_url: 'https://inflowcli.ai/',
},
{ status: 426 },
),
),
);
const r = new UserResource({
apiBaseUrl: BASE_URL,
accessToken: 'tk',
});

let error: unknown;
try {
await r.retrieve();
} catch (err) {
error = err;
}

expect(error).toBeInstanceOf(InflowApiError);
expect((error as InflowApiError).status).toBe(426);
expect((error as InflowApiError).code).toBe('VERSION_UNSUPPORTED');
expect((error as InflowApiError).message).toBe(
'This InFlow CLI version is no longer supported.\nInstall the latest version: https://inflowcli.ai/',
);
expect((error as InflowApiError).details).toMatchObject({
current_version: '0.8.0',
minimum_supported_version: '0.9.0',
latest_version: '0.9.1',
install_url: 'https://inflowcli.ai/',
});
});

it('uses problem detail messages for ordinary API failures', async () => {
server.use(
http.get(`${BASE_URL}/v1/users/self`, () =>
HttpResponse.json(
{
errors: [
{
code: 'account_locked',
message: 'Account access is locked.',
},
],
},
{ status: 403 },
),
),
);
const r = new UserResource({
apiBaseUrl: BASE_URL,
accessToken: 'tk',
});

await expect(r.retrieve()).rejects.toMatchObject({
code: 'account_locked',
message: 'Failed to retrieve user (403): Account access is locked.',
status: 403,
});
});

it('aborts the underlying fetch when the caller-supplied signal fires', async () => {
server.use(
http.get(`${BASE_URL}/v1/users/self`, async () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/unit/utils/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,13 @@ describe('InflowApiClient — request bodies & headers', () => {
fetch,
apiBaseUrl: 'https://api.test',
accessToken: 'tk',
defaultHeaders: { 'InFlow-CLI-Version': '1.2.3' },
});
const client = new InflowApiClient(c, c.apiBaseUrl);
await client.postForm('/v1/auth', { client_id: 'cid', scope: 'a b' });
expect(calls[0]?.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded');
expect(calls[0]?.body).toBe('client_id=cid&scope=a+b');
expect(calls[0]?.headers.get('InFlow-CLI-Version')).toBe('1.2.3');
expect(calls[0]?.headers.get('Authorization')).toBeNull();
});

Expand Down
Loading