-
Notifications
You must be signed in to change notification settings - Fork 27
refactor(host): route #746's host resolution through HostResolution #749
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rafaeelaudibert
merged 1 commit into
feat/base-url-override
from
posthog-code/host-resolution-class
Jun 30, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { HostResolution } from '@lib/host-resolution'; | ||
|
|
||
| /** | ||
| * Contract test for HostResolution — the durable record of what the host family | ||
| * guarantees. `fromApiHost` is environment-independent (it never consults | ||
| * IS_DEV), so it locks down the prod region→host mapping even though the test | ||
| * runner sets NODE_ENV=test (which makes `fromRegion` resolve to localhost). | ||
| */ | ||
| describe('HostResolution.fromApiHost', () => { | ||
| it('derives the full US host family from the US ingestion host', () => { | ||
| const h = HostResolution.fromApiHost('https://us.i.posthog.com'); | ||
| expect(h.region).toBe('us'); | ||
| expect(h.apiHost).toBe('https://us.i.posthog.com'); | ||
| expect(h.appHost).toBe('https://us.posthog.com'); | ||
| expect(h.assetHost).toBe('https://us-assets.i.posthog.com'); | ||
| expect(h.gatewayUrl).toBe('https://gateway.us.posthog.com/wizard'); | ||
| }); | ||
|
|
||
| it('derives the full EU host family from the EU ingestion host', () => { | ||
| const h = HostResolution.fromApiHost('https://eu.i.posthog.com'); | ||
| expect(h.region).toBe('eu'); | ||
| expect(h.apiHost).toBe('https://eu.i.posthog.com'); | ||
| expect(h.appHost).toBe('https://eu.posthog.com'); | ||
| expect(h.assetHost).toBe('https://eu-assets.i.posthog.com'); | ||
| expect(h.gatewayUrl).toBe('https://gateway.eu.posthog.com/wizard'); | ||
| }); | ||
|
|
||
| it('preserves the given apiHost verbatim (provisioning may return a non-canonical host)', () => { | ||
| // Trailing slash and all — the task-stream destination relies on this so its | ||
| // own normalization is exercised. | ||
| const h = HostResolution.fromApiHost('https://us.posthog.com/'); | ||
| expect(h.apiHost).toBe('https://us.posthog.com/'); | ||
| }); | ||
|
|
||
| it('points everything at the local stack for a localhost host', () => { | ||
| const h = HostResolution.fromApiHost('http://localhost:8010'); | ||
| expect(h.region).toBe('us'); | ||
| expect(h.apiHost).toBe('http://localhost:8010'); | ||
| expect(h.appHost).toBe('http://localhost:8010'); | ||
| expect(h.gatewayUrl).toBe('http://localhost:3308/wizard'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('HostResolution mcpUrl', () => { | ||
| it('defaults to the region-independent prod MCP url', () => { | ||
| expect(HostResolution.fromApiHost('https://us.i.posthog.com').mcpUrl).toBe( | ||
| 'https://mcp.posthog.com/mcp', | ||
| ); | ||
| expect(HostResolution.fromApiHost('https://eu.i.posthog.com').mcpUrl).toBe( | ||
| 'https://mcp.posthog.com/mcp', | ||
| ); | ||
| }); | ||
|
|
||
| it('points at the local MCP server when localMcp is set', () => { | ||
| const h = HostResolution.fromApiHost('https://us.i.posthog.com', { | ||
| localMcp: true, | ||
| }); | ||
| expect(h.mcpUrl).toBe('http://localhost:8787/mcp'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('HostResolution immutability', () => { | ||
| it('is frozen and rejects mutation', () => { | ||
| const h = HostResolution.fromApiHost('https://us.i.posthog.com'); | ||
| expect(Object.isFrozen(h)).toBe(true); | ||
| expect(() => { | ||
| (h as unknown as { apiHost: string }).apiHost = 'https://evil.example'; | ||
| }).toThrow(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('HostResolution.fromAccessToken', () => { | ||
| it('short-circuits to us in dev/test without a network probe', async () => { | ||
| const h = await HostResolution.fromAccessToken('any-token'); | ||
| expect(h.region).toBe('us'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| /** | ||
| * HostResolution — the single, immutable snapshot of where the wizard talks to | ||
| * PostHog for one run. | ||
| * | ||
| * One cloud region (or a pinned `--base-url`) implies a whole family of hosts: | ||
| * the event-ingestion/API host, the user-facing web app host, the CDN asset | ||
| * host, the LLM gateway, and the MCP server. Rather than re-deriving each of | ||
| * these at every call site and threading `region` + `baseUrl` around as loose | ||
| * params, resolve once at auth time and pass this frozen object around — read | ||
| * the property you need. | ||
| * | ||
| * The actual region→URL math (and the `--base-url` override) lives in | ||
| * `@utils/urls`; this class is a thin, immutable façade over those resolvers so | ||
| * the override flows through every field without callers having to know about | ||
| * it. The OAuth-server URL stays in `@utils/urls` (`getOAuthUrl`) because it is | ||
| * needed *before* a region is known, so it can't come from this post-auth object. | ||
| */ | ||
|
|
||
| import { | ||
| getHost, | ||
| getCloudUrl, | ||
| getLlmGatewayUrl, | ||
| getUiHostFromHost, | ||
| detectRegion, | ||
| resolveBaseUrl, | ||
| } from '@utils/urls'; | ||
| import { runtimeEnv } from '@env'; | ||
| import type { CloudRegion } from '@utils/types'; | ||
|
|
||
| const LOCAL_MCP_URL = 'http://localhost:8787/mcp'; | ||
| const PROD_MCP_URL = 'https://mcp.posthog.com/mcp'; | ||
|
|
||
| /** Construction-time inputs that aren't implied by the region. */ | ||
| export interface HostResolutionOptions { | ||
| /** `--local-mcp`: point the agent's MCP url at the local dev server. */ | ||
| localMcp?: boolean; | ||
| /** `--base-url`: pin every PostHog origin to one URL, bypassing region resolution. */ | ||
| baseUrl?: string; | ||
| } | ||
|
|
||
| function assetHostFor(region: CloudRegion, baseUrl?: string): string { | ||
| const override = resolveBaseUrl(baseUrl); | ||
| if (override) return override; | ||
| return region === 'eu' | ||
| ? 'https://eu-assets.i.posthog.com' | ||
| : 'https://us-assets.i.posthog.com'; | ||
| } | ||
|
|
||
| function assetHostFromApiHost(apiHost: string): string { | ||
| if (apiHost.includes('us.i.posthog.com')) { | ||
| return 'https://us-assets.i.posthog.com'; | ||
| } | ||
| if (apiHost.includes('eu.i.posthog.com')) { | ||
| return 'https://eu-assets.i.posthog.com'; | ||
| } | ||
| return apiHost; | ||
| } | ||
|
|
||
| function mcpUrlFor(localMcp: boolean): string { | ||
| if (localMcp) return LOCAL_MCP_URL; | ||
| return runtimeEnv('MCP_URL') || PROD_MCP_URL; | ||
| } | ||
|
|
||
| export class HostResolution { | ||
| /** The resolved cloud region. `'us'` when a base URL is pinned. */ | ||
| readonly region: CloudRegion; | ||
| /** | ||
| * Event-ingestion / REST API host (e.g. `https://us.i.posthog.com`, or the | ||
| * pinned `--base-url`). The SDK `host` written into the user's `.env`, the | ||
| * base for the wizard-session REST calls, and the host shown to the agent. | ||
| */ | ||
| readonly apiHost: string; | ||
| /** | ||
| * User-facing web app host (e.g. `https://us.posthog.com`). Use for any link | ||
| * we hand to the user or open in their browser (dashboards, settings, inbox, | ||
| * deep-link base). | ||
| */ | ||
| readonly appHost: string; | ||
| /** CDN asset host (e.g. `https://us-assets.i.posthog.com`). */ | ||
| readonly assetHost: string; | ||
| /** PostHog LLM gateway URL the agent SDK authenticates its model calls against. */ | ||
| readonly gatewayUrl: string; | ||
| /** | ||
| * PostHog MCP server URL the agent connects to. Region-independent — the | ||
| * server resolves the user's region from the bearer token — so this is driven | ||
| * only by `--local-mcp` and the `MCP_URL` override, not by region/base-url. | ||
| */ | ||
| readonly mcpUrl: string; | ||
|
|
||
| private constructor(fields: { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice |
||
| region: CloudRegion; | ||
| apiHost: string; | ||
| appHost: string; | ||
| assetHost: string; | ||
| gatewayUrl: string; | ||
| mcpUrl: string; | ||
| }) { | ||
| this.region = fields.region; | ||
| this.apiHost = fields.apiHost; | ||
| this.appHost = fields.appHost; | ||
| this.assetHost = fields.assetHost; | ||
| this.gatewayUrl = fields.gatewayUrl; | ||
| this.mcpUrl = fields.mcpUrl; | ||
| Object.freeze(this); | ||
| } | ||
|
|
||
| /** | ||
| * Canonical path: build the host family from a resolved region. A pinned | ||
| * `--base-url` (via `opts.baseUrl`) wins for every origin; otherwise the | ||
| * region's standard hosts are used. Honors IS_DEV through `resolveBaseUrl`. | ||
| */ | ||
| static fromRegion( | ||
| region: CloudRegion, | ||
| opts: HostResolutionOptions = {}, | ||
| ): HostResolution { | ||
| const apiHost = getHost(region, opts.baseUrl); | ||
| return new HostResolution({ | ||
| region, | ||
| apiHost, | ||
| appHost: getCloudUrl(region, opts.baseUrl), | ||
| assetHost: assetHostFor(region, opts.baseUrl), | ||
| gatewayUrl: getLlmGatewayUrl(apiHost), | ||
| mcpUrl: mcpUrlFor(opts.localMcp ?? false), | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Build from an ingestion host string (the provisioning API returns one). The | ||
| * given host is preserved verbatim as `apiHost`; the region and the derived | ||
| * app/asset/gateway hosts are inferred from it. | ||
| */ | ||
| static fromApiHost( | ||
| apiHost: string, | ||
| opts: Pick<HostResolutionOptions, 'localMcp'> = {}, | ||
| ): HostResolution { | ||
| const region: CloudRegion = apiHost.includes('eu.') ? 'eu' : 'us'; | ||
| return new HostResolution({ | ||
| region, | ||
| apiHost, | ||
| appHost: getUiHostFromHost(apiHost), | ||
| assetHost: assetHostFromApiHost(apiHost), | ||
| gatewayUrl: getLlmGatewayUrl(apiHost), | ||
| mcpUrl: mcpUrlFor(opts.localMcp ?? false), | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the region from an access token (the us/eu probe, skipped when a | ||
| * base URL is pinned), then build the host family. Used after OAuth and after | ||
| * CI-mode API-key auth. | ||
| */ | ||
| static async fromAccessToken( | ||
| accessToken: string, | ||
| opts: HostResolutionOptions = {}, | ||
| ): Promise<HostResolution> { | ||
| const region = await detectRegion(accessToken, opts.baseUrl); | ||
| return HostResolution.fromRegion(region, opts); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if this should simply be
nullwhen a base URL is pinned? We don't even use this for a lot. Or maybeunknown.