diff --git a/src/integrations/aimlapi/client.test.ts b/src/integrations/aimlapi/client.test.ts new file mode 100644 index 0000000000..e5d5945b9e --- /dev/null +++ b/src/integrations/aimlapi/client.test.ts @@ -0,0 +1,657 @@ +import { afterEach, expect, mock, test } from 'bun:test' + +import { AimlapiApiError, AimlapiClient } from './client.js' +import type { AimlapiEndpoints } from './config.js' + +const originalFetch = globalThis.fetch +afterEach(() => { + globalThis.fetch = originalFetch +}) + +const endpoints: AimlapiEndpoints = { + authBaseUrl: 'https://auth.example.test', + appBaseUrl: 'https://app.example.test', + inferenceBaseUrl: 'https://api.example.test/v1', +} + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +/** A structurally complete pay/top-up receipt, as the backend contract defines. */ +function payReceipt(overrides: Record = {}): Record { + return { + checkout: { providerSessionId: 'provider', payUrl: 'https://checkout.test' }, + partnerCheckout: { + id: 'sess_1', + sessionToken: 'session', + partnerId: 'part_1', + partnerName: 'OpenClaude', + userId: 1, + amountUsdMinor: 2500, + status: 'pending_payment', + issuedKeyId: null, + returnUrl: null, + }, + ...overrides, + } +} + +test('passwordless onboarding methods use the current backend contracts', async () => { + const calls: Array<{ url: string; init?: RequestInit; body?: unknown }> = [] + globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input) + calls.push({ + url, + init, + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + }) + if (url.endsWith('/v1/auth/account') && init?.method === 'PATCH') { + return jsonResponse({ action: 'sign-in' }) + } + if (url.endsWith('/code/verify')) return jsonResponse({ token: 'bearer', exp: 1 }) + if (url.endsWith('/passwordless')) return jsonResponse({ token: 'new-bearer', exp: 2 }) + if (url.endsWith('/v1/keys')) return jsonResponse({ key: 'key_test', id: 'id_test' }) + if (url.endsWith('/billing/balance')) { + return jsonResponse({ balance: 10, lowBalance: true, lowBalanceThreshold: 20 }) + } + return new Response('', { status: 204 }) + }) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + expect(await client.checkAccount('user@example.com')).toEqual({ action: 'sign-in' }) + await client.sendSignInCode('user@example.com') + expect(await client.verifySignInCode('user@example.com', '123456')).toEqual({ + token: 'bearer', + exp: 1, + }) + expect(await client.createPasswordlessAccount('new@example.com')).toEqual({ + token: 'new-bearer', + exp: 2, + }) + expect(await client.createKey('bearer', 'OpenClaude CLI')).toEqual({ + key: 'key_test', + id: 'id_test', + }) + expect((await client.getBalance('key_test')).lowBalance).toBe(true) + + expect(calls.map(call => [call.init?.method, call.url, call.body])).toEqual([ + ['PATCH', 'https://auth.example.test/v1/auth/account', { email: 'user@example.com' }], + ['POST', 'https://auth.example.test/v1/auth/sign-in/code', { email: 'user@example.com' }], + ['POST', 'https://auth.example.test/v1/auth/sign-in/code/verify', { email: 'user@example.com', code: '123456' }], + ['POST', 'https://auth.example.test/v1/auth/account/passwordless', { email: 'new@example.com' }], + ['POST', 'https://app.example.test/v1/keys', { name: 'OpenClaude CLI' }], + ['GET', 'https://api.example.test/v1/billing/balance', undefined], + ]) +}) + +test('pay only sends autoTopUp when it is enabled', async () => { + const bodies: unknown[] = [] + globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(typeof init?.body === 'string' ? JSON.parse(init.body) : undefined) + return jsonResponse(payReceipt()) + }) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + await client.pay('bearer', 'session', { + amountUsdMinor: 2500, + paymentSessionId: 'payment-id', + }) + await client.pay('bearer', 'session', { + amountUsdMinor: 2500, + paymentSessionId: 'payment-id', + autoTopUp: true, + }) + expect(bodies).toEqual([ + { amountUsdMinor: 2500, paymentSessionId: 'payment-id', method: 'card' }, + { amountUsdMinor: 2500, paymentSessionId: 'payment-id', method: 'card', autoTopUp: true }, + ]) +}) + +test('pay carries the selected method and omits an absent payment session id', async () => { + // The password flow lets the user pick crypto and has no payment session id; + // both must survive alongside the passwordless defaults. + const bodies: unknown[] = [] + globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(typeof init?.body === 'string' ? JSON.parse(init.body) : undefined) + return jsonResponse(payReceipt()) + }) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + await client.pay('bearer', 'session', { + amountUsdMinor: 2500, + method: 'crypto', + successUrl: 'https://ok.test', + }) + expect(bodies).toEqual([ + { amountUsdMinor: 2500, method: 'crypto', successUrl: 'https://ok.test' }, + ]) +}) + +test('pay and topUpByKey reject a malformed checkout receipt', async () => { + // The charge is already requested by the time this returns, so a receipt that + // is not fully usable must fail loudly instead of being opened as a URL and + // polled until timeout. + for (const receipt of [ + { checkout: { payUrl: true } }, + { checkout: { providerSessionId: 'provider', payUrl: 42 } }, + { checkout: { providerSessionId: '', payUrl: 'https://checkout.test' } }, + payReceipt({ partnerCheckout: { sessionToken: 'session' } }), + ]) { + globalThis.fetch = mock(async () => jsonResponse(receipt)) as unknown as typeof fetch + const client = new AimlapiClient(endpoints) + + await expect( + client.pay('bearer', 'session', { amountUsdMinor: 2500, paymentSessionId: 'p' }), + ).rejects.toThrow('invalid checkout') + await expect( + client.topUpByKey('key', { + sessionToken: 'session', + amountUsdMinor: 2500, + paymentSessionId: 'p', + }), + ).rejects.toThrow('invalid checkout') + } + + // `payUrl: null` is a valid receipt - the backend may defer the URL. + globalThis.fetch = mock(async () => + jsonResponse(payReceipt({ checkout: { providerSessionId: 'provider', payUrl: null } })), + ) as unknown as typeof fetch + const client = new AimlapiClient(endpoints) + expect( + (await client.pay('bearer', 'session', { amountUsdMinor: 2500, paymentSessionId: 'p' })) + .checkout.payUrl, + ).toBeNull() +}) + +test('sendSignInCode accepts a non-empty plain-text acknowledgement', async () => { + // The code has already been delivered by the time this returns, so a + // non-JSON acknowledgement must not surface as an error and push the user + // into a retry that can invalidate or rate-limit the one-time code. + const requests: Array<{ method?: string; url: string; body?: unknown }> = [] + for (const body of ['code sent', '', 'OK']) { + globalThis.fetch = mock( + async (input: string | URL | Request, init?: RequestInit) => { + requests.push({ + method: init?.method, + url: String(input), + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + }) + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + }, + ) as unknown as typeof fetch + const client = new AimlapiClient(endpoints) + expect(await client.sendSignInCode('user@example.com')).toBeUndefined() + } + + // Accepting any successful body must not mask a wrong endpoint or payload. + expect(requests).toEqual( + Array.from({ length: 3 }, () => ({ + method: 'POST', + url: `${endpoints.authBaseUrl}/v1/auth/sign-in/code`, + body: { email: 'user@example.com' }, + })), + ) + + // A non-2xx acknowledgement is still an error. + globalThis.fetch = mock( + async () => new Response('rate limited', { status: 429 }), + ) as unknown as typeof fetch + const client = new AimlapiClient(endpoints) + await expect(client.sendSignInCode('user@example.com')).rejects.toBeInstanceOf( + AimlapiApiError, + ) +}) + +test('a receipt whose payUrl cannot be opened is rejected', async () => { + // `payUrl` goes straight to openBrowser; a value it cannot open would leave the + // flow polling for 20 minutes with no usable checkout link after the charge. + for (const payUrl of [ + 'not-a-url', + 'javascript:alert(1)', + 'file:///tmp/checkout', + 'ftp://checkout.test/pay', + ]) { + globalThis.fetch = mock(async () => + jsonResponse(payReceipt({ checkout: { providerSessionId: 'provider', payUrl } })), + ) as unknown as typeof fetch + const client = new AimlapiClient(endpoints) + await expect( + client.pay('bearer', 'session', { amountUsdMinor: 2500, paymentSessionId: 'p' }), + ).rejects.toThrow('invalid checkout') + } +}) + +test('the one-time sign-in code is redacted from a reflected error', async () => { + // An auth service that echoes the invalid code must not leak it through + // `error.body`, which CLI handlers print verbatim. + globalThis.fetch = mock( + async () => + new Response('{"error":"code 123456 is invalid"}', { status: 400 }), + ) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + const error = await client + .verifySignInCode('user@example.com', '123456') + .then(() => null, (reason: unknown) => reason) + + expect(error).toBeInstanceOf(AimlapiApiError) + expect((error as AimlapiApiError).body).not.toContain('123456') + expect((error as AimlapiApiError).body).toContain('[REDACTED]') +}) + +test('exported result types reject malformed required fields', async () => { + const client = new AimlapiClient(endpoints) + + // `exp` is part of AuthResult, so a missing/non-numeric value must not pass. + for (const auth of [{ token: 'bearer' }, { token: 'bearer', exp: 'soon' }]) { + globalThis.fetch = mock(async () => jsonResponse(auth)) as unknown as typeof fetch + await expect( + client.verifySignInCode('user@example.com', '123456'), + ).rejects.toThrow('did not return an auth token') + } + + // Nullable-but-required session fields are still typed, so validate them. + for (const override of [ + { partnerName: 42 }, + { userId: 'one' }, + { amountUsdMinor: '2500' }, + { issuedKeyId: 7 }, + { returnUrl: false }, + ]) { + const session = { ...(payReceipt().partnerCheckout as object), ...override } + globalThis.fetch = mock(async () => jsonResponse(session)) as unknown as typeof fetch + await expect(client.getSession('session-token')).rejects.toThrow('invalid session') + } +}) + +test('checkAccount rejects an unsupported account action', async () => { + // `action` selects the onboarding branch, so an unknown value must not cross + // the client boundary as an impossible typed value. + globalThis.fetch = mock(async () => + jsonResponse({ action: 'disabled' }), + ) as unknown as typeof fetch + const client = new AimlapiClient(endpoints) + await expect(client.checkAccount('user@example.com')).rejects.toThrow( + 'invalid account response', + ) + + globalThis.fetch = mock(async () => + jsonResponse({ action: 'sign-up', provider: 'google' }), + ) as unknown as typeof fetch + expect(await client.checkAccount('user@example.com')).toEqual({ + action: 'sign-up', + provider: 'google', + }) +}) + +test('a non-success body is redacted before it reaches the error', async () => { + // CLI handlers print `error.body` verbatim, and a proxy can reflect the + // credential back in a 4xx/5xx payload. + globalThis.fetch = mock( + async () => + new Response('{"error":"bad token session-secret for bearer-secret"}', { + status: 401, + }), + ) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + const error = await client + .exchange('bearer-secret', 'session-secret') + .then(() => null, (reason: unknown) => reason) + + expect(error).toBeInstanceOf(AimlapiApiError) + const apiError = error as AimlapiApiError + expect(apiError.body).not.toContain('session-secret') + expect(apiError.body).not.toContain('bearer-secret') + expect(apiError.body).toContain('[REDACTED]') +}) + +test('a JSON-escaped credential is redacted from a reflected body', async () => { + // A backend reflecting a credential usually re-serializes it, so the body + // carries the JSON-escaped form rather than the raw one. + const password = 'p\\q"r' + globalThis.fetch = mock( + async () => + new Response(JSON.stringify({ error: `bad password ${password}` }), { + status: 401, + }), + ) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + const error = await client + .login('user@example.com', password) + .then(() => null, (reason: unknown) => reason) + + expect(error).toBeInstanceOf(AimlapiApiError) + const body = (error as AimlapiApiError).body + expect(body).not.toContain(JSON.stringify(password).slice(1, -1)) + expect(body).not.toContain(password) + expect(body).toContain('[REDACTED]') +}) + +test('overlapping secrets are redacted longest-first', async () => { + // The bearer is a prefix of the session token; redacting it first would leave + // the token's tail behind. + globalThis.fetch = mock( + async () => new Response('{"error":"abc123 rejected"}', { status: 403 }), + ) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + const error = await client + .exchange('abc', 'abc123') + .then(() => null, (reason: unknown) => reason) + + expect(error).toBeInstanceOf(AimlapiApiError) + const body = (error as AimlapiApiError).body + expect(body).not.toContain('abc123') + expect(body).not.toContain('123') + expect(body).toContain('[REDACTED]') +}) + +test('a cancelled request does not leak its token through the error', async () => { + const controller = new AbortController() + globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => + reject( + new DOMException( + 'The operation was aborted: https://app.example.test/v3/partner-checkout/sessions/session-secret', + 'AbortError', + ), + ), + { once: true }, + ) + }) + }) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + const pending = client.getSession('session-secret', controller.signal) + controller.abort() + const error = await pending.then(() => null, (reason: unknown) => reason) + + // Cancellation identity is preserved for callers that branch on it... + expect((error as Error).name).toBe('AbortError') + // ...but the token never reaches the message. + expect((error as Error).message).not.toContain('session-secret') + expect((error as Error).message).toContain('[REDACTED]') +}) + +test('a short session token is still redacted from transport errors', async () => { + // The path-segment scan skips short segments, so tokens are redacted from an + // explicit secret list instead of relying on their length. + globalThis.fetch = mock(async () => { + throw new Error('connect ECONNREFUSED https://app.example.test/v3/partner-checkout/sessions/abc') + }) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + const error = await client + .getSession('abc') + .then(() => null, (reason: unknown) => reason) + + expect(error).toBeInstanceOf(AimlapiApiError) + expect((error as AimlapiApiError).message).not.toContain('abc') + expect((error as AimlapiApiError).message).toContain('[REDACTED]') +}) + +test('password sign-up and sign-in keep their existing contracts', async () => { + const calls: Array<{ method?: string; url: string; body?: unknown }> = [] + globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ + method: init?.method, + url: String(input), + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + }) + return jsonResponse({ token: 'legacy-bearer', exp: 7 }) + }) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + expect( + await client.signup({ + email: 'user@example.com', + password: 'secret', + inviteCode: 'invite', + }), + ).toEqual({ token: 'legacy-bearer', exp: 7 }) + expect(await client.login('user@example.com', 'secret')).toEqual({ + token: 'legacy-bearer', + exp: 7, + }) + + expect(calls).toEqual([ + { + method: 'POST', + url: 'https://auth.example.test/v1/auth/account', + body: { email: 'user@example.com', password: 'secret', inviteCode: 'invite' }, + }, + { + method: 'PUT', + url: 'https://auth.example.test/v1/auth/account', + body: { email: 'user@example.com', password: 'secret' }, + }, + ]) +}) + +test('password methods reject a response without a token', async () => { + globalThis.fetch = mock(async () => jsonResponse({ exp: 1 })) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + // A malformed success payload must surface the same error contract as every + // other endpoint, so a caller can branch on the type/status uniformly instead + // of special-casing the auth paths. + for (const call of [ + () => client.signup({ email: 'user@example.com', password: 'secret' }), + () => client.login('user@example.com', 'secret'), + ]) { + const error = await call().then( + () => null, + (reason: unknown) => reason, + ) + expect(error).toBeInstanceOf(AimlapiApiError) + expect((error as AimlapiApiError).status).toBe(200) + expect((error as AimlapiApiError).message).toContain('did not return an auth token') + } +}) + +test('topUpByKey uses the v2 billing endpoint and API key bearer', async () => { + let seenUrl = '' + let seenHeaders = new Headers() + let seenBody: unknown + globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { + seenUrl = String(input) + seenHeaders = new Headers(init?.headers) + seenBody = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined + return jsonResponse(payReceipt()) + }) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + await client.topUpByKey('key_test', { + sessionToken: 'session', + amountUsdMinor: 2500, + paymentSessionId: 'payment-id', + autoTopUp: true, + }) + + expect(seenUrl).toBe('https://api.example.test/v2/billing/topup') + expect(seenHeaders.get('Authorization')).toBe('Bearer key_test') + expect(seenBody).toEqual({ + sessionToken: 'session', + amountUsdMinor: 2500, + paymentSessionId: 'payment-id', + autoTopUp: true, + }) +}) + +test('typed requests reject an empty successful response', async () => { + globalThis.fetch = mock(async () => new Response('', { status: 204 })) as unknown as typeof fetch + const client = new AimlapiClient(endpoints) + await expect(client.getBalance('key_test')).rejects.toThrow('returned empty body') +}) + +test('getBalance rejects malformed successful payloads', async () => { + const client = new AimlapiClient(endpoints) + for (const payload of [ + {}, + { balance: 25, lowBalance: false }, + { balance: '25', lowBalance: false, lowBalanceThreshold: 20 }, + { balance: 25, lowBalance: 'false', lowBalanceThreshold: 20 }, + { balance: 25, lowBalance: false, lowBalanceThreshold: null }, + ]) { + globalThis.fetch = mock(async () => jsonResponse(payload)) as unknown as typeof fetch + await expect(client.getBalance('key_test')).rejects.toThrow( + 'returned invalid balance response', + ) + } +}) + +test('session tokens are excluded from HTTP and network errors', async () => { + const client = new AimlapiClient(endpoints) + const token = 'session-secret-token' + + globalThis.fetch = mock(async () => new Response('failed', { status: 500 })) as unknown as typeof fetch + let httpError: unknown + try { + await client.getSession(token) + } catch (error) { + httpError = error + } + expect(httpError).toBeInstanceOf(Error) + expect((httpError as Error).message).toContain('https://app.example.test') + expect((httpError as Error).message).not.toContain(token) + + globalThis.fetch = mock(async () => { + throw new Error(`transport failed for ${token}`) + }) as unknown as typeof fetch + let networkError: unknown + try { + await client.exchange('bearer', token) + } catch (error) { + networkError = error + } + expect(networkError).toBeInstanceOf(Error) + expect((networkError as Error).message).not.toContain(token) +}) + +test('response bodies are capped before decoding or surfacing errors', async () => { + globalThis.fetch = mock( + async () => new Response('x'.repeat((1 << 20) + 1), { status: 502 }), + ) as unknown as typeof fetch + const client = new AimlapiClient(endpoints) + await expect(client.getBalance('key_test')).rejects.toThrow( + 'response body exceeds 1048576 bytes', + ) +}) + +test('token-producing methods reject an empty token', async () => { + globalThis.fetch = mock(async () => jsonResponse({ token: '', exp: 1 })) as unknown as typeof fetch + const client = new AimlapiClient(endpoints) + await expect(client.verifySignInCode('user@example.com', '123456')).rejects.toThrow( + 'did not return an auth token', + ) + await expect(client.createPasswordlessAccount('user@example.com')).rejects.toThrow( + 'did not return an auth token', + ) +}) + +test('a request forwards the abort signal to fetch and rejects when cancelled', async () => { + const controller = new AbortController() + let forwardedSignal: AbortSignal | undefined + globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { + forwardedSignal = init?.signal ?? undefined + // Model a transport that only settles when the request is aborted. + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('The operation was aborted.', 'AbortError')), + { once: true }, + ) + }) + }) as unknown as typeof fetch + + const client = new AimlapiClient(endpoints) + const pending = client.getSession('resume-token', controller.signal) + controller.abort() + await expect(pending).rejects.toThrow() + // The signal reached the transport layer and observed the cancellation. + expect(forwardedSignal).toBeInstanceOf(AbortSignal) + expect(forwardedSignal?.aborted).toBe(true) +}) + +test('session methods reject a malformed or empty success payload', async () => { + // A structurally-invalid 200 must surface as a non-terminal (status 200) + // error rather than a session with an unknown status, so callers never clear + // the retained payment identity or take an ambiguous retry on it. + const client = new AimlapiClient(endpoints) + + // Empty object: passes the request-level object guard, rejected by the + // session shape check (no valid status). + globalThis.fetch = mock(async () => jsonResponse({})) as unknown as typeof fetch + const emptyError = await client.getSession('resume-token').catch((error: unknown) => error) + expect(emptyError).toBeInstanceOf(AimlapiApiError) + expect(emptyError).toHaveProperty('status', 200) + + // null / non-object: rejected by the request-level guard. + globalThis.fetch = mock(async () => jsonResponse(null)) as unknown as typeof fetch + const nullError = await client.getSession('resume-token').catch((error: unknown) => error) + expect(nullError).toBeInstanceOf(AimlapiApiError) + expect(nullError).toHaveProperty('status', 200) + + // Unknown status: object with a status outside the allowlist. + globalThis.fetch = mock(async () => + jsonResponse({ sessionToken: 'session', status: 'nonsense' }), + ) as unknown as typeof fetch + const badStatusError = await client + .createSession({ partnerId: 'part_x' }) + .catch((error: unknown) => error) + expect(badStatusError).toBeInstanceOf(AimlapiApiError) + expect(badStatusError).toHaveProperty('status', 200) +}) + +test('typed methods reject wrong-typed success fields without a raw TypeError', async () => { + const client = new AimlapiClient(endpoints) + + // A 2xx payload with a numeric token/key/apiKey must not reach .trim(). + globalThis.fetch = mock(async () => jsonResponse({ token: 1 })) as unknown as typeof fetch + await expect(client.verifySignInCode('user@example.com', '123456')).rejects.toThrow( + 'did not return an auth token', + ) + await expect(client.createPasswordlessAccount('user@example.com')).rejects.toThrow( + 'did not return an auth token', + ) + + globalThis.fetch = mock(async () => jsonResponse({ key: 1 })) as unknown as typeof fetch + await expect(client.createKey('bearer', 'OpenClaude CLI')).rejects.toThrow( + 'did not return an API key', + ) + // Key without its required id is an incomplete receipt and must be rejected. + globalThis.fetch = mock(async () => jsonResponse({ key: 'k_only' })) as unknown as typeof fetch + await expect(client.createKey('bearer', 'OpenClaude CLI')).rejects.toThrow( + 'did not return an API key', + ) + + globalThis.fetch = mock(async () => jsonResponse({ apiKey: 1 })) as unknown as typeof fetch + const exchangeError = await client.exchange('bearer', 'session').catch((e: unknown) => e) + expect(exchangeError).toBeInstanceOf(AimlapiApiError) + expect(exchangeError).toHaveProperty('status', 200) + + // apiKey without its required apiKeyId is an incomplete exchange receipt. + globalThis.fetch = mock(async () => + jsonResponse({ apiKey: 'k_only' }), + ) as unknown as typeof fetch + const partialExchange = await client.exchange('bearer', 'session').catch((e: unknown) => e) + expect(partialExchange).toBeInstanceOf(AimlapiApiError) + expect(partialExchange).toHaveProperty('status', 200) + + globalThis.fetch = mock(async () => jsonResponse({ action: 1 })) as unknown as typeof fetch + const accountError = await client.checkAccount('user@example.com').catch((e: unknown) => e) + expect(accountError).toBeInstanceOf(AimlapiApiError) + expect(accountError).toHaveProperty('status', 200) +}) diff --git a/src/integrations/aimlapi/client.ts b/src/integrations/aimlapi/client.ts index 201754dc16..cbc0119fc0 100644 --- a/src/integrations/aimlapi/client.ts +++ b/src/integrations/aimlapi/client.ts @@ -1,14 +1,4 @@ -/** - * AI/ML API partner-checkout HTTP client. - * - * Talks to two services: - * - app/auth (`authBaseUrl`) - `POST /v1/auth/account` (signup) / - * `PUT /v1/auth/account` (login) -> Bearer token - * - app/gateway(`appBaseUrl`) - `/v3/partner-checkout/*` - * - * Uses the global `fetch` (Node >= 22). All error bodies are surfaced verbatim - * so failures are debuggable. - */ +/** AI/ML API passwordless onboarding and partner-checkout HTTP client. */ import { createCombinedAbortSignal } from '../../utils/combinedAbortSignal.js' import type { AimlapiEndpoints } from './config.js' @@ -45,15 +35,243 @@ export type PayResult = { partnerCheckout: PartnerCheckoutSession } -export type ExchangeResult = { - apiKey: string - apiKeyId: string -} +export type TopUpByKeyResult = PayResult -export type PaymentMethod = 'card' | 'crypto' +export type ExchangeResult = { apiKey: string; apiKeyId: string } export type AuthResult = { token: string; exp: number } +/** + * Payment method for the password-based checkout. The passwordless flow always + * pays by card; this is retained while the top-up flow still offers the choice. + */ +export type PaymentMethod = 'card' | 'crypto' +export type AccountCheckResult = { + action: 'sign-in' | 'sign-up' + provider?: string | null +} +export type CreatedKey = { key: string; id: string } +export type BalanceResult = { + balance: number + lowBalance: boolean + lowBalanceThreshold: number +} + +const REQUEST_TIMEOUT_MS = 60_000 +const MAX_RESPONSE_BODY_BYTES = 1 << 20 + +function requestLabel(url: string): string { + try { + return new URL(url).origin + } catch { + return 'aimlapi.com endpoint' + } +} + +function redactRequestSecrets( + message: string, + url: string, + bearer: string | undefined, + extraSecrets: ReadonlyArray = [], +): string { + const secrets = new Set() + const addSecret = (value: string | undefined): void => { + const trimmed = value?.trim() + if (!trimmed) return + secrets.add(trimmed) + // A token embedded in a path is percent-encoded, and a backend reflecting a + // credential usually re-serializes it with JSON.stringify (escaping quotes + // and backslashes). Redact every form it can appear in. + secrets.add(encodeURIComponent(trimmed)) + secrets.add(JSON.stringify(trimmed).slice(1, -1)) + try { + secrets.add(decodeURIComponent(trimmed)) + } catch { + // Keep the raw value when it is not valid percent-encoding. + } + } + addSecret(bearer) + for (const extra of extraSecrets) addSecret(extra) + try { + // Callers pass every short-lived token through `extraSecrets`, so this scan + // is only a backstop for opaque ids: length is never relied on for safety. + // Short segments are route names (`v1`, `keys`) whose redaction would mangle + // the message without protecting anything. + for (const segment of new URL(url).pathname.split('/')) { + if (segment.length < 6) continue + secrets.add(segment) + try { + secrets.add(decodeURIComponent(segment)) + } catch { + // Keep the encoded segment when it is not valid percent-encoding. + } + } + } catch { + // The request label already handles malformed URLs without exposing them. + } + let redacted = message + // Longest first: a shorter credential must not redact the prefix of a longer + // one (`abc` vs `abc123`) and leave the remaining tail exposed. + for (const secret of [...secrets].sort((a, b) => b.length - a.length)) { + if (secret) redacted = redacted.split(secret).join('[REDACTED]') + } + return redacted +} + +const PARTNER_CHECKOUT_STATUSES: ReadonlySet = new Set([ + 'pending_auth', + 'pending_payment', + 'paid', + 'exchanging', + 'exchanged', + 'cancelled', + 'expired', + 'failed', +]) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === 'string' +} + +function isNullableFiniteNumber(value: unknown): value is number | null { + return value === null || (typeof value === 'number' && Number.isFinite(value)) +} + +/** Consumers hand `payUrl` straight to `openBrowser`, which only opens HTTP(S). */ +function isOpenableHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value) + return protocol === 'https:' || protocol === 'http:' + } catch { + return false + } +} -const REQUEST_TIMEOUT_MS = 30_000 +const ACCOUNT_ACTIONS: ReadonlySet = new Set([ + 'sign-in', + 'sign-up', +]) + +function isAccountCheckResult(value: unknown): value is AccountCheckResult { + // `action` drives the onboarding branch, so an unsupported value must fail at + // the boundary instead of crossing it as an impossible typed value. + return ( + isRecord(value) && + typeof value.action === 'string' && + ACCOUNT_ACTIONS.has(value.action) && + (value.provider === undefined || + value.provider === null || + typeof value.provider === 'string') + ) +} + +function isAuthResult(value: unknown): value is AuthResult { + return ( + isRecord(value) && + isNonEmptyString(value.token) && + typeof value.exp === 'number' && + Number.isFinite(value.exp) + ) +} + +function isCreatedKey(value: unknown): value is CreatedKey { + return isRecord(value) && isNonEmptyString(value.key) && isNonEmptyString(value.id) +} + +function isExchangeResult(value: unknown): value is ExchangeResult { + return isRecord(value) && isNonEmptyString(value.apiKey) && isNonEmptyString(value.apiKeyId) +} + +function isPaymentSession(value: unknown): value is PaymentSession { + return ( + isRecord(value) && + isNonEmptyString(value.providerSessionId) && + (value.payUrl === null || + (isNonEmptyString(value.payUrl) && isOpenableHttpUrl(value.payUrl))) + ) +} + +function isPayResult(value: unknown): value is PayResult { + // The charge has already been requested by the time this lands, so validate + // the whole receipt: a non-string `payUrl` would otherwise be opened as a URL, + // silently fail, and leave the flow polling until it times out. + return ( + isRecord(value) && + isPaymentSession(value.checkout) && + isPartnerCheckoutSession(value.partnerCheckout) + ) +} + +function isPartnerCheckoutSession(value: unknown): value is PartnerCheckoutSession { + if (typeof value !== 'object' || value === null) return false + const session = value as Record + return ( + isNonEmptyString(session.id) && + isNonEmptyString(session.sessionToken) && + isNonEmptyString(session.partnerId) && + typeof session.status === 'string' && + PARTNER_CHECKOUT_STATUSES.has(session.status) && + // Nullable-but-required fields are part of the exported type, so validate + // them too rather than letting a wrong-typed value cross the boundary. + isNullableString(session.partnerName) && + isNullableFiniteNumber(session.userId) && + isNullableFiniteNumber(session.amountUsdMinor) && + isNullableString(session.issuedKeyId) && + isNullableString(session.returnUrl) + ) +} + +function isBalanceResult(value: unknown): value is BalanceResult { + if (typeof value !== 'object' || value === null) return false + const result = value as Record + return ( + typeof result.balance === 'number' && + Number.isFinite(result.balance) && + typeof result.lowBalance === 'boolean' && + typeof result.lowBalanceThreshold === 'number' && + Number.isFinite(result.lowBalanceThreshold) + ) +} + +class AimlapiResponseTooLargeError extends Error { + constructor() { + super(`aimlapi.com response body exceeds ${MAX_RESPONSE_BODY_BYTES} bytes.`) + this.name = 'AimlapiResponseTooLargeError' + } +} + +async function readResponseText(response: Response): Promise { + if (!response.body) return '' + const reader = response.body.getReader() + const decoder = new TextDecoder() + let totalBytes = 0 + let text = '' + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + totalBytes += value.byteLength + if (totalBytes > MAX_RESPONSE_BODY_BYTES) { + try { + await reader.cancel() + } catch { + // Keep the deterministic size-limit error if stream cancellation fails. + } + throw new AimlapiResponseTooLargeError() + } + text += decoder.decode(value, { stream: true }) + } + return text + decoder.decode() + } finally { + reader.releaseLock() + } +} export class AimlapiApiError extends Error { constructor( @@ -69,13 +287,12 @@ export class AimlapiApiError extends Error { export class AimlapiClient { constructor(private readonly endpoints: AimlapiEndpoints) {} - /** Register a new AI/ML API account -> access (Bearer) token. */ - async signup(input: { - email: string - password: string - inviteCode?: string - }): Promise { - return this.request( + /** Register a password account -> access (Bearer) token. */ + async signup( + input: { email: string; password: string; inviteCode?: string }, + signal?: AbortSignal, + ): Promise { + const result = await this.request( `${this.endpoints.authBaseUrl}/v1/auth/account`, { method: 'POST', @@ -84,146 +301,354 @@ export class AimlapiClient { password: input.password, ...(input.inviteCode ? { inviteCode: input.inviteCode } : {}), }, + signal, + secrets: [input.password, input.inviteCode], }, ) + if (!isAuthResult(result)) { + throw new AimlapiApiError('aimlapi.com did not return an auth token.', 200, '') + } + return result } /** Sign in with email + password -> access (Bearer) token. */ - async login(email: string, password: string): Promise { - return this.request( + async login( + email: string, + password: string, + signal?: AbortSignal, + ): Promise { + const result = await this.request( `${this.endpoints.authBaseUrl}/v1/auth/account`, - { method: 'PUT', body: { email, password } }, + { method: 'PUT', body: { email, password }, signal, secrets: [password] }, ) + if (!isAuthResult(result)) { + throw new AimlapiApiError('aimlapi.com did not return an auth token.', 200, '') + } + return result } - /** Create a partner-checkout session (public - no auth). */ - async createSession(input: { - partnerId: string - partnerName?: string | null - returnUrl?: string | null - }): Promise { - return this.request( - `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions`, - { - method: 'POST', - body: { - partnerId: input.partnerId, - ...(input.partnerName ? { partnerName: input.partnerName } : {}), - ...(input.returnUrl ? { returnUrl: input.returnUrl } : {}), - }, - }, + async checkAccount(email: string, signal?: AbortSignal): Promise { + const url = `${this.endpoints.authBaseUrl}/v1/auth/account` + const result = await this.request(url, { + method: 'PATCH', + body: { email }, + signal, + }) + if (!isAccountCheckResult(result)) { + throw new AimlapiApiError(`PATCH ${requestLabel(url)} returned an invalid account response`, 200, '') + } + return result + } + + async sendSignInCode(email: string, signal?: AbortSignal): Promise { + await this.request(`${this.endpoints.authBaseUrl}/v1/auth/sign-in/code`, { + method: 'POST', + body: { email }, + signal, + expectJson: false, + }) + } + + async verifySignInCode( + email: string, + code: string, + signal?: AbortSignal, + ): Promise { + const result = await this.request( + `${this.endpoints.authBaseUrl}/v1/auth/sign-in/code/verify`, + { method: 'POST', body: { email, code }, signal, secrets: [code] }, ) + if (!isAuthResult(result)) { + throw new AimlapiApiError('aimlapi.com did not return an auth token.', 200, '') + } + return result } - /** Poll a session by its one-time token (public - no auth). */ - async getSession(sessionToken: string): Promise { - return this.request( - `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}`, - { method: 'GET' }, + async createPasswordlessAccount(email: string, signal?: AbortSignal): Promise { + const result = await this.request( + `${this.endpoints.authBaseUrl}/v1/auth/account/passwordless`, + { method: 'POST', body: { email }, signal }, ) + if (!isAuthResult(result)) { + throw new AimlapiApiError('aimlapi.com did not return an auth token.', 200, '') + } + return result + } + + async createKey( + bearer: string, + name: string, + signal?: AbortSignal, + ): Promise { + const result = await this.request(`${this.endpoints.appBaseUrl}/v1/keys`, { + method: 'POST', + bearer, + body: name.trim() ? { name: name.trim() } : {}, + signal, + }) + if (!isCreatedKey(result)) { + throw new AimlapiApiError('aimlapi.com did not return an API key.', 200, '') + } + return result + } + + async getBalance(apiKey: string, signal?: AbortSignal): Promise { + const url = `${this.endpoints.inferenceBaseUrl.replace(/\/+$/, '')}/billing/balance` + const result = await this.request( + url, + { method: 'GET', bearer: apiKey, signal }, + ) + if (!isBalanceResult(result)) { + throw new AimlapiApiError( + `GET ${requestLabel(url)} returned invalid balance response`, + 200, + '', + ) + } + return result + } + + async createSession( + input: { partnerId: string; partnerName?: string | null; returnUrl?: string | null }, + signal?: AbortSignal, + ): Promise { + const url = `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions` + const result = await this.request(url, { + method: 'POST', + body: { + partnerId: input.partnerId, + ...(input.partnerName ? { partnerName: input.partnerName } : {}), + ...(input.returnUrl ? { returnUrl: input.returnUrl } : {}), + }, + signal, + }) + if (!isPartnerCheckoutSession(result)) { + throw new AimlapiApiError(`POST ${requestLabel(url)} returned an invalid session`, 200, '') + } + return result + } + + async getSession( + sessionToken: string, + signal?: AbortSignal, + ): Promise { + const url = `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}` + const result = await this.request(url, { + method: 'GET', + signal, + secrets: [sessionToken], + }) + // A malformed/empty 200 must not read as an unknown status: that would let + // callers clear the retained payment identity or take an ambiguous retry. + // Surface it as a non-terminal error so retained state is preserved. + if (!isPartnerCheckoutSession(result)) { + throw new AimlapiApiError(`GET ${requestLabel(url)} returned an invalid session`, 200, '') + } + return result } - /** - * Bind the session to the logged-in user and open a hosted payment page. - * Requires the Bearer token. Returns `checkout.payUrl` to open in a browser. - * - * `successUrl`/`cancelUrl` are the co-branded `/checkout` return URLs the - * payment provider redirects the browser to after pay/cancel (see - * `buildPartnerCheckoutReturnUrls`). When omitted the backend falls back to a - * bare, non-co-branded `/checkout?checkout=success`. - */ async pay( bearer: string, sessionToken: string, input: { amountUsdMinor: number - method: PaymentMethod + /** Supplied by the passwordless flow to make the charge idempotent. */ + paymentSessionId?: string + /** Password flow lets the user choose; the passwordless flow uses card. */ + method?: PaymentMethod successUrl?: string cancelUrl?: string + autoTopUp?: boolean }, + signal?: AbortSignal, ): Promise { - return this.request( - `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}/pay`, - { - method: 'POST', - bearer, - body: { - amountUsdMinor: input.amountUsdMinor, - method: input.method, - ...(input.successUrl ? { successUrl: input.successUrl } : {}), - ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), - }, + const url = `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}/pay` + const result = await this.request(url, { + method: 'POST', + bearer, + body: { + amountUsdMinor: input.amountUsdMinor, + ...(input.paymentSessionId + ? { paymentSessionId: input.paymentSessionId } + : {}), + method: input.method ?? 'card', + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + ...(input.autoTopUp ? { autoTopUp: true } : {}), }, - ) + signal, + secrets: [sessionToken], + }) + if (!isPayResult(result)) { + throw new AimlapiApiError(`POST ${requestLabel(url)} returned an invalid checkout`, 200, '') + } + return result } - /** - * Exchange a PAID session for the raw CLI key. One-shot: a second call after - * a successful exchange loses the claim and returns no key. Requires Bearer. - */ - async exchange(bearer: string, sessionToken: string): Promise { - return this.request( - `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}/exchange`, - { method: 'POST', bearer }, - ) + async topUpByKey( + apiKey: string, + input: { + sessionToken: string + amountUsdMinor: number + paymentSessionId: string + successUrl?: string + cancelUrl?: string + autoTopUp?: boolean + }, + signal?: AbortSignal, + ): Promise { + const inferenceBase = this.endpoints.inferenceBaseUrl + .trim() + .replace(/\/+$/, '') + .replace(/\/v1$/i, '') + const url = `${inferenceBase}/v2/billing/topup` + const result = await this.request(url, { + method: 'POST', + bearer: apiKey, + body: { + sessionToken: input.sessionToken, + amountUsdMinor: input.amountUsdMinor, + paymentSessionId: input.paymentSessionId, + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + ...(input.autoTopUp ? { autoTopUp: true } : {}), + }, + signal, + secrets: [input.sessionToken], + }) + if (!isPayResult(result)) { + throw new AimlapiApiError(`POST ${requestLabel(url)} returned an invalid checkout`, 200, '') + } + return result + } + + async exchange( + bearer: string, + sessionToken: string, + signal?: AbortSignal, + ): Promise { + const url = `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}/exchange` + const result = await this.request(url, { + method: 'POST', + bearer, + signal, + secrets: [sessionToken], + }) + if (!isExchangeResult(result)) { + throw new AimlapiApiError(`POST ${requestLabel(url)} returned an invalid exchange response`, 200, '') + } + return result } private async request( url: string, options: { - method: 'GET' | 'POST' | 'PUT' + method: 'GET' | 'POST' | 'PUT' | 'PATCH' body?: unknown bearer?: string + signal?: AbortSignal + expectJson?: boolean + /** + * Short-lived tokens this request embeds (path or body). Redacted from + * every error message and body, independent of their length. + */ + secrets?: ReadonlyArray }, ): Promise { - const headers: Record = { Accept: 'application/json' } - if (options.body !== undefined) { - headers['Content-Type'] = 'application/json' - } - if (options.bearer) { - headers.Authorization = `Bearer ${options.bearer}` + const label = requestLabel(url) + const redact = (value: string): string => + redactRequestSecrets(value, url, options.bearer, options.secrets) + // A cancelled request still rethrows the transport error, whose message can + // carry the request URL and its token. Keep the cancellation identity (the + // error name, alongside the caller's own signal) but redact the message. + const redactCancellation = (error: unknown): unknown => { + if (!(error instanceof Error)) return error + const redacted = new Error(redact(error.message)) + redacted.name = error.name + return redacted } + const headers: Record = { Accept: 'application/json' } + if (options.body !== undefined) headers['Content-Type'] = 'application/json' + if (options.bearer) headers.Authorization = `Bearer ${options.bearer.trim()}` - const { signal, cleanup } = createCombinedAbortSignal(undefined, { + const combined = createCombinedAbortSignal(options.signal, { timeoutMs: REQUEST_TIMEOUT_MS, }) - let response: Response - let text: string try { response = await fetch(url, { method: options.method, headers, - signal, + signal: combined.signal, ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), }) - text = await response.text() } catch (error) { - const reason = error instanceof Error ? error.message : String(error) - throw new AimlapiApiError(`Network request to ${url} failed: ${reason}`, 0, '') + combined.cleanup() + if (options.signal?.aborted) throw redactCancellation(error) + const reason = redact(error instanceof Error ? error.message : String(error)) + throw new AimlapiApiError(`Network request to ${label} failed: ${reason}`, 0, '') + } + + let text: string + try { + text = await readResponseText(response) + } catch (error) { + if (options.signal?.aborted) throw redactCancellation(error) + if (error instanceof AimlapiResponseTooLargeError) { + throw new AimlapiApiError( + `${options.method} ${label} response body exceeds ${MAX_RESPONSE_BODY_BYTES} bytes`, + response.status, + '', + ) + } + const reason = redact(error instanceof Error ? error.message : String(error)) + throw new AimlapiApiError(`Network response from ${label} failed: ${reason}`, 0, '') } finally { - cleanup() + combined.cleanup() } if (!response.ok) { + // A proxy or backend can reflect the bearer or session token in a 4xx/5xx + // body, and CLI handlers print `body` verbatim — redact before exposing. throw new AimlapiApiError( - `${options.method} ${url} -> ${response.status}`, + `${options.method} ${label} -> ${response.status}`, response.status, - text, + redact(text), ) } - - if (!text) { - return undefined as T + // The caller opted out of a JSON payload, so any successful body counts as + // an acknowledgement — including a non-empty plain-text one. Parsing it + // would fail a request that already delivered the one-time code and push the + // user into a retry that can invalidate or rate-limit it. + if (options.expectJson === false) return undefined as T + if (!text.trim()) { + throw new AimlapiApiError( + `${options.method} ${label} returned empty body`, + response.status, + '', + ) } + let parsed: unknown try { - return JSON.parse(text) as T + parsed = JSON.parse(text) } catch { throw new AimlapiApiError( - `${options.method} ${url} returned non-JSON body`, + `${options.method} ${label} returned non-JSON body`, + response.status, + redact(text), + ) + } + // Every endpoint returns a JSON object. Reject null/non-object bodies here so + // no method dereferences a null/primitive success payload (which would throw + // a raw TypeError instead of a controlled, non-terminal error); endpoint + // guards below still validate structural completeness. + if (typeof parsed !== 'object' || parsed === null) { + throw new AimlapiApiError( + `${options.method} ${label} returned an unexpected body`, response.status, - text, + '', ) } + return parsed as T } }