Skip to content

Commit bbc6670

Browse files
committed
feat(data-warehouse): generic OAuth flow starter for inbox sources
Generalize the inbox OAuth connect flow so any PostHog-supported OAuth integration kind works without provider-specific code. `DynamicSourceSetup` already renders `oauth` and `oauth-account-select` fields generically (account listing + server-side resource search); the only provider-specific gate was the flow starter, which hardcoded `kind === "linear"`. PostHog's `…/integrations/authorize/?kind=<kind>` endpoint is already generic, so this adds a kind-parameterized `IntegrationService` + `integration` tRPC router and points the OAuth field's connect button at it via `field.kind`. Result: adding an OAuth warehouse source (Intercom, HubSpot, Salesforce, Stripe, ad platforms, …) needs no bespoke setup form or per-kind router — just the source's registry entry, whose connect-form schema already carries the oauth field and kind.
1 parent e11448d commit bbc6670

8 files changed

Lines changed: 145 additions & 11 deletions

File tree

packages/core/src/integrations/identifiers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
export const INTEGRATION_SERVICE = Symbol.for(
2+
"posthog.core.integrationService",
3+
);
4+
15
export const GITHUB_INTEGRATION_SERVICE = Symbol.for(
26
"posthog.core.githubIntegrationService",
37
);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { IntegrationService } from "./integration";
3+
4+
function createService() {
5+
const urlLauncher = { launch: vi.fn().mockResolvedValue(undefined) };
6+
const service = new IntegrationService(urlLauncher as never);
7+
return { service, urlLauncher };
8+
}
9+
10+
describe("IntegrationService.startFlow", () => {
11+
it("launches an authorize URL for the given kind scoped to the project", async () => {
12+
const { service, urlLauncher } = createService();
13+
14+
const result = await service.startFlow("intercom", "us", 42);
15+
16+
expect(result).toEqual({ success: true });
17+
const launched = urlLauncher.launch.mock.calls[0][0];
18+
expect(launched).toContain("/api/environments/42/integrations/authorize/");
19+
expect(launched).toContain("kind=intercom");
20+
});
21+
22+
it("url-encodes the kind", async () => {
23+
const { service, urlLauncher } = createService();
24+
25+
await service.startFlow("rapid7_insightvm", "eu", 7);
26+
27+
expect(urlLauncher.launch.mock.calls[0][0]).toContain(
28+
"kind=rapid7_insightvm",
29+
);
30+
});
31+
32+
it("returns a failure result when launching the browser throws", async () => {
33+
const { service, urlLauncher } = createService();
34+
urlLauncher.launch.mockRejectedValue(new Error("no browser"));
35+
36+
expect(await service.startFlow("hubspot", "us", 42)).toEqual({
37+
success: false,
38+
error: "no browser",
39+
});
40+
});
41+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
type IUrlLauncher,
3+
URL_LAUNCHER_SERVICE,
4+
} from "@posthog/platform/url-launcher";
5+
import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared";
6+
import { inject, injectable } from "inversify";
7+
import type { StartIntegrationFlowOutput } from "./schemas";
8+
9+
/**
10+
* Generic OAuth integration flow starter. PostHog's
11+
* `…/integrations/authorize/?kind=<kind>` endpoint is generic over the integration kind, so a
12+
* single service starts the flow for any supported OAuth provider (linear, intercom, hubspot,
13+
* salesforce, …) — no per-kind service or router required. The OAuth grant, callback, and token
14+
* storage all happen on PostHog Cloud; the caller then polls the integrations list for the new
15+
* integration of this `kind`.
16+
*/
17+
@injectable()
18+
export class IntegrationService {
19+
constructor(
20+
@inject(URL_LAUNCHER_SERVICE)
21+
private readonly urlLauncher: IUrlLauncher,
22+
) {}
23+
24+
public async startFlow(
25+
kind: string,
26+
region: CloudRegion,
27+
projectId: number,
28+
): Promise<StartIntegrationFlowOutput> {
29+
try {
30+
const cloudUrl = getCloudUrlFromRegion(region);
31+
const next = `${cloudUrl}/project/${projectId}`;
32+
const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=${encodeURIComponent(kind)}&next=${encodeURIComponent(next)}`;
33+
34+
await this.urlLauncher.launch(authorizeUrl);
35+
36+
return { success: true };
37+
} catch (error) {
38+
return {
39+
success: false,
40+
error: error instanceof Error ? error.message : "Unknown error",
41+
};
42+
}
43+
}
44+
}

packages/core/src/integrations/integrations.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import { ContainerModule } from "inversify";
22
import { GitHubIntegrationService } from "./github";
33
import {
44
GITHUB_INTEGRATION_SERVICE,
5+
INTEGRATION_SERVICE,
56
LINEAR_INTEGRATION_SERVICE,
67
SLACK_INTEGRATION_SERVICE,
78
} from "./identifiers";
9+
import { IntegrationService } from "./integration";
810
import { LinearIntegrationService } from "./linear";
911
import { SlackIntegrationService } from "./slack";
1012

1113
export const integrationsModule = new ContainerModule(({ bind }) => {
14+
bind(INTEGRATION_SERVICE).to(IntegrationService).inSingletonScope();
1215
bind(GITHUB_INTEGRATION_SERVICE)
1316
.to(GitHubIntegrationService)
1417
.inSingletonScope();

packages/core/src/integrations/schemas.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@ export type StartIntegrationFlowInput = z.infer<
1111
typeof startIntegrationFlowInput
1212
>;
1313

14+
/**
15+
* Generic integration flow input: any OAuth `kind` PostHog supports. The per-kind routers
16+
* (linear/slack/github) keep the narrower input above; this one drives the generic starter so
17+
* new OAuth sources need no dedicated router.
18+
*/
19+
export const startGenericIntegrationFlowInput = z.object({
20+
kind: z.string(),
21+
region: cloudRegion,
22+
projectId: z.number(),
23+
});
24+
export type StartGenericIntegrationFlowInput = z.infer<
25+
typeof startGenericIntegrationFlowInput
26+
>;
27+
1428
export const startIntegrationFlowOutput = z.object({
1529
success: z.boolean(),
1630
error: z.string().optional(),

packages/host-router/src/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { gitRouter } from "./routers/git.router";
2525
import { githubIntegrationRouter } from "./routers/github-integration.router";
2626
import { githubReleasesRouter } from "./routers/github-releases.router";
2727
import { handoffRouter } from "./routers/handoff.router";
28+
import { integrationRouter } from "./routers/integration.router";
2829
import { linearIntegrationRouter } from "./routers/linear-integration.router";
2930
import { llmGatewayRouter } from "./routers/llm-gateway.router";
3031
import { localMcpRouter } from "./routers/local-mcp.router";
@@ -76,6 +77,7 @@ export const hostRouter = router({
7677
fs: fsRouter,
7778
git: gitRouter,
7879
handoff: handoffRouter,
80+
integration: integrationRouter,
7981
githubIntegration: githubIntegrationRouter,
8082
githubReleases: githubReleasesRouter,
8183
linearIntegration: linearIntegrationRouter,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { INTEGRATION_SERVICE } from "@posthog/core/integrations/identifiers";
2+
import type { IntegrationService } from "@posthog/core/integrations/integration";
3+
import {
4+
startGenericIntegrationFlowInput,
5+
startIntegrationFlowOutput,
6+
} from "@posthog/core/integrations/schemas";
7+
import { publicProcedure, router } from "@posthog/host-trpc/trpc";
8+
9+
/**
10+
* Generic OAuth integration flow starter, parameterized by `kind`. Replaces the need for a
11+
* per-provider router when adding a new OAuth data source — the source's connect-form schema
12+
* already carries the `kind`, so the UI passes it straight through.
13+
*/
14+
export const integrationRouter = router({
15+
startFlow: publicProcedure
16+
.input(startGenericIntegrationFlowInput)
17+
.output(startIntegrationFlowOutput)
18+
.mutation(({ ctx, input }) => {
19+
return ctx.container
20+
.get<IntegrationService>(INTEGRATION_SERVICE)
21+
.startFlow(input.kind, input.region, input.projectId);
22+
}),
23+
});

packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -399,9 +399,9 @@ function SourceField({
399399
/**
400400
* Renders an `oauth` config field: a connect button that launches the provider's
401401
* OAuth flow, polls for the resulting integration, and writes its id into the
402-
* form. Mirrors the previous bespoke Linear setup. Only providers with a wired
403-
* flow starter (currently `linear`) can be connected here; others surface a
404-
* message.
402+
* form. The flow is started generically by the field's `kind` (PostHog's
403+
* `…/integrations/authorize/?kind=…` endpoint is generic), so any OAuth source
404+
* PostHog supports works here without provider-specific code.
405405
*/
406406
function OAuthSourceField({
407407
field,
@@ -418,8 +418,8 @@ function OAuthSourceField({
418418
const projectId = useAuthStateValue((state) => state.currentProjectId);
419419
const client = useAuthenticatedClient();
420420
const trpc = useHostTRPC();
421-
const startLinearFlow = useMutation(
422-
trpc.linearIntegration.startFlow.mutationOptions(),
421+
const startIntegrationFlow = useMutation(
422+
trpc.integration.startFlow.mutationOptions(),
423423
);
424424
const [connecting, setConnecting] = useState(false);
425425
const [error, setError] = useState<string | null>(null);
@@ -441,13 +441,16 @@ function OAuthSourceField({
441441
const connected = value !== undefined && value !== "";
442442

443443
const startFlow = useCallback(async () => {
444-
if (field.kind === "linear") {
445-
if (!region || !projectId) throw new Error("Missing project context");
446-
await startLinearFlow.mutateAsync({ region, projectId });
447-
return;
444+
if (!region || !projectId) throw new Error("Missing project context");
445+
const result = await startIntegrationFlow.mutateAsync({
446+
kind: field.kind,
447+
region,
448+
projectId,
449+
});
450+
if (!result.success) {
451+
throw new Error(result.error ?? `Failed to connect ${providerName}`);
448452
}
449-
throw new Error(`Connecting ${providerName} isn't supported here yet.`);
450-
}, [field.kind, region, projectId, startLinearFlow, providerName]);
453+
}, [field.kind, region, projectId, startIntegrationFlow, providerName]);
451454

452455
const handleConnect = useCallback(async () => {
453456
if (!projectId || !client) return;

0 commit comments

Comments
 (0)