From bbc66700d17d3d6f9dfdac11288b893247706f33 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 21 Jul 2026 10:59:48 +0000 Subject: [PATCH 1/2] feat(data-warehouse): generic OAuth flow starter for inbox sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=` 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. --- packages/core/src/integrations/identifiers.ts | 4 ++ .../core/src/integrations/integration.test.ts | 41 +++++++++++++++++ packages/core/src/integrations/integration.ts | 44 +++++++++++++++++++ .../src/integrations/integrations.module.ts | 3 ++ packages/core/src/integrations/schemas.ts | 14 ++++++ packages/host-router/src/router.ts | 2 + .../src/routers/integration.router.ts | 23 ++++++++++ .../inbox/components/DynamicSourceSetup.tsx | 25 ++++++----- 8 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/integrations/integration.test.ts create mode 100644 packages/core/src/integrations/integration.ts create mode 100644 packages/host-router/src/routers/integration.router.ts diff --git a/packages/core/src/integrations/identifiers.ts b/packages/core/src/integrations/identifiers.ts index a71c99eb94..d76d4a9b48 100644 --- a/packages/core/src/integrations/identifiers.ts +++ b/packages/core/src/integrations/identifiers.ts @@ -1,3 +1,7 @@ +export const INTEGRATION_SERVICE = Symbol.for( + "posthog.core.integrationService", +); + export const GITHUB_INTEGRATION_SERVICE = Symbol.for( "posthog.core.githubIntegrationService", ); diff --git a/packages/core/src/integrations/integration.test.ts b/packages/core/src/integrations/integration.test.ts new file mode 100644 index 0000000000..27a8a03805 --- /dev/null +++ b/packages/core/src/integrations/integration.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { IntegrationService } from "./integration"; + +function createService() { + const urlLauncher = { launch: vi.fn().mockResolvedValue(undefined) }; + const service = new IntegrationService(urlLauncher as never); + return { service, urlLauncher }; +} + +describe("IntegrationService.startFlow", () => { + it("launches an authorize URL for the given kind scoped to the project", async () => { + const { service, urlLauncher } = createService(); + + const result = await service.startFlow("intercom", "us", 42); + + expect(result).toEqual({ success: true }); + const launched = urlLauncher.launch.mock.calls[0][0]; + expect(launched).toContain("/api/environments/42/integrations/authorize/"); + expect(launched).toContain("kind=intercom"); + }); + + it("url-encodes the kind", async () => { + const { service, urlLauncher } = createService(); + + await service.startFlow("rapid7_insightvm", "eu", 7); + + expect(urlLauncher.launch.mock.calls[0][0]).toContain( + "kind=rapid7_insightvm", + ); + }); + + it("returns a failure result when launching the browser throws", async () => { + const { service, urlLauncher } = createService(); + urlLauncher.launch.mockRejectedValue(new Error("no browser")); + + expect(await service.startFlow("hubspot", "us", 42)).toEqual({ + success: false, + error: "no browser", + }); + }); +}); diff --git a/packages/core/src/integrations/integration.ts b/packages/core/src/integrations/integration.ts new file mode 100644 index 0000000000..0a3cb19c60 --- /dev/null +++ b/packages/core/src/integrations/integration.ts @@ -0,0 +1,44 @@ +import { + type IUrlLauncher, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { StartIntegrationFlowOutput } from "./schemas"; + +/** + * Generic OAuth integration flow starter. PostHog's + * `…/integrations/authorize/?kind=` endpoint is generic over the integration kind, so a + * single service starts the flow for any supported OAuth provider (linear, intercom, hubspot, + * salesforce, …) — no per-kind service or router required. The OAuth grant, callback, and token + * storage all happen on PostHog Cloud; the caller then polls the integrations list for the new + * integration of this `kind`. + */ +@injectable() +export class IntegrationService { + constructor( + @inject(URL_LAUNCHER_SERVICE) + private readonly urlLauncher: IUrlLauncher, + ) {} + + public async startFlow( + kind: string, + region: CloudRegion, + projectId: number, + ): Promise { + try { + const cloudUrl = getCloudUrlFromRegion(region); + const next = `${cloudUrl}/project/${projectId}`; + const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=${encodeURIComponent(kind)}&next=${encodeURIComponent(next)}`; + + await this.urlLauncher.launch(authorizeUrl); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } +} diff --git a/packages/core/src/integrations/integrations.module.ts b/packages/core/src/integrations/integrations.module.ts index bd57929177..3c3af8ab5f 100644 --- a/packages/core/src/integrations/integrations.module.ts +++ b/packages/core/src/integrations/integrations.module.ts @@ -2,13 +2,16 @@ import { ContainerModule } from "inversify"; import { GitHubIntegrationService } from "./github"; import { GITHUB_INTEGRATION_SERVICE, + INTEGRATION_SERVICE, LINEAR_INTEGRATION_SERVICE, SLACK_INTEGRATION_SERVICE, } from "./identifiers"; +import { IntegrationService } from "./integration"; import { LinearIntegrationService } from "./linear"; import { SlackIntegrationService } from "./slack"; export const integrationsModule = new ContainerModule(({ bind }) => { + bind(INTEGRATION_SERVICE).to(IntegrationService).inSingletonScope(); bind(GITHUB_INTEGRATION_SERVICE) .to(GitHubIntegrationService) .inSingletonScope(); diff --git a/packages/core/src/integrations/schemas.ts b/packages/core/src/integrations/schemas.ts index f2d3220591..b58c263f68 100644 --- a/packages/core/src/integrations/schemas.ts +++ b/packages/core/src/integrations/schemas.ts @@ -11,6 +11,20 @@ export type StartIntegrationFlowInput = z.infer< typeof startIntegrationFlowInput >; +/** + * Generic integration flow input: any OAuth `kind` PostHog supports. The per-kind routers + * (linear/slack/github) keep the narrower input above; this one drives the generic starter so + * new OAuth sources need no dedicated router. + */ +export const startGenericIntegrationFlowInput = z.object({ + kind: z.string(), + region: cloudRegion, + projectId: z.number(), +}); +export type StartGenericIntegrationFlowInput = z.infer< + typeof startGenericIntegrationFlowInput +>; + export const startIntegrationFlowOutput = z.object({ success: z.boolean(), error: z.string().optional(), diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index a9582f4172..17d323b1eb 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -25,6 +25,7 @@ import { gitRouter } from "./routers/git.router"; import { githubIntegrationRouter } from "./routers/github-integration.router"; import { githubReleasesRouter } from "./routers/github-releases.router"; import { handoffRouter } from "./routers/handoff.router"; +import { integrationRouter } from "./routers/integration.router"; import { linearIntegrationRouter } from "./routers/linear-integration.router"; import { llmGatewayRouter } from "./routers/llm-gateway.router"; import { localMcpRouter } from "./routers/local-mcp.router"; @@ -76,6 +77,7 @@ export const hostRouter = router({ fs: fsRouter, git: gitRouter, handoff: handoffRouter, + integration: integrationRouter, githubIntegration: githubIntegrationRouter, githubReleases: githubReleasesRouter, linearIntegration: linearIntegrationRouter, diff --git a/packages/host-router/src/routers/integration.router.ts b/packages/host-router/src/routers/integration.router.ts new file mode 100644 index 0000000000..680980f01c --- /dev/null +++ b/packages/host-router/src/routers/integration.router.ts @@ -0,0 +1,23 @@ +import { INTEGRATION_SERVICE } from "@posthog/core/integrations/identifiers"; +import type { IntegrationService } from "@posthog/core/integrations/integration"; +import { + startGenericIntegrationFlowInput, + startIntegrationFlowOutput, +} from "@posthog/core/integrations/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +/** + * Generic OAuth integration flow starter, parameterized by `kind`. Replaces the need for a + * per-provider router when adding a new OAuth data source — the source's connect-form schema + * already carries the `kind`, so the UI passes it straight through. + */ +export const integrationRouter = router({ + startFlow: publicProcedure + .input(startGenericIntegrationFlowInput) + .output(startIntegrationFlowOutput) + .mutation(({ ctx, input }) => { + return ctx.container + .get(INTEGRATION_SERVICE) + .startFlow(input.kind, input.region, input.projectId); + }), +}); diff --git a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx index afe1e5c36d..c5d57650c4 100644 --- a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx @@ -399,9 +399,9 @@ function SourceField({ /** * Renders an `oauth` config field: a connect button that launches the provider's * OAuth flow, polls for the resulting integration, and writes its id into the - * form. Mirrors the previous bespoke Linear setup. Only providers with a wired - * flow starter (currently `linear`) can be connected here; others surface a - * message. + * form. The flow is started generically by the field's `kind` (PostHog's + * `…/integrations/authorize/?kind=…` endpoint is generic), so any OAuth source + * PostHog supports works here without provider-specific code. */ function OAuthSourceField({ field, @@ -418,8 +418,8 @@ function OAuthSourceField({ const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); const trpc = useHostTRPC(); - const startLinearFlow = useMutation( - trpc.linearIntegration.startFlow.mutationOptions(), + const startIntegrationFlow = useMutation( + trpc.integration.startFlow.mutationOptions(), ); const [connecting, setConnecting] = useState(false); const [error, setError] = useState(null); @@ -441,13 +441,16 @@ function OAuthSourceField({ const connected = value !== undefined && value !== ""; const startFlow = useCallback(async () => { - if (field.kind === "linear") { - if (!region || !projectId) throw new Error("Missing project context"); - await startLinearFlow.mutateAsync({ region, projectId }); - return; + if (!region || !projectId) throw new Error("Missing project context"); + const result = await startIntegrationFlow.mutateAsync({ + kind: field.kind, + region, + projectId, + }); + if (!result.success) { + throw new Error(result.error ?? `Failed to connect ${providerName}`); } - throw new Error(`Connecting ${providerName} isn't supported here yet.`); - }, [field.kind, region, projectId, startLinearFlow, providerName]); + }, [field.kind, region, projectId, startIntegrationFlow, providerName]); const handleConnect = useCallback(async () => { if (!projectId || !client) return; From 43ca74c1f8de78f8e519d93b3a8446928c00f4f1 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 21 Jul 2026 14:14:44 +0100 Subject: [PATCH 2/2] fix(desktop): register integration router in main trpc assembly The HostRouter type declares an `integration` route, but the desktop app's trpcRouter assembly did not serve it, tripping the servesEveryHostRoute compile-time guard and failing typecheck. Generated-By: PostHog Code Task-Id: 9fd73e57-6627-4884-bb0e-953cc4fa6b33 --- apps/code/src/main/trpc/router.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index f2209456c6..a8bcb3623d 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -26,6 +26,7 @@ import { gitRouter } from "@posthog/host-router/routers/git.router"; import { githubIntegrationRouter } from "@posthog/host-router/routers/github-integration.router"; import { githubReleasesRouter } from "@posthog/host-router/routers/github-releases.router"; import { handoffRouter } from "@posthog/host-router/routers/handoff.router"; +import { integrationRouter } from "@posthog/host-router/routers/integration.router"; import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router"; import { llmGatewayRouter } from "@posthog/host-router/routers/llm-gateway.router"; import { localMcpRouter } from "@posthog/host-router/routers/local-mcp.router"; @@ -87,6 +88,7 @@ export const trpcRouter = router({ githubIntegration: githubIntegrationRouter, githubReleases: githubReleasesRouter, handoff: handoffRouter, + integration: integrationRouter, linearIntegration: linearIntegrationRouter, llmGateway: llmGatewayRouter, localMcp: localMcpRouter,