Skip to content

Commit c101aeb

Browse files
MattBroclaude
andauthored
feat: add provisioning API signup flow for new users (#377)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9b4bf52 commit c101aeb

8 files changed

Lines changed: 523 additions & 5 deletions

File tree

bin.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ yargs(hideBin(process.argv))
8787
'PostHog project ID to use (optional; when not set, uses default from API key or OAuth)\nenv: POSTHOG_WIZARD_PROJECT_ID',
8888
type: 'string',
8989
},
90+
email: {
91+
describe:
92+
'Email address for account creation (used with --signup)\nenv: POSTHOG_WIZARD_EMAIL',
93+
type: 'string',
94+
},
9095
})
9196
.command(
9297
['$0'],
@@ -209,6 +214,7 @@ yargs(hideBin(process.argv))
209214
signup: options.signup as boolean | undefined,
210215
localMcp: options.localMcp as boolean | undefined,
211216
apiKey: options.apiKey as string | undefined,
217+
email: options.email,
212218
menu: options.menu as boolean | undefined,
213219
integration: options.integration as Parameters<
214220
typeof buildSession

src/lib/agent-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ export async function runAgentWizard(
197197
signup: session.signup,
198198
ci: session.ci,
199199
apiKey: session.apiKey,
200+
email: session.email,
201+
region: session.region,
200202
projectId: session.projectId,
201203
});
202204

