From 27016e962b0babe8cce14cbdefc5b7c040f25551 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 1 Sep 2026 13:02:40 +0200 Subject: [PATCH] fix(vcs): derive the GitHub web host from the configured API base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GITHUB_API_BASE_URL` points an installation at GitHub Enterprise, but the two URLs that are not on the API host were hardcoded to `https://github.com`: the App installation redirect, and the OAuth code exchange — which posts the App's client id and secret. An Enterprise deployment therefore sent those credentials to public GitHub and opened an install page for an app that does not exist there. Both now derive the web host from the same setting, stripping the Enterprise Server `/api/v3` suffix and mapping `api.github.com` to `github.com`. An unparseable value falls back to public GitHub, which is the existing default. --- .../vcs/vendor/github/GithubAppClient.ts | 6 ++-- .../vcs/vendor/github/GithubConnectService.ts | 4 +-- .../github/__tests__/github-hosts.test.ts | 35 +++++++++++++++++++ .../vcs/vendor/github/github-hosts.ts | 34 ++++++++++++++++++ 4 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/services/integrations/vcs/vendor/github/__tests__/github-hosts.test.ts create mode 100644 apps/api/src/services/integrations/vcs/vendor/github/github-hosts.ts diff --git a/apps/api/src/services/integrations/vcs/vendor/github/GithubAppClient.ts b/apps/api/src/services/integrations/vcs/vendor/github/GithubAppClient.ts index 36040c5a5..4455ff549 100644 --- a/apps/api/src/services/integrations/vcs/vendor/github/GithubAppClient.ts +++ b/apps/api/src/services/integrations/vcs/vendor/github/GithubAppClient.ts @@ -2,6 +2,7 @@ import { GitCommitSha } from "@maple/domain/http" import { Clock, Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect" import { Env } from "@/platform/Env" import { GithubHttp } from "./GithubHttp" +import { githubWebBaseUrl } from "./github-hosts" // GitHub App REST client. Vendor-specific: mints a short-lived App JWT (RS256, // Web Crypto), exchanges it for per-installation tokens, and calls the GitHub @@ -24,9 +25,6 @@ export class GithubAppError extends Schema.TaggedError()("@maple const GITHUB_API_VERSION = "2022-11-28" const USER_AGENT = "maple-vcs-integration" -// The user-facing OAuth host (NOT the REST API host): the App's web OAuth leg -// exchanges the install-callback `code` for a user access token here. -const GITHUB_OAUTH_BASE_URL = "https://github.com" const PER_PAGE = 100 // Paginate effectively to the end (up to 100k items) while still bounding a // pathological loop. Hitting this cap is logged — truncation is never silent. @@ -803,7 +801,7 @@ export class GithubAppClient extends Context.Service()( }) const response = yield* rateLimitedFetch( tracedFetch( - `${GITHUB_OAUTH_BASE_URL}/login/oauth/access_token`, + `${githubWebBaseUrl(env.GITHUB_API_BASE_URL)}/login/oauth/access_token`, { method: "POST", headers: { diff --git a/apps/api/src/services/integrations/vcs/vendor/github/GithubConnectService.ts b/apps/api/src/services/integrations/vcs/vendor/github/GithubConnectService.ts index 2893b37b4..ba2562a1b 100644 --- a/apps/api/src/services/integrations/vcs/vendor/github/GithubConnectService.ts +++ b/apps/api/src/services/integrations/vcs/vendor/github/GithubConnectService.ts @@ -22,6 +22,7 @@ import { VcsRepository } from "@/services/integrations/vcs/VcsRepository" import { BACKFILL_WINDOW_MS } from "@/services/integrations/vcs/VcsSyncService" import { VcsSyncQueue } from "@/services/integrations/vcs/VcsSyncQueue" import { GithubAppClient, type GithubAppError } from "./GithubAppClient" +import { githubWebBaseUrl } from "./github-hosts" // The dashboard connect flow for the GitHub App. Bridges a real GitHub // installation into a `vcs_installations` row owned by a Maple org, then hands @@ -39,7 +40,6 @@ import { GithubAppClient, type GithubAppError } from "./GithubAppClient" // installation that already belongs to a different org. const GITHUB_PROVIDER = "github" as const -const GITHUB_WEB_BASE = "https://github.com" const STATE_TTL_MS = 10 * 60_000 // 10 minutes interface GithubBranchStatus { @@ -194,7 +194,7 @@ export class GithubConnectService extends Context.Service { + it("maps the public API host to the public web host", () => { + expect(githubWebBaseUrl("https://api.github.com")).toBe("https://github.com") + expect(githubWebBaseUrl("https://api.github.com/")).toBe("https://github.com") + }) + + it("strips the Enterprise Server API suffix", () => { + expect(githubWebBaseUrl("https://github.acme.internal/api/v3")).toBe( + "https://github.acme.internal", + ) + expect(githubWebBaseUrl("https://github.acme.internal/api/v3/")).toBe( + "https://github.acme.internal", + ) + }) + + it("keeps a path prefix that is not the API suffix", () => { + expect(githubWebBaseUrl("https://intranet.acme.internal/github/api/v3")).toBe( + "https://intranet.acme.internal/github", + ) + }) + + it("falls back to public GitHub when the configured value is unusable", () => { + expect(githubWebBaseUrl("not a url")).toBe("https://github.com") + expect(githubWebBaseUrl("")).toBe("https://github.com") + }) +}) diff --git a/apps/api/src/services/integrations/vcs/vendor/github/github-hosts.ts b/apps/api/src/services/integrations/vcs/vendor/github/github-hosts.ts new file mode 100644 index 000000000..d63d87a89 --- /dev/null +++ b/apps/api/src/services/integrations/vcs/vendor/github/github-hosts.ts @@ -0,0 +1,34 @@ +import { Option, Schema } from "effect" + +/** + * A string to a `URL`, or `Option.none` when it is not one — the same shape and + * the same reasoning as `parsePullRequestUrl`: the schema reports the failure as + * a value rather than a thrown exception, and `decodeUnknownOption` keeps this + * synchronous and total, so it stays a plain function every caller can use. + */ +const decodeUrl = Schema.decodeUnknownOption(Schema.URLFromString) + +const PUBLIC_GITHUB_WEB = "https://github.com" + +/** + * `GITHUB_API_BASE_URL` is the only host knob, but two GitHub URLs are not on + * the API host: the OAuth code exchange and the App installation page both live + * on the web host. Hardcoding `https://github.com` for those sent a GitHub + * Enterprise deployment's OAuth client secret to public GitHub and pointed its + * install flow at an app that does not exist there. + * + * Enterprise Server publishes its API under `https:///api/v3`; public + * GitHub splits the two across `api.github.com` and `github.com`. + */ +export const githubWebBaseUrl = (apiBaseUrl: string): string => { + const decoded = decodeUrl(apiBaseUrl.trim()) + if (Option.isNone(decoded)) return PUBLIC_GITHUB_WEB + const url = decoded.value + + if (url.hostname === "api.github.com") return PUBLIC_GITHUB_WEB + + // Everything else is Enterprise Server, whose web root is the API URL minus + // its `/api/v3` (GraphQL: `/api/graphql`) suffix. + const path = url.pathname.replace(/\/(api\/v3|api\/graphql|api)\/?$/, "") + return `${url.origin}${path}`.replace(/\/+$/, "") +}