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(/\/+$/, "") +}