src/lib/wizard-session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export interface WizardSession {
8989
localMcp: boolean;
9090
mcpFeatures?: string[];
9191
apiKey?: string;
92+
email?: string;
93+
region?: CloudRegion;
9294
menu: boolean;
9395
benchmark: boolean;
9496
yaraReport: boolean;
@@ -163,6 +165,8 @@ export function buildSession(args: {
163165
localMcp?: boolean;
164166
mcpFeatures?: string[];
165167
apiKey?: string;
168+
email?: string;
169+
region?: CloudRegion;
166170
menu?: boolean;
167171
integration?: Integration;
168172
benchmark?: boolean;
@@ -178,6 +182,8 @@ export function buildSession(args: {
178182
localMcp: args.localMcp ?? false,
179183
mcpFeatures: args.mcpFeatures,
180184
apiKey: args.apiKey,
185+
email: args.email,
186+
region: args.region,
181187
menu: args.menu ?? false,
182188
benchmark: args.benchmark ?? false,
183189
yaraReport: args.yaraReport ?? false,

src/run.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type Args = {
3131
localMcp?: boolean;
3232
ci?: boolean;
3333
apiKey?: string;
34+
email?: string;
3435
projectId?: string;
3536
menu?: boolean;
3637
benchmark?: boolean;
@@ -68,6 +69,8 @@ export async function runWizard(argv: Args, session?: WizardSession) {
6869
signup: finalArgs.signup,
6970
localMcp: finalArgs.localMcp,
7071
apiKey: finalArgs.apiKey,
72+
email: finalArgs.email,
73+
region: finalArgs.region,
7174
menu: finalArgs.menu,
7275
integration: finalArgs.integration,
7376
benchmark: finalArgs.benchmark,
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import axios from 'axios';
2+
import { provisionNewAccount } from '../provisioning';
3+
4+
jest.mock('axios');
5+
jest.mock('../debug', () => ({ logToFile: jest.fn() }));
6+
jest.mock('../analytics', () => ({
7+
analytics: { captureException: jest.fn() },
8+
}));
9+
10+
const mockedAxios = axios as jest.Mocked<typeof axios>;
11+
12+
describe('provisionNewAccount', () => {
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
});
16+
17+
it('completes the full PKCE flow and returns credentials', async () => {
18+
// Step 1: account_requests
19+
mockedAxios.post.mockResolvedValueOnce({
20+
data: {
21+
id: 'req_1',
22+
type: 'oauth',
23+
oauth: { code: 'test_code_123' },
24+
},
25+
});
26+
27+
// Step 2: oauth/token
28+
mockedAxios.post.mockResolvedValueOnce({
29+
data: {
30+
token_type: 'bearer',
31+
access_token: 'pha_test_access',
32+
refresh_token: 'phr_test_refresh',
33+
expires_in: 3600,
34+
account: { id: 'org_123' },
35+
},
36+
});
37+
38+
// Step 3: resources
39+
mockedAxios.post.mockResolvedValueOnce({
40+
data: {
41+
status: 'complete',
42+
id: '42',
43+
service_id: 'analytics',
44+
complete: {
45+
access_configuration: {
46+
api_key: 'phc_test_key',
47+
host: 'https://us.posthog.com',
48+
personal_api_key: 'phx_test_pat',
49+
},
50+
},
51+
},
52+
});
53+
54+
const result = await provisionNewAccount('user@example.com', 'Test User');
55+
56+
expect(result).toEqual({
57+
accessToken: 'pha_test_access',
58+
refreshToken: 'phr_test_refresh',
59+
projectApiKey: 'phc_test_key',
60+
host: 'https://us.posthog.com',
61+
personalApiKey: 'phx_test_pat',
62+
projectId: '42',
63+
accountId: 'org_123',
64+
});
65+
66+
expect(mockedAxios.post).toHaveBeenCalledTimes(3);
67+
68+
// Verify account_requests call
69+
const accountCall = mockedAxios.post.mock.calls[0];
70+
expect(accountCall[0]).toContain('/account_requests');
71+
expect(accountCall[1]).toMatchObject({
72+
email: 'user@example.com',
73+
name: 'Test User',
74+
code_challenge_method: 'S256',
75+
configuration: { region: 'US' },
76+
});
77+
expect(
78+
(accountCall[1] as Record<string, unknown>).code_challenge,
79+
).toBeTruthy();
80+
expect((accountCall[1] as Record<string, unknown>).client_id).toBeTruthy();
81+
82+
// Verify token exchange includes code_verifier
83+
const tokenCall = mockedAxios.post.mock.calls[1];
84+
expect(tokenCall[0]).toContain('/oauth/token');
85+
expect(tokenCall[1]).toContain('code_verifier=');
86+
expect(tokenCall[1]).toContain('grant_type=authorization_code');
87+
88+
// Verify resources call uses bearer token
89+
const resourceCall = mockedAxios.post.mock.calls[2];
90+
expect(resourceCall[0]).toContain('/resources');
91+
expect(resourceCall[2]?.headers?.Authorization).toBe(
92+
'Bearer pha_test_access',
93+
);
94+
});
95+
96+
it('throws when account already exists', async () => {
97+
mockedAxios.post.mockResolvedValueOnce({
98+
data: {
99+
id: 'req_2',
100+
type: 'requires_auth',
101+
requires_auth: { type: 'redirect', redirect: { url: 'https://...' } },
102+
},
103+
});
104+
105+
await expect(
106+
provisionNewAccount('existing@example.com', ''),
107+
).rejects.toThrow('already associated');
108+
});
109+
110+
it('throws on API error response', async () => {
111+
mockedAxios.post.mockResolvedValueOnce({
112+
data: {
113+
id: 'req_3',
114+
type: 'error',
115+
error: { code: 'forbidden', message: 'Account creation disabled' },
116+
},
117+
});
118+
119+
await expect(
120+
provisionNewAccount('blocked@example.com', ''),
121+
).rejects.toThrow('Account creation disabled');
122+
});
123+
124+
it('throws when resource provisioning fails', async () => {
125+
mockedAxios.post
126+
.mockResolvedValueOnce({
127+
data: { id: 'req_4', type: 'oauth', oauth: { code: 'code_4' } },
128+
})
129+
.mockResolvedValueOnce({
130+
data: {
131+
token_type: 'bearer',
132+
access_token: 'pha_4',
133+
refresh_token: 'phr_4',
134+
expires_in: 3600,
135+
},
136+
})
137+
.mockResolvedValueOnce({
138+
data: { status: 'error', id: '0', service_id: 'analytics' },
139+
});
140+
141+
await expect(provisionNewAccount('fail@example.com', '')).rejects.toThrow(
142+
'did not complete',
143+
);
144+
});
145+
146+
it('sends correct region parameter', async () => {
147+
mockedAxios.post
148+
.mockResolvedValueOnce({
149+
data: { id: 'req_5', type: 'oauth', oauth: { code: 'code_5' } },
150+
})
151+
.mockResolvedValueOnce({
152+
data: {
153+
token_type: 'bearer',
154+
access_token: 'pha_5',
155+
refresh_token: 'phr_5',
156+
expires_in: 3600,
157+
},
158+
})
159+
.mockResolvedValueOnce({
160+
data: {
161+
status: 'complete',
162+
id: '99',
163+
service_id: 'analytics',
164+
complete: {
165+
access_configuration: {
166+
api_key: 'phc_eu',
167+
host: 'https://eu.posthog.com',
168+
},
169+
},
170+
},
171+
});
172+
173+
const result = await provisionNewAccount('eu@example.com', '', 'EU');
174+
175+
const accountCall = mockedAxios.post.mock.calls[0];
176+
expect((accountCall[1] as Record<string, unknown>).configuration).toEqual({
177+
region: 'EU',
178+
});
179+
expect(result.host).toBe('https://eu.posthog.com');
180+
});
181+
182+
it('includes timeouts on all requests', async () => {
183+
mockedAxios.post
184+
.mockResolvedValueOnce({
185+
data: { id: 'req_6', type: 'oauth', oauth: { code: 'code_6' } },
186+
})
187+
.mockResolvedValueOnce({
188+
data: {
189+
token_type: 'bearer',
190+
access_token: 'pha_6',
191+
refresh_token: 'phr_6',
192+
expires_in: 3600,
193+
},
194+
})
195+
.mockResolvedValueOnce({
196+
data: {
197+
status: 'complete',
198+
id: '1',
199+
service_id: 'analytics',
200+
complete: {
201+
access_configuration: {
202+
api_key: 'phc_t',
203+
host: 'https://us.posthog.com',
204+
},
205+
},
206+
},
207+
});
208+
209+
await provisionNewAccount('timeout@example.com', '');
210+
211+
// account_requests and resources have config at index 2
212+
const accountConfig = mockedAxios.post.mock.calls[0][2] as
213+
| Record<string, unknown>
214+
| undefined;
215+
const resourceConfig = mockedAxios.post.mock.calls[2][2] as
216+
| Record<string, unknown>
217+
| undefined;
218+
expect(accountConfig?.timeout).toBe(30_000);
219+
expect(resourceConfig?.timeout).toBe(30_000);
220+
// token exchange has config at index 2 (URL-encoded body is at index 1)
221+
const tokenConfig = mockedAxios.post.mock.calls[1][2] as
222+
| Record<string, unknown>
223+
| undefined;
224+
expect(tokenConfig?.timeout).toBe(30_000);
225+
});
226+
});

0 commit comments

Comments
 (0)