Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,9 +25,6 @@ export class GithubAppError extends Schema.TaggedError<GithubAppError>()("@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.
Expand Down Expand Up @@ -803,7 +801,7 @@ export class GithubAppClient extends Context.Service<GithubAppClient>()(
})
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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -194,7 +194,7 @@ export class GithubConnectService extends Context.Service<GithubConnectService,
})
const params = new URLSearchParams({ state })
return {
redirectUrl: `${GITHUB_WEB_BASE}/apps/${slug}/installations/new?${params.toString()}`,
redirectUrl: `${githubWebBaseUrl(env.GITHUB_API_BASE_URL)}/apps/${slug}/installations/new?${params.toString()}`,
state,
}
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest"

import { githubWebBaseUrl } from "../github-hosts"

/**
* The OAuth code exchange posts the App's client secret to this host, so a wrong
* answer for an Enterprise deployment is a credential sent to a third party —
* which is what a hardcoded `https://github.com` did.
*/
describe("githubWebBaseUrl", () => {
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")
})
})
Original file line number Diff line number Diff line change
@@ -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://<host>/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(/\/+$/, "")
}
Loading