From 1b616605a0155287f5923fc24dadb4822251d3e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 19:40:40 +0000 Subject: [PATCH 1/2] fix: auto-detect axiom region from the api token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Axiom has no regional console or API hosts (app.axiom.co / api.axiom.co everywhere); the region only surfaces as the org's edge deployment under Settings > General, which the old prompt pointed at vaguely. Collect the token first and detect the region from /v2/orgs (defaultEdgeDeployment) with /v2/datasets (edgeDeployment) as fallback; only ask — with exact directions — when detection is ambiguous. A 401 from either probe surfaces as a bad-token error instead of a wrong region. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MefBGfjDGUX8nRkF1YrE9w --- src/commands/integration/connect.ts | 139 +++++++++++++++++--- test/integration-connect-axiom.test.ts | 170 +++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 18 deletions(-) create mode 100644 test/integration-connect-axiom.test.ts diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 14dd6c5..fe4d85f 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -136,6 +136,74 @@ function datadogConsoleUrl(site: string): string { return appPrefixed ? `https://app.${site}` : `https://${site}`; } +// Axiom's console and management API are region-less (app.axiom.co / +// api.axiom.co); only the org's edge deployment differs, shown in the console +// under Settings > General > Edge deployment and returned by the management +// API as identifiers like "cloud.eu-central-1.aws". +// https://axiom.co/docs/reference/edge-deployments +export type AxiomRegion = 'us-east-1' | 'eu-central-1'; + +const AXIOM_REGIONS: Array<{ value: AxiomRegion; label: string }> = [ + { value: 'us-east-1', label: 'US East 1' }, + { value: 'eu-central-1', label: 'EU Central 1' }, +]; + +const AXIOM_API_BASE = 'https://api.axiom.co'; +const AXIOM_PROBE_TIMEOUT_MS = 5000; +const AXIOM_REGION_HINT = 'In Axiom: Settings > General > Edge deployment (https://app.axiom.co/settings/general)'; + +export function axiomRegionFromEdgeDeployment(value: unknown): AxiomRegion | null { + if (typeof value !== 'string') return null; + if (value.includes('eu-central-1')) return 'eu-central-1'; + if (value.includes('us-east-1')) return 'us-east-1'; + return null; +} + +function uniqueAxiomRegion(items: unknown, field: string): AxiomRegion | null { + if (!Array.isArray(items)) return null; + const regions = new Set(); + for (const item of items) { + const value = (item as Record | null)?.[field]; + const region = axiomRegionFromEdgeDeployment(value); + if (region !== null) regions.add(region); + } + return regions.size === 1 ? [...regions][0]! : null; +} + +export type AxiomRegionDetection = + | { outcome: 'detected'; region: AxiomRegion } + | { outcome: 'unauthorized' } + | { outcome: 'unknown' }; + +// Detect the org's edge deployment from the token: /v2/orgs carries +// defaultEdgeDeployment but needs an org permission the recommended token may +// lack; /v2/datasets works with the Datasets-read permission the connect +// instructions require and carries edgeDeployment per dataset. Ambiguity +// (multi-edge org, no datasets, network trouble) falls back to asking. +export async function detectAxiomRegion( + apiToken: string, + fetchFn: typeof fetch = fetch +): Promise { + const probe = async (path: string): Promise<{ status: number; body: unknown } | null> => { + try { + const res = await fetchFn(`${AXIOM_API_BASE}${path}`, { + headers: { authorization: `Bearer ${apiToken}` }, + signal: AbortSignal.timeout(AXIOM_PROBE_TIMEOUT_MS), + }); + return { status: res.status, body: res.ok ? ((await res.json()) as unknown) : null }; + } catch { + return null; + } + }; + const [orgs, datasets] = await Promise.all([probe('/v2/orgs'), probe('/v2/datasets')]); + const fromOrgs = orgs?.status === 200 ? uniqueAxiomRegion(orgs.body, 'defaultEdgeDeployment') : null; + if (fromOrgs !== null) return { outcome: 'detected', region: fromOrgs }; + const fromDatasets = datasets?.status === 200 ? uniqueAxiomRegion(datasets.body, 'edgeDeployment') : null; + if (fromDatasets !== null) return { outcome: 'detected', region: fromDatasets }; + if (orgs?.status === 401 || datasets?.status === 401) return { outcome: 'unauthorized' }; + return { outcome: 'unknown' }; +} + const CODE_AGENTS = { devin: { name: 'Devin', @@ -555,24 +623,10 @@ async function connectWithCredentials( ...honeycombManagementKeyFields(managementApiKeyId, managementApiKeySecret), }; } else if (type === 'axiom') { - let region: 'us-east-1' | 'eu-central-1' = 'us-east-1'; + let region: AxiomRegion = 'us-east-1'; let apiToken = ''; + const quiet = config.quiet || config.output === 'json'; const ok = await runSteps([ - choiceStep<'us-east-1' | 'eu-central-1'>( - config, - args, - 'region', - '--region', - 'Axiom edge deployment region: see your organization settings (https://app.axiom.co/settings/org)', - [ - { value: 'us-east-1', label: 'US East 1' }, - { value: 'eu-central-1', label: 'EU Central 1' }, - ], - (v) => { - region = v; - }, - { strict: true } - ), secretStep( config, args, @@ -589,6 +643,55 @@ async function connectWithCredentials( apiToken = v; } ), + // Region: flag wins, then auto-detection from the token, then (only when + // detection is ambiguous) an interactive prompt. + async () => { + const fromFlag = getArgString(args, 'region'); + if (fromFlag !== undefined) { + if (!AXIOM_REGIONS.some((o) => o.value === fromFlag)) { + throw new CLIError( + `Invalid value for --region: "${fromFlag}"`, + ExitCode.USAGE, + `Use one of: ${AXIOM_REGIONS.map((o) => o.value).join(', ')}` + ); + } + region = fromFlag as AxiomRegion; + return SKIPPED; + } + if (!quiet) process.stderr.write('Detecting your Axiom region…\n'); + const detection = await detectAxiomRegion(apiToken); + if (detection.outcome === 'detected') { + region = detection.region; + if (!quiet) process.stderr.write(`✓ Axiom region: ${detection.region}\n`); + return SKIPPED; + } + if (detection.outcome === 'unauthorized') { + if (!isInteractive(config.nonInteractive) || getArgString(args, 'apiToken') !== undefined) { + throw new CLIError( + 'Axiom rejected the API token', + ExitCode.AUTH, + 'Create a token at https://app.axiom.co/settings/api-tokens and retry.' + ); + } + note('Axiom rejected the token (401 from api.axiom.co). Paste it again — it starts with xaat-.', 'Axiom API token'); + return BACK; + } + if (!isInteractive(config.nonInteractive)) { + throw new CLIError( + 'Missing required flag: --region', + ExitCode.USAGE, + `Could not detect the region from the token. Pass --region us-east-1 or --region eu-central-1.\n${AXIOM_REGION_HINT}` + ); + } + const picked = await promptSelectOrBack( + { nonInteractive: config.nonInteractive }, + `Axiom region — could not detect it from the token. ${AXIOM_REGION_HINT}`, + AXIOM_REGIONS + ); + if (picked === BACK) return BACK; + region = picked; + return; + }, ]); if (!ok) return BACK; body = { type: 'axiom', workspaceId, region, apiToken }; @@ -729,7 +832,7 @@ export const integrationConnectCommand: Command = { type: 'string', }, { flag: '--site ', description: 'Datadog site (e.g. us5.datadoghq.com)', type: 'string' }, - { flag: '--region ', description: 'Honeycomb (us|eu) or Axiom (us-east-1|eu-central-1)', type: 'string' }, + { flag: '--region ', description: 'Honeycomb (us|eu) or Axiom (us-east-1|eu-central-1; detected from the token if omitted)', type: 'string' }, { flag: '--api-key ', description: 'API key (Datadog / Honeycomb / Devin / Cursor / Factory / Conductor)', type: 'string' }, { flag: '--app-key ', description: 'App key (Datadog only)', type: 'string' }, { flag: '--management-api-key-id ', description: 'Management API key ID (Honeycomb)', type: 'string' }, @@ -755,7 +858,7 @@ export const integrationConnectCommand: Command = { 'polylane integration connect --category code-agent', 'polylane integration connect --type datadog --site us5.datadoghq.com --api-key ... --app-key ...', 'polylane integration connect --type honeycomb --region us --api-key ... --management-api-key-id ... --management-api-key-secret ...', - 'polylane integration connect --type axiom --region us-east-1 --api-token ...', + 'polylane integration connect --type axiom --api-token ...', 'polylane integration connect --type betterstack --api-token ... --uptime-api-token ... --telemetry-api-token ...', 'polylane integration connect --type cursor --api-key crsr_...', 'polylane integration connect --type mcp --url https://mcp.example.com/sse --name "My MCP"', diff --git a/test/integration-connect-axiom.test.ts b/test/integration-connect-axiom.test.ts new file mode 100644 index 0000000..722999c --- /dev/null +++ b/test/integration-connect-axiom.test.ts @@ -0,0 +1,170 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { axiomRegionFromEdgeDeployment, detectAxiomRegion } from '../src/commands/integration/connect'; + +describe('axiomRegionFromEdgeDeployment', () => { + it('maps edge deployment identifiers to regions', () => { + assert.equal(axiomRegionFromEdgeDeployment('cloud.eu-central-1.aws'), 'eu-central-1'); + assert.equal(axiomRegionFromEdgeDeployment('cloud.us-east-1.aws'), 'us-east-1'); + assert.equal(axiomRegionFromEdgeDeployment('eu-central-1.aws.edge.axiom.co'), 'eu-central-1'); + assert.equal(axiomRegionFromEdgeDeployment('us-east-1.aws.edge.axiom.co'), 'us-east-1'); + }); + + it('returns null for unknown or non-string values', () => { + assert.equal(axiomRegionFromEdgeDeployment('cloud.ap-south-1.aws'), null); + assert.equal(axiomRegionFromEdgeDeployment(''), null); + assert.equal(axiomRegionFromEdgeDeployment(undefined), null); + assert.equal(axiomRegionFromEdgeDeployment(42), null); + }); +}); + +type Route = { status: number; body?: unknown } | 'error'; + +function mockFetch(routes: Record): typeof fetch { + return (async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const path = new URL(url).pathname; + const route = routes[path]; + assert.ok(route, `unexpected fetch: ${path}`); + if (route === 'error') throw new Error('network down'); + return new Response(JSON.stringify(route.body ?? null), { + status: route.status, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; +} + +describe('detectAxiomRegion', () => { + it('detects the region from the org default edge deployment', async () => { + const result = await detectAxiomRegion( + 'xaat-token', + mockFetch({ + '/v2/orgs': { status: 200, body: [{ id: 'org1', defaultEdgeDeployment: 'cloud.eu-central-1.aws' }] }, + '/v2/datasets': { status: 200, body: [] }, + }) + ); + assert.deepEqual(result, { outcome: 'detected', region: 'eu-central-1' }); + }); + + it('falls back to dataset edge deployments when orgs is forbidden', async () => { + const result = await detectAxiomRegion( + 'xaat-token', + mockFetch({ + '/v2/orgs': { status: 403 }, + '/v2/datasets': { + status: 200, + body: [ + { id: 'logs', edgeDeployment: 'cloud.us-east-1.aws' }, + { id: 'traces', edgeDeployment: 'cloud.us-east-1.aws' }, + ], + }, + }) + ); + assert.deepEqual(result, { outcome: 'detected', region: 'us-east-1' }); + }); + + it('falls back to datasets when org regions are ambiguous', async () => { + const result = await detectAxiomRegion( + 'xaat-token', + mockFetch({ + '/v2/orgs': { + status: 200, + body: [ + { id: 'org1', defaultEdgeDeployment: 'cloud.us-east-1.aws' }, + { id: 'org2', defaultEdgeDeployment: 'cloud.eu-central-1.aws' }, + ], + }, + '/v2/datasets': { status: 200, body: [{ id: 'logs', edgeDeployment: 'cloud.eu-central-1.aws' }] }, + }) + ); + assert.deepEqual(result, { outcome: 'detected', region: 'eu-central-1' }); + }); + + it('is unknown when datasets span both regions', async () => { + const result = await detectAxiomRegion( + 'xaat-token', + mockFetch({ + '/v2/orgs': { status: 403 }, + '/v2/datasets': { + status: 200, + body: [ + { id: 'logs', edgeDeployment: 'cloud.us-east-1.aws' }, + { id: 'traces', edgeDeployment: 'cloud.eu-central-1.aws' }, + ], + }, + }) + ); + assert.deepEqual(result, { outcome: 'unknown' }); + }); + + it('is unknown when no response carries an edge deployment', async () => { + const result = await detectAxiomRegion( + 'xaat-token', + mockFetch({ + '/v2/orgs': { status: 200, body: [{ id: 'org1' }] }, + '/v2/datasets': { status: 200, body: [{ id: 'logs' }] }, + }) + ); + assert.deepEqual(result, { outcome: 'unknown' }); + }); + + it('is unauthorized when every probe returns 401', async () => { + const result = await detectAxiomRegion( + 'xaat-bad', + mockFetch({ + '/v2/orgs': { status: 401 }, + '/v2/datasets': { status: 401 }, + }) + ); + assert.deepEqual(result, { outcome: 'unauthorized' }); + }); + + it('is unauthorized on a single 401 even when the other probe fails on the network', async () => { + const result = await detectAxiomRegion( + 'xaat-token', + mockFetch({ + '/v2/orgs': 'error', + '/v2/datasets': { status: 401 }, + }) + ); + assert.deepEqual(result, { outcome: 'unauthorized' }); + }); + + it('is unknown when both probes fail on the network', async () => { + const result = await detectAxiomRegion( + 'xaat-token', + mockFetch({ + '/v2/orgs': 'error', + '/v2/datasets': 'error', + }) + ); + assert.deepEqual(result, { outcome: 'unknown' }); + }); + + it('is unknown when a body is not the expected array', async () => { + const result = await detectAxiomRegion( + 'xaat-token', + mockFetch({ + '/v2/orgs': { status: 200, body: { defaultEdgeDeployment: 'cloud.us-east-1.aws' } }, + '/v2/datasets': { status: 200, body: 'nope' }, + }) + ); + assert.deepEqual(result, { outcome: 'unknown' }); + }); + + it('sends the token as a bearer header to api.axiom.co', async () => { + const seen: Array<{ url: string; auth: string | null }> = []; + const fetchFn = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const headers = new Headers(init?.headers); + seen.push({ url, auth: headers.get('authorization') }); + return new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + await detectAxiomRegion('xaat-secret', fetchFn); + assert.equal(seen.length, 2); + for (const req of seen) { + assert.ok(req.url.startsWith('https://api.axiom.co/v2/'), req.url); + assert.equal(req.auth, 'Bearer xaat-secret'); + } + }); +}); From 51c029f201298f3b6d3e052cbbeaae5d96b8e382 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 17:48:06 +0000 Subject: [PATCH 2/2] refactor(integration): let the backend detect the axiom region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API now serves region as optional on integrations.connect and detects it from the token server-side, so the client-side probing of Axiom's /v2/orgs and /v2/datasets is gone. The axiom flow sends the connect request without region (--region stays as an explicit override) and only asks for the region when the backend answers 422 — interactively via a picker pointing at Axiom Settings > General > Edge deployment, non-interactively as a usage error hinting at --region. mapApiError now returns ApiError (a CLIError carrying the HTTP status), with 422 mapped to a usage error, so commands can react to specific statuses. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MefBGfjDGUX8nRkF1YrE9w --- src/commands/integration/connect.ts | 149 +++++------------- src/errors/api.ts | 44 ++++-- test/errors.test.ts | 7 + test/integration-connect-axiom.test.ts | 202 ++++++------------------- 4 files changed, 128 insertions(+), 274 deletions(-) diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index fe4d85f..70b9b84 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -19,6 +19,7 @@ import { type WizardStep, } from '../helpers'; import type { Integration } from '../../generated/types'; +import { isApiError } from '../../errors/api'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { openBrowser } from '../../utils/browser'; @@ -136,72 +137,42 @@ function datadogConsoleUrl(site: string): string { return appPrefixed ? `https://app.${site}` : `https://${site}`; } -// Axiom's console and management API are region-less (app.axiom.co / -// api.axiom.co); only the org's edge deployment differs, shown in the console -// under Settings > General > Edge deployment and returned by the management -// API as identifiers like "cloud.eu-central-1.aws". -// https://axiom.co/docs/reference/edge-deployments -export type AxiomRegion = 'us-east-1' | 'eu-central-1'; +// The backend detects the Axiom edge deployment region from the API token and +// answers 422 when it cannot; --region is only an explicit override. +type AxiomRegion = 'us-east-1' | 'eu-central-1'; const AXIOM_REGIONS: Array<{ value: AxiomRegion; label: string }> = [ { value: 'us-east-1', label: 'US East 1' }, { value: 'eu-central-1', label: 'EU Central 1' }, ]; -const AXIOM_API_BASE = 'https://api.axiom.co'; -const AXIOM_PROBE_TIMEOUT_MS = 5000; const AXIOM_REGION_HINT = 'In Axiom: Settings > General > Edge deployment (https://app.axiom.co/settings/general)'; -export function axiomRegionFromEdgeDeployment(value: unknown): AxiomRegion | null { - if (typeof value !== 'string') return null; - if (value.includes('eu-central-1')) return 'eu-central-1'; - if (value.includes('us-east-1')) return 'us-east-1'; - return null; -} - -function uniqueAxiomRegion(items: unknown, field: string): AxiomRegion | null { - if (!Array.isArray(items)) return null; - const regions = new Set(); - for (const item of items) { - const value = (item as Record | null)?.[field]; - const region = axiomRegionFromEdgeDeployment(value); - if (region !== null) regions.add(region); - } - return regions.size === 1 ? [...regions][0]! : null; -} - -export type AxiomRegionDetection = - | { outcome: 'detected'; region: AxiomRegion } - | { outcome: 'unauthorized' } - | { outcome: 'unknown' }; - -// Detect the org's edge deployment from the token: /v2/orgs carries -// defaultEdgeDeployment but needs an org permission the recommended token may -// lack; /v2/datasets works with the Datasets-read permission the connect -// instructions require and carries edgeDeployment per dataset. Ambiguity -// (multi-edge org, no datasets, network trouble) falls back to asking. -export async function detectAxiomRegion( - apiToken: string, - fetchFn: typeof fetch = fetch -): Promise { - const probe = async (path: string): Promise<{ status: number; body: unknown } | null> => { - try { - const res = await fetchFn(`${AXIOM_API_BASE}${path}`, { - headers: { authorization: `Bearer ${apiToken}` }, - signal: AbortSignal.timeout(AXIOM_PROBE_TIMEOUT_MS), - }); - return { status: res.status, body: res.ok ? ((await res.json()) as unknown) : null }; - } catch { - return null; +export async function connectAxiom( + config: Config, + api: PolylaneAPI, + body: Extract +): Promise { + try { + return await api.integrationsConnect(body); + } catch (err) { + if (!isApiError(err) || err.status !== 422 || body.region !== undefined) throw err; + if (!isInteractive(config.nonInteractive)) { + throw new CLIError( + err.message, + ExitCode.USAGE, + `Pass --region us-east-1 or --region eu-central-1.\n${AXIOM_REGION_HINT}` + ); } - }; - const [orgs, datasets] = await Promise.all([probe('/v2/orgs'), probe('/v2/datasets')]); - const fromOrgs = orgs?.status === 200 ? uniqueAxiomRegion(orgs.body, 'defaultEdgeDeployment') : null; - if (fromOrgs !== null) return { outcome: 'detected', region: fromOrgs }; - const fromDatasets = datasets?.status === 200 ? uniqueAxiomRegion(datasets.body, 'edgeDeployment') : null; - if (fromDatasets !== null) return { outcome: 'detected', region: fromDatasets }; - if (orgs?.status === 401 || datasets?.status === 401) return { outcome: 'unauthorized' }; - return { outcome: 'unknown' }; + note(`${err.message}\n${AXIOM_REGION_HINT}`, 'Axiom region'); + const picked = await promptSelectOrBack( + { nonInteractive: config.nonInteractive }, + 'Axiom edge deployment region', + AXIOM_REGIONS + ); + if (picked === BACK) return BACK; + return api.integrationsConnect({ ...body, region: picked }); + } } const CODE_AGENTS = { @@ -623,9 +594,15 @@ async function connectWithCredentials( ...honeycombManagementKeyFields(managementApiKeyId, managementApiKeySecret), }; } else if (type === 'axiom') { - let region: AxiomRegion = 'us-east-1'; + const region = getArgString(args, 'region'); + if (region !== undefined && !AXIOM_REGIONS.some((o) => o.value === region)) { + throw new CLIError( + `Invalid value for --region: "${region}"`, + ExitCode.USAGE, + `Use one of: ${AXIOM_REGIONS.map((o) => o.value).join(', ')}` + ); + } let apiToken = ''; - const quiet = config.quiet || config.output === 'json'; const ok = await runSteps([ secretStep( config, @@ -643,58 +620,9 @@ async function connectWithCredentials( apiToken = v; } ), - // Region: flag wins, then auto-detection from the token, then (only when - // detection is ambiguous) an interactive prompt. - async () => { - const fromFlag = getArgString(args, 'region'); - if (fromFlag !== undefined) { - if (!AXIOM_REGIONS.some((o) => o.value === fromFlag)) { - throw new CLIError( - `Invalid value for --region: "${fromFlag}"`, - ExitCode.USAGE, - `Use one of: ${AXIOM_REGIONS.map((o) => o.value).join(', ')}` - ); - } - region = fromFlag as AxiomRegion; - return SKIPPED; - } - if (!quiet) process.stderr.write('Detecting your Axiom region…\n'); - const detection = await detectAxiomRegion(apiToken); - if (detection.outcome === 'detected') { - region = detection.region; - if (!quiet) process.stderr.write(`✓ Axiom region: ${detection.region}\n`); - return SKIPPED; - } - if (detection.outcome === 'unauthorized') { - if (!isInteractive(config.nonInteractive) || getArgString(args, 'apiToken') !== undefined) { - throw new CLIError( - 'Axiom rejected the API token', - ExitCode.AUTH, - 'Create a token at https://app.axiom.co/settings/api-tokens and retry.' - ); - } - note('Axiom rejected the token (401 from api.axiom.co). Paste it again — it starts with xaat-.', 'Axiom API token'); - return BACK; - } - if (!isInteractive(config.nonInteractive)) { - throw new CLIError( - 'Missing required flag: --region', - ExitCode.USAGE, - `Could not detect the region from the token. Pass --region us-east-1 or --region eu-central-1.\n${AXIOM_REGION_HINT}` - ); - } - const picked = await promptSelectOrBack( - { nonInteractive: config.nonInteractive }, - `Axiom region — could not detect it from the token. ${AXIOM_REGION_HINT}`, - AXIOM_REGIONS - ); - if (picked === BACK) return BACK; - region = picked; - return; - }, ]); if (!ok) return BACK; - body = { type: 'axiom', workspaceId, region, apiToken }; + body = { type: 'axiom', workspaceId, apiToken, ...(region !== undefined ? { region: region as AxiomRegion } : {}) }; } else if (type === 'betterstack') { let apiToken = ''; let uptimeApiToken = ''; @@ -775,7 +703,8 @@ async function connectWithCredentials( body = { type, workspaceId, apiKey }; } - const integration = await api.integrationsConnect(body); + const integration = body.type === 'axiom' ? await connectAxiom(config, api, body) : await api.integrationsConnect(body); + if (integration === BACK) return BACK; if (type === 'honeycomb') assertHoneycombManagementKeyStored(integration); printConnectSuccess(config, integration, TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type); if (isCodeAgentType(integration.type) && !config.quiet && config.output !== 'json') { diff --git a/src/errors/api.ts b/src/errors/api.ts index 523e4d9..0c2c019 100644 --- a/src/errors/api.ts +++ b/src/errors/api.ts @@ -6,43 +6,64 @@ export interface ApiErrorPayload { detail?: string; } -export function mapApiError(status: number, error: ApiErrorPayload | null): CLIError { +export class ApiError extends CLIError { + readonly status: number; + + constructor(status: number, message: string, exitCode: ExitCode = ExitCode.GENERAL, hint?: string) { + super(message, exitCode, hint); + this.name = 'ApiError'; + this.status = status; + } +} + +export function isApiError(err: unknown): err is ApiError { + return err instanceof ApiError; +} + +export function mapApiError(status: number, error: ApiErrorPayload | null): ApiError { const detail = error?.detail; const message = error?.message ?? `The request did not succeed (${status})`; switch (status) { case 400: - return new CLIError(detail || 'Bad request', ExitCode.USAGE); + return new ApiError(status, detail || 'Bad request', ExitCode.USAGE); case 401: - return new CLIError( + return new ApiError( + status, detail || 'Not signed in.', ExitCode.AUTH, 'Run `polylane auth login`' ); case 403: - return new CLIError( + return new ApiError( + status, detail || 'Permission denied', ExitCode.AUTH, 'Check your API key scopes or workspace permissions' ); case 404: - return new CLIError(detail || 'Resource not found', ExitCode.GENERAL); + return new ApiError(status, detail || 'Resource not found', ExitCode.GENERAL); case 409: - return new CLIError(detail || 'Conflict', ExitCode.GENERAL); + return new ApiError(status, detail || 'Conflict', ExitCode.GENERAL); case 418: - return new CLIError( + return new ApiError( + status, detail || 'Feature not available', ExitCode.GENERAL, 'This feature may be disabled on your plan' ); + case 422: + return new ApiError(status, detail || 'Unprocessable content', ExitCode.USAGE); case 426: - return new CLIError( + return new ApiError( + status, detail || 'Plan upgrade required', ExitCode.QUOTA, 'Upgrade your workspace plan to use this feature' ); case 429: - return new CLIError( + return new ApiError( + status, detail || 'Rate limited', ExitCode.QUOTA, 'Wait a moment and retry' @@ -51,12 +72,13 @@ export function mapApiError(status: number, error: ApiErrorPayload | null): CLIE case 502: case 503: case 504: - return new CLIError( + return new ApiError( + status, detail || 'Server error', ExitCode.GENERAL, 'Try again later' ); default: - return new CLIError(detail || message, ExitCode.GENERAL); + return new ApiError(status, detail || message, ExitCode.GENERAL); } } diff --git a/test/errors.test.ts b/test/errors.test.ts index 602116a..861c738 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -46,6 +46,13 @@ describe('mapApiError', () => { assert.equal(err.message, 'workspace xyz missing'); }); + it('maps 422 -> USAGE with the status preserved', () => { + const err = mapApiError(422, { message: 'Unprocessable content', detail: 'pass region' }); + assert.equal(err.exitCode, ExitCode.USAGE); + assert.equal(err.message, 'pass region'); + assert.equal(err.status, 422); + }); + it('maps 426 -> QUOTA', () => { const err = mapApiError(426, { message: 'Upgrade Required' }); assert.equal(err.exitCode, ExitCode.QUOTA); diff --git a/test/integration-connect-axiom.test.ts b/test/integration-connect-axiom.test.ts index 722999c..918ad8a 100644 --- a/test/integration-connect-axiom.test.ts +++ b/test/integration-connect-axiom.test.ts @@ -1,170 +1,66 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { axiomRegionFromEdgeDeployment, detectAxiomRegion } from '../src/commands/integration/connect'; +import { connectAxiom } from '../src/commands/integration/connect'; +import { ApiError } from '../src/errors/api'; +import { CLIError } from '../src/errors/base'; +import { ExitCode } from '../src/errors/codes'; +import type { Config } from '../src/config/schema'; +import type { PolylaneAPI } from '../src/generated/client'; +import type { Integration } from '../src/generated/types'; -describe('axiomRegionFromEdgeDeployment', () => { - it('maps edge deployment identifiers to regions', () => { - assert.equal(axiomRegionFromEdgeDeployment('cloud.eu-central-1.aws'), 'eu-central-1'); - assert.equal(axiomRegionFromEdgeDeployment('cloud.us-east-1.aws'), 'us-east-1'); - assert.equal(axiomRegionFromEdgeDeployment('eu-central-1.aws.edge.axiom.co'), 'eu-central-1'); - assert.equal(axiomRegionFromEdgeDeployment('us-east-1.aws.edge.axiom.co'), 'us-east-1'); - }); - - it('returns null for unknown or non-string values', () => { - assert.equal(axiomRegionFromEdgeDeployment('cloud.ap-south-1.aws'), null); - assert.equal(axiomRegionFromEdgeDeployment(''), null); - assert.equal(axiomRegionFromEdgeDeployment(undefined), null); - assert.equal(axiomRegionFromEdgeDeployment(42), null); - }); -}); +const config = { nonInteractive: true } as Config; +const body = { type: 'axiom', workspaceId: 'ws_1', apiToken: 'xaat-token' } as const; -type Route = { status: number; body?: unknown } | 'error'; - -function mockFetch(routes: Record): typeof fetch { - return (async (input: string | URL | Request) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - const path = new URL(url).pathname; - const route = routes[path]; - assert.ok(route, `unexpected fetch: ${path}`); - if (route === 'error') throw new Error('network down'); - return new Response(JSON.stringify(route.body ?? null), { - status: route.status, - headers: { 'content-type': 'application/json' }, - }); - }) as typeof fetch; +function mockApi(connect: (body: unknown) => Promise): PolylaneAPI { + return { integrationsConnect: connect } as unknown as PolylaneAPI; } -describe('detectAxiomRegion', () => { - it('detects the region from the org default edge deployment', async () => { - const result = await detectAxiomRegion( - 'xaat-token', - mockFetch({ - '/v2/orgs': { status: 200, body: [{ id: 'org1', defaultEdgeDeployment: 'cloud.eu-central-1.aws' }] }, - '/v2/datasets': { status: 200, body: [] }, - }) - ); - assert.deepEqual(result, { outcome: 'detected', region: 'eu-central-1' }); - }); - - it('falls back to dataset edge deployments when orgs is forbidden', async () => { - const result = await detectAxiomRegion( - 'xaat-token', - mockFetch({ - '/v2/orgs': { status: 403 }, - '/v2/datasets': { - status: 200, - body: [ - { id: 'logs', edgeDeployment: 'cloud.us-east-1.aws' }, - { id: 'traces', edgeDeployment: 'cloud.us-east-1.aws' }, - ], - }, - }) - ); - assert.deepEqual(result, { outcome: 'detected', region: 'us-east-1' }); - }); - - it('falls back to datasets when org regions are ambiguous', async () => { - const result = await detectAxiomRegion( - 'xaat-token', - mockFetch({ - '/v2/orgs': { - status: 200, - body: [ - { id: 'org1', defaultEdgeDeployment: 'cloud.us-east-1.aws' }, - { id: 'org2', defaultEdgeDeployment: 'cloud.eu-central-1.aws' }, - ], - }, - '/v2/datasets': { status: 200, body: [{ id: 'logs', edgeDeployment: 'cloud.eu-central-1.aws' }] }, - }) - ); - assert.deepEqual(result, { outcome: 'detected', region: 'eu-central-1' }); - }); - - it('is unknown when datasets span both regions', async () => { - const result = await detectAxiomRegion( - 'xaat-token', - mockFetch({ - '/v2/orgs': { status: 403 }, - '/v2/datasets': { - status: 200, - body: [ - { id: 'logs', edgeDeployment: 'cloud.us-east-1.aws' }, - { id: 'traces', edgeDeployment: 'cloud.eu-central-1.aws' }, - ], - }, - }) - ); - assert.deepEqual(result, { outcome: 'unknown' }); - }); - - it('is unknown when no response carries an edge deployment', async () => { - const result = await detectAxiomRegion( - 'xaat-token', - mockFetch({ - '/v2/orgs': { status: 200, body: [{ id: 'org1' }] }, - '/v2/datasets': { status: 200, body: [{ id: 'logs' }] }, - }) - ); - assert.deepEqual(result, { outcome: 'unknown' }); - }); - - it('is unauthorized when every probe returns 401', async () => { - const result = await detectAxiomRegion( - 'xaat-bad', - mockFetch({ - '/v2/orgs': { status: 401 }, - '/v2/datasets': { status: 401 }, - }) - ); - assert.deepEqual(result, { outcome: 'unauthorized' }); +describe('connectAxiom', () => { + it('sends the body without region and returns the integration', async () => { + const seen: unknown[] = []; + const integration = { id: 'int_1', type: 'axiom' } as Integration; + const api = mockApi(async (b) => { + seen.push(b); + return integration; + }); + assert.equal(await connectAxiom(config, api, body), integration); + assert.deepEqual(seen, [body]); }); - it('is unauthorized on a single 401 even when the other probe fails on the network', async () => { - const result = await detectAxiomRegion( - 'xaat-token', - mockFetch({ - '/v2/orgs': 'error', - '/v2/datasets': { status: 401 }, - }) + it('turns a 422 into a usage error with a --region hint when not interactive', async () => { + const api = mockApi(async () => { + throw new ApiError(422, 'Could not detect the region from the token. Pass region.', ExitCode.USAGE); + }); + await assert.rejects( + () => connectAxiom(config, api, body), + (err: unknown) => + err instanceof CLIError && + err.exitCode === ExitCode.USAGE && + err.message.includes('Could not detect the region') && + (err.hint?.includes('--region us-east-1') ?? false) && + (err.hint?.includes('Settings > General > Edge deployment') ?? false) ); - assert.deepEqual(result, { outcome: 'unauthorized' }); }); - it('is unknown when both probes fail on the network', async () => { - const result = await detectAxiomRegion( - 'xaat-token', - mockFetch({ - '/v2/orgs': 'error', - '/v2/datasets': 'error', - }) + it('rethrows a 422 when a region was already sent', async () => { + const original = new ApiError(422, 'Invalid region', ExitCode.USAGE); + const api = mockApi(async () => { + throw original; + }); + await assert.rejects( + () => connectAxiom(config, api, { ...body, region: 'us-east-1' }), + (err: unknown) => err === original ); - assert.deepEqual(result, { outcome: 'unknown' }); }); - it('is unknown when a body is not the expected array', async () => { - const result = await detectAxiomRegion( - 'xaat-token', - mockFetch({ - '/v2/orgs': { status: 200, body: { defaultEdgeDeployment: 'cloud.us-east-1.aws' } }, - '/v2/datasets': { status: 200, body: 'nope' }, - }) + it('rethrows non-422 errors untouched', async () => { + const original = new ApiError(401, 'Not signed in.', ExitCode.AUTH); + const api = mockApi(async () => { + throw original; + }); + await assert.rejects( + () => connectAxiom(config, api, body), + (err: unknown) => err === original ); - assert.deepEqual(result, { outcome: 'unknown' }); - }); - - it('sends the token as a bearer header to api.axiom.co', async () => { - const seen: Array<{ url: string; auth: string | null }> = []; - const fetchFn = (async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - const headers = new Headers(init?.headers); - seen.push({ url, auth: headers.get('authorization') }); - return new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } }); - }) as typeof fetch; - await detectAxiomRegion('xaat-secret', fetchFn); - assert.equal(seen.length, 2); - for (const req of seen) { - assert.ok(req.url.startsWith('https://api.axiom.co/v2/'), req.url); - assert.equal(req.auth, 'Bearer xaat-secret'); - } }); });