diff --git a/bin.ts b/bin.ts index a61e4cce..daba3689 100644 --- a/bin.ts +++ b/bin.ts @@ -87,6 +87,11 @@ yargs(hideBin(process.argv)) 'PostHog project ID to use (optional; when not set, uses default from API key or OAuth)\nenv: POSTHOG_WIZARD_PROJECT_ID', type: 'string', }, + email: { + describe: + 'Email address for account creation (used with --signup)\nenv: POSTHOG_WIZARD_EMAIL', + type: 'string', + }, }) .command( ['$0'], @@ -209,6 +214,7 @@ yargs(hideBin(process.argv)) signup: options.signup as boolean | undefined, localMcp: options.localMcp as boolean | undefined, apiKey: options.apiKey as string | undefined, + email: options.email, menu: options.menu as boolean | undefined, integration: options.integration as Parameters< typeof buildSession diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index 06c50d5c..26254945 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -197,6 +197,8 @@ export async function runAgentWizard( signup: session.signup, ci: session.ci, apiKey: session.apiKey, + email: session.email, + region: session.region, projectId: session.projectId, }); diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 7bce7c7a..c665a623 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -89,6 +89,8 @@ export interface WizardSession { localMcp: boolean; mcpFeatures?: string[]; apiKey?: string; + email?: string; + region?: CloudRegion; menu: boolean; benchmark: boolean; yaraReport: boolean; @@ -163,6 +165,8 @@ export function buildSession(args: { localMcp?: boolean; mcpFeatures?: string[]; apiKey?: string; + email?: string; + region?: CloudRegion; menu?: boolean; integration?: Integration; benchmark?: boolean; @@ -178,6 +182,8 @@ export function buildSession(args: { localMcp: args.localMcp ?? false, mcpFeatures: args.mcpFeatures, apiKey: args.apiKey, + email: args.email, + region: args.region, menu: args.menu ?? false, benchmark: args.benchmark ?? false, yaraReport: args.yaraReport ?? false, diff --git a/src/run.ts b/src/run.ts index bb689227..c486286c 100644 --- a/src/run.ts +++ b/src/run.ts @@ -31,6 +31,7 @@ type Args = { localMcp?: boolean; ci?: boolean; apiKey?: string; + email?: string; projectId?: string; menu?: boolean; benchmark?: boolean; @@ -68,6 +69,8 @@ export async function runWizard(argv: Args, session?: WizardSession) { signup: finalArgs.signup, localMcp: finalArgs.localMcp, apiKey: finalArgs.apiKey, + email: finalArgs.email, + region: finalArgs.region, menu: finalArgs.menu, integration: finalArgs.integration, benchmark: finalArgs.benchmark, diff --git a/src/utils/__tests__/provisioning.test.ts b/src/utils/__tests__/provisioning.test.ts new file mode 100644 index 00000000..ba461646 --- /dev/null +++ b/src/utils/__tests__/provisioning.test.ts @@ -0,0 +1,226 @@ +import axios from 'axios'; +import { provisionNewAccount } from '../provisioning'; + +jest.mock('axios'); +jest.mock('../debug', () => ({ logToFile: jest.fn() })); +jest.mock('../analytics', () => ({ + analytics: { captureException: jest.fn() }, +})); + +const mockedAxios = axios as jest.Mocked; + +describe('provisionNewAccount', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('completes the full PKCE flow and returns credentials', async () => { + // Step 1: account_requests + mockedAxios.post.mockResolvedValueOnce({ + data: { + id: 'req_1', + type: 'oauth', + oauth: { code: 'test_code_123' }, + }, + }); + + // Step 2: oauth/token + mockedAxios.post.mockResolvedValueOnce({ + data: { + token_type: 'bearer', + access_token: 'pha_test_access', + refresh_token: 'phr_test_refresh', + expires_in: 3600, + account: { id: 'org_123' }, + }, + }); + + // Step 3: resources + mockedAxios.post.mockResolvedValueOnce({ + data: { + status: 'complete', + id: '42', + service_id: 'analytics', + complete: { + access_configuration: { + api_key: 'phc_test_key', + host: 'https://us.posthog.com', + personal_api_key: 'phx_test_pat', + }, + }, + }, + }); + + const result = await provisionNewAccount('user@example.com', 'Test User'); + + expect(result).toEqual({ + accessToken: 'pha_test_access', + refreshToken: 'phr_test_refresh', + projectApiKey: 'phc_test_key', + host: 'https://us.posthog.com', + personalApiKey: 'phx_test_pat', + projectId: '42', + accountId: 'org_123', + }); + + expect(mockedAxios.post).toHaveBeenCalledTimes(3); + + // Verify account_requests call + const accountCall = mockedAxios.post.mock.calls[0]; + expect(accountCall[0]).toContain('/account_requests'); + expect(accountCall[1]).toMatchObject({ + email: 'user@example.com', + name: 'Test User', + code_challenge_method: 'S256', + configuration: { region: 'US' }, + }); + expect( + (accountCall[1] as Record).code_challenge, + ).toBeTruthy(); + expect((accountCall[1] as Record).client_id).toBeTruthy(); + + // Verify token exchange includes code_verifier + const tokenCall = mockedAxios.post.mock.calls[1]; + expect(tokenCall[0]).toContain('/oauth/token'); + expect(tokenCall[1]).toContain('code_verifier='); + expect(tokenCall[1]).toContain('grant_type=authorization_code'); + + // Verify resources call uses bearer token + const resourceCall = mockedAxios.post.mock.calls[2]; + expect(resourceCall[0]).toContain('/resources'); + expect(resourceCall[2]?.headers?.Authorization).toBe( + 'Bearer pha_test_access', + ); + }); + + it('throws when account already exists', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: { + id: 'req_2', + type: 'requires_auth', + requires_auth: { type: 'redirect', redirect: { url: 'https://...' } }, + }, + }); + + await expect( + provisionNewAccount('existing@example.com', ''), + ).rejects.toThrow('already associated'); + }); + + it('throws on API error response', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: { + id: 'req_3', + type: 'error', + error: { code: 'forbidden', message: 'Account creation disabled' }, + }, + }); + + await expect( + provisionNewAccount('blocked@example.com', ''), + ).rejects.toThrow('Account creation disabled'); + }); + + it('throws when resource provisioning fails', async () => { + mockedAxios.post + .mockResolvedValueOnce({ + data: { id: 'req_4', type: 'oauth', oauth: { code: 'code_4' } }, + }) + .mockResolvedValueOnce({ + data: { + token_type: 'bearer', + access_token: 'pha_4', + refresh_token: 'phr_4', + expires_in: 3600, + }, + }) + .mockResolvedValueOnce({ + data: { status: 'error', id: '0', service_id: 'analytics' }, + }); + + await expect(provisionNewAccount('fail@example.com', '')).rejects.toThrow( + 'did not complete', + ); + }); + + it('sends correct region parameter', async () => { + mockedAxios.post + .mockResolvedValueOnce({ + data: { id: 'req_5', type: 'oauth', oauth: { code: 'code_5' } }, + }) + .mockResolvedValueOnce({ + data: { + token_type: 'bearer', + access_token: 'pha_5', + refresh_token: 'phr_5', + expires_in: 3600, + }, + }) + .mockResolvedValueOnce({ + data: { + status: 'complete', + id: '99', + service_id: 'analytics', + complete: { + access_configuration: { + api_key: 'phc_eu', + host: 'https://eu.posthog.com', + }, + }, + }, + }); + + const result = await provisionNewAccount('eu@example.com', '', 'EU'); + + const accountCall = mockedAxios.post.mock.calls[0]; + expect((accountCall[1] as Record).configuration).toEqual({ + region: 'EU', + }); + expect(result.host).toBe('https://eu.posthog.com'); + }); + + it('includes timeouts on all requests', async () => { + mockedAxios.post + .mockResolvedValueOnce({ + data: { id: 'req_6', type: 'oauth', oauth: { code: 'code_6' } }, + }) + .mockResolvedValueOnce({ + data: { + token_type: 'bearer', + access_token: 'pha_6', + refresh_token: 'phr_6', + expires_in: 3600, + }, + }) + .mockResolvedValueOnce({ + data: { + status: 'complete', + id: '1', + service_id: 'analytics', + complete: { + access_configuration: { + api_key: 'phc_t', + host: 'https://us.posthog.com', + }, + }, + }, + }); + + await provisionNewAccount('timeout@example.com', ''); + + // account_requests and resources have config at index 2 + const accountConfig = mockedAxios.post.mock.calls[0][2] as + | Record + | undefined; + const resourceConfig = mockedAxios.post.mock.calls[2][2] as + | Record + | undefined; + expect(accountConfig?.timeout).toBe(30_000); + expect(resourceConfig?.timeout).toBe(30_000); + // token exchange has config at index 2 (URL-encoded body is at index 1) + const tokenConfig = mockedAxios.post.mock.calls[1][2] as + | Record + | undefined; + expect(tokenConfig?.timeout).toBe(30_000); + }); +}); diff --git a/src/utils/provisioning.ts b/src/utils/provisioning.ts new file mode 100644 index 00000000..15f0e0af --- /dev/null +++ b/src/utils/provisioning.ts @@ -0,0 +1,204 @@ +/** + * Provisioning API client for creating new PostHog accounts. + * + * Uses the agentic provisioning API with PKCE auth: + * 1. POST /account_requests - create account, get auth code + * 2. POST /oauth/token - exchange code for tokens (with PKCE) + * 3. POST /resources - provision project, get API key + */ + +import * as crypto from 'node:crypto'; +import axios from 'axios'; +import { z } from 'zod'; +import { IS_DEV, WIZARD_USER_AGENT } from '../lib/constants'; +import { logToFile } from './debug'; +import { analytics } from './analytics'; + +const WIZARD_CLIENT_ID = 'posthog-wizard'; +const API_VERSION = '0.1d'; + +const PROVISIONING_BASE_URL = IS_DEV + ? 'http://localhost:8010' + : 'https://us.posthog.com'; + +function generateCodeVerifier(): string { + return crypto.randomBytes(32).toString('base64url'); +} + +function generateCodeChallenge(verifier: string): string { + return crypto.createHash('sha256').update(verifier).digest('base64url'); +} + +// --- Response schemas --- + +const AccountRequestResponseSchema = z.object({ + id: z.string(), + type: z.enum(['oauth', 'requires_auth', 'error']), + oauth: z + .object({ + code: z.string(), + }) + .optional(), + error: z + .object({ + code: z.string(), + message: z.string(), + }) + .optional(), +}); + +const TokenResponseSchema = z.object({ + token_type: z.string(), + access_token: z.string(), + refresh_token: z.string(), + expires_in: z.number(), + account: z + .object({ + id: z.string(), + }) + .optional(), +}); + +const ResourceResponseSchema = z.object({ + status: z.string(), + id: z.string(), + service_id: z.string(), + complete: z + .object({ + access_configuration: z.object({ + api_key: z.string(), + host: z.string(), + personal_api_key: z.string().optional(), + }), + }) + .optional(), +}); + +export interface ProvisioningResult { + accessToken: string; + refreshToken: string; + projectApiKey: string; + host: string; + personalApiKey?: string; + projectId: string; + accountId: string; +} + +/** + * Create a new PostHog account and provision a project via the provisioning API. + * + * This is the "no browser" signup path: the wizard collects the email, + * calls the provisioning API to create the account, and gets back + * credentials without opening a browser. + */ +export async function provisionNewAccount( + email: string, + name: string, + region: 'US' | 'EU' = 'US', +): Promise { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + + logToFile('[provisioning] starting account creation'); + + // Step 1: Create account + const accountRes = await axios.post( + `${PROVISIONING_BASE_URL}/api/agentic/provisioning/account_requests`, + { + id: crypto.randomUUID(), + email, + name, + client_id: WIZARD_CLIENT_ID, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + configuration: { region }, + }, + { + headers: { + 'Content-Type': 'application/json', + 'API-Version': API_VERSION, + 'User-Agent': WIZARD_USER_AGENT, + }, + timeout: 30_000, + }, + ); + + const accountData = AccountRequestResponseSchema.parse(accountRes.data); + + if (accountData.type === 'error') { + const msg = accountData.error?.message ?? 'Account creation failed'; + analytics.captureException(new Error(msg), { + step: 'provisioning_account_request', + error_code: accountData.error?.code, + }); + throw new Error(msg); + } + + if (accountData.type === 'requires_auth') { + throw new Error( + 'This email is already associated with a PostHog account. Please use the login flow instead.', + ); + } + + const code = accountData.oauth?.code; + if (!code) { + throw new Error('No authorization code received from account creation'); + } + + logToFile('[provisioning] account created, exchanging code for tokens'); + + // Step 2: Exchange code for tokens + const tokenRes = await axios.post( + `${PROVISIONING_BASE_URL}/api/agentic/oauth/token`, + new URLSearchParams({ + grant_type: 'authorization_code', + code, + code_verifier: codeVerifier, + }).toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'API-Version': API_VERSION, + 'User-Agent': WIZARD_USER_AGENT, + }, + timeout: 30_000, + }, + ); + + const tokenData = TokenResponseSchema.parse(tokenRes.data); + + logToFile('[provisioning] tokens received, provisioning resources'); + + // Step 3: Provision resources + const resourceRes = await axios.post( + `${PROVISIONING_BASE_URL}/api/agentic/provisioning/resources`, + { service_id: 'analytics' }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${tokenData.access_token}`, + 'API-Version': API_VERSION, + 'User-Agent': WIZARD_USER_AGENT, + }, + timeout: 30_000, + }, + ); + + const resourceData = ResourceResponseSchema.parse(resourceRes.data); + + if (resourceData.status !== 'complete' || !resourceData.complete) { + throw new Error('Resource provisioning did not complete'); + } + + logToFile('[provisioning] resources provisioned successfully'); + + return { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + projectApiKey: resourceData.complete.access_configuration.api_key, + host: resourceData.complete.access_configuration.host, + personalApiKey: resourceData.complete.access_configuration.personal_api_key, + projectId: resourceData.id, + accountId: tokenData.account?.id ?? '', + }; +} diff --git a/src/utils/setup-utils.ts b/src/utils/setup-utils.ts index f24ab546..404d3eda 100644 --- a/src/utils/setup-utils.ts +++ b/src/utils/setup-utils.ts @@ -26,6 +26,7 @@ import { detectRegionFromToken, } from './urls'; import { performOAuthFlow } from './oauth'; +import { provisionNewAccount } from './provisioning'; import { fetchUserData, fetchProjectData } from '../lib/api'; import { fulfillsVersionRange } from './semver'; import { wizardAbort } from './wizard-abort'; @@ -319,7 +320,10 @@ export function isUsingTypeScript({ * Get project data for the wizard via OAuth or CI API key. */ export async function getOrAskForProjectData( - _options: Pick, + _options: Pick & { + email?: string; + region?: CloudRegion; + }, ): Promise<{ host: string; projectApiKey: string; @@ -357,6 +361,8 @@ export async function getOrAskForProjectData( await traceStep('login', () => askForWizardLogin({ signup: _options.signup, + email: _options.email, + region: _options.region, }), ); @@ -416,7 +422,13 @@ async function fetchProjectDataById( async function askForWizardLogin(options: { signup: boolean; + email?: string; + region?: CloudRegion; }): Promise { + if (options.signup) { + return askForProvisioningSignup(options.email, options.region); + } + const tokenResponse = await performOAuthFlow({ scopes: [ 'user:read', @@ -427,7 +439,7 @@ async function askForWizardLogin(options: { 'insight:write', 'query:read', ], - signup: options.signup, + signup: false, }); const projectId = tokenResponse.scoped_teams?.[0]; @@ -464,15 +476,69 @@ async function askForWizardLogin(options: { cloudRegion, }; - getUI().log.success( - `Login complete. ${options.signup ? 'Welcome to PostHog!' : ''}`, - ); + getUI().log.success('Login complete.'); analytics.setTag('opened-wizard-link', true); analytics.setDistinctId(data.distinctId); return data; } +async function askForProvisioningSignup( + email?: string, + region?: CloudRegion, +): Promise { + if (!email || !email.includes('@')) { + getUI().log.error( + 'Email is required for signup. Use --email your@email.com with --signup.', + ); + await abort(); + throw new Error('unreachable'); + } + + const spinner = getUI().spinner(); + spinner.start('Creating your PostHog account...'); + + try { + const provisionRegion = (region ?? 'us').toUpperCase() as 'US' | 'EU'; + const result = await provisionNewAccount(email, '', provisionRegion); + + spinner.stop('Account created!'); + getUI().log.success('Welcome to PostHog!'); + + const host = result.host; + const cloudRegion: CloudRegion = host.includes('eu.') ? 'eu' : 'us'; + + analytics.setTag('provisioning-signup', true); + + return { + accessToken: result.accessToken, + projectApiKey: result.projectApiKey, + host, + distinctId: email, + projectId: parseInt(result.projectId, 10) || 0, + cloudRegion, + }; + } catch (error) { + spinner.stop('Account creation failed.'); + const message = error instanceof Error ? error.message : 'Unknown error'; + + if (message.includes('already associated')) { + getUI().log.info( + 'This email already has a PostHog account. Switching to login flow...', + ); + return askForWizardLogin({ signup: false }); + } + + getUI().log.error(`Failed to create account: ${message}`); + analytics.captureException( + error instanceof Error ? error : new Error(message), + { step: 'provisioning_signup' }, + ); + await abort(); + throw error; + } +} + /** * Creates a new config file with the given filepath and codeSnippet. */ diff --git a/src/utils/types.ts b/src/utils/types.ts index 38a03017..982f55b9 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -51,6 +51,11 @@ export type WizardOptions = { */ apiKey?: string; + /** + * Email address for account creation (used with --signup) + */ + email?: string; + /** * PostHog project ID. When set (e.g. in CI with --project-id), the wizard uses this project * instead of the default from the API key or OAuth.