diff --git a/package.json b/package.json index 8956587..72dddbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clix-so/clix-cli", - "version": "1.1.2", + "version": "1.1.3", "description": "A CLI tool for integrating and managing the Clix SDK in your mobile projects", "type": "module", "bin": { diff --git a/src/lib/api/__tests__/internal-client.test.ts b/src/lib/api/__tests__/internal-client.test.ts new file mode 100644 index 0000000..4ed5b75 --- /dev/null +++ b/src/lib/api/__tests__/internal-client.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { AUTH_ENV_VARS } from '@/lib/auth'; +import { InternalApiClient } from '../internal-client'; + +const originalFetch = globalThis.fetch; +const originalConsoleUrl = process.env[AUTH_ENV_VARS.CONSOLE_URL]; +const originalManagementApiUrl = process.env[AUTH_ENV_VARS.MANAGEMENT_API_URL]; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('InternalApiClient', () => { + const requests: string[] = []; + + beforeEach(() => { + requests.length = 0; + delete process.env[AUTH_ENV_VARS.CONSOLE_URL]; + delete process.env[AUTH_ENV_VARS.MANAGEMENT_API_URL]; + + globalThis.fetch = mock(async (input: string | URL | Request) => { + const url = String(input); + requests.push(url); + + if (url.endsWith('/api/v1/members/me')) { + return jsonResponse({ + member: { + id: 'member-1', + auth_provider_id: 'auth0|1', + email: 'user@example.com', + name: 'User', + }, + }); + } + + if (url.endsWith('/api/v1/organizations')) { + return jsonResponse({ organizations: [{ id: 'org-1', name: 'Org 1' }] }); + } + + if (url.endsWith('/api/v1/organizations/org-1/projects')) { + return jsonResponse({ + projects: [{ id: 'project-1', organization_id: 'org-1', name: 'Project 1' }], + }); + } + + if (url.endsWith('/api/v1/projects/project-1')) { + return jsonResponse({ + project: { id: 'project-1', organization_id: 'org-1', name: 'Project 1' }, + }); + } + + return new Response('not found', { status: 404 }); + }) as unknown as typeof fetch; + }); + + afterEach(() => { + if (originalConsoleUrl === undefined) { + delete process.env[AUTH_ENV_VARS.CONSOLE_URL]; + } else { + process.env[AUTH_ENV_VARS.CONSOLE_URL] = originalConsoleUrl; + } + + if (originalManagementApiUrl === undefined) { + delete process.env[AUTH_ENV_VARS.MANAGEMENT_API_URL]; + } else { + process.env[AUTH_ENV_VARS.MANAGEMENT_API_URL] = originalManagementApiUrl; + } + + globalThis.fetch = originalFetch; + }); + + test('uses the production management API by default', async () => { + const client = new InternalApiClient(); + + await client.getMe({ authToken: 'token-1' }); + + expect(requests).toEqual(['https://management-api.clix.so/api/v1/members/me']); + }); + + test('derives management reads from the console override', async () => { + process.env[AUTH_ENV_VARS.CONSOLE_URL] = 'http://localhost:3000'; + const client = new InternalApiClient(); + + await client.getMe({ authToken: 'token-1' }); + + expect(requests).toEqual(['http://localhost:3000/api/clix/management/api/v1/members/me']); + }); + + test('uses the explicit management API override when configured', async () => { + process.env[AUTH_ENV_VARS.CONSOLE_URL] = 'http://localhost:3000'; + process.env[AUTH_ENV_VARS.MANAGEMENT_API_URL] = 'https://management.example'; + const client = new InternalApiClient(); + + await client.getMe({ authToken: 'token-1' }); + + expect(requests).toEqual(['https://management.example/api/v1/members/me']); + }); + + test('reads member, organizations, and org projects from management-api', async () => { + const client = new InternalApiClient('https://console.example', 'https://management.example'); + + await client.getMe({ authToken: 'token-1' }); + await client.listOrganizations({ authToken: 'token-1' }); + await client.listProjects('org-1', { authToken: 'token-1' }); + + expect(requests).toEqual([ + 'https://management.example/api/v1/members/me', + 'https://management.example/api/v1/organizations', + 'https://management.example/api/v1/organizations/org-1/projects', + ]); + }); + + test('keeps runtime project reads on the console internal proxy', async () => { + const client = new InternalApiClient('https://console.example', 'https://management.example'); + + await client.getProject('project-1', { authToken: 'token-1' }); + + expect(requests).toEqual([ + 'https://console.example/api/clix/internal/api/v1/projects/project-1', + ]); + }); +}); diff --git a/src/lib/api/internal-client.ts b/src/lib/api/internal-client.ts index d3ee91f..0802d4d 100644 --- a/src/lib/api/internal-client.ts +++ b/src/lib/api/internal-client.ts @@ -1,12 +1,10 @@ -import { getConsoleUrl, getCredentialsManager } from '../auth'; +import { AUTH_ENV_VARS, DEFAULT_CONSOLE_URL, getConsoleUrl, getCredentialsManager } from '../auth'; import { AuthError } from '../auth/errors'; import { NetworkError } from '../errors/types'; import type { Member, Organization, Project, SenderConfig } from './types'; -/** - * Internal API proxy prefix on Console. - */ -const API_PROXY_PREFIX = '/api/clix/internal'; +const INTERNAL_API_PROXY_PREFIX = '/api/clix/internal'; +const DEFAULT_MANAGEMENT_API_URL = 'https://management-api.clix.so'; const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; interface InternalApiRequestOptions { @@ -30,10 +28,19 @@ interface InternalApiRequestOptions { * ``` */ export class InternalApiClient { - private baseUrl: string; - - constructor(consoleUrl?: string) { - this.baseUrl = (consoleUrl ?? getConsoleUrl()) + API_PROXY_PREFIX; + private internalBaseUrl: string; + private managementBaseUrl: string; + + constructor(consoleUrl?: string, managementApiUrl?: string) { + const resolvedConsoleUrl = (consoleUrl ?? getConsoleUrl()).replace(/\/+$/, ''); + + this.internalBaseUrl = resolvedConsoleUrl + INTERNAL_API_PROXY_PREFIX; + this.managementBaseUrl = + managementApiUrl ?? + process.env[AUTH_ENV_VARS.MANAGEMENT_API_URL] ?? + (resolvedConsoleUrl === DEFAULT_CONSOLE_URL + ? DEFAULT_MANAGEMENT_API_URL + : `${resolvedConsoleUrl}/api/clix/management`); } private async resolveAccessToken(authToken?: string): Promise { @@ -64,6 +71,7 @@ export class InternalApiClient { * @throws NetworkError if request fails */ private async request( + baseUrl: string, endpoint: string, options: RequestInit = {}, requestOptions: InternalApiRequestOptions = {}, @@ -71,7 +79,7 @@ export class InternalApiClient { const token = await this.resolveAccessToken(requestOptions.authToken); const timeoutMs = requestOptions.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const maxRetries = requestOptions.maxRetries ?? 0; - const url = `${this.baseUrl}${endpoint}`; + const url = `${baseUrl}${endpoint}`; let attempt = 0; while (true) { @@ -129,8 +137,13 @@ export class InternalApiClient { * * @returns Current member */ - async getMe(): Promise { - const response = await this.request<{ member: Member }>('/api/v1/members/me'); + async getMe(options?: InternalApiRequestOptions): Promise { + const response = await this.request<{ member: Member }>( + this.managementBaseUrl, + '/api/v1/members/me', + {}, + options, + ); return response.member; } @@ -141,6 +154,7 @@ export class InternalApiClient { */ async listOrganizations(options?: InternalApiRequestOptions): Promise { const response = await this.request<{ organizations: Organization[] }>( + this.managementBaseUrl, '/api/v1/organizations', {}, options, @@ -159,6 +173,7 @@ export class InternalApiClient { options?: InternalApiRequestOptions, ): Promise { const response = await this.request<{ projects: Project[] }>( + this.managementBaseUrl, `/api/v1/organizations/${organizationId}/projects`, {}, options, @@ -174,6 +189,7 @@ export class InternalApiClient { */ async getProject(projectId: string, options?: InternalApiRequestOptions): Promise { const response = await this.request<{ project: Project }>( + this.internalBaseUrl, `/api/v1/projects/${projectId}`, {}, options, @@ -194,6 +210,7 @@ export class InternalApiClient { options?: InternalApiRequestOptions, ): Promise { const response = await this.request<{ sender_config: SenderConfig }>( + this.internalBaseUrl, `/api/v1/projects/${projectId}/sender-configs`, { method: 'POST', diff --git a/src/lib/auth/__tests__/pkce-flow.test.ts b/src/lib/auth/__tests__/pkce-flow.test.ts new file mode 100644 index 0000000..c52a46f --- /dev/null +++ b/src/lib/auth/__tests__/pkce-flow.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { AUTH_ENV_VARS, getAuth0Config } from '../config'; +import { PKCEFlowService } from '../pkce-flow'; + +const authEnvVars = Object.values(AUTH_ENV_VARS); +const previousEnv = new Map(authEnvVars.map((key) => [key, process.env[key]])); + +afterEach(() => { + for (const key of authEnvVars) { + const previous = previousEnv.get(key); + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } +}); + +describe('Auth0 PKCE flow', () => { + test('uses the production Auth0 organization for the bundled client', () => { + for (const key of authEnvVars) { + delete process.env[key]; + } + + expect(getAuth0Config().organizationId).toBe('org_X7z9sXwAd3bWpEqu'); + }); + + test('does not use the production Auth0 organization for custom clients', () => { + for (const key of authEnvVars) { + delete process.env[key]; + } + + process.env[AUTH_ENV_VARS.AUTH0_CLIENT_ID] = 'custom-client-id'; + + expect(getAuth0Config().organizationId).toBeUndefined(); + }); + + test('allows custom Auth0 organization override', () => { + for (const key of authEnvVars) { + delete process.env[key]; + } + + process.env[AUTH_ENV_VARS.AUTH0_CLIENT_ID] = 'custom-client-id'; + process.env[AUTH_ENV_VARS.AUTH0_ORGANIZATION_ID] = 'org_custom'; + + expect(getAuth0Config().organizationId).toBe('org_custom'); + }); + + test('includes organization in authorization URL', async () => { + const service = new PKCEFlowService({ + domain: 'example.auth0.com', + clientId: 'client-id', + organizationId: 'org_test', + audience: 'https://example.auth0.com/api/v2/', + scope: 'openid profile email offline_access', + }); + + try { + const { authUrl } = await service.startAuthFlow(); + expect(new URL(authUrl).searchParams.get('organization')).toBe('org_test'); + } finally { + service.abort(); + } + }); +}); diff --git a/src/lib/auth/config.ts b/src/lib/auth/config.ts index dde22db..05c3318 100644 --- a/src/lib/auth/config.ts +++ b/src/lib/auth/config.ts @@ -8,12 +8,16 @@ export const AUTH_ENV_VARS = { ACCESS_TOKEN: 'CLIX_ACCESS_TOKEN', /** Console base URL */ CONSOLE_URL: 'CLIX_CONSOLE_URL', + /** Management API base URL */ + MANAGEMENT_API_URL: 'CLIX_MANAGEMENT_API_URL', /** Auth0 domain override */ AUTH0_DOMAIN: 'CLIX_AUTH0_DOMAIN', /** Auth0 client ID override */ AUTH0_CLIENT_ID: 'CLIX_AUTH0_CLIENT_ID', /** Auth0 audience override */ AUTH0_AUDIENCE: 'CLIX_AUTH0_AUDIENCE', + /** Auth0 organization ID override */ + AUTH0_ORGANIZATION_ID: 'CLIX_AUTH0_ORGANIZATION_ID', } as const; /** @@ -23,6 +27,7 @@ export const AUTH_ENV_VARS = { const DEFAULT_AUTH0_CONFIG: Auth0Config = { domain: 'clix-so.us.auth0.com', clientId: 'eWQvmzVSL2xOUzrV2oGc81sDGkdC2RfY', + organizationId: 'org_X7z9sXwAd3bWpEqu', audience: 'https://clix-so.us.auth0.com/api/v2/', // offline_access is required for refresh tokens scope: 'openid profile email offline_access', @@ -31,7 +36,7 @@ const DEFAULT_AUTH0_CONFIG: Auth0Config = { /** * Default Console URL for API proxy. */ -const DEFAULT_CONSOLE_URL = 'https://console.clix.so'; +export const DEFAULT_CONSOLE_URL = 'https://console.clix.so'; /** * Get Auth0 configuration with environment variable overrides. @@ -39,9 +44,15 @@ const DEFAULT_CONSOLE_URL = 'https://console.clix.so'; * @returns Auth0 configuration */ export function getAuth0Config(): Auth0Config { + const clientId = process.env[AUTH_ENV_VARS.AUTH0_CLIENT_ID] ?? DEFAULT_AUTH0_CONFIG.clientId; + const organizationId = + process.env[AUTH_ENV_VARS.AUTH0_ORGANIZATION_ID] ?? + (clientId === DEFAULT_AUTH0_CONFIG.clientId ? DEFAULT_AUTH0_CONFIG.organizationId : undefined); + return { domain: process.env[AUTH_ENV_VARS.AUTH0_DOMAIN] ?? DEFAULT_AUTH0_CONFIG.domain, - clientId: process.env[AUTH_ENV_VARS.AUTH0_CLIENT_ID] ?? DEFAULT_AUTH0_CONFIG.clientId, + clientId, + ...(organizationId && { organizationId }), audience: process.env[AUTH_ENV_VARS.AUTH0_AUDIENCE] ?? DEFAULT_AUTH0_CONFIG.audience, scope: DEFAULT_AUTH0_CONFIG.scope, }; diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 7ad47f3..19f6531 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -3,7 +3,13 @@ // Auth Flows export { openBrowser } from './browser'; // Config -export { AUTH_ENV_VARS, getAuth0Config, getConsoleUrl, getIssuerUrl } from './config'; +export { + AUTH_ENV_VARS, + DEFAULT_CONSOLE_URL, + getAuth0Config, + getConsoleUrl, + getIssuerUrl, +} from './config'; // Credentials Manager export { CredentialsManager, diff --git a/src/lib/auth/pkce-flow.ts b/src/lib/auth/pkce-flow.ts index 25452b1..0a3f890 100644 --- a/src/lib/auth/pkce-flow.ts +++ b/src/lib/auth/pkce-flow.ts @@ -72,6 +72,10 @@ export class PKCEFlowService { state: this.state, }); + if (this.config.organizationId) { + params.set('organization', this.config.organizationId); + } + const authUrl = `${this.baseUrl}/authorize?${params.toString()}`; return { authUrl, port }; } diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts index 6eabf9d..2f1115a 100644 --- a/src/lib/auth/types.ts +++ b/src/lib/auth/types.ts @@ -6,6 +6,8 @@ export interface Auth0Config { domain: string; /** Client ID */ clientId: string; + /** Auth0 organization ID */ + organizationId?: string; /** API audience */ audience: string; /** OAuth scopes */ diff --git a/src/lib/services/agent-handoff.ts b/src/lib/services/agent-handoff.ts index 1413bf6..b1771b5 100644 --- a/src/lib/services/agent-handoff.ts +++ b/src/lib/services/agent-handoff.ts @@ -156,6 +156,7 @@ export async function runAgentHandoff( displayName: invocation.agent.displayName, }, spawnProcess, + spawnProcess === defaultSpawner, ); } @@ -172,14 +173,16 @@ export async function runCommandHandoff( displayName: invocation.displayName ?? invocation.command, }, spawnProcess, + spawnProcess === defaultSpawner, ); } async function runHandoffInvocation( invocation: HandoffExecutionInvocation, spawnProcess: HandoffSpawner, + allowExecve: boolean, ): Promise { - const execve = getExecve(); + const execve = allowExecve ? getExecve() : null; if (execve) { try { execve(invocation.command, [invocation.command, ...invocation.args], invocation.env);